1171 lines
25 KiB
Markdown
1171 lines
25 KiB
Markdown
# 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!**
|