Skip to the content
PCBKitNew board
2 layers100 × 55 mmWellD, the printed circuit board in 3D

ESP32-C6 Zigbee well monitor — reads a 4–20 mA pressure transducer and DS18B20, reports water level, temperature, and battery to Zigbee2MQTT with OTA update support

Size
100 × 55 mm
Copper layers
2
Thickness
1.6 mm
Parts
122
Nets
34
Pads
360
Tracks, vias
0, 33
Pours
3
Routed
Not yet
KiCad
KiCad 10.0 (file 20260206)
Created
9 May 2026
Updated
22 Jul 2026

Overview

WellD is an open-source printed circuit board by kinggh0stee, from the kinggh0stee/WellD repository on GitHub, published under the MIT licence. It is a 2-layer board of 100 × 55 mm with 122 parts on 34 nets.

It is built around the ESP32-C6, AP63205WU and MT3608B, with the DS18B20, ADS1115 and CN3722 among its other parts. Its parts include 31 capacitors, 31 resistors, 12 diodes, 11 connectors and 7 ICs. It belongs with the home automation and IoT and power boards and the ESP32 and RISC-V boards of this library.

Main components

12

README

README.md

WellD

Battery-powered well-level monitor for the ESP32-C6. Each wakeup it reads a 4–20 mA submersible pressure transducer, a DS18B20 temperature probe, and battery voltage; reports them over Zigbee to Zigbee2MQTT; then deep-sleeps until the next cycle.

  • Radio: Zigbee 3.0 over the C6's built-in 802.15.4 — no extra modules
  • Battery life: months on a single 18650 cell at the default 5-minute interval (deep sleep between readings)
  • Adaptive sleep: sleep duration scales with how fast the level is changing — longer windows when stable, shorter during transients
  • Rate-of-change reporting: water_level_rate in cm/h; distinguishes "well recovering" from "well being drawn down"
  • Self-healing: wipes Zigbee NVS state and rejoins fresh after 5 consecutive send failures
  • OTA: Zigbee OTA Upgrade client, dual 1.5 MB app slots
  • Sentinels: open-loop (< 3.5 mA) and short-circuit (> 21 mA) transducer faults both report null water level so Home Assistant can alert on wiring faults

Hardware — Custom PCB

The current design is a purpose-built custom PCB (outline set at layout — working target ≈100 × 60 mm, sized around the on-board 18650 carrier). Design files, BOM, and assembly guide live in hardware/.

Key ICs

ICRole
ESP32-C6 moduleMCU + Zigbee radio
ADS1115 (0x48)16-bit I²C ADC — shunt voltage (AIN0) + battery divider (AIN2)
HT7333-A3.3 V LDO (≈4 µA Iq, 250 mA, ~90 mV dropout). Replaced the AP63203WU buck in the 2026-07-19 1S conversion — the buck's 3.8 V minimum input fails at 1S empty (3.0 V). Below ≈3.4 V the output follows the battery minus dropout; the ESP32-C6 runs down to 3.0 V
MT3608B12 V asynchronous boost for the 4–20 mA loop supply (VLOOP, GPIO5-gated). D15 (SS34 Schottky) rectifies the switch node; a Q3/Q4 load-disconnect switch in series with L1 blocks the permanent VBAT leak to the loop terminals during deep sleep
CN37911S MPPT solar buck charge controller (external SI2319CDS P-FET stage; fixed 4.2 V CV, MPPT ≈5.0 V for 6 V-nominal panels; panel Voc limited to 10 V by the SMAJ10CA input TVS). A solar-powered LM393 cold-charge cutoff clamps charging below ≈+2 °C — zero battery draw at night
TP4056USB-C 1 A linear charger (2026-07-19 1S conversion — replaced the IP2326 boost selection, which itself replaced the non-functional TP5100). Auto-charges whenever VBUS is present (CE strapped high); charge-temperature NTC window ≈ +5…+44 °C
DW01A + FS8205AOn-board 1S battery protection (supervisor + dual N-FET in the cell− path) — added 2026-07-19 with the generic-18650 carrier; a bare cell has no PCM

Test points (production / field debug)

All 15 test point pads are 1.0 mm SMD, DNF (do not fit) — bare copper pads suitable for ICT fixture probes or a hook wire. No component is installed in production. Test points are placed scattered near their associated circuits rather than grouped in a single row.

RefNetRefNet
TP1VBAT (3.0–4.2 V, 1S range)TP9I2C SCL
TP2VLOOP (12 V boost)TP10VSOLAR_IN
TP3+3V3TP11VBAT_RAW (before D5 reverse-polarity MOSFET)
TP4GNDTP12ADS1115 DRDY (GPIO22)
TP5LOOP+ (LOOP_TERM_CH1)TP13FACTORY_RESET (GPIO23 — short to GND while powering on)
TP6LOOP- (LOOP_TERM_CH2)TP14/CHRG_SOLAR (CN3791 charge status)
TP71WIRETP15/CHRG_USB (TP4056 charge status, with R38 pull-up)
TP8I2C SDA

Thermal improvements

The PCB includes a 10 × 10 mm solid GND copper pour on F.Cu and B.Cu centred on the CN3791 solar charger (U7), connected by four thermal vias (0.6 mm drill / 1.0 mm pad) — the CN3791's integrated switch dissipates in-package, so this pour matters more than it did for the CN3722 controller it replaced. A matching pour under the TP4056 EPAD carries the ≈1.3 W of linear charge dissipation at 1 A. A GND via-stitch ring around the MT3608B / L1 boost island provides a low-inductance return path for the switching node without adding a solid pour under L1.

GPIO map

GPIODirectionFunction
18SDAI²C bus (ADS1115 only) — GPIO10/11 are not bonded out on the ESP32-C6-MINI-1 module, so I²C is on GPIO18/19
19SCLI²C bus (ADS1115 only)
12USBNative USB D− → USB-C (console/programming); from U11 USBLC6
5OutputMT3608B EN + Q4 gate — 12 V loop supply (VLOOP), with Q3/Q4 load-disconnect; R27 pull-down holds it off during deep sleep
4—Spare — no schematic connection (the TP4056 USB charger is autonomous, CE strapped high in hardware)
6InputSolar charger /CHRG (CN3791) — solar-charging-active detect (active-low: LOW = charging; R25 external pull-up)
71-WireDS18B20 data
13USBNative USB D+ → USB-C (console/programming)
22InputADS1115 ALERT/DRDY (moved off GPIO12)
23InputFactory reset (hold LOW at boot → NVS erase + rejoin; via TP13; moved off GPIO13)
15OutputBattery-divider enable (BATT_DIV_EN) — drives the Q2 level-shifter that switches the Q5 high-side P-FET
14—Status LED (D4 via R14/SJ3 — moved off GPIO13, which is reserved for factory reset; no firmware support yet)

Connections (screw terminals)

TerminalWireESD / surge protection
LOOP+ / LOOP−4–20 mA transducer, two-wireD9 / D10 SMAJ3.3CA bidirectional TVS (200 W, DO-214AC); D11 SMAJ13A unidirectional TVS on VLOOP; D1 PRTR5V0U2X rail clamp at ADS1115 inputs
1W DATA / GNDDS18B20 data + groundD12 PRTR5V0U2X dual-channel rail clamp (SOT-363) — 0.5 pF added capacitance, well below the 800 pF 1-Wire add limit
BatterySingle generic 18650 (3.0–4.2 V) in the on-board THT carrier BT1 — no cable, no connector (the XT30 was deleted 2026-07-19)On-board 1S protection DW01A + FS8205A in the cell− path (generic bare cells have no PCM): over-discharge ≈2.4 V, over-charge ≈4.3 V, over-current. D13 SMAJ5.0A TVS at the cell+ tab; D5 AO3407 P-ch MOSFET in series on cell+ blocks a reversed-inserted cell; R31 10 kΩ gate pull-down holds D5 on
SOLAR+/−Solar panel, ≤ 10 V Voc (6 V-nominal panels — 1S conversion 2026-07-19; the 2S design accepted 12 V panels)D14 SMAJ10CA bidirectional TVS (400 W, DO-214AC) at the terminal, plus second-stage D8 SMAJ10CA at the CN3791 VIN

RF / antenna

The ESP32-C6-MINI-1U-H4 module exposes a U.FL RF port. A ~50 mm internal pigtail — Taoglas CAB.100.07.0050B (U.FL female to SMA male, RG178 coax) — connects the module to J3, an Amphenol 132289 SMD edge-launch SMA connector on the top board edge. PCBWay hand-attaches the pigtail after reflow. An external 2.4 GHz SMA antenna (e.g. Taoglas FXP73 rubber-duck, 2 dBi) screws directly onto J3.

Assembly notes (PCBWay)

Connector placement:

  • Bottom edge (left to right): J12 (solar, 2-pos), J4 (loop ch1, 3-pos), J5 (loop ch2, 3-pos), J6 (DS18B20, 3-pos), J7 (spare sensor, 3-pos), J10 (programming header, 6-pin 1.27 mm pitch)

  • Left edge: J13 (USB-C — charging and console/programming, SMD; D± wired to the ESP32 native USB pins). The BT1 18650 carrier occupies its own top-side band along one long edge (board size is unconstrained, so the outline grows to fit)

  • Top edge: J3 (Amphenol 132289 SMA edge-launch)

  • J4, J5, J6, J7, J12 — Phoenix Contact MC 1.5/x-G-3.5 THT terminal blocks, wave-soldered.

  • J10 — 1.27 mm pitch THT vertical programming header (6-pin), wave-soldered.

  • J8, J9 (expansion headers) — DNF (do not populate) on all production boards. Footprints are present on the PCB for future use; no components are installed.

  • J3 pigtail — hand-attached by PCBWay post-reflow; not wave-soldered.

4–20 mA loop

The MT3608B boost converter lifts VLOOP to 12 V to power the transducer loop. The boost is asynchronous: D15 (SS34 Schottky) carries the switch node to VLOOP, and a Q3 (P-FET) / Q4 (level-shifter) load-disconnect switch in series with the inductor prevents VBAT from leaking through L1 + D15 to the loop terminals during deep sleep. GPIO5 drives both the MT3608B EN and the Q4 gate; R27 pulls the net low while GPIO5 is isolated in deep sleep.

Firmware gates GPIO5 high for 10 ms (MT3608B soft-start plus rail settling through the Q3 load-disconnect P-FET into C20/C22 — raised from 5 ms in the 2026-07 PCB review) before reading, then drives it low immediately after. Maximum ON time is 100 ms.

The ADS1115 measures the voltage across a shunt resistor on the loop return. PGA ±2.048 V, single-shot at 860 SPS, AIN0 vs GND.

10 nF X7R bypass capacitors (C_SH1 / C_SH2) are placed directly across each 100 Ω shunt resistor (R2 / R4) to suppress HF pickup from the loop cable. The RC corner is ~1.6 MHz, well above the measurement bandwidth.

LOOP+ and LOOP– screw terminals are protected by SMAJ3.3CA bidirectional TVS diodes (D9 / D10, 200 W, DO-214AC/SMA). The 3.3 V standoff is above the normal 2 V loop maximum so the TVS does not conduct in normal operation; the ~5.3 V clamp at 10 A provides tighter protection for the ADS1115 AIN absolute-maximum chain than the previous SMAJ5.0CA devices.

The 1W DATA terminal (J6) is protected by D12, a PRTR5V0U2X dual-channel rail clamp in SOT-363 (the same part as D1 at the ADS1115 inputs). D12 clamps the data line to 3V3 + ~0.5 V during an ESD transient in sub-nanosecond response time. The added capacitance per channel is 0.5 pF, which is negligible compared to the 800 pF DS18B20 bus limit and does not affect 1-Wire timing.

Battery monitoring

Battery voltage is read via the gated resistor divider (R7 100 kΩ / R8 100 kΩ) on ADS1115 AIN2. The divider is disconnected on the high side by Q5 (AO3407 P-FET) between VBAT and R7; GPIO15 drives the Q2 level-shifter (AO3400A) that pulls Q5's gate low. GPIO15 is pulsed HIGH for 1 ms before reading, then LOW immediately after — C8 across R8 is 1 nF so the mid-node settles well inside that 1 ms window. With Q5 off the divider is completely dead: AIN2 rests at 0 V through R8, eliminating sleep leakage into the ADS1115 input ESD diodes. The divider ratio is 2.00 (200/100), mapping 4.2 V full charge to 2.1 V — the firmware reads AIN2 with the ADS1115 ±4.096 V PGA (the ±2.048 V range used for the loop shunt would clip right at full charge).

The BAT+ rail protection chain: J1 BAT+ → D13 (SMAJ10CA TVS, at terminal) → D5 S→D (AO3407 reverse-polarity MOSFET) → VBAT system rail.

D13 is a SMAJ5.0A unidirectional TVS (400 W, DO-214AC/SMA). Its 5 V standoff is above the 4.2 V 1S full-charge voltage so it does not conduct in normal operation; its forward diode clamps negative transients. Cable-induction surges are absorbed before they reach D5 and downstream circuits.

D5 (AO3407, SOT-23) is a P-channel MOSFET placed in series on BAT+. Its body diode blocks reverse current when the battery is connected with reversed polarity; in normal operation the MOSFET is on (Vgs = −Vbat — still twice the −1.5 V Vgs(th) max even at the 3.0 V 1S cutoff, though RDS(on) rises somewhat below the −4.5 V datasheet test point; the drop stays in the tens of millivolts at ≤1 A).

R31 (10 kΩ, 0402) connects D5's gate to GND. R31 holds Vgs = −Vbat so the MOSFET is unconditionally on whenever a battery is present, regardless of firmware state.

Enclosure

The enclosure is designed in hardware/case/welld_case.scad. No separate battery bay is needed any more — the single 18650 lives in the top-side on-board carrier (BT1), so the case only has to clear ≈19–21 mm above the board. The external footprint follows the final board outline (unconstrained; working target ≈100 × 60 mm) and is sized at layout time. A USB-C slot on the left short wall accommodates the J13 connector (TP4056 charging and USB console/programming via the ESP32 native USB). The case dimensions consume the final board layout, so case work stays last.

For concrete underside mounting, the lid grows four corner wings with M6 anchor-bolt clearance holes. The bolt pattern centre-to-centre span is 127 × 82 mm (X × Y). Use the drill_template() module to print a 1:1 paper/card drill guide before installing anchor bolts.


Quickstart

Prerequisites

Build and flash

bash
git clone https://github.com/kinggh0stee/WellD.git
cd WellD

# one-time: select the target chip
idf.py set-target esp32c6

# local config — copy the template and edit it (gitignored)
cp sdkconfig.defaults.local.example sdkconfig.defaults.local
$EDITOR sdkconfig.defaults.local

# build and flash
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor

Or use the interactive menu: idf.py menuconfig → WellD Configuration.


Configuration

All options have sensible defaults. Only change what differs from your hardware.

Sensor

OptionDefaultDescription
CONFIG_WELLD_SENSOR_SHUNT_MILLIOHMS100000Loop shunt resistor value in milliohms (100 Ω = 100000)
CONFIG_WELLD_SENSOR_MAX_DEPTH_CM600Full-scale depth at 20 mA, in cm
CONFIG_WELLD_SENSOR_OFFSET_CM0Level offset in cm applied after conversion (±600). Persisted in NVS; runtime-writable via sensor_set_offset_cm()
CONFIG_WELLD_DS18B20_GPIO7GPIO connected to DS18B20 data pin

Battery

OptionDefaultDescription
CONFIG_WELLD_BATT_REPORT_ENABLEDyReport battery voltage over Zigbee (EP2, Analog Input). The battery is always measured internally via ADS1115 AIN2 (R7/R8 divider) for the low-battery guard; disabling this only removes the Zigbee endpoint
CONFIG_WELLD_BATT_DIVIDER_RATIO200Divider ratio × 100 — matches the 100 kΩ / 100 kΩ divider (R7/R8): (100+100)/100 = 2.00
CONFIG_WELLD_BATT_FULL_MV4200Voltage (mV) reported as 100 % by the Z2M converter (1S full charge)
CONFIG_WELLD_BATT_EMPTY_MV3000Voltage (mV) at or below which the device skips the Zigbee send and all NVS writes to protect flash (1S minimum safe discharge)

Sleep

OptionDefaultDescription
CONFIG_WELLD_SLEEP_DURATION_SEC300Baseline sleep between readings (seconds). When adaptive sleep is enabled, this is the "normal rate" window; the heavy-pumping band targets default/2 (integer division), so very short base values fall below WELLD_SLEEP_MIN_SEC and are clamped, making that band unreachable
CONFIG_WELLD_ADAPTIVE_SLEEP_ENABLEDyScale sleep duration to the observed level rate-of-change
CONFIG_WELLD_SLEEP_MIN_SEC60Lower bound on adaptive sleep (seconds)
CONFIG_WELLD_SLEEP_MAX_SEC1800Upper bound on adaptive sleep (seconds)

Zigbee

OptionDefaultDescription
CONFIG_WELLD_ZIGBEE_CHANNEL_MASK0x07FFF800Channels to scan; narrow to your coordinator's channel to speed up joins
CONFIG_WELLD_ZIGBEE_SEND_DELAY_MS2000Time the stack stays alive after sending, to allow coordinator ACK

Hardware I/O

OptionDefaultDescription
CONFIG_WELLD_I2C_SDA_GPIO18I²C SDA pin (ADS1115). GPIO10/11 unavailable on the MINI-1 module
CONFIG_WELLD_I2C_SCL_GPIO19I²C SCL pin (ADS1115)
CONFIG_WELLD_VLOOP_GPIO5MT3608B EN + Q3/Q4 load-disconnect — 12 V loop supply enable. Held HIGH ≥ 10 ms before any 4–20 mA read (rail settles through the Q3 P-FET into C20/C22)
CONFIG_WELLD_SOLAR_DETECT_GPIO6Solar charger /CHRG (CN3791) — solar-charging-active detect (active-low)
CONFIG_WELLD_BATT_DIV_EN_GPIO15Battery-divider enable — Q2 level-shifter gate, switching the Q5 high-side P-FET
CONFIG_WELLD_ADS1115_DRDY_GPIO22ADS1115 ALERT/DRDY interrupt input (moved off GPIO12, now native USB D−)

Advanced / diagnostics

OptionDefaultDescription
CONFIG_WELLD_ADC_OVERSAMPLE_ENABLEDnTake 3 ADS1115 samples per measurement and report the median. Opt-in for electrically noisy installations (pump motor interference, long cable runs). Costs ~8 ms extra active time per wakeup; leave off for maximum battery life
CONFIG_WELLD_TEMP_COMPENSATION_ENABLEDyApply water-density correction to the level reading: level / (1 + alpha * (temp - 20)). Suppressed automatically when DS18B20 fails (temp ≤ −127 °C)
CONFIG_WELLD_TEMP_COMPENSATION_PPM_PER_C207Water density coefficient in ppm/°C. Depends on WELLD_TEMP_COMPENSATION_ENABLED. Typical value for fresh water is 207 ppm/°C; range 0–500
CONFIG_WELLD_DS18B20_RESOLUTION_BITS11DS18B20 conversion resolution: 9 / 10 / 11 / 12 bit. Conversion time: 94 / 188 / 375 / 750 ms. 11-bit gives 0.0625 °C steps — adequate for well water monitoring and saves 375 ms per wakeup vs 12-bit
CONFIG_WELLD_FACTORY_RESET_GPIO23If this GPIO is held LOW at boot, NVS is erased and the device rejoins Zigbee fresh (moved off GPIO13, now native USB D+). Internal pull-up enabled; leave unconnected for normal operation
CONFIG_WELLD_SELFTEST_ENABLEDnExercise all peripherals on every boot and log PASS/FAIL. Adds ~200 ms to boot time; useful for factory QA and PCB bring-up
CONFIG_WELLD_DIAGNOSTIC_MODE_ENABLEDnStay awake for WELLD_DIAGNOSTIC_STAY_AWAKE_SEC after each sensor read, printing verbose logs. Deep sleep is still entered after the window expires
CONFIG_WELLD_DIAGNOSTIC_STAY_AWAKE_SEC60Stay-awake window when diagnostic mode is enabled (seconds, 10–300). Depends on WELLD_DIAGNOSTIC_MODE_ENABLED
CONFIG_WELLD_ZIGBEE_BACKOFF_MAX_MS500Random delay before Zigbee network steering on each wakeup (0–5000 ms). Jitters start time to avoid simultaneous steering from multiple devices after a power outage
CONFIG_WELLD_OTA_STALL_TIMEOUT_SEC240Maximum wall-clock time an OTA download may take before it is considered stalled and aborted (60–600 s)
CONFIG_WELLD_RTC_LOG_PRINT_ENABLEDnPrint RTC event log on wakeup — enable when diagnosing a retrieved device over USB; leave off in production

Zigbee2MQTT integration

1. Install the external converter (before pairing)

bash
cp zigbee2mqtt/welld.js /opt/zigbee2mqtt/data/

Add it to /opt/zigbee2mqtt/data/configuration.yaml:

yaml
external_converters:
  - welld.js

Restart Zigbee2MQTT:

bash
sudo systemctl restart zigbee2mqtt

2. Pair the device

Enable permit-join in configuration.yaml, power up the board, and wait for the join. First pairing takes up to 25 s; subsequent wakeups rejoin in a few seconds using cached network state in NVS. Disable permit-join once paired.

3. MQTT payload

The device publishes to zigbee2mqtt/<friendly_name> on each wakeup:

json
{
  "water_level": 3.42,
  "water_level_rate": 12.5,
  "temperature": 12.3,
  "battery_voltage": 7.41,
  "battery": 59,
  "zb_fails": 0,
  "device_lqi": 156,
  "solar_charging": false
}
  • water_level is null when the pressure loop reads below 3.5 mA (open circuit / broken wire) or above 21 mA (short circuit / shorted transducer). Trigger a Home Assistant alert on water_level is null to catch both fault conditions.
  • water_level_rate is signed cm/h. Positive = recovering, negative = drawing down. Omitted on the first valid wakeup after a cold boot and during open-loop cycles.
  • temperature is omitted when the DS18B20 is not detected.
  • battery_voltage comes from the ADS1115 AIN2 voltage divider (R7/R8 100 kΩ / 100 kΩ, switched high-side by Q5 under GPIO15 control).
  • battery is a percentage derived from battery_voltage using the device options battery_full_mv / battery_empty_mv. Options are coerced to numbers (YAML strings like "4200" are accepted) and fall back to the defaults 4200 / 3000 mV for anything missing or non-numeric. If the thresholds are misconfigured (battery_full_mv <= battery_empty_mv), the voltage is still published but the percentage is omitted.
  • zb_fails counts consecutive Zigbee send failures since the last success (0 = healthy; the device auto-rejoins at 5).
  • device_lqi is the link quality measured on the device side (EP6, 1–255) — the LQI of the device's parent entry in its own neighbor table. It is distinct from Zigbee2MQTT's own linkquality field, which comes from the coordinator side of the link. A value of 0 means "unknown" (no parent entry found) and is not published. Firmware ≤ 1.0.2 always sent 0, so the key only appears from firmware 1.0.3 onwards.
  • solar_charging is a boolean (binary expose): true = the solar MPPT charger (CN3791) is actively charging, false = not charging.
  • When previous sends failed, the buffered readings (up to 8, RTC store-and-forward) are burst-reported oldest-first before the current reading, so the coordinator receives the full history in order.
  • The converter rejects malformed reports: any non-numeric or non-finite presentValue is dropped instead of being published.

4. Home Assistant

The Z2M–Home Assistant integration auto-creates these entities:

EntityUnit / values
sensor.<name>_water_levelm
sensor.<name>_water_level_ratecm/h
sensor.<name>_temperature°C
sensor.<name>_battery_voltageV
sensor.<name>_battery% (omitted when battery_full_mv <= battery_empty_mv)
sensor.<name>_zb_failscount, 0–255
sensor.<name>_device_lqidevice-side LQI, 1–255 (needs firmware ≥ 1.0.3; 0 = unknown is never published)
binary_sensor.<name>_solar_chargingon / off

OTA firmware updates

The device runs a Zigbee OTA Upgrade client. Zigbee2MQTT (ota: true in the converter) distributes images automatically once placed in its OTA folder.

1. Bump the version

Edit PROJECT_VER in the root CMakeLists.txt:

cmake
set(PROJECT_VER "1.0.2")   # MAJOR.MINOR.PATCH

The OTA file-version 0xMMmmPP00 (major << 24 | minor << 16 | patch << 8; e.g. 1.0.2 → 0x01000200) is derived from PROJECT_VER at build time — don't edit OTA_FW_VERSION directly. The build fails loudly on a malformed PROJECT_VER.

2. Build the OTA image

After idf.py build, wrap the binary using ota_image_create.py from the esp-zigbee-sdk:

bash
python path/to/ota_image_create.py \
    --manufacturer-code 0x1234 \
    --image-type 0x0001 \
    --file-version 0x01000200 \
    --output welld-v1.0.2.zigbee \
    build/welld.bin

--manufacturer-code and --image-type must match the constants in components/zigbee/zigbee.c (0x1234 / 0x0001). The device rejects mismatched headers — don't wildcard them to 0xFFFF.

--file-version must increase monotonically and match the 0xMMmmPP00 encoding of PROJECT_VER; the device only installs images with a higher version than the running one. (CI derives it automatically and uploads the packed .zigbee file as a build artifact.)

3. Deploy

bash
cp welld-v1.0.2.zigbee /opt/zigbee2mqtt/data/ota/

Zigbee2MQTT picks the file up automatically. On the next wakeup the device queries for an update, downloads it in 128-byte blocks over Zigbee, and reboots into the new firmware.

Upgrading from ≤ 1.0.2: firmware 1.0.3 adds the battery endpoint (EP2) to the device descriptor. Zigbee2MQTT caches the endpoint list from the original interview, so after the OTA completes, re-interview the device (Z2M frontend → device → Reconfigure/Interview, or re-pair) to surface the new battery_voltage / battery entities. Also note the Kconfig option rename below — a stale CONFIG_WELLD_BATT_ADC_CHANNEL=-1 line in sdkconfig.defaults.local is silently ignored; set CONFIG_WELLD_BATT_REPORT_ENABLED=n if you want battery reporting to stay off.

OTA rollback guard

The firmware tracks consecutive boot failures in RTC memory. If 3 consecutive boots complete without a successful Zigbee send, the device automatically rolls back to the previous image in the inactive OTA slot (esp_ota_get_next_update_partition() — nothing in this app-level scheme ever marks the old slot invalid, so esp_ota_get_last_invalid_partition() would never fire). The rollback is skipped when the inactive slot holds no valid image (first flash), and the boot-attempt counter is cleared before the restart so the rolled-back image doesn't immediately bounce back to the broken slot. This prevents a bad OTA image from permanently bricking the device in the field.


Operations

Level offset calibration

Use CONFIG_WELLD_SENSOR_OFFSET_CM (or sensor_set_offset_cm() at runtime) to shift the reported level without moving the transducer. For example, if the transducer hangs 15 cm above the well bottom, set the offset to -15 to report depth from the bottom of the well.

The offset is persisted in NVS (key offset_cm in namespace welld) and clamped to ±600 cm.

Adaptive sleep & rate of change

The device remembers its last valid reading and elapsed time in RTC slow memory (preserved across deep sleep, zeroed on cold boot). At each wakeup it computes a signed rate of change in cm/h and uses it to:

  1. Report water_level_rate to Zigbee2MQTT.

  2. Adjust the next sleep duration when CONFIG_WELLD_ADAPTIVE_SLEEP_ENABLED=y:

    Rate (cm/h)Sleep targetInterval at defaultsCondition
    0 – 2max1800 swell at rest
    2 – 5default × 2600 sslow drawdown
    5 – 10default300 sactive pumping
    10 – 20default / 2150 sheavy pumping
    ≥ 20min60 srapid event

    The middle bands scale from WELLD_SLEEP_DURATION_SEC (integer division for the /2 band); the result is always clamped to [WELLD_SLEEP_MIN_SEC, WELLD_SLEEP_MAX_SEC], so a very short base value makes the heavy-pumping band collapse into the minimum.

Set CONFIG_WELLD_ADAPTIVE_SLEEP_ENABLED=n for a fixed reporting cadence.

Factory reset

Hold CONFIG_WELLD_FACTORY_RESET_GPIO (default GPIO23) LOW during boot to erase NVS and force a clean Zigbee rejoin. On the custom PCB, short the TP13 pad to GND (TP4) while powering on. The internal pull-up is enabled; leaving the pin unconnected causes normal operation. This is the same NVS erase that occurs automatically after 5 consecutive Zigbee send failures.

I2C bus recovery

On every boot, sensor_i2c_init() checks whether SDA is stuck LOW (a common symptom of a previous aborted transaction). If SDA is LOW, it clocks 9 SCL pulses to force any stuck I2C slave to release the bus before initialising the I2C master. This is a no-op under normal conditions.

Pre-sleep GPIO and I2C cleanup

Before every esp_deep_sleep() call, sensor_pre_sleep_cleanup() is invoked automatically. It removes the ADS1115 DRDY ISR (GPIO22), deletes the I2C semaphore, and releases the I2C bus handles for GPIO18 (SDA) and GPIO19 (SCL). Without this step the I2C driver retains ownership of those GPIOs across the sleep boundary; the driver context is invalid after wakeup and the bus can be left in a partially-driven state. Combined with esp_sleep_gpio_isolate(), all GPIOs are in a defined low-leakage state before the core powers down.

Low-battery protection

When battery voltage drops to or below CONFIG_WELLD_BATT_EMPTY_MV, the device skips the Zigbee send and all NVS writes, and sleeps for the maximum interval to conserve charge. The rate history is invalidated entirely on this path — the level can drift arbitrarily far across a low-battery blackout, so the first valid reading after recovery re-seeds the history and reports no rate once (the EP4 report is skipped) instead of producing a false rate spike.

Expected serial output

Normal cycle:

text
I (sensor): voltage=1485 mV  current=14850 µA  level=3.42 m
I (sensor): temperature=12.3 °C
I (sensor): battery=7.41 V (ADS1115 AIN2)
I (zigbee): joined; reporting level=3.42 m battery=7.41 V temp=12.3 °C rate=12.5 cm/h
I (main):   sleeping 300 s

Solar charging active:

text
I (main): solar charging active (solar /CHRG asserted)

No coordinator in range:

text
E (zigbee): timeout — no coordinator found
W (main):   Zigbee send failed (1/5)
I (main):   sleeping 300 s

After 5 consecutive failures the NVS partition is erased on the next boot, forcing a clean Zigbee rejoin.

Pressure transducer disconnected (open loop, < 3.5 mA):

text
E (sensor): transducer open loop (voltage=12 mV, 120 µA < 3.5 mA)

Pressure transducer short circuit (> 21 mA):

text
E (sensor): transducer short-circuit (voltage=2400 mV, 24000 µA > 21 mA)

DS18B20 ROM change (sensor replaced):

text
W (sensor): DS18B20 ROM changed: stored=28ff1234ab000002 active=28ff5678cd000003

Power

The device spends nearly all of its time in deep sleep. Each wakeup is typically 6–12 seconds of active current (I²C reads, Zigbee send, 1-Wire conversion), followed by a sleep window of 1–30 minutes depending on rate-of-change. At the default 5-minute interval, average current is well under 1 mA — months of runtime on a single 18650 (3.6 V nominal, ≈3–3.4 Ah for a quality generic cell). The HT7333-A LDO's ≈4 µA quiescent current keeps the standby draw negligible, and the Q3/Q5 high-side disconnect switches leave no DC path from VBAT during sleep except the LDO and IC quiescents. Charging is dual-path: solar via the CN3791 MPPT charger and USB-C via the TP4056 (both autonomous — they charge whenever their input is present).

Both power-control GPIOs (VLOOP, BATT_DIV_EN) are driven low and the GPIO matrix is isolated (esp_sleep_gpio_isolate()) before every esp_deep_sleep() call to eliminate leakage through partially-driven outputs during sleep.


Development

Project layout

text
main/                    wakeup orchestration, NVS fail counter, RTC history
components/sensor/       I²C (ADS1115), DS18B20, GPIO power control
components/zigbee/       esp-zigbee-lib wrapper, OTA client, BDB commissioning task
components/welld_core/   pure helpers (rate-of-change, adaptive sleep, ZCL encoding)
zigbee2mqtt/welld.js     external converter for Zigbee2MQTT
hardware/pcb/            PCB design reference, BOM, Gerber generation script
hardware/case/           OpenSCAD parametric enclosure (cell lives on the PCB — no battery bay)
test/sensor/             on-device Unity tests (1-Wire, NVS round-trips)
test/welld_core/         on-device Unity tests (rate, sleep, ZCL helpers)
test/host/               host CMake test project — no ESP-IDF required

Host tests

Pure helpers (sensor_level_from_mv, sensor_battery_from_mv, sensor_temp_in_range, welld_*) are exercised by a plain CMake project under test/host/:

bash
cmake -S test/host -B test/host/build
cmake --build test/host/build
ctest --test-dir test/host/build --output-on-failure

CI runs these on every push. No ESP-IDF or hardware required.

On-device tests

For NVS round-trips and 1-Wire / ADC paths the host runner can't cover:

bash
idf.py -C test/sensor build flash monitor
idf.py -C test/welld_core build flash monitor

CI builds these to catch compile breaks but cannot execute them (real hardware needed).

CI

.github/workflows/build.yml runs six jobs:

  • ESP-IDF build — v6.0.1, esp32c6, builds firmware and OTA image, uploads *.bin, *.elf, *.map, *.zigbee, dependencies.lock
  • C static analysis — cppcheck over all main/ and components/ C sources; fails the build on any finding
  • Version bump check — PR-only gate; fails if firmware sources changed without bumping PROJECT_VER in CMakeLists.txt
  • Host unit tests — plain ubuntu-latest, ctest, fetches Unity v2.6.0
  • On-device test build — compile-only check for test/sensor and test/welld_core
  • Z2M converter tests — Node 20, npm test in zigbee2mqtt/

Legacy: dev board wiring

If building from an off-the-shelf ESP32-C6 dev board instead of the custom PCB, wire the pressure transducer shunt directly to an ADC1 pin and power the loop externally. The ADS1115 and MT3608B boost are not available on a bare dev board — configure CONFIG_WELLD_SENSOR_ADC_CHANNEL to the ESP ADC channel used and set CONFIG_WELLD_BATT_REPORT_ENABLED=n (no battery divider on a bare dev board, so EP2 would only report read errors). The power-control GPIO options (VLOOP, BATT_DIV_EN) are still compiled in but can be left disconnected if the solar charger is absent.

Parts

122
QtyValueFootprintReferences
13100nFCapacitor_SMD:C_0402_1005MetricC7, C8, C9, C10, C11, C13, C21, C23, C25, C30, C31, C32, C33
910uFCapacitor_SMD:C_0805_2012MetricC4, C6, C15, C16, C17, C18, C19, C28, C29
64k7Resistor_SMD:R_0402_1005MetricR6, R9, R10, R26, R37, R38
510kResistor_SMD:R_0402_1005MetricR11, R12, R13, R21, R31
4100kResistor_SMD:R_0402_1005MetricR8, R24, R34, R36
41uFCapacitor_SMD:C_0402_1005MetricC3, C5, C12, C24
4MountingHole_3.2mm_M3MountingHole:MountingHole_3.2mm_M3MH, MH, MH, MH
3100RResistor_SMD:R_0402_1005MetricR3, R5, R28
2100RResistor_SMD:R_0805_2012MetricR2, R4
210nFCapacitor_SMD:C_0402_1005MetricC34, C35
24.7uHInductor_SMD:L_Vishay_IFSC-1515AH_4x4x1.8mmL1, L2
25k1Resistor_SMD:R_0402_1005MetricR50, R51
2PRTR5VPackage_TO_SOT_SMD:SOT-363_SC-70-6D1, D12
2SMAJ28CADiode_SMD:D_SMAD8, D14
2SMAJ3.3Diode_SMD:D_SMAD9, D10
1+3V3TestPoint:TestPoint_Pad_1.0x1.0mmTP3
11.91MResistor_SMD:R_0402_1005MetricR23
110uF 25VCapacitor_SMD:C_0805_2012MetricC22
1132289Connector_Coaxial:SMA_Amphenol_132289_EdgeMountJ3
11WIRETestPoint:TestPoint_Pad_1.0x1.0mmTP7
11kResistor_SMD:R_0402_1005MetricR14
11k DNFResistor_SMD:R_0402_1005MetricR22
11k2Resistor_SMD:R_0402_1005MetricR35
122uF 25VCapacitor_SMD:C_1206_3216MetricC20
12k0Resistor_SMD:R_0402_1005MetricR19
1330kResistor_SMD:R_0402_1005MetricR7
133RResistor_SMD:R_0402_1005MetricR32
136kResistor_SMD:R_0402_1005MetricR20
14-20mA_1TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_HorizontalJ4
14-20mA_2TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_HorizontalJ5
14.7uFCapacitor_SMD:C_0805_2012MetricC27
1604kResistor_SMD:R_0402_1005MetricR33
1ADS1115Package_SO:MSOP-10_3x3mm_P0.5mmU9
1ADS_DRDYTestPoint:TestPoint_Pad_1.0x1.0mmTP12
1AO3407Package_TO_SOT_SMD:SOT-23D5
1AP63205Package_TO_SOT_SMD:SOT-23-6U1
1BOOTButton_Switch_SMD:SW_Push_1P1T_NO_CK_KSC6xxGSW2
1BSS123Package_TO_SOT_SMD:SOT-23Q2
1CHRG_SOLTestPoint:TestPoint_Pad_1.0x1.0mmTP14
1CHRG_USBTestPoint:TestPoint_Pad_1.0x1.0mmTP15
1CN3722Package_SO:SOIC-8_3.9x4.9mm_P1.27mmU7
1DS18B20TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_HorizontalJ6
1ESP32-C6Espressif:ESP32-C6-MINI-1UU6
1FerriteInductor_SMD:L_0402_1005MetricFB1
1GNDTestPoint:TestPoint_Pad_1.0x1.0mmTP4
1GPIOConnector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_VerticalJ9
1I2CConnector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_VerticalJ8
1LEDLED_SMD:LED_0603_1608MetricD7
1LED_DISJumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mmSJ3
1LOOP1TestPoint:TestPoint_Pad_1.0x1.0mmTP5
1LOOP2TestPoint:TestPoint_Pad_1.0x1.0mmTP6
1MBRS140Diode_SMD:D_SOD-123D6
1MT3608BPackage_TO_SOT_SMD:SOT-23-6U8
1PROGConnector_PinHeader_1.27mm:PinHeader_1x06_P1.27mm_VerticalJ10
1PolyfuseFuse:Fuse_1206_3216MetricF2
1RESETButton_Switch_SMD:SW_Push_1P1T_NO_CK_KSC6xxGSW1
1SCLTestPoint:TestPoint_Pad_1.0x1.0mmTP9
1SDATestPoint:TestPoint_Pad_1.0x1.0mmTP8
1SMAJ10CADiode_SMD:D_SMAD13
1SMAJ13Diode_SMD:D_SMAD11
1SolarTerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-2-3.5-H_1x02_P3.50mm_HorizontalJ12
1SpareTerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_HorizontalJ7
1StatusLED_SMD:LED_0603_1608MetricD4
1TP5100Package_SO:SOIC-8_3.9x4.9mm_P1.27mmU12
1UART_RXJumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mmSJ5
1UART_TXJumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mmSJ4
1USB-CConnector_USB:USB_C_Receptacle_GCT_USB4135-GF-A_6P_TopMnt_HorizontalJ13
1USBLC6Package_TO_SOT_SMD:SOT-23-6U11
1VBATTestPoint:TestPoint_Pad_1.0x1.0mmTP1
1VBAT_RAWTestPoint:TestPoint_Pad_1.0x1.0mmTP11
1VBOOSTJumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mmSJ1
1VLOOPJumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mmSJ2
1VLOOPTestPoint:TestPoint_Pad_1.0x1.0mmTP2
1VSOLARTestPoint:TestPoint_Pad_1.0x1.0mmTP10
1XT30PW-FWellD:XT30PW-F_RightAngleJ1

Questions

How big is the WellD PCB?

It measures 100 × 55 mm and has 2 copper layers, 1.6 mm thick.

What parts does the WellD use?

122 parts, built around the ESP32-C6, AP63205WU, MT3608B and DS18B20. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the WellD KiCad files?

From the kinggh0stee/WellD repository on GitHub, at the commit this page was made from: the board is hardware/pcb_old/welld.kicad_pcb and its schematic hardware/pcb/interfaces.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the WellD design in my own project?

Its repository publishes it under the MIT licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).

Similar boards

  • 2 layers99.6 × 69.6 mmRGBW Lampe, the printed circuit board in 3D

    RGBW Lampe

    martinclausen2/RGBW-Lampe

    The "RGBW-Lampe" projects allows to create full color lighting appliances.

    162 parts
  • 2 layers48 × 32 mmBuck regulator, the printed circuit board in 3D

    Buck regulator

    HacklabJKL/sergei_cnc2

    Second version of Hacklab's custom built CNC machine

    28 parts6
  • 2 layers40 × 40 mmIkoka nano meshtastic device, the printed circuit board in 3D

    Ikoka nano meshtastic device

    ndoo/ikoka-nano-meshtastic-device

    A Meshtastic device based on the Seeed Studio XIAO nRF52840 and EBYTE E22-XXXMXXS series LoRa modules. Pinout matches the Meshtastic xiaoble variant.

    22 parts104CERN-OHL-P-2.0
  • 2 layersSmd aio, the printed circuit board in 3D

    Smd aio

    freeDSP/freeDSP-ALLinONE

    All-in-One Version with amp and ADAU1701

    434 parts17
  • 6 layers100 × 70 mmCanardBoard, the printed circuit board in 3D

    CanardBoard

    waterloo-rocketry/canhw

    Hardware monorepo avionics PCB

    280 parts15GPL-3.0
  • 2 layers148.3 × 108.4 mmRJ Schematic v1.0, the printed circuit board in 3D

    RJ Schematic v1.0

    Jscott44/RTEP5-the-raspberry-jam

    Realtime digital guitar effects pedal designed for Raspberry PI, written in C++

    121 parts13MIT
  • 4 layers95.9 × 46 mmDragon 4L R02, the printed circuit board in 3D

    Dragon 4L R02

    CyberSaiyanIT/why2025-badge

    Cyber Saiyan community has designed and developed a special gadget for WHY2025. The badge was designed to recall the dragon spheres, and is an updated version of RomHack Camp 2022 badge, both in term of design and…

    127 parts10MIT
  • 2 layers80 × 95 mmLaserBoard, the printed circuit board in 3D

    LaserBoard

    matthewSorensen/pewpew-laserboard

    LaserBoard is an open source controller for fiber laser marking systems, based around a Teensy 4.1 MCU board. LaserBoard provides a USB interface compatible with most low-cost fiber laser sources and XY-100-speaking…

    123 parts10CERN-OHL-S-2.0