A modular ESP32 Zigbee/Wi-Fi node β safety-first firmware you extend with modules.
Its flagship module drives a DC pump (MOSFET) or a mains pump (opto-relay) and auto-waters from a
programmable rule engine β controllable from Home Assistant / Zigbee2MQTT.
The pump is just one module; the core runs on multiple boards, peripherals compiled in per board.
| π§© Modular core | π§ Safety-first pump | π‘ Native Zigbee 3.0 |
| Module registry + event bus β add a peripheral in one file, no core edit. | Parked OFF before radios, hard max-runtime watchdog, dry-run gate, min-off cooldown. | Router for Home Assistant & Zigbee2MQTT: pump / auto-water / soil endpoints + 0xFC00 config. |
| π Wi-Fi web panel | π₯οΈ Expansion peripherals | π OTA + rollback |
C6 only: live panel at zignode.local β telemetry + a local update window. |
OLED Β· buzzer Β· PCF8563 RTC Β· wear-safe microSD event log (RAM-buffered). | Dual-slot Zigbee / HTTP OTA, health-gated with bootloader rollback. |
Built with ESP-IDF v5.4.4 (β₯5.4.1 required for the ESP32-H2 v1.2/ECO5 silicon β see docs/BUILD.md) and the classic
esp-zigbee-lib1.6.x (esp_zb_*) API.
Board selection is a Kconfig choice (decoupled from the chip), and each board takes an optional
:release/:beta/:debug channel. See docs/BOARDS.md for the capability
matrix + pin maps and docs/MODULES.md for how a peripheral module plugs in.
The board alone is not a system β ZigNode is a Zigbee device, so it needs a network to join and something to talk to.
| Required? | Notes | |
|---|---|---|
| A Zigbee 3.0 coordinator | Yes | Any standard stick β SONOFF ZBDongle-E/P, UZG-01, ConBee II/III, TI CC2652. Developed and tested against a UZG-01 (zstack). |
| Zigbee2MQTT + an MQTT broker | Yes (this path) | Z2M is the supported integration. It needs a broker β Mosquitto is the usual choice (the HA "Mosquitto broker" add-on works). Install the external converter from docs/zigbee2mqtt/. |
| or Home Assistant ZHA | alternative | ZHA needs no MQTT broker, and standard entities (pump on/off, soil humidity) work out of the box β but the 0xFC00 config cluster needs a ZHA quirk that this project does not ship, so tuning/rules would be unavailable. Z2M is recommended. |
| Home Assistant | Optional | Not required to run, but it's where the automations and the ready-made watering blueprint live β docs/home-assistant/. |
| Pump + driver electronics | Yes | A DC pump with a logic-level N-MOSFET, or a mains pump with an opto-isolated relay board, plus a PSU, a flyback diode and the pull-up/pull-down resistors. Read Safety before wiring. |
| Soil moisture sensor | Optional | An analog capacitive probe on the free Grove pin (H2), or separate Zigbee soil sensors bound directly to the node (cluster 0x0408). |
| Wi-Fi network | Optional, C6 only | Only for the web panel / SNTP / Wi-Fi OTA. Zigbee works without it. |
| ESP-IDF v5.4.1+ | To build | Only if you build from source. β₯5.4.1 is mandatory on ESP32-H2 v1.2/ECO5 silicon β see docs/TROUBLESHOOTING.md. |
The ESP32-C6 is a multi-protocol radio, so it's worth being explicit about what the silicon can do versus what this firmware actually implements:
| Protocol | ESP32-H2 M5Stack NanoH2 |
ESP32-C6 XIAO / XIAO + Base |
Status in ZigNode |
|---|---|---|---|
| Zigbee 3.0 | β Router (ZCZR) | β End Device (non-sleepy) | Shipped β classic esp_zb_* API on esp-zigbee-lib 1.6.8: BDB steering on a pinned channel, min-join-LQI gate, multi-endpoint model + the 0xFC00 config cluster. |
| IEEE 802.15.4 | β native radio | β native radio | Shipped, owned by the Zigbee stack. Called directly only for RSSI/LQI link telemetry β no raw 802.15.4 networking. |
Zigbee OTA 0x0019 |
β client on EP10 | β client on EP10 | Shipped β dual-slot images + bootloader rollback behind a health gate. |
| Wi-Fi 6 β 802.11ax (HE) | β no Wi-Fi silicon | β STA | Shipped on C6, and measured β the panel reports esp_wifi_sta_get_negotiated_phymode(), and on a Wi-Fi 6 AP it reads HE20: the link really is running 802.11ax, not merely talking to an AP that could. Falls back to b/g/n otherwise. Station mode comes up after Zigbee joins and coexists with 802.15.4: web panel, mDNS, SNTP, ICMP link monitor, HTTP firmware upload. Not done: explicit iTWT power-save tuning. No SoftAP, no ESP-NOW, no TLS. |
| Bluetooth LE 5.3 | βͺ hardware-ready | βͺ hardware-ready | Not implemented β the controller is compiled out of both shipped builds. |
| Thread 1.3 | βͺ hardware-ready | βͺ hardware-ready | Not implemented β the single 802.15.4 PHY is exclusively owned by Zigbee, so Thread is mutually exclusive with it. |
| Matter | βͺ over Thread | βͺ over Wi-Fi or Thread | Roadmap only β docs/MATTER.md describes a separate esp-matter binary; nothing Matter is in the default build. |
β shipped in this firmware Β· βͺ hardware-ready β the silicon supports it, ZigNode does not implement it Β· β not present in the silicon
Pin maps for all three boards β including the XIAO C6 and the Expansion Base peripherals β are in docs/BOARDS.md. The table below is the original H2 reference wiring.
Board: M5Stack NanoH2 Dev Kit Β· SoC ESP32-H2FH4S (RISC-V, 96 MHz, 4 MB flash,
320 KB SRAM). Radios: BLE 5 + IEEE 802.15.4 (Zigbee 3.0 / Thread / Matter-over-Thread).
No Wi-Fi. Programming and console are over the SoC's native USB-Serial-JTAG (no UART
bridge). Pin map verified against schematic SCH_M5NanoH2_v0.0.3_2025_08_20.
| Silkscreen | GPIO | Role in this firmware |
|---|---|---|
| G1 (Grove white) | GPIO1 | Transistor/MOSFET gate β active-HIGH (HIGH = pump ON). Moisture ADC (ADC1_CH0) when in relay mode. |
| G2 (Grove yellow) | GPIO2 | Relay IN β active-LOW (LOW = pump ON). Moisture ADC (ADC1_CH1) when in transistor mode. |
| 5V / GND (Grove) | β | Sensor / relay-module power + ground. |
| G3 | GPIO3 | On-board IR LED (PIN_IR) β NEC transmit, drivable from HA via ir_nec. |
| G4 | GPIO4 | On-board blue LED (active-HIGH) β per-command activity blink. |
| G9 | GPIO9 | BOOT / user button (active-LOW, external pull-up). |
| G10 | GPIO10 | WS2812 power-enable β drive HIGH to power the RGB pixel. |
| G11 | GPIO11 | WS2812 data (single RGB pixel). |
G2 = GPIO2 (MTMS) is a strapping pin, but it is only sampled at reset; using it as a runtime relay output is fine. The external resistors below keep it safe through the power-on-reset window.
- Three drive modes, switchable at runtime β transistor (DC via a logic-level N-MOSFET, active-HIGH), relay (AC/mains via an opto-isolated relay, active-LOW), or both (the default β a pump wired to either path just works). A single-driver mode frees the other pin for the on-board moisture sensor.
- A safety chain that does not depend on the network. The pump is parked OFF before the radios start; every ON goes through one choke-point that arms a hard max-runtime watchdog (default 300 s, ceiling 3600 s), enforces a min-off cooldown, and applies a fail-closed dry-run gate (stale/faulty moisture β "unsafe, do not run"). A firmware hang trips the Task Watchdog and the pads return to a safe state.
- Manual dose β a one-shot "run N seconds" from the panel or Home Assistant that bypasses the automation cooldown but never the safety limits.
- 8 independent rules, each with a trigger β time of day (with day-of-week mask and a catch-up window), every N minutes, moisture below X, or on demand β and an action: a single pulse, or a soak loop (pulse β wait β re-check, capped by max pulses).
- Budgets and interlocks: per-rule minimum gap, a global daily seconds budget, a max runs-per-day, and a moisture-staleness limit. The engine is fail-CLOSED β if it can't prove watering is safe and due, it doesn't water.
- 14 human-readable veto reasons ("soil is not dry enough", "moisture reading is stale", "daily budget spent", β¦) so an idle pump is never a mystery.
- Rules are editable both over Zigbee (cursor + commit attributes) and in the web panel.
- Native Zigbee for Home Assistant and Zigbee2MQTT β endpoints for Pump (EP10),
Auto-water (EP11) and Soil moisture (EP13), plus the
0xFC00config cluster surfaced as HA numbers and apump_modeenum. - Direct sensor binding β bind an external Zigbee soil sensor (cluster
0x0408) straight to the node, so auto-watering keeps working even if the coordinator is down. - Link telemetry β parent address, LQI/RSSI, parent poll interval, round-trip time to the coordinator, and "last seen by coordinator", all readable live.
The C6 is a Wi-Fi 6 (802.11ax) part and the station uses it β associating in 11ax with a Wi-Fi 6 AP, falling back to b/g/n otherwise, and reporting the negotiated PHY in the panel. Wi-Fi comes up after Zigbee joins, and the two share one radio (see the coexistence readout below).
A self-hosted panel at http://zignode.local/ (mDNS), open as a self-closing maintenance
window by default or permanently on, with optional HTTP Basic auth β 12 REST endpoints
across 7 sections:
- Live pump card β animated running state with an elapsed/remaining countdown, plus ON / OFF / timed-dose controls.
- Watering engine β automation toggle, engine state, the current veto reason, soil moisture with reading age, seconds used today, lifetime pumping time and estimated Wh.
- Rule editor β all 8 rules, every field, day-of-week pickers, and "run this rule now".
- Diagnostics β CPU load, chip temperature, heap (free / minimum-ever / largest block), uptime, decoded reset reason, and the event log.
- Radio coexistence readout β Wi-Fi and Zigbee share one 2.4 GHz radio; the panel shows the effective time-share (ED poll interval) and flags sharing OK vs contended.
- Network β SSID, BSSID, channel/PHY, RSSI, clickable IP / gateway / mDNS links, link uptime, connect/drop counters, and a gateway ping with min/avg/max and loss %.
- Antenna switch β flip between the on-board ceramic antenna and the external u.FL connector from the browser.
- Zigbee OTA β dual-slot (
ota_0/ota_1) updates from the coordinator. - Wi-Fi OTA β drop a
.bininto the panel; it is streamed straight into the inactive slot with the pump locked for the duration of the flash, then the boot partition is switched. - Integrity-checked β the panel hashes the
.binin the browser and declares it viaX-SHA-256; the device hashes every byte it accepts and aborts before the image can become bootable on any mismatch.GET /api/statusreports the running image's ELF SHA-256, so you can confirm exactly which build is executing β a version string can't tell two "2.0.0"s apart. - Health-gated rollback β a new image must prove itself or the bootloader rolls back to the previous one, so a bad update can't leave a pump controller bricked.
ZCL Time from the Zigbee coordinator, SNTP over Wi-Fi, and a battery-backed PCF8563 RTC that seeds the clock at cold boot. Time-of-day rules simply refuse to run without a clock. The panel shows the active source, UTC offset, last sync and last correction.
SSD1306 OLED status screen Β· passive buzzer Β· PCF8563 RTC Β· microSD event log that is RAM-buffered, batched and rotated so it never wears out internal flash (off by default β its SPI pins overlap the pump pads; relocate them via Kconfig to enable).
- Persistent event log β a wear-safe ring (boot/reset cause, pump on/off, auto-water cycles,
mode changes, IR sends, factory reset, faults, OTA) in a dedicated
evtlogflash partition. It is dumped to the serial console at every boot, readable over Zigbee (0xFC00cursor attrs0x000Bβ0x000E), and rendered in the panel. - Button gestures β short = toggle pump, double = toggle automation, long β₯3 s = factory reset / network leave β plus a status LED (WS2812 on H2, single LED on C6).
- IR NEC transmitter (H2) β write
(address << 16) | commandto the0x000Aattribute to blast a frame straight from a Home Assistant automation.
Every feature above is a module in one explicit, ordered registry with an event bus. Adding a
peripheral is a file + a descriptor + one gated line β never an edit to the core. Board
selection is a Kconfig choice, decoupled from the chip target. See
docs/MODULES.md.
The Grove port only exposes G1 and G2 as user signals. In the two single-driver modes
the moisture sensor uses whichever Grove pin the active mode does not drive: relay mode
pumps on G2 and reads moisture on G1 (ADC1_CH0); transistor mode pumps on G1 and reads
moisture on G2 (ADC1_CH1). The default both mode drives both pins, so no pin is
free and the moisture sensor is auto-disabled β pick a single-driver mode if you want
on-board moisture sensing. Do not hot-swap pump_mode at runtime with a sensor connected to
a pin the new mode will drive; the firmware re-points the ADC and disables the sensor rather
than collide with the pump pin.
ESP-IDF v5.4.1+ (pinned: v5.4.4). Build a board profile β it picks the chip target for you and builds into its own isolated directory:
tools/build_board.sh m5nanoh2 # or: xiao_c6 Β· xiao_c6_exp
tools/build_board.sh xiao_c6_exp:release # optional :release Β· :beta Β· :debug channel
tools/build_board.sh all # every board, sequentially
idf.py -B build.xiao_c6_exp -p <PORT> flash monitorDon't run
idf.py set-targetin the working tree β it regeneratessdkconfigand wipes your Wi-Fi credentials.build_board.shexists precisely to avoid that; see docs/BOARDS.md.
The managed Zigbee/LED components (esp-zigbee-lib ~1.6.0, esp-zboss-lib ~1.6.0,
led_strip ^2.5.0) are pulled automatically at build time. If the board doesn't enumerate
for flashing, hold the button (GPIO9) while plugging in USB to enter download mode.
Full build/flash/OTA details: docs/BUILD.md.
Put a Zigbee coordinator into join mode, then trigger commissioning on the device (fresh
flash auto-joins; otherwise long-press the button to factory-reset and re-join). The three
endpoints surface as switches and a humidity sensor; the 0xFC00 config attributes
(max_runtime_s, thresholds, calibration, pump_mode, ir_nec, β¦) surface as numbers plus
a pump_mode enum.
- Zigbee2MQTT: drop in the external converter at
docs/zigbee2mqtt/zignode.js. - Home Assistant: entity map and the auto-water blueprint live under
docs/home-assistant/. - Endpoint / cluster / attribute reference and the pairing walkthrough: docs/ZIGBEE.md.
Read this before wiring a real pump.
- Mains through the relay path only. Switch AC/mains loads exclusively with an
opto-isolated relay board on G2 (drive
INfrom 3V3, power the coil from 5V). Never switch mains with a bare MOSFET. DC pumps (5/12/24 V) go on the transistor path. - Boot-safe OFF is enforced, but external resistors are mandatory. Add a 10 kΞ© pull-up from G2 β 3V3 (relay reads de-energized during the reset Hi-Z window) and, for the transistor path, a 100 Ξ© gate series resistor + 10 kΞ© gate pull-down (G1 β GND) so the FET is OFF at boot. Without these, the pump can twitch on during reset.
- Flyback diode across any DC pump/motor (cathode to +V). Use a true logic-level N-MOSFET (AO3400 / SI2302 / IRLML0030).
- The firmware defends you in software too: pump parked OFF before radios init, a single
pump_set()choke-point, a hard max-runtime cutoff, min-off cooldown, a dry-run gate, and a Task Watchdog that resets the MCU (returning the pads to Hi-Z) on a hang. None of this replaces the resistors and the flyback diode.
See docs/DESIGN.md Β§1β2 for the full wiring and control-invariant spec.
zignode/
βββ CMakeLists.txt project cmake (project zignode)
βββ partitions.csv dual-OTA (ota_0/ota_1) + zb_storage/zb_fct + evtlog layout
βββ sdkconfig.defaults Zigbee ZCZR defaults, 4 MB flash, OTA rollback ON
βββ main/
β βββ app_main.c six lines: zignode_run(&cfg) β all logic is in the component
βββ components/zignode/ THE reusable component (esp32h2 + esp32c6)
β βββ include/zignode.h the entire public API surface
β βββ Kconfig CONFIG_ZIGNODE_* feature gates, target-aware defaults
β βββ idf_component.yml registry metadata + pinned deps (esp-zigbee-lib, led_strip)
β βββ examples/ minimal app that consumes the component
β βββ src/
β βββ zignode.c boot-safe init order, wires the modules together
β βββ board.c per-board early init (C6: RF switch + antenna β load-bearing)
β βββ pump_control.c THE choke-point: mode, max-runtime, min-off, dry-run, OTA lock
β βββ watering_program.c rules/profiles/limits model, versioned CRC'd NVS blob
β βββ watering_engine.c evaluates the program and drives the pump β fail-CLOSED
β βββ moisture.c ADC oneshot on the free Grove pin, calibration, %
β βββ status_led.c WS2812 (H2) or single active-low LED (C6)
β βββ button.c short=toggle pump Β· double=automation Β· long=factory reset
β βββ nvs_store.c persist pump_settings_t; factory reset
β βββ event_log.c wear-safe persistent event ring (evtlog partition)
β βββ zigbee_device.c endpoints, 0xFC00 (incl. rule editor), reporting, OTA client
β βββ ota_guard.c health gate: confirms a new image, or lets it roll back
β βββ ir_blaster.c NEC transmit over RMT (H2 only)
β βββ private_include/ everything that is not the public API
βββ tools/make_ota.py wraps a build into a ZCL OTA image for a LOCAL index
βββ docs/
βββ DESIGN.md single source of truth (hardware + firmware contract)
βββ ZIGBEE.md endpoints, clusters, every 0xFC00 attribute
βββ OTA.md private updates: rollback, health gate, pump interlock
βββ BUILD.md build / flash
βββ WIRING.md pump wiring, sensor, external passives
βββ TROUBLESHOOTING.md what actually blocked the join (ECO5 + IDF < 5.4.1)
βββ REUSE.md reuse map: what we copy vs. clean-room reimplement
βββ zigbee2mqtt/ Z2M external converter + OTA index template
βββ home-assistant/ on-device program package + HA-driven blueprint
This firmware is built on permissively licensed Espressif examples (esp-zigbee-sdk and
esp-idf peripheral examples β mostly CC0-1.0 / Unlicense, ADC and managed components
Apache-2.0), version-matched to the pinned esp-zigbee-lib 1.6.x. Board pin facts are
ported from M5Stack's MIT-licensed docs. The domain architecture (multi-endpoint model,
safety watchdog, moisture control loop) is clean-room reimplemented: GPL-3.0 and
unlicensed community projects were consulted as design references only, never copied β
copying them would force this firmware to their license.
Every file copied verbatim keeps its original upstream SPDX-License-Identifier header.
Apache-2.0 origins are listed in THIRD_PARTY_NOTICES.md. The
full copy-vs-write decision table is in docs/REUSE.md.
MIT Β© 2026 Ilia Arestov. See LICENSE. Third-party components pulled at build time (Espressif ESP-IDF, esp-zigbee-sdk / esp-zboss-lib) remain under their own licenses; see COPYRIGHT and THIRD_PARTY_NOTICES.md.