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

+113
View File
@@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 940 520" width="940" height="520" role="img" aria-labelledby="t4">
<title id="t4">Solar and battery power topology with boost converter and battery sense divider</title>
<style>
.bg { fill: #fcfcfa; }
.ttl { font: 600 19px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.h { font: 600 13px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.s { font: 11.5px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #6b7280; }
.m { font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #374151; }
.box { fill: #f3f4f6; stroke: #16181d; stroke-width: 1.6; }
.boxp { fill: #fff3ec; stroke: #c2410c; stroke-width: 1.8; }
.boxs { fill: #eef2ff; stroke: #1d4ed8; stroke-width: 1.8; }
.pw { fill: none; stroke: #c2410c; stroke-width: 2.2; }
.sigw { fill: none; stroke: #1d4ed8; stroke-width: 1.8; }
.gw { fill: none; stroke: #374151; stroke-width: 1.9; }
.comp { fill: #ffffff; stroke: #16181d; stroke-width: 1.5; }
</style>
<rect class="bg" x="0" y="0" width="940" height="520"/>
<text class="ttl" x="20" y="30">Solar / battery topology — remote node</text>
<text class="s" x="20" y="49">The cell cannot feed the probes directly: an 18650 runs 3.0–4.2V and the HW-390's XC6206 LDO needs ~3.4V of headroom.</text>
<!-- ============ chain ============ -->
<rect class="boxp" x="24" y="96" width="150" height="80" rx="6"/>
<text class="h" x="38" y="120">Solar panel</text>
<text class="m" x="38" y="139">6V / 1–2W</text>
<text class="s" x="38" y="158">sized for winter, not</text>
<text class="s" x="38" y="171">for a sunny afternoon</text>
<path class="pw" d="M174,136 L216,136" marker-end="url(#ar)"/>
<rect class="boxp" x="222" y="96" width="160" height="80" rx="6"/>
<text class="h" x="236" y="120">TP4056 + DW01</text>
<text class="m" x="236" y="139">CC/CV charger</text>
<text class="s" x="236" y="158">protection variant only</text>
<text class="s" x="236" y="171">— no MPPT</text>
<path class="pw" d="M382,136 L424,136" marker-end="url(#ar)"/>
<rect class="boxp" x="430" y="96" width="140" height="80" rx="6"/>
<text class="h" x="444" y="120">18650 Li-Ion</text>
<text class="m" x="444" y="139">3.0 – 4.2 V</text>
<text class="s" x="444" y="158">VBAT node</text>
<!-- split to MCU and boost -->
<circle cx="600" cy="136" r="4" fill="#c2410c"/>
<path class="pw" d="M570,136 L600,136"/>
<path class="pw" d="M600,136 L600,86 L716,86" marker-end="url(#ar)"/>
<path class="pw" d="M600,136 L600,206 L716,206" marker-end="url(#ar)"/>
<rect class="boxs" x="722" y="56" width="192" height="62" rx="6"/>
<text class="h" x="736" y="79">ESP32-S3 · 5V/VIN</text>
<text class="s" x="736" y="97">onboard reg accepts VBAT</text>
<text class="m" x="736" y="112">deep sleep ≈ 10 µA</text>
<rect class="boxp" x="722" y="176" width="192" height="62" rx="6"/>
<text class="h" x="736" y="199">Boost converter → 5V</text>
<text class="s" x="736" y="217">on the SWITCHED side of</text>
<text class="s" x="736" y="231">the P-FET, not before it</text>
<path class="pw" d="M818,238 L818,282" marker-end="url(#ar)"/>
<rect class="boxp" x="722" y="288" width="192" height="54" rx="6"/>
<text class="h" x="736" y="310">P-FET → Cat5 pin 2</text>
<text class="s" x="736" y="329">see power-switch.svg</text>
<!-- ============ battery sense divider ============ -->
<path class="sigw" d="M500,176 L500,222"/>
<circle cx="500" cy="176" r="4" fill="#c2410c"/>
<text class="h" x="300" y="228">Battery sense divider</text>
<rect class="comp" x="480" y="222" width="40" height="52" rx="2"/>
<text class="m" x="528" y="252">R1 1 MΩ</text>
<path class="sigw" d="M500,274 L500,302"/>
<circle cx="500" cy="302" r="4" fill="#1d4ed8"/>
<path class="sigw" d="M500,302 L640,302" marker-end="url(#ab)"/>
<text class="h" x="648" y="299">GPIO10</text>
<text class="s" x="648" y="315">ADC1_CH9</text>
<!-- shunt cap -->
<path class="sigw" d="M566,302 L566,336"/>
<circle cx="566" cy="302" r="3.4" fill="#1d4ed8"/>
<line x1="555" y1="336" x2="577" y2="336" stroke="#16181d" stroke-width="1.8"/>
<line x1="555" y1="342" x2="577" y2="342" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M566,342 L566,368"/>
<use xlink:href="#gnd" href="#gnd" x="566" y="368"/>
<text class="m" x="584" y="342">1 µF</text>
<rect class="comp" x="480" y="302" width="40" height="52" rx="2"/>
<text class="m" x="410" y="332">R2 1 MΩ</text>
<path class="gw" d="M500,354 L500,382"/>
<use xlink:href="#gnd" href="#gnd" x="500" y="382"/>
<!-- ============ notes ============ -->
<rect x="20" y="410" width="900" height="90" rx="5" fill="#fff3ec" stroke="#c2410c" stroke-width="1.4"/>
<text class="h" x="34" y="431">Why 1 MΩ and not the usual 100 kΩ.</text>
<text class="s" x="34" y="450">A 100 kΩ/100 kΩ divider draws ~21 µA continuously — more than the sleeping MCU itself (~10 µA), and enough to dominate</text>
<text class="s" x="34" y="468">the winter power budget. 1 MΩ/1 MΩ draws ~2 µA. The 1 µF cap is not optional at that impedance: it supplies the ADC's sampling charge.</text>
<text class="s" x="34" y="486">Trim BATTERY_DIVIDER_RATIO in config.h against a meter to cancel resistor tolerance.</text>
<defs>
<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>
<marker id="ab" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#1d4ed8"/>
</marker>
<g id="gnd">
<line x1="-10" y1="0" x2="10" y2="0" stroke="#374151" stroke-width="1.9"/>
<line x1="-6" y1="4.5" x2="6" y2="4.5" stroke="#374151" stroke-width="1.9"/>
<line x1="-2.5" y1="9" x2="2.5" y2="9" stroke="#374151" stroke-width="1.9"/>
</g>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB