Files
soilMoistureSystem/README.md
T
mattandClaude ad9c457ea7 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>
2026-08-14 13:38:41 -07:00

139 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.