Skip to content

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

ExportParametersReturns
getSpeakersInChannelfrequency: string|numbertable — server IDs connected as speakers
getListenersInChannelfrequencytable — server IDs scanning (listen only)
getAllUsersInChannelfrequencytable — speakers and listeners combined
getActiveTalkersInChannelfrequencytable — server IDs holding PTT right now
getActiveChannels—string[] — frequencies with at least one active user
getChannelInfofrequency{ speakers, listeners, activeTalkers } or nil
getAllChannels—table[] — the full configured channel list
lua
local info = exports['tRadio']:getChannelInfo("154.755")
-- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} }

getAllChannels returns one entry per channel:

lua
{
    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

ExportParametersReturns
getChannelAlertfrequencyThe active alert config, or nil
setAlertOnChannelfrequency, enabled: boolean|nil, alertIndexOrName: number|string|nilboolean — false when the alert list is empty or nothing matched
setChannelSignalfrequency, enabled: booleantrue — fires the first configured alert, like the in-game Alrt key
getChannelPanicfrequency{ [serverId] = serverId, ... } — every unit in emergency, including Silent
getChannelPanicMetafrequency{ [serverId] = { mode, silent, phase, source, since, acked }, ... }
setChannelPanicfrequency, serverId, enabled: boolean, meta?: { mode?, silent? }boolean
getUserPanicStateserverId, frequencyboolean

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.

lua
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

ExportParametersReturns
setUserChannelserverId, frequencyboolean — handles trunked routing automatically
disconnectUserserverIdboolean
isUserTalkingserverId, frequencyboolean
getPlayerNameserverIdstring
getPlayerNacIdserverIdstring or nil
getUserInfoserverId{ name, nacId }
hasRadioAccessserverIdboolean
setUserNacIdserverId, nacId: stringboolean — display override until the next refreshNacId
refreshNacIdserverIdboolean — re-resolves access and pushes it to the client. Call after a job change
refreshPlayerInfo—number — count of players refreshed
getAcePermissionsserverId{ 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

ExportParametersReturns
getSubscriberInfoserverIdThe subscriber record, or nil
getSubscriberZonesserverIdtable[] of zone objects, or nil
refreshSubscriberserverIdboolean — rebuilds the whole record. More thorough than refreshNacId
lua
-- 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

ExportParametersReturns
getUnitStatusserverId{ value: string, at: number } — the last status this unit said and the Unix time. nil if unset
setUnitStatusserverId, value: stringboolean — 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.

ExportParametersReturns
setCallsignserverId, callsign: string|nil — "" or nil clearsboolean — false if callsigns are off or validation failed
getCallsignserverIdstring or nil — always nil while callsigns are off
playToneOnChannelfrequency, tone: stringtrue
playToneOnSourceserverId, tone: stringtrue

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

ExportParametersReturns
openRadiofocus: boolean (default true)—
closeRadio——
connectToFrequencyfrequencyboolean
setCurrentChannelfrequency—
getCurrentFrequency—frequency, or -1 when not connected
getCurrentChannel—channel object
addListeningChannelfrequency—
removeListeningChannelfrequency—
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.

  • addListeningChannel adds 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 on
  • removeListeningChannel removes the channel and never turns scanning off
  • getListeningChannels is 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

ExportParametersReturns
startTransmitting / stopTransmitting——
setTalkingtalking: boolean— — same as start/stop
isTransmitting—boolean
getActiveTalker—{ serverId, name, frequency } or nil
activateProximityVoice—— — triggers in-game proximity voice
isConnected / isPowerOn / isRadioOpen / isRadioFocused—boolean
setPowerstate — omit to toggle—

Volume, appearance and information

ExportParametersReturns
setVolume / getVolume0.0–1.0— / number
setToneVolume / getToneVolume0.0–1.0— / number
set3DVolume / get3DVolume0.0–1.0— / number
playTonetone: string— — plays locally only
NotifySirenChanged—— — forces an immediate re-poll of bgModeCheck
setRadioLayout / getRadioLayoutmodel: string— / string
setRadioTheme / getRadioTheme"Auto"|"Light"|"Dark"— / string
setAnimationId / getAnimationIdid: string— / string
setEarbudsEnabled / getEarbudsEnabledboolean— / boolean
setGPSEnabled / getGPSEnabledboolean— / boolean
getCurrentName / getCurrentNacId / getZone—string
refreshMyNacId—— — re-fetches from the server
getMyCallsign / setMyCallsignstring|nil / — ("" clears)string|nil
getBatteryLevel—number (0–100)
getSignalStrength—number (0–5)
getConnectionDiagnostics—diagnostic snapshot table

Alerts and panic

ExportParameters
triggerAlertOnChannelfrequency, enabled: boolean, alertConfig?
panicButtonfrequency, 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.

bash
curl -X POST http://203.0.113.10:7777/dispatch/radio/dispatch/auth \
  -H "Content-Type: application/json" \
  -d '{"nacId": "4417", "callsign": "Dispatcher-01"}'
json
{
  "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
  • nacId must match dispatchNacId in config.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 202 and a redirectUri
  • After a server restart, re-issue a session with POST /radio/dispatch/reauth, sending only the Authorization header

Endpoint prefixes

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

MethodPathDescription
GET/api/health{ status, service, serviceName, authMethod }. Used by the startup health check
GET/api/localeThe string table — shipped defaults with your config/locales.json merged over them
GET/radio/dispatch/installerWindows desktop app installer. Rate-limited to 5/min
GET/radio/dispatch/update.jsonDesktop app auto-updater manifest

Status and config:

MethodPathDescription
GET/radio/dispatch/statusUsers, 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/statusA subset of the above
GET/radio/dispatch/configZones, alerts, volumes, FX settings, installed themes
GET/radio/dispatch/tonesAvailable tone list

Broadcasts, tones and alerts:

MethodPathBody
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-toneAlias 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:

MethodPathBody
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 }
bash
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"]}'

Error responses

All errors are application/json.

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

javascript
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

EventPayloadDescription
setDispatchSessionsessionId: stringLink the socket to the auth session
updateUserInfo{ name, nacId }Set dispatcher identity
setSpeakerChannelfrequency: stringJoin a channel to speak
addListeningChannel / removeListeningChannelfrequency: stringStart or stop scanning
listenToUser / stopListeningToUsertargetServerId: numberFollow 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
heartbeatDate.now()Keep-alive

Listen — server to client

EventPayloadFires 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

FieldTypeNotes
database64 stringRequired. One self-contained audio chunk
encodingstring"imbe" selects the P25 vocoder path. Anything else, including omitting it, is treated as Opus
frequencystring|numberOnly honored for dispatchers, and only when it matches the frequency passed to setTalking
signalnumberOptional 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.

LimitValueWhen you exceed it
voice payload size65,536Packet dropped. Silent
voice rate100 per second, per socketExcess packets dropped. Silent
Control events120 per 10 seconds (admin-tunable)setTalking with state: true gets ptt:denied with reason: "rate_limit"; other control events are dropped silently
Transmission lengthNo limitNothing — 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

EventWhat it means
ptt:deniedYour key-up was refused. Stop — nothing you send will be routed
radio:forcedStopTalkingThe 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

javascript
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

Still stuck? Support can answer it directly.

Was this page helpful?