WebSocket Topic Hierarchy¶
Who this page is for
Integrators writing an external WebSocket client against the daemon. For the request/response side of the API see REST + WebSocket API.
Status: Normative reference Owner: OpenCCU-Loom Maintainer
Purpose¶
This document is the source of truth for the WebSocket topic namespace external clients subscribe to. It complements:
assets/wsapi.json— per-broadcast catalogue (event name → topic pattern → payload schema reference)assets/openapi.yaml— payload schema definitions undercomponents.schemas
Envelope¶
Every WebSocket frame the daemon emits follows the same shape:
{
"seq": 1247,
"kind": "change",
"topic": "device.0001ABCDE.channels.1.data_points.LEVEL",
"type": "datapoint.value_changed",
"ts": "2026-05-24T08:42:13.456789Z",
"payload": { /* per-event-type schema */ }
}
| Field | Type | Meaning |
|---|---|---|
seq | uint64 | Monotonic daemon-assigned cursor. Store the last value seen and pass it back as since to resume after a reconnect. |
kind | enum | initial | change | refresh. Event-family discriminator; change is the dominant case (ADR 0022). |
topic | string | Present on broadcasts, absent on command replies; the field a subscription is matched against. |
type | string | Discriminator for the payload schema; matches the name of the broadcast entry in wsapi.json. |
ts | string | RFC3339Nano UTC timestamp of when the event was emitted. |
payload | object | The schema named by the broadcast entry's payload field, under components.schemas. |
Subscription operations¶
The WS client exchanges three frame ops:
{"op": "subscribe", "topics": ["device.*", "hub.*"]}
{"op": "unsubscribe", "topics": ["hub.*"]}
{"op": "pong"}
The server answers {"op":"subscribed","topics":[...]} and {"op":"unsubscribed","topics":[...]} — unconditionally, also for an empty topic list — and emits broadcast frames whose topics match any active subscription pattern.
Limits¶
- A connection keeps at most 1024 subscribed topics. Further topics in a
subscribeare ignored without an error; the subscriptions already held keep working. - The outbound queue per connection holds 1000 frames. A client that reads too slowly loses the oldest queued events, not its connection, and receives
replay_lost.
Resume after reconnect¶
Every broadcast envelope carries a monotonic seq cursor. Clients that store the last received seq can resume the stream after a reconnect:
The daemon then:
- Replays the buffered events with
seq > sincematching the supplied topic patterns (via the standard outbound event frames), - Sends a
{"op": "replay_done", "seq": N}control frame whereNis the last replayed seq (or the originalsinceif nothing matched).
If since precedes the oldest event still in the buffer:
In that case the client must perform a fresh GET /snapshot to resync state — relying on the event stream alone would silently miss the events that aged out of the buffer.
replay_lost is also sent outside a resume. Its causes:
sinceprecedes the oldest buffered event.sinceis above the daemon's current sequence — a cursor from before a daemon restart, since the counter restarts at 0.- The connection fell behind and the daemon dropped queued events to keep it open (one frame per overflow episode).
- The daemon has just published a CCU's whole model once that CCU finished coming up; instead of one frame per data point, every connection gets a single
replay_lost.
The replay buffer ceiling is 1024 events in the default configuration. It is a first-class operator knob: north.rest.ws.replay_capacity (cfg:"expert", default 1024) — bursty operators on multi-CCU deployments raise it in the daemon config; the daemon applies it to the hub via Hub.SetReplayCapacity at startup.
Auth lifecycle on long-lived connections¶
The bearer token / session cookie presented at the HTTP Upgrade handshake fixes the connection's identity. That identity holds until the client sends a {op: "reauth"} frame, until either side closes the connection, or until the daemon closes it because the credential behind it stopped being valid — see Revocation and expiry below.
In-band reauth¶
The daemon re-resolves the supplied token via its configured TokenStore and either:
- swaps the connection's identity and acks with
{"op": "reauth_ok"}— the existing subscriptions stay in place, no replay is triggered; - on unknown / empty token (or when no token store is wired):
{"op": "reauth_failed"}, then the daemon closes the connection.
This is the supported path for rotating credentials on a long-lived WS without reconnecting. Useful when an operator revokes the active token via DELETE /api/v1/auth/tokens/{id} and the client wants to present a freshly-issued one without losing its subscription state.
Revocation and expiry¶
The daemon closes a connection whose credential stops being valid; it never silently keeps dispatching on the identity captured at the upgrade. Three events reach an open socket:
- Token revocation.
DELETE /api/v1/auth/tokens/{id}andDELETE /api/v1/auth/tokens/v2/{fingerprint}both close every connection that authenticated with the revoked token. - Credential change on a principal. A logout, a role change, an account deletion or a password change closes that subject's connections.
- Expiry. A session past its TTL and a bearer token past its
expires_atclose the connection at the deadline; a command that arrives in the meantime is refused withunauthorized.
Clients reconnect and re-present credentials — the upgrade re-runs the full auth chain, which is the authority on what the principal may still do. A client holding a freshly-issued token can instead send reauth (above) to swap the identity without losing its subscription state.
Multi-central addressing¶
Every push payload carries an explicit central field naming the CCU the event came from. Subscriptions can scope per-central via the hierarchical topic prefix:
| Subscription | Scope |
|---|---|
device.* | events from every device across every central |
central.home.state | only the home central's lifecycle |
system.*.status | status events from every central |
system.daemon_status | the daemon itself starting to shut down |
hub.home.sysvars.* | sysvar changes on the home central only |
A single openccu-loom daemon can manage multiple CCUs (see ADR-0002). The central.*, hub.* and system.* topic prefixes embed the central name, so a multi-central client distinguishes which CCU emitted those events by topic alone. Single-central deployments can subscribe to the broader wildcards (device.*, central.*.state) without ambiguity — there is only one central to fan out from.
NORMATIVE — device events scope by payload, not by topic (P3)¶
The device.* / device.{addr}.* topics do NOT embed the central name — there is no device.{central}.* form. A multi-central client therefore cannot restrict device value events to one CCU by topic. The binding contract for clients:
- Subscribe broadly (
device.*) and filter every device event by the payloadcentralfield. This applies to at leastdatapoint.value_changedandcustom_data_point.state_changed(and any futuredevice.*event) — each carriescentral. centralequals the canonical central name (central == SystemCCUEntry.name == payload.central, see ADR-0024 and the openapiSystemCCUEntrynormative note); resolve it from a CCUserialviaGET /system/ccu.- Hub / lifecycle / status events, by contrast, MAY be scoped by topic (
hub.{central}.*,central.{central}.state,system.{central}.*).
This pairs with the REST ?central= filter on /devices and /snapshot (P2): single-central is a clean story either way; multi-CCU clients filter device events by payload and scope REST reads by query.
HA today wires one homematicip_local config entry per central; the daemon's multi-central support is forward-compatible with a single config entry covering several CCUs if HA's component grows that capability.
Heartbeat¶
The daemon sends {"op": "ping"} every 30 seconds on every connected WebSocket. Clients MUST respond with {"op": "pong"} within 60 seconds of the most recent ping, otherwise the daemon closes the connection.
Clients behind NAT or mobile-data proxies that drop idle TCP connections faster than 60 s should additionally emit their own {"op": "pong"} frames pre-emptively — the daemon accepts unsolicited pongs without disconnecting.
The interval and timeout are documented in wsapi.json under the root-level heartbeat object so codegen tools can surface them; current values are stable for the v1 API contract. Changes to these constants would be a major-version bump per ADR-0020.
Round-trip time¶
The ping carries an echo token: {"op":"ping","echo":"<token>"}. Copy it back verbatim — {"op":"pong","echo":"<token>"} — and the daemon times the round trip and reports it on the following ping as rtt_ms:
It is absent on the first ping of a connection and after every pong that carried no echo. Echoing is optional — a bare {"op":"pong"} stays a valid heartbeat — and the token is opaque: its encoding is not part of the contract. rtt_ms measures the link between the client and the daemon only; the daemon's own link to the CCU is the separate connection_latency_ms hub metric, and the two are routinely orders of magnitude apart. Never add them or present them as one number.
Topic-matching semantics¶
Matching is implemented by matchTopic(pattern, topic) in internal/north/rest/ws/hub.go:
| Pattern | Matches |
|---|---|
"*" | every topic |
"prefix.*" | the literal prefix plus any topic starting with prefix. |
| any other string | only the exact topic |
There is no segment-level wildcard (no MQTT-style +). A pattern that ends in .* consumes the rest of the topic hierarchy below that prefix.
Examples:
| Subscription | Matches | Does not match |
|---|---|---|
device.* | device.0001ABCDE.channels.1.data_points.LEVEL, device.0001ABCDE.cdps.main | central.home.state |
device.0001ABCDE.* | every event for one device (DPs, CDPs, future device-scoped topics) | device.0002XYZ.* |
device.0001ABCDE.channels.1.data_points.LEVEL | only that exact DP | anything else |
hub.home.* | every sysvar + program event for the home central | hub.other.* |
Top-level namespaces¶
| Prefix | Scope | Subscribe with |
|---|---|---|
device.{addr}.* | per-device events (DataPoint values, CDP state) | device.0001ABCDE.* |
central.{name}.* | CentralUnit lifecycle | central.home.* |
system.{central}.* | aggregated system-status / health | system.home.* |
hub.{central}.* | CCU programs + system variables | hub.home.* |
node.* | the Matter bridge's node (go-fabric-ui's events: fabrics, the commissioning window, the endpoints, the exposure selection) | node.* |
{central} is the CCU name as defined in the daemon's config (cfg.Centrals[*].Name); single-central setups can use the literal configured name or subscribe via wildcard.
Broadcast catalogue¶
The full broadcast catalogue is machine-readable in assets/wsapi.json — every entry with "kind": "broadcast" carries topic (pattern) and payload (schema name in openapi.yaml). It is the complete, authoritative list; tests/contract/ws_broadcast_emitter_test.go::TestWSBroadcastsHaveProductionEmitter enforces that every broadcast in it has a production emitter. The generated WebSocket reference renders every broadcast with its topic pattern, payload schema and description, grouped by category.
The node.* broadcasts are go-fabric-ui's events (the same seven the handler's own stream GET /api/v1/node/stream carries), published onto the hub under the node. prefix with the stream's payload unchanged: node.fabric_added and node.fabric_removed carry a FabricEvent, node.window_opened and node.window_closed a WindowEvent, node.exposure_changed an ExposureEvent with the changed items' keys, node.surface_changed a SurfaceChangedEvent with the key of the one endpoint whose served surface an assembly changed ("" for the root), and node.endpoints_changed an empty object — reload GET /api/v1/node/endpoints.
node.window_opened carries no credential: only the discriminator. Subscribing to a topic requires no role, so the passcode and the pairing codes that commission the bridge onto a new fabric never leave the admin-gated POST /api/v1/node/pairing/window and GET /api/v1/node/pairing responses.
Node broadcast frames set type to the full dotted broadcast name — the same convention core broadcasts use (e.g. type: "node.fabric_added" on topic node.fabric_added, type: "datapoint.value_changed" on topic device.{address}.channels.{channel}.data_points.{parameter}). The wsapi.json name field reflects the topic in both cases.
Reserved namespaces¶
To avoid colliding with future broadcast additions, external clients should treat the following prefixes as reserved:
device.*,central.*,system.*,hub.*,node.*(currently in use)recovery.*,connection.*,client.*,connectivity.*,scheduler.*,data.*,rpc.*,cache.*,health.*,reconciliation.*,link.*(defined inpkg/hmevent/catalogue.go, may be surfaced over WS in the future)
Clients that publish or proxy events back through the daemon (none exist today) must not invent topics in any reserved namespace.