# Rdio Scanner WebSocket API Documentation **Last Updated:** January 2026 **Status:** Proprietary Service (See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for licensing) --- ## Table of Contents 1. [Overview](#overview) 2. [Connection](#connection) 3. [Message Format](#message-format) 4. [Client Commands (Request)](#client-commands-request) 5. [Server Responses & Events](#server-responses--events) 6. [Message Flow Examples](#message-flow-examples) 7. [Client Event Handlers](#client-event-handlers) 8. [Practical Examples](#practical-examples) 9. [Error Handling](#error-handling) 10. [Protocol Details](#protocol-details) --- ## Overview The Rdio Scanner WebSocket API provides real-time bidirectional communication between the server and connected clients. Messages are transmitted as JSON arrays with a command code, payload, and optional flags. ### Access Restrictions ⚠️ **Important:** The WebSocket API is a proprietary service provided exclusively by Saubeo Solutions: - Available on **Saubeo Solutions' hosted service** for native applications and integrated web applications - Available on **self-hosted deployments** with a valid license agreement - See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for full terms The **HTTP REST API** is fully available under GPL terms for all uses. --- ## Connection ### Establishing a WebSocket Connection ``` ws://[host]:[port]/ wss://[host]:[port]/ (TLS) ``` ### Connection Flow 1. Client initiates WebSocket connection to the server 2. Server upgrades the connection using gorilla/websocket 3. Server waits for configuration before registering the client 4. Client sends `VER` (version) and `CFG` (config request) commands 5. Upon receiving `CFG` response, client is registered and can receive live updates ### Connection Parameters - **Read Buffer Size:** 1024 bytes - **Write Buffer Size:** 1024 bytes - **Read Timeout (Pong Deadline):** 60 seconds - **Ping Period:** 54 seconds (90% of pong wait) - **Write Timeout:** 10 seconds --- ## Message Format ### Structure All WebSocket messages are JSON arrays with the following structure: ```json [command, payload, flag] ``` - **command** (string, required): Three-letter command code - **payload** (any, optional): Command-specific data - **flag** (string, optional): Additional flags or metadata ### Key Convention 📤 **CLIENT → SERVER:** Messages you send to request data or perform actions 📥 **SERVER → CLIENT:** Messages the server sends in response or as broadcasts ### Example Messages ```javascript // CLIENT → SERVER: Request a call ["CAL", "12345", "DL"] // SERVER → CLIENT: Send configuration ["CFG", {...config object...}] // CLIENT → SERVER: Simple command with no payload ["VER"] ``` --- ## Client Commands (Request) **Legend:** 📤 = You send this to the server ### 📤 VER (Version) **Description:** Request the server version. ```javascript ["VER"] ``` **Parameters:** None **Server Response:** ```javascript ["VER", "7.0.0"] ``` --- ### 📤 CFG (Config) **Description:** Request the current server configuration including systems, groups, tags, and client options. This must be sent during connection initialization. ```javascript ["CFG"] ``` **Parameters:** None **Server Response:** ```javascript ["CFG", { "alerts": {...}, "branding": {...}, "dimmerDelay": number, "email": string, "groups": {...}, "groupsData": [...], "keypadBeeps": {...}, "playbackGoesLive": boolean, "showListenersCount": boolean, "systems": {...}, "tags": {...}, "tagsData": [...], "time12hFormat": boolean }] ``` --- ### 📤 CAL (Call) **Description:** Request a specific call by ID, optionally with a flag for download or playback. ```javascript ["CAL", "callId"] ["CAL", "callId", "DL"] ["CAL", "callId", "PY"] ``` **Parameters:** - **callId** (string, required): Numeric call ID - **flag** (string, optional): - `"DL"` - Download the call audio file - `"PY"` - Play the call audio **Server Response:** ```javascript ["CAL", { "id": 12345, "audioName": "call_20260128.wav", "audioType": "audio/wav", "audio": Uint8Array, "dateTime": 1705372800000, "frequencies": [461.125], "system": { "id": 1, "label": "Police" }, "talkgroup": { "id": 101, "label": "Dispatch", "tagId": 5, "name": "Dispatch (Police)" }, "units": ["Unit-5", "Unit-12"], "patches": [], ... }, "DL"] ``` --- ### 📤 LCL (List Calls) **Description:** Request a list of calls with filtering, sorting, and pagination options. ```javascript ["LCL", { "offset": 0, "limit": 50, "sort": -1, "filters": { "system": 1, "group": 2, "talkgroup": 101, "tag": 5, "unit": 3, "date": "2026-01-28", "keyword": "emergency" } }] ``` **Parameters:** - **offset** (number): Starting position in results - **limit** (number): Maximum number of results to return - **sort** (number): Sort order - `1` = ascending (oldest first) - `-1` = descending (newest first) - **filters** (object, optional): - `system` (number): Filter by system ID - `group` (number): Filter by group ID - `talkgroup` (number): Filter by talkgroup ID - `tag` (number): Filter by tag ID - `unit` (number): Filter by unit ID - `date` (string): Filter by date - `keyword` (string): Text search in call metadata **Server Response:** ```javascript ["LCL", { "results": [ { ...call object... }, { ...call object... } ], "count": 250, "offset": 0, "limit": 50 }] ``` --- ### 📤 PIN (PIN Authentication) **Description:** Send a PIN for user authentication and restricted system access. ```javascript ["PIN", "1234"] ``` **Parameters:** - **pin** (string, required): User's access PIN code **Server Response (on success):** ```javascript ["CFG", {...full config...}] ``` **Server Response (on failure):** ```javascript ["PIN", null] ``` --- ### 📤 LFM (Livefeed Map) **Description:** Configure which systems, groups, and talkgroups to receive live call updates for. Only send updates for enabled feeds. ```javascript ["LFM", { "1": { "2": { "101": true, "102": true }, "3": { "201": true } } }] ``` **Parameters:** - **filters** (object): Nested object structure - Key: System ID - Value: Object with Group ID keys - Key: Group ID - Value: Object with Talkgroup ID keys - Key: Talkgroup ID - Value: `true` to enable, `false` to disable **Example:** Subscribe to live calls from System 1 (Police), Groups 2 & 3, specific talkgroups: ```javascript ["LFM", { "1": { // System 1 "2": { // Group 2 "101": true, // Talkgroup 101 "102": true // Talkgroup 102 }, "3": { // Group 3 "201": true // Talkgroup 201 } } }] ``` **Server Response:** None (fire-and-forget command, but server will start sending filtered `CAL` events) --- ### 📤 PID (Push ID) **Description:** Register a push notification device ID for mobile platforms. ```javascript ["PID", "firebase_device_token_abc123xyz"] ``` **Parameters:** - **pushId** (string, required): Device push notification identifier **Server Response:** None (acknowledgment is implicit) --- ### 📤 IOS (iOS Metadata) **Description:** Send iOS-specific metadata and capabilities. ```javascript ["IOS", { "version": "1.0", "platform": "iOS", "osVersion": "16.1" }] ``` **Parameters:** iOS-specific metadata object **Server Response:** None --- ## Server Responses & Events **Legend:** 📥 = Server sends this to you ### 📥 CAL (Call - Broadcast) **Description:** Server broadcasts a new call or call update. Can be sent in response to a `CAL` request or as a live feed broadcast. ```javascript ["CAL", { "id": 12345, "audioName": "call_20260128.wav", "audioType": "audio/wav", "audio": Uint8Array, "dateTime": 1705372800000, "frequencies": [461.125, 462.500], "system": { "id": 1, "label": "Police Dispatch" }, "talkgroup": { "id": 101, "label": "Dispatch", "tagId": 5, "name": "Police Dispatch" }, "units": ["Unit-5", "Unit-12"], "patches": ["Tg-102"], "source": "trunk_recorder", "duration": 45000 }, "flag"] ``` **Possible Flags:** - `"DL"` - Audio data included, client should download/save - `"PY"` - Audio data included, client should play - `null`/undefined - Call metadata broadcast (no audio) --- ### 📥 CFG (Configuration - Broadcast/Response) **Description:** Server sends configuration updates when settings change. Also sent in response to `CFG` request during connection. ```javascript ["CFG", { "alerts": { "email": true, "browser": true }, "branding": { "color": "#FF6B00", "name": "My Scanner" }, "dimmerDelay": 5000, "email": "admin@example.com", "groups": { "1": { "id": 1, "label": "Emergency Services", "systems": [1, 2, 3] } }, "groupsData": [...], "keypadBeeps": { "enabled": true, "volume": 0.5 }, "playbackGoesLive": false, "showListenersCount": true, "systems": { "1": { "id": 1, "label": "Police", "talkgroups": [...] } }, "tags": { "5": "Emergency" }, "tagsData": [...], "time12hFormat": false }] ``` --- ### 📥 VER (Version - Response) **Description:** Server responds with version information. ```javascript ["VER", "7.0.0"] ``` --- ### 📥 LSC (Listeners Count - Broadcast) **Description:** Server broadcasts the current number of connected listeners (if enabled in config). ```javascript ["LSC", 42] ``` --- ### 📥 LFM (Livefeed Map - Broadcast) **Description:** Server sends livefeed configuration or updates. ```javascript ["LFM", { "1": { "2": { "101": true, "102": true } } }] ``` --- ### 📥 MAX (Maximum - Broadcast) **Description:** Server-side limit notification (e.g., max concurrent connections reached). ```javascript ["MAX", { "type": "clients", "current": 1000, "limit": 1000 }] ``` --- ### 📥 PIN (PIN Required - Broadcast) **Description:** Server requests PIN authentication. Client must respond with `["PIN", "code"]`. ```javascript ["PIN", null] ``` **Meaning:** Client is connected but restricted content requires PIN authentication. Use the `PIN` command to authenticate. --- ### 📥 XPR (Expired - Broadcast) **Description:** Session has expired; client should disconnect and reconnect. This may be due to authentication timeout or server policy change. ```javascript ["XPR", null] ``` **Action Required:** Close connection and reconnect --- ### 📥 SRV (Server - Broadcast) **Description:** Server status and metadata. ```javascript ["SRV", { "version": "7.0.0", "uptime": 86400000, "timestamp": 1705372800000 }] ``` --- ## Message Flow Examples ### Example 1: Complete Connection Sequence ```javascript // CLIENT → SERVER: Request version CLIENT SENDS: ["VER"] SERVER SENDS: ["VER", "7.0.0"] // CLIENT → SERVER: Request configuration CLIENT SENDS: ["CFG"] SERVER SENDS: ["CFG", { "systems": {...}, "groups": {...}, "tags": {...}, ... }] // After CFG received, client is registered and can receive live calls SERVER SENDS: ["CAL", {...call data...}] // Live broadcast SERVER SENDS: ["CAL", {...call data...}] // Live broadcast SERVER SENDS: ["LSC", 42] // Listeners count update ``` --- ### Example 2: Requesting and Playing a Specific Call ```javascript // CLIENT → SERVER: Request call playback CLIENT SENDS: ["CAL", "12345", "PY"] // SERVER → CLIENT: Send call data with play flag SERVER SENDS: ["CAL", { "id": 12345, "audio": Uint8Array, "dateTime": 1705372800000, ... }, "PY"] // Client receives the flag "PY" and should play the audio ``` --- ### Example 3: Searching with Filters ```javascript // CLIENT → SERVER: Search for recent police dispatch calls CLIENT SENDS: ["LCL", { "offset": 0, "limit": 50, "sort": -1, "filters": { "system": 1, "talkgroup": 101 } }] // SERVER → CLIENT: Send filtered results SERVER SENDS: ["LCL", { "results": [ { "id": 12345, "system": {...}, "talkgroup": {...}, ... }, { "id": 12344, "system": {...}, "talkgroup": {...}, ... }, ... ], "count": 250, "offset": 0, "limit": 50 }] ``` --- ### Example 4: Authentication with PIN ```javascript // SERVER → CLIENT: Server requires PIN (restricted access) SERVER SENDS: ["PIN", null] // CLIENT → SERVER: Send authentication PIN CLIENT SENDS: ["PIN", "1234"] // SERVER → CLIENT: Authentication successful, send config SERVER SENDS: ["CFG", {...restricted config...}] ``` --- ### Example 5: Setting Up Livefeed Filter ```javascript // CLIENT → SERVER: Subscribe to specific talkgroups CLIENT SENDS: ["LFM", { "1": { "2": { "101": true, "102": true } } }] // SERVER → CLIENT: Acknowledgment (implicit, no response) // Now client will only receive CAL events for System 1, Group 2, TGs 101-102 // SERVER SENDS (filtered): ["CAL", {...call...}] // Only if matches filter // SERVER SENDS (no): ["CAL", {...call...}] // Filtered out ``` --- ## Client Event Handlers ### Connection Events #### onopen Fired when the WebSocket connection is successfully established. ```javascript websocket.onopen = () => { console.log("Connected to Rdio Scanner"); // Send initial commands websocket.send(JSON.stringify(["VER"])); websocket.send(JSON.stringify(["CFG"])); }; ``` --- #### onmessage Fired when the server sends a message. Parse the message array and dispatch to appropriate handlers. ```javascript websocket.onmessage = (event) => { try { const message = JSON.parse(event.data); const [command, payload, flag] = message; switch (command) { case "CAL": // 📥 Call broadcast or response handleNewCall(payload, flag); break; case "CFG": // 📥 Configuration handleConfigUpdate(payload); break; case "VER": // 📥 Version response console.log("Server version:", payload); break; case "LSC": // 📥 Listeners count handleListenersCount(payload); break; case "PIN": // 📥 PIN required promptForPIN(); break; case "XPR": // 📥 Session expired handleSessionExpired(); break; default: console.log("Unknown command:", command); } } catch (error) { console.warn("Invalid message received:", error); } }; ``` --- #### onclose Fired when the connection is closed. ```javascript websocket.onclose = (event) => { console.log("Disconnected from server"); console.log("Close code:", event.code); // 1000 = normal, other = error // Attempt reconnection if not a normal close if (event.code !== 1000) { setTimeout(() => reconnectWebsocket(), 2000); } }; ``` --- #### onerror Fired when an error occurs. ```javascript websocket.onerror = (error) => { console.error("WebSocket error:", error); }; ``` --- ## Practical Examples ### Basic Connection Setup with Auto-Reconnect ```javascript const WEBSOCKET_URL = 'ws://localhost:3000/'; class RdioScannerClient { constructor() { this.websocket = null; this.connected = false; this.config = null; } connect() { this.websocket = new WebSocket(WEBSOCKET_URL); this.websocket.onopen = () => { console.log('Connected to Rdio Scanner'); this.connected = true; // 📤 Send version request this.send(['VER']); // 📤 Send config request (enables live feeds) this.send(['CFG']); }; this.websocket.onmessage = (event) => { this.handleMessage(JSON.parse(event.data)); }; this.websocket.onclose = (event) => { console.log('Disconnected'); this.connected = false; // Reconnect on abnormal closure if (event.code !== 1000) { console.log('Reconnecting in 2 seconds...'); setTimeout(() => this.connect(), 2000); } }; this.websocket.onerror = (error) => { console.error('WebSocket error:', error); }; } send(message) { if (this.connected && this.websocket) { this.websocket.send(JSON.stringify(message)); } else { console.warn('Not connected, cannot send:', message); } } handleMessage(message) { const [command, payload, flag] = message; switch (command) { case 'VER': // 📥 Version response console.log('Server version:', payload); break; case 'CFG': // 📥 Configuration response console.log('Configuration received'); this.config = payload; this.onConfigReceived(payload); break; case 'CAL': // 📥 Call broadcast console.log('New call:', payload.id); this.onCallReceived(payload, flag); break; case 'LSC': // 📥 Listeners count console.log('Active listeners:', payload); break; case 'PIN': // 📥 PIN required console.log('Authentication required'); this.promptForPIN(); break; case 'XPR': // 📥 Session expired console.log('Session expired'); this.handleSessionExpired(); break; default: console.log('Unknown command:', command); } } // 📤 Request specific call requestCall(callId, action = null) { if (action) { this.send(['CAL', callId, action]); } else { this.send(['CAL', callId]); } } // 📤 Download call downloadCall(callId) { this.requestCall(callId, 'DL'); } // 📤 Play call playCall(callId) { this.requestCall(callId, 'PY'); } // 📤 Search calls searchCalls(filters = {}, offset = 0, limit = 50) { this.send(['LCL', { offset, limit, sort: -1, // Newest first filters }]); } // 📤 Authenticate with PIN sendPIN(pin) { this.send(['PIN', pin]); } // 📤 Set livefeed filter setLivefeedFilter(filterObject) { this.send(['LFM', filterObject]); } onConfigReceived(config) { // Handle configuration update // Store systems, groups, tags, options } onCallReceived(call, flag) { if (flag === 'DL') { // Audio included for download this.downloadAudio(call); } else if (flag === 'PY') { // Audio included for playback this.playAudio(call); } else { // Metadata only this.displayCall(call); } } promptForPIN() { const pin = prompt('Enter PIN:'); if (pin) { this.sendPIN(pin); } } handleSessionExpired() { console.log('Session expired, reconnecting...'); this.disconnect(); setTimeout(() => this.connect(), 1000); } downloadAudio(call) { // Convert Uint8Array to Blob and download const blob = new Blob([call.audio], { type: call.audioType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = call.audioName || 'call.wav'; link.click(); URL.revokeObjectURL(url); } playAudio(call) { // Play audio using HTML5 audio const blob = new Blob([call.audio], { type: call.audioType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.play(); } displayCall(call) { console.log(`New call: ${call.system.label} > ${call.talkgroup.label}`); } disconnect() { if (this.websocket) { this.websocket.close(); } } } // Usage const client = new RdioScannerClient(); client.connect(); // Later... client.downloadCall('12345'); client.playCall('12346'); client.searchCalls({ system: 1, talkgroup: 101 }); ``` --- ### Advanced: Filtered Livefeed Example ```javascript class FilteredLivefeeds { constructor(client) { this.client = client; this.filters = {}; } // 📤 Subscribe to System 1, all talkgroups in Group 2 subscribeToSystemGroup(systemId, groupId) { if (!this.filters[systemId]) { this.filters[systemId] = {}; } this.filters[systemId][groupId] = { '*': true }; this.updateFilters(); } // 📤 Subscribe to specific talkgroup subscribeToTalkgroup(systemId, groupId, talkgroupId) { if (!this.filters[systemId]) { this.filters[systemId] = {}; } if (!this.filters[systemId][groupId]) { this.filters[systemId][groupId] = {}; } this.filters[systemId][groupId][talkgroupId] = true; this.updateFilters(); } // 📤 Unsubscribe from talkgroup unsubscribeFromTalkgroup(systemId, groupId, talkgroupId) { if (this.filters[systemId]?.[groupId]) { delete this.filters[systemId][groupId][talkgroupId]; this.updateFilters(); } } updateFilters() { this.client.send(['LFM', this.filters]); } } // Usage const livefeeds = new FilteredLivefeeds(client); livefeeds.subscribeToTalkgroup(1, 2, 101); // System 1, Group 2, TG 101 livefeeds.subscribeToTalkgroup(1, 2, 102); // System 1, Group 2, TG 102 ``` --- ## Error Handling ### Common Issues #### 1. Invalid JSON **Problem:** Message is not valid JSON **Server:** Logs warning: `"Invalid control message received"` ```javascript // ❌ Wrong websocket.send("VER"); // ✅ Correct websocket.send(JSON.stringify(["VER"])); ``` --- #### 2. Session Expired **Problem:** 📥 Received `["XPR", null]` ```javascript if (command === 'XPR') { console.log('Session expired, reconnecting...'); this.disconnect(); setTimeout(() => this.connect(), 1000); } ``` --- #### 3. Maximum Clients Reached **Problem:** Connection is immediately closed **Solution:** Retry after delay or check max client limit ```javascript websocket.onclose = (event) => { if (event.code !== 1000) { setTimeout(() => this.connect(), 5000); // Backoff } }; ``` --- #### 4. Authentication Required **Problem:** 📥 Received `["PIN", null]` ```javascript if (command === 'PIN' && payload === null) { // Prompt user for PIN const pin = prompt('Enter your PIN:'); client.send(['PIN', pin]); // 📤 Send PIN } ``` --- #### 5. Read/Write Timeouts **Problem:** No pong received within 60 seconds **Cause:** Network latency, firewall, or dead connection ```javascript // Server sends pings every 54 seconds // You should auto-respond with pong (most WebSocket libraries do this) ``` --- ## Protocol Details ### Keep-Alive (Ping/Pong) The server sends WebSocket PING frames every 54 seconds to detect dead connections. Most WebSocket implementations automatically respond with PONG. **Server Side:** ```go pingPeriod := pongWait / 10 * 9 // 54 seconds pongWait := 60 * time.Second ticker.NewTicker(pingPeriod) client.Conn.WriteMessage(websocket.PingMessage, nil) ``` **Client Side:** ```javascript // Automatically handled by browser WebSocket API // No action needed unless using low-level library ``` --- ### Message Serialization Messages use a compact array format for bandwidth efficiency: ```go // Server Go code type Message struct { Command any Payload any Flag any } func (message *Message) ToJson() ([]byte, error) { str := []any{message.Command} if message.Payload != nil && message.Payload != "" { str = append(str, message.Payload) } if message.Flag != nil && message.Flag != "" { str = append(str, message.Flag) } return json.Marshal(str) } // Produces: // ["VER"] (command only) // ["CFG", {...}] (command + payload) // ["CAL", {...}, "DL"] (command + payload + flag) ``` --- ### Admin Configuration WebSocket For administrative functions, use a separate WebSocket endpoint with token authentication: ``` wss://[host]:[port]/api/admin/config?token=[token] ``` **Flow:** ```javascript // 1. Connect to admin config endpoint const adminWs = new WebSocket('wss://localhost:3000/api/admin/config?token=eyJ...'); // 2. Send token on open adminWs.onopen = () => { adminWs.send(token); }; // 3. Receive config updates adminWs.onmessage = (event) => { const config = JSON.parse(event.data); // Admin configuration received }; ``` --- ## Performance Considerations - **Max Clients:** Configurable; typical default is 1000 - **Message Queue per Client:** 8192 messages - **Read Buffer:** 1024 bytes - **Write Buffer:** 1024 bytes - **Keepalive Interval:** 54 seconds - **Connection Timeout:** 60 seconds (pong wait) ### Optimization Tips 1. **Livefeed Filtering** - Use `LFM` to reduce message volume 2. **Batch Requests** - Don't spam individual `CAL` requests 3. **Limit Search Results** - Use `offset` and `limit` in `LCL` commands 4. **Connection Pooling** - Use a single connection, don't reconnect frequently 5. **Message Throttling** - If client receives many messages, process in batches --- ## Reference ### Command Summary | Command | Direction | Purpose | |---------|-----------|---------| | `VER` | 📤→ 📥 | Request/receive server version | | `CFG` | 📤→ 📥 | Request/receive configuration | | `CAL` | 📤→ 📥 | Request/receive call data | | `LCL` | 📤→ 📥 | Search calls with filters | | `PIN` | 📤→ 📥 | Authenticate with PIN | | `LFM` | 📤 | Set livefeed filter | | `PID` | 📤 | Register push device ID | | `IOS` | 📤 | Send iOS metadata | | `LSC` | 📥 | Listeners count broadcast | | `MAX` | 📥 | Maximum limit notification | | `XPR` | 📥 | Session expired notification | | `SRV` | 📥 | Server status | --- ## Licensing & Support For questions about WebSocket API usage: - **Commercial Licensing:** [rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions) - **Self-Hosted Instances:** A license agreement is required - **Official Service:** Available through Saubeo Solutions See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for full terms. --- **Happy Rdio scanning!**