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.
{ "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.
{ "id": 42, "ok": true, "data": { /* handler output */ } }data is embedded verbatim, not re-serialised — the handler writes
JSON directly into the response buffer.
{ "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.
- Request body (
argsserialised): 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.
The table below mirrors kWsCmds[] in ws_bridge.cpp. Each row maps
a command name to an api_* handler and the equivalent REST route.
| cmd | handler | REST equivalent |
|---|---|---|
status.get |
api_status_get |
GET /api/status |
time.set |
api_time_set |
none (WS only) |
alerts.get |
api_alerts_get |
GET /api/alerts |
logs.get |
api_logs_get |
GET /api/logs |
diagnostics.unhandled.get |
api_diagnostics_unhandled_get |
GET /api/diagnostics/unhandled |
settings.set |
api_settings_set |
POST /api/settings |
time.set {"epoch": <Unix seconds>} hands a hub the time when it has none. No ZHAC board
has a battery-backed clock, so a hub without internet access (no NTP) starts every boot at
1970, and its scheduled rules wait. The hub takes the time only while its own clock is unset:
the reply {"set": true} means it did, {"set": false} that its clock was already set and was
left alone. status.get reports clock_set, and the web UI sends time.set by itself when that
is false. An epoch before 2020 or after 2099 is an error.
| cmd | handler | REST equivalent |
|---|---|---|
wifi.status |
api_wifi_status |
GET /api/wifi/status |
wifi.scan |
api_wifi_scan |
GET /api/wifi/scan |
wifi.connect |
api_wifi_connect |
POST /api/wifi |
wifi.disconnect |
api_wifi_disconnect |
DELETE /api/wifi |
| cmd | handler | REST equivalent |
|---|---|---|
ota.s3 |
api_ota_s3 |
POST /api/ota |
ota.p4 |
api_ota_p4 |
POST /api/p4-ota |
| cmd | handler | REST equivalent |
|---|---|---|
zigbee.permit_join |
api_zigbee_permit_join |
POST /api/permit_join |
zigbee.reset |
api_zigbee_reset |
POST /api/zigbee/reset |
zigbee.settings.set |
api_zigbee_settings_set |
POST /api/zigbee/settings |
net.map.scan |
wired build: starts a network-map scan, replies {started:true}; errors busy, not_ready, oom |
— |
net.map.get |
wired build: {scanning, progress:{done,total,current}, map}; map is null before the first scan |
— |
| cmd | handler | REST equivalent |
|---|---|---|
device.list |
api_device_list |
GET /api/devices |
device.get |
api_device_get |
GET /api/devices/:ieee — response includes the exposes array (see below) |
device.bind |
api_device_bind |
POST /api/devices/:ieee/bind |
device.delete |
api_device_delete |
DELETE /api/devices/:ieee |
device.rename |
api_device_rename |
PUT /api/devices/:ieee/attrs (name field) |
diag.tasks |
— (wired and single-chip builds) | — |
device.reinterview |
api_device_reinterview |
POST /api/devices/:ieee/interview |
Rename, delete and permit join share one implementation with REST (device_cmd in
zhac-components): device.rename refuses names over 29 bytes or with quotes, backslashes or
control characters; device.delete with hard: false (default) asks the device to leave and
hides it, hard: true also wipes its stored row, shadow and converter caches;
zigbee.permit_join takes duration 0–254 (255 is clamped to 254 on the radio) and
zigbee.permit_join.status reads the same deadline the REST route set. diag.tasks answers
{"cores":2,"tasks":[{"name","core","cpu","prio","stack_free"}, …]}: every FreeRTOS task with
its CPU share since the previous call (per-task 0–100, so the sum may reach 100 × cores), the core
it is pinned to (−1 = any), priority and stack headroom; the Diag page polls it every 5 s. The rule.added /
rule.updated / rule.deleted pushes are emitted for every change, whichever door made it
(WebSocket, REST, backup restore), on the wired and mono builds.
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.
On the wired build, device.get of a motion sensor that reports motion but never "no motion"
(its definition has zigbee2mqtt's occupancy_timeout option) also carries
occupancy_timeout_default (the definition's default, seconds: 90, or 62 / 32 for some Aqara
models) and occupancy_timeout (the interval in force: the user's choice once saved, else the
default; 0 = never). The hub reports occupancy = false that long after the last motion.
device.options.set {"ieee", "occupancy_timeout": N} saves the user's choice (0–65535; out of
range is refused). Devices without the option carry neither field.
device.get on the wired build also carries no_occupancy_since (seconds, 0 = off), zigbee2mqtt's no_occupancy_since option with one interval, for any motion sensor. Once the device reports occupancy = false (or the hub's no motion interval does) and that many seconds pass without motion, the hub sets the device value no_occupancy_since to N; the next motion sets it back to 0, so an edge-triggered rule fires once per quiet period. A repeated "no motion" report does not restart a running count, and after a hub restart counting starts again at the next motion change. device.options.set {"ieee", "no_occupancy_since": N} saves it (0–65535; out of range is refused). device.options.set merges each call into the device's stored options.
| cmd | handler | REST equivalent |
|---|---|---|
rule.list |
api_rule_list |
GET /api/rules |
rule.create |
api_rule_create |
POST /api/rules |
rule.delete |
api_rule_delete |
DELETE /api/rules |
rule.enable |
api_rule_enable |
PUT /api/rules |
rule.update |
api_rule_update |
PUT /api/rules/:id |
rule.check |
— (wired build) | — |
rules.status |
— (wired build) | — |
rule.run |
— (wired build) | — |
On the wired build:
rule.list {"cursor": "0"}answers one page,{"items": [{id, enabled, trigger_type, rule_type, name, dsl}, …], "next_cursor": "<last id>"}: rows sorted by id, after the cursor, at most 6,144 bytes of rows; the last page has nonext_cursor. Withoutcursorit answers the bare array, as before.rule.createanswers{"id": <new id>}.rule.update {id, name, dsl, base}: withbase(the lowercase hex SHA-256 of the rule text the editor opened), the hub answerschangedand saves nothing when the stored text is different. A rule deleted on the hub in the meantime answerschangedtoo; a reload then shows it gone.rule.check {dsl}answers{"ok": bool, "err": "<parser message>"}and saves nothing.
rules.status answers [{"id","last_fired","runs","last_skip","ago"}, …], one entry per active
rule: runs since boot, the last run as epoch seconds (seconds since boot while the clock is unset),
ago = seconds since it (present when runs > 0), and why the rule last did not run or which
action failed ("", unchanged, condition_false, action_error:<action>). RAM only, reset on
reboot. rule.run {"id"} ("Run now") runs a rule's actions at once with %value% = the trigger
attribute's current value; the reply comes after they ran. Both are separate from rule.list and
the rule.* pushes on purpose: the cloud mirrors the rule objects, and volatile fields there would
churn every sync. Firmware without them answers unknown cmd; the Rules page then hides the status.
See RULES_DSL.md.
| cmd | handler | REST equivalent |
|---|---|---|
script.list |
api_script_list |
GET /api/scripts |
script.read |
api_script_read |
GET /api/scripts/:name |
script.write |
api_script_write (wired: WebSocket too) |
POST /api/scripts/:name |
script.part |
— (wired build) | — |
script.check |
— | POST /api/scripts/:name/check |
script.delete |
api_script_delete |
DELETE /api/scripts/:name |
script.run |
api_script_run |
POST /api/scripts/:name/run |
script.reload |
— (wired build) | — |
script.run routes through HAP message SCRIPT_RUN_REQ = 0x58 to the
P4, which calls lua_engine_run_script(const char*).
On the wired build (one request frame from the cloud must fit 8,192 bytes, so a long text travels in parts):
script.listitems carrymtime(epoch seconds) when the hub's clock was set at the save.script.read {name, offset}answers one page{src, offset, size, fp, next_offset?}: at most 6,144 bytes of JSON-encoded text, whole characters only;sizeandfp(the lowercase hex SHA-256 of the whole file) repeat on every page. Withoutoffsetit answers{src}, the whole file (up to 16,384 bytes).script.part {xfer, part, parts, src}stages partpart(0-based) ofparts(2–16) of transferxfer(1–16 of[A-Za-z0-9]), in order, and answers{xfer, received}. Part 0 opens the hub's one staging slot and drops any other transfer; nothing reaches flash beforescript.write. Errors:bad_part(another xfer, an index other than the next, a changed count),too_large(over 16,384 bytes),oom. The slot is dropped 30 s after its last use.script.write {name, src}or{name, xfer, size, fp}(the staged text, whose byte count and fingerprint must match), with optionalbase:""= the name must be free (exists), a fingerprint = the stored file must still have it (changed). The hub checks the syntax first (check:<line>:<message>) and answers{name, size, fp}. Other errors:bad_name,too_large,too_many(16 scripts),no_xfer,bad_part,write_failed,missing src(neithersrcnorxfer), andoom(no memory to read a stagedxfer). Only a successful save uses the staged text up: after any error it stays until it expires (30 s after its last use) or a new part 0 replaces it, so the sender can correctname,base,sizeorfpand sendscript.writeagain.script.check {name?, src}or{name?, xfer}answers{ok, err, line}; a staged text stays staged. Errors:missing src(neithersrcnorxfer),no_xfer,oom.script.reloadrestarts the Lua engine without a reboot (the hub page's Apply): every stored script is loaded again andon_boothandlers do not run again. It answers{loaded, total}, or{"queued": true}when the pass takes longer than 5 s (it still finishes), or the errorlua queue fullwhen the Lua task's queue has no room (ask again).- A REST save over 16,384 bytes is answered 413, and a chunked one (
Transfer-EncodingwithoutContent-Length, which the hub's HTTP server cannot read) 411; the stored script is kept either way. - If the hub restarted twice in a row while its scripts were starting (a load at boot or Apply, or the minute after it), it holds them: the next boot does not start them, a rule's
script.runaction is skipped,script.run(and RESTPOST /api/scripts/<name>/run, 500) answersscripts are held: the hub restarted twice while scripts were starting; Apply starts them again,status.getand everystatus.tickcarryscripts_held(the script that was loading at the last crash,""when unknown; a tick without it means the hold ended), andscript.errorrepeats the reason once a minute. Ascript.reloadthat completes, or a clean boot, ends the hold.
The cloud relay runs the same commands as this socket, with these limits:
- Allow script changes from the cloud (Settings → Remote;
settings.set {"remote_scripts": true|false};status.getreportsremote_scripts) is off by default and after a storage reset. While it is off, the relay answersscript.write,script.run,script.reloadandsystem.restartwithscripts_off. Only the hub's own page can change it: the relay answerssettings.setcarryingremote_scriptswithlocal_only. - Log to cloud (Settings → Remote;
settings.set {"log_to_cloud": "off" | "errors" | "warnings" | "everything"};status.getreportslog_to_cloud;remote.statusreportslog_sentandlog_droppedsince the hub started) is off by default and after a storage reset. Errors sends errors; Warnings sends warnings and errors; Everything adds the information lines. Only the hub's own page can change it: the relay answerssettings.setcarryinglog_to_cloudwithlocal_only. While it is on, the hub sendslog.batchevents (below) and, once per run,log.tail. The cloud link's own lines (remote_client,websocket_client,transport*,esp-tls*) never enter the backlog (log.batch); the restart tail may include them. - The relay passes
rule.check,script.partand every rule command. - Every rule change (
rule.create,rule.update,rule.enable,rule.delete,rule.run), every script change (script.write,script.delete,script.run,script.reload) andsystem.restartthat the relay passes is written to the hub's log with the tagcloud, before it runs, e.g.I (812345) cloud: script.write by <account id> name=heating; the line addsid=<n>when the command carries a numericid, andname=<name>when itsnamefits[A-Za-z0-9_-]{1,24}(every script name, and a rule name of that shape such asporch_light); any other name is left out. No other command the relay passes is logged. - Firmware with these commands says so when it connects:
caps: ["rules.v1", "scripts.v1", "logs.v1"],remote_scriptsandlog_to_cloud. - Every frame the hub sends to the cloud or to its own page is valid UTF-8: a byte that is not (a REST-uploaded script, a log line, a dual-chip rule text cut at 127 bytes) goes out as
?. The hub does this where frames leave it, so the JSON keeps its shape and length.
| cmd | handler | REST equivalent |
|---|---|---|
group.list |
api_group_list |
GET /api/groups |
group.create |
api_group_create |
POST /api/groups |
group.get |
api_group_get |
GET /api/groups/:id |
group.update |
api_group_update |
PUT /api/groups/:id |
group.delete |
api_group_delete |
DELETE /api/groups/:id |
group.cmd |
api_group_cmd |
POST /api/groups/:id/cmd |
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 | emitted from | payload |
|---|---|---|
device.added |
hap_bridge.cpp on DEVICE_JOINED |
{ieee, ...device summary} |
device.updated |
hap_bridge.cpp on identity / state change |
{ieee, ...device summary} |
device.removed |
hap_bridge.cpp on DEVICE_DELETED |
{ieee} |
device.list.snapshot |
hap_bridge.cpp after HAP resync |
[{...}, ...] — full device array |
attr.bulk |
hap_bridge.cpp coalesced 100 ms window |
[{type:"device_update", ieee, attrs:{...}, lqi, last_seen}, ...] |
alert.added |
hap_bridge.cpp on new persisted alert |
{code, ieee, msg, ts} |
alert.fired |
hap_bridge.cpp on transient alert |
{code, ieee, msg, ts} |
net.map.progress |
wired build, after each router answers a map scan; hub page only, never the cloud | {done, total, current} |
net.map.done |
wired build, scan finished; the page then calls net.map.get |
{ts, ok, err?} |
script.added / script.updated |
wired: a script was saved (REST or WebSocket) | {name, size, fp, mtime?} |
script.deleted |
wired: a script was deleted | {name} |
script.error |
wired: a script failed to compile at load, reload or run, or raised an error while running; at most one per script per 10 s and 17 in all per 10 s; once a minute while the hub holds its scripts | {name, line, message} (message at most 120 bytes; name only when it is [A-Za-z0-9_-]{1,24}, else "") |
rule.added / rule.updated |
wired: a rule was saved, switched on or off (any door) | the row rule.list carries: {id, enabled, trigger_type, rule_type, name, dsl} |
hub.caps |
wired: "Allow script changes from the cloud" or "Log to cloud" changed | {caps, remote_scripts, log_to_cloud} |
log.batch |
wired, Log to cloud on: every 5 s, or once 6 KB waits | {boot, seq, up, clock, dropped, lines: [[up_ms, "E"|"W"|"I", tag, msg], …]}, at most 6,144 bytes; seq is the first line's number in this run, clock the hub's epoch ms or 0 when its clock is not set |
log.tail |
wired, Log to cloud on: once per run, first | {boot, prev_boot, up, clock, reason, planned, prev_clock_base, prev_sent, crash, lines: [[up_ms, level, tag, msg, seq], …]}: why the hub restarted, a crash summary, and the previous run's lines the cloud never got |
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".
- Client opens
ws://<host>/ws.ws_serverhandles the HTTP upgrade. WsRxCallback(int fd, payload, len)is invoked for every text frame; non-JSON frames are rejected witherr: "bad request".ws_server_reply(fd, ...)sends the envelope back on the originating socket. Push events go to every connected socket.- LRU purge (
cfg.lru_purge_enable = trueinws_server_init) evicts the oldest idle socket whenmax_open_socketsis hit — keeps mobile browsers with 6 speculative connections from exhaustingaccept().
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.
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}— everyapi_*called by the table above.