WebSocket Protocol Reference
The daemon WebSocket endpoint at /api/v1/ws streams real-time data to the UI.
Connection Lifecycle
Connect to
ws://127.0.0.1:9420/api/v1/wswith the subprotocolhypercolor-v1(HYPERCOLOR_WS_PROTOCOLinhypercolor-leptos-ext::ws). The daemon offers it back throughws.protocols([HYPERCOLOR_WS_PROTOCOL])and advertises it in the manifest, but it does not gate the upgrade: the only rejection inws_handleris the Origin check, and a connect that names no subprotocol still upgrades. Send it anyway, because it is the versioning handle. When the client holds a verified session token it rides as a?token=query parameter, re-read on every connect so a rotated token takes effect.Set binary type:
ws.set_binary_type(BinaryType::Arraybuffer)— required for canvas framesSubscribe on open, three topics, one carrying a config:
{ "type": "subscribe", "topics": [ { "topic": "events" }, { "topic": "metrics", "config": { "fps": 2.0 } }, { "topic": "sensors" } ] }Preview topics are never in the initial subscribe; they are added on demand as consumers register. A timeout guards the first
subscribedacknowledgment: if it does not arrive, the client closes and reconnects rather than sitting on a half-open socket.Receive: Binary messages (canvas/frame data) and JSON messages (events/metrics/audio)
Binary Frame Format
Preview transport v2 can carry canvas data in the legacy 0x03 envelope,
the wide 0x0b envelope with u32 dimensions, or the chunk 0x0f
envelope. Cancellations use 0x10. Never inspect these layouts by hand in UI
code. Decode them through PreviewBinaryDecoder, which uses the canonical
hypercolor-leptos-ext::ws codecs and negotiates the daemon's advertised
transport capability.
The generated byte-level contract lives in
docs/content/api/websocket-binary-frames.md.
JSON Message Types
// Event (note: subtype key is "event", NOT "event_type")
{ "type": "event", "event": "effect_started", "data": {...} }
// Audio arrives as an event subtype, not a separate message type
{ "type": "event", "event": "audio_level_update", "data": { "level": 0.45, "bass": 0.3, "mid": 0.5, "treble": 0.2, "beat": true } }
// Device events also arrive as event subtypes
{ "type": "event", "event": "device_connected", "data": { "device_id": "..." } }
// Metrics (top-level type, structured data payload)
{ "type": "metrics", "data": { "fps": {...}, "frame_time": {...}, "stages": {...}, ... } }
// Backpressure warning for a drop-with-notice topic
{ "type": "backpressure", "dropped_frames": 12, "topic": "frames", "recommendation": "reduce_fps", "suggested_fps": 15 }
// Hello (sent on connect, runtime state only; content comes from GET /scene)
// fps is { target, capacity, delivered }; there is no "actual" key.
// capacity is paced_fps(avg_frame_time, target), clamped to target, so it
// never exceeds target no matter how much headroom the frame loop has.
// brightness is a percent (0-100). layout is always null today.
{
"type": "hello",
"state": {
"running": true,
"paused": false,
"brightness": 80,
"fps": { "target": 30, "capacity": 30.0, "delivered": 29.8 },
"scene": { "id": "...", "name": "Desk", "snapshot_locked": false },
"layout": null,
"device_count": 3,
"total_leds": 214
}
}
// Subscribed (confirmation after subscribe request)
{ "type": "subscribed", "topics": [{ "topic": "canvas", "config": { "fps": 30 } }] }Reconnection State Machine
Connected → (socket close/error) → Disconnected
Disconnected → (wait backoff) → Connecting
Connecting → (success) → Connected
Connecting → (failure) → Disconnected (increment attempt, increase backoff)Backoff: 500ms initial, doubles each attempt, caps at 15s. Resets on successful connection.
On reconnect: re-subscribe to topics, wait for the first subscribed
acknowledgment, then refetch every REST-backed mirror. Reset FPS smoothing and
clear stale frame data before accepting new preview publications. Events are not
replayed across the socket gap.
Backpressure Handling
Each topic declares its backpressure behavior. Preview topics use latest-value delivery. Drop-with-notice topics report their dropped publication count.
The UI receives BackpressureNotice messages for frames, spectrum,
metrics, and device_metrics. Canvas preview does not emit notices.
Event Types That Trigger UI Updates
| Event Type | UI Reaction |
|---|---|
effect_started |
Refetch the active scene and output state |
effect_stopped |
Refetch the active scene and output state |
device_connected |
Refetch device list (if device not already known) |
device_disconnected |
Refetch device list (if device was known) |
device_discovered |
Refetch device list |
config_changed |
Reload config resources |
device_state_changed |
Refetch device list (if device was known) |
device_discovery_completed |
Refetch when the list is empty and the scan found devices |
active_scene_changed |
Refetch the active scene and output state |
scene_library_changed |
Refetch the saved scene library |
scene_settings_changed |
Refetch the active scene |
zone_changed |
Refetch the active scene, unless the change kind is controls_patched |
effect_control_changed |
Refresh live control values |
layer_health_changed |
Update WsContext::layer_health; no refetch |
control_surface_changed |
Refetch the control-surface list |
extension_state_changed |
Extension-owned; filter on source/kind and refetch that extension's REST state |
SCENE_EVENTS, DEVICE_LIFECYCLE_EVENTS, LAYER_HEALTH_EVENTS, and
CONTROL_SURFACE_EVENTS in src/ws/messages.rs:54-69 are the authoritative
lists; the hint signals on WsContext are populated from them.
Closure Lifetime Management
WebSocket callbacks must stay alive for exactly the socket lifetime. Retain the callback handles next to the socket and drop both together before reconnecting:
let handlers =
WebSocketEventHandlers::attach(&ws, on_open, on_close, on_error, on_message);
socket_callbacks.set_value(Some(handlers));The constructor is attach, and the argument order is open, close, error,
message. The closures are all FnMut with distinct event types
(Event, CloseEvent, Event, MessageEvent), so swapping close and message
is a type error rather than a silent miswire, but swapping open and error is
not.
Do not call .forget(). Reconnect loops would leak every previous callback.
dispose_existing_socket clears the browser callbacks and drops the retained
handler bundle before installing the next socket.