REST & WebSocket API¶
OpenCCU-Loom exposes a versioned REST API and a single multiplexed WebSocket stream for clients that want to read state, drive devices, and follow live changes without going through MQTT.
Who this page is for
API integrators building external clients, dashboards, or automations against the daemon's HTTP surface. For the configuration of the daemon itself, see Configuration.
Base URL and transport¶
The REST API and the WebSocket endpoint share one HTTP listener. By default it binds :8119, so the base URL is:
The bind address is the north.rest.listen field in the config (default ":8119"); see Configuration. All REST paths below are relative to /api/v1.
Source of truth¶
Both surfaces are spec-driven. The published files in the repository are authoritative — this page is an orientation, not the contract:
| Surface | Contract file |
|---|---|
| REST | assets/openapi.yaml |
| WebSocket | assets/wsapi.json |
When a field, status code, or schema differs between this page and those files, the files win.
Versions¶
The REST and WebSocket contracts carry their own version numbers, independent of the daemon's release version:
- REST —
GET /api/v1/infoanswers withapi_version(the same value asinfo.versioninassets/openapi.yaml) and acapabilitieslist. Read both at runtime instead of assuming a daemon release; optional features announce themselves as capability tokens. - WebSocket — the top-level
versionfield ofassets/wsapi.json. The WebSocket commandsystem.commandslists the commands a running daemon registers.
The full, generated lists are in the REST reference and the WebSocket reference.
Authentication¶
Most endpoints require authentication. A handful of routes are mounted outside the AuthRequire group by design — they either serve before any credentials exist (first-run) or must work for a logged-out client:
GET /api/v1/info,GET /api/v1/health— public status probes.POST /api/v1/auth/login,POST /api/v1/auth/logout,GET /api/v1/auth/me— the SPA's login lifecycle (/auth/meis the startup probe; it returns 401 with no active session).GET /api/v1/setup/status,POST /api/v1/setup— first-run onboarding, unauthenticated because no admin exists yet.GET /api/v1/devices/{addr}/icon— device-model icon proxy. It is not public: it answers differently for known and unknown addresses, so it sits behind authentication like the rest of the API; the web UI loads it with its session cookie like/healthsince it serves only non-sensitive artwork and must resolve from a bare<img>tag.GET /api/v1/auth/oidc/start,GET /api/v1/auth/oidc/callback— the OIDC redirect dance, only mounted when OIDC is configured.
See internal/north/rest/router.go for the authoritative route mounting. Everything else sits behind AuthRequire. Supported credentials are Basic auth, session cookies (after POST /auth/login), API tokens, and OIDC. Two role gates apply on top of authentication: an operator gate (op) for state-changing device calls, and an admin gate for system, user, and config administration. See Authentication and the Security guide for the full model.
Endpoint overview¶
Every path, with its method, summary and the minimum role it requires, is listed in the generated REST API reference; the OpenAPI file carries the parameters and response schemas.
Multi-CCU scoping
Device, channel, and hub resources are scoped per CCU. The central dimension threads through addresses and snapshot payloads. See Multi-CCU.
Per-central features and the feature_unavailable problem¶
GET /api/v1/system/ccu reports, per central, system_type (ccu or openccu-lite) and a features map keyed by feature (hub.sysvars, device.rename, system.reboot, taxonomy.tree, …). Every known key is present with {available: bool, reason?, scope?} — a CCU offers everything it always did, while what an openccu-lite central offers depends on its API token's scopes. The WebSocket broadcast central.features_changed (topic central.{name}.features) carries the complete set whenever it changes — a token re-paired, a scope revoked, or the central finishing its first bring-up. See Connecting an openccu-lite system for the full key-to-scope table.
An operation a central does not offer right now — its system has no such thing, its token lacks the scope, or it has not finished bring-up — never fails silently:
- REST answers
422 Unprocessable Entity,Content-Type: application/problem+json, problem typefeature_unavailable, headerX-Problem-Code: feature_unavailable, and afeaturemember:{central, key, reason, scope?}—scopeis present only whenreasonismissing_scope. - WebSocket commands answer error code
feature_unavailablewithdetailscarrying the same{central, key, reason, scope?}shape. - MCP hub list tools (
list_sysvars,list_programs, …) name such a central under anunavailablemember instead of returning an empty list, so an absent feature is never mistaken for "nothing configured".
Taxonomy: rooms, functions, and other enums¶
Rooms, functions and whatever else a system organises devices by are modelled as a taxonomy of nested nodes, backend-neutral across a CCU's flat rooms/functions and an openccu-lite system's nested tree. DeviceSummary, channel summaries and the MQTT device/info payload keep their unchanged rooms/functions names (the display name of each device's directly-assigned node — a device assigned to "Ground floor › Kitchen" is in "Kitchen") and additionally carry a taxonomy array: [{enum, path, name, parent_path?}].
GET /taxonomy[?central=] is the one surface that also shows empty nodes — {centrals: [{central, revision, writable, tree, enums: [{id, names, nodes: [{id, path, name, icon?, children}]}]}]} — writable reflects taxonomy.edit, tree reflects taxonomy.tree (false on a CCU, whose rooms/functions are flat). Node CRUD: POST /taxonomy/{central}/{enum}/nodes {parent_path?, name}, PATCH .../nodes?path=... {name?, parent_path?, position?}, DELETE .../nodes?path=... — the matching WebSocket commands are taxonomy.list, taxonomy.node_create, taxonomy.node_update, taxonomy.node_delete. Creating or moving a node below another needs taxonomy.tree; on a CCU only a root node of room/function can be created, and a nested parent_path or a move answers 422 feature_unavailable.
GET /rooms and GET /functions stay name-merged across centrals as before and additionally carry refs: [{central, path, parent_path?}], so a client can tell two same-named rooms on different systems apart. DevicePatchRequest/ChannelPatchRequest gain room_paths/ function_paths; when given, they win over rooms/functions and are how you pick one of two rooms that share a name. An ambiguous name write (a plain rooms: ["Küche"] matching more than one node) answers 409 with the candidate paths; an unknown name answers 422.
CreatedNamedResource (the answer to POST /rooms and POST /functions) carries an integer id on a CCU; on a system whose nodes have no numeric id (openccu-lite) id is absent and path names the new node instead — this is the sole breaking change in REST API 12.0.0 (see CHANGELOG.md).
ise_id (on DeviceSummary and the MQTT device/info payload) and the sysvar vid field are CCU-only and omitted on an openccu-lite central, which has no ReGa object ids.
WebSocket stream¶
Connect a WebSocket to:
This is one multiplexed channel. The same connection carries command replies and topic broadcasts. The schema in assets/wsapi.json defines both invokable commands and server-pushed broadcasts in one commands array; run these against your checkout for the current counts rather than trusting a number here, since the catalogue grows every release: jq '[.commands[] | select(.kind!="broadcast")] | length' assets/wsapi.json for invokable commands, jq '[.commands[] | select(.kind=="broadcast")] | length' assets/wsapi.json for broadcasts.
The protocol — envelope fields, subscribe/unsubscribe, resume with since and the replay_lost causes, connection limits, in-band reauth and the heartbeat with its round-trip measurement — is specified in the External Client Wire Contract; every broadcast and command is listed in the WebSocket reference. Resume cursor and kind semantics are decided in ADR 0022.
Example¶
const ws = new WebSocket("ws://localhost:8119/api/v1/events");
ws.onopen = () => {
ws.send(JSON.stringify({ op: "subscribe", topics: ["device.*"], since: lastSeq }));
};
ws.onmessage = (ev) => {
const frame = JSON.parse(ev.data);
if (frame.op === "ping") { ws.send(JSON.stringify({ op: "pong" })); return; }
if (frame.op === "replay_lost") { /* GET /api/v1/snapshot and resync */ return; }
if (frame.seq !== undefined) lastSeq = frame.seq;
if (frame.kind) handleEvent(frame); // initial | change | refresh
};
Related reading¶
- External Client Wire Contract — the topic hierarchy and wire guarantees external clients depend on.
- MCP server — driving the daemon through the Model Context Protocol.
- MQTT topic schema — the parallel MQTT plane.