rdio-to-discord/rdio-docs.md

25 KiB

Rdio Scanner WebSocket API Documentation

Last Updated: January 2026
Status: Proprietary Service (See API_ACCESS_POLICY.md for licensing)


Table of Contents

  1. Overview
  2. Connection
  3. Message Format
  4. Client Commands (Request)
  5. Server Responses & Events
  6. Message Flow Examples
  7. Client Event Handlers
  8. Practical Examples
  9. Error Handling
  10. 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 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:

[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

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

["VER"]

Parameters: None

Server Response:

["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.

["CFG"]

Parameters: None

Server Response:

["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.

["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:

["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.

["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:

["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.

["PIN", "1234"]

Parameters:

  • pin (string, required): User's access PIN code

Server Response (on success):

["CFG", {...full config...}]

Server Response (on failure):

["PIN", null]

📤 LFM (Livefeed Map)

Description: Configure which systems, groups, and talkgroups to receive live call updates for. Only send updates for enabled feeds.

["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:

["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.

["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.

["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.

["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.

["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.

["VER", "7.0.0"]

📥 LSC (Listeners Count - Broadcast)

Description: Server broadcasts the current number of connected listeners (if enabled in config).

["LSC", 42]

📥 LFM (Livefeed Map - Broadcast)

Description: Server sends livefeed configuration or updates.

["LFM", {
  "1": {
    "2": {
      "101": true,
      "102": true
    }
  }
}]

📥 MAX (Maximum - Broadcast)

Description: Server-side limit notification (e.g., max concurrent connections reached).

["MAX", {
  "type": "clients",
  "current": 1000,
  "limit": 1000
}]

📥 PIN (PIN Required - Broadcast)

Description: Server requests PIN authentication. Client must respond with ["PIN", "code"].

["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.

["XPR", null]

Action Required: Close connection and reconnect


📥 SRV (Server - Broadcast)

Description: Server status and metadata.

["SRV", {
  "version": "7.0.0",
  "uptime": 86400000,
  "timestamp": 1705372800000
}]

Message Flow Examples

Example 1: Complete Connection Sequence

// 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

// 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

// 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

// 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

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

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.

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.

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.

websocket.onerror = (error) => {
  console.error("WebSocket error:", error);
};

Practical Examples

Basic Connection Setup with Auto-Reconnect

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

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"

// ❌ Wrong
websocket.send("VER");

// ✅ Correct
websocket.send(JSON.stringify(["VER"]));

2. Session Expired

Problem: 📥 Received ["XPR", null]

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

websocket.onclose = (event) => {
  if (event.code !== 1000) {
    setTimeout(() => this.connect(), 5000);  // Backoff
  }
};

4. Authentication Required

Problem: 📥 Received ["PIN", null]

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

// 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:

pingPeriod := pongWait / 10 * 9  // 54 seconds
pongWait := 60 * time.Second

ticker.NewTicker(pingPeriod)
client.Conn.WriteMessage(websocket.PingMessage, nil)

Client Side:

// 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:

// 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:

// 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
  • Self-Hosted Instances: A license agreement is required
  • Official Service: Available through Saubeo Solutions

See API_ACCESS_POLICY.md for full terms.


Happy Rdio scanning!