Web API Reference¶
The host exposes a JSON HTTP API that the WebUI and the RotorHazard
plugin call. Everything is mounted under the WebUI's prefix (default
/racelink):
http://<host>:5077/racelink/api/... (standalone)
http://<host>:5000/racelink/api/... (RotorHazard plugin, default port)
For the SSE channel and state-scope tokens that complement these
endpoints, see SSE channels. For the wire-format
constants (OPC_*, flags byte, P_Config option codes) referenced
below, see Wire protocol.
Source. Everything in this page is derived from
RaceLink_Host/racelink/web/api.py,RaceLink_Host/racelink/web/blueprint.py,RaceLink_Host/racelink/web/request_helpers.py, andRaceLink_Host/racelink/web/dto.py. When the source and this page disagree, the source wins.
Conventions¶
- All POST/PUT bodies are JSON. Empty / non-JSON bodies are tolerated
(treated as
{}); the route then either applies defaults or returns a 400 if a required field is missing. - Validation errors raise
RequestParseError(aValueErrorsubclass, inrequest_helpers.py), which the route handler catches and translates to HTTP 400 with a body of{"ok": false, "error": "<message>"}. - Routes that kick off a long-running operation use the task manager: only one task can run at a time. While a task is running, conflicting routes return HTTP 409:
- Long-running routes return immediately with
{"ok": true, "task": <snapshot>}. Progress is delivered over SSE on thetaskevent — see SSE channels. - Routes that depend on a service which isn't wired up
(
scenes_service,rl_presets_service,scene_runner_service) return HTTP 503 with{"ok": false, "error": "<service> not available"}. - All other unhandled exceptions are caught at the top of each
handler, logged with type + traceback, and translated to
HTTP 500 with
{"ok": false, "error": "<TypeName>: <message>"}.
Common shapes¶
Task snapshot¶
Returned by every route that starts a task-manager job (under the
task key) and by GET /api/task. Source:
RaceLink_Host/racelink/web/tasks.py.
{
"id": 42, // monotonic, per-process
"name": "discover", // see "task names" below
"state": "running", // running | done | error
"started_ts": 1714498220.13,
"ended_ts": null, // unix ts when state became done/error
"meta": { /* task-specific; see per-task notes */ },
"rx_replies": 0,
"rx_window_events": 0,
"rx_count_delta_total": 0,
"last_error": null, // string when state="error"
"result": null // task return value when state="done"
}
Task name values currently emitted by the API:
discover, status, bulk_set_group, force_groups,
special_config, presets_download, fwupdate.
Gateway readiness snapshot¶
Returned by /api/gateway and as the gateway field of /api/master.
Source: _gateway_status() in api.py + Controller.gateway_status.
{
"ready": true,
"last_error": null, // string | null
"failure_count": 0
// additional fields when the controller's getter is wired:
// port, baud, state, etc.
}
Master snapshot¶
Source: MasterState.snapshot() in
RaceLink_Host/racelink/web/sse.py.
{
"state": "IDLE", // mirrored from gateway EV_STATE_*
"state_byte": 0,
"state_metadata_ms": 0,
"last_event": "CONTROL_SENT",
"last_event_ts": 1714498220.13,
"last_error": null
}
Device row¶
Returned by /api/devices and inside the master blob. Source:
serialize_device() in RaceLink_Host/racelink/web/dto.py.
{
"addr": "AABBCC", // MAC suffix (last 3 bytes hex)
"name": "Pit-1",
"dev_type": 2, // see GLOSSARY § "Capability"
"dev_type_name": "RL_Node_v4_S3",
"dev_type_caps": ["WLED"],
"caps": 2,
"groupId": 1,
"flags": 0,
"configByte": 0,
"presetId": 3,
"effectId": 0,
"brightness": 200,
"specials": { /* per-capability */ },
"voltage_mV": 4012,
"node_rssi": -67,
"node_snr": 8,
"host_rssi": -69,
"host_snr": 7,
"version": 4,
"last_seen_ts": 1714498220.13,
"last_ack": null,
"online": true
}
Group row¶
Returned by /api/groups.
{
"id": 0,
"name": "Unconfigured",
"static": false, // true for system groups (e.g. group 0)
"dev_type": 0, // capability filter (0 = mixed)
"device_count": 3,
"caps_in_group": { "WLED": 3, "STARTBLOCK": 0 }
}
Health & state¶
GET /api/health¶
Cheap liveness probe used by the WebUI's auto-reconnect loop.
Response 200:
phase is "booting" until the controller finishes
startup, then "ready".
GET /api/master¶
Full master snapshot (state mirror, in-flight task, gateway status).
Response 200:
{
"ok": true,
"master": <master snapshot>,
"task": <task snapshot> | null,
"gateway": <gateway snapshot>
}
GET /api/task¶
Current in-flight task only. Returns null for the task field when
no task has run yet; otherwise the most recent snapshot (running, done,
or error).
Response 200: { "ok": true, "task": <task snapshot> | null }
GET /api/gateway¶
Response 200: { "ok": true, "gateway": <gateway snapshot> }
POST /api/gateway/retry¶
Retry the gateway connection (port reopen + state reset).
Request body: ignored.
Response 200: { "ok": <bool>, "gateway": <gateway snapshot> } —
ok mirrors gateway.ready.
POST /api/gateway/query-state¶
Send GW_CMD_STATE_REQUEST; await the matching EV_STATE_REPORT;
return the resolved state. Used by the master-pill ↻ refresh button.
Bounded by a ~500 ms timeout so a stalled gateway doesn't block the
WebUI thread.
Request body: ignored.
Response 200 (gateway available):
Response 503 (gateway service unavailable):
{
"ok": false,
"state": "UNKNOWN",
"state_byte": 255,
"state_metadata_ms": 0,
"error": "gateway_service unavailable"
}
GET /api/options¶
Returns the WLED-preset dropdown options for the WebUI's
<select> widgets.
Response 200: { "ok": true, "presets": [{"value": "...", "label": "..."}] }
Devices¶
GET /api/devices¶
Response 200: { "ok": true, "devices": [<device row>, ...] }
POST /api/discover¶
Run device discovery. Newly-found devices land in targetGroupId
(or in a freshly-created group named newGroupName).
Request body:
{
"targetGroupId": 1, // optional; default 0 (Unconfigured)
"newGroupName": "Pit-A" // optional; if set, a new group is created and used
}
Response 200 (task started):
Task result: { "found": <int>, "createdGroupId": <int|null>, "targetGroupId": <int|null> }.
Response 409 if a task is already running.
POST /api/status¶
Run a fleet status poll. selection/macs and groupId are mutually
exclusive — when both are absent, the broadcast group filter (255)
is used.
Request body:
Response 200 (task started):
Task result: { "updated": <int>, "groupId": <int|null>, "selectionCount": <int> }.
POST /api/devices/update-meta¶
Bulk rename / regroup. Two paths:
- Pure rename (no
groupIdfield, single MAC,nameset): synchronous, no RF I/O. Returns the result inline. - Group change (
groupIdset): wrapped in a TaskManager job namedbulk_set_group. Already-offline devices skip the SET_GROUP wire send (auto-restore handles them on next IDENTIFY/STATUS).
Request body:
{
"macs": ["AABBCC", "DDEEFF"],
"groupId": 2, // optional; triggers async path when present
"name": "Pit-1" // optional
}
Response 200, sync:
Response 200, async: { "ok": true, "task": <task snapshot> }.
Task meta carries {stage, index, total, addr, groupId, message}.
POST /api/devices/control¶
Send OPC_CONTROL to a group or to a list of devices.
Request body:
{
"macs": ["AABBCC"], // EITHER macs (per-device unicast)
"groupId": 1, // OR groupId (broadcast within group)
"flags": 0,
"presetId": 3,
"brightness": 200
}
- Exactly one of
macs/groupIdmust be present (else 400). flags,presetId,brightnessare required (else 400).- For body field semantics see Glossary § "Flags byte" and Wire protocol § "P_Preset".
Response 200:
{
"ok": true,
"changed": 1 // # of frames the transport accepted (0
// when the gateway is offline)
}
POST /api/devices/indicate¶
Trigger an OPC_INDICATE overlay on one or more devices —
operator-facing flow is the "Locate" / device-name click in the
WebUI (WebUI Overview). The
indicator is rendered as a frame-buffer overlay on each receiver
and auto-restores when duration_sec expires; the underlying
effect is not disturbed (see
wire-protocol § P_Indicate).
Route name intentionally uses indicate (matching the wire
opcode OPC_INDICATE). identify is reserved for the
RF-discovery opcode OPC_DEVICES; the operator-facing button
label is "Locate".
Request body:
{
"macs": ["AABBCC"], // non-empty list of MAC suffixes (12-char hex accepted; last 3 bytes used)
"indicator_type": 4, // optional; default = IDENTIFY (4). See racelink_indicators.h / IndicatorType.
"duration_sec": 5 // optional; default = 5. Clamped to 0..255. 0 = cancel running indicator.
}
Response 200:
Errors: 400 (macs missing or not a non-empty list,
indicator_type / duration_sec not coercible to int); 503
(control_service unavailable, e.g. plugin not yet bound).
GET /api/specials¶
Response 200: { "ok": true, "specials": <SpecialsService config> } —
shape is the per-capability "specials" form schema. See
RaceLink_Host/racelink/services/specials_service.py.
POST /api/specials/config¶
Send an OPC_CONFIG packet that updates a single per-device
property (e.g. WLED FPS, ABL max mA, segment geometry,
STARTBLOCK slot count). Wrapped in a TaskManager job.
Request body:
{
"mac": "AABBCC",
"key": "wled_fps", // capability-specific knob name
"value": 60 // scalar integer OR
// { "start": 0, "stop": 18 } for
// uint16-pair shapes (segment geometry)
}
The key → option byte mapping is resolved by SpecialsService.
The host packs value into data0..3 per the schema's bytes /
shape fields (see
opcodes.md §"Properties vs Methods").
Response 200: { "ok": true, "task": <task snapshot> }.
Task meta carries {mac, key, message}. The task itself waits for
ACK with a 6 s timeout; on ACK it updates the in-memory specials
dict and persists with scope DEVICE_SPECIALS. The ACK is the
device's persistence confirmation — the route does not issue
a follow-up OPC_GET_CONFIG after the write.
POST /api/specials/action¶
Trigger a one-shot per-device action (e.g. a startblock identify beep). Synchronous; ACK is not awaited inside the route.
Request body:
{
"mac": "AABBCC",
"function": "startblock_control", // capability-specific function key
"params": { /* per-function */ } // optional
}
Response 200:
{
"ok": true,
"result": <comm-handler return value>,
"function": "startblock_control",
"params": { /* coerced */ }
}
Errors: 400 (missing/invalid mac/function, broadcast not allowed, unsupported function, params coercion failed); 404 (device not found); 500 (action failed).
POST /api/specials/get¶
Read one per-device property live from the device via
OPC_GET_CONFIG. Used by the Device Options dialog when it opens
to populate each row's "device value" badge.
Request body:
The key → option byte mapping is resolved by SpecialsService;
sending a key that maps to a method-class option (e.g. 0x0F)
returns no reply and the route reports {"ok": false,
"error": "timeout"}.
Response 200 (success):
{
"ok": true,
"mac": "AABBCC",
"key": "wled_fps",
"value": 60 // scalar OR
// { "start": 0, "stop": 18 } for
// uint16-pair shapes
}
Response 200 (timeout / no reply):
Errors: 400 (missing/invalid mac/key, broadcast not allowed, option not readable); 404 (device not found); 500 (controller missing the read service — should not occur on a healthy build).
Bypasses the global TaskManager gate so the dialog can fire several reads in quick succession during its open pass without blocking other operator actions.
POST /api/specials/config/import¶
Adopt the device's reported value into the host's
dev.specials[<key>] without sending an OPC_CONFIG packet.
Used by the Device Options dialog's "Import device" button when
the operator chooses to accept the device-side value rather than
push the host-side value to the device.
Request body:
Response 200:
{
"ok": true,
"mac": "AABBCC",
"key": "wled_fps",
"value": 60,
"written": ["wled_fps"] // flat keys actually persisted
// (two for uint16-pair shapes)
}
Errors: 400 (missing fields, option not supported, value validation failed); 404 (device not found).
POST /api/config¶
Send a raw OPC_CONFIG packet to a single device. Lower-level than
/api/specials/config — the client is responsible for the
option byte and four data bytes; no host-side state mirror is
updated.
Request body:
{
"mac": "AABBCC", // alias: "macs": ["AABBCC"] (must be exactly 1)
"option": 1, // see wire-protocol.md § "P_Config"
"data0": 0, "data1": 0, "data2": 0, "data3": 0 // optional, default 0
}
option must be one of {0x01, 0x03, 0x04, 0x80, 0x81} (else 400).
Broadcast (mac == "FFFFFF") is rejected (else 400).
Response 200:
{
"ok": true, "sent": 1, "recv3": "AABBCC", "option": 1,
"data0": 0, "data1": 0, "data2": 0, "data3": 0
}
Groups¶
GET /api/groups¶
Response 200: { "ok": true, "groups": [<group row>, ...] }.
The synthetic group id=0 ("Unconfigured") is always included. The
legacy "All WLED Nodes" entry is filtered out.
POST /api/groups/create¶
Request body:
Response 200: { "ok": true, "id": <new gid> }.
Errors: 400 (name empty/missing).
POST /api/groups/rename¶
Request body:
id validated by require_int(label="group id").
Response 200: { "ok": true }. Errors: 400 (missing id, invalid
group id, static group).
POST /api/groups/delete¶
Devices in the deleted group move to groupId=0; devices in
higher-indexed groups have their groupId decremented. Scene actions
referencing the deleted group are renumbered via
SceneService.renumber_group_references().
Request body: { "id": 2 }.
Response 200:
Errors: 400 (missing id, invalid group id, static group).
POST /api/groups/force¶
Re-broadcast every device's stored groupId to the network
(SET_GROUP per device). Wrapped in a TaskManager job named
force_groups.
Request body:
skipOffline=false(default): pushes SET_GROUP to every device, including offline ones — the operator's "re-sync ALL" semantic.skipOffline=true: skips offline devices; auto-restore handles them on next IDENTIFY/STATUS.
Response 200: { "ok": true, "task": <task snapshot> }.
Task meta carries {stage, index, total, addr, message, skipOffline}.
Persistence¶
POST /api/save¶
Manual save of the in-memory state (devices, groups, presets, scenes) to disk.
Request body: ignored.
Response 200: { "ok": true }. Errors: 500 (DB lock, disk
full, etc.) — body includes "error": "<TypeName>: <message>".
POST /api/reload¶
Re-load state from disk. Pushes a FULL SSE refresh on success.
Request body: ignored.
Response 200: { "ok": true }. Errors: 500.
Firmware uploads¶
POST /api/fw/upload¶
Upload a firmware binary (multipart/form-data — not JSON).
Form fields:
| Field | Type | Notes |
|---|---|---|
file |
binary | the firmware payload |
kind |
string | "firmware" or "cfg" |
Response 200:
{
"ok": true,
"file": {
"id": "<opaque>", "kind": "firmware", "name": "wled.bin",
"size": 1234567, "sha256": "...", "uploaded_ts": 1714498220.13
}
}
file.id is later passed as fwId / cfgId in POST /api/fw/start.
Errors: 400 (validation: missing file, wrong kind, size, MIME). 409 if a task is running.
GET /api/fw/uploads¶
Response 200: { "ok": true, "files": [<file info>, ...] }.
POST /api/fw/start¶
Start an OTA workflow. Wrapped in a TaskManager job named fwupdate.
Request body:
{
"macs": ["AABBCC", "DDEEFF"], // required; non-empty
"doFirmware": true, // optional; default true
"doPresets": false, // optional; default false
"doCfg": false, // optional; default false
"fwId": "<from fw/upload>", // required when doFirmware
"presetsName": "race-event.json", // required when doPresets
"cfgId": "<from fw/upload>", // required when doCfg
"retries": 3, // optional; clamp 1..10
"stopOnError": false, // optional; default false
"skipValidation": false, // optional; default false
"wifi": { // see "WiFi sub-body" below
"ssids": ["WLED_RaceLink_AP"],
"password": "wled1234",
"iface": "wlan0",
"bssid": "",
"timeoutS": 20,
"otaPassword": "wledota",
"hostWifiEnable": true,
"hostWifiRestore": true
},
"baseUrl": "http://4.3.2.1" // optional; OTA targets default to wled.local
}
At least one of doFirmware / doPresets / doCfg must be true
(else 400). skipValidation=true is forwarded as
skipValidation=1 in WLED's /update form, bypassing WLED's
release-name check — used when migrating between firmware forks.
Response 200: { "ok": true, "task": <task snapshot> }. The
fwupdate task's meta carries the per-stage pointer
{stage, index, total, retries, addr, attempt?, message, baseUrl}
plus two §9 fields the WebUI's progress panel relies on:
macs: string[]— the planned target list captured at Start. The dialog's re-entry path restores row identity from this verbatim, so a header re-entry shows the right rows even if the operator changed the device selection since.deviceState: { [addr]: "queued" | "running" | "ok" | "error" }— authoritative per-device row state, mutated as each device transitions throughRACELINK_AP_ON→WAIT_HTTP→UPLOAD_FW→DEVICE_DONE(orDEVICE_ERROR). Each emit carries a fresh shallow copy so a slow SSE consumer cannot see a future mutation aliased into an earlier event.
The two new explicit terminal stages (DEVICE_DONE, DEVICE_ERROR)
fire immediately after each per-device terminal so a row flips to
ok / error without waiting for the next addr to advance.
WiFi sub-body shape¶
Resolved by parse_wifi_options() in request_helpers.py. All
fields are optional; any can also live at the body root with a
wifi-prefixed alias (wifiSsid, wifiPassword, wifiIface,
wifiBssid, wifiTimeoutS, wifiOtaPassword).
| Field | Type | Default | Notes |
|---|---|---|---|
ssids |
string[] |
["WLED_RaceLink_AP", "WLED-AP"] |
candidate APs (newer firmware first); singular ssid (comma-split) accepted for back-compat |
password |
string |
"wled1234" |
WLED AP password |
iface |
string |
"wlan0" |
host wireless interface |
bssid |
string |
"" |
pin to a specific BSSID |
timeoutS |
number |
20 |
overall scan+connect budget |
otaPassword |
string |
"wledota" |
WLED OTA password (for the auto-unlock POST on 401 from /update) |
hostWifiEnable |
bool |
true |
enable host's WiFi radio for the workflow |
hostWifiRestore |
bool |
true |
restore host's WiFi radio afterwards |
If the resolved SSID list is empty after normalisation, the route
returns 400 (the workflow has no AP to look for). The legacy
connName field is silently ignored.
WLED preset files¶
POST /api/presets/upload¶
Upload a presets.json from the operator's machine.
multipart/form-data; field file.
Response 200:
{
"ok": true,
"file": { "name": "x.json", "size": 1234, "saved_ts": 1714498220.13 },
"files": [<file info>, ...]
}
Errors: 400 (validation); 409 (busy).
GET /api/presets/list¶
Response 200: { "ok": true, "files": [<file info>, ...], "current": "<name>" }.
POST /api/presets/select¶
Activate one of the uploaded preset files for the host's "current" preset slot.
Request body: { "name": "race-event.json" }.
Response 200: { "ok": true, "current": "race-event.json" }.
Errors: 404 (file not found); 400 (parse failed); 409 (busy).
POST /api/presets/download¶
Download a node's presets.json to the host. Wrapped in a
TaskManager job named presets_download. Same WiFi sub-body as
/api/fw/start.
Request body:
Response 200: { "ok": true, "task": <task snapshot> }.
Errors: 400 (missing/invalid mac, empty SSID list); 409.
RaceLink-native presets (RL presets)¶
RL presets are parameter snapshots applied via OPC_CONTROL.
See Glossary § "Preset" for the disambiguation between RL preset
and WLED preset.
GET /api/rl-presets¶
Response 200: { "ok": true, "presets": [<RL preset>, ...] }.
<RL preset> shape: {key, label, params, flags, ...} — see
RaceLink_Host/racelink/services/rl_presets_service.py.
GET /api/rl-presets/schema¶
Return the 14-field editor schema with select-option generators
resolved (effects metadata, palette list, palette-color rules). The
WebUI's ensureRlPresetUiSchema consumes this.
Response 200: { "ok": true, "schema": <RL_PRESET_EDITOR_SCHEMA> }.
POST /api/rl-presets¶
Create an RL preset.
Request body:
{
"label": "Track sweep",
"params": { /* per-effect */ }, // optional
"flags": 0, // optional
"key": "track-sweep" // optional; auto-generated if omitted
}
Response 200: { "ok": true, "preset": <RL preset> }.
Errors: 400 (missing/empty label, invalid params).
GET /api/rl-presets/<key>¶
Response 200: { "ok": true, "preset": <RL preset> }.
Errors: 404.
PUT /api/rl-presets/<key>¶
Update an existing RL preset. All body fields are optional — pass only what's changing.
Request body:
Response 200: { "ok": true, "preset": <RL preset> }.
Errors: 400 (validation); 404.
DELETE /api/rl-presets/<key>¶
Response 200: { "ok": true }. Errors: 404.
POST /api/rl-presets/<key>/duplicate¶
Request body: { "label": "Copy of …" } (optional).
Response 200: { "ok": true, "preset": <RL preset> }.
Errors: 400 (validation); 404 (source not found).
Scenes¶
For the on-disk scene format (the actions list shape), see Scene file format.
GET /api/scenes¶
Response 200: { "ok": true, "scenes": [<scene>, ...] }.
GET /api/scenes/editor-schema¶
Per-action-kind UI hints + LoRa parameters for the cost-estimator tooltip. Used by the scene editor frontend.
Response 200:
{
"ok": true,
"kinds": [
{
"kind": "rl_preset",
"ui": {
"presetId": { "widget": "select", "options": [...] },
"brightness":{ "widget": "slider", "min": 0, "max": 255 }
},
/* + canonical kind metadata: label, target_kinds, defaults, ... */
},
/* wled_preset, rl_effect, startblock, delay, sync, offset_group */
],
// Per §13 both schema endpoints serve the user-intent flag list
// from ``racelink.domain.flags.USER_FLAG_DEFS`` so the RL-preset
// editor and the per-action override block render identical labels.
"flags": [
{ "key": "arm_on_sync", "label": "Arm on SYNC" },
{ "key": "force_tt0", "label": "Force TT=0" },
{ "key": "force_reapply", "label": "Force reapply" },
{ "key": "offset_mode", "label": "Offset mode" }
],
// Per §8b every editor-rendered enum carries an operator-facing
// label alongside the wire value — no hard-coded strings on the
// frontend.
"target_kinds": [
{ "value": "broadcast", "label": "Broadcast" },
{ "value": "groups", "label": "Group" },
{ "value": "device", "label": "Device" }
],
"container_target_kinds": [
{ "value": "broadcast", "label": "Broadcast" },
{ "value": "groups", "label": "Group" }
],
"offset_group": {
"max_groups": 64,
"max_children": 16,
"group_id": { "min": 0, "max": 254 },
"offset_ms": { "min": 0, "max": 65535 },
// ``modes`` carries the formula picker labels + descriptions
// sourced from ``OFFSET_FORMULA_MODE_LABELS``.
"modes": [
{ "value": "none", "label": "none", "description": "no per-group offset" },
{ "value": "linear", "label": "linear", "description": "base + gid · step" },
{ "value": "vshape", "label": "vshape", "description": "base + |gid − center| · step" },
{ "value": "modulo", "label": "modulo", "description": "base + (gid mod cycle) · step" },
{ "value": "explicit", "label": "explicit", "description": "per-group table" }
],
"base_ms": { "min": -32768, "max": 32767 },
"step_ms": { "min": -32768, "max": 32767 },
"center": { "min": 0, "max": 254 },
"cycle": { "min": 1, "max": 255 },
"supports_broadcast_target": true,
"child_kinds": ["rl_preset", "wled_preset", "rl_effect"],
"child_target_kinds": [
{ "value": "broadcast", "label": "Broadcast" },
{ "value": "groups", "label": "Group" },
{ "value": "device", "label": "Device" }
]
},
"lora": { /* SF, BW, CR, preamble, sync byte */ }
}
GET /api/scenes/<key>¶
Response 200: { "ok": true, "scene": <scene> }. Errors: 404.
POST /api/scenes¶
Create a scene.
Request body:
{
"label": "Race start",
"actions": [ /* per scene-format.md */ ],
"key": "race-start", // optional; auto-generated if omitted
"stop_on_error": true // optional; default true
}
Response 200: { "ok": true, "scene": <scene> }. Emits SSE
refresh with scope SCENES. Errors: 400 (validation: missing
label, invalid actions).
PUT /api/scenes/<key>¶
Update a scene. All body fields optional.
Request body: { "label": "...", "actions": [...], "stop_on_error": true }.
Response 200: { "ok": true, "scene": <scene> }. Emits SSE
SCENES refresh. Errors: 400; 404.
DELETE /api/scenes/<key>¶
Response 200: { "ok": true }. Emits SSE SCENES refresh.
Errors: 404.
POST /api/scenes/<key>/duplicate¶
Request body: { "label": "Copy of …" } (optional).
Response 200: { "ok": true, "scene": <scene> }. Emits SSE
SCENES refresh.
GET /api/scenes/<key>/estimate¶
Projected wire cost for a saved scene (packets, bytes, airtime).
Response 200:
{
"ok": true,
"total": { "packets": 12, "bytes": 192, "airtime_ms": 740, "wall_clock_ms": 760 },
"per_action": [
{ "packets": 1, "bytes": 16, "airtime_ms": 60, "wall_clock_ms": 60, "detail": {} },
/* ... */
],
"lora": { /* see editor-schema */ }
}
Errors: 404.
POST /api/scenes/estimate¶
Estimate cost for an unsaved draft. Validates actions through
SceneService._canonical_actions() before estimating.
Request body:
Response 200: same shape as /api/scenes/<key>/estimate.
Errors: 400 (validation).
POST /api/scenes/<key>/run¶
Run a scene synchronously. The HTTP response holds open until the
runner finishes (worst-case ~20 minutes for 20 actions × 60 s
delays; realistic scenes finish in seconds). Per-action progress is
broadcast on the SSE bus on the scene_progress topic before/after
each action — see SSE channels.
Ephemeral-draft path: when the body contains an actions list,
the runner executes that list instead of the persisted scene; the
saved scene is untouched.
Request body (optional):
{
"label": "...", // optional; default "draft"
"actions": [ ... ], // optional; triggers draft path
"stop_on_error": true // optional; falls back to saved scene's setting
}
Response 200:
{
"ok": true,
"result": {
/* SceneRunResult.to_dict():
ok, error, actions: [{ok, error, ...}], started_ts, ended_ts, ... */
}
}
Response 404: when neither the saved scene nor a draft body
exists (error: "scene_not_found"). Errors: 400 (draft validation).
WiFi (host)¶
GET /api/wifi/interfaces¶
Probe the host's wireless interfaces (used by the OTA workflow's "select interface" dropdown).
Response 200:
Shape per HostWifiService.wifi_interfaces().
Error responses summary¶
| Status | Triggers |
|---|---|
| 400 | request validation (missing/invalid field, empty body where required, scene-action validator failure, file-upload validation, broadcast not allowed for unicast op) |
| 404 | resource not found (preset, scene, group id, device address) |
| 409 | task busy — body carries the running task's snapshot |
| 500 | unhandled exception (incl. transport errors, persistence failures) — body carries "<TypeName>: <message>" |
| 501 | reserved (/api/specials/get) |
| 503 | service not wired up (scenes, rl_presets, scene_runner); gateway service unavailable |
Error body shape is consistently
{"ok": false, "error": "<message>" /* + extras for some routes */}.