WebSocket Reference
Endpoint: wss://<device-ip>/ws (or wss://safi.local/ws). The socket mirrors state as push messages and accepts a small playback command set from authenticated clients; everything else goes through REST. Design rationale lives in WebSocket push. Every message is a flat JSON object with a type discriminator.
Connection lifecycle
The socket requires the same bearer token as the REST API, presented one of two ways:
- Browsers cannot attach headers to a WebSocket, so they send the token as the first text frame:
{"type":"auth","token":"<api token>"}. On success the server answers withconnected; on failure it closes the socket. Sockets that send nothing are closed after 10 seconds. - CLI clients can authenticate at the handshake instead:
wscat -c wss://safi.local/ws --no-check -H "Authorization: Bearer <api token>"receivesconnectedimmediately.
Until authenticated, a socket receives no event traffic at all. There is deliberately no token-in-URL form: URLs end up in shell history and logs, frames do not.
| Type | Shape | When |
|---|---|---|
connected | {"type":"connected","message":"Safi WebSocket Ready"} | Once, after successful auth |
ack | {"type":"ack","action":"volume","ok":true} | Reply to every command frame; ok:false carries an error string |
Clients should fetch current state via REST after connecting (and reconnecting); the socket only carries changes.
Playback
| Type | Shape | Trigger |
|---|---|---|
playback | {"type":"playback","state":"playing","track":"...","reciter":"...","surah":1,"position":128470,"duration":431000,"volume":50,"advance_mode":"continuous","repeat_done":0,"repeat_target":0} | Any playback state change. state: playing, paused, loading, buffering, error, stopped |
prayer | {"type":"prayer","next":{"name":"Asr","name_ar":"العصر","epoch":1783690591,"countdown_s":10434},"reached":false} | Prayer times recomputed, and at prayer time with reached:true |
volume | {"type":"volume","level":70} | Volume changed from any source, including the physical knob |
metadata | {"type":"metadata","title":"..."} | ICY stream title changed; UTF-8, Arabic passes through unescaped |
stream | {"type":"stream","status":"reconnecting"} | Stream connection lost, retrying with backoff |
underrun | {"type":"underrun","count":3} | Audio underrun counter incremented (meaning) |
Device state
| Type | Shape | Trigger |
|---|---|---|
battery | {"type":"battery","present":true,"level":71,"charging":false,"voltage":3.92} | Periodic and on charge state change; present false means no battery is attached |
wifi | {"type":"wifi","status":"connected","ip":"192.168.1.37"} | Connect and disconnect |
nfc | {"type":"nfc","uid":"8C9108CD","known":true,"name":"Bedroom card"} | Card tapped; known:false UIDs are what the registration flow listens for |
library | {"type":"library","status":"updated"} | SD rescan finished |
ota_progress | {"type":"ota_progress","progress":42} | During an update |
error | {"type":"error","message":"..."} | Device-side fault worth showing a human |
Client-to-device frames
After the auth frame, authenticated clients may send playback commands. The socket enforces the same bearer-token authorization as REST (auth happens before any command is accepted) plus a per-client throttle of one command per 150 ms; violations receive {"type":"ack","ok":false,"error":"rate_limited"}.
{"type":"cmd","action":"pause"} // pause, resume, stop, next, prev
{"type":"cmd","action":"volume","value":42}
{"type":"cmd","action":"seek","position":60000}
{"type":"cmd","action":"mode","mode":"continuous"}
Every command is answered with an ack; the state change itself arrives as the usual playback push. Anything unrecognized receives {"type":"ack","ok":false,"error":"unknown"}. Configuration and anything destructive stays REST-only.
Consumption pattern
The dashboard's js/websocket.js is the reference client: connect, send the auth frame on open, treat the connected reply (not the raw socket open) as online, dispatch on type, reconnect with backoff on close, refetch REST state on each (re)connect. Multiple concurrent clients each receive every message; there is no per-client filtering, at these message rates none is needed.