Files
mattandClaude ad9c457ea7 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>
2026-08-14 13:38:41 -07:00

135 lines
5.9 KiB
C++

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