# 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. Broker and Home Assistant setup — including EMQX authentication and ACLs — is in [docs/MQTT_SETUP.md](docs/MQTT_SETUP.md). Read it before flashing: the broker at 192.168.1.31 currently accepts anonymous connections, so a node that connects successfully proves nothing about your credentials being right. 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 docs/MQTT_SETUP.md EMQX auth/ACL + Home Assistant wiring tools/mqtt_watch.py Broker-side bring-up watcher ``` ## 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.