API Reference
Lua exports, REST endpoints and Socket.IO events for driving Tommy's Radio from your own code.
Four surfaces: Lua exports server-side and client-side, a REST API, and a Socket.IO stream. The exports need nothing enabled; REST and Socket.IO need exposeWeb = true.
Server exports
Call these as exports['tRadio']:name(...), whatever you named the folder.
Channel information
| Export | Parameters | Returns |
|---|---|---|
getSpeakersInChannel | frequency: string|number | table — server IDs connected as speakers |
getListenersInChannel | frequency | table — server IDs scanning (listen only) |
getAllUsersInChannel | frequency | table — speakers and listeners combined |
getActiveTalkersInChannel | frequency | table — server IDs holding PTT right now |
getActiveChannels | — | string[] — frequencies with at least one active user |
getChannelInfo | frequency | { speakers, listeners, activeTalkers } or nil |
getAllChannels | — | table[] — the full configured channel list |
local info = exports['tRadio']:getChannelInfo("154.755")
-- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} }getAllChannels returns one entry per channel:
{
zoneId = 1, -- 1-based index into the configured zones
zoneName = "Statewide",
channelId = 1, -- 1-based index within that zone
name = "C2C",
frequency = 856.1125,
type = "conventional" or "trunked",
frequencyRange = { 856.0, 859.0 }, -- trunked channels only
coverage = 500.0, -- nil where the channel sets none
}zoneId and channelId are positions in the config, not the stable string IDs the admin panel stores — reordering a zone changes them.
Alerts and panic
| Export | Parameters | Returns |
|---|---|---|
getChannelAlert | frequency | The active alert config, or nil |
setAlertOnChannel | frequency, enabled: boolean|nil, alertIndexOrName: number|string|nil | boolean — false when the alert list is empty or nothing matched |
setChannelSignal | frequency, enabled: boolean | true — fires the first configured alert, like the in-game Alrt key |
getChannelPanic | frequency | { [serverId] = serverId, ... } — every unit in emergency, including Silent |
getChannelPanicMeta | frequency | { [serverId] = { mode, silent, phase, source, since, acked }, ... } |
setChannelPanic | frequency, serverId, enabled: boolean, meta?: { mode?, silent? } | boolean |
getUserPanicState | serverId, frequency | boolean |
setAlertOnChannel resolves its third argument as: omitted or nil → the first alert; a number → 1-based index; a string → case-insensitive name match. enabled = nil toggles.
exports['tRadio']:setAlertOnChannel("154.755", true) -- first alert, activate
exports['tRadio']:setAlertOnChannel("154.755", true, "SIGNAL 100") -- by name
exports['tRadio']:setAlertOnChannel("154.755", nil, 2) -- toggle the second
exports['tRadio']:setChannelPanic("154.755", 5, true)setChannelPanic is a remote emergency for CAD and MDT integrations. Where the codeplug programs an emergency profile for that channel, the profile's mode and Silent setting win over meta. An emergency raised this way never auto-clears.
In getChannelPanicMeta: mode is "alarm", "call" or "alarmCall"; phase is "alarm", "call", "acked" or "noAck"; source is "button", "manDown" or "export".
Users
| Export | Parameters | Returns |
|---|---|---|
setUserChannel | serverId, frequency | boolean — handles trunked routing automatically |
disconnectUser | serverId | boolean |
isUserTalking | serverId, frequency | boolean |
getPlayerName | serverId | string |
getPlayerNacId | serverId | string or nil |
getUserInfo | serverId | { name, nacId } |
hasRadioAccess | serverId | boolean |
setUserNacId | serverId, nacId: string | boolean — display override until the next refreshNacId |
refreshNacId | serverId | boolean — re-resolves access and pushes it to the client. Call after a job change |
refreshPlayerInfo | — | number — count of players refreshed |
getAcePermissions | serverId | { connect, scan, gps, zones, dispatch } |
connect, scan and gps are frequency arrays, zones is a 1-based index array, and dispatch is a boolean (supervisor privileges).
Codeplugs and subscribers
| Export | Parameters | Returns |
|---|---|---|
getSubscriberInfo | serverId | The subscriber record, or nil |
getSubscriberZones | serverId | table[] of zone objects, or nil |
refreshSubscriber | serverId | boolean — rebuilds the whole record. More thorough than refreshNacId |
-- getSubscriberInfo
{
codeplugId = "leo",
unitId = "1041",
nac = "141",
allowedModels = { "QRX-720", "HLX-120" },
defaultLayouts = { Handheld = "QRX-720", Vehicle = "HLX-120" },
features = { gps = true, scan = true, emergencyButton = true, manDown = false },
tones = { ... },
emergency = {
activateHoldMs = 1000,
profiles = { { id = "default", name = "Default", mode = "alarmCall",
revert = "tactical", hotMicSeconds = 5 } },
assignments = { default = "default" }, -- "" = no emergency
-- legacy fields (emergencyChannelId, autoTransmit, autoTransmitDuration,
-- panicRepeatInterval) are still present, mirrored from the default profile
},
announcementVolume = 0.8,
}A player on the same codeplug keeps the same P25 unit ID across sessions and restarts.
AI dispatcher
| Export | Parameters | Returns |
|---|---|---|
getUnitStatus | serverId | { value: string, at: number } — the last status this unit said and the Unix time. nil if unset |
setUnitStatus | serverId, value: string | boolean — false if empty or over 48 characters after cleanup |
refreshAiDispatchTools | — | number — the tool count after rebuilding the registry |
A status is whatever a unit said — there is no allow-list. setUnitStatus trims whitespace and drops trailing punctuation, so "10-8." and "10-8" are the same. Every change fires tradio:ai:unitStatusChanged(serverId, value) as a normal server event.
refreshAiDispatchTools is needed only if you change what Config.aiDispatchTools returns after startup.
Callsigns and audio
Callsign exports require Enable Callsigns in the admin panel.
| Export | Parameters | Returns |
|---|---|---|
setCallsign | serverId, callsign: string|nil — "" or nil clears | boolean — false if callsigns are off or validation failed |
getCallsign | serverId | string or nil — always nil while callsigns are off |
playToneOnChannel | frequency, tone: string | true |
playToneOnSource | serverId, tone: string | true |
Tone keys come from the tone catalog. Both tone exports return true as soon as the request is dispatched, so a typo returns true and plays nothing. Check the key against the catalog, not the return value.
Client exports
Radio control
| Export | Parameters | Returns |
|---|---|---|
openRadio | focus: boolean (default true) | — |
closeRadio | — | — |
connectToFrequency | frequency | boolean |
setCurrentChannel | frequency | — |
getCurrentFrequency | — | frequency, or -1 when not connected |
getCurrentChannel | — | channel object |
addListeningChannel | frequency | — |
removeListeningChannel | frequency | — |
getListeningChannels | — | string[] — frequencies the radio is listening to now |
Scan exports. A radio scans the scan list of the channel it is on (see Scanning), and these edit that list.
addListeningChanneladds the channel (or restores a nuisance-deleted one) and turns scanning on. For a channel already scanned it does nothing, so it is safe to repeat. Nothing is added if the current channel has no scan list, or the frequency is the channel you are onremoveListeningChannelremoves the channel and never turns scanning offgetListeningChannelsis empty while scan is off, even with entries in the list
tradio:client:openRadio also fires as a client event when a player uses the radio item — handle it yourself only if Config.inventory.system = 'custom'.
Transmission and state
| Export | Parameters | Returns |
|---|---|---|
startTransmitting / stopTransmitting | — | — |
setTalking | talking: boolean | — — same as start/stop |
isTransmitting | — | boolean |
getActiveTalker | — | { serverId, name, frequency } or nil |
activateProximityVoice | — | — — triggers in-game proximity voice |
isConnected / isPowerOn / isRadioOpen / isRadioFocused | — | boolean |
setPower | state — omit to toggle | — |
Volume, appearance and information
| Export | Parameters | Returns |
|---|---|---|
setVolume / getVolume | 0.0–1.0 | — / number |
setToneVolume / getToneVolume | 0.0–1.0 | — / number |
set3DVolume / get3DVolume | 0.0–1.0 | — / number |
playTone | tone: string | — — plays locally only |
NotifySirenChanged | — | — — forces an immediate re-poll of bgModeCheck |
setRadioLayout / getRadioLayout | model: string | — / string |
setRadioTheme / getRadioTheme | "Auto"|"Light"|"Dark" | — / string |
setAnimationId / getAnimationId | id: string | — / string |
setEarbudsEnabled / getEarbudsEnabled | boolean | — / boolean |
setGPSEnabled / getGPSEnabled | boolean | — / boolean |
getCurrentName / getCurrentNacId / getZone | — | string |
refreshMyNacId | — | — — re-fetches from the server |
getMyCallsign / setMyCallsign | string|nil / — ("" clears) | string|nil |
getBatteryLevel | — | number (0–100) |
getSignalStrength | — | number (0–5) |
getConnectionDiagnostics | — | diagnostic snapshot table |
Alerts and panic
| Export | Parameters |
|---|---|
triggerAlertOnChannel | frequency, enabled: boolean, alertConfig? |
panicButton | frequency, enabled: boolean — asks the server to raise or clear this player's emergency directly, without the button hold or revert channel. The server still applies the codeplug's profile |
Web API
Requires Config.exposeWeb = true and confirmed publication through tLib. The public base is <your tLib URL>/dispatch; paths below are relative to it. Examples use http://203.0.113.10:7777/dispatch.
Authenticating
Protected endpoints need a per-session token obtained at runtime. This is not Config.authToken.
curl -X POST http://203.0.113.10:7777/dispatch/radio/dispatch/auth \
-H "Content-Type: application/json" \
-d '{"nacId": "4417", "callsign": "Dispatcher-01"}'import requests
res = requests.post(
"http://203.0.113.10:7777/dispatch/radio/dispatch/auth",
json={"nacId": "4417", "callsign": "Dispatcher-01"},
).json()
auth_token, session_id = res["authToken"], res["sessionId"]const res = await fetch("http://203.0.113.10:7777/dispatch/radio/dispatch/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nacId: "4417", callsign: "Dispatcher-01" }),
}).then((r) => r.json());
const { authToken, sessionId } = res;{
"success": true,
"sessionId": "dispatch_abc123",
"authToken": "550e8400-e29b-41d4-a716-446655440000"
}Send both headers on every protected request:
Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
x-session-id: dispatch_abc123
nacIdmust matchdispatchNacIdinconfig.lua- Sessions last 24 hours; at most 100 exist at once, oldest evicted first
- Authentication is rate-limited to 15 attempts per 15 minutes per IP
- With Discord auth enabled the same endpoint returns
202and aredirectUri - After a server restart, re-issue a session with
POST /radio/dispatch/reauth, sending only theAuthorizationheader
Endpoint prefixes
| Prefix | Purpose |
|---|---|
/api/ | General integrations — bots, CAD, external monitoring |
/radio/dispatch/ | Full dispatcher presence and real-time state |
/dispatch/ | Unit management — controlling individual in-game players |
Endpoints
Public, no auth:
| Method | Path | Description |
|---|---|---|
GET | /api/health | { status, service, serviceName, authMethod }. Used by the startup health check |
GET | /api/locale | The string table — shipped defaults with your config/locales.json merged over them |
GET | /radio/dispatch/installer | Windows desktop app installer. Rate-limited to 5/min |
GET | /radio/dispatch/update.json | Desktop app auto-updater manifest |
Status and config:
| Method | Path | Description |
|---|---|---|
GET | /radio/dispatch/status | Users, channels, panic, alerts, patches. Prefer this over /api/status — it is a superset and the actively maintained one. panicMeta gives each unit in emergency as { [frequency]: { [serverId]: { mode, silent, phase, source, since, acked } } } |
GET | /api/status | A subset of the above |
GET | /radio/dispatch/config | Zones, alerts, volumes, FX settings, installed themes |
GET | /radio/dispatch/tones | Available tone list |
Broadcasts, tones and alerts:
| Method | Path | Body |
|---|---|---|
POST | /radio/dispatch/broadcast | { message, frequency?, type?, tone? } |
POST | /api/trigger-broadcast | { message, frequency?, type? } — no tone field |
POST | /radio/dispatch/tone | { frequency, tone } |
POST | /api/play-tone | Alias of the above |
POST | /radio/dispatch/speaker-tone | { targetId, tone } — a world speaker, or every speaker in a group |
POST | /radio/dispatch/alert/trigger | { frequency, alertType, alertConfig } |
POST | /radio/dispatch/alert/clear | { frequency } |
POST | /radio/dispatch/alert/oneshot | { frequency, alertConfig } — fire once without persisting |
Channels, units and patches:
| Method | Path | Body |
|---|---|---|
POST | /radio/dispatch/switchChannel | { serverId, frequency, oldFrequency } — move an in-game player |
POST | /dispatch/user/alert | { userId, message, frequency } |
POST | /dispatch/user/disconnect | { userId } |
POST | /dispatch/user/set-player-callsign | { userId, callsign } |
POST | /dispatch/user/update-callsign | { callsign, userId } — a dispatcher's callsign; userId must be negative |
GET | /api/patches | — |
POST | /api/patch/create | { label, frequencies: ["154.755", "460.25"] } |
POST | /api/patch/remove | { id } |
SESSION_TOKEN="550e8400-e29b-41d4-a716-446655440000"
SESSION_ID="dispatch_abc123"
curl http://203.0.113.10:7777/dispatch/radio/dispatch/status \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "x-session-id: $SESSION_ID"
curl -X POST http://203.0.113.10:7777/dispatch/api/patch/create \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "x-session-id: $SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"label": "TAC1-FIRE", "frequencies": ["154.755", "460.25"]}'import requests
headers = {"Authorization": f"Bearer {auth_token}", "x-session-id": session_id}
status = requests.get(
"http://203.0.113.10:7777/dispatch/radio/dispatch/status", headers=headers
).json()
requests.post(
"http://203.0.113.10:7777/dispatch/api/patch/create",
headers=headers,
json={"label": "TAC1-FIRE", "frequencies": ["154.755", "460.25"]},
)Error responses
All errors are application/json.
| Status | Body | Cause |
|---|---|---|
400 | { "error": "..." } | Missing or invalid parameters |
401 | { "error": "Authentication required" } | Missing or invalid auth headers |
404 | { "error": "Channel not found" } | Unknown frequency |
429 | { "error": "Too many..." } | Rate limited |
500 | { "error": "..." } | Server error |
Frequencies accept strings ("154.755") or numbers (154.755) everywhere. Dispatcher serverId values must be negative; in-game player IDs are positive. CORS is enabled for all origins.
Socket.IO
Connect to appear as a dispatcher and receive real-time events.
const res = await fetch("http://203.0.113.10:7777/dispatch/radio/dispatch/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nacId: "4417", callsign: "Bot-01" }),
}).then((r) => r.json());
const socket = io("http://203.0.113.10:7777", {
path: "/dispatch/socket.io",
auth: {
authToken: res.authToken, // per-session UUID — not Config.authToken
sessionId: res.sessionId,
serverId: -(1000 + Math.floor(Math.random() * 10000)), // must be negative
},
});Emit — client to server
| Event | Payload | Description |
|---|---|---|
setDispatchSession | sessionId: string | Link the socket to the auth session |
updateUserInfo | { name, nacId } | Set dispatcher identity |
setSpeakerChannel | frequency: string | Join a channel to speak |
addListeningChannel / removeListeningChannel | frequency: string | Start or stop scanning |
listenToUser / stopListeningToUser | targetServerId: number | Follow a user across any frequency |
setTalking | { state, frequency?, override? } | PTT. override: true bypasses half-duplex blocking and is audit-logged |
voice | { data, encoding?, frequency? } | Base64 audio packet; encoding: "imbe" for P25 |
panic:acknowledge | { channelId, serverId? } | Acknowledge one unit's emergency; without serverId, every unit on the channel. Audit-logged |
panic:clear | { channelId, serverId } | End one unit's emergency. Dispatcher sessions only; audit-logged |
heartbeat | Date.now() | Keep-alive |
Listen — server to client
| Event | Payload | Fires when |
|---|---|---|
voice | { serverId, frequency, data, encoding?, receiveType, patchId? } | A voice packet is relayed |
talkingState | { serverId, frequency, state } | PTT starts or stops |
speakerJoined / speakerLeft | { serverId, frequency, name?, nacId? } | A speaker joins or leaves |
listenerJoined / listenerLeft | { serverId, frequency } | A listener joins or leaves |
channelState | { frequency, speakers, listeners, activeTalkers, empty } | Full channel snapshot |
patchStatus | { patchId, active, label, frequencies } | A patch is created or removed |
serverTone | { tone, frequency } | A tone plays on a channel |
dispatchNotification | { type, ...payload } | Cross-dispatcher notification |
playerInfoUpdate | { serverId, name?, nacId?, unitId? } | Player identity changes |
configUpdate | { ... } | Slim config re-broadcast, no zones or alerts inline |
configRefresh | {} | Re-fetch /radio/dispatch/config |
ptt:denied | { frequency, reason, currentTalker? } | PTT refused. reason is busy, rate_limit, direct-endpoint-unavailable or scan-only. An in-game talkback transmission can also be refused with invalid-frequency, talkback-unavailable, not-allowed or not-listening |
panicState | { frequency, active, players, meta } | A unit's emergency starts, changes phase, is acknowledged, moves or clears. A unit missing from an active update has left the emergency |
panic:acknowledged | { channelId, serverId? } | A dispatcher acknowledged one unit, or the channel |
Transmitting voice
The ordering, pacing and size rules a client must keep to be heard. A client that breaks them is usually dropped silently — most of these limits have no refusal event.
Connect
Authenticate, then open the socket with authToken, sessionId and a negative serverId.
Register the session
Emit setDispatchSession with the sessionId the handshake carried. A different one is ignored; a socket that never authenticated a session is disconnected. Then emit updateUserInfo.
Tune a channel
Emit setSpeakerChannel. Voice sent before this has nowhere to be routed.
Key up
Emit setTalking — { state: true }, or with a frequency to transmit on a channel other than your speaker channel. Wait a moment for a refusal: if ptt:denied arrives, every packet you send is discarded.
Stream packets
Emit voice roughly every 300 ms, in real time. This is the step third-party clients get wrong.
Unkey
Emit setTalking with { state: false }, and the same frequency if you sent one on key-up. Nothing else ends a transmission cleanly.
Send heartbeat every 5 seconds for the whole session.
The voice payload
| Field | Type | Notes |
|---|---|---|
data | base64 string | Required. One self-contained audio chunk |
encoding | string | "imbe" selects the P25 vocoder path. Anything else, including omitting it, is treated as Opus |
frequency | string|number | Only honored for dispatchers, and only when it matches the frequency passed to setTalking |
signal | number | Optional signal bars, 1–5, relayed to listeners so they can degrade the audio. Anything outside that range becomes 5 |
Each non-IMBE packet must be a complete, independently decodable container — a WebM/Opus chunk, not a slice of one continuous stream.
Pacing and limits
A receiving radio queues at most 0.5 seconds of audio per talker and discards from the front beyond that, with no event, so a whole reply sent in one burst is mostly thrown away before it plays. Send about 300 ms of audio every 300 ms.
A transmission ends on the receiving end 450 ms after its queue runs dry, or 600 ms for encoding: "imbe". A longer gap splits one transmission into two for every listener.
| Limit | Value | When you exceed it |
|---|---|---|
voice payload size | 65,536 | Packet dropped. Silent |
voice rate | 100 per second, per socket | Excess packets dropped. Silent |
| Control events | 120 per 10 seconds (admin-tunable) | setTalking with state: true gets ptt:denied with reason: "rate_limit"; other control events are dropped silently |
| Transmission length | No limit | Nothing — but see the callout below |
setTalking with state: false and heartbeat are exempt from the control-event limit, so an unkey is never throttled.
Events you must handle
| Event | What it means |
|---|---|
ptt:denied | Your key-up was refused. Stop — nothing you send will be routed |
radio:forcedStopTalking | The server cleared your talk state and released the frequency. Every later voice packet is dropped silently until you key up again. reason is voice-timeout, preempted (by is the other unit's server ID), channel-switch (frequency is the channel you were keyed on, not the new one), or talk-grant-lost |
voice:notRegistered | { reason: "no-speaker-channel" } — the server holds no speaker channel for you. Re-send setSpeakerChannel. Rate-limited to one per 2 seconds, so treat one as a burst |
Five things get dropped silently: voice sent without a granted setTalking(true); voice sent after a force-stop; a packet over the size cap; a burst over 100/second; and a setSpeakerChannel changed mid-transmission, which clears your talk state. Unkey before you retune.
A minimal send loop
const CHUNK_MS = 300;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// `chunks` is an array of base64 WebM/Opus containers, each ~300 ms of audio,
// each independently decodable.
async function transmit(socket, frequency, chunks) {
let halted = null;
const onDenied = (d) => { halted = d; };
const onForced = () => { halted = { reason: "forced-stop" }; };
socket.on("ptt:denied", onDenied);
socket.on("radio:forcedStopTalking", onForced);
try {
socket.emit("setTalking", { state: true, frequency });
await sleep(50); // let a denial land before the first packet
if (halted) return halted;
const started = Date.now();
for (let i = 0; i < chunks.length; i++) {
if (halted) break;
socket.emit("voice", { data: chunks[i], encoding: "opus", frequency });
// Pace to real time. Never run ahead of the audio you are sending —
// the receiver only queues 0.5 s and drops the oldest beyond that.
await sleep(Math.max(0, started + (i + 1) * CHUNK_MS - Date.now()));
}
} finally {
socket.emit("setTalking", { state: false, frequency });
socket.off("ptt:denied", onDenied);
socket.off("radio:forcedStopTalking", onForced);
}
return halted;
}The finally block matters: an unkey that never goes out leaves the channel held until the liveness sweep clears it, and half-duplex blocks every other unit on that channel meanwhile.
Next steps
- Restyling or replacing the dispatch panel: Dispatch panel theming
- Config keys and admin-panel settings: Configuration
- Something not behaving: Troubleshooting
Tommy's Radio
Still stuck? Support can answer it directly.
Was this page helpful?
TIMMYG Studios