Skip to content

Latest commit

 

History

History
607 lines (455 loc) · 17.7 KB

REST_API.md

File metadata and controls

607 lines (455 loc) · 17.7 KB

ZHAC REST API Reference

Base URL: http://<device-ip>/api
Content-Type: application/json (all requests and responses)


Authentication

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.

Password login (WebUI flow)

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).


Relationship to the WebSocket API and the SPA

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.


Endpoints

System

GET /api/status

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)

GET /api/alerts

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.


WiFi

GET /api/wifi/status

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}

GET /api/wifi/scan

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.

POST /api/wifi

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.

DELETE /api/wifi

Erase WiFi credentials and reboot into AP mode. Auth required.

Response: {"ok": true} — device reboots after 500ms.


Devices

GET /api/devices

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 }
    ]
  }
]

GET /api/devices/:ieee

Returns a single device. :ieee = 0x001234567890ABCD. No auth required.

Response: Same shape as one item from GET /api/devices.

Errors: 500 on P4 timeout.

PUT /api/devices/:ieee/attrs

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, default 0. P4 resolves cluster/attr from key when 0.

Response:

{ "ok": true }

Errors: 500 on P4 timeout (3 s).

DELETE /api/devices/:ieee

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).

GET /api/devices/:ieee/options

Read per-device options blob from S3 NVS. No auth required.

Response: Free-form JSON object (returns {} if none saved).

POST /api/devices/:ieee/options

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 /interview are all served by a single wildcard handler (handle_post_device_subroute in rest_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/*.

POST /api/devices/:ieee/bind

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).

POST /api/devices/:ieee/unbind

Same as /bind but removes the binding. Auth required.

POST /api/devices/:ieee/interview

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.


Groups

GET /api/groups

Returns all groups. No auth required.

Response:

{
  "groups": [
    {
      "id": 1,
      "name": "Living Room",
      "members": [
        { "ieee": "0x001234567890ABCD", "ep": 1 }
      ]
    }
  ]
}

POST /api/groups

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).

GET /api/groups/:id

Returns a single group by numeric ID. No auth required.

Errors: 404 if not found.

PUT /api/groups/:id

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 /api/groups/:id

Delete a group. Auth required.

Response: {"ok": true} or {"ok": false}.

POST /api/groups/:id/cmd

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.


Rules

GET /api/rules

Returns all automation rules from P4. No auth required.

Response: P4 JSON — array of rule objects.

POST /api/rules

Create a new rule. Auth required.

Request:

{ "dsl": "on 0x001234567890ABCD.state == 1 do set 0xAABBCCDDEEFF0011.state 0" }

Response: 201 Created + created rule object.

DELETE /api/rules

Delete a rule by ID. Auth required.

Request:

{ "id": 3 }

Response: P4 acknowledgement JSON.

PUT /api/rules

Toggle a rule enabled/disabled. Auth required.

Request:

{ "id": 3, "enabled": false }

Response: P4 acknowledgement JSON.

PUT /api/rules/:id

Replace rule DSL source. Auth required.

Request body: Raw DSL string (plain text, not JSON).

Response: {"ok": true} or {"ok": false}.


Scripts (Lua)

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.

GET /api/scripts

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 /api/scripts/:name

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.

POST /api/scripts/:name

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.

POST /api/scripts

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 /api/scripts/:name

Delete a Lua script. Auth required.

Response: {"ok": true}. Deleting a non-existent script is idempotent and returns success.


Network & System Config

POST /api/wifi

Update WiFi credentials and reboot. Auth required.

Request:

{ "ssid": "MyNetwork", "pass": "secret" }

Response: {"status": "ok", "reboot": true} — device reboots ~1 s later.

POST /api/settings

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.


OTA (Firmware Update)

POST /api/ota

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.

POST /api/p4-ota

Trigger P4 OTA update over HAP. Auth required.

Request:

{ "url": "http://192.168.1.5:8080/p4_core.bin" }

Response: 202 Accepted + {"status": "queued"}.


Permit Join

POST /api/permit_join

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.


WebSocket

URL: ws://<device-ip>/ws

The /ws endpoint serves two roles:

  1. Command envelope — {id, cmd, args} ↔ {id, ok, data|err}. Every command calls the same api_* handler as the REST endpoint above; response bodies are byte-identical.
  2. 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.


Error Responses

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