From 5d11a276c5e53a9d33855fa76ab7f86bd5841d25 Mon Sep 17 00:00:00 2001 From: ChrisChrome Date: Tue, 28 Jul 2026 21:59:49 -0600 Subject: [PATCH] do thing, pretty specific to my county but can be changed for other uses! --- .gitignore | 143 ++++++ index.js | 222 +++++++++ package-lock.json | 329 +++++++++++++ package.json | 18 + rdio-docs.md | 1170 +++++++++++++++++++++++++++++++++++++++++++++ twotonedec.py | 8 + 6 files changed, 1890 insertions(+) create mode 100644 .gitignore create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 rdio-docs.md create mode 100644 twotonedec.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..872d5f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/index.js b/index.js new file mode 100644 index 0000000..427f419 --- /dev/null +++ b/index.js @@ -0,0 +1,222 @@ +require("dotenv").config({quiet:true}) + +const ws = require("ws"); +// const conn = new ws("wss://saubeo.solutions/demo/rdio-scanner/"); +const conn = new ws("wss://rosebud-mt.ko4wal.radio") +const fs = require("fs"); +const Discord = require("discord.js"); +const hook = new Discord.WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL }); + +const knownTones = { + "634.5:706.8": "<@&1531872533119504464>", + "601.1:706.8": "<@&1531872550852755596>", +} + +// create or empty the temp folder +if (!fs.existsSync("./temp")) { + fs.mkdirSync("./temp"); +} else { + fs.readdirSync("./temp").forEach((file) => { + fs.unlinkSync(`./temp/${file}`); + }); +} + +// Build an LFM filter map that enables every talkgroup, in every group, +// on every system found in the CFG payload. +var globalSystems = {}; + +function buildFullLfmFilter(config) { + const filter = {}; + const systems = (config && config.systems) || {}; + + for (const sysId of Object.keys(systems)) { + const system = systems[sysId]; + const talkgroups = (system && system.talkgroups) || []; + + filter[system.id] = filter[system.id] || {}; + globalSystems[system.id] = {}; + globalSystems[system.id].name = system.label; + for (const tg of talkgroups) { + const groupId = tg.groupId ?? tg.group ?? tg.tagId ?? 0; + filter[system.id][tg.id] = true; + globalSystems[system.id][tg.id] = tg; + // console.log(tg) + } + // Print out number of talkgroups for system, and sys name + console.log(`System ${system.label} (${system.id}) has ${talkgroups.length} talkgroups.`); + } + // console.log(JSON.stringify(filter, null, 2)); + // console.log("Global systems:", JSON.stringify(globalSystems, null, 2)); + return filter; +} + +// Wrapper function around twotonedec.py to call it from Node.js and return the result. +const { spawn } = require("child_process"); + +const decodeTwoTone = (filePath) => { + return new Promise((resolve, reject) => { + // FFMPEG Convert file to wav + const wavFilePath = filePath.replace(/\.\w+$/, ".wav"); + console.log(`Converting ${filePath} to ${wavFilePath} using FFMPEG...`); + // unlink the wav file if it already exists + const path = require("path"); + + console.log("Original:", filePath); + console.log("WAV:", wavFilePath); + console.log("Absolute:", path.resolve(wavFilePath)); + console.log("Exists:", fs.existsSync(wavFilePath)); + if (fs.existsSync(wavFilePath)) { + fs.unlinkSync(wavFilePath); + } + const ffmpegProcess = spawn("ffmpeg", [ + "-y", + "-i", filePath, + "-acodec", "pcm_s16le", + "-ar", "8000", // or whatever your decoder expects + "-ac", "1", // mono if required + wavFilePath + ]); + + ffmpegProcess.on("close", (code) => { + if (code !== 0) { + reject(new Error(`FFMPEG process exited with code ${code}`)); + return; + } + // Proceed with Python process only after FFMPEG conversion is successful + const pythonProcess = spawn("python", ["twotonedec.py", wavFilePath]); + let result = ""; + + pythonProcess.stdout.on("data", (data) => { + result += data.toString(); + }); + pythonProcess.stderr.on("data", (data) => { + console.error(`stderr: ${data}`); + }); + pythonProcess.on("close", (code) => { + if (code === 0) { + try { + result = JSON.parse(result.replace(/'/g, '"')); + // Delete the wav file after processing + fs.unlink(wavFilePath, (err) => { + if (err) { + console.error(`Error deleting wav file: ${err}`); + } + }); + resolve(result); + } catch (e) { + reject(e); + } + } else { + reject(new Error(`Python process exited with code ${code}`)); + } + }); + }); + }); +}; + + +// decodeTwoTone("test2.m4a").then((result) => { +// console.log("Two-tone detection result:", result); +// }) // Test code + +conn.on("message", (data) => { + let msg = data.toString() + let parsed = JSON.parse(msg) + // console.log("Received message:", parsed); + switch(parsed[0]) { + case "CFG": // Config RX + const config = parsed[1]; + const lfmFilter = buildFullLfmFilter(config); + console.log("Got config from server, sending LFM filter"); + conn.send(JSON.stringify(["LFM", lfmFilter])); + break; + case "LFM": // LFM Response + if (parsed[1] == true) { + console.log("LFM filter accepted by server."); + + // TEST + // conn.send(JSON.stringify(["CAL","616","p"])) + } else { + console.error("LFM filter rejected by server:", parsed[1]); + process.exit(1); + } + break; + case "LSC": // Listener Count + console.log("Listener count:", parsed[1]); + break; + case "VER": // Ver info + console.log(`${parsed[1].branding} RDIO v${parsed[1].version}`) + conn.send(JSON.stringify(["CFG"])); + break; + case "CAL": // New call + console.log(`New Call ${globalSystems[parsed[1].system][parsed[1].talkgroup].name} call #${parsed[1].id}`); + const tgName = globalSystems[parsed[1].system][parsed[1].talkgroup].name; + fs.writeFileSync(`./temp/${parsed[1].audioName}`, Buffer.from(parsed[1].audio.data)); + decodeTwoTone(`./temp/${parsed[1].audioName}`).then((result) => { + console.log(`Two-tone detection result for ${tgName}:`, result); + // Two tone result will be array of objects for each detected twotone activation. .detected is array of two frequencies, one for each tone. Create a counter for each pair of frequencies detected. Make a message to send to discord with the tone names, so if 2 copies of RC EMS are sent, send "RC EMS (2x)" instead. For multiple, send "RC EMS (2x), RC Fire (1x)" etc. + const toneCounts = {}; + for (const activation of result) { + const detected = activation.detected; + const key = `${detected[0]}:${detected[1]}`; + if (toneCounts[key]) { + toneCounts[key]++; + } else { + toneCounts[key] = 1; + } + } + const toneMessages = []; + for (const key in toneCounts) { + const name = knownTones[key] || key; + const count = toneCounts[key]; + toneMessages.push(count > 1 ? `${name} (${count}x)` : name); + } + const message = toneMessages.join(", "); + console.log(`Two-tone summary for ${tgName}:`, message); + hook.send({ + // Username is tg name + username: globalSystems[parsed[1].system][parsed[1].talkgroup].name, + content: `${message}`, + // Upload audio file + files: [{ + attachment: `./temp/${parsed[1].audioName}`, + name: parsed[1].audioName + }] + }).then(() => { + // Delete the audio file after sending to Discord + fs.unlink(`./temp/${parsed[1].audioName}`, (err) => { + if (err) { + console.error(`Error deleting audio file: ${err}`); + } + }); + }) + }); + break; + default: + console.log("Unknown message:", JSON.stringify(parsed)); + break; + } +}); + +conn.on("open", () => { + console.log("Connected to the server."); + conn.send(JSON.stringify(["VER"])) +}); + +conn.on("close", () => { + // Set 3 second timeout to reconnect. + setTimeout(() => { + console.log("Connection closed. Reconnecting..."); + conn = new ws("wss://rosebud-mt.ko4wal.radio"); + }, 3000); +}); + +conn.on("error", (err) => { + console.error("Connection error:", err); + // Close conn, and reconnect after 3 seconds + conn.close(); + setTimeout(() => { + console.log("Reconnecting..."); + conn = new ws("wss://rosebud-mt.ko4wal.radio"); + }, 3000); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..db9ce9f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,329 @@ +{ + "name": "rdio-to-discord", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rdio-to-discord", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "ws": "^8.21.1" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", + "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.50", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.52", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.52.tgz", + "integrity": "sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz", + "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==", + "license": "MIT" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c1a763 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "rdio-to-discord", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "ws": "^8.21.1" + } +} diff --git a/rdio-docs.md b/rdio-docs.md new file mode 100644 index 0000000..ea08125 --- /dev/null +++ b/rdio-docs.md @@ -0,0 +1,1170 @@ +# 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!** diff --git a/twotonedec.py b/twotonedec.py new file mode 100644 index 0000000..41ec22c --- /dev/null +++ b/twotonedec.py @@ -0,0 +1,8 @@ + +import sys +from icad_tone_detection import tone_detect + +file_path = sys.argv[1] + +result = tone_detect(file_path) +print(result.two_tone_result) \ No newline at end of file