ZHAC WebSocket API Reference

May 12, 2026 · View on GitHub

URL: ws://<device-ip>/ws Transport: single endpoint serves both the command-envelope RPC and the unsolicited push-event stream. Text frames only. UTF-8 JSON.

The Preact SPA under www-spa/ is the first-party consumer of this API and speaks WebSocket only. REST (see REST_API.md) is retained for scripts and third-party integrations; every command below is byte-identical to its REST equivalent because both transports call the same api_* handler in zhac-net-core/main/api_handlers.cpp.


Envelope format

Request

{ "id": 42, "cmd": "<name>", "args": { /* command-specific */ } }
  • id — any JSON value (int, string, null). Echoed verbatim on the response so the client can correlate. Integers are the common case.
  • cmd — command name; see the dispatch table below.
  • args — optional object. Passed through to the handler as the same body it would receive from a REST POST/PUT.

Response — success

{ "id": 42, "ok": true, "data": { /* handler output */ } }

data is embedded verbatim, not re-serialised — the handler writes JSON directly into the response buffer.

Response — error

{ "id": 42, "ok": false, "err": "<reason>" }

Possible err strings: missing cmd, unknown cmd, bad request, not found, method not allowed, alloc failed, response too large, internal error.

Size limits

  • Request body (args serialised): 2 KB stack buffer.
  • Response body: 8 KB PSRAM buffer, 40 KB for logs.get.
  • Envelope overhead: 1 KB additional PSRAM.

Implementation: dispatch_envelope in zhac-net-core/main/ws_bridge.cpp.


Command dispatch table

The table below mirrors kWsCmds[] in ws_bridge.cpp. Each row maps a command name to an api_* handler and the equivalent REST route.

Status / system

cmdhandlerREST equivalent
status.getapi_status_getGET /api/status
alerts.getapi_alerts_getGET /api/alerts
logs.getapi_logs_getGET /api/logs
diagnostics.unhandled.getapi_diagnostics_unhandled_getGET /api/diagnostics/unhandled
settings.setapi_settings_setPOST /api/settings

WiFi

cmdhandlerREST equivalent
wifi.statusapi_wifi_statusGET /api/wifi/status
wifi.scanapi_wifi_scanGET /api/wifi/scan
wifi.connectapi_wifi_connectPOST /api/wifi
wifi.disconnectapi_wifi_disconnectDELETE /api/wifi

OTA

cmdhandlerREST equivalent
ota.s3api_ota_s3POST /api/ota
ota.p4api_ota_p4POST /api/p4-ota

Zigbee control

cmdhandlerREST equivalent
zigbee.permit_joinapi_zigbee_permit_joinPOST /api/permit_join
zigbee.resetapi_zigbee_resetPOST /api/zigbee/reset
zigbee.settings.setapi_zigbee_settings_setPOST /api/zigbee/settings

Devices

cmdhandlerREST equivalent
device.listapi_device_listGET /api/devices
device.getapi_device_getGET /api/devices/:ieee — response includes the exposes array (see below)
device.bindapi_device_bindPOST /api/devices/:ieee/bind
device.deleteapi_device_deleteDELETE /api/devices/:ieee
device.renameapi_device_renamePUT /api/devices/:ieee/attrs (name field)
device.reinterviewapi_device_reinterviewPOST /api/devices/:ieee/interview

device.get / device.list responses carry an exposes array with {name, type, access, unit, values} entries, built on P4 by zhac_adapter_build_exposes_json from the device's PreparedDefinition. The SPA StatesTab uses the access bitmask (bit 0 = STATE, bit 1 = SET, bit 2 = GET) to render read-only labels, editable inputs, or enum dropdowns.

Rules

cmdhandlerREST equivalent
rule.listapi_rule_listGET /api/rules
rule.createapi_rule_createPOST /api/rules
rule.deleteapi_rule_deleteDELETE /api/rules
rule.enableapi_rule_enablePUT /api/rules
rule.updateapi_rule_updatePUT /api/rules/:id

Scripts

cmdhandlerREST equivalent
script.listapi_script_listGET /api/scripts
script.readapi_script_readGET /api/scripts/:name
script.writeapi_script_writePOST /api/scripts/:name
script.deleteapi_script_deleteDELETE /api/scripts/:name
script.runapi_script_runPOST /api/scripts/:name/run

script.run routes through HAP message SCRIPT_RUN_REQ = 0x58 to the P4, which calls lua_engine_run_script(const char*).

Groups

cmdhandlerREST equivalent
group.listapi_group_listGET /api/groups
group.createapi_group_createPOST /api/groups
group.getapi_group_getGET /api/groups/:id
group.updateapi_group_updatePUT /api/groups/:id
group.deleteapi_group_deleteDELETE /api/groups/:id
group.cmdapi_group_cmdPOST /api/groups/:id/cmd

Push events

Events are broadcast unsolicited to every connected client using the envelope:

{ "event": "<name>", "data": <payload> }

data is a JSON value of any shape — object, array, number, string. No id field, no correlation — events are fire-and-forget.

Implementation: ws_event_broadcast(name, payload_json, payload_len) in zhac-net-core/main/ws_bridge.cpp, fan-out protected by a mutex over a 2 KB file-static scratch slab.

Event catalogue

eventemitted frompayload
device.addedhap_bridge.cpp on DEVICE_JOINED{ieee, ...device summary}
device.updatedhap_bridge.cpp on identity / state change{ieee, ...device summary}
device.removedhap_bridge.cpp on DEVICE_DELETED{ieee}
device.list.snapshothap_bridge.cpp after HAP resync[{...}, ...] — full device array
attr.bulkhap_bridge.cpp coalesced 100 ms window[{type:"device_update", ieee, attrs:{...}, lqi, last_seen}, ...]
alert.addedhap_bridge.cpp on new persisted alert{code, ieee, msg, ts}
alert.firedhap_bridge.cpp on transient alert{code, ieee, msg, ts}

log.entry — currently disabled

The log ring (zhac-net-core/main/log_ring.cpp) is capable of emitting a log.entry event per log line, but s_ws_enabled is force-initialised to false. Per-line broadcast caused httpd back-pressure and a feedback storm through log_vprintf_hook during bootstrap. The SPA Logs page polls logs.get every 5 s as a stopgap. Re-enabling needs a dedicated TaskLogStream — see TODO.md "Follow-ups from 2026-04-22".


Connection lifecycle

  1. Client opens ws://<host>/ws. ws_server handles the HTTP upgrade.
  2. WsRxCallback(int fd, payload, len) is invoked for every text frame; non-JSON frames are rejected with err: "bad request".
  3. ws_server_reply(fd, ...) sends the envelope back on the originating socket. Push events go to every connected socket.
  4. LRU purge (cfg.lru_purge_enable = true in ws_server_init) evicts the oldest idle socket when max_open_sockets is hit — keeps mobile browsers with 6 speculative connections from exhausting accept().

Authentication: the bearer token accepted on REST also gates /ws; see REST_API.md "Authentication" and the open follow-up in TODO.md for the planned session cookie.


See also

  • REST_API.md — per-endpoint request / response shapes (same schema on both transports).
  • LUA_API.md §8–§9 — Scripts REST + WS surface.
  • zhac-net-core/main/ws_bridge.cpp — dispatcher source of truth.
  • zhac-net-core/main/api_handlers.{h,cpp} — every api_* called by the table above.