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 <noreply@anthropic.com>
139 lines
6.0 KiB
Markdown
139 lines
6.0 KiB
Markdown
# 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
|
||
|
||

|
||
|
||
## 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.
|
||
|
||

|
||
|
||
## 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.
|