ADR 0072 — openccu-lite pushes over occulited's event stream¶
- Status: accepted
- Date: 2026-09-27
Context¶
On a CCU every interface pushes to the daemon: OpenCCU-Loom registers a callback URL with XML-RPC init, and the interface process calls /RPC2/<central> for every event, new or deleted device (ADR 0002 routes them per central). The specification's non-goal "every interface supports push callbacks; there is no JSON-RPC-only mode" rests on that.
openccu-lite does not accept init on its XML-RPC proxy, and its interface processes are not reachable from outside the box. The box's daemon, occulited, is itself the subscriber of every interface and republishes what it receives on one authenticated event stream (server-sent events or WebSocket), with a replay ring, a heartbeat, hello/resync control messages, an interface state message and — optionally — its own sweep of a chosen set of datapoints. A token may hold two streams (sixteen in total per box).
The question is how a lite central receives events without giving up "push, never poll".
Decision¶
One server-sent-event stream per central carries every configured interface (internal/central/adapter/lite_events.go, reader in internal/client/transport/occulited). It feeds the same CallbackHandlers an XML-RPC callback would, so hot-plug, deletion, command tracking, PONG correlation and bus events are shared with the CCU path.
- Filter. The stream asks for
event,interface,newDevices,deleteDevices,updateDevice,replaceDeviceandreaddedDevice. occulited's own sweep (statemessages) is not requested: it re-publishes values the value seed already reconciles and would reach the bus as changes no device reported.helloandresyncarrive regardless. - Lifecycle. The stream is attached once per central, like the callback route, and outlives bring-up generations. A supervisor reconnects with backoff and resumes from the last event id.
- Liveness. Every message and every heartbeat stamps the interfaces the stream reports up (
CallbackHandlers.NoteAlive), so a quiet interface is not declared lost. An interface the box reportsdownstops being stamped and raises the sameConnectionLostEventa CCU outage raises: forced device unavailability and recovery react exactly as for a CCU. A stream that stops delivering heartbeats is dropped and reconnected. - Announcer.
init/deinitare replaced: announcing an interface waits until the stream is live and reports the interface up;deinitis a no-op, because occulited owns the subscription on the box. - Reconciliation. After a reconnect, a
resync(gap, overflow, box reboot) or an interface restart, the interface is reconciled: devices are re-listed (new ones ingested, vanished ones deleted — a gap can drop adeleteDevices) and values are re-seeded. Reconciliations are serialised per central. - Typing. Values on the stream are JSON; each is re-typed by its parameter's paramset description before it reaches the model, so a FLOAT sent as
1is1.0and no change is invented. - Ping/pong stays enabled for the lite backend kind:
pinggoes through the proxy and thePONGcomes back on the stream. A real box relays the PONG of a caller it did not register (confirmed 2026-09-28, live check L-4); HmIP-RF answers onCENTRAL:0, BidCos-RF onCENTRAL.
The specification's statement becomes: every interface pushes — via init callbacks on a CCU, via occulited's event stream on openccu-lite. There is still no polling path.
Alternatives considered¶
- One stream per interface. Three interfaces would exceed the two streams a token may hold, and would leave no slot for a reconnect overlap.
- WebSocket instead of SSE. Same content, but SSE is a plain
GETthrough the box's web server and needs no framing code. - Consume occulited's
statesweep as the value source. Doubles the event rate and reports sweeps as changes; the value seed and the stream's device events are sufficient. - Poll
/api/rpc/v1/state. A polling path is a declared non-goal.
Consequences¶
- A lite central registers no callback route and needs no reachable callback port — useful where the box cannot reach the daemon.
- The stream is a single point of delivery per central; its heartbeat and the per-interface state make its failure visible instead of silent.
- The two-streams-per-token limit binds other clients of the same token; pairing issues a dedicated token per daemon (ADR 0075).
- The SPECIFICATION's goal 1, non-goal list and §4.3 (callback servers) are amended accordingly.