Skip to content

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 under components.schemas

Envelope

Every WebSocket frame the daemon emits follows the same shape:

{
  "topic": "device.0001ABCDE.channels.1.data_points.LEVEL",
  "type":  "datapoint.value_changed",
  "ts":    "2026-05-24T08:42:13.456789Z",
  "payload": { /* per-event-type schema */ }
}
  • topic — present on broadcasts; absent on command replies. The field a client matched a subscription against.
  • type — discriminator for the payload schema. Matches the name field of the corresponding broadcast entry in wsapi.json.
  • ts — RFC3339Nano UTC timestamp of when the event was emitted.
  • payload — see the schema referenced under components.schemas by the broadcast entry's payload field.

Subscription operations

The WS client exchanges three frame ops:

{"op": "subscribe",   "topics": ["device.*", "hub.*"]}
{"op": "unsubscribe", "topics": ["hub.*"]}
{"op": "pong"}

The server responds with subscribe/unsubscribe acks (omitted here) and emits broadcast frames whose topics match any active subscription pattern.

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:

{"op": "subscribe", "topics": ["device.*"], "since": 1247}

The daemon then:

  1. Replays the buffered events with seq > since matching the supplied topic patterns (via the standard outbound event frames),
  2. Sends a {"op": "replay_done", "seq": N} control frame where N is the last replayed seq (or the original since if nothing matched).

If since precedes the oldest event still in the buffer:

{"op": "replay_lost", "oldest_seq": 901}

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.

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 until either side closes the connection or the client sends a {op: "reauth"} frame.

In-band reauth

{"op": "reauth", "token": "<new-bearer-token>"}

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.

Token-revoke semantics

If a client does NOT send reauth after the operator revokes its token, the existing WS connection continues to receive broadcasts — matching the standard WebSocket security model (no automatic in-band re-check of the upgrade-time credentials). Clients that need hard-revoke semantics should react to operator-side revocation (e.g. an audit-event tail) by either reauthing with a fresh token or closing the connection.

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
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 payload central field. This applies to at least datapoint.value_changed and custom_data_point.state_changed (and any future device.* event) — each carries central.
  • central equals the canonical central name (central == SystemCCUEntry.name == payload.central, see ADR-0024 and the openapi SystemCCUEntry normative note); resolve it from a CCU serial via GET /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.

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.*
matter.* Matter bridge lifecycle (commissioning, fabrics, allowlist) matter.*

{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). assets/wsapi.json holds 23 broadcast entries (17 non-Matter + 6 Matter); the table below is the human-readable view of all 17 non-Matter broadcasts emitted today — tests/contract/ws_broadcast_emitter_test.go::TestWSBroadcastsHaveProductionEmitter enforces that every one of them has a production emitter, so this list does not drift from reality.

Core broadcasts (daemon-emitted, openapi-described)

Type (name) Topic pattern Payload schema
datapoint.value_changed device.{address}.channels.{channel}.data_points.{parameter} DataPointValueChangedPayload
datapoint.optimistic_rolled_back device.{address}.channels.{channel}.data_points.{parameter} OptimisticRollbackPayload
custom_data_point.state_changed device.{address}.cdps.{name} CustomDataPointStateChangedPayload
device.trigger device.{address}.channels.{channel}.trigger DeviceTriggerPayload
device.created device.{address}.lifecycle DeviceCreatedPayload
device.removed device.{address}.lifecycle DeviceRemovedPayload
central.state_changed central.{name}.state CentralStateChangedPayload
system.status_changed system.{central}.status SystemStatusChangedPayload
hub.sysvar_changed hub.{central}.sysvars.{name} SysvarChangedPayload
hub.program_executed hub.{central}.programs.{id} ProgramExecutedPayload
hub.install_mode_changed hub.{central}.install_mode InstallModeChangedPayload
hub.alarm_message hub.{central}.alarm_messages HubCountChangedPayload
hub.service_message hub.{central}.service_messages HubCountChangedPayload
hub.inbox_changed hub.{central}.inbox HubCountChangedPayload
hub.metrics_changed hub.{central}.metrics HubMetricChangedPayload
connectivity.changed hub.{central}.connectivity.{interface_id} HubConnectivityChangedPayload
hub.system_update_changed hub.{central}.system_update HubSystemUpdateChangedPayload

Matter broadcasts

Topic (also used as type in the envelope) Payload schema
matter.exposable_changed MatterExposureUpdate
matter.commissioning_window_opened MatterCommissioningWindowResponse
matter.commissioning_progress MatterCommissioningProgressPayload
matter.fabric_added MatterFabric
matter.fabric_removed MatterFabricRemovedPayload
matter.endpoint_assembled MatterEndpointAssembledPayload

Known divergence: Matter broadcast frames currently set type to the trailing segment after matter. (e.g. type: "exposable_changed" on topic matter.exposable_changed), while core broadcasts use the full event name as type (e.g. type: "datapoint.value_changed"). The wsapi.json name field reflects the topic in both cases. This asymmetry is tracked for future alignment; clients should not rely on either convention for cross-namespace generic dispatch.

Reserved namespaces

To avoid colliding with future broadcast additions, external clients should treat the following prefixes as reserved:

  • device.*, central.*, system.*, hub.*, matter.* (currently in use)
  • recovery.*, connection.*, client.*, connectivity.*, scheduler.*, data.*, rpc.*, cache.*, health.*, reconciliation.*, link.* (defined in pkg/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.