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 <noreply@anthropic.com>
This commit is contained in:
mattandClaude committed 2026-08-14 13:38:41 -07:00
commit ad9c457ea7
24 files changed
+2178

No files matched your search

+138
View File
@@ -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.