Base URL: http://<device-ip>/api
Content-Type: application/json (all requests and responses)
Protected endpoints require the header:
X-Api-Key: <token>
The token is a 32-character hex string generated on first boot and printed to the serial console:
W (1234) main: *** NEW API TOKEN (save this): a1b2c3d4e5f6... ***
The token persists in NVS across reboots. Endpoints marked Auth required return 401 if the header is missing or wrong.
Humans don't handle 32-hex tokens well, so the WebUI authenticates with an admin password and exchanges it for the token. The token remains the only wire credential; these endpoints are the exchange layer on top:
| Endpoint | Auth | Body | Purpose |
|---|---|---|---|
POST /api/auth/login |
none (is the exchange) | {"password":"…"} |
Verify password → {"ok":true,"token":"<32hex>"}. 401 wrong password, 409 no password set yet. |
POST /api/auth/setup |
none, only while no password exists and for 10 minutes after power-on | {"password":"…"} |
First-boot: set the admin password (8-63 chars) and return the token. 403 {"error":"already_set"} once set; 403 {"error":"setup_closed"} when the window has passed (power-cycle the hub to reopen it). |
POST /api/auth/password |
Auth required + current password | {"current":"…","new":"…"} |
Change password. Rotates the API token (invalidates other clients) and returns the fresh one. |
The password is stored as a salted iterated-SHA-256 hash in NVS (never
plaintext). Login/setup attempts share the same per-peer lockout as token
checks. /api/status reports auth_setup_required: true while auth is
enabled but no password exists — the SPA shows its one-time setup card then —
and auth_setup_secs_left, the seconds the first-claim window has left (0 once
it closed). storage_error: true means the whole NVS partition could not be initialised at boot; the hub
runs locked and empty, erases nothing by itself, and the WebSocket command
system.storage_reset (no REST twin) erases it and restarts on the owner's request.
auth_storage_error: true means the hub could not open its sign-in
storage at boot and locked itself: sign-in is on, the only credential is the token printed
on the serial console for that boot, and POST /api/auth/setup answers
503 {"error":"storage_error"}. The window bounds the "first visitor claims the hub" pattern: a hub
that was never set up, or was reset weeks ago, cannot be claimed by whoever
finds it on the network later, only by someone who can power-cycle it.
A build can seed the password via CONFIG_ZHAC_DEFAULT_PASSWORD (keep it
empty in anything public — a committed password is a shared credential).
The serial-printed token remains the recovery path (lost password → sign in
with the token, or erase NVS to re-run setup).
Every endpoint below is a thin wrapper over a shared
transport-agnostic handler in
zhac-net-core/main/api_handlers.cpp (the api_* family). The
same handler is called from the WebSocket envelope dispatcher
(zhac-net-core/main/ws_bridge.cpp, 35-entry table), so request
and response bodies are byte-identical on the two transports.
The first-party web UI (www-spa/, Preact SPA) speaks WebSocket
only using the envelope
{id, cmd, args} ↔ {id, ok, data|err} — see
WS_API.md for the command list and push-event
catalogue. The REST surface is intended for scripts / third-party
integrations.
Returns S3 and P4 health metrics. No auth required. The P4-side values
are populated from the periodic HAP heartbeat (HapHeartbeat — 14
fields as of 2026-04-22).
Response:
{
"heap": 123456,
"heap_min": 100000,
"int_free": 48000,
"int_min": 32000,
"int_blk": 16000,
"psram_free": 4000000,
"psram_min": 3800000,
"psram_blk": 2000000,
"psram_total":8000000,
"stack_hwm": 1024,
"uptime": 3600,
"cpu_c0": 12,
"cpu_c1": 4,
"ip": "192.168.1.100",
"mac": "AA:BB:CC:DD:EE:FF",
"wifi": true,
"mqtt_connected": true,
"ws_clients": 2,
"synced": true,
"metrics_enabled":false,
"auth_enabled": true,
"api_token": "",
"p4": {
"devices": 12,
"uptime": 3598,
"fw": "1.0.0",
"heap": 123456,
"heap_min": 100000,
"int_free": 48000,
"int_min": 32000,
"int_blk": 16000,
"psram_free": 2000000,
"psram_min": 1800000,
"psram_blk": 900000,
"psram_total":4000000,
"stack_hwm": 512,
"cpu_c0": 9,
"cpu_c1": 3,
"proto_mask": 1,
"synced": true
}
}| Field | Meaning |
|---|---|
heap / heap_min |
Current and all-time-low free bytes across the full default heap |
int_free / int_min / int_blk |
Internal-RAM free / low-water / largest free block |
psram_free / psram_min / psram_blk / psram_total |
PSRAM equivalents |
stack_hwm |
Smallest high-water-mark remaining across the chip's task table, in bytes |
cpu_c0 / cpu_c1 |
Per-core CPU utilisation, percent |
proto_mask (P4 only) |
Bitmask of live DeviceBackends; bit 0 = Zigbee |
api_token |
Echoes the token only when auth is disabled (first-boot helper) |
Returns recent system alerts (battery low, script crash, rule errors). Auth required.
Response: Array, oldest first:
[
{
"code": 1,
"ieee": "0x001234567890ABCD",
"msg": "battery low: 15%",
"ts": 1712345678
}
]Alert codes: 1 = battery low, 2 = script crash, 3 = rule error.
Returns current WiFi mode and connection info. No auth required (must work in AP mode before token is known).
Response (AP mode):
{"mode": "ap", "ssid": "ZHAC-A1B2", "ip": "192.168.4.1", "sta_ssid": null, "rssi": null}Response (STA mode):
{"mode": "sta", "ssid": "MyNetwork", "ip": "192.168.1.100", "rssi": -42}Scans for nearby WiFi networks. Auth required. Rate limited: 1 scan per 10 seconds.
Response:
{
"networks": [
{"ssid": "MyNetwork", "rssi": -45, "auth": "wpa2"},
{"ssid": "Neighbor", "rssi": -72, "auth": "wpa2"},
{"ssid": "OpenNet", "rssi": -80, "auth": "open"}
]
}Auth types: open, wep, wpa, wpa2, wpa3, enterprise.
Save WiFi credentials and reboot into STA mode. Auth required. Rate limited: 1 per 30 seconds.
Body: {"ssid": "MyNetwork", "pass": "secret"}
Response: {"ok": true} — device reboots after 1 second.
Erase WiFi credentials and reboot into AP mode. Auth required.
Response: {"ok": true} — device reboots after 500ms.
Returns all devices from P4 (all protocols). No auth required.
Response: Raw P4 JSON — array of device objects:
[
{
"ieee": "0x001234567890ABCD",
"proto": "zb",
"nwk": "0x1234",
"name": "Living Room Bulb",
"eps": [1, 2],
"clusters": { "1": [6, 8, 768] },
"exposes": [
{ "type": "binary", "name": "state", "access": 7 },
{ "type": "numeric", "name": "brightness", "min": 0, "max": 254, "access": 7 }
]
}
]Returns a single device. :ieee = 0x001234567890ABCD. No auth required.
Response: Same shape as one item from GET /api/devices.
Errors: 500 on P4 timeout.
value may be a boolean, an integer, a decimal (21.5 — the device's converter scales it;
a thermostat setpoint goes out as 2150, a Tuya datapoint with divisor 10 as 215; a converter
that only takes integers refuses it with "no zhc converter") or a string (an enum option).
The same rule applies to the WebSocket device.attr.set and to the wired REST
/api/device/state.
Send a ZCL attribute write or command to a device. Auth required.
name: 1–29 bytes, no quotes, backslashes or control characters (the device list is
built with snprintf; one quote would break it). Refused with
name must be 1-29 characters without quotes, backslashes or control characters. A rename
reloads the rule engine's name table on every core.
Request:
{
"key": "state",
"val": 1,
"ep": 1,
"cluster": 0,
"attr": 0
}key— required. Well-known keys:state,brightness,color_temp,hue,saturation.val— required. Integer value.ep,cluster,attr— optional, default0. P4 resolves cluster/attr fromkeywhen0.
Response:
{ "ok": true }Errors: 500 on P4 timeout (3 s).
Remove a device. Auth required. Body {"hard": false} (default) asks the device to leave
and hides it; its name is kept, so a rejoin restores it. {"hard": true} also wipes the
stored row, the shadow and the converter caches. Same contract on every core (device_cmd).
Response: {"ok": true} or {"ok": false} on P4 timeout (5 s).
Read per-device options blob from S3 NVS. No auth required.
Response: Free-form JSON object (returns {} if none saved).
Save per-device options blob to S3 NVS. Auth required.
Request: Any JSON object — replaces the stored blob entirely.
Response: {"ok": true}
Sub-route dispatch:
POST /api/devices/*/bind,/unbind, and/intervieware all served by a single wildcard handler (handle_post_device_subrouteinrest_devices.cpp) that parses the suffix and delegates — ESP-IDF httpd only supports the trailing-segment*wildcard, so separate registrations can't coexist with/api/devices/*.
Issue a ZDO bind request. Auth required.
Request:
{
"src_ep": 1,
"cluster": 6,
"dst_ieee": "0x00124B0009D12345",
"dst_ep": 1
}Response: {"ok": true} — confirmed by BIND_ACK from P4 (5 s timeout).
Same as /bind but removes the binding. Auth required.
Trigger a fresh interview on the target device (useful after a stalled join or when the ZHC definition has been updated). Auth required.
Response: {"ok": true} when the interview task has accepted
the request; the interview itself runs asynchronously and emits
device.updated events as identity / support state changes.
Returns all groups. No auth required.
Response:
{
"groups": [
{
"id": 1,
"name": "Living Room",
"members": [
{ "ieee": "0x001234567890ABCD", "ep": 1 }
]
}
]
}Create a group. Auth required.
Request:
{
"name": "Living Room",
"members": [
{ "ieee": "0x001234567890ABCD", "ep": 1 }
]
}Response: Created group object (same shape as one item from GET /api/groups).
Returns a single group by numeric ID. No auth required.
Errors: 404 if not found.
Update group name and/or members. Auth required.
Request: Same shape as POST /api/groups — fields are optional; omitting name or members leaves them unchanged.
Response: Updated group object.
Delete a group. Auth required.
Response: {"ok": true} or {"ok": false}.
Fan-out a command to all group members. Auth required.
Request:
{ "key": "state", "val": 1 }Response:
{ "ok": true, "sent": 3 }sent = number of members the command was dispatched to.
Returns all automation rules from P4. No auth required.
Response: P4 JSON — array of rule objects.
Create a new rule. Auth required.
Request:
{ "dsl": "on 0x001234567890ABCD.state == 1 do set 0xAABBCCDDEEFF0011.state 0" }Response: 201 Created + created rule object.
Delete a rule by ID. Auth required.
Request:
{ "id": 3 }Response: P4 acknowledgement JSON.
Toggle a rule enabled/disabled. Auth required.
Request:
{ "id": 3, "enabled": false }Response: P4 acknowledgement JSON.
Replace rule DSL source. Auth required.
Request body: Raw DSL string (plain text, not JSON).
Response: {"ok": true} or {"ok": false}.
Lua scripts are stored on the P4 SPIFFS partition at /scripts/<name>.lua. See docs/LUA_API.md for the scripting API reference.
Name validation: [a-z][a-z0-9_-]{0,23} (up to 24 characters, lowercase alphanumeric + _ + -).
Source cap: 16 KB per script. Soft limit: 16 distinct files.
List all Lua scripts. Auth required. Returns an empty list if the P4 script cache isn't initialised yet — failed SCRIPT_LIST_REQ round-trips do not fail the endpoint.
Response:
{ "scripts": [ { "name": "motion", "size": 256 } ] }Get one Lua script's source. Auth required.
Response:
{ "name": "motion", "src": "zhac.log('hi')\n" }Errors: 400 on invalid name, 500 on P4 timeout.
Create or overwrite a Lua script. Auth required.
Request body: Raw Lua source text (Content-Type text/plain; body up to 16 KB). No JSON wrapper.
Response: 201 Created + {"ok":true}. On busy channel returns 429; on P4 timeout returns 500.
On the wired build (S31, P4+C6; 200 + {"ok":true}, also as PUT): a body over 16,384 bytes is answered 413, a body
that does not arrive in full 408 (timeout) or 400, and a chunked upload (Transfer-Encoding without Content-Length)
411; the stored script is kept in each case.
Bulk-upload multiple Lua scripts in a single HAP round-trip. Auth required.
Request:
[
{ "name": "motion", "src": "..." },
{ "name": "schedule", "src": "..." }
]Response:
{ "ok": true, "written": 2, "failed": 0 }A busy channel returns 429 for the entire batch; callers must retry the full array.
Delete a Lua script. Auth required.
Response: {"ok": true}. Deleting a non-existent script is idempotent and returns success.
Update WiFi credentials and reboot. Auth required.
Request:
{ "ssid": "MyNetwork", "pass": "secret" }Response: {"status": "ok", "reboot": true} — device reboots ~1 s later.
Update settings. Every field is optional; send only what changes. Auth required.
Request:
{ "broker_url": "mqtt://192.168.1.10:1883", "timezone": "CET-1CEST,M3.5.0,M10.5.0/3",
"ntp_server": "192.168.1.1" }ota_state (wired build) is pending while a freshly updated firmware is on trial and
verified once the hub has kept it; ota_rollback_reason appears only after an update was
undone and says which check failed (storage, web server or radio). See the wired README,
"Updates".
ntp_dhcp_server appears when the router offered a time server (DHCP option 42) and the hub is
using it; it comes first, ntp_server second. Naming a server switches the offer off.
ntp_server is the time server SNTP asks. Name one on your own network when the hub has no
internet access, or send an empty string for the public default (pool.ntp.org). It is
persisted and applied at once, and GET /api/status reports it as ntp_server alongside
clock_set. A host with spaces, quotes or over 63 characters is rejected.
Response: {"ok": true} — takes effect immediately without reboot.
Trigger S3 OTA update. Auth required.
Request:
{ "url": "http://192.168.1.5:8080/zhac-net-core.bin" }Response: 202 Accepted + {"status": "queued"} — download runs in background task.
Trigger P4 OTA update over HAP. Auth required.
Request:
{ "url": "http://192.168.1.5:8080/p4_core.bin" }Response: 202 Accepted + {"status": "queued"}.
Open the Zigbee network for new device joins. Auth required.
Request:
{ "duration": 254 }duration — seconds (0–254). 254 is the longest window (about four minutes); 0 closes it.
The Zigbee value 255 (open forever) is refused here and clamped to 254 on the radio core.
Default: 254.
Response: {"ok": true} — fire-and-forget, no join confirmation.
URL: ws://<device-ip>/ws
The /ws endpoint serves two roles:
- Command envelope —
{id, cmd, args}↔{id, ok, data|err}. Every command calls the sameapi_*handler as the REST endpoint above; response bodies are byte-identical. - Push events —
{"event":"<name>","data":<payload>}frames broadcast unsolicited when the underlying state changes (device.added,device.updated,device.removed,attr.bulk,alert.added,alert.fired).
Full envelope format, the 35-entry command table, and the
push-event catalogue live in WS_API.md.
| HTTP | Meaning |
|---|---|
400 |
Bad request — missing or invalid field; message in body |
401 |
Missing or wrong X-Api-Key |
404 |
Resource not found |
500 |
P4 timeout (5 s) or internal error |