{"id":50769634,"url":"https://github.com/gre/jardin-sensors","last_synced_at":"2026-06-11T17:00:57.795Z","repository":{"id":355434885,"uuid":"1228074091","full_name":"gre/jardin-sensors","owner":"gre","description":"LoRa-linked garden sensors with HMAC-authenticated radio and native Home Assistant integration: dumb emitters in the field, smart gateway at home.","archived":false,"fork":false,"pushed_at":"2026-05-31T16:37:42.000Z","size":178,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-31T18:22:37.289Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"C++","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/gre.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-03T15:02:49.000Z","updated_at":"2026-05-31T16:37:46.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/gre/jardin-sensors","commit_stats":null,"previous_names":["gre/jardin-sensors"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/gre/jardin-sensors","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fjardin-sensors","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fjardin-sensors/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fjardin-sensors/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fjardin-sensors/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/gre","download_url":"https://codeload.github.com/gre/jardin-sensors/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fjardin-sensors/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34208761,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-11T02:00:06.485Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-11T17:00:56.673Z","updated_at":"2026-06-11T17:00:57.620Z","avatar_url":"https://github.com/gre.png","language":"C++","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Connected Garden Project – LoRa \u0026 Home Assistant\n\n\u003e **Status**: deployed, full LoRa + MQTT + HA + auth pipeline working end-to-end.\n\u003e **License**: [GPL v3](#license) · **Project**: personal\n\n## Goal\n\nMonitoring, alerting, and remote control for the garden:\n\n- tank level reported via ultrasonic sensor\n- \"needs refill\" notification\n- **remote outlet control** (relay actuator) — switch garden sockets from HA\n- **plant monitoring** via Flower Care BLE sensors (soil temp, moisture, light, conductivity)\n- centralized integration into **Home Assistant**\n\n## Constraints\n\n- Distance house ↔ garden: ~100 m\n- House Wi-Fi insufficient on the garden side → LoRa radio link\n- Outdoor environment (humidity, interference, frost)\n- **Mains** powered on both sides\n- Reliability and simplicity required\n\n## Philosophy: **emitter dumb, gateway smart**\n\nStructural rule of the project, to follow for any new sensor added later.\n\n- **The emitter** (garden side, in a waterproof box, hard to access) only\n  does **raw measurement**: pin toggling, pulseIn, physical filtering\n  (median). It serializes raw values into JSON and sends them over LoRa. No\n  semantics, no calibration.\n- **The gateway** (house side, accessible and reflashable) carries all the\n  **semantic intelligence**: calibration, derivation of business values\n  (`tank_pct`), enrichment (`rssi`, `snr`), MQTT publish and HA discovery.\n- **Why**: recalibrating the tank or changing a threshold should never\n  require opening the waterproof box in the garden.\n\n```mermaid\nflowchart LR\n    Sensor((Sensor))\n    Emitter[\"\u003cb\u003eEMITTER\u003c/b\u003e\u003cbr/\u003eraw values + meta\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003ephysical filtering\u003cbr/\u003etx interval\u003c/i\u003e\"]\n    Gateway[\"\u003cb\u003eGATEWAY\u003c/b\u003e\u003cbr/\u003eraw + derived\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003ecalibration geometry\u003cbr/\u003elink tracking\u003c/i\u003e\"]\n    Actuator[\"\u003cb\u003eACTUATOR\u003c/b\u003e\u003cbr/\u003erelay state\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003ecmd + heartbeat\u003c/i\u003e\"]\n    HA[\"\u003cb\u003eHOME ASSISTANT\u003c/b\u003e\u003cbr/\u003eraw + derived entities\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003eautomation\u003cbr/\u003enotifications\u003cbr/\u003elong-term storage\u003c/i\u003e\"]\n    Sensor --\u003e Emitter\n    Emitter --\u003e|\"LoRa\u003cbr/\u003epoint-to-point\"| Gateway\n    Gateway --\u003e|\"MQTT retain\u003cbr/\u003e+ discovery\"| HA\n    HA --\u003e|\"MQTT set\"| Gateway\n    Gateway --\u003e|\"LoRa cmd\"| Actuator\n    Actuator --\u003e|\"LoRa heartbeat\"| Gateway\n```\n\n## Overall architecture\n\nThe emitter and gateway run on the same board (LILYGO LoRa32 T3 V1.6.1).\nThe actuator has two firmware variants: the same LILYGO board\n(`prises-actuator`) or a **DX-LR30** module (STM32F103 + SX1262,\n`prises-actuator-dx-lr30`).\n\n```mermaid\nflowchart TB\n    subgraph garden [\"🌿 GARDEN\"]\n        E[\"\u003cb\u003eEMITTER\u003c/b\u003e\u003cbr/\u003eLILYGO LoRa32 T3 V1.6.1\u003cbr/\u003e━━━━━━━━━━━━\u003cbr/\u003eSR04M-2 → tank_cm\u003cbr/\u003eDS18B20 → water_temp_c\u003cbr/\u003eLoRa TX every ~60 s\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003e'dumb sensor'\u003c/i\u003e\"]\n        A[\"\u003cb\u003eACTUATOR\u003c/b\u003e\u003cbr/\u003eLILYGO LoRa32 T3 V1.6.1\u003cbr/\u003e\u003ci\u003eor\u003c/i\u003e DX-LR30 (STM32F103)\u003cbr/\u003e━━━━━━━━━━━━\u003cbr/\u003e2-channel relay module\u003cbr/\u003eLoRa RX cmds + TX heartbeat\u003cbr/\u003estate persisted in NVS/EEPROM\"]\n    end\n    subgraph house [\"🏠 HOUSE\"]\n        G[\"\u003cb\u003eGATEWAY\u003c/b\u003e\u003cbr/\u003eLILYGO LoRa32 T3 V1.6.1\u003cbr/\u003e━━━━━━━━━━━━\u003cbr/\u003eVerify HMAC, decode JSON\u003cbr/\u003eDerive tank_pct\u003cbr/\u003eTrack availability\u003cbr/\u003eMQTT publish + LWT\u003cbr/\u003eHA auto-discovery\u003cbr/\u003eRelay cmd + retry\u003cbr/\u003e\u003cbr/\u003e\u003ci\u003e'smart hub'\u003c/i\u003e\"]\n        HA[\"\u003cb\u003eHome Assistant\u003c/b\u003e\u003cbr/\u003eauto discovery\u003cbr/\u003enotifications\u003cbr/\u003elong-term graphs\u003cbr/\u003eswitch entities\"]\n    end\n    E --\u003e|\"LoRa 868.1 MHz\"| G\n    G --\u003e|\"LoRa cmd\"| A\n    A --\u003e|\"LoRa heartbeat\"| G\n    G --\u003e|\"Wi-Fi + MQTT\"| HA\n```\n\n## Per-measurement data flow\n\n```mermaid\nflowchart TB\n    SR[\"SR04M-2\u003cbr/\u003etrig / echo\"] --\u003e raw[\u003cb\u003etank_cm\u003c/b\u003e\u003cbr/\u003efloat, raw distance]\n    DS[\"DS18B20\u003cbr/\u003e1-Wire\"] --\u003e wt[\u003cb\u003ewater_temp_c\u003c/b\u003e\u003cbr/\u003e°C, factory-calibrated]\n    raw --\u003e|\"LoRa JSON\u003cbr/\u003e+ HMAC\"| GW\n    wt --\u003e|\"LoRa JSON\u003cbr/\u003e+ HMAC\"| GW\n    GW[\"\u003cb\u003eGATEWAY augment\u003c/b\u003e\"]\n    GW --\u003e meta[+ rssi, snr]\n    GW --\u003e pct[\"\u003cb\u003etank_pct\u003c/b\u003e\u003cbr/\u003egeometric mapping\u003cbr/\u003e% filled, 0..100\u003cbr/\u003e\u003ci\u003eprimary entity\u003c/i\u003e\"]\n    GW --\u003e diag[\"\u003cb\u003etank_cm\u003c/b\u003e relayed in parallel\u003cbr/\u003e\u003ci\u003ediagnostic entity\u003c/i\u003e\"]\n```\n\n## Relay actuator\n\nA second LoRa node in the garden controls a **2-channel relay module** (garden\nsockets). The gateway bridges HA switch commands to LoRa and tracks the\nactuator's state.\n\n### Command/heartbeat protocol\n\n```mermaid\nsequenceDiagram\n    participant H as Home Assistant\n    participant G as Gateway\n    participant A as Actuator\n\n    H-\u003e\u003eG: MQTT jardin/prises/relay1/set = 1\n    Note over G: optimistic publish\u003cbr/\u003ejardin/prises/state {relay1:1,...}\n    G-\u003e\u003eA: LoRa { to:prises, relay1:1 } + HMAC\n    A-\u003e\u003eA: set relay, persist to NVS/EEPROM\n    A-\u003e\u003eG: LoRa heartbeat { node:prises, relay1:1, relay2:0, vbat, seq } + HMAC\n    G-\u003e\u003eH: MQTT jardin/prises/state (confirmed, retain)\n    Note over G: desired cleared — command confirmed\n```\n\nProperties:\n- **Optimistic publish**: the gateway immediately updates `jardin/prises/state`\n  when a command arrives from HA, without waiting for the LoRa round-trip.\n  HA feels instant.\n- **Confirmation**: when the actuator's heartbeat echoes the expected state,\n  the desired state is cleared. If the heartbeat is missed (gateway was still\n  in TX when the actuator responded), the gateway retransmits after\n  `RELAY_CMD_RETRY_MS` (default 2500 ms).\n- **Stale overlay**: if the heartbeat doesn't match the desired state (packet\n  loss, collision), the gateway overlays the desired values before publishing\n  so HA does not visually flip back. `g_relayCommandPending` triggers a\n  retransmit.\n- **Periodic heartbeat**: the actuator also sends a heartbeat every\n  `TX_INTERVAL_S` seconds (default 60 s) so `rssi`/`vbat`/`snr` stay fresh\n  in HA even with no commands.\n- **State persistence**: relay state is saved to NVS (ESP32 `Preferences`) or\n  EEPROM (STM32) on every change and restored at boot, so a power cycle keeps\n  the last commanded state without any gateway intervention.\n- **Restore on actuator reboot**: the first heartbeat after boot carries\n  `restore_req:1`. The gateway responds by re-sending the last HA-commanded\n  state according to the **Power-on behavior** setting (HA select entity,\n  namespace `gw-sec`/`pob`): `previous` (default, re-apply last commanded\n  state), `on` (force all relays on), `off` (force all-off for a safe restart),\n  or `toggle` (invert current state, handled locally by the actuator).\n\n### HA entities (actuator device \"Prises\")\n\n| Entity | Type | MQTT topic | Notes |\n|---|---|---|---|\n| `Prise 1` | switch (primary) | `jardin/prises/relay1/set` | |\n| `Prise 2` | switch (primary) | `jardin/prises/relay2/set` | |\n| `Power-on behavior` | select (config) | `jardin/prises/power_on/set` | previous / on / off / toggle |\n| `Battery voltage` | sensor (diagnostic) | `jardin/prises/state` → `vbat` | V |\n| `LoRa RSSI` | sensor (diagnostic) | `jardin/prises/state` → `rssi` | dBm |\n| `LoRa SNR` | sensor (diagnostic) | `jardin/prises/state` → `snr` | dB |\n\nAvailability: same mechanism as the emitter — `jardin/prises/availability`\ngoes `offline` after `NODE_TIMEOUT_MS` (3 min) without a heartbeat.\n\n### Actuator JSON schema\n\nHeartbeat payload on `jardin/prises/state` (after gateway augmentation):\n\n```json\n{ \"node\": \"prises\", \"seq\": 12, \"relay1\": 1, \"relay2\": 0, \"vbat\": 3.82,\n  \"rssi\": -71, \"snr\": 8.5 }\n```\n\nGateway command over LoRa (authenticated):\n\n```\n{\"to\":\"prises\",\"cs\":42,\"relay1\":1,\"power_on\":\"previous\"}|\u003chmac16\u003e\n```\n\nFields: `cs` (monotonic command sequence for anti-replay), `relay1`/`relay2`\n(0/1, only changed fields included), `power_on` (power-on behavior, echoed on\nevery command so the actuator stays in sync). Absent relay fields keep their\ncurrent state.\n\n## JSON fields schema (cuve emitter)\n\n| Field | Source | Type | Unit | HA category | Meaning |\n|---|---|---|---|---|---|\n| `node` | emitter | string | – | – | emitter identifier |\n| `seq` | emitter | int | – | – | packet counter |\n| `tank_cm` | emitter | float | cm | diagnostic | raw ultrasonic distance |\n| `water_temp_c` | emitter | float | °C | primary | water temperature (DS18B20, factory-calibrated) |\n| `fc` | emitter | array | – | – | Flower Care readings, one entry per MAC (see below) |\n| `rssi` | gateway | int | dBm | diagnostic | LoRa reception quality |\n| `snr` | gateway | float | dB | diagnostic | LoRa signal-to-noise ratio |\n| `tank_pct` | **gateway derived** | int | % | primary | tank fill level |\n\n\u003e Note: `water_temp_c` is sent directly in °C by the emitter without gateway-side derivation. The DS18B20 is factory-calibrated to ±0.5 °C, no deployment-specific parameter. The \"raw vs derived\" rule applies to analog sensors or sensors that need deployment calibration (tank %).\n\nThe `fc` array contains one entry per configured MAC in order; `null` if the BLE read failed for that slot:\n\n```json\n\"fc\": [\n  { \"t\": 22.5, \"m\": 45, \"l\": 1234, \"c\": 250, \"b\": 85 },\n  null\n]\n```\n\n| Key | Meaning | Unit |\n|---|---|---|\n| `t` | soil temperature | °C |\n| `m` | soil moisture | % |\n| `l` | light intensity | lux |\n| `c` | soil conductivity | µS/cm |\n| `b` | sensor battery | % |\n\n## Tank geometry\n\nThe sensor is fixed under the tank cover, head facing down. It measures the\n**distance between itself and the water surface** (`tank_cm`). The fuller\nthe tank, the smaller this distance.\n\n```\n   ┌──────────────────────┐  ◄── tank ceiling (cover with hole)\n   │                      │\n   │      ▼ SR04M-2       │  ◄── sensor head fixed to cover,\n   │      ║║              │      measurement cone facing down\n   │      ║║   ↓          │\n   │      ║║   │          │\n   │   acoustic wave      │\n   │      │               │\n   │      ▼               │\n   │  ~~~~~~~~~~~~~~      │  ◄── water surface\n   │                      │\n   │       water          │\n   │                      │\n   └──────────────────────┘  ◄── tank bottom\n\n   tank_cm = distance sensor -\u003e water surface (read by sensor)\n   TANK_FULL_DISTANCE_CM  = sensor offset alone (full tank, water near cover)\n   TANK_EMPTY_DISTANCE_CM = tank depth + offset (empty tank, bottom visible)\n```\n\n\u003e Mind the **dead zone** of the SR04M-2 (~25 cm): if `TANK_FULL_DISTANCE_CM \u003c 25`, the full-tank state will translate to erratic readings. Better to over-measure (sensor 25-30 cm above the \"full\" level) than razor-thin.\n\n## Calibration\n\nCalibration is **not hardcoded**: the gateway exposes two HA `number`\nentities (range 0-200 cm) that drive the thresholds at runtime, persisted\nvia MQTT retain.\n\n| HA entity (number) | MQTT topic | Range | Meaning |\n|---|---|---|---|\n| `Tank empty distance` | `jardin/config/tank_empty_cm` | 0-200 cm | sensor distance when tank is empty |\n| `Tank full distance` | `jardin/config/tank_full_cm` | 0-200 cm | sensor distance when tank is full |\n| `Cuve TX interval` | `jardin/config/cuve_tx_interval_s` | 5-3600 s | emitter packet cadence, pushed via `cfg_req` |\n\n`tank_pct = clamp(map(tank_cm, tank_empty_cm, tank_full_cm, 0, 100), 0, 100)`\n\nAt boot, the gateway loads values from `[env:gateway]/build_flags`\n(`TANK_EMPTY_DISTANCE_CM=80`, `TANK_FULL_DISTANCE_CM=5`), then HA overrides\nthem via the retained messages on subscribe.\n\nIn-service calibration procedure:\n1. Connect the emitter in the garden, watch `tank_cm` in HA (diagnostic entity)\n2. Empty tank → note the value, push it into the HA `Tank empty distance` slider\n3. Full tank → same in `Tank full distance`\n4. No reflash: values are retained on the broker, persist across gateway reboot\n\nIf `tank_empty == tank_full` (bad calibration), the `tank_pct` field is\nomitted from the payload (no div/0, the HA entity goes `unavailable`).\n\n### \"null = full\" rule + debounce\n\nThe ultrasonic sensor is mounted **at the top of the tank**. When it is\nnearly full, the water surface goes **below the dead zone (~25 cm)** of the\nSR04M-2 → no echo → `tank_cm: null`. This missing echo is **interpreted as\nfull tank** by the gateway, but with a **debounce** to avoid flicker when a\nsingle ping is missed in an otherwise valid stream:\n\n- **Isolated null** in a stream of recent valid measurements (`\u003cTANK_NULL_GRACE_MS=3000` ms) → the gateway substitutes the last known value (per node)\n- **Sustained null** beyond 3 s → switches to 100% (truly full tank)\n- **Valid measurement** (`tank_cm` non-null) → memorized as the new reference for debounce, and pct computed directly\n\nConsequence:\n- `tank_pct` is **always defined** when the emitter is transmitting (never \"unavailable\" while the node is online)\n- `tank_cm` (diagnostic) stays `null` when the measurement is missing, which lets you distinguish \"really full\" (null) from a real distance reading\n- If the emitter goes down completely (offline), `tank_pct` AND `tank_cm` go unavailable via the MQTT `availability` mechanism (do not confuse with null tank_cm)\n\n## LoRa link security\n\nLoRa point-to-point has **no native security** (no encryption, no\nauthentication, public sync word). Forge / replay / sniff attacks are\ntrivial by default. The project implements **truncated HMAC-SHA256** +\n**anti-replay** at the application layer.\n\n### HMAC-SHA256 (auth + integrity)\n\n- Pre-shared key `LORA_PSK` (string), defined in `secrets.ini` section `[lora]`, **identical between emitter and gateway**\n- On-air payload format: `\u003cjson\u003e|\u003chex16\u003e` where `hex16` = first 8 bytes of `HMAC-SHA256(key, json)` in hex\n- Emitter: automatic MAC append on every TX (`include/auth.h`, `authAppendMac`)\n- Gateway: const-time verification before any processing (`authVerifyMac`). Any packet without a valid MAC is dropped with `[gateway] HMAC invalid, drop ...`\n- HMAC implementation: `mbedtls/md.h` (bundled in arduino-esp32, no external lib)\n- Guarantees: **an attacker without the key can neither forge nor modify packets**. The MAC truncated to 64 bits gives 2⁶⁴ possibilities, brute-force unfeasible.\n\n### Anti-replay (per node monotonic seq)\n\n- Emitter: `seq` field that increments on each packet (uint32_t counter since boot)\n- Gateway: `NodeState` stores `lastSeq` per node, accepts a packet only if `seq \u003e lastSeq`\n- Emitter reboot tolerance: if `seq \u003c 100` and `lastSeq \u003e 10000`, we assume a reset and re-arm the counter (the \"attacker replays after gateway reboot\" scenario is already mitigated by the HMAC, which prevents forging)\n- Logged drops: `[gateway] replay/reorder drop node=cuve seq=42 (last=43)`\n\n### What it does NOT cover\n\n- **Confidentiality**: the JSON is in clear over the air, sniffable. To hide values, AES-128 would have to be added on top (not implemented)\n- **Radio DoS**: an attacker can spam the frequency to block reception. Mitigation = RF level (not applicable in software here)\n\nFor a garden water-level sensor, auth + integrity + anti-replay is largely\nenough.\n\n## MQTT topics\n\n```mermaid\nflowchart LR\n    HA[\"\u003cb\u003ehomeassistant/\u003c/b\u003e\u003cbr/\u003e\u003ci\u003eHA discovery prefix\u003c/i\u003e\"]\n    HA --\u003e HAS[\"sensor/jardin-{node}/{key}/config\u003cbr/\u003e\u003ci\u003eone per sensor x node\u003c/i\u003e\"]\n    HA --\u003e HAN[\"number/jardin-gateway/{key}/config\u003cbr/\u003e\u003ci\u003eruntime config sliders\u003c/i\u003e\"]\n    HA --\u003e HASW[\"switch/jardin-prises-{key}/config\u003cbr/\u003e\u003ci\u003erelay switch entities\u003c/i\u003e\"]\n\n    J[\"\u003cb\u003ejardin/\u003c/b\u003e\u003cbr/\u003e\u003ci\u003eMQTT_BASE_TOPIC\u003c/i\u003e\"]\n    J --\u003e JS[\"{node}/state\u003cbr/\u003e\u003ci\u003efull JSON payload, retain\u003c/i\u003e\"]\n    J --\u003e JA[\"{node}/availability\u003cbr/\u003e\u003ci\u003eonline | offline, retain\u003c/i\u003e\"]\n    J --\u003e JG[\"gateway/availability\u003cbr/\u003e\u003ci\u003eonline | offline, broker LWT\u003c/i\u003e\"]\n    J --\u003e JC[\"config/\u003cbr/\u003e\u003ci\u003eruntime config, retain\u003c/i\u003e\"]\n    JC --\u003e JCE[tank_empty_cm]\n    JC --\u003e JCF[tank_full_cm]\n    JC --\u003e JCT[cuve_tx_interval_s]\n    JC --\u003e JCFC[flowercare_sensors\u003cbr/\u003e\u003ci\u003eJSON array, retain\u003c/i\u003e]\n    J --\u003e JR[\"prises/{relay}/set\u003cbr/\u003e\u003ci\u003eHA switch command, 0 or 1\u003c/i\u003e\"]\n    J --\u003e JFC[\"flowercare/{mac}/state\u003cbr/\u003e\u003ci\u003eper-sensor JSON, retain\u003c/i\u003e\"]\n```\n\nExample payload for `jardin/cuve/state` after augmentation by the gateway:\n\n```json\n{\n  \"node\": \"cuve\",\n  \"seq\": 42,\n  \"tank_cm\": 47.3,\n  \"water_temp_c\": 18.5,\n  \"rssi\": -75,\n  \"snr\": 9.5,\n  \"tank_pct\": 43\n}\n```\n\nWhat goes **over the air** (LoRa) before the gateway parses, validates and\naugments:\n\n```\n{\"node\":\"cuve\",\"seq\":42,\"tank_cm\":47.3,\"water_temp_c\":18.5}|6d17ea007dd04cf8\n                                                           ^^^^^^^^^^^^^^^^^^\n                                                           HMAC-SHA256 truncated (8 hex bytes)\n```\n\n`rssi`, `snr`, `tank_pct` are added on the gateway side (never transmitted\nover the air).\n\n## Gateway-side node lifecycle\n\n```mermaid\nflowchart TB\n    RX([LoRa RX])\n    HMAC{HMAC valid?}\n    PARSE{parse JSON}\n    SEQ{seq \u003e lastSeq?\u003cbr/\u003eor reboot heuristic}\n    FIND{node already known?}\n    DISC[publishDiscovery\u003cbr/\u003e+ registerNode]\n    MARK[markSeen\u003cbr/\u003eupdate lastSeenMs]\n    AVA{was offline?}\n    PA[publishAvailability online]\n    AUG[augmentDerived\u003cbr/\u003etank_pct, rssi, snr]\n    PUB[(\"MQTT publish\u003cbr/\u003ejardin/{node}/state\u003cbr/\u003eretain=true\")]\n\n    RX --\u003e HMAC\n    HMAC -- no --\u003e D1[drop: HMAC invalid]\n    HMAC -- yes --\u003e PARSE\n    PARSE -- err --\u003e D2[drop: parse error]\n    PARSE -- ok --\u003e SEQ\n    SEQ -- replay/reorder --\u003e D3[drop]\n    SEQ -- ok --\u003e FIND\n    FIND -- no --\u003e DISC --\u003e MARK\n    FIND -- yes --\u003e MARK\n    MARK --\u003e AVA\n    AVA -- yes --\u003e PA --\u003e AUG\n    AVA -- no --\u003e AUG\n    AUG --\u003e PUB\n```\n\n`checkNodeTimeouts()` runs in `loop()`: any `online` node that has not sent\nsince `NODE_TIMEOUT_MS` (3 min default) flips to `offline`.\n\n`softWatchdog()` reboots the gateway via `ESP.restart()` if Wi-Fi or MQTT\nhave been down for more than 5 min.\n\n## Hardware\n\n### Boards\n\n**2x LILYGO LoRa32 T3 V1.6.1** (emitter + gateway), identical (silkscreen\n`T3_V1.6.1 20210104`).\n\n- ESP32-PICO-D4 + LILYGO LORA32 module (SX1276, 868/915 MHz)\n- CH9102F USB-UART, micro-USB port, ON/OFF switch, JST battery\n- SMA + IPEX antenna connector\n- Built-in OLED SSD1306 0.96\" (I2C: SDA=21, SCL=22, RST=16)\n\n\u003e Note: vendor listings may still mention \"TTGO\" (`2ASYE-T3-V1-6-1` / `XY241015`). Same product.\n\n**1x DX-LR30** (actuator alternative) — STM32F103C8T6 + SX1262 LoRa module.\n\n- STM32F103C8T6 (72 MHz Cortex-M3, 64 KB flash, 20 KB SRAM)\n- SX1262 LoRa radio (868 MHz), XOSC 32 MHz crystal (not TCXO)\n- External RF switch controlled by TXEN (PA0) and RXEN (PA1)\n- WCH USB-serial (CH340-compatible) for flashing, upload via `stm32flash`\n- No built-in OLED\n\nPinout verified from the DX-LR30 development board manual:\n\n```\nSPI1 (LoRa):    SCK=PA5  MISO=PA6  MOSI=PA7\nSX1262 control: NSS=PA4  DIO1=PC15  RST=PA15  BUSY=PA14(=JTCK)\nRF switch:      TXEN=PA0  RXEN=PA1\nRelay outputs:  IN1=PB3(=JTDO)  IN2=PB4(=NJTRST)\nSerial:         TX=PA9  RX=PA10\nLED:            PC13 (active LOW)\nVBAT:           PA3\n```\n\n\u003e PB3, PB4, PA14 are JTAG pins. The firmware calls\n\u003e `loraDisableJtag()` in `setup()` (via `__HAL_AFIO_REMAP_SWJ_DISABLE()`) to\n\u003e free them as GPIO before any `pinMode`/`digitalWrite`. Serial flashing only\n\u003e after this point (SWD is also released).\n\n### Sensors\n\n**1. SR04M-2** (JSN-SR04T family) — waterproof ultrasonic for distance.\n\n- Waterproof transducer head at the end of a ~2 m cable, **control board to keep dry**\n- Dead zone ~25 cm, range ~25-450 cm\n- **5 V** powered, echo signals at **5 V** → voltage divider mandatory to ESP32 (3.3 V max)\n- Pin header silkscreen: `RST | 5V | RX | TX | GND` (+ `SWIM` test pad)\n- In **mode 0** (factory default): `RX` = TRIG (sensor input from MCU), `TX` = ECHO (sensor output to MCU). Same behavior as a standard HC-SR04 / JSN-SR04T.\n- **22 µF electrolytic decoupling cap** (or 100 nF + 22 µF in parallel) between VCC and GND **directly on the sensor PCB**: required to absorb the transducer's current peaks. Without the cap, the sensor becomes unstable after a few pings.\n\n**2. DS18B20** (5 m stainless steel waterproof probe, Aideepen) — water temperature.\n\n- 1-Wire digital, factory-calibrated ±0.5 °C\n- Range -55 to +125 °C (the tank will be between 0 and 30 °C in practice)\n- 5 m factory-sealed waterproof cable → goes directly into the tank water\n- Wires: Red=VDD (3.3V), Black=GND, Yellow=DATA\n- **4.7 kΩ pull-up mandatory** between DATA and VDD (the ESP32 internal pull-up is too weak for 1-Wire)\n\n### Emitter wiring\n\nLILYGO T3 V1.6.1 ↔ sensors pinout (defaults `platformio.ini`):\n\n```\n   LILYGO T3 V1.6.1 (emitter)              SR04M-2 (ultrasonic)\n   ──────────────────────────              ─────────────────────\n            5V o─────────────────────────o 5V (VCC)\n           GND o─────────────────────────o GND\n                                          o RX  (= TRIG, mode 0)\n           IO4 o─────────────────────────┘\n                                          \n          IO25 o◄──┐                       \n                   │                       \n                   ├──[ R1 = 10k ]─o TX    (= ECHO, mode 0, 5V signal)\n                   │\n                  [ R2 = 20k ]\n                   │\n                  GND\n                  \n   + 22 uF (electro) decoupling capacitor BETWEEN VCC AND GND PINS\n     of the SR04M-2 PCB, leads as short as possible. Without the cap,\n     the sensor becomes unstable after a few pings.\n\n   5V -\u003e 3.3V voltage divider (echo):\n     V_out = V_in * R2 / (R1 + R2) = 5 * 20 / 30 = 3.33V\n\n\n   LILYGO T3 V1.6.1 (emitter)              DS18B20 (water temp)\n   ──────────────────────────              ────────────────────\n          3.3V o───────────┬─────────────o Red    (VDD)\n                           │\n                       [ 4.7k ]            \u003c- 1-Wire pull-up mandatory\n                           │\n          IO13 o───────────┴─────────────o Yellow (DATA)\n           GND o─────────────────────────o Black  (GND)\n```\n\nLoRa pinout internal to the board (already wired on the PCB, exposed via\n`build_flags`):\n`SCK=5  MISO=19  MOSI=27  SS=18  RST=23  DIO0=26`\n\nOLED pinout internal (I2C):\n`SDA=21  SCL=22  RST=16`\n\n### Actuator wiring\n\n**ESP32 variant** (`prises-actuator`, LILYGO LoRa32 T3 V1.6.1):\n\n```\n   LILYGO T3 V1.6.1 (actuator)     2-ch relay module (5V)\n   ────────────────────────────     ──────────────────────\n            5V o──────────────────o VCC\n           GND o──────────────────o GND\n          IO32 o──────────────────o IN1\n          IO33 o──────────────────o IN2\n```\n\n`RELAY1_PIN=32  RELAY2_PIN=33`  (defaults; overridable via `build_flags`)\n\n**STM32 variant** (`prises-actuator-dx-lr30`, DX-LR30 module):\n\n```\n   DX-LR30 (actuator)               2-ch relay module (5V)\n   ──────────────────               ──────────────────────\n            3.3V or 5V o──────────o VCC\n                  GND  o──────────o GND\n                  PB3  o──────────o IN1\n                  PB4  o──────────o IN2\n```\n\n`RELAY1_PIN=PB3  RELAY2_PIN=PB4` — freed from JTAG by `loraDisableJtag()`.\n\n**Relay module polarity**: configurable per channel via `RELAY1_ACTIVE_LOW`\nand `RELAY2_ACTIVE_LOW` build flags (`0` = active-HIGH, `1` = active-LOW).\nThe two channels of a given module may have different polarity — set each flag\nindependently. If only `RELAY_ACTIVE_LOW` is defined, both channels inherit it.\n\n### OLED display\n\nOLED controlled by `WITH_OLED=1` in `platformio.ini`. If the board does not\nhave a soldered OLED, set to `0` or omit — the code I2C-probes at 0x3C and\ndisables the display cleanly. The DX-LR30 has no OLED; `WITH_OLED` is not\nset for that target.\n\n**Emitter**: every cycle (1 s):\n\n```\n┌────────────────────────────┐\n│ Cuve emitter      LoRa OK  │\n│────────────────────────────│\n│                            │\n│   47.3              cm     │   ◄── ultrasonic distance (large)\n│                            │\n│────────────────────────────│\n│ TX #42      18.5C          │   ◄── seq + water temp if available\n└────────────────────────────┘\n```\n\n- Failed distance → `----`, OR last known value with `?` if \u003c30 s\n- No DS18B20 (disconnected/init failure) → temp omitted from footer\n- LoRa init failed → `LoRa ERR` top right, TX skipped\n\n**Actuator** (`prises-actuator`, ESP32 only):\n\n```\n┌────────────────────────────┐\n│ Prises actuateur  LoRa OK  │\n│────────────────────────────│\n│ Relay1: ON                 │\n│ Relay2: OFF                │\n│────────────────────────────│\n│ TX #5   cmd 3s ago         │   ◄── seq + age of last received command\n└────────────────────────────┘\n```\n\n**Gateway**: refresh every 500 ms:\n\n```\n┌────────────────────────────┐\n│ Gateway     W:OK M:OK      │\n│────────────────────────────│\n│                            │\n│   87 %       18.5C         │   ◄── tank_pct (large) + temp\n│                            │\n│────────────────────────────│\n│ cuve 2s -75dBm             │   ◄── node + age last RX + RSSI\n└────────────────────────────┘\n```\n\n- No RX yet since boot → `no RX yet`\n- WiFi/MQTT down → `W:--` or `M:--` at the top\n- `tank_pct` not yet received → `----`\n\nDuring deployment: the gateway OLED is valuable to manually check that\npackets are arriving without having to look at HA.\n\n### Power and enclosure\n\n- **Mains** powered on the garden side (USB) and house side\n- IP65 enclosure + cable glands on the garden side (transducer cable + USB power passthrough)\n- STLs to design and print (`hardware/3d/`)\n\n## Communication\n\n### Radio\n\n- **LoRa point-to-point** (no LoRaWAN)\n- Frequency: **868.1 MHz**\n- CRC enabled\n- TX cadence is **runtime-configurable** by the gateway (see *Runtime config sync* below); the emitter persists the chosen value in NVS\n- **CSMA/CAD** (`loraTx()` in `lora_board.h`): every TX call runs a channel\n  activity detection scan before transmitting. If the channel is busy, a\n  random 20-120 ms backoff is applied, then the packet is transmitted. Prevents\n  blind collisions when two nodes are close in time.\n\n### Runtime config sync (gateway → emitter)\n\nThe gateway publishes a HA `number` slider per emitter (currently `Cuve TX\ninterval`, range 5-3600 s). When the emitter wants the latest value, it\nappends `\"cfg_req\":1` to its next data packet; the gateway replies inline\nwith a small config packet, the emitter saves to NVS, and the new cadence\ntakes effect immediately.\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant E as Cuve emitter\n    participant G as Gateway\n    participant H as Home Assistant\n    Note over E: boot or every ~1h\u003cbr/\u003e(rtcCfgAccumS \u003e= CFG_REFRESH_S)\n    E-\u003e\u003eG: state packet { node, seq=N, tank_cm, ..., cfg_req:1 } + HMAC\n    G--\u003e\u003eH: MQTT publish (state, retain)\n    G-\u003e\u003eE: { to:cuve, ack:N, cfg:{tx_interval_s} } + HMAC\n    Note over E: HMAC ok? to==me? ack==N?\u003cbr/\u003e→ save NVS, use new cadence\n    H-\u003e\u003eG: HA slider edit → MQTT retain on jardin/config/cuve_tx_interval_s\n    Note over G: g_cuveTxIntervalS updated\u003cbr/\u003edelivered on next cfg_req\n```\n\nProperties:\n- **Authentication**: same HMAC-SHA256 + PSK as the data direction; an attacker without the key cannot forge config.\n- **Replay protection**: the emitter only accepts a response whose `ack` field matches the seq it just sent. A captured old reply cannot push stale config.\n- **Liveness**: if the gateway's reply is lost (RF, gateway down), the emitter retries on the next cycle until `rtcCfgAccumS` is reset by a successful ack.\n- **Persistence**: once acked, the value lives in NVS, so a power cycle keeps the configured cadence even if the gateway is offline at boot.\n\n### Deep sleep (battery / solar)\n\n`-DWITH_DEEP_SLEEP=1` in `[env:cuve-emitter]` switches the emitter to one-shot mode: `setup()` performs a single TX cycle (with optional `cfg_req` round-trip) then calls `esp_deep_sleep_start()` for `tx_interval_s` seconds. RTC memory preserves `seq` and the cfg-refresh accumulator across sleeps; full power cycles reset them and the gateway's reboot heuristic re-arms the anti-replay counter.\n\nThe OLED stays **off** on scheduled wakes to save power. On a fresh boot (RESET button or power-up — distinguished from a timer wake via `esp_sleep_get_wakeup_cause()`), the OLED comes on for 10 s with the latest measurement, then sleeps with the rest. So **press RESET on the emitter to read the screen**: no extra hardware, no extra firmware path. Configurable via `OLED_BOOT_DISPLAY_MS`.\n\nPower-budget rules of thumb (LILYGO T3 V1.6.1, OLED on, no power-gating):\n- ~80 mA awake (~250 ms per cycle: sense + LoRa TX, more if cfg_req → +2 s RX window)\n- ~10 µA in deep sleep\n- At 60 s cadence: average ~0.5 mA → a 2000 mAh LiPo lasts months without charge; a small 5 W solar panel + CN3791-style MPPT covers year-round in most climates.\n\n## Home Assistant integration\n\nNative MQTT auto-discovery: entities appear in HA on the first packet from\nany new node.\n\n### Cuve emitter device\n\n- **Primary entities**: `tank_pct` (% filled), `water_temp_c` (°C)\n- **Diagnostic entities**: `tank_cm` (raw distance, may be `null` = full),\n  `rssi`, `snr`, `vbat`\n\n### Prises actuator device\n\n- **Switch entities (primary)**: `Prise 1`, `Prise 2` (`device_class: outlet`) —\n  toggle from HA, state reflects the actuator's confirmed relay state with\n  optimistic intermediate updates\n- **Diagnostic entities**: `vbat`, `rssi`, `snr`\n\n### Flower Care plant sensors\n\nUp to 3 HHCC JCY01HHCC sensors are supported. Each appears as a separate HA\ndevice (\"Flower Care \\\u003cname\\\u003e\") with 5 entities:\n\n| Entity | Unit | Key |\n|---|---|---|\n| Soil temperature | °C | `soil_temp_c` |\n| Soil moisture | % | `soil_moisture` |\n| Light intensity | lux | `soil_light` |\n| Soil conductivity | µS/cm | `soil_cond` |\n| Battery | % | `soil_bat` |\n\nState topic: `jardin/flowercare/\u003cmac-lowercase-nocolon\u003e/state` (retained).\nAvailability tied to the cuve emitter (`jardin/cuve/availability`).\n\n#### Configuring sensors from HA\n\nPublish a retained JSON array to `jardin/config/flowercare_sensors`. The\ngateway stores it immediately and pushes the MAC list to the emitter on its\nnext `cfg_req` round-trip.\n\n```bash\nmosquitto_pub -r -t jardin/config/flowercare_sensors \\\n  -m '[{\"mac\":\"AA:BB:CC:DD:EE:FF\",\"name\":\"Tomate\"},{\"mac\":\"11:22:33:44:55:66\",\"name\":\"Basilic\"}]'\n```\n\n- MACs in uppercase colon-separated format (visible in nRF Connect or similar BLE scanner)\n- `name`: max 16 chars, displayed (truncated to 5-8 chars) on the gateway OLED\n- Max 3 sensors (LoRa packet budget)\n- Remove all sensors: publish `[]`\n\nTo add sensors from the HA UI without a terminal, create a script in\n`configuration.yaml`:\n\n```yaml\nscript:\n  set_flowercare_sensors:\n    alias: \"Configurer capteurs Flower Care\"\n    fields:\n      sensors_json:\n        description: 'Ex: [{\"mac\":\"AA:BB:CC:DD:EE:FF\",\"name\":\"Tomate\"}]'\n        example: '[{\"mac\":\"AA:BB:CC:DD:EE:FF\",\"name\":\"Tomate\"}]'\n    sequence:\n      - action: mqtt.publish\n        data:\n          topic: \"jardin/config/flowercare_sensors\"\n          payload: \"{{ sensors_json }}\"\n          retain: true\n```\n\nThen run it from **Developer Tools \u003e Scripts \u003e set_flowercare_sensors**.\n\n#### Deployment order (first setup)\n\n1. Flash the gateway\n2. Publish the MAC list via MQTT (above)\n3. Flash the emitter\n4. On first boot, the emitter sends `cfg_req=1` → gateway replies with MAC\n   list → emitter stores in NVS → starts polling\n\nThe gateway OLED cycles through each sensor's readings every 5 s (shown as\n`\u003cname\u003e X/N` then temp/moisture/light/conductivity lines).\n\n### Jardin Gateway device (config)\n\n- `Tank empty distance` (number, 0-200 cm)\n- `Tank full distance` (number, 0-200 cm)\n- `Cuve TX interval` (number, 5-3600 s) — pushed to the emitter on its next\n  `cfg_req` round-trip, persisted in NVS\n- `Cuve boot delay` (number, ms) — delay between sensor power-on and first measurement\n- `Cuve OLED display` (number, ms) — how long the OLED stays on after a boot wake\n- `Cuve ultrasonic retries` (number) — ping retry count for tank distance\n- `Cuve temp retries` (number) — DS18B20 retry count\n\n### Availability\n\n- Per-node: `jardin/\u003cnode\u003e/availability` — goes `offline` after 3 min without a packet\n- Gateway: MQTT LWT on `jardin/gateway/availability`\n- If the emitter dies → all cuve entities go unavailable\n- If the DS18B20 is missing but the emitter works → only `water_temp_c` goes unavailable\n\n### Suggested automations\n\n- \"Tank low\" notification on `tank_pct \u003c 20`\n- \"Tank full\" notification on `tank_pct \u003e= 100` (transition)\n- \"Emitter / actuator offline\" alert\n- Temperature alert (frost `\u003c2°C`, indicative overheat `\u003e30°C`)\n\n## Repo structure\n\n```\n.\n├── README.md\n├── CLAUDE.md                   rules for Claude Code\n├── LICENSE                     GPL v3\n├── platformio.ini              all firmware envs\n├── secrets.example.ini         template to copy to secrets.ini (gitignore)\n├── include/\n│   ├── auth.h                  HMAC-SHA256 + verify (header-only, all targets)\n│   ├── lora_board.h            RadioLib init + helpers (SX1276 / SX1262)\n│   └── wdt.h                   watchdog abstraction (ESP32 + STM32 no-op stubs)\n├── cuve-emitter/src/           firmware: tank emitter (garden, ESP32)\n├── gateway/src/                firmware: gateway (house, ESP32)\n├── prises-actuator/src/        firmware: relay actuator (garden, ESP32)\n├── prises-actuator-dx-lr30/src/ firmware: relay actuator (garden, STM32F103)\n└── hardware/                   wiring schematics, enclosure STLs (TODO)\n```\n\n## Setup\n\n```bash\npip install platformio\n\n# 1. Configure secrets (WiFi, MQTT, shared LoRa key)\ncp secrets.example.ini secrets.ini\n\n# 2. Generate a random key for LoRa auth, then edit secrets.ini\nopenssl rand -hex 32\n# paste this value into the [lora] section of secrets.ini, between the \\\"...\\\"\n\n# 3. Also edit the [wifi] and [mqtt] sections with your real credentials\n\n# Tank emitter (continuous or battery/solar variant)\npio run -e cuve-emitter\npio run -e cuve-emitter -t upload\npio device monitor -e cuve-emitter\n# pio run -e cuve-emitter-battery -t upload   # deep-sleep variant\n\n# Gateway\npio run -e gateway\npio run -e gateway -t upload\npio device monitor -e gateway\n\n# Relay actuator — ESP32 variant\npio run -e prises-actuator\npio run -e prises-actuator -t upload\npio device monitor -e prises-actuator\n\n# Relay actuator — DX-LR30 (STM32F103) variant\n# Adjust upload_port in platformio.ini to match your /dev/cu.wchusbserial* device.\n# Two-step flash (stm32flash -i resets to bootloader, second call writes):\npio run -e prises-actuator-dx-lr30\nstm32flash -i rts,-dtr,dtr:-rts,-dtr,dtr -b 115200 /dev/cu.wchusbserial10\nstm32flash -b 115200 -w .pio/build/prises-actuator-dx-lr30/firmware.bin \\\n           -v /dev/cu.wchusbserial10\npio device monitor -e prises-actuator-dx-lr30\n```\n\nWithout a complete `secrets.ini`, the build fails with clear messages\n(`#error WIFI_SSID undefined`, `#error MQTT_HOST undefined`, `#error\nLORA_PSK undefined`).\n\n\u003e Important: **emitter and gateway must share the same `LORA_PSK`**, otherwise the gateway will drop every packet (`HMAC invalid, drop ...` on the serial monitor). If you change the key, reflash both.\n\n## Roadmap\n\n### Software (done)\n- [x] `emitter` skeleton (JSN-SR04T as JSON LoRa every 1s)\n- [x] `gateway` skeleton (LoRa RX → serial log)\n- [x] Wi-Fi + MQTT on the gateway\n- [x] MQTT discovery for Home Assistant (diagnostic vs primary entities)\n- [x] Soft watchdog (reboot if Wi-Fi/MQTT down \u003e 5 min)\n- [x] Link loss detection (`NODE_TIMEOUT_MS`, availability)\n- [x] raw (emitter) / derived (gateway) split\n- [x] Contextual OLED on emitter AND gateway (with graceful degradation if absent)\n- [x] DS18B20 stainless steel waterproof probe (1-Wire async, factory-calibrated, °C direct)\n- [x] Runtime calibration via HA `number` entities (TANK_EMPTY/FULL_DISTANCE_CM)\n- [x] \"null = full\" rule + 3 s debounce to avoid flicker\n- [x] **HMAC-SHA256 + anti-replay seq** on the LoRa link (auth + integrity)\n- [x] Bidirectional LoRa: emitter pulls `tx_interval_s` from gateway via `cfg_req`/ack, persists in NVS\n- [x] Deep-sleep mode for battery/solar deployment (build flag `WITH_DEEP_SLEEP=1`)\n- [x] **Relay actuator** (ESP32 + STM32/DX-LR30 variants): LoRa command/heartbeat, NVS/EEPROM persistence, per-channel polarity, HA switch entities\n- [x] Gateway optimistic relay publish + timeout retry (`RELAY_CMD_RETRY_MS=2500ms`) for reliable HA reactivity\n- [x] Migrated radio stack from sandeepmistry/LoRa to jgromes/RadioLib (SX1276 + SX1262 support)\n- [x] **CSMA/CAD**: `loraTx()` wrapper in `lora_board.h` — pre-TX channel scan + random backoff before every transmit across all firmwares\n- [x] **Actuator restore on reboot**: `restore_req:1` in first heartbeat; gateway re-applies state per **Power-on behavior** select entity (`previous` / `on` / `off` / `toggle`)\n- [x] HA switch `device_class: outlet` for relay switch entities\n- [x] **Flower Care BLE** (HHCC JCY01HHCC): up to 3 sensors, MAC list configurable at runtime via MQTT/HA (no reflash), per-sensor HA device + 5 entities, gateway OLED cycling display, MAC list pushed to emitter via signed `cfg_req` LoRa reply\n\n### Software (possibly later)\n- [ ] AES-128 on the LoRa payload for confidentiality (HMAC alone does not encrypt)\n- [ ] Time smoothing on the gateway side (moving average over the last N valid `tank_cm`)\n- [ ] HA `number` for thermal offset if drift observed vs reference\n\n### Hardware\n- [x] Emitter wiring documented (LILYGO T3 V1.6.1 + SR04M-2 + voltage divider + 22 µF)\n- [x] DS18B20 wiring documented (1-Wire + 4.7 kΩ pull-up)\n- [x] Actuator wiring documented (relay module, ESP32 + DX-LR30 pinout)\n- [ ] 3D enclosure for emitter (IP65, cable glands for power + sensor exits)\n- [ ] 3D enclosure for gateway (indoor)\n- [ ] 3D enclosure for actuator\n- [ ] House ↔ garden range tests\n\n### Doc\n- [ ] Photos of the final build\n- [ ] Tank calibration (cm ↔ liters depending on geometry)\n\n## License\n\n[GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html) — see `LICENSE`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgre%2Fjardin-sensors","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgre%2Fjardin-sensors","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgre%2Fjardin-sensors/lists"}