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

+131
View File
@@ -0,0 +1,131 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 710" width="900" height="710" role="img" aria-labelledby="t5">
<title id="t5">Firmware control flow for the mains and battery power profiles</title>
<style>
.bg { fill: #fcfcfa; }
.ttl { font: 600 19px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.col { font: 600 14px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.h { font: 600 12.5px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.s { font: 11px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #6b7280; }
.m { font: 10.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #374151; }
.node { fill: #f3f4f6; stroke: #16181d; stroke-width: 1.5; }
.nsig { fill: #eef2ff; stroke: #1d4ed8; stroke-width: 1.6; }
.npw { fill: #fff3ec; stroke: #c2410c; stroke-width: 1.6; }
.nnet { fill: #ecfdf3; stroke: #15803d; stroke-width: 1.6; }
.flow { fill: none; stroke: #16181d; stroke-width: 1.6; }
.alt { fill: none; stroke: #c2410c; stroke-width: 1.6; stroke-dasharray: 5 4; }
</style>
<rect class="bg" x="0" y="0" width="900" height="710"/>
<text class="ttl" x="20" y="30">Firmware flow — one compile-time switch, two shapes</text>
<text class="s" x="20" y="49">POWER_PROFILE_MAINS keeps setup() short and does the work in loop(). POWER_PROFILE_BATTERY does everything in setup() and never reaches loop().</text>
<!-- divider -->
<line x1="450" y1="72" x2="450" y2="676" stroke="#d1d5db" stroke-width="1.4" stroke-dasharray="4 4"/>
<!-- ================= MAINS ================= -->
<text class="col" x="30" y="94">-e mains · continuous power</text>
<rect class="node" x="30" y="108" width="200" height="38" rx="5"/>
<text class="h" x="44" y="132">boot → setup()</text>
<path class="flow" d="M130,146 L130,168" marker-end="url(#a)"/>
<rect class="npw" x="30" y="168" width="200" height="52" rx="5"/>
<text class="h" x="44" y="189">power::begin()</text>
<text class="m" x="44" y="207">gate HIGH, rail stays on</text>
<path class="flow" d="M130,220 L130,242" marker-end="url(#a)"/>
<rect class="node" x="30" y="242" width="200" height="52" rx="5"/>
<text class="h" x="44" y="263">node_id::begin()</text>
<text class="m" x="44" y="281">MAC → soil-node-a1b2c3</text>
<path class="flow" d="M130,294 L130,316" marker-end="url(#a)"/>
<rect class="nnet" x="30" y="316" width="200" height="52" rx="5"/>
<text class="h" x="44" y="337">Wi-Fi + MQTT connect</text>
<text class="m" x="44" y="355">LWT → availability topic</text>
<path class="flow" d="M130,368 L130,390" marker-end="url(#a)"/>
<rect class="nnet" x="30" y="390" width="200" height="52" rx="5"/>
<text class="h" x="44" y="411">publish discovery</text>
<text class="m" x="44" y="429">retained, homeassistant/</text>
<path class="flow" d="M130,442 L130,464" marker-end="url(#a)"/>
<rect class="node" x="30" y="464" width="200" height="38" rx="5"/>
<text class="h" x="44" y="488">loop()</text>
<path class="flow" d="M130,502 L130,524" marker-end="url(#a)"/>
<rect class="nsig" x="30" y="524" width="200" height="52" rx="5"/>
<text class="h" x="44" y="545">every 60 s: sample</text>
<text class="m" x="44" y="563">+ publish state JSON</text>
<!-- loop back -->
<path class="flow" d="M230,550 L256,550 L256,483 L230,483" marker-end="url(#a)"/>
<text class="s" x="262" y="520">repeat</text>
<!-- reconnect branch, routed clear of the repeat loop -->
<path class="alt" d="M230,566 L330,566 L330,342 L230,342" marker-end="url(#ar)"/>
<text class="s" x="338" y="436">on drop:</text>
<text class="s" x="338" y="452">reconnect +</text>
<text class="s" x="338" y="468">re-announce</text>
<!-- ================= BATTERY ================= -->
<text class="col" x="482" y="94">-e battery · solar / 18650</text>
<rect class="node" x="482" y="108" width="212" height="38" rx="5"/>
<text class="h" x="496" y="132">RTC wake → setup()</text>
<path class="flow" d="M588,146 L588,166" marker-end="url(#a)"/>
<rect class="npw" x="482" y="166" width="212" height="52" rx="5"/>
<text class="h" x="496" y="187">armAwakeBudget()</text>
<text class="m" x="496" y="205">30 s ceiling on the cycle</text>
<path class="flow" d="M588,218 L588,238" marker-end="url(#a)"/>
<rect class="npw" x="482" y="238" width="212" height="52" rx="5"/>
<text class="h" x="496" y="259">sensorsOn() + settle</text>
<text class="m" x="496" y="277">MOSFET on, wait 500 ms</text>
<path class="flow" d="M588,290 L588,310" marker-end="url(#a)"/>
<rect class="nnet" x="482" y="310" width="212" height="52" rx="5"/>
<text class="h" x="496" y="331">Wi-Fi + MQTT connect</text>
<text class="m" x="496" y="349">expire_after, no LWT</text>
<path class="flow" d="M588,362 L588,382" marker-end="url(#a)"/>
<rect class="nnet" x="482" y="382" width="212" height="52" rx="5"/>
<text class="h" x="496" y="403">cold boot? → discovery</text>
<text class="m" x="496" y="421">skipped on RTC wake</text>
<path class="flow" d="M588,434 L588,454" marker-end="url(#a)"/>
<rect class="nsig" x="482" y="454" width="212" height="66" rx="5"/>
<text class="h" x="496" y="475">oversample + publish</text>
<text class="m" x="496" y="493">16 reads, trim 10%,</text>
<text class="m" x="496" y="509">map, one JSON publish</text>
<path class="flow" d="M588,520 L588,540" marker-end="url(#a)"/>
<rect class="npw" x="482" y="540" width="212" height="66" rx="5"/>
<text class="h" x="496" y="561">flush → sensorsOff()</text>
<text class="m" x="496" y="579">→ deep sleep 15 min</text>
<text class="m" x="496" y="595">loop() never runs</text>
<!-- wake cycle back -->
<path class="flow" d="M694,573 L744,573 L744,127 L694,127" marker-end="url(#a)"/>
<text class="s" x="752" y="346">RTC timer</text>
<text class="s" x="752" y="361">wake</text>
<!-- early-sleep bailout -->
<path class="alt" d="M482,336 L458,336 L458,573 L482,573" marker-end="url(#ar)"/>
<text class="s" x="466" y="470" transform="rotate(-90 466 470)">network down → sleep early</text>
<!-- ================= note ================= -->
<rect x="20" y="626" width="860" height="62" rx="5" fill="#fff3ec" stroke="#c2410c" stroke-width="1.4"/>
<text class="h" x="34" y="646">The awake budget is armed before anything that can block.</text>
<text class="s" x="34" y="664">Per-step timeouts don't cover an AP that associates but never issues a DHCP lease, or a broker that completes TCP but never</text>
<text class="s" x="34" y="681">sends CONNACK. Without the ceiling, one bad night flattens the cell.</text>
<defs>
<marker id="a" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#16181d"/>
</marker>
<marker id="ar" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#c2410c"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB