Skip to content

Subsystem WS Server

OneSeventyFour edited this page May 14, 2026 · 1 revision

Subsystem: WebSocket server

host/pythings/websock_server/ws_server.py — a thin fan-out server that delivers daemon state to every connected browser at up to 30 Hz.

What it serves

A single endpoint at ws://<host>:8090. No path routing; clients just open the WS and start receiving JSON state blobs. There's no client → server message format — it's a one-way push.

Each message is one of:

  • A full state blob containing the entire fw_state (parsed /data/state), fw_cursor (current playback time), fw_firing (currently-firing markers), fw_system (CPU/mem/temp), and fw_d_error (last 5 lines of /data/log/daemon.err).
  • A heartbeat of the form { "_hb": true, "fw_last_update": <ms> } if nothing has changed within 5 seconds. The browser uses this to keep its "Daemon fresh" indicator green.

Where the data comes from

Three sources, merged on every tick:

  1. The Unix datagram socket /tmp/byh_state.sock — the fast path. The daemon sendtos this socket every time state changes (debounced ~10 ms). The WS server reads, parses once, stashes in LATEST_FW_STATE, and notifies the broadcast loop via a threading.Condition. Sub-millisecond delivery from daemon → WS → browser.
  2. watchfiles.awatch on /data/ — the fallback for /data/state. Triggers when the daemon does its atomic file replace (os.replace after mkstemp). This catches state pushes the WS server might miss if it wasn't bound to the socket yet (e.g. WS server still starting).
  3. watchfiles.awatch on /tmp/ — auxiliary watcher for /tmp/fw_cursor (current show time) and /tmp/fw_firing (currently-firing items). The cursor file is rewritten ~1 Hz during a show; the firing file is rewritten when items start/stop firing.

It also reads CPU/mem/temp via psutil once per broadcast (cheap, no separate watcher).

Broadcast model

The broadcast loop:

  1. Wait on the state condition (signaled by any of the three watchers).
  2. Build the merged payload via _build_payload().
  3. If the rendered JSON differs from the last sent JSON, push to every connected client.
  4. If nothing changed but it's been more than HEARTBEAT_FORCE_SECONDS (5 s), push a heartbeat.
  5. Rate limit: at least MIN_SEND_INTERVAL_S (1/30 s) between pushes.

So worst-case latency from "daemon writes new state" → "browser sees new state" is bounded by:

  • ~10 ms (daemon debounce window) +
  • ~0.1 ms (Unix socket round trip) +
  • ~33 ms (broadcast rate cap).

Way under human-perceptible. During an active show, telemetry feels instant.

Payload shape

{
  "fw_state": {
    /* contents of /data/state — see PC daemon -> "State publishing" */
    "device_running": true,
    "show_loaded": true,
    "loaded_show_id": 42,
    "show_running": false,
    "receivers": { "RX146": { "label": "...", "type": "...", "status": {...} } },
    "settings": { "led_brightness": 10, "rf": { "current_channel": 76, ... } },
    "ota": { "phase": "idle", ... },
    /* ... */
  },
  "fw_cursor": -1.0,                    /* current show time in seconds, or -1 if idle */
  "fw_firing": null,                    /* { itemId: { firing_until_ms } } during a show */
  "fw_system": {
    "cpu_pct": 12.4, "mem_pct": 38.0, "temp_c": 51.2
  },
  "fw_d_error": ["[2026-05-14 00:33:12Z] Some error message", ...],
  "fw_last_update": 1715680123456,
  "daemon_active": true                 /* derived from daemon_lup freshness */
}

Client side

StatusBar.jsx is the only WS subscriber. On message:

  • If _hbpatchStateData({ fw_last_update }) only.
  • Otherwise → setStateData(payload) (replace).

The data then flows through useStateAppStore to every component that consumes it (useAppMode, ReceiverDisplay, ShowControl, etc.).

Failure modes

  • WS server crashes: clients reconnect with exponential backoff. The "Link" indicator in the status bar goes yellow.
  • Daemon stops writing: the WS server keeps sending heartbeats (to prove it's alive itself). The "Daemon" indicator goes yellow once fw_last_update is older than ~5 s.
  • Browser tab loses focus: modern browsers throttle inactive tabs' WS, but the server doesn't care — it'll continue to send and the browser will buffer.

Why not server-sent events / long polling / etc.?

WS is the simplest API for "sub-second push from daemon to many browsers". SSE would work too but is harder to get right with reconnect logic. HTTP long-polling would quintuple the request overhead. The Unix-socket-to-WS hop is the pattern that gives both low latency and clean separation of concerns: the daemon knows nothing about HTTP/WS; the WS server knows nothing about the protocol handler.

Clone this wiki locally