Skip to main content

WebSocket Push

The dashboard never polls. Every live update it shows (playback position, volume moves, battery, NFC taps, stream titles, OTA progress, buffering warnings) arrives as a push over one WebSocket per client. The entire mechanism is ~300 lines because it is a mirror, not a system: it reflects the event bus onto TCP.

The design in one sentence

websocket_handler.c subscribes to the events that matter to a UI, serializes each into a small JSON message, and broadcasts it to every connected socket; it holds no state of its own beyond the client list.

That framing dictates everything else:

  • No socket-specific state machine beyond auth. An authenticated client gets a connected hello and then simply sees the same stream everyone sees. The dashboard fetches current state once over REST at page load, then stays current via deltas. (Replaying full state on connect was considered and rejected: it duplicates the REST responses for no gain.)
  • The device never trusts the socket with commands. After the auth frame, messages from clients are limited to a ping/ack; all commands go through the authenticated REST API. One authorization model, not two.
  • Origin of truth stays in components. The socket layer cannot invent state; it can only forward what a component announced. If the dashboard shows it, an event carried it, which makes "the dashboard is wrong" bugs mechanically traceable.

First-frame authentication

The socket carries device telemetry, so it demands the same bearer token as REST. The wrinkle is the browser: the WebSocket API cannot attach an Authorization header. The common workaround, wss://host/ws?token=SECRET, puts the secret in a URL, and URLs leak: shell history, proxy logs, browser tooling. Safi rejects that form entirely.

Instead the handshake admits the socket unauthenticated (after the same rate limit and strict Origin allow-list as before) and requires {"type":"auth","token":"..."} as the first text frame, validated with the existing constant-time comparison. Success earns the connected hello and a place in the broadcast list; failure closes the socket, and a reconnect storm lands on the handshake rate limiter. Non-browser clients skip the dance by sending the header at handshake. A periodic sweep closes sockets that never authenticate within ten seconds, and the same sweep reaps slots left behind by clients that vanished without a close frame, which previously lingered until a broadcast happened to fail on them. One detail worth copying: the handler does not log frame payloads before authentication, because the first frame is the token.

The message catalog

Fourteen message types, each a flat JSON object with a type field; the full shapes are in the WebSocket reference. The mapping is nearly one-to-one with bus events:

Bus eventSocket message
EVENT_AUDIO_PLAY_START / _STOP / pause, resumeplayback snapshot
EVENT_AUDIO_VOLUME_CHANGEvolume
EVENT_STREAM_METADATAmetadata (ICY title)
EVENT_STREAM_RECONNECTINGstream status
EVENT_AUDIO_UNDERRUNunderrun counter
EVENT_OTA_PROGRESSota_progress
EVENT_NFC_CARD_DETECTEDnfc
battery and WiFi eventsbattery, wifi

Serialization notes that earn their keep: ICY titles are JSON-escaped with the Arabic left intact (UTF-8 end to end, no \u mangling that would double the payload), and the playback message is a compact snapshot rather than a diff, so a missed frame can never desynchronize a client.

Broadcast mechanics

httpd owns the sockets; the handler keeps a small array of live socket descriptors. Sends use httpd_ws_send_frame_async scheduled on the httpd task, because the publisher of a bus event may be the audio task, and invariant three of the audio pipeline forbids it from ever blocking on a slow client's TCP window. A client that stops reading gets closed by the server rather than backing pressure into the device.

Subscription of the socket layer to the bus happens once at server start; per-client cost is one descriptor slot. Disconnects (page closed, phone slept) reap the slot on the next send failure or on the periodic sweep, and the dashboard's JS reconnects with backoff, refetching state via REST on each reconnect: the one moment full state is actually needed.

Why not Server-Sent Events

SSE would cover pure push, but the upgrade path rides the existing HTTPS server either way, browsers cap concurrent SSE connections per origin awkwardly, and WebSocket keeps the option of client pings for liveness. Equal cost, strictly more headroom; not a close call.