A private smart-garden monitoring platform: Xiaomi MiFlora BLE soil sensors + MQTT + TimescaleDB + Grafana + a Telegram bot + a custom React/Fastify photo dashboard ("Canopy"), deployed via Docker Compose to a single Raspberry Pi. This document condenses the project's design/build/ops history into one reference sufficient to understand the system and rebuild it from scratch.
MiFlora BLE sensors (passive advertisements)
│
▼
Theengs Gateway (same Pi as everything else, --net=host --privileged)
│ MQTT: canopy/ble/<MAC>
▼
ingest service
│
▼
TimescaleDB (Postgres + hypertables)
│
┌────┼────────────────────┐
▼ ▼ ▼
decision-engine Grafana Canopy UI (React/Fastify,
(digests) (dashboards + photo dashboard, port 8090)
│ sensor-health
▼ alerting)
Telegram bot
(digests, photo-triggered
plant identification)
All services run as Docker containers on a single Raspberry Pi via docker compose,
built natively for arm64 on the Pi itself — no cross-arch emulation, no registry.
GARDENA smart-device cloud integration (soil/valve sensors via the Husqvarna OAuth2 API) was part of the original design and is described in §2 below for historical/rebuild reference, but was removed entirely before this repo's Canopy rename — it never shipped in production and added OAuth2/websocket complexity for no retained benefit. The system is MiFlora-only.
This section is kept verbatim as reference in case GARDENA integration is revisited; none of it reflects current behavior.
- OAuth2 client_credentials grant:
POST https://api.authentication.husqvarnagroup.dev/v1/oauth2/token, form-encodedgrant_type=client_credentials&client_id=...&client_secret=.... - Response includes
access_token,expires_in(3600s),scope,token_type. There is no refresh token for this grant type. The correct strategy is simply to re-authenticate whenever a call returns 401 (invalid token) or 404 (expired token) — do not try to pre-emptively refresh on a timer alone. - Every API call requires two headers:
Authorization: Bearer <token>andX-Api-Key: <app_key>. TheAuthorization-Provider: husqvarnaheader some older sample code includes is no longer required (removed from the API 2022-03-14) — do not add it. - Limits: max 3 simultaneous access tokens per user+app; 10 invalid auth attempts in 5 min triggers a 30-minute lockout.
- Rate limits: 10 requests/10s, 700 requests/week (429 on exceed). Recommended average is
~1 call/15min.
GET /healthis unmetered — use it for uptime/liveness checks, not the metered endpoints. - SENSOR service schema (soil probes):
soilHumidity(%),soilTemperature(°C),ambientTemperature(°C),lightIntensity(lux). Exactly these 4 fields, nothing else. - COMMON service schema (all devices):
name,batteryLevel(%),batteryState(OK/LOW/REPLACE_NOW/OUT_OF_OPERATION/CHARGING/NO_BATTERY/UNKNOWN),rfLinkLevel(%, absent on Sileno Sense mowers),serial,modelType,rfLinkState(ONLINE/OFFLINE/UNKNOWN). - VALVE control:
PUT /v1/command/{service_id}(note:service_id≠device_id— find it via the device'srelationships.services.data).VALVE_CONTROLcommands:START_SECONDS_TO_OVERRIDE(seconds must be a positive multiple of 60, range 1–36000),STOP_UNTIL_NEXT_TASK,PAUSE,UNPAUSE.VALVE_SET_CONTROLonly supportsSTOP_UNTIL_NEXT_TASK(closes all valves on the set).- At most 2 valves may be open simultaneously — a 3rd start attempt returns
CONCURRENT_LIMIT_REACHED. - A 202 response only confirms the Gateway received the command; you must poll device state afterward to confirm it actually executed.
- VALVE monitoring fields:
activity(CLOSED/MANUAL_WATERING/SCHEDULED_WATERING),state(OK/WARNING/ERROR/UNAVAILABLE),lastErrorCode(large enum incl.VALVE_BROKEN,FROST_PREVENTS_STARTING,LOW_BATTERY_PREVENTS_STARTING),duration.
POST /v1/websocketwith a JSON:API body containinglocationIdreturns a pre-signed WSS URL. That URL is only valid to connect for ~30 seconds after issuance.- Connection max lifetime is ~2 hours (server closes with code 1001); it also auto-closes after ~10 minutes of idle (no events).
- Recommended keep-alive: ping every ~2.5 minutes (
ping_interval=150sin the reference client; GARDENA's own OpenAPI docs suggest 5 min). - Reconnect protocol: re-fetch location → reset all internal/local state → request a fresh WS URL → open → process incoming events as JSON:API PATCH documents → on any terminal close, go back to the "reset state" step (don't just reopen with stale local state).
- Event payload document types:
SENSOR,VALVE,VALVE_SET,POWER_SOCKET,MOWER,COMMON.
Token-endpoint per-second rate limit; concurrent-WebSocket-connections-per-app limit; exact
behavior of a seconds parameter on PAUSE; WebSocket message rate limit.
Xiaomi/HHCC HHCCJCY01 "Flower Care" soil sensors. Two access modes:
- Active GATT connect (handle-based reads) — required only to read battery level
(handle
0x38, byte 0 = battery %). The canonicalmifloraPython library reads this at most once per 24h to conserve the sensor's coin cell. Full sensor data lives at handle0x35(16 bytes, little-endian: bytes 0–1 = temp ×10, bytes 3–6 = lux uint32, byte 7 = soil moisture %, bytes 8–9 = conductivity µS/cm), but this is not how the primary pipeline reads it — see below. - Passive BLE advertisements (firmware 3.x) — the sensor broadcasts using Xiaomi service
UUID
0xFE95; each single advertisement carries only one metric (temp/moisture/ lux/fertility, selected by a condition byte), rotating through all four over time. Listening passively costs the sensor zero additional battery beyond what it broadcasts anyway, unlike active polling.
This active-vs-passive split is why the primary sensor pipeline uses passive scanning (via Theengs Gateway) for temp/moisture/lux/fertility, and battery is collected separately on its own, much slower cadence via a dedicated active-connect step (see §5, battery cadence — the shipped value is 48 hours).
Theengs Gateway was chosen over the alternatives evaluated during design:
bt-mqtt-gateway— rejected: effectively unmaintained, active-connect-only via the unmaintainedbluepylibrary, unreliable on modern Python/Raspberry Pi OS Bookworm, no auto-discovery.- A custom
python-bleakscript — rejected as the primary path (full protocol control but full maintenance burden); kept in mind only as a DIY fallback. - Theengs Gateway — actively maintained, native passive-scan support, first-class HHCCJCY01 decoder, simple pip/Docker/systemd install, built-in Home Assistant MQTT discovery (not used here, but harmless).
Runs as an ordinary docker compose service on the same single Raspberry Pi as every
other container (an earlier design used a separate dedicated Pi for BLE range; re-evaluate
placement if BLE range becomes a real issue on your deployment). Real-world indoor BLE range
is ~5–10m through walls (not the theoretical 30m open-air figure); a fleet spread across
multiple rooms may need a second collector if a sensor's RSSI consistently sits below −90 dBm.
Required container flags — --net=host --privileged AND a bind-mounted D-Bus socket
(-v /var/run/dbus:/var/run/dbus). This last mount is easy to miss and causes a completely
silent failure: the gateway connects to MQTT and "starts scanning" successfully in its logs,
but never decodes a single advertisement, because Bleak (the BLE library Theengs uses)
talks to bluetoothd over D-Bus and the container has no D-Bus socket to talk over without
this mount. Symptom: docker ps looks healthy, logs show "Starting BLE scan" / "Connected to
MQTT broker", but zero MQTT payloads ever appear even with a sensor at 20cm. Diagnose by
running BleakScanner.discover() directly inside the container — it throws
FileNotFoundError on /var/run/dbus/system_bus_socket if the mount is missing.
Host networking vs. Mosquitto: network_mode: host means Theengs cannot use compose
bridge-network service-name DNS to reach Mosquitto. Mosquitto stays on the default bridge
network with 1883:1883 published to the host, and Theengs is pointed at
MQTT_HOST=127.0.0.1 (a .env var, overridable to the Pi's LAN IP as an escape hatch if
the loopback-hairpin path doesn't work on some future kernel/nftables config) — Docker's
DNAT for published ports applies to traffic from the host's own network namespace, which is
what a network_mode: host container shares.
Shipped Pi collector env vars (see compose/docker-compose.yml, verified against
gateway.theengs.io/install/docker/ for the pinned theengs/gateway:v1.7.5.1 image):
MQTT_HOST, MQTT_PUBLISH_TOPIC (not PUBLISH_TOPIC — an earlier design draft used the
wrong name), SCANNING_MODE=passive, BLE_SCAN_TIME (not SCAN_DURATION) and
BLE_TIME_BETWEEN_SCANS (not TIME_BETWEEN) — the image documents no MQTT_PORT env var,
so the stack relies on Mosquitto's default 1883. Defaults: BLE_SCAN_TIME=10,
BLE_TIME_BETWEEN_SCANS=50 (a 10s-scan/50s-pause duty cycle).
MQTT topic: canopy/ble/<MAC> (Theengs appends the MAC to MQTT_PUBLISH_TOPIC). Payload
JSON fields: name, id, rssi, brand, model, model_id, tempc, moi, lux, fer.
- Image:
timescale/timescaledb:2.28.2-pg17(slim variant; sufficient for this workload — no PostGIS/WAL-G needed). Always pin the exact tag, neverlatest-pg17, for reproducibility. - PG15-based TimescaleDB tags are deprecated (support ends with the 2.28.x series) — do not use them for new deployments.
- Current (2.18+) compression API naming is "columnstore," not "compress" — the old
names (
compress_chunk(),add_compression_policy(),timescaledb.compress, etc.) still work as aliases in 2.28.x but are scheduled for eventual removal in 3.0. New SQL should useconvert_to_columnstore(),add_columnstore_policy(),timescaledb.enable_columnstore = true. The underlying "Hypercore" table-access-method experiment (2.18) was fully removed in 2.22 — don't reference it as current. - Continuous aggregates: standard
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)add_continuous_aggregate_policy(...); a 1-hour rollup with hourly refresh is the pattern used for the Canopy dashboard's 48h sparklines.
- Backup:
tools/backup_db.shvia a daily host crontab entry (pg_dump -Fcinside thecp-dbcontainer, output landing on the host through the/backupsbind mount), not WAL-G (overkill for this data volume) and not bare filesystem snapshots as the sole backup — a snapshot taken mid-write of a live Postgres data directory can capture an inconsistent mix of WAL and data-file state; a filesystem/volume snapshot is a secondary safety net only, never the primary backup path. Keep the last 14 daily dumps (the script prunes automatically); it also verifies each dump file is non-empty before pruning.CREATE EXTENSION IF NOT EXISTS timescaledb;must exist beforepg_restoreruns; always do a full dump/restore (never schema-only + data-only separately — hypertable metadata ordering matters). - Deployment paths: always use explicit
$DATA_DIR/...bind mounts for Postgres data (see.env.example), never Docker named volumes (named volumes are harder to inspect/back up directly from the host filesystem). restart: unless-stopped(notalways) on every service, so a deliberatedocker stopduring maintenance is respected across a host reboot.
The alerting design went through several iterations; only the final, shipped behavior matters operationally:
- Battery-check cadence: 48 hours, with the low-battery rule (
cp_low_battery) widened to a 14-day / 2-sample window to match. This was reached via a sequence of tightenings (daily → 2h → 6h → 48h) as the operational cost of frequent active-GATT battery reads became clear; "monthly" was floated independently in three early design/research documents as a starting proposal but was never shipped — do not treat it as current behavior. - Light out-of-range alerting: sustained-only, 3-day window. Light was originally
excluded entirely from "needs attention" (too noisy — day/night cycles constantly cross
any fixed threshold), then, later, changed to only fire when every reading across a
full rolling 3-day window is out of range — mirroring the existing
light_lux_sustained_oorpattern already used for other sustained conditions. A single in-range daytime reading resets the streak. Treat "light excluded from needs-attention" as a superseded interim rule, not current behavior. out_of_rangewas split into 7 distinct alert kinds (per-metric/per-direction, e.g. moisture-low, moisture-high, temperature-low, etc.) rather than one generic "out_of_range" bucket, to make Grafana panels and digest messages specific.- Alert delivery: two different mechanisms for two different alert families —
- Plant-state conditions (moisture/temperature/light/fertility out-of-range, low battery, etc.) are delivered as scheduled Telegram digests (a batched summary message sent on a schedule), not realtime per-condition pings. This replaced an earlier realtime, fire-immediately-per-condition design, which produced alert-burst spam whenever the decision-engine restarted (see below).
- Sensor/infra-health conditions (device offline, stale data, etc.) are delivered via Grafana Alerting, separately from the Telegram digest path.
- Cooldown/dedup state (
within_cooldown()) is derived from the persistedalert_eventstable at startup, not kept only in an in-memory dict — an in-memory-only cooldown was the root cause of a restart-triggered alert-spam bug (every restart forgot all cooldowns and re-fired everything at once). - Alert severity ranking for UI sorting ("water always wins"): moisture (3) > temperature (2) > light/fertility (1).
Chosen over Plant.id/Kindwise (no real ongoing free tier — 100 one-time test credits, then
paid) and a self-hosted ViT model (kept only as a hypothetical offline fallback, never
built). Pl@ntNet gives 500 free identifications/day (resets 00:00 UTC), accepts 1–5 images
per request tagged with an organ (flower/leaf/fruit/bark/habit/auto), and returns a
scientificNameWithoutAuthor binomial directly usable as an OpenPlantbook lookup key.
Non-plant photos return a clean HTTP 404 (no hallucinated species). A 30-species live
accuracy test measured 96.7% top-1 genus-level accuracy, ~0.5s average latency, and
confirmed the 404-on-non-plant behavior — production-ready for this project's volume.
Requires a "Powered by Pl@ntNet" attribution line in the bot UI.
OAuth2 client_credentials against open.plantbook.io. Correct env var names are
OPENPLANTBOOK_CLIENT_ID / OPENPLANTBOOK_CLIENT_SECRET — an earlier draft used a single
OPENPLANTBOOK_TOKEN name, which does not match the actual two-credential client_credentials
flow; don't reintroduce it. All external-service secrets (OpenPlantbook, PlantNet, Telegram
bot token) are supplied via env_file, never inlined under a compose service's
environment: block, to keep them out of compose/docker-compose.yml (which is tracked in
git).
- Search:
GET /api/v1/plant/search?alias=NAME&limit=N(case-insensitive, matches display name, alias, and multilingual common names). - Detail:
GET /api/v1/plant/detail/{pid}/(?include=carefor text guidance) — returns min/max soil moisture %, soil EC (µS/cm), light (lux), and temperature (°C), all in units directly compatible with the MiFlora sensor readings. A0value inmin/max_soil_ecmeans "unset," not a real threshold — filter those out before applying. Take the top hit'spidfrom asearchcall — never guess apidby lowercasing the scientific name (subspecies/cultivars/hybrid binomials don't always map cleanly, and OpenPlantbook 301-redirects/plant/detail/<pid>to a trailing-slash URL that some HTTP clients won't auto-follow without configuration). - Unresolved plants fall back to conservative indoor-default thresholds (moisture 15–60%, EC
100–2000 µS/cm, light 500–20000 lux, temp 10–35°C) and are flagged with
threshold_source = 'default'so the UI can indicate the value is a guess.
There is no /identify or /id command anymore. Both were removed entirely; any photo
an admin sends to the bot automatically triggers PlantNet identification via a single
MessageHandler(filters.PHOTO, handle_photo). Do not describe the old explicit-command
design as current — it was the original design but was superseded.
Other durable fixes/gotchas from building this flow (still relevant even though the command
itself changed): PlantNet responses carry no per-result reference image, so the bot uses a
Wikipedia thumbnail lookup for a visual confirmation; Telegram group privacy mode must be
disabled via BotFather or the bot silently never sees plain-text group replies (a platform
behavior, not a bug); a multipart data payload must be a Mapping, not a list of tuples,
or httpx silently skips multipart encoding; conversation_timeout requires the
python-telegram-bot[job-queue] extra installed or it is silently a no-op; httpx/httpcore
log full request URLs (including the bot token embedded in Telegram's URL path) at INFO —
force those loggers to WARNING unconditionally to avoid leaking the token into container
logs.
A custom React/Fastify app (not Grafana) for a warm, editorial "greenhouse/conservatory"
photo-forward plant browser, served on port 8090. "Canopy" is the product name (unchanged
by the project rename); its service directory is services/canopy-ui (renamed from
services/canopy to avoid colliding with the project's own new name, "Canopy"). Predecessor
design "Object Atlas" was rejected on visual grounds before Canopy's design was approved
and is out of scope here.
Key shipped decisions:
- Photo treatment: "strong grade + soft vignette" via
sharp. Tile crops usecoverwith a portrait-biased center; the detail/hero view never crops — it contains the full photo and fills the remaining canvas with a blurred, darkened echo of the same image.sharp'sattention-based auto-crop and ML background removal (rembg) were both tried and rejected:attentionlocked onto background clutter (e.g. bark mulch) instead of the plant on wide crops, andrembgerased ~93% of a spiky/feathery test plant. Neither is used anywhere in the pipeline. - All photo uploads must call sharp's
.rotate()— an early version didn't, so phone photos taken in portrait orientation (EXIF orientation tag 6) rendered 90° off; crop math for the side-by-side "duo tile" layout also needs a width/height swap when EXIF orientation ≥ 5. - Location grouping uses client-side fuzzy (Levenshtein ≤2) matching of the free-text
locationcolumn into a fixed display order (Terrace → Upstairs → Living Room → Kitchen → Elsewhere), not a rigid enum column. /plantsand/sensorslisting order usesORDER BY LENGTH(name), nameto sort numeric-string names correctly — a regex/cast-based alternative (~ '^[0-9]+$'::int) was considered but rejected because it silently behaves differently between real Postgres and the test suite's SQLite shim.- Photo upload is read-write from Canopy directly onto the same corpus/layout the Telegram
bot writes to (
YYYY-MM-DD/<uuid>.jpg); a scroll-position bug after route transitions requires pinning scroll position for ~1.2s post-navigation because the route transition transiently collapses document height. - Sustained light-OOR display logic mirrors the decision-engine's
light_lux_sustained_oorrule (§5) — a full 3-day all-readings-OOR window, not the earlier interim "exclude light entirely" rule.
install.shis the one-shot bring-up path for a fresh Pi:curl -fsSL https://raw.githubusercontent.com/alfarme/canopy/main/install.sh | bash. It's idempotent and re-runnable by design: the first run clones the repo and scaffolds.envfrom.env.example, then stops and asks you to fill in the required Telegram values; the second (and every later) run clones/pulls, validates.env, generatesPOSTGRES_PASSWORD/GF_SECURITY_ADMIN_PASSWORDif left blank (persisting them back into.envso reruns are stable), splits.envinto the per-container files under$DATA_DIR/env/*.env, sets up$DATA_DIR, installs the daily backup cron job, and runs./deploy.sh. Because it runs undercurl | bash, stdin is the script itself — it never prompts interactively; anything missing is a stop-and-ask-you-to-edit-.env-and-rerun, not a terminal read..envis regenerated into$DATA_DIR/env/*.envon everyinstall.shrun — it is the single source of truth; don't hand-edit the generated per-container files, they get overwritten.HOST_UID/HOST_GIDget appended to.envautomatically (not part of the user-facing template) so Grafana's container runs as the deploying host user rather than its image-default uid 472, which otherwise can't write to a bind-mounted$DATA_DIR/grafanaowned by a non-root Pi user — a non-root user can'tchowna directory to an arbitrary uid to fix that after the fact, so it has to be solved this way, on the compose side, not bychowning ininstall.sh.- Grafana's own Telegram alerting contact point needs
TELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDin$DATA_DIR/env/grafana.envtoo — the same credentials astelegram.env, but Grafana reads its own copy (compose/grafana/provisioning/alerting/contact-points.yaml). Missing either one is fatal for Grafana specifically (unlike telegram-bot, which just fails its own auth check and crash-loops on its own): Grafana's alerting provisioning refuses to finish starting at all if the contact point fails to validate. - Images are built natively for arm64 directly on the Pi via
docker compose build— no cross-arch buildx/QEMU emulation, no registry push/pull. This is a deliberate simplification: a single Pi building its own images needs no build host or registry —docker compose build && docker compose up -dis sufficient. - All persistent state lives under
$DATA_DIR(set in.env, copied from.env.example):pgdata,backups,mosquitto/{config,data,log},grafana,identify-photos,canopy-cache,deploy-history/,env/*.env. Secrets (DB passwords, API keys, bot tokens) live in$DATA_DIR/env/*.env, untracked by git — never inlined under a compose service'senvironment:block. deploy.shbuilds the changed services, recreates the stack, snapshots pre-deploy image IDs to$DATA_DIR/deploy-history/, then health-checks every container (cp-mosquitto cp-db cp-ingest cp-decision cp-telegram cp-canopy-ui cp-grafana cp-theengs), restarting Grafana explicitly afterward (its config is bind-mounted, soup -dalone won't pick up provisioning changes). There is no--to <tag>rollback mode any more — with no registry, rollback isgit checkout <sha> -- .followed by a rebuild.- Theengs Gateway needs
network_mode: hostfor BLE (see §3), which is why it talks to Mosquitto over127.0.0.1against a published port rather than compose bridge-network DNS — the one real topology wrinkle in an otherwise ordinary single-host compose stack. - Bare-metal tools unchanged in kind:
tools/miflora_battery.sh/_listener.sh/.servicestill run on the Pi's host (cron+systemd, needgatttool/bluetoothctl), not containerized — they talk to Mosquitto on127.0.0.1(no longer a hardcoded LAN IP) and use the renamedcanopy/ble,canopy/cmd/batterytopics. restart: unless-stoppedeverywhere (see §4).
The short version: on the target machine run
curl -fsSL https://raw.githubusercontent.com/alfarme/canopy/main/install.sh | bash and answer
the prompts (data directory, optional port changes, Telegram bot token / user id / chat id).
install.sh handles everything from step 3 onward below; the optional API keys (step 2) are
added to .env afterwards, followed by ~/canopy/install.sh. The long version, for
understanding what it's doing or for doing it by hand:
- Provision the host: a Raspberry Pi (or similar SBC) with Docker + Docker Compose,
Bluetooth working (
systemctl is-active bluetooth), and the deploying user in thedockergroup.install.shinstallsgitvia apt and, with your consent, Docker via Docker's official script (then asks you to log out and back in for the group to apply). It only warns about an inactive Bluetooth service. - Register external API credentials:
- OpenPlantbook: register at
open.plantbook.io, generateOPENPLANTBOOK_CLIENT_ID/OPENPLANTBOOK_CLIENT_SECRET. - Pl@ntNet: register a free account at
my.plantnet.orgfor an API key (500 req/day). - Telegram: create a bot via BotFather; disable Group Privacy mode if the bot needs to
read plain-text messages in a group chat; get your own numeric user id from
@userinfobot for
TELEGRAM_ADMIN_USER_ID. Put all of these into the single root.env(install.shscaffolds it from.env.exampleon first run) — never inline them under a compose service'senvironment:block, and never hand-edit the generated$DATA_DIR/env/*.envfiles directly (see §8).
- OpenPlantbook: register at
- Copy
.env.exampleto.envat the repo root and fill it in (install.shdoes the copy for you on first run). - Deploy TimescaleDB:
timescale/timescaledb:2.28.2-pg17(or a newer pinned pg17/pg18 tag), with the schema migrations applied on first boot viacompose/initdb/. - Bring up Mosquitto and Theengs Gateway as ordinary compose services (see
compose/docker-compose.yml) — Theengs needsnetwork_mode: host,privileged: true, and the D-Bus bind mount (§3); do not skip the D-Bus mount. docker compose build && docker compose up -dfrom the repo root (or./deploy.sh, which wraps the same build/up/health-check sequence, orinstall.sh, which wrapsdeploy.sh).- Deploy Grafana with provisioned dashboards/datasources from
compose/grafana/provisioning/, plus Grafana Alerting rules for sensor/infra health. - Set up daily backups: a host crontab entry running
tools/backup_db.sh(see the script header for the exact line, or letinstall.shinstall it), retaining 14 days. - Seed plant/sensor data: assign each MiFlora MAC to a plant, resolve care thresholds via OpenPlantbook (falling back to the conservative defaults in §6 for unresolved species), and confirm each Telegram admin can trigger identification by simply sending a photo (no command needed — see §6).
Canopy has been exercised end to end in pieces but not on every kind of host, so verify the BLE-to-database path once on a fresh machine. In order:
- Containers up:
docker psshowscp-mosquitto,cp-db,cp-theengs,cp-ingest,cp-decision,cp-telegram,cp-canopy-uiandcp-grafanaallUp(not restarting). A restart loop oncp-grafanaalmost always meansTELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_IDare missing from$DATA_DIR/env/grafana.env; oncp-telegram, a wrong bot token. - Bluetooth is up:
systemctl is-active bluetoothprintsactive, andhciconfig(orbluetoothctl list) shows an adapter. No adapter, no data. - Theengs sees advertisements:
docker logs cp-theengsshould show decoded devices within a minute or two. Silence while the container looks healthy usually means the D-Bus mount orprivileged/host-network settings are missing (§3). - MQTT carries them:
docker exec cp-mosquitto mosquitto_sub -t 'canopy/ble/#' -vprints JSON per sensor. Theengs reaches Mosquitto over the host loopback (MQTT_HOST=127.0.0.1, §3); if Theengs logs connection errors, setMQTT_HOSTin.envto the machine's LAN IP and re-runinstall.sh. This loopback topology has not been confirmed on every kernel/firewall setup. - Ingest writes rows:
docker logs cp-ingestis free of errors, anddocker exec cp-db psql -U canopy -d canopy -c "select count(*) from bt_readings"grows. Rows are written when a bucket closes (everyBUCKET_MINUTES, default 30), so allow up to that long for the first one. - Sensors announce themselves: each new MAC triggers a "new sensor" alert in Telegram;
register it with
/addsensor MAC NUMBERand/assign. - Weak signal: a sensor with very low RSSI (around -85 dBm or worse) is unreliable; move the host or the sensor closer. A successful data reading does not guarantee the separate battery read (§5) succeeds on a weak link.
- Automated irrigation (never merged): would add an
irrigation_zonesschema, a valve command executor, and planned sun/weather gating (Open-Meteo) for automated watering. This work predates the GARDENA removal and is not part of this repository. - Deploy generalization (removing hardcoded paths from
deploy.shtoward a fully.env-driven,curl | bash-installable deploy mechanism): done — see §8 andinstall.sh. - Easier MiFlora sensor discovery for fresh setups is still proposed future work, not yet designed or implemented.