mattandClaude ee22a54398 Add broker checker; correct EMQX version to 6.0.1
The broker is EMQX 6.0.1, not 5.x as first assumed -- 6.x keeps serving its
management API under /api/v5 for backward compatibility, so the API path is
not a version indicator. Menu paths in the setup guide verified against the
current EMQX documentation.

tools/check_broker.py verifies broker configuration end to end: anonymous
refused, made-up credentials refused, the configured account accepted, and
publishes permitted to both topic trees the firmware uses. It reads the local
config header by path and never prints the account secret, so its output is
safe to paste anywhere.

Two bugs found and fixed while testing it against the live broker:

  - paho 2.x hands the VERSION2 on_connect callback a ReasonCode object, not
    an int. int(rc) raised, every probe fell back to the timeout path, and the
    script reported an open broker as secure -- a false pass on the one check
    that most needs to be trustworthy.
  - An inconclusive result (timeout, transport error) was rendered as a pass.
    It now reports INCONCLUSIVE and fails the run, because "could not tell"
    must never read as "locked down".

The reason-code table now covers both MQTT 3.1.1 CONNACK codes and the MQTT 5
numbering paho normalises onto, so refusals print as text rather than 134/135.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 14:35:07 -07:00

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.

  • 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

Quick start

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

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.

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 End-to-end: probes → Cat5 → breakout → MCU → HA
RJ45 breakout Per-pin schematic with the six mandatory RC filters
High-side P-FET switch Sensor rail switching and the 2N7002 inverting stage
Solar / battery topology Panel, charger, cell, boost, battery sense divider
Firmware flow 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.

S
Description
Soil Moisture Sensors over CAT5 cabling
Readme
86 KiB
0 Stars 1 Watchers 0 Forks
Languages
C++ 71%
Python 29%