Skip to main content

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 with connected; 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>" receives connected immediately.

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.

TypeShapeWhen
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

TypeShapeTrigger
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

TypeShapeTrigger
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.