<!-- SPDX-License-Identifier: MIT -->
<!-- Copyright (c) 2026 Ilia Arestov <code@ilia.ae> -->
# zignode
A modular ESP32 Zigbee/Wi-Fi node for ESP-IDF — the whole device in one component. Its flagship
module is a safety-first watering pump.
Safety-first pump driver, soil-moisture auto-watering, status LED, button gestures, a
wear-safe persistent event log, and a Zigbee Router with a Home Assistant / Zigbee2MQTT
friendly endpoint model plus an OTA client.
**Verified on real hardware** on both supported boards: it joins Zigbee2MQTT, is
controllable from Home Assistant, and its settings survive reboots and power-cycles.
## Supported boards
| Target | Board | On-board extras |
|--------|-------|-----------------|
| `esp32h2` | **M5Stack NanoH2** | WS2812 status pixel, blue activity LED, IR NEC LED, soil-moisture ADC |
| `esp32c6` | **Seeed Studio XIAO ESP32-C6** | single user LED; pump on D9/D10; RF switch + antenna initialized automatically |
Other targets do not build, by design — the component has no pin map for them and would
otherwise drive whatever GPIOs happen to exist. This is enforced three ways: `targets:` in
`idf_component.yml` (registry-side), a compile-time `#error` in the component, and in
practice the Zigbee libs ship no 802.15.4 port for non-radio targets. (Verified: an
`esp32s3` build fails.)
> **ESP-IDF ≥ 5.4.1 is required** (or ≥ 5.3.3 / ≥ 5.5). The M5NanoH2 ships with ESP32-H2
> chip revision **v1.2 (ECO5)**, which older 5.4.x does not support: it boots only via the
> `CONFIG_ESP32H2_REV100_DEVELOPMENT` *Preview* flag, which relaxes the boot-revision gate
> **without** the v1.2 RF/PHY bring-up — leaving the 802.15.4 receiver deaf, so the device
> transmits beacon requests but hears nothing and never joins. Do not set that flag; use a
> supported IDF instead. See Espressif advisory AR2025-002.
## Install
```bash
idf.py add-dependency "ilia-ae/zignode^2.0.0"
```
## Use
```c
#include "zignode.h"
void app_main(void)
{
zignode_config_t cfg = ZIGNODE_CONFIG_DEFAULT();
ESP_ERROR_CHECK(zignode_run(&cfg));
}
```
That is the entire application — see `examples/zignode_minimal`.
`zignode_run()` is `zignode_init()` + `zignode_start()`. Call the two separately
if you need a hook between "all local subsystems up, pump provably parked OFF" and "radio
started" — that is where, for example, Wi-Fi/802.15.4 coexistence setup belongs on the C6.
## Project requirements
The component cannot own a partition table or project-wide sdkconfig, so the application
must provide both.
**`partitions.csv`** (copy from `examples/zignode_minimal/partitions.csv`) needs
dual OTA slots, the Zigbee stack's `zb_storage` + `zb_fct`, and — for the event log — an
`evtlog` partition:
```
nvs, data, nvs, , 0x6000,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
ota_0, app, ota_0,, 0x180000,
ota_1, app, ota_1,, 0x180000,
zb_storage, data, fat, , 0x4000,
zb_fct, data, fat, , 0x400,
evtlog, data, nvs, , 0x8000,
```
Without `evtlog` the event log degrades to RAM-only and says so at boot; everything else
keeps working.
**`sdkconfig.defaults`** minimum:
```ini
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_ZB_ENABLED=y
CONFIG_ZB_ZCZR=y # mains-powered Router (or CONFIG_ZB_ZED=y for a sleepy device)
CONFIG_ZB_RADIO_NATIVE=y
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
```
The component `#error`s at build time if the Zigbee role or `CONFIG_ZB_ENABLED` is missing,
rather than failing mysteriously later.
## Safety model
This drives a water pump, so the failure mode that matters is "stuck ON floods the room".
- **One choke-point.** A single function drives the GPIOs; nothing else touches them.
- **Boot-safe OFF.** The pump is parked OFF as the very first action of
`zignode_init()` — before NVS, the radio, or anything else. Pair it with an external
pull-up on the relay pin and pull-down on the MOSFET gate so the pre-firmware Hi-Z window
is safe too.
- **Max-runtime watchdog.** Every ON arms a one-shot timer (default 300 s, hard ceiling
3600 s) that force-stops the pump regardless of connectivity. If the timer cannot be
armed, the pump is driven OFF instead of running unwatched.
- **Min-off cooldown** between automated runs protects the motor and relay contacts.
Deliberate manual control (button / Home Assistant) bypasses it, so a human is never
locked out.
- **Dry-run gate.** A faulted moisture sensor blocks starts and forces OFF.
- **Task watchdog** enabled: a firmware hang resets the MCU, which returns the pads to
Hi-Z and lets the external resistors re-park the pump.
## Zigbee model
Router (ZCZR) by default. Three endpoints, one plain custom cluster.
| EP | Role | Clusters |
|----|------|----------|
| 10 | Pump | Basic, Identify, On/Off, custom `0xFC00` config, OTA Upgrade (client) |
| 11 | Auto-water on/off | Identify, On/Off |
| 13 | Soil moisture | Basic, Identify, Relative Humidity (soil % reported as %RH) |
`0xFC00` is a **plain** custom cluster (no manufacturer code) carrying max-runtime,
min-off, dry/wet thresholds, auto interval/duration, ADC calibration, a read-only status
bitmask, the pump drive mode, an IR-NEC write, and a cursor to read the event log.
The device also **self-binds** its reportable clusters to the coordinator on every boot, so
attribute reporting survives a power-cycle without a manual "Reconfigure".
A ready Zigbee2MQTT external converter and a Home Assistant blueprint live in the
[repository `docs/`](https://github.com/ilia-ae/zignode/tree/main/docs).
## Configuration
`idf.py menuconfig` → **Component config → ZigNode**:
| Option | Default | Notes |
|---|---|---|
| `ZIGNODE_ENABLE_MOISTURE` | y on H2, n elsewhere | on-board ADC soil sensor |
| `ZIGNODE_ENABLE_SCHEDULER` | y | interval auto-watering |
| `ZIGNODE_ENABLE_ZB_CONFIG_CLUSTER` | y | expose `0xFC00` |
| `ZIGNODE_ENABLE_OTA` | y | Zigbee OTA client |
| `ZIGNODE_ENABLE_IR` | y on H2, n elsewhere | **never enable on the XIAO C6** — its GPIO3 is the RF-switch power line |
| `ZIGNODE_ENABLE_EVENTLOG` | y | needs the `evtlog` partition |
| `ZIGNODE_ZB_PRIMARY_CHANNEL` | 0 | pin the join channel (0 = scan all) |
| `ZIGNODE_ZB_MIN_JOIN_LQI` | 0 | 0 = accept any parent; the stack's default of 32 can make an H2 refuse a coordinator that is right next to it |
## License
MIT — see `LICENSE`. This component also contains third-party material from Espressif
(Apache-2.0 and CC0-1.0); see `NOTICE` and `THIRD_PARTY_NOTICES.md`, and the SPDX headers
in the individual sources.
f31d21be45de0609ac4f19157d67ba8b4380c323
idf.py add-dependency "ilia-ae/zignode^2.0.0"