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:
commit
ad9c457ea7
24 files changed
+2178
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
.claude/
|
||||
include/secrets.h
|
||||
*.bin
|
||||
*.elf
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
@@ -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
@@ -0,0 +1,135 @@
|
||||
# Wiring & Breakout
|
||||
|
||||
Implements PRD §2 (cabling) and §3.1–3.2 (signal integrity, ground bounce).
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||

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

|
||||
|
||||
`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)
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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/<node>/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 |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user