Server Exports#
Export Parameters Returns getSpeakersInChannelfrequency: string|numbertable — server IDs connected as speakersgetListenersInChannelfrequency: string|numbertable — server IDs scanning (listen-only)getAllUsersInChannelfrequency: string|numbertable — speakers + listeners combinedgetActiveTalkersInChannelfrequency: string|numbertable — server IDs with PTT held right nowgetActiveChannels— string[] — frequencies with at least one active usergetChannelInfofrequency: string|number{ speakers, listeners, activeTalkers } or nilgetAllChannels— table[] — full channel list from config (zones, names, types, etc.)
local speakers = exports[ 'tRadio' ]: getSpeakersInChannel ( "154.755" )
local info = exports[ 'tRadio' ]: getChannelInfo ( "154.755" )
-- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} } Copy
Alerts & Panic#
Export Parameters Returns getChannelAlertfrequencyalert config object or nil setAlertOnChannelfrequency, enabled: boolean|nil, alertIndexOrName: number|string|nil— getChannelPanicfrequency{ [serverId] = true, ... }setChannelPanicfrequency, serverId, enabled: boolean— getUserPanicStateserverId, frequencyboolean
setAlertOnChannel resolution rules:
alertIndexOrName omitted or nil → first alert in config
number → 1-based index into the alerts array
string → case-insensitive name match
enabled = nil → toggle current state
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 second alert
exports[ 'tRadio' ]: setChannelPanic ( "154.755" , 5 , true ) Copy
User Management#
Export Parameters Returns setUserChannelserverId: number, frequency: string|numberbooleandisconnectUserserverId: numberbooleanisUserTalkingserverId: number, frequency: string|numberboolean
setUserChannel handles trunked channel routing automatically.
local ok = exports[ 'tRadio' ]: setUserChannel ( 5 , "460.125" )
exports[ 'tRadio' ]: disconnectUser ( 5 ) Copy
Export Parameters Returns getPlayerNameserverIdstringgetPlayerNacIdserverIdstring or nilgetUserInfoserverId{ name, nacId }hasRadioAccessserverIdbooleansetUserNacIdserverId, nacId: stringboolean — overrides until next refreshNacIdrefreshNacIdserverIdboolean — re-fetches NAC ID from source; call after job changesrefreshPlayerInfo— number — count of players refreshedgetAcePermissionsserverId{ connect, scan, gps, zones, dispatch }
local perms = exports[ 'tRadio' ]: getAcePermissions ( 5 )
-- { connect = true, scan = true, gps = false, zones = true, dispatch = false } Copy
Codeplug / Subscriber#
Export Parameters Returns getSubscriberInfoserverIdsubscriber record or nil getSubscriberZonesserverIdtable[] of zone objects or nilrefreshSubscriberserverIdboolean — rebuilds full subscriber record; more thorough than refreshNacId
getSubscriberInfo returns:
{
codeplugId = "leo" ,
unitId = "141" ,
nac = 0x293 ,
radioModel = "APX8000" ,
features = { ... },
tones = { ... },
emergency = { ... },
announcementVolume = 0.8 ,
} Copy
Callsigns#
Requires useCallsignSystem = true in the admin panel.
Export Parameters Returns setCallsignserverId, callsign: string|nil — pass "" or nil to clear— getCallsignserverIdstring or nil
exports[ 'tRadio' ]: setCallsign ( 5 , "L-141" )
local cs = exports[ 'tRadio' ]: getCallsign ( 5 ) -- "L-141" Copy
Audio#
Export Parameters Returns playToneOnChannelfrequency, tone: string— playToneOnSourceserverId, tone: string—
exports[ 'tRadio' ]: playToneOnChannel ( "154.755" , "ALERT" )
exports[ 'tRadio' ]: playToneOnSource ( 5 , "BEEP" ) Copy
Client Exports#
All volume exports use 0.0–1.0 . The admin panel and config.lua use 0–100 — divide by 100
before passing those values to any volume export.
Radio Control#
Export Parameters Returns openRadiofocus: boolean (default true)— closeRadio— — connectToFrequencyfrequency: string|numberbooleangetCurrentFrequency— frequency string/number, or -1 if not connected getCurrentChannel— channel object addListeningChannelchannel— removeListeningChannelchannel— getListeningChannels— string[] — active scan frequencies
exports[ 'tRadio' ]: openRadio () -- open with NUI focus
exports[ 'tRadio' ]: openRadio ( false ) -- open without focus
local ok = exports[ 'tRadio' ]: connectToFrequency ( "154.755" ) Copy
Transmission#
Export Parameters Returns startTransmitting— — stopTransmitting— — isTransmitting— booleangetActiveTalker— { serverId, name, frequency } or nilsetTalkingtalking: boolean— — UI feedback only, does not affect voice
exports[ 'tRadio' ]: startTransmitting ()
local talker = exports[ 'tRadio' ]: getActiveTalker ()
-- { serverId = 5, name = "John Smith", frequency = "154.755" } Copy
Radio State#
Export Parameters Returns isConnected— booleanisPowerOn— booleansetPowerstate: boolean|nil (omit to toggle)— isRadioOpen— booleanisRadioFocused— boolean
Volume & Audio#
Export Parameters Returns setVolumevolume: number (0.0–1.0)— getVolume— number (0.0–1.0)setToneVolumevolume: number (0.0–1.0)— getToneVolume— number (0.0–1.0)set3DVolumevolume: number (0.0–1.0)— get3DVolume— number (0.0–1.0)playTonetone: string— — plays locally NotifySirenChanged— — — forces immediate re-poll of bgModeCheck; call after siren state changes in custom ELS
exports[ 'tRadio' ]: setVolume ( 0.75 )
exports[ 'tRadio' ]: playTone ( "ALERT" )
exports[ 'tRadio' ]: NotifySirenChanged () Copy
Appearance & Settings#
Export Parameters Returns setRadioLayoutlayout: string— getRadioLayout— stringsetRadioThemetheme: string— getRadioTheme— stringsetAnimationIdanimId: string— getAnimationId— stringsetEarbudsEnabledenabled: boolean— getEarbudsEnabled— booleansetGPSEnabledenabled: boolean— getGPSEnabled— boolean
Alerts & Panic (Client)#
Export Parameters Returns triggerAlertOnChannelfrequency, enabled: boolean— panicButtonfrequency, enabled: boolean—
Export Parameters Returns getCurrentName— stringgetCurrentNacId— stringrefreshMyNacId— — — re-fetches from server getMyCallsign— string or nilsetMyCallsigncallsign: string — pass "" to clear— getZone— string — current zone name
System#
Export Parameters Returns getBatteryLevel— number (0–100)getSignalStrength— signal strength value getConnectionDiagnostics— diagnostic snapshot table
Web API#
Authentication#
All protected endpoints require a per-session authToken obtained at runtime. This is not Config.authToken from config.lua.
Step 1 — create a session:
curl -X POST http://192.0.2.100:7777/radio/dispatch/auth \
-H "Content-Type: application/json" \
-d '{"nacId": "141", "callsign": "Dispatcher-01"}'
Response:
{
"success" : true ,
"sessionId" : "dispatch_abc123" ,
"authToken" : "550e8400-e29b-41d4-a716-446655440000"
} Copy
nacId must match dispatchNacId in config.lua
Sessions expire after 24 hours
To re-issue a session after a server restart: POST /radio/dispatch/reauth with Authorization header only (no x-session-id)
Step 2 — authenticate all protected requests:
Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
x-session-id: dispatch_abc123
Endpoint Prefix Guide#
Prefix Purpose /api/General server integrations — bots, CAD, external monitoring /radio/dispatch/Full dispatcher presence and real-time state /dispatch/Unit management — controlling individual in-game players
Prefer GET /radio/dispatch/status over GET /api/status for all external integrations — it is a
superset and actively maintained.
Public Endpoints#
Method Path Description GET/api/healthServer status, version, auth method GET/radio/dispatch/installerWindows desktop app installer (.exe) GET/radio/dispatch/update.jsonAuto-updater manifest
Protected Endpoints#
Status & Config#
Method Path Body Description GET/radio/dispatch/status— Users, channels, panic, alerts, patches (preferred) GET/api/status— Subset of /radio/dispatch/status GET/radio/dispatch/config— Zones, alerts, volumes, FX settings, installed themes GET/radio/dispatch/tones— Available tone list
Broadcast & Tones#
Method Path Body Description POST/radio/dispatch/broadcast{ message, frequency?, type?, tone? }Send broadcast to channel or all POST/api/trigger-broadcast{ message, frequency?, type? }Broadcast (no tone field) POST/radio/dispatch/tone{ frequency, tone }Play tone on a channel POST/api/play-tone{ frequency, tone }Play tone (alias)
Alerts#
Method Path Body Description POST/radio/dispatch/alert/trigger{ frequency, alertType, alertConfig }Activate a named alert POST/radio/dispatch/alert/clear{ frequency }Clear active alert POST/radio/dispatch/alert/oneshot{ frequency, alertConfig }Fire alert once without persisting
Channel & User Control#
Method Path Body Description POST/radio/dispatch/switchChannel{ serverId, frequency, oldFrequency }Move in-game player to a channel POST/dispatch/user/alert{ userId, message, frequency }Send alert message to a player POST/dispatch/user/disconnect{ userId }Disconnect a player from the radio POST/dispatch/user/set-player-callsign{ userId, callsign }Set in-game player callsign (requires useCallsignSystem) POST/dispatch/user/update-callsign{ callsign, userId }Update callsign for a dispatch user (userId must be negative)
Patches#
Method Path Body Description GET/api/patches— List active patches POST/api/patch/create{ label, frequencies: ["154.755", "460.125"] }Create a cross-channel patch POST/api/patch/remove{ id }Remove a patch by ID
Session#
Method Path Headers Description POST/radio/dispatch/reauthAuthorization onlyRe-issue session after server restart
SESSION_TOKEN = "550e8400-e29b-41d4-a716-446655440000"
SESSION_ID = "dispatch_abc123"
# Status
curl http://192.0.2.100:7777/radio/dispatch/status \
-H "Authorization: Bearer $SESSION_TOKEN " \
-H "x-session-id: $SESSION_ID "
# Create patch
curl -X POST http://192.0.2.100:7777/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.125"]}'
# Play tone
curl -X POST http://192.0.2.100:7777/radio/dispatch/tone \
-H "Authorization: Bearer $SESSION_TOKEN " \
-H "x-session-id: $SESSION_ID " \
-H "Content-Type: application/json" \
-d '{"frequency": "154.755", "tone": "ALERT"}'
Socket.IO#
Connect to appear as a dispatcher and receive real-time channel events.
const res = await fetch ( "http://192.0.2.100:7777/radio/dispatch/auth" , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ nacId: "141" , callsign: "Bot-01" }),
}). then (( r ) => r. json ());
const socket = io ( "http://192.0.2.100:7777" , {
auth: {
authToken: res.authToken, // per-session UUID — not Config.authToken
sessionId: res.sessionId,
serverId: - ( 1000 + Math. floor (Math. random () * 10000 )), // must be negative
},
}); Copy
Emit (client → server)#
Event Payload Description setDispatchSessionsessionId: stringLink socket to auth session updateUserInfo{ name: string, nacId: string }Set dispatcher identity setSpeakerChannelfrequency: stringJoin channel to speak addListeningChannelfrequency: stringStart scanning a channel removeListeningChannelfrequency: stringStop scanning a channel listenToUsertargetServerId: numberFollow a specific user across any frequency stopListeningToUsertargetServerId: numberStop following a specific user setTalking{ state: boolean, frequency?: string, override?: boolean }PTT. override: true bypasses blockTransmission (audit-logged) voice{ data: string, encoding?: string, frequency?: string }Base64 Opus packet; encoding: "imbe" for P25 heartbeatDate.now()Keep-alive
Listen (server → client)#
Event Payload Description voice{ serverId, frequency, data, encoding?, receiveType, patchId? }Relayed voice packet talkingState{ serverId, frequency, state: boolean }PTT start/stop speakerJoined{ serverId, frequency, name?, nacId? }Speaker joined channel speakerLeft{ serverId, frequency, name?, nacId? }Speaker left channel listenerJoined{ serverId, frequency }Listener joined channel listenerLeft{ serverId, frequency }Listener left channel channelState{ frequency, speakers, listeners, activeTalkers, empty }Full channel state snapshot patchStatus{ patchId, active: boolean, label, frequencies }Patch created or removed serverTone{ tone: string, frequency: string }Tone played on channel dispatchNotification{ type: string, ...payload }Cross-dispatcher notification playerInfoUpdate{ serverId, name?, nacId?, unitId? }Player identity changed configUpdate{ ... }Slim config re-broadcast — no zones/alerts inline configRefresh{}Re-fetch /radio/dispatch/config ptt:denied{ frequency, reason: string, currentTalker? }PTT denied — channel busy (blockTransmission = true)
Dispatch Theme JavaScript API#
JS themes run directly in the dispatch panel with full DOM access. Dispatchers are shown the
complete source code and must explicitly accept a warning before installation.
Theme JavaScript receives three injected globals:
Global Purpose dispatchRead state, subscribe to events, call actions themeCSS helpers, DOM control onUnmount(fn)Register cleanup for when the theme changes or unmounts
Reading State#
dispatch.getState() returns a plain-object snapshot of the current store. Does not set up reactive subscriptions.
const s = dispatch. getState (); Copy
Field Type Description channelstringCurrently monitored frequency scannedChannelsstring[]Additional scanned frequencies zonesZone[]Full zone → channel → user tree pttActivebooleanGlobal PTT active channelPttActivebooleanPer-channel PTT active channelPttFrequencystringFrequency for active channel PTT voiceStatusstringMUTED | LISTENING | TRANSMITTING | TX (IMBE) | TX (Opus)micReadyboolean | nullMicrophone availability activeAlertsRecord<string, string>{ [frequency]: alertName }alertTypesAlertType[]Available alert configs tones{ id, name }[]Available tones for broadcast alerts patchesPatch[]Active frequency patches transmissionsTransmissionEntry[]Last 50 transmission log entries announcementGroupsAnnouncementGroup[]Group definitions activeAnnouncementGroupstring | nullCurrently armed group ID settingsDispatchSettingsPTT bindings, volumes, callsign callsignstringDispatcher callsign serverNamestringConnected server name connectedbooleanSocket connection state healthstringconnected | stale | disconnectedauthenticatedbooleanAuth state
Zone shape:
{
id : string, name : string,
channels : [{
id: string, frequency: string, name: string,
type: string, // "conventional" | "trunked" | "digital"
canTransmit: boolean,
listeners: number,
users: [{
id: string, name: string,
nacId: string | null , unitId: string | null ,
transmitting: boolean, panic: boolean
}]
}]
} Copy
DispatchSettings shape:
{
callsign : string,
voiceVolume : number, // 0–1
sfxVolume : number, // 0–1
globalPTT : { pttKey, pttType, pttMouseButton },
channelPTT : { [frequency]: PTTSettings },
announcementGroupPTT : { [groupId]: PTTSettings }
} Copy
Events#
Subscribe with dispatch.on(event, callback). All events fire immediately on subscribe with the current state — no separate initialisation call needed.
dispatch. on ( "dispatch:zones" , ({ zones }) => render (zones));
// Unsubscribe
const handler = ({ active }) => {
/* ... */
};
dispatch. on ( "dispatch:ptt" , handler);
onUnmount (() => dispatch. off ( "dispatch:ptt" , handler)); Copy
Event Payload Fires when dispatch:zones{ zones }Any channel, user, panic, or listener-count change dispatch:ptt{ active }Global PTT pressed or released dispatch:channel{ freq }Main monitored channel changes dispatch:channel-ptt{ active, freq }Per-channel PTT starts or stops dispatch:voice-status{ status }Voice status changes dispatch:connection{ connected, health }Connection state changes dispatch:alerts{ alerts: [{id, name}] }Active channel alerts change dispatch:alert-types{ alertTypes }Alert config reloads dispatch:scans{ scanned: string[] }Scanned channel list changes dispatch:patches{ patches }Patches created or removed dispatch:transmissions{ transmissions }TX log entry added or completed dispatch:callsign{ callsign }Dispatcher callsign changes dispatch:announcement-group{ groupId: string | null }Active announcement group changes dispatch:settings{ settings }Any PTT binding or volume setting changes
Actions#
Channel:
dispatch. joinChannel ( "460.0625" );
dispatch. leaveChannel ();
dispatch. addScan ( "154.755" );
dispatch. removeScan ( "154.755" ); Copy
PTT:
dispatch. startPTT (); // global PTT — requires microphone + active channel
dispatch. stopPTT ();
dispatch. startChannelPTT ( "460.0625" ); // per-channel PTT
dispatch. stopChannelPTT (); Copy
PTT silently no-ops if the dispatcher has no active channel, the channel has canTransmit: false,
or another PTT is already active.
Announcement groups:
dispatch. activateAnnouncementGroup ( "group-id" ); // arm
dispatch. activateAnnouncementGroup ( null ); // disarm Copy
Alerts:
dispatch. triggerAlert ( "460.0625" , "SIGNAL 100" );
dispatch. sendBroadcastAlert ({
frequency: "460.0625" ,
type: "Priority Alert" , // "General Alert" | "Information Alert" | "Priority Alert" | "Emergency Alert"
message: "All units respond to 123 Main St" ,
tone: "PRIORITY" ,
});
dispatch. acknowledgePanic (serverId, unitId); // unitId optional Copy
Patches:
const patchId = await dispatch. createPatch ([ "460.0625" , "154.755" ]);
await dispatch. removePatch (patchId); Copy
Settings & user management:
dispatch. updateSettings ({ voiceVolume: 0.8 });
dispatch. updateSettings ({ callsign: "DISP-1" });
dispatch. openSettings ();
dispatch. closeSettings ();
// All return Promise<boolean>
dispatch. kickUser (serverId);
dispatch. alertUser (serverId, "Message text" , frequency);
dispatch. setUserCallsign (serverId, "UNIT-5" );
dispatch. switchUserChannel (serverId, "462.000" , "460.000" ); // (id, newFreq, oldFreq) Copy
DOM Control#
All CSS changes through the theme API are automatically reversed when the theme changes or the panel unmounts.
theme. setVar ( "--accent-raw" , "#ff6600" ); // set a CSS variable on <html>
theme. removeVar ( "--accent-raw" );
// Adds "tj-ptt-active" to <html> (auto-prefixed)
theme. addClass ( "ptt-active" );
theme. removeClass ( "ptt-active" );
theme. hideDefaultUI (); // hides Header, ZoneList, and announcement bar
theme. showDefaultUI (); // restores them
// Persistent <div> inside .dispatch-app — mount custom HTML here
const root = theme. getContainer (); Copy
Variable names: --[a-zA-Z][a-zA-Z0-9-]* (max 60 chars). Class names: [a-zA-Z][a-zA-Z0-9-_]* (max 40 chars, without the tj- prefix). hideDefaultUI() does not affect Settings, broadcast, or patch modals — they render in overlay portals.
Security Model#
What How it's enforced CSS injection @import and external url() calls are stripped at install time. Relative URLs and data: URIs are permitted.JS execution Runs directly in the dispatch panel — no iframe sandbox. Full DOM access by design. Install review Any theme containing jsText shows a mandatory source-review dialog. Cannot be bypassed. JS badge Marketplace listing cards display a visible JS badge on themes that include JavaScript. Auto-cleanup CSS vars, classes, canvas contents, and hideDefaultUI state are all reversed automatically on theme change or panel close.
Full Panel Replacement Example#
theme. hideDefaultUI ();
const root = theme. getContainer ();
root.style.cssText = "flex:1;overflow:auto;display:flex;flex-direction:column" ;
function renderChannel ( c , activeFreq ) {
const panicking = c.users. filter (( u ) => u.panic). length ;
const active = c.frequency === activeFreq;
return `
<div data-freq="${ c . frequency }" style="
padding:10px 14px; border-bottom:1px solid var(--border); cursor:pointer;
background:${ active ? "var(--my-dispatch-bg)" : "transparent"};
border-left:3px solid ${ active ? "var(--accent-raw)" : "transparent"}">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:600;color:var(--text-primary)">${ c . name }</span>
<span style="font-size:11px;color:var(--text-secondary)">${ c . frequency }</span>
${ panicking ? `<span style="color:var(--danger);font-size:11px;font-weight:700">⚠ PANIC</span>` : ""}
${ c . users . some (( u ) => u . transmitting ) ? `<span style="color:var(--accent-raw);font-size:11px">● TX</span>` : ""}
</div>
<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">
${
c . users
. map (
( u ) =>
`<span style="color:${ u . transmitting ? "var(--accent-raw)" : u . panic ? "var(--danger)" : "var(--text-secondary)"}">${ u . name }</span>` ,
)
. join ( ", " ) || "No users"
}
</div>
</div>` ;
}
function render () {
const s = dispatch. getState ();
const channels = s.zones. flatMap (( z ) => z.channels);
root.innerHTML = `
<div style="padding:10px 14px;border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;background:var(--bg-secondary)">
<span style="font-weight:700;color:var(--text-primary)">${ s . serverName || "Dispatch"}</span>
<span style="font-size:11px;color:${ s . connected ? "var(--success)" : "var(--danger)"}">● ${ s . health }</span>
<span style="font-size:11px;color:var(--text-secondary);margin-left:auto">${ s . callsign }</span>
</div>
<div style="flex:1;overflow-y:auto">
${ channels . map (( c ) => renderChannel ( c , s . channel )). join ( "" ) }
</div>
<div style="padding:10px 14px;border-top:1px solid var(--border);
display:flex;gap:8px;background:var(--bg-secondary)">
<button id="ptt-btn" style="flex:1;padding:8px;border-radius:6px;border:none;cursor:pointer;
font-weight:700;color:var(--text-primary);
background:${ s . pttActive ? "var(--accent-raw)" : "var(--bg-hover)"}">
${ s . pttActive ? "● TRANSMITTING" : "PTT"}
</button>
<button onclick="dispatch.openSettings()" style="padding:8px 12px;border-radius:6px;
border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer">⚙</button>
</div>` ;
root. querySelectorAll ( "[data-freq]" ). forEach (( el ) => {
el. onclick = () => dispatch. joinChannel (el.dataset.freq);
});
const pttBtn = root. querySelector ( "#ptt-btn" );
if (pttBtn) {
pttBtn. onmousedown = () => dispatch. startPTT ();
pttBtn. onmouseup = () => dispatch. stopPTT ();
pttBtn. onmouseleave = () => dispatch. stopPTT ();
}
}
dispatch. on ( "dispatch:zones" , render);
dispatch. on ( "dispatch:channel" , render);
dispatch. on ( "dispatch:ptt" , render);
dispatch. on ( "dispatch:connection" , render);
dispatch. on ( "dispatch:callsign" , render); Copy
Error Responses#
All error responses use Content-Type: 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" }Invalid frequency 500{ "error": "..." }Server error
Type notes:
Frequencies accept strings ("154.755") or numbers (154.755) on all endpoints and exports.
Dispatcher serverId values must be negative ; in-game player IDs are positive.
CORS is enabled for all origins.
Volume: exports use 0.0–1.0 ; admin panel and config.lua use 0–100 — divide by 100 when passing config values to exports.