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

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
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.
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.
water_level_rate in cm/h; distinguishes "well recovering" from "well being drawn down"null water level so Home Assistant can alert on wiring faultsThe 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/.
| IC | Role |
|---|---|
| ESP32-C6 module | MCU + Zigbee radio |
| ADS1115 (0x48) | 16-bit I²C ADC — shunt voltage (AIN0) + battery divider (AIN2) |
| HT7333-A | 3.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 |
| MT3608B | 12 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 |
| CN3791 | 1S 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 |
| TP4056 | USB-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 + FS8205A | On-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 |
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.
| Ref | Net | Ref | Net |
|---|---|---|---|
| TP1 | VBAT (3.0–4.2 V, 1S range) | TP9 | I2C SCL |
| TP2 | VLOOP (12 V boost) | TP10 | VSOLAR_IN |
| TP3 | +3V3 | TP11 | VBAT_RAW (before D5 reverse-polarity MOSFET) |
| TP4 | GND | TP12 | ADS1115 DRDY (GPIO22) |
| TP5 | LOOP+ (LOOP_TERM_CH1) | TP13 | FACTORY_RESET (GPIO23 — short to GND while powering on) |
| TP6 | LOOP- (LOOP_TERM_CH2) | TP14 | /CHRG_SOLAR (CN3791 charge status) |
| TP7 | 1WIRE | TP15 | /CHRG_USB (TP4056 charge status, with R38 pull-up) |
| TP8 | I2C SDA |
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 | Direction | Function |
|---|---|---|
| 18 | SDA | I²C bus (ADS1115 only) — GPIO10/11 are not bonded out on the ESP32-C6-MINI-1 module, so I²C is on GPIO18/19 |
| 19 | SCL | I²C bus (ADS1115 only) |
| 12 | USB | Native USB D− → USB-C (console/programming); from U11 USBLC6 |
| 5 | Output | MT3608B 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) |
| 6 | Input | Solar charger /CHRG (CN3791) — solar-charging-active detect (active-low: LOW = charging; R25 external pull-up) |
| 7 | 1-Wire | DS18B20 data |
| 13 | USB | Native USB D+ → USB-C (console/programming) |
| 22 | Input | ADS1115 ALERT/DRDY (moved off GPIO12) |
| 23 | Input | Factory reset (hold LOW at boot → NVS erase + rejoin; via TP13; moved off GPIO13) |
| 15 | Output | Battery-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) |
| Terminal | Wire | ESD / surge protection |
|---|---|---|
| LOOP+ / LOOP− | 4–20 mA transducer, two-wire | D9 / D10 SMAJ3.3CA bidirectional TVS (200 W, DO-214AC); D11 SMAJ13A unidirectional TVS on VLOOP; D1 PRTR5V0U2X rail clamp at ADS1115 inputs |
| 1W DATA / GND | DS18B20 data + ground | D12 PRTR5V0U2X dual-channel rail clamp (SOT-363) — 0.5 pF added capacitance, well below the 800 pF 1-Wire add limit |
| Battery | Single 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 |
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.
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.
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 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.
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.
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 monitorOr use the interactive menu: idf.py menuconfig → WellD Configuration.
All options have sensible defaults. Only change what differs from your hardware.
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_SENSOR_SHUNT_MILLIOHMS | 100000 | Loop shunt resistor value in milliohms (100 Ω = 100000) |
CONFIG_WELLD_SENSOR_MAX_DEPTH_CM | 600 | Full-scale depth at 20 mA, in cm |
CONFIG_WELLD_SENSOR_OFFSET_CM | 0 | Level offset in cm applied after conversion (±600). Persisted in NVS; runtime-writable via sensor_set_offset_cm() |
CONFIG_WELLD_DS18B20_GPIO | 7 | GPIO connected to DS18B20 data pin |
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_BATT_REPORT_ENABLED | y | Report 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_RATIO | 200 | Divider ratio × 100 — matches the 100 kΩ / 100 kΩ divider (R7/R8): (100+100)/100 = 2.00 |
CONFIG_WELLD_BATT_FULL_MV | 4200 | Voltage (mV) reported as 100 % by the Z2M converter (1S full charge) |
CONFIG_WELLD_BATT_EMPTY_MV | 3000 | Voltage (mV) at or below which the device skips the Zigbee send and all NVS writes to protect flash (1S minimum safe discharge) |
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_SLEEP_DURATION_SEC | 300 | Baseline 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_ENABLED | y | Scale sleep duration to the observed level rate-of-change |
CONFIG_WELLD_SLEEP_MIN_SEC | 60 | Lower bound on adaptive sleep (seconds) |
CONFIG_WELLD_SLEEP_MAX_SEC | 1800 | Upper bound on adaptive sleep (seconds) |
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_ZIGBEE_CHANNEL_MASK | 0x07FFF800 | Channels to scan; narrow to your coordinator's channel to speed up joins |
CONFIG_WELLD_ZIGBEE_SEND_DELAY_MS | 2000 | Time the stack stays alive after sending, to allow coordinator ACK |
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_I2C_SDA_GPIO | 18 | I²C SDA pin (ADS1115). GPIO10/11 unavailable on the MINI-1 module |
CONFIG_WELLD_I2C_SCL_GPIO | 19 | I²C SCL pin (ADS1115) |
CONFIG_WELLD_VLOOP_GPIO | 5 | MT3608B 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_GPIO | 6 | Solar charger /CHRG (CN3791) — solar-charging-active detect (active-low) |
CONFIG_WELLD_BATT_DIV_EN_GPIO | 15 | Battery-divider enable — Q2 level-shifter gate, switching the Q5 high-side P-FET |
CONFIG_WELLD_ADS1115_DRDY_GPIO | 22 | ADS1115 ALERT/DRDY interrupt input (moved off GPIO12, now native USB D−) |
| Option | Default | Description |
|---|---|---|
CONFIG_WELLD_ADC_OVERSAMPLE_ENABLED | n | Take 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_ENABLED | y | Apply 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_C | 207 | Water 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_BITS | 11 | DS18B20 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_GPIO | 23 | If 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_ENABLED | n | Exercise 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_ENABLED | n | Stay 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_SEC | 60 | Stay-awake window when diagnostic mode is enabled (seconds, 10–300). Depends on WELLD_DIAGNOSTIC_MODE_ENABLED |
CONFIG_WELLD_ZIGBEE_BACKOFF_MAX_MS | 500 | Random 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_SEC | 240 | Maximum wall-clock time an OTA download may take before it is considered stalled and aborted (60–600 s) |
CONFIG_WELLD_RTC_LOG_PRINT_ENABLED | n | Print RTC event log on wakeup — enable when diagnosing a retrieved device over USB; leave off in production |
cp zigbee2mqtt/welld.js /opt/zigbee2mqtt/data/Add it to /opt/zigbee2mqtt/data/configuration.yaml:
external_converters:
- welld.jsRestart Zigbee2MQTT:
sudo systemctl restart zigbee2mqttEnable 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.
The device publishes to zigbee2mqtt/<friendly_name> on each wakeup:
{
"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.presentValue is dropped instead of being published.The Z2M–Home Assistant integration auto-creates these entities:
| Entity | Unit / values |
|---|---|
sensor.<name>_water_level | m |
sensor.<name>_water_level_rate | cm/h |
sensor.<name>_temperature | °C |
sensor.<name>_battery_voltage | V |
sensor.<name>_battery | % (omitted when battery_full_mv <= battery_empty_mv) |
sensor.<name>_zb_fails | count, 0–255 |
sensor.<name>_device_lqi | device-side LQI, 1–255 (needs firmware ≥ 1.0.3; 0 = unknown is never published) |
binary_sensor.<name>_solar_charging | on / off |
The device runs a Zigbee OTA Upgrade client. Zigbee2MQTT (ota: true in the converter) distributes images automatically once placed in its OTA folder.
Edit PROJECT_VER in the root CMakeLists.txt:
set(PROJECT_VER "1.0.2") # MAJOR.MINOR.PATCHThe 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.
After idf.py build, wrap the binary using ota_image_create.py from the esp-zigbee-sdk:
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.)
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/batteryentities. Also note the Kconfig option rename below — a staleCONFIG_WELLD_BATT_ADC_CHANNEL=-1line insdkconfig.defaults.localis silently ignored; setCONFIG_WELLD_BATT_REPORT_ENABLED=nif you want battery reporting to stay off.
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.
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.
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:
Report water_level_rate to Zigbee2MQTT.
Adjust the next sleep duration when CONFIG_WELLD_ADAPTIVE_SLEEP_ENABLED=y:
| Rate (cm/h) | Sleep target | Interval at defaults | Condition |
|---|---|---|---|
| 0 – 2 | max | 1800 s | well at rest |
| 2 – 5 | default × 2 | 600 s | slow drawdown |
| 5 – 10 | default | 300 s | active pumping |
| 10 – 20 | default / 2 | 150 s | heavy pumping |
| ≥ 20 | min | 60 s | rapid 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.
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.
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.
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.
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.
Normal cycle:
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 sSolar charging active:
I (main): solar charging active (solar /CHRG asserted)No coordinator in range:
E (zigbee): timeout — no coordinator found
W (main): Zigbee send failed (1/5)
I (main): sleeping 300 sAfter 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):
E (sensor): transducer open loop (voltage=12 mV, 120 µA < 3.5 mA)Pressure transducer short circuit (> 21 mA):
E (sensor): transducer short-circuit (voltage=2400 mV, 24000 µA > 21 mA)DS18B20 ROM change (sensor replaced):
W (sensor): DS18B20 ROM changed: stored=28ff1234ab000002 active=28ff5678cd000003The 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.
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 requiredPure helpers (sensor_level_from_mv, sensor_battery_from_mv, sensor_temp_in_range, welld_*) are exercised by a plain CMake project under test/host/:
cmake -S test/host -B test/host/build
cmake --build test/host/build
ctest --test-dir test/host/build --output-on-failureCI runs these on every push. No ESP-IDF or hardware required.
For NVS round-trips and 1-Wire / ADC paths the host runner can't cover:
idf.py -C test/sensor build flash monitor
idf.py -C test/welld_core build flash monitorCI builds these to catch compile breaks but cannot execute them (real hardware needed).
.github/workflows/build.yml runs six jobs:
esp32c6, builds firmware and OTA image, uploads *.bin, *.elf, *.map, *.zigbee, dependencies.lockmain/ and components/ C sources; fails the build on any findingPROJECT_VER in CMakeLists.txtubuntu-latest, ctest, fetches Unity v2.6.0test/sensor and test/welld_corenpm test in zigbee2mqtt/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.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 13 | 100nF | Capacitor_SMD:C_0402_1005Metric | C7, C8, C9, C10, C11, C13, C21, C23, C25, C30, C31, C32, C33 |
| 9 | 10uF | Capacitor_SMD:C_0805_2012Metric | C4, C6, C15, C16, C17, C18, C19, C28, C29 |
| 6 | 4k7 | Resistor_SMD:R_0402_1005Metric | R6, R9, R10, R26, R37, R38 |
| 5 | 10k | Resistor_SMD:R_0402_1005Metric | R11, R12, R13, R21, R31 |
| 4 | 100k | Resistor_SMD:R_0402_1005Metric | R8, R24, R34, R36 |
| 4 | 1uF | Capacitor_SMD:C_0402_1005Metric | C3, C5, C12, C24 |
| 4 | MountingHole_3.2mm_M3 | MountingHole:MountingHole_3.2mm_M3 | MH, MH, MH, MH |
| 3 | 100R | Resistor_SMD:R_0402_1005Metric | R3, R5, R28 |
| 2 | 100R | Resistor_SMD:R_0805_2012Metric | R2, R4 |
| 2 | 10nF | Capacitor_SMD:C_0402_1005Metric | C34, C35 |
| 2 | 4.7uH | Inductor_SMD:L_Vishay_IFSC-1515AH_4x4x1.8mm | L1, L2 |
| 2 | 5k1 | Resistor_SMD:R_0402_1005Metric | R50, R51 |
| 2 | PRTR5V | Package_TO_SOT_SMD:SOT-363_SC-70-6 | D1, D12 |
| 2 | SMAJ28CA | Diode_SMD:D_SMA | D8, D14 |
| 2 | SMAJ3.3 | Diode_SMD:D_SMA | D9, D10 |
| 1 | +3V3 | TestPoint:TestPoint_Pad_1.0x1.0mm | TP3 |
| 1 | 1.91M | Resistor_SMD:R_0402_1005Metric | R23 |
| 1 | 10uF 25V | Capacitor_SMD:C_0805_2012Metric | C22 |
| 1 | 132289 | Connector_Coaxial:SMA_Amphenol_132289_EdgeMount | J3 |
| 1 | 1WIRE | TestPoint:TestPoint_Pad_1.0x1.0mm | TP7 |
| 1 | 1k | Resistor_SMD:R_0402_1005Metric | R14 |
| 1 | 1k DNF | Resistor_SMD:R_0402_1005Metric | R22 |
| 1 | 1k2 | Resistor_SMD:R_0402_1005Metric | R35 |
| 1 | 22uF 25V | Capacitor_SMD:C_1206_3216Metric | C20 |
| 1 | 2k0 | Resistor_SMD:R_0402_1005Metric | R19 |
| 1 | 330k | Resistor_SMD:R_0402_1005Metric | R7 |
| 1 | 33R | Resistor_SMD:R_0402_1005Metric | R32 |
| 1 | 36k | Resistor_SMD:R_0402_1005Metric | R20 |
| 1 | 4-20mA_1 | TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_Horizontal | J4 |
| 1 | 4-20mA_2 | TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_Horizontal | J5 |
| 1 | 4.7uF | Capacitor_SMD:C_0805_2012Metric | C27 |
| 1 | 604k | Resistor_SMD:R_0402_1005Metric | R33 |
| 1 | ADS1115 | Package_SO:MSOP-10_3x3mm_P0.5mm | U9 |
| 1 | ADS_DRDY | TestPoint:TestPoint_Pad_1.0x1.0mm | TP12 |
| 1 | AO3407 | Package_TO_SOT_SMD:SOT-23 | D5 |
| 1 | AP63205 | Package_TO_SOT_SMD:SOT-23-6 | U1 |
| 1 | BOOT | Button_Switch_SMD:SW_Push_1P1T_NO_CK_KSC6xxG | SW2 |
| 1 | BSS123 | Package_TO_SOT_SMD:SOT-23 | Q2 |
| 1 | CHRG_SOL | TestPoint:TestPoint_Pad_1.0x1.0mm | TP14 |
| 1 | CHRG_USB | TestPoint:TestPoint_Pad_1.0x1.0mm | TP15 |
| 1 | CN3722 | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | U7 |
| 1 | DS18B20 | TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_Horizontal | J6 |
| 1 | ESP32-C6 | Espressif:ESP32-C6-MINI-1U | U6 |
| 1 | Ferrite | Inductor_SMD:L_0402_1005Metric | FB1 |
| 1 | GND | TestPoint:TestPoint_Pad_1.0x1.0mm | TP4 |
| 1 | GPIO | Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical | J9 |
| 1 | I2C | Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical | J8 |
| 1 | LED | LED_SMD:LED_0603_1608Metric | D7 |
| 1 | LED_DIS | Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm | SJ3 |
| 1 | LOOP1 | TestPoint:TestPoint_Pad_1.0x1.0mm | TP5 |
| 1 | LOOP2 | TestPoint:TestPoint_Pad_1.0x1.0mm | TP6 |
| 1 | MBRS140 | Diode_SMD:D_SOD-123 | D6 |
| 1 | MT3608B | Package_TO_SOT_SMD:SOT-23-6 | U8 |
| 1 | PROG | Connector_PinHeader_1.27mm:PinHeader_1x06_P1.27mm_Vertical | J10 |
| 1 | Polyfuse | Fuse:Fuse_1206_3216Metric | F2 |
| 1 | RESET | Button_Switch_SMD:SW_Push_1P1T_NO_CK_KSC6xxG | SW1 |
| 1 | SCL | TestPoint:TestPoint_Pad_1.0x1.0mm | TP9 |
| 1 | SDA | TestPoint:TestPoint_Pad_1.0x1.0mm | TP8 |
| 1 | SMAJ10CA | Diode_SMD:D_SMA | D13 |
| 1 | SMAJ13 | Diode_SMD:D_SMA | D11 |
| 1 | Solar | TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-2-3.5-H_1x02_P3.50mm_Horizontal | J12 |
| 1 | Spare | TerminalBlock_Phoenix:TerminalBlock_Phoenix_PT-1,5-3-3.5-H_1x03_P3.50mm_Horizontal | J7 |
| 1 | Status | LED_SMD:LED_0603_1608Metric | D4 |
| 1 | TP5100 | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | U12 |
| 1 | UART_RX | Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm | SJ5 |
| 1 | UART_TX | Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm | SJ4 |
| 1 | USB-C | Connector_USB:USB_C_Receptacle_GCT_USB4135-GF-A_6P_TopMnt_Horizontal | J13 |
| 1 | USBLC6 | Package_TO_SOT_SMD:SOT-23-6 | U11 |
| 1 | VBAT | TestPoint:TestPoint_Pad_1.0x1.0mm | TP1 |
| 1 | VBAT_RAW | TestPoint:TestPoint_Pad_1.0x1.0mm | TP11 |
| 1 | VBOOST | Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm | SJ1 |
| 1 | VLOOP | Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm | SJ2 |
| 1 | VLOOP | TestPoint:TestPoint_Pad_1.0x1.0mm | TP2 |
| 1 | VSOLAR | TestPoint:TestPoint_Pad_1.0x1.0mm | TP10 |
| 1 | XT30PW-F | WellD:XT30PW-F_RightAngle | J1 |
It measures 100 × 55 mm and has 2 copper layers, 1.6 mm thick.
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.
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.
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).

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


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.



Jscott44/RTEP5-the-raspberry-jam
Realtime digital guitar effects pedal designed for Raspberry PI, written in C++

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…

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…