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

+6
View File
@@ -0,0 +1,6 @@
.pio/
.vscode/
.claude/
include/secrets.h
*.bin
*.elf
+78
View File
@@ -0,0 +1,78 @@
# Product Requirements Document (PRD)
**Project:** Scalable ESP32 Soil Moisture Monitoring Node
**Target Audience:** AI Engineering Agents (Firmware, Hardware Design, Home Assistant Integration)
## 1. System Overview
A modular, distributed soil moisture monitoring system designed to interface up to six HW-390 (v2.0) capacitive soil moisture sensors per local microcontroller node. The system utilizes Cat5 cabling for consolidated power distribution and analog signal collection. Data is gathered by an ESP32-class microcontroller and broadcasted over Wi-Fi for ingestion, recording, and automation by a Home Assistant server. The architecture supports multi-node deployments with conflict-free network identification and accommodates both continuous (mains) and remote (solar/battery) power profiles.
## 2. Hardware Architecture & Wiring Specifications
### 2.1 Microcontroller Unit (MCU)
* **Supported Platforms:** ESP32-S3 (Primary), ESP32-C3, ESP32-S2, or standard ESP32.
* **Requirements:** Must have at least 6 accessible ADC (Analog-to-Digital Converter) channels capable of reading 0-3.3V, Wi-Fi capabilities, and deep-sleep support for battery operations.
### 2.2 Cabling & Breakout
* **Cable Type:** Standard 24 AWG solid-core Cat5/Cat5e cable.
* **Pinout/Pairing Strategy:**
* **Pair 1 (e.g., Orange/Orange-White):** `5V VCC` and `Shared GND`. (Crucial to keep power and ground on the same twisted pair to minimize loop area).
* **Pair 2, 3, 4 (e.g., Green, Blue, Brown pairs):** Assigned to `Analog Signal 1` through `Analog Signal 6`.
* **Termination:** Cat5 cable terminates at an RJ45 receiver/breakout board at the MCU enclosure.
### 2.3 Sensor Wiring & Protection (HW-390 v2.0)
* **Power:** Sensors are supplied with 5V over the Cat5 cable. The HW-390 v2.0 onboard XC6206 LDO regulates this to a clean 3.3V locally at the sensor.
* **Environmental Protection:** The upper PCB of all HW-390 sensors (components, exposed edges, and spliced connections) MUST be sealed with marine epoxy or conformal coating to prevent rapid outdoor degradation. Only the capacitive prongs remain unsealed.
## 3. Required Mitigations: Pitfalls & Solutions
To ensure system stability, subsequent AI agents designing the PCB/breadboard layout and firmware must implement the following solutions.
### 3.1 Hardware Mitigation: Signal Integrity & EMI
* **Issue:** Cat5 is unshielded and tightly twisted, causing capacitive coupling (crosstalk) and EMI susceptibility for DC analog signals.
* **Solution (Mandatory Hardware):** Implement an RC Low-Pass Filter on the RJ45 receiver board for every analog input line before it reaches the ESP32 ADC pin.
* *Component Values:* 10kΩ inline resistor, 0.1µF to 1µF ceramic capacitor bridging the ADC pin to GND.
### 3.2 Hardware Mitigation: Ground Bounce
* **Issue:** Six sensors sharing one Cat5 GND wire can cause the ground potential to float, altering analog readings.
* **Solution:** All spliced connections on the sensor end must be soldered/heat-shrunk or secured with high-retention Wago connectors. Limit total sensor draw to ~30mA (5mA per sensor).
### 3.3 Software Mitigation: ESP32 ADC Non-Linearity & Signal Smoothing
* **Issue:** ESP32 ADCs are non-linear, especially at extreme voltage ranges, and sandy/fast-draining soil creates rapid micro-fluctuations in moisture distribution.
* **Solution (Mandatory Firmware):**
* Employ the ESP32 hardware ADC calibration API.
* **Oversampling:** The firmware must take an array of 10-20 readings per sensor per cycle, discard the highest and lowest 10% (outliers), and average the remaining values.
* **Calibration:** Implement a two-point software calibration curve (Air = 0%, Water = 100%) to map the raw ADC integers to a percentage value.
## 4. Power Profiles & Management
The system must support two power topologies, determinable at compilation or runtime configuration:
### 4.1 Continuous Power (Mains)
* Supplied via 5V USB or dedicated 5V buck converter.
* MCU remains active, broadcasting updates at a set interval (e.g., every 60 seconds).
### 4.2 Solar / Battery Power (Remote Nodes)
* **Hardware:** Integrates a LiPo/Li-Ion battery (e.g., 18650 cell) and a solar charge controller (e.g., TP4056 with protection circuitry) paired with a 5V/6V solar panel. (Highly compatible with automated solar drip irrigation setups).
* **Firmware Deep Sleep:**
1. Wake up based on RTC timer (e.g., every 15-30 minutes).
2. Power on sensors (via a GPIO-controlled MOSFET switching the 5V line to save idle current).
3. Stabilize (wait 500ms).
4. Oversample, average, and map ADC readings.
5. Connect to Wi-Fi, publish MQTT/ESPHome payload.
6. Return to deep sleep.
## 5. Network Architecture & Home Assistant Integration
### 5.1 Multi-Node Identification (Conflict Prevention)
* **Unique Addressing:** Firmware must dynamically generate a unique Node ID based on the ESP32's internal MAC address (e.g., `soil-node-A1B2C3`).
* **Data Tagging:** All broadcasted data payloads must be tagged with this Node ID.
### 5.2 Communication Protocol
* **Recommended Framework:** ESPHome is the primary recommended framework for seamless, native Home Assistant integration via the ESPHome API.
* **Alternative:** Standard MQTT (via PubSubClient or similar). If using MQTT, the firmware must publish Home Assistant MQTT Auto-Discovery configuration payloads on startup so nodes populate automatically in the HA dashboard without manual YAML configuration.
### 5.3 Data Payload Requirements
The node must expose the following entities to Home Assistant per cycle:
* `sensor.node_[ID]_moisture_1` through `6` (Value: 0-100%, Unit: %, Device Class: Moisture)
* `sensor.node_[ID]_battery_level` (If using solar profile, mapped from battery voltage via voltage divider)
* `sensor.node_[ID]_wifi_signal` (RSSI)
* `sensor.node_[ID]_uptime` (or last seen timestamp)
+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.
+79
View File
@@ -0,0 +1,79 @@
# Two-point calibration
Implements PRD §3.3. Do this **once per probe**, after sealing them (§2.3) and
with the probe connected through its final Cat5 run — the cable and the RC
filter both shift the numbers, so calibrating on the bench with short leads
produces values that are wrong in the field.
## Why per-probe
`CAL_AIR_MV` / `CAL_WATER_MV` in `include/config.h` are arrays, not scalars.
HW-390s vary: each has its own XC6206 LDO, its own coating thickness, and its
own cable run. Using one shared pair of endpoints for six probes typically
costs 5–15% of absolute accuracy, which is enough to trigger irrigation on a
bed that is already wet.
The defaults shipped in `config.h` are placeholders. They compile and produce
plausible-looking output, which makes them dangerous — plausible output is
exactly what stops you noticing you skipped this step.
## Procedure
1. Flash the mains build and let the node come up:
```
pio run -e mains -t upload -t monitor
```
2. In Home Assistant, find the **Raw mV 1–6** diagnostic entities on the node's
device page. (These exist for this procedure; set `PUBLISH_RAW_MV 0` in
`config.h` to hide them once you are done.) You can also read them straight
off the serial monitor in the `state:` line.
3. **Air point (0%).** Probe clean, dry, in open air. Wait ~30 s for the value
to settle. Record `raw_mv_N` → this is `CAL_AIR_MV[N-1]`.
4. **Water point (100%).** Submerge the probe in a glass of water **up to but
not past the marked line** on the PCB. Do not submerge the electronics.
Wait ~30 s. Record `raw_mv_N` → this is `CAL_WATER_MV[N-1]`.
5. Paste both arrays into `include/config.h` and reflash.
## Expected values
A healthy 3.3 V HW-390 v2.0 reads roughly:
| Condition | Typical |
|-----------|---------|
| Air (dry) | 2400–2800 mV |
| Water | 1000–1400 mV |
**Air must read higher than water** — the HW-390's output is inverted. The
firmware rejects `CAL_AIR_MV <= CAL_WATER_MV` at runtime and logs an error
rather than silently producing an inverted percentage, because a
moisture curve running backwards is very easy to miss on a dashboard and would
tell your irrigation to water dry soil less.
## Troubleshooting
**Air-to-water spread under ~800 mV.** Usually a probe that is degrading, or
one whose coating crept onto the prongs. A narrow span makes the percentage
noisy no matter how much you oversample.
**Reads ~0 mV.** No power reaching the probe: check the Cat5 5 V pair, the
MOSFET gate, and the splice. The firmware reports this as *unknown* rather than
0%, which is the giveaway.
**Reads ~3300 mV (rail).** Signal line shorted to the 5 V pair, or the probe's
output is disconnected and the ADC pin is floating up.
**Reading drifts down over weeks with no rain.** Moisture ingress into an
imperfectly sealed board. Re-seal per `WIRING.md` §6 and recalibrate.
## Fault margin
`SENSOR_FAULT_MARGIN_MV` (default 400 mV) sets how far outside the calibrated
window a reading may sit before it is reported as unavailable instead of being
clamped. Soil genuinely can read slightly outside the air/water endpoints —
saturated clay can go below the water point — so the margin exists to avoid
false faults. Widen it if healthy probes flap to *unknown*; narrow it if real
failures are being clamped into believable numbers.
+135
View File
@@ -0,0 +1,135 @@
# Wiring & Breakout
Implements PRD §2 (cabling) and §3.1–3.2 (signal integrity, ground bounce).
![System architecture](img/system-architecture.svg)
## 1. Cat5 pinout
Standard T568B colours. Power and ground share one twisted pair so the
supply loop area stays small (PRD §2.2).
| RJ45 pin | Colour | Function | MCU side |
|----------|--------------|---------------------|----------------|
| 1 | Orange/White | **GND** | GND |
| 2 | Orange | **+5V** | Switched 5V rail |
| 3 | Green/White | Analog signal 1 | GPIO4 (ADC1_CH3) |
| 6 | Green | Analog signal 2 | GPIO5 (ADC1_CH4) |
| 4 | Blue | Analog signal 3 | GPIO6 (ADC1_CH5) |
| 5 | Blue/White | Analog signal 4 | GPIO7 (ADC1_CH6) |
| 7 | Brown/White | Analog signal 5 | GPIO8 (ADC1_CH7) |
| 8 | Brown | Analog signal 6 | GPIO9 (ADC1_CH8) |
**Known tradeoff:** six signals over three pairs means each pair carries two
*signals* rather than a signal and its return, which is the worst case for
pair-to-pair crosstalk. It is acceptable here only because these are slow DC
levels and the RC filters below roll off everything above a few hertz. If you
ever repurpose this harness for anything with edges in it, re-plan the pairing.
## 2. RC low-pass filter — mandatory (PRD §3.1)
![RJ45 breakout schematic](img/rj45-breakout.svg)
One per analog line, on the RJ45 breakout board, **between the jack and the
ESP32 pin**:
```
RJ45 signal ──[ 10 kΩ ]──┬── GPIO (ADC1)
│
[ 0.1 µF ] ceramic, X7R
│
GND
```
Placement is the whole point: the capacitor must sit at the *ESP32 end* of the
resistor, physically close to the pin. Put it at the jack end and you have
filtered the cable but left the ADC pin exposed to everything picked up on the
last few centimetres of board trace.
- 10 kΩ + 0.1 µF → τ = 1 ms. Fine for soil, which moves over minutes.
- 10 kΩ + 1 µF → τ = 10 ms. Quieter; still far faster than the sample burst.
The series resistor also raises source impedance above what the SAR ADC would
like. The capacitor is what fixes that — it supplies the sampling charge — so
do not fit the resistor without it.
## 3. Ground integrity (PRD §3.2)
- Every splice at the sensor end: soldered + heat-shrink, or a high-retention
Wago. Twist-and-tape connections corrode and drift the ground reference.
- Budget ~5 mA per HW-390, so ~30 mA total. One 24 AWG conductor carries this
with negligible drop over a normal garden run.
- Run **one** GND back to a single point at the MCU board. Do not daisy-chain
sensor grounds through each other — that is exactly the shared-return path
that makes one probe's current show up as another probe's reading.
## 4. Sensor power switch (battery profile)
![High-side P-FET switch](img/power-switch.svg)
`SENSOR_POWER_GATE_PIN` (GPIO14) is `HIGH` for *sensors on*. A 3.3 V GPIO
cannot drive a P-channel MOSFET's gate on a 5 V rail hard enough to fully turn
it off, so an inverting stage is required:
```
+5V ──────────┬──────────────┬─── source
│ │
[100kΩ] [ P-ch MOSFET ] e.g. AO3401
│ │
GPIO14 ──[10kΩ]──┤ gate └─── drain ──→ Cat5 pin 2 (+5V to sensors)
│
[ 2N7002 N-ch ] drain ──┘
│
GND
```
- GPIO14 HIGH → 2N7002 conducts → P-FET gate pulled low → rail **on**.
- GPIO14 LOW **or floating** → 100 kΩ pulls the gate to 5 V → rail **off**.
The pull-up is what makes deep sleep safe: pins float during sleep, and this
guarantees a floating pin means *off* rather than *undefined*.
## 5. Battery & solar (PRD §4.2)
![Solar and battery topology](img/battery-solar.svg)
**The 18650 cannot feed the sensors directly.** A cell runs 3.0–4.2 V, and the
HW-390's XC6206 LDO needs roughly 3.4 V of headroom to hold a clean 3.3 V. Wire
the cell straight to the Cat5 5 V pair and the probes brown out and drift as
the cell discharges — readings that look like the soil drying out. Put a
**boost converter to 5 V on the switched side** of the MOSFET.
```
Solar panel (6V) ──→ TP4056 (with DW01 protection) ──→ 18650
│
├──→ ESP32-S3 5V/VIN
│
└──→ [P-FET] ──→ boost 5V ──→ Cat5 pin 2
```
Battery sense divider into GPIO10:
```
VBAT ──[ R1 1MΩ ]──┬── GPIO10 (ADC1_CH9)
│
[ R2 1MΩ ] ┬── [1µF]
│ │
GND GND
```
1 MΩ/1 MΩ draws ~2 µA, which is the right order next to the ESP32's ~10 µA
deep-sleep current. The obvious 100 kΩ/100 kΩ pair draws ~21 µA — more than the
sleeping MCU, and enough to dominate your winter power budget. The 1 µF cap is
not optional at this impedance: it supplies the ADC's sampling charge.
`BATTERY_DIVIDER_RATIO = 2.0` in `include/config.h` assumes R1 == R2. Measure
the actual cell voltage with a meter and adjust the ratio to trim out resistor
tolerance.
## 6. Sensor sealing (PRD §2.3)
Every HW-390's upper PCB — components, exposed board edges, and the splice —
sealed with marine epoxy or conformal coating. **Only the capacitive prongs
stay bare.** This is the single most common reason these probes die in a
season; an unsealed board wicks moisture up under the silkscreen and the
readings drift long before anything looks visibly corroded.
+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

+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

+94
View File
@@ -0,0 +1,94 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 740 500" width="740" height="500" role="img" aria-labelledby="t3">
<title id="t3">High-side P-channel MOSFET switch for the 5V sensor rail, driven through a 2N7002 inverting stage</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; }
.pin { font: 600 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #16181d; }
.pw { fill: none; stroke: #c2410c; stroke-width: 2; }
.sigw { fill: none; stroke: #1d4ed8; stroke-width: 1.8; }
.gw { fill: none; stroke: #374151; stroke-width: 2; }
.comp { fill: #ffffff; stroke: #16181d; stroke-width: 1.5; }
.fet { fill: #eef2ff; stroke: #1d4ed8; stroke-width: 1.8; }
</style>
<rect class="bg" x="0" y="0" width="740" height="500"/>
<text class="ttl" x="20" y="30">Switched 5V sensor rail — high-side P-FET</text>
<text class="s" x="20" y="49">A 3.3V GPIO cannot turn a P-FET on a 5V rail fully off. The 2N7002 inverting stage is what makes it work.</text>
<!-- ============ +5V rail ============ -->
<path class="pw" d="M60,110 L660,110"/>
<text class="h" x="60" y="100">+5V</text>
<text class="s" x="98" y="100">from buck (mains) or boost converter (battery)</text>
<!-- ============ 100k pull-up ============ -->
<path class="pw" d="M330,110 L330,150"/>
<circle cx="330" cy="110" r="3.6" fill="#c2410c"/>
<rect class="comp" x="310" y="150" width="40" height="56" rx="2"/>
<text class="m" x="212" y="174">100 kΩ</text>
<text class="s" x="212" y="190">holds gate off</text>
<path class="sigw" d="M330,206 L330,240"/>
<!-- ============ P-channel MOSFET ============ -->
<rect class="fet" x="400" y="180" width="130" height="96" rx="6"/>
<text class="h" x="416" y="206">P-channel</text>
<text class="m" x="416" y="224">AO3401</text>
<text class="s" x="416" y="242">or IRLML6402</text>
<text class="pin" x="416" y="262">S ▲ G ◀ D ▼</text>
<!-- P-FET source up to rail -->
<path class="pw" d="M465,180 L465,110"/>
<circle cx="465" cy="110" r="3.6" fill="#c2410c"/>
<text class="pin" x="472" y="172">S</text>
<!-- P-FET gate left to node -->
<path class="sigw" d="M400,240 L330,240"/>
<circle cx="330" cy="240" r="3.6" fill="#1d4ed8"/>
<text class="pin" x="380" y="234">G</text>
<!-- P-FET drain down to output -->
<path class="pw" d="M465,276 L465,330 L620,330"/>
<text class="pin" x="472" y="292">D</text>
<text class="h" x="500" y="322">+5V to Cat5 pin 2</text>
<text class="s" x="500" y="350">sensors + battery-side boost load</text>
<!-- ============ 2N7002 inverting stage ============ -->
<path class="sigw" d="M330,240 L235,240 L235,290"/>
<rect class="fet" x="170" y="290" width="130" height="90" rx="6"/>
<text class="h" x="186" y="315">N-channel</text>
<text class="m" x="186" y="333">2N7002</text>
<text class="s" x="186" y="351">logic-level</text>
<text class="pin" x="186" y="370">D ▲ G ◀ S ▼</text>
<text class="pin" x="242" y="284">D</text>
<!-- gate drive from GPIO14 -->
<path class="sigw" d="M170,335 L140,335"/>
<rect class="comp" x="80" y="325" width="60" height="20" rx="2"/>
<text class="m" x="92" y="339">10 kΩ</text>
<path class="sigw" d="M80,335 L36,335"/>
<text class="h" x="20" y="318">GPIO14</text>
<text class="pin" x="150" y="329">G</text>
<!-- source to ground -->
<path class="gw" d="M235,380 L235,420"/>
<text class="pin" x="242" y="396">S</text>
<path class="gw" d="M60,420 L660,420"/>
<circle cx="235" cy="420" r="3.6" fill="#374151"/>
<use xlink:href="#gnd" href="#gnd" x="360" y="420"/>
<text class="h" x="60" y="440">GND</text>
<text class="s" x="98" y="440">common star point with Cat5 pin 1</text>
<!-- ============ truth table ============ -->
<rect x="20" y="456" width="700" height="34" rx="5" fill="#fff3ec" stroke="#c2410c" stroke-width="1.4"/>
<text class="m" x="34" y="477">GPIO14 HIGH → 2N7002 conducts → gate pulled low → rail ON · GPIO14 LOW or FLOATING → 100 kΩ pulls gate to 5V → rail OFF</text>
<defs>
<g id="gnd">
<line x1="-10" y1="0" x2="10" y2="0" stroke="#374151" stroke-width="2"/>
<line x1="-6" y1="4.5" x2="6" y2="4.5" stroke="#374151" stroke-width="2"/>
<line x1="-2.5" y1="9" x2="2.5" y2="9" stroke="#374151" stroke-width="2"/>
</g>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+174
View File
@@ -0,0 +1,174 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 690" width="800" height="690" role="img" aria-labelledby="t2">
<title id="t2">RJ45 breakout schematic with six RC low-pass filters feeding ESP32-S3 ADC1 pins</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; }
.mp { font: 600 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #16181d; }
.wire { fill: none; stroke: #1d4ed8; stroke-width: 1.7; }
.pw { fill: none; stroke: #c2410c; stroke-width: 1.9; }
.gw { fill: none; stroke: #374151; stroke-width: 1.9; }
.comp { fill: #ffffff; stroke: #16181d; stroke-width: 1.5; }
.jack { fill: #f3f4f6; stroke: #16181d; stroke-width: 1.8; }
</style>
<rect class="bg" x="0" y="0" width="800" height="690"/>
<text class="ttl" x="20" y="30">RJ45 breakout — RC low-pass per analog line</text>
<text class="s" x="20" y="49">PRD §3.1, mandatory. One filter per signal, sited at the ESP32 end of the series resistor.</text>
<!-- ================= jack ================= -->
<rect class="jack" x="34" y="86" width="104" height="478" rx="6"/>
<text class="h" x="46" y="110">RJ45</text>
<text class="s" x="46" y="126">T568B</text>
<!-- column headers -->
<text class="s" x="152" y="80">wire</text>
<text class="s" x="238" y="80">series R</text>
<text class="s" x="360" y="80">shunt C</text>
<text class="s" x="470" y="80">to MCU</text>
<!-- ================= pin 1: GND ================= -->
<text class="mp" x="46" y="156">1</text>
<rect x="62" y="146" width="26" height="12" fill="#ffffff" stroke="#f97316" stroke-width="1.2"/>
<rect x="62" y="150" width="26" height="4" fill="#f97316"/>
<path class="gw" d="M138,152 L470,152"/>
<text class="m" x="152" y="138">Orange/White</text>
<text class="h" x="480" y="157">GND</text>
<text class="s" x="524" y="157">star point</text>
<!-- ================= pin 2: +5V ================= -->
<text class="mp" x="46" y="214">2</text>
<rect x="62" y="204" width="26" height="12" fill="#f97316" stroke="#374151" stroke-width="1"/>
<path class="pw" d="M138,210 L470,210"/>
<text class="m" x="152" y="196">Orange</text>
<text class="h" x="480" y="215">+5V</text>
<text class="s" x="524" y="215">switched rail</text>
<!-- ================= signal rows ================= -->
<!-- row: pin 3 -->
<text class="mp" x="46" y="272">3</text>
<rect x="62" y="262" width="26" height="12" fill="#ffffff" stroke="#16a34a" stroke-width="1.2"/>
<rect x="62" y="266" width="26" height="4" fill="#16a34a"/>
<text class="m" x="152" y="254">Green/White</text>
<path class="wire" d="M138,268 L206,268"/>
<rect class="comp" x="206" y="258" width="64" height="20" rx="2"/>
<text class="m" x="222" y="272">10 kΩ</text>
<path class="wire" d="M270,268 L470,268"/>
<circle cx="352" cy="268" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,268 L352,286"/>
<line x1="341" y1="286" x2="363" y2="286" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="292" x2="363" y2="292" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,292 L352,300"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="300"/>
<text class="m" x="370" y="292">0.1 µF</text>
<text class="h" x="480" y="273">GPIO4</text>
<text class="s" x="534" y="273">ADC1_CH3 · moisture 1</text>
<!-- row: pin 4 -->
<text class="mp" x="46" y="330">4</text>
<rect x="62" y="320" width="26" height="12" fill="#2563eb" stroke="#374151" stroke-width="1"/>
<text class="m" x="152" y="312">Blue</text>
<path class="wire" d="M138,326 L206,326"/>
<rect class="comp" x="206" y="316" width="64" height="20" rx="2"/>
<text class="m" x="222" y="330">10 kΩ</text>
<path class="wire" d="M270,326 L470,326"/>
<circle cx="352" cy="326" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,326 L352,344"/>
<line x1="341" y1="344" x2="363" y2="344" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="350" x2="363" y2="350" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,350 L352,358"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="358"/>
<text class="m" x="370" y="350">0.1 µF</text>
<text class="h" x="480" y="331">GPIO6</text>
<text class="s" x="534" y="331">ADC1_CH5 · moisture 3</text>
<!-- row: pin 5 -->
<text class="mp" x="46" y="388">5</text>
<rect x="62" y="378" width="26" height="12" fill="#ffffff" stroke="#2563eb" stroke-width="1.2"/>
<rect x="62" y="382" width="26" height="4" fill="#2563eb"/>
<text class="m" x="152" y="370">Blue/White</text>
<path class="wire" d="M138,384 L206,384"/>
<rect class="comp" x="206" y="374" width="64" height="20" rx="2"/>
<text class="m" x="222" y="388">10 kΩ</text>
<path class="wire" d="M270,384 L470,384"/>
<circle cx="352" cy="384" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,384 L352,402"/>
<line x1="341" y1="402" x2="363" y2="402" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="408" x2="363" y2="408" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,408 L352,416"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="416"/>
<text class="m" x="370" y="408">0.1 µF</text>
<text class="h" x="480" y="389">GPIO7</text>
<text class="s" x="534" y="389">ADC1_CH6 · moisture 4</text>
<!-- row: pin 6 -->
<text class="mp" x="46" y="446">6</text>
<rect x="62" y="436" width="26" height="12" fill="#16a34a" stroke="#374151" stroke-width="1"/>
<text class="m" x="152" y="428">Green</text>
<path class="wire" d="M138,442 L206,442"/>
<rect class="comp" x="206" y="432" width="64" height="20" rx="2"/>
<text class="m" x="222" y="446">10 kΩ</text>
<path class="wire" d="M270,442 L470,442"/>
<circle cx="352" cy="442" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,442 L352,460"/>
<line x1="341" y1="460" x2="363" y2="460" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="466" x2="363" y2="466" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,466 L352,474"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="474"/>
<text class="m" x="370" y="466">0.1 µF</text>
<text class="h" x="480" y="447">GPIO5</text>
<text class="s" x="534" y="447">ADC1_CH4 · moisture 2</text>
<!-- row: pin 7 -->
<text class="mp" x="46" y="504">7</text>
<rect x="62" y="494" width="26" height="12" fill="#ffffff" stroke="#92400e" stroke-width="1.2"/>
<rect x="62" y="498" width="26" height="4" fill="#92400e"/>
<text class="m" x="152" y="486">Brown/White</text>
<path class="wire" d="M138,500 L206,500"/>
<rect class="comp" x="206" y="490" width="64" height="20" rx="2"/>
<text class="m" x="222" y="504">10 kΩ</text>
<path class="wire" d="M270,500 L470,500"/>
<circle cx="352" cy="500" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,500 L352,518"/>
<line x1="341" y1="518" x2="363" y2="518" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="524" x2="363" y2="524" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,524 L352,532"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="532"/>
<text class="m" x="370" y="524">0.1 µF</text>
<text class="h" x="480" y="505">GPIO8</text>
<text class="s" x="534" y="505">ADC1_CH7 · moisture 5</text>
<!-- row: pin 8 -->
<text class="mp" x="46" y="562">8</text>
<rect x="62" y="552" width="26" height="12" fill="#92400e" stroke="#374151" stroke-width="1"/>
<text class="m" x="152" y="544">Brown</text>
<path class="wire" d="M138,558 L206,558"/>
<rect class="comp" x="206" y="548" width="64" height="20" rx="2"/>
<text class="m" x="222" y="562">10 kΩ</text>
<path class="wire" d="M270,558 L470,558"/>
<circle cx="352" cy="558" r="3.4" fill="#1d4ed8"/>
<path class="wire" d="M352,558 L352,576"/>
<line x1="341" y1="576" x2="363" y2="576" stroke="#16181d" stroke-width="1.8"/>
<line x1="341" y1="582" x2="363" y2="582" stroke="#16181d" stroke-width="1.8"/>
<path class="gw" d="M352,582 L352,590"/>
<use xlink:href="#gnd" href="#gnd" x="352" y="590"/>
<text class="m" x="370" y="582">0.1 µF</text>
<text class="h" x="480" y="563">GPIO9</text>
<text class="s" x="534" y="563">ADC1_CH8 · moisture 6</text>
<!-- ================= notes ================= -->
<rect x="20" y="610" width="760" height="64" rx="5" fill="#fff3ec" stroke="#c2410c" stroke-width="1.4"/>
<text class="h" x="34" y="631">Placement matters more than the values.</text>
<text class="s" x="34" y="650">The capacitor must sit at the ESP32 end of the resistor, close to the pin — it is what supplies the ADC's sampling charge.</text>
<text class="s" x="34" y="667">Fitting the 10 kΩ without the cap leaves source impedance worse than having no filter at all.</text>
<defs>
<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: 9.2 KiB

+164
View File
@@ -0,0 +1,164 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 600" width="980" height="600" role="img" aria-labelledby="t1">
<title id="t1">System architecture: six HW-390 probes over Cat5 to an ESP32-S3, published to Home Assistant over MQTT</title>
<style>
.bg { fill: #fcfcfa; }
.box { fill: #f3f4f6; stroke: #16181d; stroke-width: 1.6; }
.boxhi { fill: #eef2ff; stroke: #1d4ed8; stroke-width: 1.8; }
.boxpw { fill: #fff3ec; stroke: #c2410c; stroke-width: 1.8; }
.boxha { fill: #ecfdf3; stroke: #15803d; stroke-width: 1.8; }
.ttl { font: 600 19px ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif; fill: #16181d; }
.h { font: 600 13.5px 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; }
.mb { font: 600 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #1d4ed8; }
.sig { fill: none; stroke: #1d4ed8; stroke-width: 1.5; }
.cat5 { fill: none; stroke: #1d4ed8; stroke-width: 8; stroke-linecap: round; }
.pwr { fill: none; stroke: #c2410c; stroke-width: 2.2; }
.data { fill: none; stroke: #15803d; stroke-width: 2.2; }
.dash { stroke-dasharray: 5 4; }
</style>
<rect class="bg" x="0" y="0" width="980" height="600"/>
<text class="ttl" x="20" y="30">Soil Moisture Node — system architecture</text>
<text class="s" x="20" y="49">Up to 6 HW-390 v2.0 probes per node · one Cat5 run · multi-node safe via MAC-derived ID</text>
<!-- ============ probes ============ -->
<text class="h" x="20" y="80">Field sensors</text>
<g>
<rect class="box" x="20" y="90" width="152" height="44" rx="5"/>
<text class="h" x="32" y="110">HW-390 #1</text><text class="m" x="32" y="126">sealed · 5mA</text>
<rect class="box" x="20" y="146" width="152" height="44" rx="5"/>
<text class="h" x="32" y="166">HW-390 #2</text><text class="m" x="32" y="182">sealed · 5mA</text>
<rect class="box" x="20" y="202" width="152" height="44" rx="5"/>
<text class="h" x="32" y="222">HW-390 #3</text><text class="m" x="32" y="238">sealed · 5mA</text>
<rect class="box" x="20" y="258" width="152" height="44" rx="5"/>
<text class="h" x="32" y="278">HW-390 #4</text><text class="m" x="32" y="294">sealed · 5mA</text>
<rect class="box" x="20" y="314" width="152" height="44" rx="5"/>
<text class="h" x="32" y="334">HW-390 #5</text><text class="m" x="32" y="350">sealed · 5mA</text>
<rect class="box" x="20" y="370" width="152" height="44" rx="5"/>
<text class="h" x="32" y="390">HW-390 #6</text><text class="m" x="32" y="406">sealed · 5mA</text>
</g>
<text class="s" x="20" y="437">Each: XC6206 LDO 5V→3.3V on-board.</text>
<text class="s" x="20" y="452">Upper PCB epoxy-sealed; prongs bare.</text>
<!-- ============ cat5 funnel ============ -->
<text class="h" x="186" y="80">Cat5 24AWG</text>
<text class="s" x="186" y="96">8 conductors · T568B</text>
<path class="sig" d="M172,112 Q212,112 244,252"/>
<path class="sig" d="M172,168 Q212,168 244,252"/>
<path class="sig" d="M172,224 Q216,224 244,252"/>
<path class="sig" d="M172,280 Q216,280 244,252"/>
<path class="sig" d="M172,336 Q212,336 244,252"/>
<path class="sig" d="M172,392 Q212,392 244,252"/>
<path class="cat5" d="M244,252 L300,252"/>
<!-- ============ breakout ============ -->
<rect class="boxhi" x="300" y="112" width="184" height="302" rx="6"/>
<text class="h" x="314" y="136">RJ45 breakout</text>
<text class="s" x="314" y="153">at MCU enclosure</text>
<rect x="314" y="166" width="156" height="96" rx="4" fill="#ffffff" stroke="#1d4ed8" stroke-width="1.2"/>
<text class="h" x="326" y="187">6× RC low-pass</text>
<text class="m" x="326" y="205">10 kΩ series</text>
<text class="m" x="326" y="221">0.1 µF to GND</text>
<text class="m" x="326" y="237">at the ADC pin</text>
<text class="m" x="326" y="253" fill="#c2410c">MANDATORY §3.1</text>
<rect x="314" y="274" width="156" height="58" rx="4" fill="#ffffff" stroke="#374151" stroke-width="1.2"/>
<text class="h" x="326" y="294">Star ground</text>
<text class="m" x="326" y="312">single point, no</text>
<text class="m" x="326" y="326">daisy-chaining</text>
<text class="m" x="314" y="356" fill="#c2410c">pin 2 → +5V (switched)</text>
<text class="m" x="314" y="373" fill="#374151">pin 1 → GND</text>
<text class="m" x="314" y="390" fill="#1d4ed8">pins 3-8 → 6 signals</text>
<!-- breakout to mcu -->
<path class="sig" d="M484,252 L522,252" marker-end="url(#arrowb)"/>
<!-- ============ esp32 ============ -->
<rect class="boxhi" x="530" y="112" width="180" height="302" rx="6"/>
<text class="h" x="544" y="136">ESP32-S3</text>
<text class="s" x="544" y="153">YD-ESP32-S3 (WROOM-1)</text>
<rect x="544" y="166" width="152" height="122" rx="4" fill="#ffffff" stroke="#1d4ed8" stroke-width="1.2"/>
<text class="h" x="556" y="186">ADC1 only</text>
<text class="s" x="556" y="201">ADC2 is owned by Wi-Fi</text>
<text class="m" x="556" y="221">GPIO4-9 moisture 1-6</text>
<text class="m" x="556" y="238">GPIO10 battery÷2</text>
<text class="m" x="556" y="255">GPIO14 rail MOSFET</text>
<text class="m" x="556" y="272">GPIO48 status LED</text>
<rect x="544" y="300" width="152" height="100" rx="4" fill="#ffffff" stroke="#374151" stroke-width="1.2"/>
<text class="h" x="556" y="320">Firmware</text>
<text class="m" x="556" y="338">16× oversample</text>
<text class="m" x="556" y="354">trim 10% outliers</text>
<text class="m" x="556" y="370">eFuse ADC cal</text>
<text class="m" x="556" y="386">2-point curve → %</text>
<!-- ============ wifi ============ -->
<path class="data" d="M710,220 L770,180" marker-end="url(#arrowg)"/>
<text class="s" x="716" y="248">Wi-Fi</text>
<text class="s" x="716" y="262">MQTT</text>
<!-- ============ broker + HA ============ -->
<rect class="boxha" x="790" y="130" width="170" height="86" rx="6"/>
<text class="h" x="804" y="153">MQTT broker</text>
<text class="s" x="804" y="170">Mosquitto</text>
<text class="m" x="804" y="190">soil/&lt;node&gt;/state</text>
<text class="m" x="804" y="206">retained JSON</text>
<path class="data" d="M875,216 L875,266" marker-end="url(#arrowg)"/>
<rect class="boxha" x="790" y="270" width="170" height="120" rx="6"/>
<text class="h" x="804" y="293">Home Assistant</text>
<text class="s" x="804" y="310">auto-discovery, no YAML</text>
<text class="m" x="804" y="331">moisture 1-6 %</text>
<text class="m" x="804" y="348">battery level %</text>
<text class="m" x="804" y="365">wifi signal dBm</text>
<text class="m" x="804" y="382">uptime s</text>
<!-- ============ power path ============ -->
<!-- Flow runs right-to-left along the bottom: source -> MOSFET -> breakout -> Cat5 -->
<rect class="boxpw" x="530" y="440" width="180" height="76" rx="6"/>
<text class="h" x="544" y="462">Power source</text>
<text class="m" x="544" y="481">mains: 5V USB / buck</text>
<text class="m" x="544" y="497">remote: 18650 + solar</text>
<text class="m" x="544" y="512">→ boost to 5V</text>
<path class="pwr" d="M530,470 L492,470" marker-end="url(#arrowr)"/>
<rect class="boxpw" x="300" y="440" width="184" height="76" rx="6"/>
<text class="h" x="314" y="462">Switched 5V rail</text>
<text class="m" x="314" y="481">P-FET high-side</text>
<text class="m" x="314" y="497">HIGH = sensors on</text>
<text class="m" x="314" y="512">floating = off</text>
<path class="pwr" d="M392,440 L392,418" marker-end="url(#arrowr)"/>
<!-- GPIO14 gate control: out the right of the MCU, under both boxes, up into the FET -->
<path class="pwr dash" d="M690,414 L730,414 L730,548 L430,548 L430,522" marker-end="url(#arrowr)"/>
<text class="m" x="560" y="542" fill="#c2410c">GPIO14 gate</text>
<!-- ============ legend ============ -->
<line x1="20" y1="575" x2="46" y2="575" stroke="#1d4ed8" stroke-width="2.2"/>
<text class="s" x="54" y="579">analog signal</text>
<line x1="152" y1="575" x2="178" y2="575" stroke="#c2410c" stroke-width="2.2"/>
<text class="s" x="186" y="579">power</text>
<line x1="248" y1="575" x2="274" y2="575" stroke="#15803d" stroke-width="2.2"/>
<text class="s" x="282" y="579">network / data</text>
<line x1="378" y1="575" x2="404" y2="575" stroke="#c2410c" stroke-width="2.2" stroke-dasharray="5 4"/>
<text class="s" x="412" y="579">control</text>
<defs>
<marker id="arrowb" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#1d4ed8"/>
</marker>
<marker id="arrowr" 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="arrowg" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#15803d"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include <Arduino.h>
// ===========================================================================
// Compile-time power profile (PRD 4)
// Exactly one of these is set by the PlatformIO environment. See platformio.ini.
// ===========================================================================
#if defined(POWER_PROFILE_MAINS) && defined(POWER_PROFILE_BATTERY)
#error "Define only one of POWER_PROFILE_MAINS / POWER_PROFILE_BATTERY"
#endif
#if !defined(POWER_PROFILE_MAINS) && !defined(POWER_PROFILE_BATTERY)
#error "No power profile selected. Build with -e mains or -e battery."
#endif
// ===========================================================================
// Pin map -- VCC-GND Studio YD-ESP32-S3 (ESP32-S3-WROOM-1)
//
// Every sensor input sits on ADC1. This is not a style choice: ADC2 is owned
// by the Wi-Fi radio on the ESP32-S3, and analogRead() on an ADC2 pin returns
// garbage (or blocks) whenever the radio is active. ADC1 is GPIO1..GPIO10,
// which is exactly enough for 6 sensors + a battery divider.
//
// Also avoided on this board:
// GPIO0 strapping / BOOT button
// GPIO3 strapping (JTAG source select)
// GPIO19/20 native USB D-/D+
// GPIO33..37 octal flash/PSRAM on N*R8 variants
// GPIO43/44 UART0 TX/RX (serial monitor)
// GPIO45/46 strapping (VDD_SPI / boot log)
// ===========================================================================
constexpr uint8_t SENSOR_COUNT = 6;
// Sensor 1..6 -> ADC1_CH3..CH8
constexpr uint8_t SENSOR_ADC_PINS[SENSOR_COUNT] = {4, 5, 6, 7, 8, 9};
// Battery sense via 2:1 divider -> ADC1_CH9. Battery profile only.
constexpr uint8_t BATTERY_ADC_PIN = 10;
// Gate of the P-channel MOSFET (or high-side load switch) feeding the 5V
// sensor rail. HIGH = sensors powered. Battery profile only; on mains the
// rail is simply left on. Needs an external pulldown/pullup so the rail is
// OFF while the MCU is in deep sleep and the pin is floating.
constexpr uint8_t SENSOR_POWER_GATE_PIN = 14;
// Onboard addressable RGB LED. Used only for coarse boot/error feedback.
constexpr uint8_t STATUS_LED_PIN = 48;
// ===========================================================================
// ADC sampling & smoothing (PRD 3.3)
// ===========================================================================
// Readings taken per sensor per cycle, before outlier rejection.
constexpr uint8_t OVERSAMPLE_COUNT = 16;
// Fraction discarded from each end of the sorted sample set (10% per PRD).
// At 16 samples this trims 1 from the top and 1 from the bottom.
constexpr uint8_t OVERSAMPLE_TRIM_PERCENT = 10;
// Microseconds between individual reads within one sensor's burst. Spreads
// the burst across mains-hum periods rather than sampling one point on it.
constexpr uint32_t OVERSAMPLE_DELAY_US = 2000;
// Time for the 5V rail and the sensors' XC6206 LDOs to settle after the
// MOSFET turns on (PRD 4.2 step 3).
constexpr uint32_t SENSOR_STABILIZE_MS = 500;
// ===========================================================================
// Two-point calibration (PRD 3.3)
//
// HW-390 output is INVERTED: dry/air reads HIGH, submerged reads LOW.
// So AIR_MV > WATER_MV, and 0% is at AIR_MV.
//
// Per-sensor values because each probe's LDO and coating differ. Run
// `docs/CALIBRATION.md` once per probe and paste the numbers here.
// The defaults below are typical for a 3.3V HW-390 v2.0 and WILL be wrong
// for your probes -- they exist so a fresh build compiles and reads
// plausibly, not so you can skip calibration.
// ===========================================================================
constexpr uint16_t CAL_AIR_MV[SENSOR_COUNT] = {2600, 2600, 2600, 2600, 2600, 2600};
constexpr uint16_t CAL_WATER_MV[SENSOR_COUNT] = {1200, 1200, 1200, 1200, 1200, 1200};
// A probe reading outside [WATER_MV - margin, AIR_MV + margin] is reported as
// unavailable rather than clamped -- that pattern means a cut Cat5 pair, a
// dead LDO, or a probe pulled out of the ground, and silently clamping it to
// 0%/100% would hide a fault behind a plausible number.
constexpr uint16_t SENSOR_FAULT_MARGIN_MV = 400;
// ===========================================================================
// Battery monitoring (battery profile only)
// ===========================================================================
// Divider ratio: Vbat --[R1]--+--[R2]-- GND, ADC on the junction.
// With R1 = R2 = 100k the measured voltage is half the battery voltage.
constexpr float BATTERY_DIVIDER_RATIO = 2.0f;
// Li-Ion 18650 discharge window used for the 0-100% mapping.
constexpr uint16_t BATTERY_EMPTY_MV = 3300;
constexpr uint16_t BATTERY_FULL_MV = 4200;
// ===========================================================================
// Timing
// ===========================================================================
#if defined(POWER_PROFILE_MAINS)
// Publish cadence while awake (PRD 4.1).
constexpr uint32_t PUBLISH_INTERVAL_MS = 60UL * 1000UL;
#else
// RTC wake interval (PRD 4.2). 15 minutes.
constexpr uint64_t DEEP_SLEEP_INTERVAL_S = 15ULL * 60ULL;
// Hard ceiling on one wake cycle. If Wi-Fi or the broker is unreachable we
// sleep anyway instead of sitting at ~80mA until the cell is flat.
constexpr uint32_t AWAKE_BUDGET_MS = 30UL * 1000UL;
#endif
constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 15UL * 1000UL;
constexpr uint32_t MQTT_CONNECT_TIMEOUT_MS = 10UL * 1000UL;
// ===========================================================================
// MQTT / Home Assistant
// ===========================================================================
// Prefix HA listens on for auto-discovery. Matches the HA MQTT integration's
// "discovery prefix" setting; only change it if you changed it there.
#define HA_DISCOVERY_PREFIX "homeassistant"
// Root of this project's own state topics.
#define MQTT_TOPIC_ROOT "soil"
// Publish raw millivolts alongside the percentages as diagnostic entities.
// Leave on: these are what you read off the HA dashboard when calibrating.
#define PUBLISH_RAW_MV 1
#define FIRMWARE_VERSION "1.0.0"
+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Copy this file to `secrets.h` and fill in your values.
// cp include/secrets.h.example include/secrets.h
//
// `include/secrets.h` is gitignored. Do not commit it, and do not paste its
// contents into a chat transcript or an issue.
#define WIFI_SSID "your-ssid"
#define WIFI_PASSWORD "your-wifi-password"
#define MQTT_HOST "192.168.1.10" // Home Assistant / Mosquitto broker
#define MQTT_PORT 1883
#define MQTT_USER "soil-node" // "" for an anonymous broker
#define MQTT_PASSWORD "your-mqtt-password"
+52
View File
@@ -0,0 +1,52 @@
; ESP32-S3 6-channel soil moisture node
; Board: VCC-GND Studio YD-ESP32-S3 (ESP32-S3-DevKitC-1 clone, ESP32-S3-WROOM-1)
;
; Two power profiles, selected by which environment you build:
; pio run -e mains -t upload ; always-on, 5V USB / buck
; pio run -e battery -t upload ; 18650 + TP4056 + solar, deep sleep
[platformio]
default_envs = mains
[env]
platform = espressif32@^6.5.0
board = esp32-s3-devkitc-1
framework = arduino
; YD-ESP32-S3 ships in several flash/PSRAM variants (N8, N8R2, N8R8, N16R8).
; These flags target the common N16R8 (16MB flash, 8MB octal PSRAM).
; For an N8 board with no PSRAM, drop the two BOARD_HAS_PSRAM/OPI lines
; and set board_upload.flash_size / board_build.partitions accordingly.
board_build.flash_mode = qio
board_build.partitions = default_16MB.csv
board_upload.flash_size = 16MB
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
lib_deps =
knolleary/PubSubClient@^2.8
bblanchon/ArduinoJson@^7.0.4
build_flags =
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-DBOARD_HAS_PSRAM
-DCORE_DEBUG_LEVEL=3
-Wall
; ---------------------------------------------------------------------------
; Continuous power: MCU stays awake, publishes on a fixed interval.
; ---------------------------------------------------------------------------
[env:mains]
build_flags =
${env.build_flags}
-DPOWER_PROFILE_MAINS
; ---------------------------------------------------------------------------
; Solar / battery: RTC-timer wake, sample, publish, deep sleep.
; ---------------------------------------------------------------------------
[env:battery]
build_flags =
${env.build_flags}
-DPOWER_PROFILE_BATTERY
+108
View File
@@ -0,0 +1,108 @@
#include "adc_sampler.h"
namespace adc {
namespace {
// Insertion sort. n is OVERSAMPLE_COUNT (16 by default), where insertion sort
// beats anything cleverer and costs no extra stack.
void sortAscending(uint16_t *v, uint8_t n) {
for (uint8_t i = 1; i < n; i++) {
const uint16_t key = v[i];
int8_t j = static_cast<int8_t>(i) - 1;
while (j >= 0 && v[j] > key) {
v[j + 1] = v[j];
j--;
}
v[j + 1] = key;
}
}
} // namespace
void begin() {
analogReadResolution(12);
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
// 11dB attenuation gives the full ~0-3.1V usable span. The HW-390's
// output swings across most of its 3.3V rail, so anything less would
// clip the dry end.
analogSetPinAttenuation(SENSOR_ADC_PINS[i], ADC_11db);
}
#if defined(POWER_PROFILE_BATTERY)
analogSetPinAttenuation(BATTERY_ADC_PIN, ADC_11db);
#endif
}
uint16_t readMillivolts(uint8_t pin) {
uint16_t samples[OVERSAMPLE_COUNT];
for (uint8_t i = 0; i < OVERSAMPLE_COUNT; i++) {
// analogReadMilliVolts() applies the per-chip eFuse ADC calibration
// (esp_adc_cal) internally -- this is the "hardware calibration API" the
// PRD requires. Using analogRead() here and scaling by hand would
// reintroduce the several-percent non-linearity it exists to remove.
samples[i] = static_cast<uint16_t>(analogReadMilliVolts(pin));
delayMicroseconds(OVERSAMPLE_DELAY_US);
}
sortAscending(samples, OVERSAMPLE_COUNT);
// Discard the top and bottom OVERSAMPLE_TRIM_PERCENT, at least one sample
// from each end so a single ADC glitch can never reach the mean.
uint8_t trim = (OVERSAMPLE_COUNT * OVERSAMPLE_TRIM_PERCENT) / 100;
if (trim == 0) {
trim = 1;
}
if (2 * trim >= OVERSAMPLE_COUNT) {
trim = (OVERSAMPLE_COUNT - 1) / 2;
}
uint32_t sum = 0;
const uint8_t kept = OVERSAMPLE_COUNT - (2 * trim);
for (uint8_t i = trim; i < OVERSAMPLE_COUNT - trim; i++) {
sum += samples[i];
}
return static_cast<uint16_t>(sum / kept);
}
Reading readSensor(uint8_t index) {
Reading r{0, 0.0f, false};
if (index >= SENSOR_COUNT) {
return r;
}
r.millivolts = readMillivolts(SENSOR_ADC_PINS[index]);
const uint16_t air = CAL_AIR_MV[index];
const uint16_t water = CAL_WATER_MV[index];
// Guard against a config.h where air/water were entered backwards or equal.
// Without this the mapping below divides by zero or silently inverts.
if (air <= water) {
log_e("sensor %u: CAL_AIR_MV (%u) must be > CAL_WATER_MV (%u); HW-390 output is inverted",
index + 1, air, water);
return r;
}
// A probe should sit between the water and air endpoints. Well outside that
// band means a broken splice, an unpowered probe (reads ~0mV) or a shorted
// signal line -- report it as unavailable rather than clamping it to a
// believable 0% or 100%.
if (r.millivolts + SENSOR_FAULT_MARGIN_MV < water ||
r.millivolts > air + SENSOR_FAULT_MARGIN_MV) {
log_w("sensor %u: %umV outside [%u, %u] +/- %umV, flagging unavailable", index + 1,
r.millivolts, water, air, SENSOR_FAULT_MARGIN_MV);
return r;
}
// Two-point curve. Inverted because the HW-390 reads high when dry.
float pct = 100.0f * (static_cast<float>(air) - static_cast<float>(r.millivolts)) /
(static_cast<float>(air) - static_cast<float>(water));
r.percent = constrain(pct, 0.0f, 100.0f);
r.valid = true;
return r;
}
} // namespace adc
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
#include "config.h"
// ADC acquisition for the soil probes (PRD 3.3).
//
// Three problems are handled here, in order:
// 1. The ESP32 SAR ADC is non-linear, so raw counts are converted to
// millivolts through the per-chip eFuse calibration rather than by a
// naive count * 3300 / 4095.
// 2. Sandy soil and an unshielded Cat5 run both produce fast noise, so each
// reading is an outlier-trimmed mean of a burst, not a single sample.
// 3. Raw millivolts mean nothing to a user, so a two-point curve maps them
// to 0-100%.
namespace adc {
struct Reading {
uint16_t millivolts; // trimmed-mean sensor output
float percent; // 0-100, calibrated; only meaningful when valid
bool valid; // false => reading outside plausible range, treat as a fault
};
// Configures resolution and attenuation on every sensor pin (and the battery
// pin in the battery profile). Call once after power-up.
void begin();
// Samples one probe. `index` is 0-based (sensor 1 is index 0).
Reading readSensor(uint8_t index);
// Trimmed-mean millivolts on an arbitrary ADC1 pin, no calibration curve
// applied. Used for the battery divider.
uint16_t readMillivolts(uint8_t pin);
} // namespace adc
+165
View File
@@ -0,0 +1,165 @@
#include "ha_discovery.h"
#include <ArduinoJson.h>
#include "config.h"
#include "net.h"
#include "node_id.h"
namespace ha {
namespace {
// Abbreviated HA discovery keys are used throughout ("stat_t" rather than
// "state_topic"). They are not cosmetic: the long-form equivalent of these
// payloads runs past PubSubClient's buffer once the device block is included.
void configTopic(char *out, size_t len, const char *objectId) {
snprintf(out, len, HA_DISCOVERY_PREFIX "/sensor/%s/%s/config", node_id::slug(), objectId);
}
// The identical "dev" block on every entity is what collapses them into one
// device card in HA, rather than N loose sensors.
void addDeviceBlock(JsonDocument &doc) {
JsonObject dev = doc["dev"].to<JsonObject>();
dev["ids"][0] = node_id::slug();
dev["name"] = node_id::friendlyName();
dev["mdl"] = "ESP32-S3 6ch Soil Node";
dev["mf"] = "DIY";
dev["sw"] = FIRMWARE_VERSION;
dev["cu"] = node_id::macAddress();
}
// Shared scaffolding for every sensor entity.
void beginEntity(JsonDocument &doc, const char *objectId, const char *name, const char *valueKey) {
char uniqueId[64];
snprintf(uniqueId, sizeof(uniqueId), "%s_%s", node_id::slug(), objectId);
char valueTemplate[64];
snprintf(valueTemplate, sizeof(valueTemplate), "{{ value_json.%s }}", valueKey);
doc["name"] = name;
doc["uniq_id"] = uniqueId;
doc["stat_t"] = net::stateTopic();
doc["val_tpl"] = valueTemplate;
doc["stat_cla"] = "measurement";
#if defined(POWER_PROFILE_MAINS)
// An always-on node can be tracked by an availability topic + LWT.
doc["avty_t"] = net::availabilityTopic();
#else
// A sleeping node is "offline" most of the time, so an availability topic
// would leave the entities greyed out between wakes. Age the values out
// instead: 2.5 sleep intervals without an update means genuinely missing.
doc["exp_aft"] = static_cast<uint32_t>(DEEP_SLEEP_INTERVAL_S * 5 / 2);
#endif
addDeviceBlock(doc);
}
void publishEntity(JsonDocument &doc, const char *objectId) {
char topic[128];
configTopic(topic, sizeof(topic), objectId);
char payload[768];
const size_t written = serializeJson(doc, payload, sizeof(payload));
if (written == 0 || written >= sizeof(payload) - 1) {
log_e("discovery: payload for %s did not fit in %u bytes", objectId, sizeof(payload));
return;
}
net::publish(topic, payload, true);
}
} // namespace
void publishDiscovery() {
log_i("discovery: publishing entities for %s", node_id::slug());
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
char objectId[24];
char name[24];
char valueKey[24];
snprintf(objectId, sizeof(objectId), "moisture_%u", i + 1);
snprintf(name, sizeof(name), "Moisture %u", i + 1);
snprintf(valueKey, sizeof(valueKey), "moisture_%u", i + 1);
JsonDocument doc;
beginEntity(doc, objectId, name, valueKey);
doc["unit_of_meas"] = "%";
doc["dev_cla"] = "moisture";
doc["ic"] = "mdi:water-percent";
publishEntity(doc, objectId);
#if PUBLISH_RAW_MV
// Diagnostic twin of each probe. This is the number you read while
// following docs/CALIBRATION.md, so it is worth the extra topic.
snprintf(objectId, sizeof(objectId), "raw_mv_%u", i + 1);
snprintf(name, sizeof(name), "Raw mV %u", i + 1);
snprintf(valueKey, sizeof(valueKey), "raw_mv_%u", i + 1);
JsonDocument rawDoc;
beginEntity(rawDoc, objectId, name, valueKey);
rawDoc["unit_of_meas"] = "mV";
rawDoc["dev_cla"] = "voltage";
rawDoc["ent_cat"] = "diagnostic";
publishEntity(rawDoc, objectId);
#endif
}
#if defined(POWER_PROFILE_BATTERY)
{
JsonDocument doc;
beginEntity(doc, "battery_level", "Battery Level", "battery_level");
doc["unit_of_meas"] = "%";
doc["dev_cla"] = "battery";
doc["ent_cat"] = "diagnostic";
publishEntity(doc, "battery_level");
}
{
JsonDocument doc;
beginEntity(doc, "battery_voltage", "Battery Voltage", "battery_mv");
doc["unit_of_meas"] = "mV";
doc["dev_cla"] = "voltage";
doc["ent_cat"] = "diagnostic";
publishEntity(doc, "battery_voltage");
}
#endif
{
JsonDocument doc;
beginEntity(doc, "wifi_signal", "Wi-Fi Signal", "wifi_signal");
doc["unit_of_meas"] = "dBm";
doc["dev_cla"] = "signal_strength";
doc["ent_cat"] = "diagnostic";
publishEntity(doc, "wifi_signal");
}
{
JsonDocument doc;
beginEntity(doc, "uptime", "Uptime", "uptime");
doc["unit_of_meas"] = "s";
doc["dev_cla"] = "duration";
doc["ent_cat"] = "diagnostic";
// Uptime resets to 0 on reboot, which is what total_increasing expects;
// "measurement" would draw a sawtooth in the statistics graph.
doc["stat_cla"] = "total_increasing";
publishEntity(doc, "uptime");
}
}
void clearDiscovery() {
const char *objectIds[] = {"moisture_1", "moisture_2", "moisture_3", "moisture_4",
"moisture_5", "moisture_6", "raw_mv_1", "raw_mv_2",
"raw_mv_3", "raw_mv_4", "raw_mv_5", "raw_mv_6",
"battery_level", "battery_voltage", "wifi_signal", "uptime"};
for (const char *objectId : objectIds) {
char topic[128];
configTopic(topic, sizeof(topic), objectId);
// An empty retained payload is HA's signal to delete the entity.
net::publish(topic, "", true);
}
}
} // namespace ha
+19
View File
@@ -0,0 +1,19 @@
#pragma once
// Home Assistant MQTT auto-discovery (PRD 5.2 / 5.3).
//
// Publishing these retained config topics is what makes a node appear in HA
// with no YAML edits: HA subscribes to homeassistant/sensor/#, sees the
// config, and creates the entities itself.
namespace ha {
// Publishes one retained config topic per entity. Safe to call repeatedly --
// HA treats a repeat as an update of the same unique_id.
void publishDiscovery();
// Clears every retained config topic for this node, which removes its device
// and entities from HA. Not called anywhere by default; wire it to a button
// or a serial command if you want to decommission a node cleanly.
void clearDiscovery();
} // namespace ha
+156
View File
@@ -0,0 +1,156 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include "adc_sampler.h"
#include "config.h"
#include "ha_discovery.h"
#include "net.h"
#include "node_id.h"
#include "power.h"
namespace {
// One JSON object carries every entity's value. A single retained publish per
// cycle costs one round trip instead of ~14, which on the battery profile is
// the difference between a ~3s and a ~6s wake.
void buildStatePayload(char *out, size_t len) {
JsonDocument doc;
doc["node_id"] = node_id::slug();
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
const adc::Reading r = adc::readSensor(i);
char key[16];
snprintf(key, sizeof(key), "moisture_%u", i + 1);
if (r.valid) {
doc[key] = roundf(r.percent * 10.0f) / 10.0f;
} else {
// null renders as "unknown" in HA rather than a plausible-looking 0%.
doc[key] = nullptr;
}
#if PUBLISH_RAW_MV
snprintf(key, sizeof(key), "raw_mv_%u", i + 1);
doc[key] = r.millivolts;
#endif
}
#if defined(POWER_PROFILE_BATTERY)
doc["battery_mv"] = power::batteryMillivolts();
doc["battery_level"] = roundf(power::batteryPercent() * 10.0f) / 10.0f;
// A sleeping node has no meaningful "uptime" -- awake time is a few seconds
// per cycle. Report elapsed time since cold boot, inferred from the wake
// count, so the HA graph still shows how long the node has been in service.
doc["uptime"] = static_cast<uint32_t>(power::bootCount() * DEEP_SLEEP_INTERVAL_S);
#else
doc["uptime"] = static_cast<uint32_t>(millis() / 1000UL);
#endif
doc["wifi_signal"] = net::rssi();
serializeJson(doc, out, len);
}
void sampleAndPublish() {
char payload[512];
buildStatePayload(payload, sizeof(payload));
log_i("state: %s", payload);
net::publish(net::stateTopic(), payload, true);
}
} // namespace
void setup() {
Serial.begin(115200);
// The S3's native USB CDC enumerates after boot; without a short grace
// period the first log lines are lost and every boot looks like a crash.
const uint32_t serialDeadline = millis() + 1500;
while (!Serial && millis() < serialDeadline) {
delay(10);
}
// Rail into a known state before anything else touches the sensor pins.
power::begin();
node_id::begin();
log_i("=== %s (%s) fw %s ===", node_id::friendlyName(), node_id::macAddress(), FIRMWARE_VERSION);
#if defined(POWER_PROFILE_BATTERY)
log_i("profile: battery | wake %lu | cold boot: %s", power::bootCount(),
power::isColdBoot() ? "yes" : "no");
// Arm before anything that can block, so no failure path can outlive it.
power::armAwakeBudget();
power::sensorsOn();
adc::begin();
if (!net::connectWifi() || !net::connectMqtt()) {
// Nothing to be done about it from out in the garden -- do not sit here
// burning the cell. Sleep and try again next cycle.
log_e("network unavailable, sleeping early");
power::deepSleep();
}
// Discovery topics are retained, so the broker replays them to HA on every
// HA restart. Republishing on each wake would be pure waste; a cold boot is
// the one time the firmware (and so the entity set) may have changed.
if (power::isColdBoot()) {
ha::publishDiscovery();
}
sampleAndPublish();
net::disconnect();
power::deepSleep();
#else
log_i("profile: mains | publishing every %lus", PUBLISH_INTERVAL_MS / 1000UL);
power::sensorsOn();
adc::begin();
net::connectWifi();
if (net::connectMqtt()) {
ha::publishDiscovery();
sampleAndPublish();
}
#endif
}
void loop() {
#if defined(POWER_PROFILE_MAINS)
static uint32_t lastPublish = millis();
static bool discoveryPublished = net::isConnected();
if (!net::isConnected()) {
if (net::connectWifi() && net::connectMqtt()) {
// The broker may have restarted and dropped its retained set, so
// re-announce rather than assuming HA still knows about this node.
ha::publishDiscovery();
discoveryPublished = true;
} else {
delay(5000);
return;
}
}
net::loop();
if (millis() - lastPublish >= PUBLISH_INTERVAL_MS) {
lastPublish = millis();
if (!discoveryPublished) {
ha::publishDiscovery();
discoveryPublished = true;
}
sampleAndPublish();
}
delay(50);
#endif
// Battery profile never reaches loop(): setup() ends in deep sleep, and
// waking re-enters setup() from the top.
}
+129
View File
@@ -0,0 +1,129 @@
#include "net.h"
#include <WiFi.h>
#include "config.h"
#include "node_id.h"
#include "secrets.h"
namespace net {
namespace {
WiFiClient s_wifiClient;
PubSubClient s_mqtt(s_wifiClient);
char s_stateTopic[64];
char s_availabilityTopic[64];
bool s_topicsBuilt = false;
void buildTopics() {
if (s_topicsBuilt) {
return;
}
snprintf(s_stateTopic, sizeof(s_stateTopic), MQTT_TOPIC_ROOT "/%s/state", node_id::slug());
snprintf(s_availabilityTopic, sizeof(s_availabilityTopic), MQTT_TOPIC_ROOT "/%s/availability",
node_id::slug());
s_topicsBuilt = true;
}
} // namespace
bool connectWifi() {
if (WiFi.status() == WL_CONNECTED) {
return true;
}
WiFi.mode(WIFI_STA);
WiFi.setHostname(node_id::slug());
// The node has nothing to serve and sleeps most of its life; persisting
// credentials to NVS on every connect just burns flash cycles.
WiFi.persistent(false);
WiFi.setAutoReconnect(true);
log_i("wifi: associating with %s", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
const uint32_t deadline = millis() + WIFI_CONNECT_TIMEOUT_MS;
while (WiFi.status() != WL_CONNECTED && millis() < deadline) {
delay(100);
}
if (WiFi.status() != WL_CONNECTED) {
log_e("wifi: association timed out after %ums", WIFI_CONNECT_TIMEOUT_MS);
return false;
}
log_i("wifi: %s, rssi %d", WiFi.localIP().toString().c_str(), WiFi.RSSI());
return true;
}
bool connectMqtt() {
buildTopics();
if (s_mqtt.connected()) {
return true;
}
s_mqtt.setServer(MQTT_HOST, MQTT_PORT);
// A single HA discovery payload runs well past PubSubClient's 256-byte
// default buffer; publish() silently returns false when the payload does
// not fit, so entities would just never appear in HA.
s_mqtt.setBufferSize(1024);
s_mqtt.setSocketTimeout(MQTT_CONNECT_TIMEOUT_MS / 1000);
const char *user = (strlen(MQTT_USER) > 0) ? MQTT_USER : nullptr;
const char *pass = (strlen(MQTT_PASSWORD) > 0) ? MQTT_PASSWORD : nullptr;
log_i("mqtt: connecting to %s:%d as %s", MQTT_HOST, MQTT_PORT, node_id::slug());
// Last will: if the node drops off without disconnecting, the broker
// publishes "offline" for us and HA greys out the entities.
const bool ok = s_mqtt.connect(node_id::slug(), user, pass, s_availabilityTopic, 0, true,
"offline");
if (!ok) {
log_e("mqtt: connect failed, state %d", s_mqtt.state());
return false;
}
s_mqtt.publish(s_availabilityTopic, "online", true);
return true;
}
void loop() { s_mqtt.loop(); }
bool isConnected() { return WiFi.status() == WL_CONNECTED && s_mqtt.connected(); }
bool publish(const char *topic, const char *payload, bool retain) {
const bool ok = s_mqtt.publish(topic, payload, retain);
if (!ok) {
log_e("mqtt: publish to %s failed (%u bytes, buffer %u)", topic, strlen(payload),
s_mqtt.getBufferSize());
}
return ok;
}
void disconnect() {
// Pump the client so queued bytes reach the socket before we tear it down.
s_mqtt.loop();
s_mqtt.disconnect();
s_wifiClient.flush();
s_wifiClient.stop();
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
int8_t rssi() { return static_cast<int8_t>(WiFi.RSSI()); }
const char *stateTopic() {
buildTopics();
return s_stateTopic;
}
const char *availabilityTopic() {
buildTopics();
return s_availabilityTopic;
}
} // namespace net
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
#include <PubSubClient.h>
// Wi-Fi association and MQTT session management.
namespace net {
// Blocks until associated or WIFI_CONNECT_TIMEOUT_MS elapses.
bool connectWifi();
// Connects to the broker and publishes "online" to the availability topic.
// The LWT is registered here so an unexpected drop marks the node offline.
bool connectMqtt();
// Must be called frequently on the mains profile to service keepalives.
void loop();
bool isConnected();
// Retained by default: HA restarts should not blank out the node's entities.
bool publish(const char *topic, const char *payload, bool retain = true);
// Blocks until the TCP buffer has drained, then closes the session cleanly.
// Called before deep sleep so the last publish is not lost mid-flight.
void disconnect();
int8_t rssi();
// "soil/<node-slug>/state"
const char *stateTopic();
// "soil/<node-slug>/availability"
const char *availabilityTopic();
} // namespace net
+34
View File
@@ -0,0 +1,34 @@
#include "node_id.h"
#include <WiFi.h>
#include <esp_mac.h>
namespace node_id {
namespace {
char s_slug[24];
char s_friendly[24];
char s_mac[18];
} // namespace
void begin() {
uint8_t mac[6] = {0};
// Read the factory-burned base MAC rather than WiFi.macAddress(): this is
// stable from the first instruction of setup() and does not require the
// Wi-Fi driver to have been started yet. In the battery profile we want the
// node id available before the radio comes up.
esp_read_mac(mac, ESP_MAC_WIFI_STA);
snprintf(s_slug, sizeof(s_slug), "soil-node-%02x%02x%02x", mac[3], mac[4], mac[5]);
snprintf(s_friendly, sizeof(s_friendly), "Soil Node %02X%02X%02X", mac[3], mac[4], mac[5]);
snprintf(s_mac, sizeof(s_mac), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
}
const char *slug() { return s_slug; }
const char *friendlyName() { return s_friendly; }
const char *macAddress() { return s_mac; }
} // namespace node_id
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <Arduino.h>
// Unique per-node identity derived from the ESP32's factory MAC (PRD 5.1).
// Two nodes flashed with the identical binary still land on distinct MQTT
// topics and distinct HA entity ids, so adding a node never collides with an
// existing one.
namespace node_id {
// Must be called once in setup(), before anything reads the values below.
void begin();
// e.g. "soil-node-a1b2c3" -- lowercase, safe for MQTT topics and HA object ids.
const char *slug();
// e.g. "Soil Node A1B2C3" -- shown as the device name in Home Assistant.
const char *friendlyName();
// e.g. "a1:b2:c3:d4:e5:f6"
const char *macAddress();
} // namespace node_id
+108
View File
@@ -0,0 +1,108 @@
#include "power.h"
#include <esp_sleep.h>
#include <esp_timer.h>
#include "adc_sampler.h"
namespace power {
#if defined(POWER_PROFILE_BATTERY)
namespace {
// RTC_DATA_ATTR survives deep sleep but not a power cycle or reset -- which is
// precisely the distinction we need to detect a cold boot.
RTC_DATA_ATTR uint32_t s_bootCount = 0;
bool s_coldBoot = false;
} // namespace
#endif
void begin() {
#if defined(POWER_PROFILE_BATTERY)
s_coldBoot = (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED);
s_bootCount++;
pinMode(SENSOR_POWER_GATE_PIN, OUTPUT);
digitalWrite(SENSOR_POWER_GATE_PIN, LOW);
#else
// Mains: drive the gate on and leave it there. Wiring the switch is still
// worthwhile on a mains node -- it lets you power-cycle a wedged probe
// without walking out to the bed.
pinMode(SENSOR_POWER_GATE_PIN, OUTPUT);
digitalWrite(SENSOR_POWER_GATE_PIN, HIGH);
#endif
}
void sensorsOn() {
digitalWrite(SENSOR_POWER_GATE_PIN, HIGH);
delay(SENSOR_STABILIZE_MS);
}
void sensorsOff() {
#if defined(POWER_PROFILE_BATTERY)
digitalWrite(SENSOR_POWER_GATE_PIN, LOW);
#endif
}
#if defined(POWER_PROFILE_BATTERY)
uint16_t batteryMillivolts() {
const uint16_t atPin = adc::readMillivolts(BATTERY_ADC_PIN);
return static_cast<uint16_t>(atPin * BATTERY_DIVIDER_RATIO);
}
float batteryPercent() {
const uint16_t mv = batteryMillivolts();
if (mv <= BATTERY_EMPTY_MV) {
return 0.0f;
}
if (mv >= BATTERY_FULL_MV) {
return 100.0f;
}
return 100.0f * static_cast<float>(mv - BATTERY_EMPTY_MV) /
static_cast<float>(BATTERY_FULL_MV - BATTERY_EMPTY_MV);
}
void deepSleep() {
sensorsOff();
log_i("sleeping for %llus", DEEP_SLEEP_INTERVAL_S);
Serial.flush();
esp_sleep_enable_timer_wakeup(DEEP_SLEEP_INTERVAL_S * 1000000ULL);
esp_deep_sleep_start();
// esp_deep_sleep_start() does not return.
__builtin_unreachable();
}
void armAwakeBudget() {
// esp_timer callbacks run on the esp_timer task rather than in ISR context,
// so calling into the sleep path from here is safe.
const esp_timer_create_args_t args = {
.callback = [](void *) {
log_e("awake budget of %ums exceeded, forcing sleep", AWAKE_BUDGET_MS);
deepSleep();
},
.arg = nullptr,
.dispatch_method = ESP_TIMER_TASK,
.name = "awake_budget",
.skip_unhandled_events = false,
};
esp_timer_handle_t timer = nullptr;
if (esp_timer_create(&args, &timer) != ESP_OK) {
log_e("could not arm awake budget timer");
return;
}
esp_timer_start_once(timer, static_cast<uint64_t>(AWAKE_BUDGET_MS) * 1000ULL);
}
uint32_t bootCount() { return s_bootCount; }
bool isColdBoot() { return s_coldBoot; }
#endif
} // namespace power
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <Arduino.h>
#include "config.h"
// Sensor-rail switching, battery measurement and deep sleep (PRD 4).
namespace power {
// Puts the sensor rail into a known-off state. Call first in setup(), before
// the rail has a chance to be driven by a floating pin.
void begin();
// Energise / cut the 5V sensor rail. On the mains profile the rail is left
// permanently on and sensorsOff() is a no-op, so callers do not need to
// branch on the power profile.
void sensorsOn();
void sensorsOff();
#if defined(POWER_PROFILE_BATTERY)
// Battery terminal voltage, corrected for the divider ratio.
uint16_t batteryMillivolts();
// 0-100% over the configured Li-Ion discharge window.
float batteryPercent();
// Cuts the sensor rail and enters deep sleep for DEEP_SLEEP_INTERVAL_S.
// Does not return.
[[noreturn]] void deepSleep();
// Arms a one-shot timer that forces deep sleep if a wake cycle overruns
// AWAKE_BUDGET_MS.
//
// The per-step timeouts in net.cpp do not cover everything: an AP that
// associates but never hands out a DHCP lease, or a broker that completes the
// TCP handshake but never sends CONNACK, both leave the node awake at ~80mA
// indefinitely. Over a cloudy week that is the whole cell. This is the
// backstop that guarantees every wake ends in sleep.
void armAwakeBudget();
// Wake counter, preserved in RTC memory across deep sleep. 1 on cold boot.
uint32_t bootCount();
// True only on a cold boot (power-on / reset), false on an RTC-timer wake.
// Used to decide whether HA discovery needs republishing.
bool isColdBoot();
#endif
} // namespace power