ZHAC
September 20, 2026 · View on GitHub
Updated: 2026-09-18
Scope: The three hub firmwares (dual-chip, wired, single-chip), the device library, rules, Lua,
the APIs, the web UI and the browser flasher. Rows name the build when a feature is not on all
three.
At a glance
Three ways to build a hub
| Build | Chips | Network | Zigbee radio | Status |
|---|---|---|---|---|
Dual-chip (zhac-platform) | ESP32-S3 + ESP32-P4 | Wi-Fi | TI CC2652P, Z-Stack | Main build, runs on hardware |
Wired (zhac-wired-core) | ESP32-P4 or ESP32-S31 | Ethernet | ESP32-C6 running ot_rcp | S31 runs on hardware; P4 builds, first hardware run pending |
Single-chip (zhac-mono-core) | ESP32-S3 | Wi-Fi | TI CC2652P, Z-Stack | Builds; same sign-in as the other builds since 2026-09-20 (not yet run on hardware) |
All three recognise the same 4,989 device definitions from 400 manufacturers (supported devices) and serve the same web UI.
What runs on the hub, and what needs a cloud
Everything ZHAC does at home runs on the hub itself, from open source, with no account and no internet connection.
| Feature | On the hub (open source, no account) | Needs a cloud service |
|---|---|---|
| Pair, name and control devices | ✅ | |
| Rules, schedules and Lua scripts | ✅ they run on the hub | |
| Web UI | ✅ served by the hub on your network | |
| MQTT and Home Assistant discovery | ✅ with your own broker | |
| Firmware updates, backup and restore | ✅ from the web UI | |
| Control from outside your home | Your own VPN or reverse proxy, or the ESP RainMaker bridge (dual-chip build option) | Optional ZHAC Cloud (in development) |
| Long-term history, charts, phone app | Optional ZHAC Cloud (in development) |
Nothing on the hub depends on a cloud service.
Legend
| Symbol | Meaning |
|---|---|
| ✅ | Fully implemented and in production |
| ⚠️ | Partially implemented — gaps noted |
| 🔲 | Planned / not started |
| 💡 | New feature idea (design below) |
1. Zigbee Stack
| # | Feature | Status | Notes |
|---|---|---|---|
| Z1 | ZNP coordinator driver (UART framing, AF/ZDO) | ✅ | znp_driver.cpp |
| Z2 | Device join / leave events | ✅ | HAP DEVICE_JOIN / DEVICE_LEAVE |
| Z3 | Device pool (ZapDevice, PSRAM) | ✅ | zigbee_pool.cpp |
| Z4 | Device shadow (live attr cache) | ✅ | device_shadow.cpp |
| Z5 | Device interview (cluster enumeration) | ✅ | task_zb_interview |
| Z6 | Device persistent store (zap_store) | ✅ | NVS-backed, survives reboot |
| Z7 | Permit-join (30-second window) | ✅ | POST /api/permit_join |
| Z8 | Device friendly names | ✅ | Stored in ZapDevice.friendly_name |
| Z9 | Device delete + Zigbee leave request | ✅ | handle_device_delete |
| Z10 | Zigbee bind / unbind (direct device linking) | ✅ | zhac_bind / zhac_unbind |
| Z11 | Zigbee group membership management | ✅ | zhac_group_add/remove/cmd |
| Z12 | ZNP task priority drop after init | ✅ | Priority 5→2 after coordinator ready |
| Z13 | ZNP transport refactor: dedicated RX + worker tasks, per-call reply queues, late-SRSP policy | ✅ | components/znp_driver/src/znp_worker.cpp |
| Z14 | Event burst back-pressure on UART RX | 🔲 | See improvements-report §Z14 |
2. ZCL Library (embedded-zhc)
Static-memory C++20 library replacing the retired zcl_converter /
zcl_generated / zhc-pipeline stack. Ported from zigbee-herdsman-converters.
| # | Feature | Status | Notes |
|---|---|---|---|
| C1 | PreparedDefinition IR — FzConverter chain + TzConverter per writable attr, no allocation at run time | ✅ | embedded/zhc/src/runtime/ |
| C2 | FzConverter engine — ZCL attr → string-keyed ZclAttribute | ✅ | zhac_adapter_try_decode |
| C3 | TzConverter engine — friendly key → ZCL write | ✅ | zhac_adapter_send_{bool,uint,string} |
| C4 | Exposes JSON generated from the library's expose bindings | ✅ | PSRAM buffer |
| C5 | Per-device ports committed under embedded-zhc/definitions/<vendor>/generated/ | ✅ | Regenerated by just refresh-parity |
| C6 | Vendor-specific shared modules (_shared.cpp) for custom decoders | ✅ | Xiaomi lumi TLV, Tuya DP, MiBoxer magic-packet, Hue dimmer, IKEA arrow |
| C7 | ZCL Default Response sender — unicast ACK, loop-prevented | ✅ | zigbee_send_default_response in zcl_commands.cpp |
| C8 | ZHC definition cache — slot per device, nullptr-skip on unresolved identity | ✅ | zhac_adapter (fix 2026-04-22) |
| C9 | Generic fallback for unlisted devices (on/off, level, colour, battery, temperature, humidity, pressure, illuminance clusters) | ✅ | zhc_adapter_fallback.cpp |
| C10 | Published supported-devices list and search page, generated from the registries the firmware matches | ✅ | supported-devices/ |
3. Simple Rules Engine
| # | Feature | Status | Notes |
|---|---|---|---|
| R1 | DSL parser (dsl_parse) | ✅ | simple_rules.cpp |
| R2 | Trigger — device attribute change | ✅ | TriggerType::DEVICE_ATTR |
| R3 | Trigger — boot | ✅ | TriggerType::BOOT |
| R4 | Trigger — named event | ✅ | TriggerType::EVENT |
| R5 | Trigger — FreeRTOS timer fire | ✅ | TriggerType::TIMER |
| R6 | Trigger — MQTT topic message | ✅ | TriggerType::MQTT_TOPIC |
| R7 | Trigger — cron schedule | ✅ | TriggerType::TIME_CRON, 1-min task |
| R8 | Action — zigbee.set (write device attribute) | ✅ | ActionType::ZIGBEE_SET |
| R9 | Action — publish (MQTT) | ✅ | ActionType::PUBLISH |
| R10 | Action — event (fire named event) | ✅ | ActionType::EVENT |
| R11 | Action — timer (start/reset FreeRTOS timer) | ✅ | ActionType::TIMER |
| R12 | Action — log (ESP_LOGI) | ✅ | ActionType::LOG |
| R13 | Rule conditions (=, !=, >, <, >=, <=) | ✅ | CondOp enum, compare_value |
| R14 | Device name resolution (friendly name → IEEE at eval) | ✅ | simple_rules_resolve_names; full 29-character names since 2026-09 |
| R15 | Rule store (NVS, survives reboot) | ✅ | rule_store.cpp |
| R16 | Up to 4 chained actions per rule | ✅ | ParsedRule.actions[4] |
| R17 | %value% expansion (inject trigger value into action arg) | ✅ | expand_value |
| R18 | Action — script.run <name> (invoke a named Lua script) | ✅ | ActionType::SCRIPT; wired via simple_rules_set_script_hook → lua_scheduler_push_run_named |
| R19 | Action — kv.set / kv.get (persistent KV from rules) | 💡 | New — see §7.2 |
| R21 | Rule and script templates in the web UI | ✅ | www-spa/src/templates.js; every rule template passes the parser |
| R20 | Value expressions — integer arithmetic over %value% in zigbee.set / publish (!%value%, %value%/100, (%value%*10)/3+5) | ✅ | expr_eval.cpp — compiled at rule save, RPN eval per fire; RULES_DSL.md §Value substitution & expressions |
4. Lua Scripting Engine
Sole scripting runtime since 2026-04-21 (Berry removed — see docs/plans/2026-04-21-lua-engine-plan.md and CHANGELOG.md). PUC-Lua 5.5, sandboxed, coroutine-scheduled on a dedicated TaskLua. See docs/LUA_API.md for the full API reference.
| # | Feature | Status | Notes |
|---|---|---|---|
| L1 | Routed allocator (internal for <512 B, PSRAM otherwise) with hard cap | ✅ | components/lua_engine/src/lua_alloc.c; budget via CONFIG_LUA_ENGINE_HEAP_KB (default 4 MB) |
| L2 | Coroutine scheduler (TaskLua + resume queue + esp_timer pool) | ✅ | lua_scheduler.cpp; pool size CONFIG_LUA_ENGINE_TIMER_POOL (16) |
| L3 | Sandbox — io.* nil'd, dangerous os.* removed, bytecode load blocked | ✅ | lua_sandbox.c |
| L4 | SPIFFS-backed script cache at /scripts/<name>.lua (atomic tmp+rename) | ✅ | lua_script_cache.cpp; 16 KB per file, up to 16 files |
| L5 | zhac.log(level, msg) — ESP_LOGI/W/E/D with tag lua_script | ✅ | zhac_lua_module.cpp::l_zhac_log |
| L6 | zhac.millis() — monotonic ms since boot | ✅ | esp_timer_get_time() / 1000 |
| L7 | zhac.sleep(ms) — cooperative yield via timer pool | ✅ | lua_scheduler_sleep; errors if pool exhausted |
| L8 | zhac.set_attr(ieee_hex, key, value) — ZCL write via zhc_adapter | ✅ | Dispatches bool/uint/string to zhac_adapter_send_* |
| L9 | zhac.get_attr(ieee_hex, key) — shadow read | ✅ | device_shadow_get_attrs |
| L10 | zhac.publish(topic, payload [, qos [, retain]]) — MQTT publish | ✅ | mqtt_gw_publish; alias zhac.mqtt_publish |
| L11 | zhac.event(name) — fire RULE_EVENT | ✅ | event_bus_publish |
| L12 | zhac.on_attr_change(ieee_hex, key, fn) — attr-change handler | ✅ | Registry-backed dispatch from EventBus ATTR_CHANGE |
| L13 | zhac.on_cron(expr, fn) — cron handler | ✅ | Registered; fired by the simple_rules cron task via EventBus |
| L14 | zhac.on_mqtt(topic, fn) — MQTT handler | ✅ | Dispatch via EventBus::MQTT_MSG |
| L15 | zhac.on_boot(fn) — one-shot at firmware boot | ✅ | Dispatch via EventBus::CTRL_BOOT |
| L16 | Curated libraries behind Kconfig: cjson, lpeg, miniz | ⚠️ | Hooks shipped, sources vendored per flag — default n |
| L17 | Prometheus metrics (zhac_p4_lua_*) — heap, peak, live coroutines, errors, yields | ✅ | metric_registry.def |
| L18 | ZCL_RAW capture from Lua | 🔲 | Deferred; use MQTT bridge meanwhile |
| L19 | Direct ZDO bind/unbind / group membership from Lua | 🔲 | Use REST /api/devices/:ieee/bind, /api/groups/:id/cmd |
| L20 | Persistent KV store accessible from Lua | 🔲 | Planned alongside §7.2 |
5. Communication & API
5a. HAP Protocol (S3 ↔ P4)
| # | Feature | Status | Notes |
|---|---|---|---|
| H1 | Binary framing (type, seq, flags, payload) | ✅ | hap_protocol.h |
| H2 | ACK / NO_ACK frame flags | ✅ | HAP_FLAG_NEEDS_ACK |
| H3 | Heartbeat (5 s interval) | ✅ | HAP_HEARTBEAT_INTERVAL_MS |
| H4 | Semaphore-drain before each roundtrip | ✅ | Race fix Q6 |
| H5 | HAP handler registry (22 message types) | ✅ | s_hap_handlers[256] |
| H6 | Bulk device-event batching (10 ms window) | ✅ | flush_bulk task |
5b. REST API (S3)
| # | Feature | Status | Notes |
|---|---|---|---|
| A1 | API token auth (REQUIRE_AUTH) | ✅ | NVS zhac_auth/token; all three builds |
| A18 | Admin password sign-in for the web UI | ✅ | All three builds; first claim bounded to 10 min after power-on |
| A2 | Rate limiting (RATE_LIMIT macro) | ✅ | POST /permit_join, /wifi, /ota |
| A3 | CORS headers on all authenticated routes | ✅ | SET_CORS_HEADERS |
| A4 | GET /api/devices | ✅ | |
| A5 | GET /api/devices/{id} + exposes | ✅ | |
| A6 | POST /api/devices/{id}/set | ✅ | |
| A7 | GET/POST/PUT/DELETE /api/rules | ✅ | Full CRUD |
| A8 | GET/POST/DELETE /api/scripts | ✅ | Lua scripts keyed by name (see docs/REST_API.md) |
| A9 | POST /api/settings (broker_url, timezone, ntp_server, MQTT and Home Assistant fields) | ✅ | All fields optional |
| A10 | POST /api/wifi | ✅ | |
| A11 | POST /api/ota (S3 and P4 OTA) | ✅ | Chunked upload; the wired build updates from a URL instead (ota.update) |
| A12 | GET/POST /api/groups + auth on GET | ✅ | R7 fix |
| A13 | POST /api/permit_join | ✅ | |
| A14 | Static file server (SPIFFS, SPA fallback) | ✅ | Path normalization R3 |
| A15 | Inconsistent response format ({"ok":true} vs bare) | ⚠️ | improvements-report §R1 |
| A16 | Blocking REST handlers (mutex contention) | ⚠️ | improvements-report §P4 |
| A17 | HTTP method misuse (POST where PATCH expected) | 🔲 | improvements-report §R5 |
5c. WebSocket & MQTT
| # | Feature | Status | Notes |
|---|---|---|---|
| W1 | WebSocket bridge (S3 → browser push events) | ✅ | ws_bridge.cpp; device.added/updated/removed, attr.bulk, alert.* |
| W2 | MQTT gateway (subscribe + publish) | ✅ | mqtt_gw; all builds (the wired build got the real client in 2026-09) |
| W3 | MQTT message ingest → MQTT_MSG event | ✅ | Rules can trigger on MQTT |
| W4 | WS command envelope {id, cmd, args} | ✅ | 35-entry dispatch table in ws_bridge.cpp; calls the same api_* as REST |
| W5 | Preact SPA speaks WS only | ✅ | www-spa/; 12 pages; adapts to dual-chip, wired and single-chip hubs |
| W7 | Home Assistant MQTT discovery | ✅ | ha_bridge; dual-chip and wired, off by default |
| W6 | SCRIPT_RUN_REQ (0x58) → lua_engine_run_script | ✅ | SPA Run button, REST /api/scripts/{name}/run, WS script.run |
6. System / Infrastructure
| # | Feature | Status | Notes |
|---|---|---|---|
| S1 | Event bus (subscribe/publish, per-subscriber queues) | ✅ | event_bus.cpp |
| S2 | NVS settings persistence (broker, timezone, token) | ✅ | Multiple namespaces |
| S3 | SPIFFS mount (web UI static files) | ✅ | /spiffs at boot |
| S4 | Stack monitor (task_stack_mon) — all tasks | ✅ | Includes TaskLua + zb_interview + task_shadow |
| S5 | Timezone load from NVS at boot + live update | ✅ | E6 implementation |
| S6 | Alert system (LOW_BATTERY, DEVICE_LOST) | ✅ | send_alert → HAP |
| S7 | SNTP time sync + hourly re-sync to P4 | ✅ | task_time_sync |
| S8 | P4 task priority: task_event_bus idle 20 ms | ✅ | P3 fix |
| S9 | justfile (build/flash/monitor shortcuts) | ✅ | Repo root |
| S10 | CI binary size report | ✅ | idf_size.py in both build jobs |
| S11 | VSCode IntelliSense (clangd + compile_commands) | ✅ | Both targets configured |
| S12 | Response buffers always-resident in RAM | ⚠️ | improvements-report §M3 |
| S13 | Redundant HAP JSON encode/decode cycle | ⚠️ | improvements-report §P5 |
| S14 | P4 partition map has unused flash | 🔲 | improvements-report §M2 |
| S15 | WiFi connection manager (AP fallback + STA) | ✅ | wifi_mgr.cpp, fully functional in AP mode |
| S16 | DNS captive portal in AP mode | ✅ | All DNS → 192.168.4.1, auto-opens browser |
| S17 | WiFi scan-and-select in Settings UI | ✅ | GET /api/wifi/scan + network list |
| S18 | GPIO0 long-press WiFi credential reset | ✅ | 5s hold → erase NVS, reboot to AP |
| S19 | MQTT auto-disable in AP mode | ✅ | Greyed out in UI, skipped in firmware |
| S20 | Wi-Fi setup from the browser flasher (Improv Wi-Fi serial) | ✅ | Dual-chip S3, UART port |
| S21 | Browser flasher (ESP Web Tools) with release images built by CI | ✅ | flash/; hardware run of each image pending |
| S22 | Backup and restore of names, rules, scripts and collections | ✅ | Web UI, every build; the Zigbee network itself is not included |
6b. Wired build (zhac-wired-core)
| # | Feature | Status | Notes |
|---|---|---|---|
| WC1 | Ethernet: IP101 over RMII (P4), YT8531 over RGMII (S31) | ✅ | DHCP hostname zhac |
| WC2 | Zigbee through an ESP32-C6 running ot_rcp (esp-zigbee-lib) | ✅ | Running on the S31 with real devices |
| WC3 | Web UI embedded in the firmware image | ✅ | One file to flash |
| WC4 | OTA from a URL, with rollback if the new image does not come up | ✅ | First hardware run pending |
| WC5 | Radio crash guard: a boot that crashed in the radio stack starts without it and says so | ✅ | Web UI shows the radio as down |
| WC6 | C6 radio firmware update from the P4 | 🔲 | Not possible on the Guition board: the C6's bootloader UART and BOOT pin do not reach the P4. Flash the C6 once through its header |
| WC7 | Wall clock from SNTP once Ethernet has an address, from a time server named in Settings, or from the browser (time.set) | ✅ | The boards have no RTC; "last seen" and cron rules depend on it, and cron rules wait while it is unset |
7. Multi-Protocol Support
| # | Feature | Status | Notes |
|---|---|---|---|
| MP1 | NcpProtocol enum + ZapDevice.protocol field | ✅ | zap_common.h, zero-init = Zigbee |
| MP2 | DeviceBackend interface + registry (max 4) | ✅ | device_backend component |
| MP3 | zigbee_backend adapter | ✅ | Wraps zigbee_mgr/pool/store/zcl |
| MP4 | proto_mask heartbeat (bitmask of running backends) | ✅ | Replaces zigbee_ok bool |
| MP5 | Key-based SET_ATTR dispatch through backend | ✅ | cluster==0 → DeviceBackend.write_attr() |
| MP6 | DeviceDefinition.protocol + matcher filtering | ✅ | 158 defs unchanged (zero-init) |
| MP7 | REST API "proto" field in device responses | ✅ | hap_json encode |
| MP8 | Web UI protocol badges (device table + detail) | ✅ | ZB/BLE/TH/WiFi/ZW color badges |
| MP9 | NVS schema migration (v3→v4) | ✅ | Struct layout change handled |
| MP10 | EZSP/ASH driver for EFR32 NCP | ⚠️ | Driver implemented, backend pending |
| MP11 | Kconfig NCP selection (ZNP/EZSP) | ✅ | ZHAC_NCP_BACKEND choice |
| MP12 | BLE driver + backend | 🔲 | Needs BLE co-processor hardware |
| MP13 | Thread/Matter support | 🔲 | Future — after BLE is proven |
8. New Feature Ideas
7.1 ✅ Run Named Lua Script from Simple Rules (landed)
Shipped alongside the Lua engine migration. A simple rule action DO script.run "<name>" enqueues the named Lua script onto TaskLua's resume queue. The trigger's string value is passed as the script's single call argument.
ON kitchen_pir#occupancy=1 DO script.run kitchen_night ENDON
Wiring:
components/simple_rules/include/simple_rules.hdefinesActionType::SCRIPT = 6+ thesimple_rules_script_hook_tcallback typedef andsimple_rules_set_script_hook(hook).components/lua_engine/src/lua_engine_rules_hook.cppregisterson_script_run(name, event_val)which callslua_scheduler_push_run_named(name, event_val).components/lua_engine/src/lua_scheduler.cpphandlesMSG_RUN_NAMEDmessages by reading/scripts/<name>.luafrom the script cache, compiling, and spawning a coroutine.
REST: see docs/REST_API.md Scripts (Lua) — GET/POST/DELETE /api/scripts/{name}.
7.2 💡 Persistent KV Store from Simple Rules
Motivation:
Common use cases — remembering the last known scene, counting events, storing a threshold — currently have no DSL-level primitive. A compact NVS-backed key-value store would let simple rules track small amounts of state without escalating to a Lua script.
Desired DSL:
# Store last lighting scene
on Kitchen_Light state = 0
do kv.set kitchen_scene off
endon
# Use stored value as an event payload
on Boot
do publish home/restore $kitchen_scene
endon
Design:
7.2.1 New ActionType
enum class ActionType : uint8_t {
...
KV_SET = 6, // ← new
// KV_GET is implicit via $kv.<key> expansion (§7.2.3)
};
// RuleAction usage: arg0 = key (≤19 chars), arg1 = value string (≤19 chars, or "$val")
7.2.2 execute_rule — KV_SET case
case ActionType::KV_SET: {
char val_buf[32];
expand_value(a.arg1, event_val, val_buf, sizeof(val_buf));
// Try integer first; fall back to string
char* end;
long ival = strtol(val_buf, &end, 10);
if (*end == '\0')
kv_store_set_int(a.arg0, (int32_t)ival);
else
kv_store_set_str(a.arg0, val_buf);
ESP_LOGI(TAG, "kv.set %s = %s", a.arg0, val_buf);
break;
}
7.2.3 $kv.<key> value expansion in action args
Extend expand_value to resolve $kv.<key> at rule execution time:
do publish home/kitchen/scene $kv.kitchen_scene
This reads kitchen_scene from the KV namespace and substitutes it into the PUBLISH payload — no new ActionType needed for read. The same namespace would also be exposed to Lua through a future zhac.kv_* module extension.
7.2.4 DSL parse change
In the dsl_parse action parser, recognise "kv.set" keyword:
do kv.set <key> <value>
key validated as [a-zA-Z0-9_], max 15 chars (NVS key limit is 15 chars).
7.2.5 REST API — KV inspection endpoint
| Method | Path | Description |
|---|---|---|
GET | /api/kv | Dump all KV entries (key + type + value) |
DELETE | /api/kv/{key} | Delete a key |
7.2.6 Files to change
| File | Change |
|---|---|
components/simple_rules/include/simple_rules.h | Repurpose the reserved ActionType::KV_SET value (currently unused — SCRIPT=6 took slot 6; pick a new unused tag) |
components/simple_rules/simple_rules.cpp | Add "kv.set" in dsl_parse, KV_SET case in execute_rule, $kv. in expand_value |
New components/kv_store/ | Small NVS namespace (kv_store) plus kv_store_set_int/str/get_int/str API |
components/lua_engine/src/zhac_lua_module.cpp | Expose zhac.kv_set_int/str, zhac.kv_get_int/str entries in kZhacLib |
firmware/zhac-net-core/main/rest_ops.cpp | Add GET /api/kv and DELETE /api/kv/{key} endpoints |
firmware/zhac-net-core/main/main.cpp | Register new URI handlers |
Effort: ~3h | Impact: High — eliminates scripting boilerplate for state-tracking patterns
8. Known Technical Debt
| ID | Area | Description | Priority |
|---|---|---|---|
| TD1 | simple_rules | RuleSlot.src[500] stores the rule's raw DSL; historical Berry-specific duplication was retired with the Berry engine | P3 |
| TD2 | rest_ops.cpp | REST handlers block on HAP mutex — high-concurrency requests queue up | P1 |
| TD3 | hap_json | S3 encodes JSON → HAP → P4 decodes JSON → re-encodes for dispatch — double parse per op | P2 |
| TD4 | main.cpp (S3) | ~90 semaphore/mutex handles as file-scope globals — should be encapsulated per subsystem | P2 |
| TD5 | hap_dispatch | Response buffers (tx_buf) declared static inside handlers — always resident in IRAM even when idle | P2 |
| TD6 | cron_parser | No cron_next() to compute next fire time — 1-min polling loop wakes every minute regardless | P2 |
| TD7 | ZapDevice | Fixed endpoints[4], clusters[16] — devices with more endpoints/clusters silently truncated | P2 |
| TD8 | Web UI | — | |
| TD9 | event_bus | No back-pressure when ZCL events burst — UART RX task can block on full subscriber queue | P1 |
| TD10 | embedded-zhc | Attr key strings duplicated across PreparedDefinitions — interning would save ~1–2 KB flash | P3 |