From ad9c457ea7d42bf0d3a7d65494c548b4bc808e95 Mon Sep 17 00:00:00 2001 From: matt_ed Date: Fri, 14 Aug 2026 13:38:41 -0700 Subject: [PATCH] Initial firmware for ESP32-S3 six-channel soil moisture node Implements ESP32_Soil_Moisture_Node_PRD.md on the VCC-GND Studio YD-ESP32-S3 (ESP32-S3-WROOM-1) using Arduino via PlatformIO, publishing to Home Assistant over MQTT with auto-discovery. Two power profiles behind a compile-time switch (PRD 4): -e mains always on, publishes every 60s -e battery RTC wake, sample, publish, deep sleep 15min PRD mitigations: - All six probes on ADC1 (GPIO4-9); ADC2 is unusable while Wi-Fi is up. - analogReadMilliVolts() for eFuse ADC calibration rather than raw counts. - 16 samples per probe, sorted, 10% trimmed from each end, averaged. - Per-probe two-point calibration; HW-390 output is inverted (dry reads high), and an inverted config is rejected rather than silently producing a backwards curve. - MAC-derived node ID so identical binaries never collide on topics. Design decisions that diverge from a literal reading of the PRD: - Out-of-range probes publish null (HA "unknown") instead of clamping to 0%/100%. A cut Cat5 pair reads ~0mV, which clamps to a believable "very dry" and would drive irrigation on wrong data. - The battery profile uses expire_after rather than an availability topic; a sleeping node is offline most of the time and would sit greyed out. - AWAKE_BUDGET_MS forces deep sleep if a wake cycle overruns, covering failure modes the per-step network timeouts miss (DHCP never issued, CONNACK never sent) that would otherwise flatten the cell. - docs/WIRING.md notes the 18650 cannot feed the probes directly: the XC6206 LDO needs ~3.4V headroom, so a boost converter is required on the switched side of the MOSFET. Both profiles compile clean under -Wall. Not yet run on hardware; pin map, MOSFET circuit and calibration defaults are unverified against a board. Co-Authored-By: Claude --- .gitignore | 6 ++ ESP32_Soil_Moisture_Node_PRD.md | 78 ++++++++++++++ README.md | 138 ++++++++++++++++++++++++ docs/CALIBRATION.md | 79 ++++++++++++++ docs/WIRING.md | 135 ++++++++++++++++++++++++ docs/img/battery-solar.svg | 113 ++++++++++++++++++++ docs/img/firmware-flow.svg | 131 +++++++++++++++++++++++ docs/img/power-switch.svg | 94 +++++++++++++++++ docs/img/rj45-breakout.svg | 174 +++++++++++++++++++++++++++++++ docs/img/system-architecture.svg | 164 +++++++++++++++++++++++++++++ include/config.h | 134 ++++++++++++++++++++++++ include/secrets.h.example | 15 +++ platformio.ini | 52 +++++++++ src/adc_sampler.cpp | 108 +++++++++++++++++++ src/adc_sampler.h | 36 +++++++ src/ha_discovery.cpp | 165 +++++++++++++++++++++++++++++ src/ha_discovery.h | 19 ++++ src/main.cpp | 156 +++++++++++++++++++++++++++ src/net.cpp | 129 +++++++++++++++++++++++ src/net.h | 36 +++++++ src/node_id.cpp | 34 ++++++ src/node_id.h | 23 ++++ src/power.cpp | 108 +++++++++++++++++++ src/power.h | 51 +++++++++ 24 files changed, 2178 insertions(+) create mode 100644 .gitignore create mode 100644 ESP32_Soil_Moisture_Node_PRD.md create mode 100644 README.md create mode 100644 docs/CALIBRATION.md create mode 100644 docs/WIRING.md create mode 100644 docs/img/battery-solar.svg create mode 100644 docs/img/firmware-flow.svg create mode 100644 docs/img/power-switch.svg create mode 100644 docs/img/rj45-breakout.svg create mode 100644 docs/img/system-architecture.svg create mode 100644 include/config.h create mode 100644 include/secrets.h.example create mode 100644 platformio.ini create mode 100644 src/adc_sampler.cpp create mode 100644 src/adc_sampler.h create mode 100644 src/ha_discovery.cpp create mode 100644 src/ha_discovery.h create mode 100644 src/main.cpp create mode 100644 src/net.cpp create mode 100644 src/net.h create mode 100644 src/node_id.cpp create mode 100644 src/node_id.h create mode 100644 src/power.cpp create mode 100644 src/power.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4940835 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio/ +.vscode/ +.claude/ +include/secrets.h +*.bin +*.elf diff --git a/ESP32_Soil_Moisture_Node_PRD.md b/ESP32_Soil_Moisture_Node_PRD.md new file mode 100644 index 0000000..0a55472 --- /dev/null +++ b/ESP32_Soil_Moisture_Node_PRD.md @@ -0,0 +1,78 @@ +# Product Requirements Document (PRD) +**Project:** Scalable ESP32 Soil Moisture Monitoring Node +**Target Audience:** AI Engineering Agents (Firmware, Hardware Design, Home Assistant Integration) + +## 1. System Overview +A modular, distributed soil moisture monitoring system designed to interface up to six HW-390 (v2.0) capacitive soil moisture sensors per local microcontroller node. The system utilizes Cat5 cabling for consolidated power distribution and analog signal collection. Data is gathered by an ESP32-class microcontroller and broadcasted over Wi-Fi for ingestion, recording, and automation by a Home Assistant server. The architecture supports multi-node deployments with conflict-free network identification and accommodates both continuous (mains) and remote (solar/battery) power profiles. + +## 2. Hardware Architecture & Wiring Specifications + +### 2.1 Microcontroller Unit (MCU) +* **Supported Platforms:** ESP32-S3 (Primary), ESP32-C3, ESP32-S2, or standard ESP32. +* **Requirements:** Must have at least 6 accessible ADC (Analog-to-Digital Converter) channels capable of reading 0-3.3V, Wi-Fi capabilities, and deep-sleep support for battery operations. + +### 2.2 Cabling & Breakout +* **Cable Type:** Standard 24 AWG solid-core Cat5/Cat5e cable. +* **Pinout/Pairing Strategy:** + * **Pair 1 (e.g., Orange/Orange-White):** `5V VCC` and `Shared GND`. (Crucial to keep power and ground on the same twisted pair to minimize loop area). + * **Pair 2, 3, 4 (e.g., Green, Blue, Brown pairs):** Assigned to `Analog Signal 1` through `Analog Signal 6`. +* **Termination:** Cat5 cable terminates at an RJ45 receiver/breakout board at the MCU enclosure. + +### 2.3 Sensor Wiring & Protection (HW-390 v2.0) +* **Power:** Sensors are supplied with 5V over the Cat5 cable. The HW-390 v2.0 onboard XC6206 LDO regulates this to a clean 3.3V locally at the sensor. +* **Environmental Protection:** The upper PCB of all HW-390 sensors (components, exposed edges, and spliced connections) MUST be sealed with marine epoxy or conformal coating to prevent rapid outdoor degradation. Only the capacitive prongs remain unsealed. + +## 3. Required Mitigations: Pitfalls & Solutions + +To ensure system stability, subsequent AI agents designing the PCB/breadboard layout and firmware must implement the following solutions. + +### 3.1 Hardware Mitigation: Signal Integrity & EMI +* **Issue:** Cat5 is unshielded and tightly twisted, causing capacitive coupling (crosstalk) and EMI susceptibility for DC analog signals. +* **Solution (Mandatory Hardware):** Implement an RC Low-Pass Filter on the RJ45 receiver board for every analog input line before it reaches the ESP32 ADC pin. + * *Component Values:* 10kΩ inline resistor, 0.1µF to 1µF ceramic capacitor bridging the ADC pin to GND. + +### 3.2 Hardware Mitigation: Ground Bounce +* **Issue:** Six sensors sharing one Cat5 GND wire can cause the ground potential to float, altering analog readings. +* **Solution:** All spliced connections on the sensor end must be soldered/heat-shrunk or secured with high-retention Wago connectors. Limit total sensor draw to ~30mA (5mA per sensor). + +### 3.3 Software Mitigation: ESP32 ADC Non-Linearity & Signal Smoothing +* **Issue:** ESP32 ADCs are non-linear, especially at extreme voltage ranges, and sandy/fast-draining soil creates rapid micro-fluctuations in moisture distribution. +* **Solution (Mandatory Firmware):** + * Employ the ESP32 hardware ADC calibration API. + * **Oversampling:** The firmware must take an array of 10-20 readings per sensor per cycle, discard the highest and lowest 10% (outliers), and average the remaining values. + * **Calibration:** Implement a two-point software calibration curve (Air = 0%, Water = 100%) to map the raw ADC integers to a percentage value. + +## 4. Power Profiles & Management + +The system must support two power topologies, determinable at compilation or runtime configuration: + +### 4.1 Continuous Power (Mains) +* Supplied via 5V USB or dedicated 5V buck converter. +* MCU remains active, broadcasting updates at a set interval (e.g., every 60 seconds). + +### 4.2 Solar / Battery Power (Remote Nodes) +* **Hardware:** Integrates a LiPo/Li-Ion battery (e.g., 18650 cell) and a solar charge controller (e.g., TP4056 with protection circuitry) paired with a 5V/6V solar panel. (Highly compatible with automated solar drip irrigation setups). +* **Firmware Deep Sleep:** + 1. Wake up based on RTC timer (e.g., every 15-30 minutes). + 2. Power on sensors (via a GPIO-controlled MOSFET switching the 5V line to save idle current). + 3. Stabilize (wait 500ms). + 4. Oversample, average, and map ADC readings. + 5. Connect to Wi-Fi, publish MQTT/ESPHome payload. + 6. Return to deep sleep. + +## 5. Network Architecture & Home Assistant Integration + +### 5.1 Multi-Node Identification (Conflict Prevention) +* **Unique Addressing:** Firmware must dynamically generate a unique Node ID based on the ESP32's internal MAC address (e.g., `soil-node-A1B2C3`). +* **Data Tagging:** All broadcasted data payloads must be tagged with this Node ID. + +### 5.2 Communication Protocol +* **Recommended Framework:** ESPHome is the primary recommended framework for seamless, native Home Assistant integration via the ESPHome API. +* **Alternative:** Standard MQTT (via PubSubClient or similar). If using MQTT, the firmware must publish Home Assistant MQTT Auto-Discovery configuration payloads on startup so nodes populate automatically in the HA dashboard without manual YAML configuration. + +### 5.3 Data Payload Requirements +The node must expose the following entities to Home Assistant per cycle: +* `sensor.node_[ID]_moisture_1` through `6` (Value: 0-100%, Unit: %, Device Class: Moisture) +* `sensor.node_[ID]_battery_level` (If using solar profile, mapped from battery voltage via voltage divider) +* `sensor.node_[ID]_wifi_signal` (RSSI) +* `sensor.node_[ID]_uptime` (or last seen timestamp) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0835c8c --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# ESP32-S3 Soil Moisture Node + +Six HW-390 v2.0 capacitive soil probes on one Cat5 run, read by an ESP32-S3, +published to Home Assistant over MQTT with auto-discovery. Multi-node safe. + +Implements [ESP32_Soil_Moisture_Node_PRD.md](ESP32_Soil_Moisture_Node_PRD.md). + +- **Board:** VCC-GND Studio YD-ESP32-S3 (ESP32-S3-DevKitC-1 clone, WROOM-1) +- **Framework:** Arduino via PlatformIO +- **Transport:** MQTT + Home Assistant auto-discovery + +![System architecture](docs/img/system-architecture.svg) + +## Quick start + +```bash +cp include/secrets.h.example include/secrets.h +$EDITOR include/secrets.h # Wi-Fi + broker credentials + +pio run -e mains -t upload -t monitor +``` + +The node appears in Home Assistant under **Settings → Devices → MQTT** as +*Soil Node XXXXXX* within a few seconds. No YAML. + +Then calibrate the probes — see [docs/CALIBRATION.md](docs/CALIBRATION.md). +The shipped calibration constants are placeholders and will read wrong. + +## Build targets + +| Command | Profile | Behaviour | +|---------|---------|-----------| +| `pio run -e mains` | Continuous (PRD §4.1) | Always on, publishes every 60 s | +| `pio run -e battery` | Solar/battery (PRD §4.2) | Wake → sample → publish → deep sleep, 15 min | + +The profile is a compile-time switch; `include/config.h` fails the build if +neither or both are defined. + +![Firmware flow](docs/img/firmware-flow.svg) + +## Pin map + +All six probes sit on **ADC1**. This is forced, not stylistic: ADC2 is owned by +the Wi-Fi radio on the ESP32-S3, and reading an ADC2 pin with the radio up +returns garbage. ADC1 is GPIO1–10, which is exactly enough for six probes plus +a battery divider. + +| Function | GPIO | Channel | +|----------|------|---------| +| Moisture 1–6 | 4, 5, 6, 7, 8, 9 | ADC1_CH3–CH8 | +| Battery divider | 10 | ADC1_CH9 | +| Sensor rail MOSFET gate | 14 | — | +| Status RGB LED | 48 | onboard | + +Avoided on this board: GPIO0/3/45/46 (strapping), GPIO19/20 (native USB), +GPIO33–37 (octal flash/PSRAM on N\*R8 variants), GPIO43/44 (UART0). + +Full harness details, RC filter values and the MOSFET circuit are in +[docs/WIRING.md](docs/WIRING.md). + +## Layout + +``` +include/config.h Pin map, calibration, timing — the file you edit +include/secrets.h Credentials (gitignored; copy from .example) +src/main.cpp Boot flow, sample/publish cycle, sleep +src/adc_sampler.* Oversampling, outlier trim, two-point curve +src/power.* Sensor rail, battery sense, deep sleep, awake budget +src/net.* Wi-Fi + MQTT session, LWT +src/ha_discovery.* HA auto-discovery payloads +src/node_id.* MAC-derived unique node identity +docs/img/*.svg Architecture, schematics and flow diagrams +``` + +## Diagrams + +| Diagram | What it covers | +|---------|----------------| +| [System architecture](docs/img/system-architecture.svg) | End-to-end: probes → Cat5 → breakout → MCU → HA | +| [RJ45 breakout](docs/img/rj45-breakout.svg) | Per-pin schematic with the six mandatory RC filters | +| [High-side P-FET switch](docs/img/power-switch.svg) | Sensor rail switching and the 2N7002 inverting stage | +| [Solar / battery topology](docs/img/battery-solar.svg) | Panel, charger, cell, boost, battery sense divider | +| [Firmware flow](docs/img/firmware-flow.svg) | Both power profiles side by side | + +## How the PRD's mitigations are implemented + +**ADC non-linearity (§3.3).** Readings go through `analogReadMilliVolts()`, +which applies the chip's eFuse ADC calibration, rather than scaling raw counts. + +**Signal smoothing (§3.3).** 16 samples per probe per cycle, spaced 2 ms apart, +sorted, top and bottom 10% discarded, remainder averaged. + +**Two-point calibration (§3.3).** Per-probe air/water endpoints in `config.h`. +Note the HW-390 is **inverted** — dry reads high — so the curve maps +`air → 0%`, `water → 100%`. The firmware refuses an inverted config rather +than silently producing a backwards curve. + +**Multi-node identity (§5.1).** Node ID is derived from the factory MAC, so two +boards flashed with the same binary get distinct topics and distinct HA +entities. Nothing is per-node in the build. + +**Auto-discovery (§5.2).** Retained config topics under `homeassistant/sensor/` +published on startup, so nodes populate the dashboard with no manual YAML. + +## Design decisions worth knowing + +**Faults are reported, not clamped.** A probe reading well outside its +calibrated window is published as `null` → *unknown* in HA, rather than being +clamped to 0% or 100%. A cut Cat5 pair reads ~0 mV, which clamps to a perfectly +believable "very dry" — and believable wrong data is worse than a gap, because +it will drive irrigation. Tune with `SENSOR_FAULT_MARGIN_MV`. + +**Availability is handled differently per profile.** The mains node uses an +MQTT availability topic plus a last-will, so a crash greys out its entities. A +sleeping node is "offline" almost all the time, so that approach would leave it +permanently greyed; the battery profile uses `expire_after` (2.5× the sleep +interval) instead. + +**Discovery is republished only on cold boot.** The config topics are retained, +so the broker replays them to HA on every HA restart. Republishing on each +15-minute wake would be radio time spent for nothing. A cold boot is the one +event that may mean new firmware and a changed entity set. + +**Every wake is guaranteed to end in sleep.** The per-step network timeouts +don't cover an AP that associates but never issues a DHCP lease, or a broker +that completes the TCP handshake but never sends CONNACK. `AWAKE_BUDGET_MS` +arms a one-shot timer before anything that can block, and forces deep sleep if +the cycle overruns. Without it, one misbehaving broker flattens the cell. + +**One state topic, not fourteen.** All entities read from a single retained +JSON payload via `value_template`. On the battery profile this is roughly the +difference between a 3 s and a 6 s wake. + +## Status + +Both build profiles compile clean under `-Wall`. **Not yet run on hardware** — +the pin map, the MOSFET circuit and the calibration defaults are all unverified +against a physical board. Bring it up on mains first with one probe. diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md new file mode 100644 index 0000000..2f0c326 --- /dev/null +++ b/docs/CALIBRATION.md @@ -0,0 +1,79 @@ +# Two-point calibration + +Implements PRD §3.3. Do this **once per probe**, after sealing them (§2.3) and +with the probe connected through its final Cat5 run — the cable and the RC +filter both shift the numbers, so calibrating on the bench with short leads +produces values that are wrong in the field. + +## Why per-probe + +`CAL_AIR_MV` / `CAL_WATER_MV` in `include/config.h` are arrays, not scalars. +HW-390s vary: each has its own XC6206 LDO, its own coating thickness, and its +own cable run. Using one shared pair of endpoints for six probes typically +costs 5–15% of absolute accuracy, which is enough to trigger irrigation on a +bed that is already wet. + +The defaults shipped in `config.h` are placeholders. They compile and produce +plausible-looking output, which makes them dangerous — plausible output is +exactly what stops you noticing you skipped this step. + +## Procedure + +1. Flash the mains build and let the node come up: + ``` + pio run -e mains -t upload -t monitor + ``` + +2. In Home Assistant, find the **Raw mV 1–6** diagnostic entities on the node's + device page. (These exist for this procedure; set `PUBLISH_RAW_MV 0` in + `config.h` to hide them once you are done.) You can also read them straight + off the serial monitor in the `state:` line. + +3. **Air point (0%).** Probe clean, dry, in open air. Wait ~30 s for the value + to settle. Record `raw_mv_N` → this is `CAL_AIR_MV[N-1]`. + +4. **Water point (100%).** Submerge the probe in a glass of water **up to but + not past the marked line** on the PCB. Do not submerge the electronics. + Wait ~30 s. Record `raw_mv_N` → this is `CAL_WATER_MV[N-1]`. + +5. Paste both arrays into `include/config.h` and reflash. + +## Expected values + +A healthy 3.3 V HW-390 v2.0 reads roughly: + +| Condition | Typical | +|-----------|---------| +| Air (dry) | 2400–2800 mV | +| Water | 1000–1400 mV | + +**Air must read higher than water** — the HW-390's output is inverted. The +firmware rejects `CAL_AIR_MV <= CAL_WATER_MV` at runtime and logs an error +rather than silently producing an inverted percentage, because a +moisture curve running backwards is very easy to miss on a dashboard and would +tell your irrigation to water dry soil less. + +## Troubleshooting + +**Air-to-water spread under ~800 mV.** Usually a probe that is degrading, or +one whose coating crept onto the prongs. A narrow span makes the percentage +noisy no matter how much you oversample. + +**Reads ~0 mV.** No power reaching the probe: check the Cat5 5 V pair, the +MOSFET gate, and the splice. The firmware reports this as *unknown* rather than +0%, which is the giveaway. + +**Reads ~3300 mV (rail).** Signal line shorted to the 5 V pair, or the probe's +output is disconnected and the ADC pin is floating up. + +**Reading drifts down over weeks with no rain.** Moisture ingress into an +imperfectly sealed board. Re-seal per `WIRING.md` §6 and recalibrate. + +## Fault margin + +`SENSOR_FAULT_MARGIN_MV` (default 400 mV) sets how far outside the calibrated +window a reading may sit before it is reported as unavailable instead of being +clamped. Soil genuinely can read slightly outside the air/water endpoints — +saturated clay can go below the water point — so the margin exists to avoid +false faults. Widen it if healthy probes flap to *unknown*; narrow it if real +failures are being clamped into believable numbers. diff --git a/docs/WIRING.md b/docs/WIRING.md new file mode 100644 index 0000000..1db89b0 --- /dev/null +++ b/docs/WIRING.md @@ -0,0 +1,135 @@ +# Wiring & Breakout + +Implements PRD §2 (cabling) and §3.1–3.2 (signal integrity, ground bounce). + +![System architecture](img/system-architecture.svg) + +## 1. Cat5 pinout + +Standard T568B colours. Power and ground share one twisted pair so the +supply loop area stays small (PRD §2.2). + +| RJ45 pin | Colour | Function | MCU side | +|----------|--------------|---------------------|----------------| +| 1 | Orange/White | **GND** | GND | +| 2 | Orange | **+5V** | Switched 5V rail | +| 3 | Green/White | Analog signal 1 | GPIO4 (ADC1_CH3) | +| 6 | Green | Analog signal 2 | GPIO5 (ADC1_CH4) | +| 4 | Blue | Analog signal 3 | GPIO6 (ADC1_CH5) | +| 5 | Blue/White | Analog signal 4 | GPIO7 (ADC1_CH6) | +| 7 | Brown/White | Analog signal 5 | GPIO8 (ADC1_CH7) | +| 8 | Brown | Analog signal 6 | GPIO9 (ADC1_CH8) | + +**Known tradeoff:** six signals over three pairs means each pair carries two +*signals* rather than a signal and its return, which is the worst case for +pair-to-pair crosstalk. It is acceptable here only because these are slow DC +levels and the RC filters below roll off everything above a few hertz. If you +ever repurpose this harness for anything with edges in it, re-plan the pairing. + +## 2. RC low-pass filter — mandatory (PRD §3.1) + +![RJ45 breakout schematic](img/rj45-breakout.svg) + +One per analog line, on the RJ45 breakout board, **between the jack and the +ESP32 pin**: + +``` +RJ45 signal ──[ 10 kΩ ]──┬── GPIO (ADC1) + │ + [ 0.1 µF ] ceramic, X7R + │ + GND +``` + +Placement is the whole point: the capacitor must sit at the *ESP32 end* of the +resistor, physically close to the pin. Put it at the jack end and you have +filtered the cable but left the ADC pin exposed to everything picked up on the +last few centimetres of board trace. + +- 10 kΩ + 0.1 µF → τ = 1 ms. Fine for soil, which moves over minutes. +- 10 kΩ + 1 µF → τ = 10 ms. Quieter; still far faster than the sample burst. + +The series resistor also raises source impedance above what the SAR ADC would +like. The capacitor is what fixes that — it supplies the sampling charge — so +do not fit the resistor without it. + +## 3. Ground integrity (PRD §3.2) + +- Every splice at the sensor end: soldered + heat-shrink, or a high-retention + Wago. Twist-and-tape connections corrode and drift the ground reference. +- Budget ~5 mA per HW-390, so ~30 mA total. One 24 AWG conductor carries this + with negligible drop over a normal garden run. +- Run **one** GND back to a single point at the MCU board. Do not daisy-chain + sensor grounds through each other — that is exactly the shared-return path + that makes one probe's current show up as another probe's reading. + +## 4. Sensor power switch (battery profile) + +![High-side P-FET switch](img/power-switch.svg) + +`SENSOR_POWER_GATE_PIN` (GPIO14) is `HIGH` for *sensors on*. A 3.3 V GPIO +cannot drive a P-channel MOSFET's gate on a 5 V rail hard enough to fully turn +it off, so an inverting stage is required: + +``` + +5V ──────────┬──────────────┬─── source + │ │ + [100kΩ] [ P-ch MOSFET ] e.g. AO3401 + │ │ + GPIO14 ──[10kΩ]──┤ gate └─── drain ──→ Cat5 pin 2 (+5V to sensors) + │ + [ 2N7002 N-ch ] drain ──┘ + │ + GND +``` + +- GPIO14 HIGH → 2N7002 conducts → P-FET gate pulled low → rail **on**. +- GPIO14 LOW **or floating** → 100 kΩ pulls the gate to 5 V → rail **off**. + +The pull-up is what makes deep sleep safe: pins float during sleep, and this +guarantees a floating pin means *off* rather than *undefined*. + +## 5. Battery & solar (PRD §4.2) + +![Solar and battery topology](img/battery-solar.svg) + +**The 18650 cannot feed the sensors directly.** A cell runs 3.0–4.2 V, and the +HW-390's XC6206 LDO needs roughly 3.4 V of headroom to hold a clean 3.3 V. Wire +the cell straight to the Cat5 5 V pair and the probes brown out and drift as +the cell discharges — readings that look like the soil drying out. Put a +**boost converter to 5 V on the switched side** of the MOSFET. + +``` +Solar panel (6V) ──→ TP4056 (with DW01 protection) ──→ 18650 + │ + ├──→ ESP32-S3 5V/VIN + │ + └──→ [P-FET] ──→ boost 5V ──→ Cat5 pin 2 +``` + +Battery sense divider into GPIO10: + +``` +VBAT ──[ R1 1MΩ ]──┬── GPIO10 (ADC1_CH9) + │ + [ R2 1MΩ ] ┬── [1µF] + │ │ + GND GND +``` + +1 MΩ/1 MΩ draws ~2 µA, which is the right order next to the ESP32's ~10 µA +deep-sleep current. The obvious 100 kΩ/100 kΩ pair draws ~21 µA — more than the +sleeping MCU, and enough to dominate your winter power budget. The 1 µF cap is +not optional at this impedance: it supplies the ADC's sampling charge. + +`BATTERY_DIVIDER_RATIO = 2.0` in `include/config.h` assumes R1 == R2. Measure +the actual cell voltage with a meter and adjust the ratio to trim out resistor +tolerance. + +## 6. Sensor sealing (PRD §2.3) + +Every HW-390's upper PCB — components, exposed board edges, and the splice — +sealed with marine epoxy or conformal coating. **Only the capacitive prongs +stay bare.** This is the single most common reason these probes die in a +season; an unsealed board wicks moisture up under the silkscreen and the +readings drift long before anything looks visibly corroded. diff --git a/docs/img/battery-solar.svg b/docs/img/battery-solar.svg new file mode 100644 index 0000000..3152cb8 --- /dev/null +++ b/docs/img/battery-solar.svg @@ -0,0 +1,113 @@ + + Solar and battery power topology with boost converter and battery sense divider + + + + Solar / battery topology — remote node + The cell cannot feed the probes directly: an 18650 runs 3.0–4.2V and the HW-390's XC6206 LDO needs ~3.4V of headroom. + + + + Solar panel + 6V / 1–2W + sized for winter, not + for a sunny afternoon + + + + + TP4056 + DW01 + CC/CV charger + protection variant only + — no MPPT + + + + + 18650 Li-Ion + 3.0 – 4.2 V + VBAT node + + + + + + + + + ESP32-S3 · 5V/VIN + onboard reg accepts VBAT + deep sleep ≈ 10 µA + + + Boost converter → 5V + on the SWITCHED side of + the P-FET, not before it + + + + P-FET → Cat5 pin 2 + see power-switch.svg + + + + + Battery sense divider + + + R1 1 MΩ + + + + + GPIO10 + ADC1_CH9 + + + + + + + + + 1 µF + + + R2 1 MΩ + + + + + + Why 1 MΩ and not the usual 100 kΩ. + A 100 kΩ/100 kΩ divider draws ~21 µA continuously — more than the sleeping MCU itself (~10 µA), and enough to dominate + the winter power budget. 1 MΩ/1 MΩ draws ~2 µA. The 1 µF cap is not optional at that impedance: it supplies the ADC's sampling charge. + Trim BATTERY_DIVIDER_RATIO in config.h against a meter to cancel resistor tolerance. + + + + + + + + + + + + + + + diff --git a/docs/img/firmware-flow.svg b/docs/img/firmware-flow.svg new file mode 100644 index 0000000..320e112 --- /dev/null +++ b/docs/img/firmware-flow.svg @@ -0,0 +1,131 @@ + + Firmware control flow for the mains and battery power profiles + + + + Firmware flow — one compile-time switch, two shapes + POWER_PROFILE_MAINS keeps setup() short and does the work in loop(). POWER_PROFILE_BATTERY does everything in setup() and never reaches loop(). + + + + + + -e mains · continuous power + + + boot → setup() + + + + power::begin() + gate HIGH, rail stays on + + + + node_id::begin() + MAC → soil-node-a1b2c3 + + + + Wi-Fi + MQTT connect + LWT → availability topic + + + + publish discovery + retained, homeassistant/ + + + + loop() + + + + every 60 s: sample + + publish state JSON + + + + repeat + + + + on drop: + reconnect + + re-announce + + + -e battery · solar / 18650 + + + RTC wake → setup() + + + + armAwakeBudget() + 30 s ceiling on the cycle + + + + sensorsOn() + settle + MOSFET on, wait 500 ms + + + + Wi-Fi + MQTT connect + expire_after, no LWT + + + + cold boot? → discovery + skipped on RTC wake + + + + oversample + publish + 16 reads, trim 10%, + map, one JSON publish + + + + flush → sensorsOff() + → deep sleep 15 min + loop() never runs + + + + RTC timer + wake + + + + network down → sleep early + + + + The awake budget is armed before anything that can block. + Per-step timeouts don't cover an AP that associates but never issues a DHCP lease, or a broker that completes TCP but never + sends CONNACK. Without the ceiling, one bad night flattens the cell. + + + + + + + + + + diff --git a/docs/img/power-switch.svg b/docs/img/power-switch.svg new file mode 100644 index 0000000..9d0a46f --- /dev/null +++ b/docs/img/power-switch.svg @@ -0,0 +1,94 @@ + + High-side P-channel MOSFET switch for the 5V sensor rail, driven through a 2N7002 inverting stage + + + + Switched 5V sensor rail — high-side P-FET + A 3.3V GPIO cannot turn a P-FET on a 5V rail fully off. The 2N7002 inverting stage is what makes it work. + + + + +5V + from buck (mains) or boost converter (battery) + + + + + + 100 kΩ + holds gate off + + + + + P-channel + AO3401 + or IRLML6402 + S ▲ G ◀ D ▼ + + + + + S + + + + + G + + + + D + +5V to Cat5 pin 2 + sensors + battery-side boost load + + + + + N-channel + 2N7002 + logic-level + D ▲ G ◀ S ▼ + D + + + + + 10 kΩ + + GPIO14 + G + + + + S + + + + GND + common star point with Cat5 pin 1 + + + + GPIO14 HIGH → 2N7002 conducts → gate pulled low → rail ON · GPIO14 LOW or FLOATING → 100 kΩ pulls gate to 5V → rail OFF + + + + + + + + + diff --git a/docs/img/rj45-breakout.svg b/docs/img/rj45-breakout.svg new file mode 100644 index 0000000..1c286da --- /dev/null +++ b/docs/img/rj45-breakout.svg @@ -0,0 +1,174 @@ + + RJ45 breakout schematic with six RC low-pass filters feeding ESP32-S3 ADC1 pins + + + + RJ45 breakout — RC low-pass per analog line + PRD §3.1, mandatory. One filter per signal, sited at the ESP32 end of the series resistor. + + + + RJ45 + T568B + + + wire + series R + shunt C + to MCU + + + 1 + + + + Orange/White + GND + star point + + + 2 + + + Orange + +5V + switched rail + + + + 3 + + + Green/White + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO4 + ADC1_CH3 · moisture 1 + + + 4 + + Blue + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO6 + ADC1_CH5 · moisture 3 + + + 5 + + + Blue/White + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO7 + ADC1_CH6 · moisture 4 + + + 6 + + Green + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO5 + ADC1_CH4 · moisture 2 + + + 7 + + + Brown/White + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO8 + ADC1_CH7 · moisture 5 + + + 8 + + Brown + + + 10 kΩ + + + + + + + + 0.1 µF + GPIO9 + ADC1_CH8 · moisture 6 + + + + Placement matters more than the values. + The capacitor must sit at the ESP32 end of the resistor, close to the pin — it is what supplies the ADC's sampling charge. + Fitting the 10 kΩ without the cap leaves source impedance worse than having no filter at all. + + + + + + + + + diff --git a/docs/img/system-architecture.svg b/docs/img/system-architecture.svg new file mode 100644 index 0000000..d75192b --- /dev/null +++ b/docs/img/system-architecture.svg @@ -0,0 +1,164 @@ + + System architecture: six HW-390 probes over Cat5 to an ESP32-S3, published to Home Assistant over MQTT + + + + Soil Moisture Node — system architecture + Up to 6 HW-390 v2.0 probes per node · one Cat5 run · multi-node safe via MAC-derived ID + + + Field sensors + + + HW-390 #1sealed · 5mA + + HW-390 #2sealed · 5mA + + HW-390 #3sealed · 5mA + + HW-390 #4sealed · 5mA + + HW-390 #5sealed · 5mA + + HW-390 #6sealed · 5mA + + Each: XC6206 LDO 5V→3.3V on-board. + Upper PCB epoxy-sealed; prongs bare. + + + Cat5 24AWG + 8 conductors · T568B + + + + + + + + + + + RJ45 breakout + at MCU enclosure + + + 6× RC low-pass + 10 kΩ series + 0.1 µF to GND + at the ADC pin + MANDATORY §3.1 + + + Star ground + single point, no + daisy-chaining + + pin 2 → +5V (switched) + pin 1 → GND + pins 3-8 → 6 signals + + + + + + + ESP32-S3 + YD-ESP32-S3 (WROOM-1) + + + ADC1 only + ADC2 is owned by Wi-Fi + GPIO4-9 moisture 1-6 + GPIO10 battery÷2 + GPIO14 rail MOSFET + GPIO48 status LED + + + Firmware + 16× oversample + trim 10% outliers + eFuse ADC cal + 2-point curve → % + + + + Wi-Fi + MQTT + + + + MQTT broker + Mosquitto + soil/<node>/state + retained JSON + + + + + Home Assistant + auto-discovery, no YAML + moisture 1-6 % + battery level % + wifi signal dBm + uptime s + + + + + Power source + mains: 5V USB / buck + remote: 18650 + solar + → boost to 5V + + + + + Switched 5V rail + P-FET high-side + HIGH = sensors on + floating = off + + + + + + GPIO14 gate + + + + analog signal + + power + + network / data + + control + + + + + + + + + + + + + diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..e187d18 --- /dev/null +++ b/include/config.h @@ -0,0 +1,134 @@ +#pragma once + +#include + +// =========================================================================== +// Compile-time power profile (PRD 4) +// Exactly one of these is set by the PlatformIO environment. See platformio.ini. +// =========================================================================== +#if defined(POWER_PROFILE_MAINS) && defined(POWER_PROFILE_BATTERY) +#error "Define only one of POWER_PROFILE_MAINS / POWER_PROFILE_BATTERY" +#endif +#if !defined(POWER_PROFILE_MAINS) && !defined(POWER_PROFILE_BATTERY) +#error "No power profile selected. Build with -e mains or -e battery." +#endif + +// =========================================================================== +// Pin map -- VCC-GND Studio YD-ESP32-S3 (ESP32-S3-WROOM-1) +// +// Every sensor input sits on ADC1. This is not a style choice: ADC2 is owned +// by the Wi-Fi radio on the ESP32-S3, and analogRead() on an ADC2 pin returns +// garbage (or blocks) whenever the radio is active. ADC1 is GPIO1..GPIO10, +// which is exactly enough for 6 sensors + a battery divider. +// +// Also avoided on this board: +// GPIO0 strapping / BOOT button +// GPIO3 strapping (JTAG source select) +// GPIO19/20 native USB D-/D+ +// GPIO33..37 octal flash/PSRAM on N*R8 variants +// GPIO43/44 UART0 TX/RX (serial monitor) +// GPIO45/46 strapping (VDD_SPI / boot log) +// =========================================================================== +constexpr uint8_t SENSOR_COUNT = 6; + +// Sensor 1..6 -> ADC1_CH3..CH8 +constexpr uint8_t SENSOR_ADC_PINS[SENSOR_COUNT] = {4, 5, 6, 7, 8, 9}; + +// Battery sense via 2:1 divider -> ADC1_CH9. Battery profile only. +constexpr uint8_t BATTERY_ADC_PIN = 10; + +// Gate of the P-channel MOSFET (or high-side load switch) feeding the 5V +// sensor rail. HIGH = sensors powered. Battery profile only; on mains the +// rail is simply left on. Needs an external pulldown/pullup so the rail is +// OFF while the MCU is in deep sleep and the pin is floating. +constexpr uint8_t SENSOR_POWER_GATE_PIN = 14; + +// Onboard addressable RGB LED. Used only for coarse boot/error feedback. +constexpr uint8_t STATUS_LED_PIN = 48; + +// =========================================================================== +// ADC sampling & smoothing (PRD 3.3) +// =========================================================================== + +// Readings taken per sensor per cycle, before outlier rejection. +constexpr uint8_t OVERSAMPLE_COUNT = 16; + +// Fraction discarded from each end of the sorted sample set (10% per PRD). +// At 16 samples this trims 1 from the top and 1 from the bottom. +constexpr uint8_t OVERSAMPLE_TRIM_PERCENT = 10; + +// Microseconds between individual reads within one sensor's burst. Spreads +// the burst across mains-hum periods rather than sampling one point on it. +constexpr uint32_t OVERSAMPLE_DELAY_US = 2000; + +// Time for the 5V rail and the sensors' XC6206 LDOs to settle after the +// MOSFET turns on (PRD 4.2 step 3). +constexpr uint32_t SENSOR_STABILIZE_MS = 500; + +// =========================================================================== +// Two-point calibration (PRD 3.3) +// +// HW-390 output is INVERTED: dry/air reads HIGH, submerged reads LOW. +// So AIR_MV > WATER_MV, and 0% is at AIR_MV. +// +// Per-sensor values because each probe's LDO and coating differ. Run +// `docs/CALIBRATION.md` once per probe and paste the numbers here. +// The defaults below are typical for a 3.3V HW-390 v2.0 and WILL be wrong +// for your probes -- they exist so a fresh build compiles and reads +// plausibly, not so you can skip calibration. +// =========================================================================== +constexpr uint16_t CAL_AIR_MV[SENSOR_COUNT] = {2600, 2600, 2600, 2600, 2600, 2600}; +constexpr uint16_t CAL_WATER_MV[SENSOR_COUNT] = {1200, 1200, 1200, 1200, 1200, 1200}; + +// A probe reading outside [WATER_MV - margin, AIR_MV + margin] is reported as +// unavailable rather than clamped -- that pattern means a cut Cat5 pair, a +// dead LDO, or a probe pulled out of the ground, and silently clamping it to +// 0%/100% would hide a fault behind a plausible number. +constexpr uint16_t SENSOR_FAULT_MARGIN_MV = 400; + +// =========================================================================== +// Battery monitoring (battery profile only) +// =========================================================================== + +// Divider ratio: Vbat --[R1]--+--[R2]-- GND, ADC on the junction. +// With R1 = R2 = 100k the measured voltage is half the battery voltage. +constexpr float BATTERY_DIVIDER_RATIO = 2.0f; + +// Li-Ion 18650 discharge window used for the 0-100% mapping. +constexpr uint16_t BATTERY_EMPTY_MV = 3300; +constexpr uint16_t BATTERY_FULL_MV = 4200; + +// =========================================================================== +// Timing +// =========================================================================== +#if defined(POWER_PROFILE_MAINS) +// Publish cadence while awake (PRD 4.1). +constexpr uint32_t PUBLISH_INTERVAL_MS = 60UL * 1000UL; +#else +// RTC wake interval (PRD 4.2). 15 minutes. +constexpr uint64_t DEEP_SLEEP_INTERVAL_S = 15ULL * 60ULL; + +// Hard ceiling on one wake cycle. If Wi-Fi or the broker is unreachable we +// sleep anyway instead of sitting at ~80mA until the cell is flat. +constexpr uint32_t AWAKE_BUDGET_MS = 30UL * 1000UL; +#endif + +constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 15UL * 1000UL; +constexpr uint32_t MQTT_CONNECT_TIMEOUT_MS = 10UL * 1000UL; + +// =========================================================================== +// MQTT / Home Assistant +// =========================================================================== + +// Prefix HA listens on for auto-discovery. Matches the HA MQTT integration's +// "discovery prefix" setting; only change it if you changed it there. +#define HA_DISCOVERY_PREFIX "homeassistant" + +// Root of this project's own state topics. +#define MQTT_TOPIC_ROOT "soil" + +// Publish raw millivolts alongside the percentages as diagnostic entities. +// Leave on: these are what you read off the HA dashboard when calibrating. +#define PUBLISH_RAW_MV 1 + +#define FIRMWARE_VERSION "1.0.0" diff --git a/include/secrets.h.example b/include/secrets.h.example new file mode 100644 index 0000000..2f17ecc --- /dev/null +++ b/include/secrets.h.example @@ -0,0 +1,15 @@ +#pragma once + +// Copy this file to `secrets.h` and fill in your values. +// cp include/secrets.h.example include/secrets.h +// +// `include/secrets.h` is gitignored. Do not commit it, and do not paste its +// contents into a chat transcript or an issue. + +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-wifi-password" + +#define MQTT_HOST "192.168.1.10" // Home Assistant / Mosquitto broker +#define MQTT_PORT 1883 +#define MQTT_USER "soil-node" // "" for an anonymous broker +#define MQTT_PASSWORD "your-mqtt-password" diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..e640011 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,52 @@ +; ESP32-S3 6-channel soil moisture node +; Board: VCC-GND Studio YD-ESP32-S3 (ESP32-S3-DevKitC-1 clone, ESP32-S3-WROOM-1) +; +; Two power profiles, selected by which environment you build: +; pio run -e mains -t upload ; always-on, 5V USB / buck +; pio run -e battery -t upload ; 18650 + TP4056 + solar, deep sleep + +[platformio] +default_envs = mains + +[env] +platform = espressif32@^6.5.0 +board = esp32-s3-devkitc-1 +framework = arduino + +; YD-ESP32-S3 ships in several flash/PSRAM variants (N8, N8R2, N8R8, N16R8). +; These flags target the common N16R8 (16MB flash, 8MB octal PSRAM). +; For an N8 board with no PSRAM, drop the two BOARD_HAS_PSRAM/OPI lines +; and set board_upload.flash_size / board_build.partitions accordingly. +board_build.flash_mode = qio +board_build.partitions = default_16MB.csv +board_upload.flash_size = 16MB + +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder, time + +lib_deps = + knolleary/PubSubClient@^2.8 + bblanchon/ArduinoJson@^7.0.4 + +build_flags = + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + -DCORE_DEBUG_LEVEL=3 + -Wall + +; --------------------------------------------------------------------------- +; Continuous power: MCU stays awake, publishes on a fixed interval. +; --------------------------------------------------------------------------- +[env:mains] +build_flags = + ${env.build_flags} + -DPOWER_PROFILE_MAINS + +; --------------------------------------------------------------------------- +; Solar / battery: RTC-timer wake, sample, publish, deep sleep. +; --------------------------------------------------------------------------- +[env:battery] +build_flags = + ${env.build_flags} + -DPOWER_PROFILE_BATTERY diff --git a/src/adc_sampler.cpp b/src/adc_sampler.cpp new file mode 100644 index 0000000..5a57b3e --- /dev/null +++ b/src/adc_sampler.cpp @@ -0,0 +1,108 @@ +#include "adc_sampler.h" + +namespace adc { +namespace { + +// Insertion sort. n is OVERSAMPLE_COUNT (16 by default), where insertion sort +// beats anything cleverer and costs no extra stack. +void sortAscending(uint16_t *v, uint8_t n) { + for (uint8_t i = 1; i < n; i++) { + const uint16_t key = v[i]; + int8_t j = static_cast(i) - 1; + while (j >= 0 && v[j] > key) { + v[j + 1] = v[j]; + j--; + } + v[j + 1] = key; + } +} + +} // namespace + +void begin() { + analogReadResolution(12); + + for (uint8_t i = 0; i < SENSOR_COUNT; i++) { + // 11dB attenuation gives the full ~0-3.1V usable span. The HW-390's + // output swings across most of its 3.3V rail, so anything less would + // clip the dry end. + analogSetPinAttenuation(SENSOR_ADC_PINS[i], ADC_11db); + } + +#if defined(POWER_PROFILE_BATTERY) + analogSetPinAttenuation(BATTERY_ADC_PIN, ADC_11db); +#endif +} + +uint16_t readMillivolts(uint8_t pin) { + uint16_t samples[OVERSAMPLE_COUNT]; + + for (uint8_t i = 0; i < OVERSAMPLE_COUNT; i++) { + // analogReadMilliVolts() applies the per-chip eFuse ADC calibration + // (esp_adc_cal) internally -- this is the "hardware calibration API" the + // PRD requires. Using analogRead() here and scaling by hand would + // reintroduce the several-percent non-linearity it exists to remove. + samples[i] = static_cast(analogReadMilliVolts(pin)); + delayMicroseconds(OVERSAMPLE_DELAY_US); + } + + sortAscending(samples, OVERSAMPLE_COUNT); + + // Discard the top and bottom OVERSAMPLE_TRIM_PERCENT, at least one sample + // from each end so a single ADC glitch can never reach the mean. + uint8_t trim = (OVERSAMPLE_COUNT * OVERSAMPLE_TRIM_PERCENT) / 100; + if (trim == 0) { + trim = 1; + } + if (2 * trim >= OVERSAMPLE_COUNT) { + trim = (OVERSAMPLE_COUNT - 1) / 2; + } + + uint32_t sum = 0; + const uint8_t kept = OVERSAMPLE_COUNT - (2 * trim); + for (uint8_t i = trim; i < OVERSAMPLE_COUNT - trim; i++) { + sum += samples[i]; + } + + return static_cast(sum / kept); +} + +Reading readSensor(uint8_t index) { + Reading r{0, 0.0f, false}; + if (index >= SENSOR_COUNT) { + return r; + } + + r.millivolts = readMillivolts(SENSOR_ADC_PINS[index]); + + const uint16_t air = CAL_AIR_MV[index]; + const uint16_t water = CAL_WATER_MV[index]; + + // Guard against a config.h where air/water were entered backwards or equal. + // Without this the mapping below divides by zero or silently inverts. + if (air <= water) { + log_e("sensor %u: CAL_AIR_MV (%u) must be > CAL_WATER_MV (%u); HW-390 output is inverted", + index + 1, air, water); + return r; + } + + // A probe should sit between the water and air endpoints. Well outside that + // band means a broken splice, an unpowered probe (reads ~0mV) or a shorted + // signal line -- report it as unavailable rather than clamping it to a + // believable 0% or 100%. + if (r.millivolts + SENSOR_FAULT_MARGIN_MV < water || + r.millivolts > air + SENSOR_FAULT_MARGIN_MV) { + log_w("sensor %u: %umV outside [%u, %u] +/- %umV, flagging unavailable", index + 1, + r.millivolts, water, air, SENSOR_FAULT_MARGIN_MV); + return r; + } + + // Two-point curve. Inverted because the HW-390 reads high when dry. + float pct = 100.0f * (static_cast(air) - static_cast(r.millivolts)) / + (static_cast(air) - static_cast(water)); + r.percent = constrain(pct, 0.0f, 100.0f); + r.valid = true; + return r; +} + +} // namespace adc diff --git a/src/adc_sampler.h b/src/adc_sampler.h new file mode 100644 index 0000000..2370f95 --- /dev/null +++ b/src/adc_sampler.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "config.h" + +// ADC acquisition for the soil probes (PRD 3.3). +// +// Three problems are handled here, in order: +// 1. The ESP32 SAR ADC is non-linear, so raw counts are converted to +// millivolts through the per-chip eFuse calibration rather than by a +// naive count * 3300 / 4095. +// 2. Sandy soil and an unshielded Cat5 run both produce fast noise, so each +// reading is an outlier-trimmed mean of a burst, not a single sample. +// 3. Raw millivolts mean nothing to a user, so a two-point curve maps them +// to 0-100%. +namespace adc { + +struct Reading { + uint16_t millivolts; // trimmed-mean sensor output + float percent; // 0-100, calibrated; only meaningful when valid + bool valid; // false => reading outside plausible range, treat as a fault +}; + +// Configures resolution and attenuation on every sensor pin (and the battery +// pin in the battery profile). Call once after power-up. +void begin(); + +// Samples one probe. `index` is 0-based (sensor 1 is index 0). +Reading readSensor(uint8_t index); + +// Trimmed-mean millivolts on an arbitrary ADC1 pin, no calibration curve +// applied. Used for the battery divider. +uint16_t readMillivolts(uint8_t pin); + +} // namespace adc diff --git a/src/ha_discovery.cpp b/src/ha_discovery.cpp new file mode 100644 index 0000000..4a48f76 --- /dev/null +++ b/src/ha_discovery.cpp @@ -0,0 +1,165 @@ +#include "ha_discovery.h" + +#include + +#include "config.h" +#include "net.h" +#include "node_id.h" + +namespace ha { +namespace { + +// Abbreviated HA discovery keys are used throughout ("stat_t" rather than +// "state_topic"). They are not cosmetic: the long-form equivalent of these +// payloads runs past PubSubClient's buffer once the device block is included. + +void configTopic(char *out, size_t len, const char *objectId) { + snprintf(out, len, HA_DISCOVERY_PREFIX "/sensor/%s/%s/config", node_id::slug(), objectId); +} + +// The identical "dev" block on every entity is what collapses them into one +// device card in HA, rather than N loose sensors. +void addDeviceBlock(JsonDocument &doc) { + JsonObject dev = doc["dev"].to(); + dev["ids"][0] = node_id::slug(); + dev["name"] = node_id::friendlyName(); + dev["mdl"] = "ESP32-S3 6ch Soil Node"; + dev["mf"] = "DIY"; + dev["sw"] = FIRMWARE_VERSION; + dev["cu"] = node_id::macAddress(); +} + +// Shared scaffolding for every sensor entity. +void beginEntity(JsonDocument &doc, const char *objectId, const char *name, const char *valueKey) { + char uniqueId[64]; + snprintf(uniqueId, sizeof(uniqueId), "%s_%s", node_id::slug(), objectId); + + char valueTemplate[64]; + snprintf(valueTemplate, sizeof(valueTemplate), "{{ value_json.%s }}", valueKey); + + doc["name"] = name; + doc["uniq_id"] = uniqueId; + doc["stat_t"] = net::stateTopic(); + doc["val_tpl"] = valueTemplate; + doc["stat_cla"] = "measurement"; + +#if defined(POWER_PROFILE_MAINS) + // An always-on node can be tracked by an availability topic + LWT. + doc["avty_t"] = net::availabilityTopic(); +#else + // A sleeping node is "offline" most of the time, so an availability topic + // would leave the entities greyed out between wakes. Age the values out + // instead: 2.5 sleep intervals without an update means genuinely missing. + doc["exp_aft"] = static_cast(DEEP_SLEEP_INTERVAL_S * 5 / 2); +#endif + + addDeviceBlock(doc); +} + +void publishEntity(JsonDocument &doc, const char *objectId) { + char topic[128]; + configTopic(topic, sizeof(topic), objectId); + + char payload[768]; + const size_t written = serializeJson(doc, payload, sizeof(payload)); + if (written == 0 || written >= sizeof(payload) - 1) { + log_e("discovery: payload for %s did not fit in %u bytes", objectId, sizeof(payload)); + return; + } + + net::publish(topic, payload, true); +} + +} // namespace + +void publishDiscovery() { + log_i("discovery: publishing entities for %s", node_id::slug()); + + for (uint8_t i = 0; i < SENSOR_COUNT; i++) { + char objectId[24]; + char name[24]; + char valueKey[24]; + + snprintf(objectId, sizeof(objectId), "moisture_%u", i + 1); + snprintf(name, sizeof(name), "Moisture %u", i + 1); + snprintf(valueKey, sizeof(valueKey), "moisture_%u", i + 1); + + JsonDocument doc; + beginEntity(doc, objectId, name, valueKey); + doc["unit_of_meas"] = "%"; + doc["dev_cla"] = "moisture"; + doc["ic"] = "mdi:water-percent"; + publishEntity(doc, objectId); + +#if PUBLISH_RAW_MV + // Diagnostic twin of each probe. This is the number you read while + // following docs/CALIBRATION.md, so it is worth the extra topic. + snprintf(objectId, sizeof(objectId), "raw_mv_%u", i + 1); + snprintf(name, sizeof(name), "Raw mV %u", i + 1); + snprintf(valueKey, sizeof(valueKey), "raw_mv_%u", i + 1); + + JsonDocument rawDoc; + beginEntity(rawDoc, objectId, name, valueKey); + rawDoc["unit_of_meas"] = "mV"; + rawDoc["dev_cla"] = "voltage"; + rawDoc["ent_cat"] = "diagnostic"; + publishEntity(rawDoc, objectId); +#endif + } + +#if defined(POWER_PROFILE_BATTERY) + { + JsonDocument doc; + beginEntity(doc, "battery_level", "Battery Level", "battery_level"); + doc["unit_of_meas"] = "%"; + doc["dev_cla"] = "battery"; + doc["ent_cat"] = "diagnostic"; + publishEntity(doc, "battery_level"); + } + { + JsonDocument doc; + beginEntity(doc, "battery_voltage", "Battery Voltage", "battery_mv"); + doc["unit_of_meas"] = "mV"; + doc["dev_cla"] = "voltage"; + doc["ent_cat"] = "diagnostic"; + publishEntity(doc, "battery_voltage"); + } +#endif + + { + JsonDocument doc; + beginEntity(doc, "wifi_signal", "Wi-Fi Signal", "wifi_signal"); + doc["unit_of_meas"] = "dBm"; + doc["dev_cla"] = "signal_strength"; + doc["ent_cat"] = "diagnostic"; + publishEntity(doc, "wifi_signal"); + } + + { + JsonDocument doc; + beginEntity(doc, "uptime", "Uptime", "uptime"); + doc["unit_of_meas"] = "s"; + doc["dev_cla"] = "duration"; + doc["ent_cat"] = "diagnostic"; + // Uptime resets to 0 on reboot, which is what total_increasing expects; + // "measurement" would draw a sawtooth in the statistics graph. + doc["stat_cla"] = "total_increasing"; + publishEntity(doc, "uptime"); + } +} + +void clearDiscovery() { + const char *objectIds[] = {"moisture_1", "moisture_2", "moisture_3", "moisture_4", + "moisture_5", "moisture_6", "raw_mv_1", "raw_mv_2", + "raw_mv_3", "raw_mv_4", "raw_mv_5", "raw_mv_6", + "battery_level", "battery_voltage", "wifi_signal", "uptime"}; + + for (const char *objectId : objectIds) { + char topic[128]; + configTopic(topic, sizeof(topic), objectId); + // An empty retained payload is HA's signal to delete the entity. + net::publish(topic, "", true); + } +} + +} // namespace ha diff --git a/src/ha_discovery.h b/src/ha_discovery.h new file mode 100644 index 0000000..1308bf3 --- /dev/null +++ b/src/ha_discovery.h @@ -0,0 +1,19 @@ +#pragma once + +// Home Assistant MQTT auto-discovery (PRD 5.2 / 5.3). +// +// Publishing these retained config topics is what makes a node appear in HA +// with no YAML edits: HA subscribes to homeassistant/sensor/#, sees the +// config, and creates the entities itself. +namespace ha { + +// Publishes one retained config topic per entity. Safe to call repeatedly -- +// HA treats a repeat as an update of the same unique_id. +void publishDiscovery(); + +// Clears every retained config topic for this node, which removes its device +// and entities from HA. Not called anywhere by default; wire it to a button +// or a serial command if you want to decommission a node cleanly. +void clearDiscovery(); + +} // namespace ha diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ab0490c --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,156 @@ +#include +#include + +#include "adc_sampler.h" +#include "config.h" +#include "ha_discovery.h" +#include "net.h" +#include "node_id.h" +#include "power.h" + +namespace { + +// One JSON object carries every entity's value. A single retained publish per +// cycle costs one round trip instead of ~14, which on the battery profile is +// the difference between a ~3s and a ~6s wake. +void buildStatePayload(char *out, size_t len) { + JsonDocument doc; + + doc["node_id"] = node_id::slug(); + + for (uint8_t i = 0; i < SENSOR_COUNT; i++) { + const adc::Reading r = adc::readSensor(i); + + char key[16]; + snprintf(key, sizeof(key), "moisture_%u", i + 1); + if (r.valid) { + doc[key] = roundf(r.percent * 10.0f) / 10.0f; + } else { + // null renders as "unknown" in HA rather than a plausible-looking 0%. + doc[key] = nullptr; + } + +#if PUBLISH_RAW_MV + snprintf(key, sizeof(key), "raw_mv_%u", i + 1); + doc[key] = r.millivolts; +#endif + } + +#if defined(POWER_PROFILE_BATTERY) + doc["battery_mv"] = power::batteryMillivolts(); + doc["battery_level"] = roundf(power::batteryPercent() * 10.0f) / 10.0f; + // A sleeping node has no meaningful "uptime" -- awake time is a few seconds + // per cycle. Report elapsed time since cold boot, inferred from the wake + // count, so the HA graph still shows how long the node has been in service. + doc["uptime"] = static_cast(power::bootCount() * DEEP_SLEEP_INTERVAL_S); +#else + doc["uptime"] = static_cast(millis() / 1000UL); +#endif + + doc["wifi_signal"] = net::rssi(); + + serializeJson(doc, out, len); +} + +void sampleAndPublish() { + char payload[512]; + buildStatePayload(payload, sizeof(payload)); + + log_i("state: %s", payload); + net::publish(net::stateTopic(), payload, true); +} + +} // namespace + +void setup() { + Serial.begin(115200); + + // The S3's native USB CDC enumerates after boot; without a short grace + // period the first log lines are lost and every boot looks like a crash. + const uint32_t serialDeadline = millis() + 1500; + while (!Serial && millis() < serialDeadline) { + delay(10); + } + + // Rail into a known state before anything else touches the sensor pins. + power::begin(); + node_id::begin(); + + log_i("=== %s (%s) fw %s ===", node_id::friendlyName(), node_id::macAddress(), FIRMWARE_VERSION); + +#if defined(POWER_PROFILE_BATTERY) + log_i("profile: battery | wake %lu | cold boot: %s", power::bootCount(), + power::isColdBoot() ? "yes" : "no"); + + // Arm before anything that can block, so no failure path can outlive it. + power::armAwakeBudget(); + + power::sensorsOn(); + adc::begin(); + + if (!net::connectWifi() || !net::connectMqtt()) { + // Nothing to be done about it from out in the garden -- do not sit here + // burning the cell. Sleep and try again next cycle. + log_e("network unavailable, sleeping early"); + power::deepSleep(); + } + + // Discovery topics are retained, so the broker replays them to HA on every + // HA restart. Republishing on each wake would be pure waste; a cold boot is + // the one time the firmware (and so the entity set) may have changed. + if (power::isColdBoot()) { + ha::publishDiscovery(); + } + + sampleAndPublish(); + + net::disconnect(); + power::deepSleep(); + +#else + log_i("profile: mains | publishing every %lus", PUBLISH_INTERVAL_MS / 1000UL); + + power::sensorsOn(); + adc::begin(); + + net::connectWifi(); + if (net::connectMqtt()) { + ha::publishDiscovery(); + sampleAndPublish(); + } +#endif +} + +void loop() { +#if defined(POWER_PROFILE_MAINS) + static uint32_t lastPublish = millis(); + static bool discoveryPublished = net::isConnected(); + + if (!net::isConnected()) { + if (net::connectWifi() && net::connectMqtt()) { + // The broker may have restarted and dropped its retained set, so + // re-announce rather than assuming HA still knows about this node. + ha::publishDiscovery(); + discoveryPublished = true; + } else { + delay(5000); + return; + } + } + + net::loop(); + + if (millis() - lastPublish >= PUBLISH_INTERVAL_MS) { + lastPublish = millis(); + if (!discoveryPublished) { + ha::publishDiscovery(); + discoveryPublished = true; + } + sampleAndPublish(); + } + + delay(50); +#endif + // Battery profile never reaches loop(): setup() ends in deep sleep, and + // waking re-enters setup() from the top. +} diff --git a/src/net.cpp b/src/net.cpp new file mode 100644 index 0000000..c5e47cc --- /dev/null +++ b/src/net.cpp @@ -0,0 +1,129 @@ +#include "net.h" + +#include + +#include "config.h" +#include "node_id.h" +#include "secrets.h" + +namespace net { +namespace { + +WiFiClient s_wifiClient; +PubSubClient s_mqtt(s_wifiClient); + +char s_stateTopic[64]; +char s_availabilityTopic[64]; +bool s_topicsBuilt = false; + +void buildTopics() { + if (s_topicsBuilt) { + return; + } + snprintf(s_stateTopic, sizeof(s_stateTopic), MQTT_TOPIC_ROOT "/%s/state", node_id::slug()); + snprintf(s_availabilityTopic, sizeof(s_availabilityTopic), MQTT_TOPIC_ROOT "/%s/availability", + node_id::slug()); + s_topicsBuilt = true; +} + +} // namespace + +bool connectWifi() { + if (WiFi.status() == WL_CONNECTED) { + return true; + } + + WiFi.mode(WIFI_STA); + WiFi.setHostname(node_id::slug()); + + // The node has nothing to serve and sleeps most of its life; persisting + // credentials to NVS on every connect just burns flash cycles. + WiFi.persistent(false); + WiFi.setAutoReconnect(true); + + log_i("wifi: associating with %s", WIFI_SSID); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + const uint32_t deadline = millis() + WIFI_CONNECT_TIMEOUT_MS; + while (WiFi.status() != WL_CONNECTED && millis() < deadline) { + delay(100); + } + + if (WiFi.status() != WL_CONNECTED) { + log_e("wifi: association timed out after %ums", WIFI_CONNECT_TIMEOUT_MS); + return false; + } + + log_i("wifi: %s, rssi %d", WiFi.localIP().toString().c_str(), WiFi.RSSI()); + return true; +} + +bool connectMqtt() { + buildTopics(); + + if (s_mqtt.connected()) { + return true; + } + + s_mqtt.setServer(MQTT_HOST, MQTT_PORT); + + // A single HA discovery payload runs well past PubSubClient's 256-byte + // default buffer; publish() silently returns false when the payload does + // not fit, so entities would just never appear in HA. + s_mqtt.setBufferSize(1024); + s_mqtt.setSocketTimeout(MQTT_CONNECT_TIMEOUT_MS / 1000); + + const char *user = (strlen(MQTT_USER) > 0) ? MQTT_USER : nullptr; + const char *pass = (strlen(MQTT_PASSWORD) > 0) ? MQTT_PASSWORD : nullptr; + + log_i("mqtt: connecting to %s:%d as %s", MQTT_HOST, MQTT_PORT, node_id::slug()); + + // Last will: if the node drops off without disconnecting, the broker + // publishes "offline" for us and HA greys out the entities. + const bool ok = s_mqtt.connect(node_id::slug(), user, pass, s_availabilityTopic, 0, true, + "offline"); + if (!ok) { + log_e("mqtt: connect failed, state %d", s_mqtt.state()); + return false; + } + + s_mqtt.publish(s_availabilityTopic, "online", true); + return true; +} + +void loop() { s_mqtt.loop(); } + +bool isConnected() { return WiFi.status() == WL_CONNECTED && s_mqtt.connected(); } + +bool publish(const char *topic, const char *payload, bool retain) { + const bool ok = s_mqtt.publish(topic, payload, retain); + if (!ok) { + log_e("mqtt: publish to %s failed (%u bytes, buffer %u)", topic, strlen(payload), + s_mqtt.getBufferSize()); + } + return ok; +} + +void disconnect() { + // Pump the client so queued bytes reach the socket before we tear it down. + s_mqtt.loop(); + s_mqtt.disconnect(); + s_wifiClient.flush(); + s_wifiClient.stop(); + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); +} + +int8_t rssi() { return static_cast(WiFi.RSSI()); } + +const char *stateTopic() { + buildTopics(); + return s_stateTopic; +} + +const char *availabilityTopic() { + buildTopics(); + return s_availabilityTopic; +} + +} // namespace net diff --git a/src/net.h b/src/net.h new file mode 100644 index 0000000..7a254d8 --- /dev/null +++ b/src/net.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +// Wi-Fi association and MQTT session management. +namespace net { + +// Blocks until associated or WIFI_CONNECT_TIMEOUT_MS elapses. +bool connectWifi(); + +// Connects to the broker and publishes "online" to the availability topic. +// The LWT is registered here so an unexpected drop marks the node offline. +bool connectMqtt(); + +// Must be called frequently on the mains profile to service keepalives. +void loop(); + +bool isConnected(); + +// Retained by default: HA restarts should not blank out the node's entities. +bool publish(const char *topic, const char *payload, bool retain = true); + +// Blocks until the TCP buffer has drained, then closes the session cleanly. +// Called before deep sleep so the last publish is not lost mid-flight. +void disconnect(); + +int8_t rssi(); + +// "soil//state" +const char *stateTopic(); + +// "soil//availability" +const char *availabilityTopic(); + +} // namespace net diff --git a/src/node_id.cpp b/src/node_id.cpp new file mode 100644 index 0000000..78eccf1 --- /dev/null +++ b/src/node_id.cpp @@ -0,0 +1,34 @@ +#include "node_id.h" + +#include +#include + +namespace node_id { +namespace { + +char s_slug[24]; +char s_friendly[24]; +char s_mac[18]; + +} // namespace + +void begin() { + uint8_t mac[6] = {0}; + + // Read the factory-burned base MAC rather than WiFi.macAddress(): this is + // stable from the first instruction of setup() and does not require the + // Wi-Fi driver to have been started yet. In the battery profile we want the + // node id available before the radio comes up. + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + snprintf(s_slug, sizeof(s_slug), "soil-node-%02x%02x%02x", mac[3], mac[4], mac[5]); + snprintf(s_friendly, sizeof(s_friendly), "Soil Node %02X%02X%02X", mac[3], mac[4], mac[5]); + snprintf(s_mac, sizeof(s_mac), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], + mac[4], mac[5]); +} + +const char *slug() { return s_slug; } +const char *friendlyName() { return s_friendly; } +const char *macAddress() { return s_mac; } + +} // namespace node_id diff --git a/src/node_id.h b/src/node_id.h new file mode 100644 index 0000000..aa9923f --- /dev/null +++ b/src/node_id.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +// Unique per-node identity derived from the ESP32's factory MAC (PRD 5.1). +// Two nodes flashed with the identical binary still land on distinct MQTT +// topics and distinct HA entity ids, so adding a node never collides with an +// existing one. +namespace node_id { + +// Must be called once in setup(), before anything reads the values below. +void begin(); + +// e.g. "soil-node-a1b2c3" -- lowercase, safe for MQTT topics and HA object ids. +const char *slug(); + +// e.g. "Soil Node A1B2C3" -- shown as the device name in Home Assistant. +const char *friendlyName(); + +// e.g. "a1:b2:c3:d4:e5:f6" +const char *macAddress(); + +} // namespace node_id diff --git a/src/power.cpp b/src/power.cpp new file mode 100644 index 0000000..fedbd41 --- /dev/null +++ b/src/power.cpp @@ -0,0 +1,108 @@ +#include "power.h" + +#include +#include + +#include "adc_sampler.h" + +namespace power { + +#if defined(POWER_PROFILE_BATTERY) +namespace { + +// RTC_DATA_ATTR survives deep sleep but not a power cycle or reset -- which is +// precisely the distinction we need to detect a cold boot. +RTC_DATA_ATTR uint32_t s_bootCount = 0; + +bool s_coldBoot = false; + +} // namespace +#endif + +void begin() { +#if defined(POWER_PROFILE_BATTERY) + s_coldBoot = (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED); + s_bootCount++; + + pinMode(SENSOR_POWER_GATE_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_GATE_PIN, LOW); +#else + // Mains: drive the gate on and leave it there. Wiring the switch is still + // worthwhile on a mains node -- it lets you power-cycle a wedged probe + // without walking out to the bed. + pinMode(SENSOR_POWER_GATE_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_GATE_PIN, HIGH); +#endif +} + +void sensorsOn() { + digitalWrite(SENSOR_POWER_GATE_PIN, HIGH); + delay(SENSOR_STABILIZE_MS); +} + +void sensorsOff() { +#if defined(POWER_PROFILE_BATTERY) + digitalWrite(SENSOR_POWER_GATE_PIN, LOW); +#endif +} + +#if defined(POWER_PROFILE_BATTERY) + +uint16_t batteryMillivolts() { + const uint16_t atPin = adc::readMillivolts(BATTERY_ADC_PIN); + return static_cast(atPin * BATTERY_DIVIDER_RATIO); +} + +float batteryPercent() { + const uint16_t mv = batteryMillivolts(); + if (mv <= BATTERY_EMPTY_MV) { + return 0.0f; + } + if (mv >= BATTERY_FULL_MV) { + return 100.0f; + } + return 100.0f * static_cast(mv - BATTERY_EMPTY_MV) / + static_cast(BATTERY_FULL_MV - BATTERY_EMPTY_MV); +} + +void deepSleep() { + sensorsOff(); + + log_i("sleeping for %llus", DEEP_SLEEP_INTERVAL_S); + Serial.flush(); + + esp_sleep_enable_timer_wakeup(DEEP_SLEEP_INTERVAL_S * 1000000ULL); + esp_deep_sleep_start(); + + // esp_deep_sleep_start() does not return. + __builtin_unreachable(); +} + +void armAwakeBudget() { + // esp_timer callbacks run on the esp_timer task rather than in ISR context, + // so calling into the sleep path from here is safe. + const esp_timer_create_args_t args = { + .callback = [](void *) { + log_e("awake budget of %ums exceeded, forcing sleep", AWAKE_BUDGET_MS); + deepSleep(); + }, + .arg = nullptr, + .dispatch_method = ESP_TIMER_TASK, + .name = "awake_budget", + .skip_unhandled_events = false, + }; + + esp_timer_handle_t timer = nullptr; + if (esp_timer_create(&args, &timer) != ESP_OK) { + log_e("could not arm awake budget timer"); + return; + } + esp_timer_start_once(timer, static_cast(AWAKE_BUDGET_MS) * 1000ULL); +} + +uint32_t bootCount() { return s_bootCount; } +bool isColdBoot() { return s_coldBoot; } + +#endif + +} // namespace power diff --git a/src/power.h b/src/power.h new file mode 100644 index 0000000..236263f --- /dev/null +++ b/src/power.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include "config.h" + +// Sensor-rail switching, battery measurement and deep sleep (PRD 4). +namespace power { + +// Puts the sensor rail into a known-off state. Call first in setup(), before +// the rail has a chance to be driven by a floating pin. +void begin(); + +// Energise / cut the 5V sensor rail. On the mains profile the rail is left +// permanently on and sensorsOff() is a no-op, so callers do not need to +// branch on the power profile. +void sensorsOn(); +void sensorsOff(); + +#if defined(POWER_PROFILE_BATTERY) + +// Battery terminal voltage, corrected for the divider ratio. +uint16_t batteryMillivolts(); + +// 0-100% over the configured Li-Ion discharge window. +float batteryPercent(); + +// Cuts the sensor rail and enters deep sleep for DEEP_SLEEP_INTERVAL_S. +// Does not return. +[[noreturn]] void deepSleep(); + +// Arms a one-shot timer that forces deep sleep if a wake cycle overruns +// AWAKE_BUDGET_MS. +// +// The per-step timeouts in net.cpp do not cover everything: an AP that +// associates but never hands out a DHCP lease, or a broker that completes the +// TCP handshake but never sends CONNACK, both leave the node awake at ~80mA +// indefinitely. Over a cloudy week that is the whole cell. This is the +// backstop that guarantees every wake ends in sleep. +void armAwakeBudget(); + +// Wake counter, preserved in RTC memory across deep sleep. 1 on cold boot. +uint32_t bootCount(); + +// True only on a cold boot (power-on / reset), false on an RTC-timer wake. +// Used to decide whether HA discovery needs republishing. +bool isColdBoot(); + +#endif + +} // namespace power