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

BuildChipsNetworkZigbee radioStatus
Dual-chip (zhac-platform)ESP32-S3 + ESP32-P4Wi-FiTI CC2652P, Z-StackMain build, runs on hardware
Wired (zhac-wired-core)ESP32-P4 or ESP32-S31EthernetESP32-C6 running ot_rcpS31 runs on hardware; P4 builds, first hardware run pending
Single-chip (zhac-mono-core)ESP32-S3Wi-FiTI CC2652P, Z-StackBuilds; 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.

FeatureOn 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 homeYour own VPN or reverse proxy, or the ESP RainMaker bridge (dual-chip build option)Optional ZHAC Cloud (in development)
Long-term history, charts, phone appOptional ZHAC Cloud (in development)

Nothing on the hub depends on a cloud service.


Legend

SymbolMeaning
✅Fully implemented and in production
⚠️Partially implemented — gaps noted
🔲Planned / not started
💡New feature idea (design below)

1. Zigbee Stack

#FeatureStatusNotes
Z1ZNP coordinator driver (UART framing, AF/ZDO)✅znp_driver.cpp
Z2Device join / leave events✅HAP DEVICE_JOIN / DEVICE_LEAVE
Z3Device pool (ZapDevice, PSRAM)✅zigbee_pool.cpp
Z4Device shadow (live attr cache)✅device_shadow.cpp
Z5Device interview (cluster enumeration)✅task_zb_interview
Z6Device persistent store (zap_store)✅NVS-backed, survives reboot
Z7Permit-join (30-second window)✅POST /api/permit_join
Z8Device friendly names✅Stored in ZapDevice.friendly_name
Z9Device delete + Zigbee leave request✅handle_device_delete
Z10Zigbee bind / unbind (direct device linking)✅zhac_bind / zhac_unbind
Z11Zigbee group membership management✅zhac_group_add/remove/cmd
Z12ZNP task priority drop after init✅Priority 5→2 after coordinator ready
Z13ZNP transport refactor: dedicated RX + worker tasks, per-call reply queues, late-SRSP policy✅components/znp_driver/src/znp_worker.cpp
Z14Event 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.

#FeatureStatusNotes
C1PreparedDefinition IR — FzConverter chain + TzConverter per writable attr, no allocation at run time✅embedded/zhc/src/runtime/
C2FzConverter engine — ZCL attr → string-keyed ZclAttribute✅zhac_adapter_try_decode
C3TzConverter engine — friendly key → ZCL write✅zhac_adapter_send_{bool,uint,string}
C4Exposes JSON generated from the library's expose bindings✅PSRAM buffer
C5Per-device ports committed under embedded-zhc/definitions/<vendor>/generated/✅Regenerated by just refresh-parity
C6Vendor-specific shared modules (_shared.cpp) for custom decoders✅Xiaomi lumi TLV, Tuya DP, MiBoxer magic-packet, Hue dimmer, IKEA arrow
C7ZCL Default Response sender — unicast ACK, loop-prevented✅zigbee_send_default_response in zcl_commands.cpp
C8ZHC definition cache — slot per device, nullptr-skip on unresolved identity✅zhac_adapter (fix 2026-04-22)
C9Generic fallback for unlisted devices (on/off, level, colour, battery, temperature, humidity, pressure, illuminance clusters)✅zhc_adapter_fallback.cpp
C10Published supported-devices list and search page, generated from the registries the firmware matches✅supported-devices/

3. Simple Rules Engine

#FeatureStatusNotes
R1DSL parser (dsl_parse)✅simple_rules.cpp
R2Trigger — device attribute change✅TriggerType::DEVICE_ATTR
R3Trigger — boot✅TriggerType::BOOT
R4Trigger — named event✅TriggerType::EVENT
R5Trigger — FreeRTOS timer fire✅TriggerType::TIMER
R6Trigger — MQTT topic message✅TriggerType::MQTT_TOPIC
R7Trigger — cron schedule✅TriggerType::TIME_CRON, 1-min task
R8Action — zigbee.set (write device attribute)✅ActionType::ZIGBEE_SET
R9Action — publish (MQTT)✅ActionType::PUBLISH
R10Action — event (fire named event)✅ActionType::EVENT
R11Action — timer (start/reset FreeRTOS timer)✅ActionType::TIMER
R12Action — log (ESP_LOGI)✅ActionType::LOG
R13Rule conditions (=, !=, >, <, >=, <=)✅CondOp enum, compare_value
R14Device name resolution (friendly name → IEEE at eval)✅simple_rules_resolve_names; full 29-character names since 2026-09
R15Rule store (NVS, survives reboot)✅rule_store.cpp
R16Up to 4 chained actions per rule✅ParsedRule.actions[4]
R17%value% expansion (inject trigger value into action arg)✅expand_value
R18Action — script.run <name> (invoke a named Lua script)✅ActionType::SCRIPT; wired via simple_rules_set_script_hook → lua_scheduler_push_run_named
R19Action — kv.set / kv.get (persistent KV from rules)💡New — see §7.2
R21Rule and script templates in the web UI✅www-spa/src/templates.js; every rule template passes the parser
R20Value 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.

#FeatureStatusNotes
L1Routed 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)
L2Coroutine scheduler (TaskLua + resume queue + esp_timer pool)✅lua_scheduler.cpp; pool size CONFIG_LUA_ENGINE_TIMER_POOL (16)
L3Sandbox — io.* nil'd, dangerous os.* removed, bytecode load blocked✅lua_sandbox.c
L4SPIFFS-backed script cache at /scripts/<name>.lua (atomic tmp+rename)✅lua_script_cache.cpp; 16 KB per file, up to 16 files
L5zhac.log(level, msg) — ESP_LOGI/W/E/D with tag lua_script✅zhac_lua_module.cpp::l_zhac_log
L6zhac.millis() — monotonic ms since boot✅esp_timer_get_time() / 1000
L7zhac.sleep(ms) — cooperative yield via timer pool✅lua_scheduler_sleep; errors if pool exhausted
L8zhac.set_attr(ieee_hex, key, value) — ZCL write via zhc_adapter✅Dispatches bool/uint/string to zhac_adapter_send_*
L9zhac.get_attr(ieee_hex, key) — shadow read✅device_shadow_get_attrs
L10zhac.publish(topic, payload [, qos [, retain]]) — MQTT publish✅mqtt_gw_publish; alias zhac.mqtt_publish
L11zhac.event(name) — fire RULE_EVENT✅event_bus_publish
L12zhac.on_attr_change(ieee_hex, key, fn) — attr-change handler✅Registry-backed dispatch from EventBus ATTR_CHANGE
L13zhac.on_cron(expr, fn) — cron handler✅Registered; fired by the simple_rules cron task via EventBus
L14zhac.on_mqtt(topic, fn) — MQTT handler✅Dispatch via EventBus::MQTT_MSG
L15zhac.on_boot(fn) — one-shot at firmware boot✅Dispatch via EventBus::CTRL_BOOT
L16Curated libraries behind Kconfig: cjson, lpeg, miniz⚠️Hooks shipped, sources vendored per flag — default n
L17Prometheus metrics (zhac_p4_lua_*) — heap, peak, live coroutines, errors, yields✅metric_registry.def
L18ZCL_RAW capture from Lua🔲Deferred; use MQTT bridge meanwhile
L19Direct ZDO bind/unbind / group membership from Lua🔲Use REST /api/devices/:ieee/bind, /api/groups/:id/cmd
L20Persistent KV store accessible from Lua🔲Planned alongside §7.2

5. Communication & API

5a. HAP Protocol (S3 ↔ P4)

#FeatureStatusNotes
H1Binary framing (type, seq, flags, payload)✅hap_protocol.h
H2ACK / NO_ACK frame flags✅HAP_FLAG_NEEDS_ACK
H3Heartbeat (5 s interval)✅HAP_HEARTBEAT_INTERVAL_MS
H4Semaphore-drain before each roundtrip✅Race fix Q6
H5HAP handler registry (22 message types)✅s_hap_handlers[256]
H6Bulk device-event batching (10 ms window)✅flush_bulk task

5b. REST API (S3)

#FeatureStatusNotes
A1API token auth (REQUIRE_AUTH)✅NVS zhac_auth/token; all three builds
A18Admin password sign-in for the web UI✅All three builds; first claim bounded to 10 min after power-on
A2Rate limiting (RATE_LIMIT macro)✅POST /permit_join, /wifi, /ota
A3CORS headers on all authenticated routes✅SET_CORS_HEADERS
A4GET /api/devices✅
A5GET /api/devices/{id} + exposes✅
A6POST /api/devices/{id}/set✅
A7GET/POST/PUT/DELETE /api/rules✅Full CRUD
A8GET/POST/DELETE /api/scripts✅Lua scripts keyed by name (see docs/REST_API.md)
A9POST /api/settings (broker_url, timezone, ntp_server, MQTT and Home Assistant fields)✅All fields optional
A10POST /api/wifi✅
A11POST /api/ota (S3 and P4 OTA)✅Chunked upload; the wired build updates from a URL instead (ota.update)
A12GET/POST /api/groups + auth on GET✅R7 fix
A13POST /api/permit_join✅
A14Static file server (SPIFFS, SPA fallback)✅Path normalization R3
A15Inconsistent response format ({"ok":true} vs bare)⚠️improvements-report §R1
A16Blocking REST handlers (mutex contention)⚠️improvements-report §P4
A17HTTP method misuse (POST where PATCH expected)🔲improvements-report §R5

5c. WebSocket & MQTT

#FeatureStatusNotes
W1WebSocket bridge (S3 → browser push events)✅ws_bridge.cpp; device.added/updated/removed, attr.bulk, alert.*
W2MQTT gateway (subscribe + publish)✅mqtt_gw; all builds (the wired build got the real client in 2026-09)
W3MQTT message ingest → MQTT_MSG event✅Rules can trigger on MQTT
W4WS command envelope {id, cmd, args}✅35-entry dispatch table in ws_bridge.cpp; calls the same api_* as REST
W5Preact SPA speaks WS only✅www-spa/; 12 pages; adapts to dual-chip, wired and single-chip hubs
W7Home Assistant MQTT discovery✅ha_bridge; dual-chip and wired, off by default
W6SCRIPT_RUN_REQ (0x58) → lua_engine_run_script✅SPA Run button, REST /api/scripts/{name}/run, WS script.run

6. System / Infrastructure

#FeatureStatusNotes
S1Event bus (subscribe/publish, per-subscriber queues)✅event_bus.cpp
S2NVS settings persistence (broker, timezone, token)✅Multiple namespaces
S3SPIFFS mount (web UI static files)✅/spiffs at boot
S4Stack monitor (task_stack_mon) — all tasks✅Includes TaskLua + zb_interview + task_shadow
S5Timezone load from NVS at boot + live update✅E6 implementation
S6Alert system (LOW_BATTERY, DEVICE_LOST)✅send_alert → HAP
S7SNTP time sync + hourly re-sync to P4✅task_time_sync
S8P4 task priority: task_event_bus idle 20 ms✅P3 fix
S9justfile (build/flash/monitor shortcuts)✅Repo root
S10CI binary size report✅idf_size.py in both build jobs
S11VSCode IntelliSense (clangd + compile_commands)✅Both targets configured
S12Response buffers always-resident in RAM⚠️improvements-report §M3
S13Redundant HAP JSON encode/decode cycle⚠️improvements-report §P5
S14P4 partition map has unused flash🔲improvements-report §M2
S15WiFi connection manager (AP fallback + STA)✅wifi_mgr.cpp, fully functional in AP mode
S16DNS captive portal in AP mode✅All DNS → 192.168.4.1, auto-opens browser
S17WiFi scan-and-select in Settings UI✅GET /api/wifi/scan + network list
S18GPIO0 long-press WiFi credential reset✅5s hold → erase NVS, reboot to AP
S19MQTT auto-disable in AP mode✅Greyed out in UI, skipped in firmware
S20Wi-Fi setup from the browser flasher (Improv Wi-Fi serial)✅Dual-chip S3, UART port
S21Browser flasher (ESP Web Tools) with release images built by CI✅flash/; hardware run of each image pending
S22Backup 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)

#FeatureStatusNotes
WC1Ethernet: IP101 over RMII (P4), YT8531 over RGMII (S31)✅DHCP hostname zhac
WC2Zigbee through an ESP32-C6 running ot_rcp (esp-zigbee-lib)✅Running on the S31 with real devices
WC3Web UI embedded in the firmware image✅One file to flash
WC4OTA from a URL, with rollback if the new image does not come up✅First hardware run pending
WC5Radio crash guard: a boot that crashed in the radio stack starts without it and says so✅Web UI shows the radio as down
WC6C6 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
WC7Wall 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

#FeatureStatusNotes
MP1NcpProtocol enum + ZapDevice.protocol field✅zap_common.h, zero-init = Zigbee
MP2DeviceBackend interface + registry (max 4)✅device_backend component
MP3zigbee_backend adapter✅Wraps zigbee_mgr/pool/store/zcl
MP4proto_mask heartbeat (bitmask of running backends)✅Replaces zigbee_ok bool
MP5Key-based SET_ATTR dispatch through backend✅cluster==0 → DeviceBackend.write_attr()
MP6DeviceDefinition.protocol + matcher filtering✅158 defs unchanged (zero-init)
MP7REST API "proto" field in device responses✅hap_json encode
MP8Web UI protocol badges (device table + detail)✅ZB/BLE/TH/WiFi/ZW color badges
MP9NVS schema migration (v3→v4)✅Struct layout change handled
MP10EZSP/ASH driver for EFR32 NCP⚠️Driver implemented, backend pending
MP11Kconfig NCP selection (ZNP/EZSP)✅ZHAC_NCP_BACKEND choice
MP12BLE driver + backend🔲Needs BLE co-processor hardware
MP13Thread/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.h defines ActionType::SCRIPT = 6 + the simple_rules_script_hook_t callback typedef and simple_rules_set_script_hook(hook).
  • components/lua_engine/src/lua_engine_rules_hook.cpp registers on_script_run(name, event_val) which calls lua_scheduler_push_run_named(name, event_val).
  • components/lua_engine/src/lua_scheduler.cpp handles MSG_RUN_NAMED messages by reading /scripts/<name>.lua from 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

MethodPathDescription
GET/api/kvDump all KV entries (key + type + value)
DELETE/api/kv/{key}Delete a key

7.2.6 Files to change

FileChange
components/simple_rules/include/simple_rules.hRepurpose the reserved ActionType::KV_SET value (currently unused — SCRIPT=6 took slot 6; pick a new unused tag)
components/simple_rules/simple_rules.cppAdd "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.cppExpose zhac.kv_set_int/str, zhac.kv_get_int/str entries in kZhacLib
firmware/zhac-net-core/main/rest_ops.cppAdd GET /api/kv and DELETE /api/kv/{key} endpoints
firmware/zhac-net-core/main/main.cppRegister new URI handlers

Effort: ~3h | Impact: High — eliminates scripting boilerplate for state-tracking patterns


8. Known Technical Debt

IDAreaDescriptionPriority
TD1simple_rulesRuleSlot.src[500] stores the rule's raw DSL; historical Berry-specific duplication was retired with the Berry engineP3
TD2rest_ops.cppREST handlers block on HAP mutex — high-concurrency requests queue upP1
TD3hap_jsonS3 encodes JSON → HAP → P4 decodes JSON → re-encodes for dispatch — double parse per opP2
TD4main.cpp (S3)~90 semaphore/mutex handles as file-scope globals — should be encapsulated per subsystemP2
TD5hap_dispatchResponse buffers (tx_buf) declared static inside handlers — always resident in IRAM even when idleP2
TD6cron_parserNo cron_next() to compute next fire time — 1-min polling loop wakes every minute regardlessP2
TD7ZapDeviceFixed endpoints[4], clusters[16] — devices with more endpoints/clusters silently truncatedP2
TD8Web UINo mobile-responsive layout Resolved: navigation collapses below 800 px—
TD9event_busNo back-pressure when ZCL events burst — UART RX task can block on full subscriber queueP1
TD10embedded-zhcAttr key strings duplicated across PreparedDefinitions — interning would save ~1–2 KB flashP3