Overview
Sal's Kewl Dispatch is two products that form one communications system:
- The in-game radio (
sals_kewldispatch) — a trunked radio resource for your FiveM server. Talkgroups with codeplug-style labels, job-driven personalities, server-enforced single-transmitter arbitration, PTT IDs on every display, a full-keypad portable on foot and a control head in vehicles, a fire/EMS tone-out pager, 911 call-taking, and an in-game admin configuration window. A one-time purchase that runs entirely on your server, forever. - The hosted dispatch console — a browser-based, multi-channel dispatch position with two-way voice, delivered as a hosted service on a subscription. Dispatchers work from any browser; no copy of the game is required.
The two are sold separately — all pricing is on the main site. The radio never depends on the console: with no dispatch subscription, everything in-game works forever on your own server, including 911 (see 911 call-taking).
The radios are modeled on real public-safety hardware:
an all-band handheld and two hi-viz full-keypad portables on foot, two control
heads for the vehicle, and a fire/EMS voice pager, which is its own device
rather than a radio you pick. Every maker and model name on them is our own
invention, and no real brand name, product number or manufacturer's logo
appears anywhere in the resource. If you would rather badge them yourself,
Config.Models.Marks puts your own print on any chassis.
3.0.0 replaced the catalog. The radios used to be drawn in CSS. A drawn faceplate never quite reads as a photograph of a radio, and the two beside each other in one picker made both look worse, so the drawn ones were retired in one go rather than added to. And you are not waiting on us for radios either — you can install your own without restarting the server, and build one with an image editor and a text editor.
Requirements & voice
Works with qb-core, qbx_core, es_extended, or standalone (everyone gets the catch-all personality). Detection is automatic; there is nothing to configure. Optional integrations (inventories, phones, CAD) are detected at runtime — none of them are hard dependencies.
Voice backends
The radio does not ship its own voice transport. Pick one in
Config.Voice.Backend (the default 'auto' detects a
running backend):
| Backend | Config value | Notes |
|---|---|---|
| pma-voice | 'pma' | Most common. Carries one talkgroup at a time; scan stops on the active talkgroup like a real single-receiver scanner and returns to your selected talkgroup after a configurable hang time (Config.Voice.ScanFollow). Selected traffic always wins, and keying up always transmits on the selected talkgroup. |
| Native Mumble | 'native' | Uses FiveM's Mumble natives directly. Hears every scanned talkgroup simultaneously. Do not run alongside pma-voice. |
| SaltyChat | 'salty' | Routing only; the SaltyChat plugin keeps its own PTT. |
| None | 'none' | State, UI and arbitration with no player audio. Browser consoles are still audible. |
pma-voice users: this resource owns the push-to-talk key
and drives pma-voice's +radiotalk / -radiotalk
on grant and release. Have players unbind pma-voice's own radio key
(Settings → Key Bindings → FiveM) or they will key up twice and
bypass arbitration. The radio warns any player whose pma key is still set —
once on spawn and again if they press it.
Installation
- Drop the folder into your
resources/directory assals_kewldispatch. - Add
ensure sals_kewldispatchtoserver.cfg. - Grant yourself the admin panel (below).
- Start the server once. It creates its
data/folder. Then open/radioadminin game: the codeplug, and under Settings every setting the resource has. Nothing needs a file.defaults.luabeside it is documentation, replaced by every release and never edited (see Configuration).
Admin access
Two routes; either one is enough.
Identifier list (simplest). Join the server and run
/radioid — it prints your identifiers to the server
console and tells you which line to paste. This is one of the few
settings read at startup rather than from the panel, so it goes in
config.local.lua (never defaults.lua, which every
upgrade replaces):
Config.Admin.Identifiers = {
'license:1a2b3c4d5e6f7890...',
}
Matching is case-insensitive and the license: prefix is
optional. Restart the resource after editing.
ACE (better once you have an admin team). In
server.cfg:
add_ace group.admin sals_kewldispatch.admin allow
add_principal identifier.license:1a2b3c4d5e6f7890... group.admin
If /radioadmin does nothing, check the server console: a denial
prints the player's actual identifiers and the exact add_ace line
to run.
Configuration: the panel and the files
You don't edit files. Every setting the resource has is on the
Settings branch of /radioadmin in game and the
Radio settings page of your customer portal, and what you
change is saved under data/, where an upgrade never touches it.
| Where | What it is |
|---|---|
/radioadmin and the portal |
Where you change things. 252 settings in 24 sections
under five headings, with a search box, a ? on every one, and
↺ default on anything you've changed. Saved to
data/overrides.json. |
defaults.lua |
Ours. Every setting at its default, with a
plain-language comment explaining it. The file you read to find
out what a setting is called. Replaced by every release, never edited.
It was called config.lua before 3.8.0. |
config.local.lua |
For the few things a screen can't hold, because the client registers
them from its own copy of the defaults when it starts: command names,
default key bindings, whether 911 and the pager exist at all, and the
identifiers that open the panel. One line per setting, same path as in
defaults.lua. Most servers never write one. |
RESTART, and what it means
A few settings are read once when the resource starts: which CAD driver,
the fire and phone adapters, chat capture on a 911 call, the radio item name,
the voice service switches. They used to be kept off the panel because a save
that changes nothing until a restart looks broken. Now they're saved like any
other, tagged RESTART beside the control, and named again on
the status line after the save so you know what waits for the next
restart sals_kewldispatch.
Export and import
EXPORT SETTINGS hands you the whole configuration as one
file: the codeplug, every setting, the tower sites, the console layouts and
the radio AI's settings. In game it's copied to your clipboard and written
under data/exports/; in the portal it downloads. Keep it in
version control, carry it to a second server, or attach it to a support
request. IMPORT SETTINGS takes it back through every check a
save gets, tells you where it came from before it applies, and says which
settings wait for a restart. From the server console the same two are
radioexport [name] and radioimport <name>.
The overlay, if you ever need it
For the boot-time names only, copy the line out of
defaults.lua into config.local.lua with your value
on it, whole path, spelled exactly:
Config.Commands.Radio = 'dispatch'
Config.Keys.Ptt = 'CAPITAL'
The file is created for you from config-local-EXAMPLE.lua the
first time the resource starts. Write only the lines you're changing; a whole
table replaces what was there. The server prints what the file changed at
every start, names a misspelled path rather than ignoring it, and says when a
panel save is winning over a line in the file.
Coming from a release before 3.8.0 with edits in
config.lua? That file is no longer loaded, and the
server warns at every start until it's gone. Your settings go in the panel
now, where every one of them is; the boot-time names go in
config.local.lua. Send us your
config.lua and we'll send back the lines that differ from what
we shipped. Blank out any key first.
Upgrading
Copy the new folder over the old one. That is all.
- Copy the new
sals_kewldispatchfolder over your existing one, letting it overwrite. Do not delete the old folder first. refresh, thenrestart sals_kewldispatch.- Run
/radiodiagas an admin. The first line names the running version, which is how you know the copy took.
There is nothing to merge and no diff to read, because everything you
configured is saved under data/, and the download contains no
data folder. Press EXPORT SETTINGS in
/radioadmin before you start and keep the file; it is the one
backup that holds everything.
Never delete the old folder. The whole data
folder, and config.local.lua if you wrote one, live only there
and are not in the download. Deleting the folder loses the codeplug and
settings your admins saved and their backups, radio aliases, per-player radio
state, tower sites, postals, Radio AI settings, console layouts and prefs,
your exports, and every chassis package you installed. Nothing can get them
back.
What is yours
If your hosting panel can only replace a folder, copy these out first and put them back before restarting. The same list is what belongs in any backup of the server.
| File or folder | What it holds |
|---|---|
data/overrides.json, data/overrides.bak*.json | The codeplug and every setting saved from /radioadmin or the portal, and their backups |
data/exports/ | Anything you exported |
data/aliases.json, data/players.json | Radio aliases and per-player radio state |
data/rfsites.json, data/postals.json | Tower sites and the postal grid |
data/airadio.json, data/layouts.json, data/consoleprefs.json | Radio AI settings, owner console layouts, dispatcher screen prefs |
data/chassis/ | Every chassis package installed from /radioadmin |
config.local.lua, data/config.local.backup.lua | Your overlay, if you wrote one, and the server's own copy of it |
integrations/phones/yours.lua | Any phone integration you wrote or renamed |
The short version: the whole data folder,
config.local.lua if you have one, and any file you added
yourself.
Where a setting can come from
Three layers, and later beats earlier:
| Layer | What it is |
|---|---|
defaults.lua | Our defaults. Ours to improve, yours to read, never edited |
config.local.lua | A hand-written overlay for the boot-time names. Beats defaults.lua |
data/overrides.json | What /radioadmin and your portal save. Where your settings are. Beats both |
So if you put something in the overlay and it does not take effect, the usual reason is that the same thing was saved from the panel. The server console says which setting, by name, at every start, and the panel shows the value it is actually running with.
Concepts
| Talkgroup | A logical channel: a stable id, a display label (what the radio screen shows), a color, and flags for encryption, emergency eligibility and dispatch monitoring. |
|---|---|
| Zone | An ordered bank of talkgroups — one position set of the channel knob. |
| Personality | The codeplug a player is handed, chosen by job. It decides which zones exist, what may be scanned, where emergency goes, and which features are unlocked. |
| Alias / PTT ID | The unit ID other radios display when you key up. The agency prefix always comes from the personality, so a civilian cannot transmit as LSPD even with a custom suffix. |
| Scan vs monitor | Scan is one radio hunting for activity across a permitted list. Monitor is a set of talkgroups heard permanently and simultaneously — what a dispatch console sits on. Both are validated server-side against the codeplug. |
Transmit arbitration
One unit holds a talkgroup at a time, enforced in the audio path, not just on the display: the server grants the channel and only the granted unit's voice is routed to it.
- A denied key-up gets the bonk and
TG BUSY <holder>on the display. Config.Tx.MaxSecondsis a repeater-style time-out timer, so a stuck mic cannot hold a talkgroup hostage.Config.Tx.QueueRequestsauto-grants to the next waiting unit on release.- A unit in emergency preempts whoever is holding the talkgroup.
Emergency
Hold the orange button (or the emergency key) for
Config.Emergency.HoldMs. The radio forces itself to the
personality's emergency talkgroup, powers on if it was off, alarms on
everyone's display, and hands the emergency unit a hot mic. It re-announces
until cleared and auto-clears as a safety net. Dispatch consoles can clear it
remotely.
Player guide
Keybinds
All rebindable in Settings → Key Bindings. Defaults:
| Key | Action |
|---|---|
| Numpad * | Push to talk |
| F3 | Show / hide the radio |
| F4 | Grab / release the mouse |
| . (hold) | Emergency |
| Page Up / Page Down | Channel up / down |
| Home | Next zone |
Commands
/radio | Toggle the radio (the radio item's own name works too) |
/radioguide | Lists every command that player can actually use, under the names you gave them, with their own key bindings — and offers the interactive tour that spotlights and explains every control on the chassis actually on screen. Nothing pressed while the guide is open transmits. |
/radiorostershow | Show or hide the panel listing who else is on the player's channel (see below). reset puts it back where it started |
/radiorosterfocus <channel> | Point that panel at one channel the player can hear. No channel puts it back on the one they have selected |
/radiokeys | Key setup panel (see below) |
/radioalias <suffix> | Set your personal alias suffix (the agency prefix stays fixed) |
/radiomic | Re-open FiveM's microphone permission prompt |
/radiomove | top-left | top-right | bottom-left | bottom-right | center | reset — reposition the radio from chat if it is ever stranded |
/radiounfocus | Escape hatch that releases NUI focus if anything strands it |
/pager | Toggle the pager (its item name works too, e.g. /pspager) |
/911, /911text | Call — or text — 911 (see 911 call-taking) |
/radiocheck | Checks your own radio and tells you what is wrong with it and what to press. Any player, no permission (see below) |
/radioid, /radioadmin, /radioreload, /radiodiag, /cadtest, /firetest | Admin: identifiers, codeplug programmer, re-issue codeplugs, diagnostics, CAD test, fire integration test |
Who else is on this channel
The dispatch console has always been able to see who is listening to a
talkgroup. Since 3.5.0 the people carrying the radios can too.
/radiorostershow opens a small panel beside the radio listing
everyone who would hear that player if they keyed up right now.
Each name is tagged with how they are hearing it — SEL sitting on the channel, SCAN sweeping it, MON monitoring it — and anyone not sitting on it also shows which channel they are on. That last column is the one that answers “why didn't my partner come back to me”: a unit on scan is working something else and may not answer. A unit with an emergency on sorts to the top.
/radiorosterfocus <channel> points the panel at one
channel the player can hear rather than the one they have selected, so a unit
can keep an eye on a talkgroup they are scanning while they work another.
Partial names work, so tac finds TAC-2, and a name that matches
nothing lists the channels in their current zone. With no channel at all it
goes back to following the selected one.
It is off for everyone until a player turns it on, and
each player's choice is remembered. There are tick boxes for it in
/radio and in /radioguide as well, so it can be
found without knowing the command exists.
It behaves like a HUD panel, because it is one. Whenever
a player has the mouse — the cursor key, the same one that works the
radio itself — they can drag it by its header, pull the bottom-right
corner to set its width and how many names are visible, and click the chevron
to fold it to a single line that still carries the channel and the count. All
of it is remembered per player. The list scrolls past whatever height they
chose, so the count in the header is always the truth. /radio
has a slider for the number of names and a Reset button, and
/radiorostershow reset is the rescue if a panel ever ends up
somewhere the mouse cannot reach. The rest of the time it is click-through,
so it never eats a click meant for the game.
A player can only read a channel they can hear. The server answers with the roster of one talkgroup, and only after checking that the asking radio actually receives it — the same test that decides whether they would hear the traffic. A unit sitting on a channel the reader's own personality does not carry shows as elsewhere rather than being named, so the panel can never hand somebody a codeplug entry they were never issued.
Config.Roster | What it does |
|---|---|
Enabled | May players see a roster at all. false removes both commands too |
Default | Does the panel start visible for a player who has never touched it. Ships false |
Scope | hearing (anything they receive) or selected (only the channel they are on, and the focus command says so rather than being ignored) |
ShowVia | The SEL / SCAN / MON tags |
ShowChannel | The “they are actually on X” column |
ShowConsoles | Include dispatch consoles in the list |
Refresh | Seconds between refreshes while it is open. Nothing is sent while it is closed. Floored at 2 |
Rows | How many names are visible at once before the list scrolls. Each player can change their own |
MaxRows | The most names the server will ever send, and the ceiling on Rows. Beyond it they are counted |
Draggable | May a player drag the panel somewhere else |
Resizable | May a player resize it by the corner grip |
Collapsible | May a player fold it to its header |
Hud | Where it sits by default, in the same terms as Config.Calls.Hud. A player's own position wins |
Checking your own radio
“I can't talk on the radio, sometimes” is the hardest report to act on, because every one of its causes feels identical from the player's chair — an unbound key, a radio switched off, someone else holding the channel, hang time, no tower, a blocked microphone, a leftover pma-voice binding.
/radiocheck tells them apart, and any player can run
it. It checks the radio they were issued (or why they were refused),
power, selected channel, the push-to-talk binding, a clashing pma-voice
“Talk over Radio” key, the voice backend, the microphone grant, RF
coverage, whether the artwork loaded, and why their last transmission
was refused — and says what to press about it. The full list
goes to F8 so it can be screenshotted; the first
problem and its fix also go to chat, because a player who does not know what
F8 is still needs the one sentence that matters.
It needs no permission because every fact in it is one that player's own
client already holds about itself, which is also why it can say nothing about
anybody else. Staff read the same rows for a specific player with
/radiodiag <id>, in their own console, without talking
anybody through anything.
Two states, deliberately separate
F3 shows the radio as a plain HUD — click-through, holding no focus, so the player keeps complete control of their ped. F4 grabs the mouse for clicking, dragging and resizing; the radio picks up a rim light so the state is obvious, and Esc releases it. Position, scale and the keypad's expanded state persist per player, separately for the portable and the mobile head.
The keypad
The portable's keypad is functional: digits enter a unit ID, #
sends a directed page to the matching unit, * backspaces.
Matching is on the digits inside an alias, so 402 finds
LSPD 402-ADAM. The KEYPAD bar collapses it to
the short limited-keypad radio, remembered per player.
Instant recall
MENU → RECALL lists the last few transmissions on the
selected talkgroup and replays them through the radio speaker. Live traffic
outranks the recorder, exactly like the recall button on a real portable.
Recordings live in server memory only — nothing is written to disk, and a
radio can only replay the talkgroup it is currently tuned to. Configure with
Config.Recall; requires the voice bridge.
Key setup modes
Config.Keys.Mode decides who owns the bindings:
'default' | Classic FiveM key mappings, changed in the pause menu — which accepts mouse side buttons. /radiokeys is a read-only view (FiveM's binding store cannot be written from script). |
|---|---|
'managed' | The radio owns the bindings and captures them in /radiokeys: click, press the key, done. Layout-independent, persists across sessions, works on production-mode servers. Keyboard only. |
For joystick buttons — or mouse buttons in managed mode — map the button to F13–F24 in your peripheral software (G HUB, Synapse, JoyToKey, reWASD, Stream Deck…) and bind that.
The radio talks
Since 3.7.0, turn the knob and the radio tells you where you landed, the way a real APX or XL does: "Zone alpha." "L S P D one." It reads the channel on a channel change, the zone and then the channel on a zone change, and the radioset at power-up. Only the person holding the radio hears it. It never goes out on the air, never takes a transmit grant, and it waits rather than talking over live traffic. Players can switch it off in the radio's own SETTINGS window, separately from the tones.
It costs nothing to switch on. With no setup at all it
uses the game browser's own voice where that works and stays silent where it
doesn't, with the name still on the display. If you want your own voice, a
voice pack is a folder of your recordings with a name, handed
to the personalities that should use it, so fire reads your fire codeplug and
PD reads PD off the same codeplug. A pack with no mapping is searched by the
channel's own name (CHANNEL_LSPD1.ogg), so you name the files after
your channels and nothing else needs writing. We ship no clips, deliberately:
a voice is a licensing question. audio/README.md in the download
is the whole procedure, and a hosted voice (Config.Annunciator.Tts,
key in server.cfg) renders your closed list of channel names once
and never pays for them again.
Config.Annunciator = {
Voices = {
['pd'] = { Dir = 'audio/bank/pd' },
['fd'] = { Dir = 'audio/bank/fd' },
},
ByPersonality = { police = { Voice = 'pd' }, fire = { Voice = 'fd' } },
Say = { ['LSPD1'] = 'L S P D one' },
}
Say is the setting to look at. Your labels
were written to be read on a display, not spoken, so LSPD1
would come out "lispid one". The radio already splits a trailing number off,
spells out short initialisms and leaves real words like FIRE and EMS alone;
Say is where you correct the rest, and /radiodiag
prints what every label will be said as so you can see which ones need it.
Your badge on the display
Config.Display puts a splash screen over the display for a
couple of seconds when the radio is switched on, and a faded wallpaper behind
the channel text the whole time it's on. They're independent and both are off
until you point them at an image. Drop a file into html/splash/
and name it as 'splash/yours.png', or use a data: URI;
a URL on another host, an absolute path and a .. hop are all
refused server side, and the console says which image it wouldn't load and
why. Keep the file small, because every player who joins downloads it, and
the wallpaper is capped at 0.6 opacity because the display is where somebody
reads their channel in a hurry. ByModel gives one chassis
different artwork from the rest.
Radio & pager items
Config.Access.RequireItem gates the radio behind an inventory
item (default on); a personality can override with its own
requireItem — false for issued kit, or a specific
item or list. Picking the item up hands you a radio without relogging
(re-checked every Config.Access.RecheckSeconds, default 15;
/radio re-checks immediately), and
Config.Access.RevokeOnLoss decides whether losing it takes the
radio back.
Using the item opens the radio, and the item's name works
as a command. When somebody cannot use the radio they are told why — the
missing item by name, or that their job has no radio assigned. Set
Config.Access.ExplainDenials = false for a flat refusal (the
right call if the item is contraband).
ox_inventory routes item use through the item definition:
point the item's client.event at
sals_kewldispatch:client:radioUse (the pager's is
sals_kewldispatch:client:pagerUse).
Codeplugs from ACE groups
A personality is normally picked from the player's job, which needs a
framework to report one. vMenu isn't a framework, and neither is running
nothing at all, so on those servers every player used to come back as one
civilian job. Since 3.7.0 the add_principal lines already in your
server.cfg decide who gets which radio:
Config.Access.ByAce = {
{ ace = 'group.police', personality = 'police' },
{ ace = 'group.fire', personality = 'fire' },
}
It's a list and the order is the priority, because people are usually in
several groups at once: the first row that matches wins, so put the most
specific group first. A row naming a personality that doesn't exist is skipped
and named at startup rather than taking anybody's radio away. It sits below a
pin and a CAD duty profile and above the job, so it's useful on a qb or ESX
server too, for somebody who dispatches but isn't employed as one.
/radiodiag prints the mapping and which group each player online
matched.
Fire/EMS pager
A two-tone voice pager beside the radio — the device a volunteer
firefighter wears off shift. Everything lives under Config.Pager;
set Config.Pager.Enabled = false and nothing else changes.
How a page happens
- Dispatch opens the console's PAGER TONE-OUT window,
checks one or more tone sets (frequency pairs —
Config.Pager.ToneSetsto start with, and edited live from the Pager tab of/radioadminor your customer portal, one catalog for every pager and every console — or custom pairs whenAllowCustomTonesis on), checks the target talkgroups, types an announcement, and sends. - The tones sound over the air — every radio and console on those talkgroups hears them.
- Every on-duty pager subscribed to a matching tone set (within
MatchToleranceHz, like a real reed decoder) alerts, shows the announcement, and opens its speaker on the paged talkgroup forAlertAudioSecondsso the voice announcement that follows is heard. REPLAY on the pager replays it afterward (requiresConfig.Recall.Enabled).
Who gets a pager is Config.Pager.Personalities — resolved from
the same job names as the radio but a separate table on purpose, so the fire
job's radio and pager are configured independently. A pager never transmits.
The pager's DUTY switch is pager-local — it silences that
pager and never touches the framework's duty flag.
The pspager item
Item definitions belong to your inventory system — add one there (optional;
the item gate is off by default). A ready-made 128×128 icon
(pspager.png) ships beside fxmanifest.lua — copy it
into your inventory's images folder.
qb-core — qb-core/shared/items.lua:
['pspager'] = {
name = 'pspager', label = 'Public Safety Pager', weight = 220,
type = 'item', image = 'pspager.png', unique = true, useable = true,
shouldClose = true, combinable = nil,
description = 'Two-tone fire/EMS voice pager',
},
ox_inventory — ox_inventory/data/items.lua
(the client.event line is what makes using it open the pager;
if you run ox alongside qb-core, define the item here only):
['pspager'] = {
label = 'Public Safety Pager',
weight = 220,
stack = false,
close = true,
description = 'Two-tone fire/EMS voice pager',
client = { event = 'sals_kewldispatch:client:pagerUse' },
},
RF coverage & tower sites
By default your radios work everywhere on the map, because nothing in the
resource models where the towers are. The four signal bars are decoration.
Turn on Config.RF.Enabled and they stop being decoration.
This arrives switched off, and off means nothing changes. No extra work per player, no new failure mode, and the bars stay exactly as they were. Turn it on when you want them to mean something.
What changes when it is on
Each radio measures the tower sites you have placed, roams to the strongest one it can hear, and shows what it actually has. The menu grows an RF STATUS page with the site name, the signal in dBm and whether you can transmit, the way a real radio's service display does.
Six sites ship on real landmarks: the Chiliad antenna farm, the Vinewood tower, downtown, the port, Sandy Shores and Paleto. They are tuned so normal play has full bars and the holes are interiors, tunnels and the far water. Move them, repower them, add your own, or delete the lot and start again. If you clear them, do it with RF switched off or place your first site before you save: RF on with zero sites means nobody has signal.
Coverage never decides who is allowed on a channel. Personalities, talkgroup grants and item checks are all resolved first and are untouched by any of this. RF only answers whether the radio can reach a tower. A unit in a dead spot has exactly the permissions it always had, it just cannot reach anybody with them.
The three thresholds
| Setting | Default | What happens below it |
|---|---|---|
Limits.RxUsableDbm | −118 | Out of range. The radio goes quiet and the screen says NO SERVICE. |
Limits.TxUsableDbm | −110 | You can still hear, but keying up gets a bonk and NO SITE. |
Limits.MarginalDbm | −110 | Usable but poor. Audio starts breaking up. |
There is a band where you can hear dispatch and cannot answer them. That is deliberate — a tower transmits with hundreds of watts and a portable answers with five, so the uplink is the weaker half of a real radio link. It is the most interesting failure in the model, and it is adjustable. The refusal is enforced on the server against its own idea of where you are standing, so a player editing their game can make their own bars lie and still not transmit.
A weak signal sounds weak
Digital radio at the edge of a cell loses syllables. It does not politely
fade, and none of this is a volume change. Two paths degrade, driven by the
same number — Receive.MaxDropChance, the fraction lost at the
weakest usable signal, ramping to none at the marginal threshold:
- Audio that arrives through the radio — console traffic, pager tones — is thinned frame by frame, because the interface is holding each frame and can decline to play it.
- Player voice belongs to your voice backend (pma-voice,
Mumble, SaltyChat), and the only lever we have on it is the listen set,
which is on or off. So the same loss is spent as short windows
instead: the radio drops the channel for about a quarter of a second at a
time.
Receive.VoiceDropouts,Receive.DropWindowMs.
A short codec artifact fires with each loss, so a lost word reads as a weak signal rather than as somebody's microphone cutting out. The display does not flicker with it: a dropout is not NO SERVICE, and a screen strobing between the two several times a second reads as a broken radio rather than a weak one.
Sites fail
At a rate you choose. Commercial power drops one outright. Air conditioning
is the interesting one: cooling stops, the shelter heats over real minutes, and
the site loses transmit power before it dies — so everyone in that
cell watches their bars sag before anything goes off the air. Repairs happen on
their own unless you set RequireManualRepair, which is the switch
to flip the day somebody is actually being paid to drive out there.
An optional shelter mode gives every site a rack of
individual parts — amplifier, feedline, rectifier, batteries, generator, HVAC,
backhaul, even the tower lights — that fail, wear out and cascade into each
other. If every site is down at once, Config.RF.Failsoft decides
what happens: 'degraded' keeps the talkgroups working with
FAILSOFT on the display, which is what a real system does.
An optional weather adapter is detected at runtime like every other integration, so storms raise the odds of a power failure and hot weather pushes the shelters closer to the edge. Without one, a configured daily temperature curve does the same job.
Your dispatch console is never affected. Consoles, 911
and the radio AI sit on the system itself rather than on a tower, so no
amount of RF trouble can make dispatch unreachable. That is not configurable
on purpose. Pager tone-outs ignore coverage too, unless you turn on
Config.RF.AffectsPager.
Channel capacity: the busy signal and the queue
A real trunked site has a finite number of voice channels. Since 3.8.0
yours can too: set channels on a site, and when they're all in use
a key-up gets the bonk and SYS BUSY on the display. Turn the
queue on and the key-up waits in line instead and is granted the moment a
channel frees, in request order.
Config.Tx.Capacity = {
Enabled = true,
Channels = 0, -- per site unless the site sets its own; 0 = unlimited
CountWireline = true, -- dispatch traffic occupies a channel at the sites carrying it
Queue = { Enabled = true, WindowMs = 6000, MaxWaiting = 10 },
}
-- and per site:
{ id = 'chiliad', label = 'MT CHILIAD', channels = 4, ... }
A channel is counted per site and per talkgroup, the way a real controller
assigns one, so two units talking on the same channel under the same tower
are one call. Only the transmitter's own site can refuse them. BUSY and OUT OF
RANGE stay different faults: /radiocheck names which one it was
and /radiodiag has a trunking section with the channel
count, what's in use, what's waiting and which sites are full. Dispatch
consoles, 911 and the radio AI are never refused a channel. An emergency never
queues: it takes a channel at a full site when
Config.Tx.EmergencyPreempt is on. With RF coverage off it's one
system-wide pool, so you get the behavior without planning towers first.
It ships unlimited, and the queue ships off. Switching capacity on without setting a number changes nothing, on purpose. A queued transmission can start a second or two after the button went down, which is right for a trunked radio and surprising for anyone used to an ordinary FiveM radio. For scale: a small real P25 site runs three to five voice channels. Pick for how many units you actually have on the air; two channels is tense, ten is invisible.
Checking it
/rfdebug prints every site's signal from where you are
standing, which one you are affiliated with, whether you can transmit, and how
much voice you are losing right now — so a coverage complaint can be checked
rather than argued about. /radiodiag carries an rf
coverage block listing each site, whether it is up, how much power it
has lost and how hot it is.
The setting worth understanding is the path-loss exponent
(Config.RF.Model.Exponent). It is the single number that decides
how far every radio reaches — raise it and the map gets bigger, lower it and
everything closes in. Get that right before you start moving sites
around.
Hosted dispatch console
The browser console is a real position on the trunked system — it registers with the same engine a handheld does, competes in the same arbitration, and shows up in everyone's PTT ID. It is delivered as a hosted service: your game server connects out to hosted dispatch, dispatchers open their community's console page over HTTPS in any browser, and each signs in with their own named account (Discord or password) — their own PTT ID, their own audit trail, revocable on their own.
Nothing inbound ever touches your game port — no reverse proxy, no port forward, no certificate to manage. How many positions may be signed on at once comes from your dispatch plan.
Linking your server
Your tenant id and key come with your dispatch account. Put them in
server.cfg — pasting a key there turns the link on by itself:
set sals_dispatch_tenant "ten_..."
set sals_dispatch_key "your-key"
Use set, never setr — a replicated convar is
sent to every connecting client, and your key must not be. Treat the key
like your rcon password. The resource audits its own posture loudly at
startup and refuses the link on a published or sample key.
The position
The console is a grid of talkgroup modules, all live at once, and two speakers: SELECT (the module your PTT goes to, at full level) and UNSELECT (everything else you monitor, mixed under it). Each module has SEL (talk on it), MON (hear it), XMIT (instant transmit without moving your SELECT; red at rest, pulsing ON AIR while keyed), its own fader, mute, live PTT ID, listener count and level meter. Click a module's listener count to see exactly who would hear traffic on it right now, tagged with why (SEL / SCAN / MON).
Simulcast. Shift+click SEL on a module to
add it to the simulcast; the tile shows a SIMUL flag and the
PTT shows +N. Every key-up on SELECT then goes out on SELECT and
every leg at once, each leg arbitrated on its own: a busy leg is skipped and
reported, a preempted one drops off while the rest carry on. XMIT on another
module stays a single-channel key. CLEAR SIMUL in the header
empties the set, and it clears at sign-off on purpose.
Layout that follows you. Press ARRANGE
to move and resize the modules freely and, since 3.3.2, the sidebar too:
drag a panel by its header to reorder it, its bottom edge for height, the
seam beside the modules for the column width. Everything on the surface,
faders and theme included, is saved against your account on your own game
server (data/consoleprefs.json), so the same console comes up
from any browser or workstation you sign on from.
Tabs. While arranging, + TAB makes a tab and modules drag onto it; anything no tab names sits on an automatic OTHER tab. A tab you are not looking at lights green with a count while anybody talks on it and flashes red for an emergency, so grouping never costs awareness; what you hear does not change with the tab. Sidebar panels can dock beside a tab's modules or stay pinned on every tab. Ctrl+1…9 switch tabs. The header carries a clock, server time by default.
Owner-designed layouts. Your portal's Console layouts tab opens the console in design mode: pick a personality, arrange exactly the modules it lays out, save under a name. Assign layouts per personality with a policy — Free (dispatchers may start from yours and arrange their own; also the default), Choose (they pick one of yours, no arranging) or Locked (always the one marked DEFAULT). A layout is filtered on your own server against the personality's talkgroups, so it can only ever hide a module.
You can do the assigning in game instead: /radioadmin carries
a Console layouts branch listing every personality, where you
tick the layouts it may use, pick the rule, and mark the default. Drawing a
layout stays a browser job — the designer is the console itself —
but assigning does not need one. Apply writes on its own:
assignments live in data/layouts.json rather than in the codeplug,
so you never press WRITE CODEPLUG for them and pressing it does not undo
them.
Live map. A MAP panel shows
where units are, from your server's own measurement, only on talkgroups the
console can reach and only while a radio has position sharing on
(/radiogps). Dots are colored by talkgroup and pulse red in an
emergency. The imagery is an open-source San Andreas map the platform hosts;
your portal's Live map tab takes a zip of tiles for Roxwood
or any other map you run.
Also on the position: the emergency queue with remote clear, unit roster, text paging, the pager tone-out window, a traffic log, and instant recall with WAV export. Which modules a position lays out comes from its personality; an account's agency can only ever narrow that list, never grant a module the codeplug does not carry.
Voice
The console carries voice in both directions — G.711 at 8 kHz riding the same transport as everything else (about 10 kB/s per talking unit):
| Direction | Notes |
|---|---|
| Console → game | Played through each radio's own speaker. Works on every voice backend, including 'none'. |
| Game → console | The player's client bridges their mic alongside game voice. Needs the mic permission (/radiomic re-asks). |
| Console → console | Same relay. |
The relay refuses any frame from a unit that does not hold the talkgroup grant — the single-transmitter rule governs bridged audio exactly as it governs game audio. Sign-on attempts are throttled per address, sessions are pinned to the address that opened them, and the account credential is re-checked on every request.
Your own name on it
A community running a real dispatch center can have the console, the browser tab and the in-game programmer carry its name, accent color and logo instead of ours. White-labeling is part of a hosted arrangement rather than a switch you set, so ask us and we will turn it on for your server. Nothing to re-download and no restart. The maker's mark printed on the handheld is a separate thing, and has always been yours to set.
The radio never dims. With no link configured, the handhelds, mobiles and everything in-game work forever, entirely on your own server.
Keys and control surfaces
Since 3.8.0 every gesture a dispatcher repeats has a key, and the whole map is under SETTINGS → KEYS AND CONTROL SURFACE: click the key, press the one you want, Backspace to clear it, Esc to leave it alone. Two actions can't share a key; choosing one that's taken is refused with the name of the action holding it. The map saves against the dispatcher's account like their layout, so it follows them to the next desk.
| Default | Action |
|---|---|
| Space | Push to talk on SELECT |
| Shift+Space | Transmit latch: press on, press off |
| ↓ / ↑ | Select the next / previous talkgroup |
| 1…8 | Select talkgroup 1 to 8 as laid out on screen |
| M | Monitor the selected talkgroup |
| Shift+1 / 2 / 3 | ALERT 1 (steady), 2 (pulsed), 3 (hi-lo) |
| T | Open the pager tone-out |
| A | Acknowledge: go to the emergency |
| Shift+C | Clear the oldest emergency |
| Shift+M | Mute everything |
A Stream Deck needs no plugin
A Stream Deck is a box of buttons that presses keys for you, so the key map is the hardware integration: nothing for a dispatcher to install, no service in their tray, no port open on their machine or on your server, and nothing that breaks when either side updates. There's a ready-made profile for the MK.2 and the table on its own for anyone building the buttons by hand on the hardware control surfaces page. Loupedeck, X-keys, a gaming keyboard's macro keys and a USB foot switch all send keystrokes just the same.
Transmit from a button is the latch, not push to talk. A Stream Deck sends a keystroke as a tap, so the console can't tell how long the button was held. The latch is one press on, one press off, and it's released by every path that already releases a transmission. A foot switch on the space bar is still the best push-to-talk money can buy.
Your customer portal
Your community's account is yours to run, at /portal: your
plan, your dispatcher accounts, who may sign in, your link key, your own model
keys, your codeplug and radio AI settings, and an audit trail of what changed
and who changed it. You do not open a ticket for any of it.
An owner is a rank in your own access table, so there is nothing extra to administer. Give somebody the owner rule in Discord and they have the portal; take the role away and they do not. A portal session is not a console session and does not use a dispatch seat, so a community sitting at its seat limit can still read its own billing.
Two things we will not let you do: save an access table that leaves nobody as owner, and write anything while the account is suspended. A suspended community can still read everything.
Signing in, and our Discord bot
There are two ways into the portal and they are not exclusive. Sign in with Discord if you hold a role mapped at rank owner, which is the usual way and needs nothing handed over. Or sign in with an email and password we issue you — for a community that would rather not tie portal access to a Discord role, or that has not mapped one yet. Ask us and we will create it; the password is shown to us once, so we send it to you separately from this address and you should change it.
Separately, we may send you a link to add our Discord bot to your server. It is worth knowing what that is and is not. It asks for no permissions at all, it reads your role names and nothing else, and it is optional — it changes nobody's access either way. Dispatcher sign-in reads a member's roles through Discord's own consent screen, bot or no bot. What the bot buys is setup: we can map your access by role name instead of you copying eighteen-digit role ids, and the connection test can tell you a role is not in the server you named.
Bringing your own model keys
If you would rather be billed directly by OpenAI and Anthropic, put your own keys in. It is both or neither. OpenAI covers voice and the radio's speech recognition and speech, Anthropic covers text and the radio's reasoning, and one key on its own would bill that vendor to you while leaving the rest on your plan's allowance. With both in, your usage stops counting against the allowance at all. Once they are saved we only ever show you the last four characters.
If your key is refused we do not quietly fall back to ours. The lane stands down, the failure is recorded, and both our panel and your portal say so. Falling back would turn your outage into our bill, and you would never find out the key was dead.
Serving the console from your own hostname
Point a hostname at us and your dispatchers reach the console there instead of on the shared address. A subdomain needs a CNAME onto our host; an apex needs matching A and AAAA records. There is no second verification record to add, so there is no state where your DNS is correct and we still refuse. The certificate is issued automatically once the name checks out.
Your original console address keeps working, so this is an alias rather than a migration, and a resolver hiccup cannot take a working console down. Your hostname serves the console and its sign-in and nothing else: the panel, this portal and your game server's link all stay on ours.
911 call-taking
A player dials 911 in game and reaches a dispatcher at the browser console. It behaves like a telephone, not like a radio: two parties, full duplex, no keying, created when somebody dials. A call is never in anybody's codeplug and cannot be reached by tuning to it — the two systems meet in exactly one place, PATCH, where a dispatcher deliberately puts the caller's voice onto a talkgroup.
911 ships off. Point Config.Calls.Services at
the personalities that should answer, set
Config.Calls.Fallback.Talkgroup, then either set
Config.Calls.Enabled = true or put
set sals_dispatch_calls "true" in server.cfg.
It works with your phone, and you can teach it a new one
Automatic detection for lb-phone, qb-phone, npwd, Roadphone,
gksphone, qs-smartphone and Y-Series. Export names are tried as
candidate lists and the one that answered is printed at boot, so a phone that
renames an export between versions keeps working. With no phone resource at
all, /911 works and numbers are synthesized stably per
character.
Your phone is not on that list? Add it yourself. Each of
those phones is one short file in integrations/phones/, which
ships outside the asset escrow — readable, copyable and yours to add to. Copy
EXAMPLE.lua to any other name, fill in your phone's export names,
restart. There is no list to add it to and nothing in defaults.lua
to change.
Registering a phone we already ship replaces ours, and the
boot log says so — which is how to correct one of our files in a way that
survives an upgrade: copy it to a name of your own and change the copy, the
same rule as config.local.lua. If you write one that works, send
it to us and it ships for everybody. A phone can announce a call either by
firing an event or by writing a state bag; Y-Series does the second, and its
file is the worked example. Config alone still works too
(Config.Calls.Phone.Custom), with no file at all.
ANI and ALI
ANI is who is calling — number, mobile or fixed line,
subscriber name when the phone can resolve one, and a repeat-caller flag.
ALI is where they are: a fixed line
(Config.Calls.Landlines) reports its registered address exactly;
a mobile draws a position with an uncertainty radius that tightens
over the first seconds of the call, the street name withheld until
the fix is tight enough, and REBID asks for a fresh fix.
Config.Calls.ALI.Phase is 'one', 'two'
(default) or 'exact'.
At the console
Three sidebar panels: CALL QUEUE (oldest first, ring timer), ACTIVE CALL (ANI, ALI, hold, transfer, conference, patch, rebid, notes, disposition) and CALL HISTORY, including abandoned calls. The telephone leg has its own fader and is deliberately not colored like radio traffic; keying the radio PTT mutes the phone leg — one mouth.
Text-to-911
/911text opens a call that carries text instead of audio, with
the same queue, location and disposition — for players with no microphone,
players who refused the permission prompt, and players who cannot speak right
now.
Without a dispatch subscription
911 still works. The call is logged,
sals_kewldispatch:call:fallback fires for your own CAD, and
Config.Calls.Fallback pages or tones the emergency talkgroup. A
player dialing 911 cannot tell the difference from the game side.
A call opens the caller's microphone for the duration of the call — the
only place this resource does that — and releases it when the call ends. A
caller who refused the permission prompt shows as CALLER HAS NO
MICROPHONE on the call card and is told so on their own screen;
/radiomic is the way back.
The radio AI dispatcher
When a unit calls dispatch on the radio and no live dispatcher answers, the AI can. It listens on a talkgroup you nominate, answers in a voice you pick, and writes to your CAD through the same seam everything else uses. It needs the AI tier of a dispatch plan, and it is a separate switch from 911 call-taking, so you can run either, both or neither.
It never decides whether it was called
A plain-text wake word decides that, and it is ordinary matching rather than a judgment call. You set your dispatch term, any aliases, and which hail formats your server actually uses. No match means no model is called and nothing goes out on the air.
A human always outranks it. Set a grace and any dispatcher keying up on that talkgroup inside the window stands the AI down for that hail, for good. With nobody signed on it answers straight away. It transmits under a real transmit grant like every other position, so it waits its turn and has no priority path.
Start it in shadow mode
Shadow mode runs the whole thing and transmits nothing, writes nothing and changes nothing. It reports every decision to a live activity feed: what it heard, whether the wake word matched, what it would have said and what it would have written to your CAD. Read a shift of that before you let it talk. Two things usually turn up, a hail format nobody here uses and callsigns your roster did not resolve, and both are a two-minute fix in the portal.
How quickly it answers
Standard transcribes, thinks, then speaks. That is three services one after another and about three to four seconds before the unit hears anything. It is the default and the cheaper one. Fast is a single live session that is already listening while the unit is still talking and starts speaking as it works out what to say, so you are looking at roughly a second, and it sounds less stitched together.
Fast costs about three times as much, because it bills audio in both directions instead of text in the middle, and because it listens to the whole talkgroup rather than only the transmissions aimed at dispatch. Everything else is identical between the two: same wake word, same grace, same shadow mode. If Fast cannot run, it answers on Standard instead, so a bad day at the vendor costs you a couple of seconds rather than a silent channel.
All of it lives in your portal, including which of the ten voices it uses, and changes apply live.
Shift changes
One voice answering a channel for six hours straight sounds like a machine; a dispatch center is several people. So every ten minutes or so, jittered, a different voice takes the desk, only ever between exchanges and never on a unit the current dispatcher is still talking to. Pick who takes turns and how often in the portal's Radio AI tab; set it to 0 to keep the one voice.
Calls opened from the air
"Central, Bravo 2, start me a call, traffic stop at Legion Square" opens an incident in your CAD with the nature and location as said, through the same path a 911 call takes, so the CAD's number comes back and the record can be noted and closed like any other. The AI asks for a location if none was given rather than guessing one. And when a unit asks for a plate or a warrants check, it keys up with "stand by", releases, runs the check, and keys up again with the answer, the way a dispatcher does.
It covers the desk when nobody else is on it
With Config.AiRadio.AutoStaff on, the AI is the dispatcher only
when none of yours is signed on. The desk goes unstaffed, a few minutes later
the AI takes the channel and says so on the air, and the moment somebody signs
back on it hands the desk straight back. An overnight channel is covered
without anybody remembering to switch anything. A browser console counts as a
dispatcher, and so does a player whose personality carries the dispatch
feature; only positions whose codeplug carries a watched channel count toward
staffing it.
Config.AiRadio.AutoStaff = {
Enabled = true,
AfterMinutes = 5, -- unstaffed this long before the AI takes it; 0 = immediately
StandDownSeconds = 30,
Announce = true,
}
Be honest with yourself about AfterMinutes.
Until the AI takes the desk, nobody is answering it. A unit who calls
dispatch two minutes after the last dispatcher signed off gets no answer if
this is set to five.
CAD integration
Send 911 calls, radio emergencies and unit positions to the CAD your server already runs, and let the CAD send calls and assignments back. Sal's Kewl CAD, CDE CAD, BackdraftCAD and Imperial CAD ship as drivers; anything else is a drop-in driver file, a config block, or one event handler in your own resource. Everything is off by default, capability by capability.
Drivers live in integrations/cad/, outside the asset escrow,
so they are readable and yours to edit or add to. Copy the example, fill in
your CAD's endpoints, point Config.CAD.Provider at it, restart.
Imperial's own convars are the credentials, so a server already running their
FiveM resource needs no extra setup. One thing worth knowing before you turn
Imperial on: its only close-shaped endpoint deletes the incident outright, so
a call ending here leaves the record in place unless you ask for otherwise.
The rule the integration obeys: the CAD is an accessory to a call, never a step in one. Nothing in the 911 or radio path waits on your CAD — a CAD that is down has exactly one symptom: the CAD does not have the data.
Setting it up (CDE)
Credentials go in server.cfg — the same convars CDE's own
resource uses, so if you already run it there is nothing to do here:
set CDE_CAD_API_URL https://your-cad.example
set CDE_CAD_API_KEY fvm_... # Admin Panel > FiveM Settings
set CDE_CAD_COMMUNITY_ID 000000000000000000 # your Discord guild id
set CDE_CAD_SERVER_NAME "Your Server"
Then turn on the parts you want in Config.CAD
(Push.Calls, Push.Panic, Push.Units,
Push.Location, Pull.Dispatch…).
/cadtest is the command to run first — it prints
the posture, fires one payload of each enabled kind, and the 911 test is a
full round trip that cleans up after itself.
If your CAD ships its own phone bridge,
Config.CAD.Pull.Calls.Ownership settles who owns 911:
'we-own' (recommended) or 'cad-owns'. The resource
warns at boot when it sees a second 911 owner running.
Running CDE? Every convar, every setting with its shipped default, duty profiles, station tone-outs and a symptom-by-symptom troubleshooting table are in the CDE CAD appendix.
Setting it up (Sal's Kewl CAD)
If you run our CAD, its bridge resource already holds the signed link to
it, so the radio needs no key, no url and nothing in server.cfg.
Start the bridge first, then:
ensure sals_kewlcadbridge # before sals_kewldispatch
Config.CAD = {
Provider = 'kewlcad',
Push = { Calls = { Enabled = true }, Panic = { Enabled = true }, Radio = { Enabled = true } },
Pull = { Dispatch = { Enabled = true, Talkgroup = 'LSPD1' },
Duty = { Enabled = true }, Status = { Enabled = true } },
}
A 911 call arrives in the CAD as a worked call, prefilled with the
caller, the location, what the call taker established and the text transcript
if they texted. Notes land on it, and when the 911 line ends the CAD is told
rather than closed, because the dispatcher working it there closes it when
the units clear. The emergency button becomes a PANIC call, PTT and pages are
logged, and a status a unit says on the radio moves their row. A call a unit
opens on the air is created as that unit, attached. Coming back, the CAD's
live board drives Pull.Dispatch and Pull.Duty, and a
plate, name, VIN, warrant or firearm serial check asked for on the radio is
run in the CAD as that unit and read back on the same talkgroup, warnings
first. Needs bridge 2.1.0 or newer.
Setting it up (BackdraftCAD)
set backdraft_api_key <your integration key>
set backdraft_api_url https://dev.backdraftcad.com/api/v1
Config.CAD = {
Provider = 'backdraftcad',
Push = { Calls = { Enabled = true } },
Pull = { Calls = { Enabled = true }, Dispatch = { Enabled = true, Talkgroup = 'LSPD1' },
Duty = { Enabled = true }, Status = { Enabled = true } },
}
It's the same integration key your CAD already uses; BackdraftCAD works
out which community you are from it. A 911 call becomes an incident filed
under one of your community's own call types, notes and dispositions land on
it, and it's closed rather than deleted when the call ends. A call a unit
opens on the air arrives with that unit assigned, a status they call in lands
on the incident they're working, and plate, person, vehicle, VIN and warrant
checks are answered on the air. Your dispatcher's calls come back to page
units and drop station tones, and the on-duty roster sets up each radio at
the start of a shift through Pull.Duty.
Their integration API is on their development server for now, and the url above is that server. Calls you send land there, not in your CAD, and the radio says so at every startup. When they move it to production, the url is the only thing that changes.
For a CAD nobody has written a driver for: Provider = 'event'
emits everything as events for your own resource, and
Provider = 'custom' posts the same payloads as JSON to endpoints
you name.
Fire script integration
If you run a fire script, the fires it starts can tone the fire department
out by themselves: station tones on the fire talkgroup, the location in the
announcement, and optionally a 911 call that rings the dispatch console.
Everything lives under Config.Fire.
This arrives switched off. Set
Config.Fire.Enabled = true when you want it. With it off,
nothing about how fires get dispatched on your server changes.
Written against Smart Fires (London Studios), which is
detected while the server runs and needs nothing configured beyond turning
this on. It is never listed as a dependency, so the resource still starts on a
server that does not run it. Any other fire script fits through
Config.Fire.Custom, or by calling one export.
A fire is not an incident
This is the part that makes it usable rather than maddening. Fire scripts spread, so one working structure fire is dozens of separate fires arriving over several minutes. If each one paged the station, the crew already riding to that job would be toned out forty times — and a pager that does that gets switched to STANDBY, and then it misses the next call.
So fires are grouped by place and time into one incident that tones out once:
| Setting | Default | What it does |
|---|---|---|
Cluster.Radius | 60.0 | A new fire within this many meters of a live incident joins it and pages nobody. |
Cluster.Seconds | 300 | How long an incident keeps swallowing fires. A fire in the same place after this is a new job — which is right: the first crew has gone. |
Cluster.SettleMs | 1500 | Wait this long before dispatching, so everything arriving alongside the first fire is in before the tones go out. |
MaxPerMinute | 6 | Hard ceiling on tone-outs per minute, on top of the grouping. Normal play never reaches it; it is what stops a misbehaving fire script turning every pager on the server into an alarm clock. |
MinSize | 0 | Ignore fires smaller than this. Size is read at the end of the settle, so a spark that has grown into a working fire still counts. |
Buckets | { 0 } | Routing buckets that page the fire department. A fire inside a housing or MLO instance is somebody's scripted scene, not a job. false pages from every bucket. |
What happens for each kind of fire
Config.Fire.Types is keyed by the fire type name your fire
script uses; anything it does not name falls through to
Config.Fire.Default.
Mode | What happens |
|---|---|
'tone' | Station tones on the configured talkgroups, straight to the pagers subscribed to that tone set. Needs nothing but the pager. |
'call' | A 911 call at the fire's own coordinates. It rings the dispatch console, it can be answered, it reaches your CAD, and when nobody takes it Config.Calls.Fallback tones the units out anyway. |
'both' | The tones now, the call alongside them. |
'none' | Ignore this kind of fire entirely. |
Config.Fire = {
Enabled = true,
Default = { Mode = 'tone', Tones = { 'ALLCALL' }, Talkgroups = { 'FIRE1' }, Nature = 'FIRE' },
Types = {
normal = { Tones = { 'STATION1' }, Nature = 'STRUCTURE FIRE', Mode = 'both' },
vehicle = { Tones = { 'STATION1' }, Nature = 'VEHICLE FIRE' },
grass = { Nature = 'BRUSH FIRE', MinSize = 3.0 },
rubbish = { Mode = 'none' },
},
}
The announcement is the fire's nature and where it is. The zone and postal
come from the same table 911 calls use
(Config.Calls.ALI), so filling that in once improves both. Where
your fire script describes a fire itself — Smart Fires does, for the
automatic ones — its description is used instead of
Nature.
Any fire script, and your own
exports['sals_kewldispatch']:ReportFire({
coords = vector3(x, y, z), size = 4.0, fireType = 'normal',
})
That is the whole integration for a script nobody has written an entry
for — two lines, and it gets the same grouping, routing and ceilings as
everything else. Every dispatched incident also fires
sals_kewldispatch:fire:incident for your own CAD or callout
script, before the tones go out.
Checking it
/firetest is the command to run first. It
prints which fire script was found, the grouping settings, the routes, every
counter and the last error — then fires one test incident at your feet
and prints the announcement it produced. “The pagers never went
off” has half a dozen possible causes and they look identical in game;
this tells them apart in one line. /radiodiag carries the same
summary.
Codeplug programmer & live config
/radioadmin opens the in-game codeplug programmer. It is built
like the software a real radio is programmed with rather than like a settings
dialog: the codeplug is a tree down the left, one editor sits beside it, the
actions are in a command strip along the top, and the line along the bottom
reports state and carries no buttons.
Under CODEPLUG: Talkgroups (add, edit,
reorder, delete — deleting one tells you which personalities reference it and
cleans them up), Personalities (each one its own row in the
tree, carrying jobs, zones, channel order, scan lists, feature flags and the
emergency talkgroup) and Pager. Under its own
SETTINGS heading, open when the panel opens, the
All settings branch holds every setting the resource has: 24 sections under five headings (Radio, Sound and
display, Dispatch and 911, Integrations, System), with a search box at the top
that finds a setting by its name, a word from its help or its config path and
draws the hits as real controls. A setting that isn't at its shipped default
is marked on its edge and offers ↺ default; the tree counts
the changed ones per section; one read once at boot carries a
RESTART tag and is named again after the save. Lists, maps and
ordered rows have their own editors. EXPORT SETTINGS and
IMPORT SETTINGS sit in the command strip (see
Configuration). A personality with no talkgroups in any
zone is flagged in the tree, because that is the one thing that cannot be
written. Under SYSTEM, Chassis shows every
radio this server can issue — shipped and installed alike, each with its
faceplate, which two are your defaults, and which are hidden from players by
Config.Models.Available — and Console layouts
lists the layouts your account has saved.
+ New personality… asks three questions, one screen each, instead of handing you twenty empty fields: who carries it (name, display prefix, jobs or catch-all, on-duty only), what they hear (click the talkgroups — click order is dial order, and each shows the position it will hold), and what they may do (scan, emergency, page, private call, encryption, dispatch monitor). It creates an ordinary personality and drops you on its card, which is where everything else is edited.
A moon/sun button switches the panel between light and dark, remembered per machine. The tool takes its accent from the radio's own display backlight, so an amber radio gets an amber programmer and an ice-blue one gets an ice-blue programmer.
Saving writes data/overrides.json and re-issues every radio
live — units keep their current zone/channel wherever the new codeplug still
has it. defaults.lua is never rewritten. Everything the
panel saves lives in overrides.json, and deleting that
file throws all of it away: talkgroups, personalities, pager tone
sets, RF sites, every panel setting. If a file setting is not taking effect
because the panel saved the same thing, change it in the panel rather than
deleting the file. If you really do want to start over, copy the file
somewhere first. The last
Config.Admin.BackupCount saves are kept, and Restore
archive loads the most recent back in. Write codeplug
commits; closing the window is the only cancel there is, and it asks before
discarding. Everything from the panel is re-validated server
side.
Talkgroup order matters. The array index determines the voice channel number. On a live server, append new talkgroups at the end rather than inserting in the middle, or you will re-map voice channels for everyone connected.
Adding a radio chassis
Several radio bodies ship with the resource, and players pick between them
in the chassis picker. From 2.25.0 you can also
install one on a server that is already running — no
restart, no fxmanifest.lua edit, nothing to copy into
html/models/.
A chassis arrives as a package: one file named
something.chassis.json, carrying the model's name, the position
of every control on it, and its faceplate picture. Open
/radioadmin and pick Chassis under SYSTEM.
| Install from URL | Paste a web address. Your server downloads the package itself, so you need no file access at all. Addresses inside your own network are refused. |
|---|---|
| Install from file | Upload the package into the resource's data/chassis/
folder, then type its filename here. |
| Re-read the folder | Reloads everything already installed, for when you have edited a package file by hand. |
| Remove | Takes a chassis away again. Anyone who had chosen it falls back to your default radio — nobody is left with a blank screen. |
Either way the radio is live at once. It appears in the chassis picker beside the ones that ship with the resource, players can choose it straight away, and you can issue it per personality from Settings › Radio models like any other.
Installed chassis do not need a line in
Config.Models.Available. That list is about the radios that ship
with the resource; a package is offered by being installed and withdrawn by
being removed.
Settings
Config.Models.Packages = {
Enabled = true,
AllowUrlInstall = true, -- false = only files you put in data/chassis/
AllowedHosts = nil, -- e.g. { 'downloads.example.com' }; nil = any public host
MaxFaceBytes = 768 * 1024,
MaxPackageBytes = 2 * 1024 * 1024,
}
Keep the faceplate small. The picture is sent to each player once and remembered for the rest of their session, so a large one is a cost everybody pays. Stay under 768 KB — the server refuses anything bigger and tells you the size it got.
data/chassis/ is where your installed radios
live. Back it up along with the rest of data/, and
never copy an old data/ folder over a live one.
A package can never replace one of the radios that ships with the resource, and it carries no code of any kind — a chassis is a picture and a list of buttons. Every value in one is checked by your server before it reaches any player, and anything it does not recognize is dropped.
Building one yourself takes an image editor and a text editor, and the whole format is in Appendix: designing your own chassis.
Audio
Voice coloring is a GTA audio submix applied to radio
traffic. Presets in Config.Audio.Presets:
| Preset | Character |
|---|---|
p25 | 350–2700 Hz passband, zero analog fudge, and a light ring-mod blend. The default, tuned against a real IMBE round trip. A vocoder empties the space between harmonics and a ring modulator fills it back in, so the blend stays low on purpose. |
p25_strict | The heaviest coloring. Same passband, a firmer edge. |
p25_light | The widest passband and the least processing. |
analog | Classic FM grit and distortion. |
off | Untouched voice. |
You do not have to edit a file to change any of this.
Config.Audio.Tuning overrides the passband and the edge on top of
whichever preset you picked, it is editable from your portal and from the
in-game codeplug programmer, and it applies to every radio live with no restart. Set
the preset, get on the air, and move one knob at a time until it sounds right
to you. Once you like it, copy the numbers into a preset so they survive as a
named thing rather than an override nobody remembers setting.
Signaling tones — talk-permit beep, bonk, courtesy tone,
squelch clicks, emergency warble, page alert, keypress clicks — are
synthesized in the UI; no audio files ship with the resource. Frequencies and
lengths are in Config.Audio.Tones, and
Config.Audio.DigitalArtifact is the short quantised-noise burst
when a transmission opens.
Exports & events
The commonly used server exports:
exports['sals_kewldispatch']:GetTalkgroupRoster('LSPD1') -- who is listening
exports['sals_kewldispatch']:IsTalkgroupBusy('LSPD1')
exports['sals_kewldispatch']:SendPageToTalkgroup('LSPD1', 'CAD', 'Signal 100')
-- 911
exports['sals_kewldispatch']:StartCall({ src = source, number = '911' })
exports['sals_kewldispatch']:EndCall(callId, 'reason')
-- pager
exports['sals_kewldispatch']:SendToneOut(
{ 'FIRE1', 'EMS1' },
{ 'STATION1', { a = 607.0, b = 1032.0 } },
'Structure fire, Vinewood Blvd', 'CAD')
exports['sals_kewldispatch']:SendPagerText(src, 'Respond to station', 'CHIEF')
-- fire script integration
exports['sals_kewldispatch']:ReportFire({
coords = vector3(x, y, z), size = 4.0, fireType = 'normal' })
-- jobs (standalone servers, or to override the framework)
exports['sals_kewldispatch']:SetPlayerJob(src, 'police', 4, true)
exports['sals_kewldispatch']:RefreshPlayer(src)
Three events fire for every 911 call, whatever else is configured:
sals_kewldispatch:call:determined -- somebody established what the call is
sals_kewldispatch:call:fallback -- nobody took it; the units are being paged
sals_kewldispatch:call:closed -- it ended, with disposition and notes
sals_kewldispatch:fire:incident -- a fire was dispatched (see Fire script integration)
StartCall accepts nature, priority,
location, a landline block for a known place, and
meta — a callout script that already knows what the call is
should say so. On qb/qbx/esx the framework outranks
SetPlayerJob: the next real job or duty event clears the
override. The full export list is in the README that ships with the
resource.
Troubleshooting
| Symptom | Fix |
|---|---|
| A player says they cannot talk on the radio | Have them run /radiocheck — it needs no permission
and names the cause and the fix. /radiodiag <id> puts
the same rows in your console. |
| Fires do not tone the station out | /firetest. It distinguishes the causes that all look like
silence: the feature off, the fire script not found, an event name that
changed, a routing bucket filter, a MinSize nothing reaches,
or a route with no talkgroup on it. |
| Anything at all | /radiodiag first. It prints the running version, which
framework / inventory / phone / CAD adapter resolved and by which export,
the voice backend, the link posture and the pma-voice key check. |
| A fix you just uploaded changes nothing | FXServer caches the manifest. After copying files, run
refresh, restart the resource, and confirm the version line
/radiodiag prints matches the CHANGELOG before testing. |
| Players key up twice / bypass arbitration | pma-voice's own radio key is still bound — have them unbind it in Settings → Key Bindings → FiveM. The radio warns them in chat, naming the key. |
| A caller or unit is silent on the console | They refused FiveM's microphone prompt. /radiomic
re-asks. The call card says CALLER HAS NO MICROPHONE when
this is the reason. |
/radioadmin does nothing |
Check the server console — a denial prints the player's identifiers
and the exact add_ace line to run. |
| The radio is stuck somewhere the mouse can't reach | /radiomove reset. Saved positions are clamped to the
screen on load, so a resolution change cannot strand it permanently. |
| The game stops taking input | /radiounfocus releases NUI focus if anything stranded
it. |
| A player says they "have no radio" | With Config.Access.ExplainDenials on (the default) the
refusal names the missing item or the job problem. Check their job is in a
personality and they hold the required item. |
Appendix: CDE CAD, end to end
The CAD integration section covers what the integration is and the one rule it follows. This appendix is the wiring detail: every convar, every setting with its shipped default, the exact endpoints, the order to turn things on in, and the failure each symptom maps to.
The CAD is an accessory to a call, never a step in one. Nothing in the 911 path or the radio path waits on the CAD, retries into it, or fails because of it. A CAD that is down, slow or misconfigured has exactly one symptom: the CAD does not have the data. The call still rings, the units are still paged, the dispatcher still works the position.
Everything here is off by default, capability by capability. Setting a provider and a key is not enough on its own, because sending your players' positions to a third-party service is not a decision we will make on your behalf.
Before you start
- A CDE CAD account with admin access, so you can read Admin Panel → FiveM Settings.
- Your Discord guild id. CDE uses it as a second authentication factor.
- Your players linking Discord to FiveM. CDE keys every unit read by Discord id, so an unlinked player cannot be matched to a status or a duty profile. This is the most common reason a correct configuration appears to do nothing at all.
- Your department names exactly as the CAD spells them. Case does not matter, the words do.
Config.Pager.Enabledon, if you want station tone-outs from CAD assignments.- A decision about who owns 911, made before you turn anything on.
Credentials
Credentials go in server.cfg, and they are the same convars
CDE's own resource reads. If you already run it, there is nothing to configure
here at all:
set CDE_CAD_API_URL https://your-cad.example # no trailing /api
set CDE_CAD_API_KEY fvm_... # Admin Panel > FiveM Settings
set CDE_CAD_COMMUNITY_ID 000000000000000000 # your Discord guild id
set CDE_CAD_SERVER_NAME "Your Server" # optional; defaults to sv_hostname
| Convar | defaults.lua equivalent | Notes |
|---|---|---|
CDE_CAD_API_URL | Auth.BaseUrl | Origin only. It must not end in /api, because every endpoint path already includes it. The driver refuses to start and says why. |
CDE_CAD_API_KEY | Auth.ApiKey | Starts with fvm_. The boot log warns if yours does not. |
CDE_CAD_COMMUNITY_ID | Auth.ServerId | Your Discord guild id. |
CDE_CAD_SERVER_NAME | Auth.ServerName | How this server is labeled inside the CAD. |
A value written in defaults.lua overrides the convar of the same
meaning. Use that for the URL or the server name if you like, but not for the
key: defaults.lua is the file you copy between servers and hand to
developers, and it is the file an update replaces. The boot line and
/cadtest both print where each credential came from, so a stale
convar and a forgotten config value cannot quietly fight.
The shape of the traffic
CDE is polled, not pushed. Its live events serve its own dispatch front end rather than FiveM, so everything inbound arrives on a timer you set. Two capabilities share one request in each direction, and the faster interval wins.
| Capability | Direction | CDE endpoint |
|---|---|---|
Push.Calls | outbound | POST /api/fivem/911, then PATCH /api/fivem/call/{ref} and POST /api/fivem/call/{ref}/close |
Push.Panic | outbound | POST /api/fivem/panic |
Push.Units, Push.Location | outbound | POST /api/dispatch/location-update |
Pull.Calls, Pull.Dispatch | inbound (poll) | GET /api/fivem/dispatch/calls, one shared request |
Pull.Status, Pull.Duty | inbound (poll) | GET /api/fivem/dispatch/units, one shared request |
Push.Radio | local only | CDE publishes no radio-traffic endpoint. The driver declines the kind, the boot log says so once, and the event still fires locally for your own consumer. |
Every request spends the same rate limit as your 911 calls.
Units × 60 ÷ IntervalSeconds is requests per minute.
30 units every 20 seconds is 90 a minute. Set the live map too fast and you do
not degrade the live map, you start losing calls. The boot log warns when your
configured worst case goes over RateBudget.
Turning it on, one capability at a time
This order exists so that when something does not work, only one thing
changed. Run /cadtest after each step.
Step 1, prove the credentials. Nothing but 911 calls, outbound:
Config.CAD = {
Provider = 'cdecad',
Push = { Calls = { Enabled = true, Include = 'determined', Notes = true, Close = false } },
}
/cadtest does a complete create, correlate and close round trip
for this one, so it proves the part that matters (their incident id comes back
and we can act on it) and leaves nothing behind in your CAD.
Step 2, add Push.Panic and
Push.Units. Step 3, add Push.Location
once you have done the arithmetic above. Step 4, add
Pull.Dispatch and watch a CAD assignment page the radio.
Step 5, add Pull.Duty last, and read the on-duty
match count in /cadtest before concluding that nobody's radio
changed.
Every setting, with its shipped default
| Setting | Default | What it does |
|---|---|---|
Provider | 'none' | 'none', 'cdecad', 'event' (emit locally, send nothing), or 'custom' (your own endpoints). |
Auth.TimeoutMs | 4000 | Ceiling on how long one message may hold a worker. Not a promise of delivery. |
Auth.Retries | 1 | Only for rate limits and server errors, only for calls and panics. Retry loops are how API keys get flagged. |
Auth.QueueSize | 200 | Messages held while the CAD is slow. Over this, the least important are dropped first. |
Auth.MaxInFlight | 2 | Concurrent requests. Keep it small. |
Auth.XPayload | false | Repeat the body in an x-payload header. CDE's documented workaround for Cloudflare configurations that strip FiveM request bodies. Turn it on only if /cadtest shows requests arriving empty. |
Push.Calls.Include | 'determined' | 'determined' sends calls somebody established the nature of, plus calls that went unanswered and fell back to the radio. 'all' sends everything, creating an uncharacterized record when the call closes. |
Push.Calls.Notes | true | Notes, disposition and radio pages go up as updates. |
Push.Calls.Close | false | Close the CAD's record when the call ends. Leave it off if your dispatchers close incidents in the CAD themselves. |
Push.Units.Jobs | police, ambulance, fire | Which jobs are reported coming on and off the radio. An empty list means everyone holding a radio, which on most servers includes civilians. |
Push.Location.IntervalSeconds | 20 | How often positions go up. |
Push.Location.MaxUnits | 30 | Ceiling on units reported per pass. |
Push.Location.RateBudget | 100 | Requests per minute you are willing to spend. Used to warn you at boot, nothing else. |
Push.Radio.Include | tx = false | Leave tx off. Every key-up on a busy server is several messages a second, which is not an integration, it is a denial of service paid for with your own API key. CDE has no endpoint for this anyway. |
Push.Panic.Enabled | false | The radio's emergency button as a CAD panic. The one radio event every CAD has somewhere to put. |
Pull.Calls.Ownership | 'we-own' | Decides whether one incident becomes two records. See below. |
Pull.Calls.IntervalSeconds | 10 | How often the CAD's call feed is read. |
Pull.Calls.Service | nil | Which of Config.Calls.Services a CAD call arrives on. Defaults to Config.Calls.DefaultService. |
Pull.Dispatch.Talkgroup | nil | Where an assignment pages. Falls back to Config.Calls.Fallback.Talkgroup. |
Pull.Dispatch.Alias | 'CAD' | What the page appears to come from on the radio display. |
Pull.Dispatch.ByDepartment | {} | Per-department talkgroup routing, checked before Talkgroup. |
Pull.Dispatch.ToneOuts | {} | Departments that get real station tones instead of a text page. |
Pull.Status | off, 15 s | Emits an event when a unit's status changes in the CAD. Acts on nothing by design: what "10-8" means belongs to your job scripts. |
Pull.Duty | off, 15 s | The CAD roster decides each unit's radio. |
Duty profiles: the CAD decides the radio
Normally the radio works out a player's codeplug from their job name. With
Pull.Duty on, the CAD's own on-duty roster decides instead. When a
unit goes on duty in the CAD, their radio here is set up from the profile their
department maps to: the codeplug (personality), the chassis they were issued,
and their CAD callsign as the radio's PTT alias suffix. Off duty, the profile is
released and resolution goes back to the job.
Config.CAD.Pull.Duty = {
Enabled = true,
ByDepartment = { -- the department string exactly as the CAD reports it
['Police Department'] = { personality = 'police', portable = 'cx8000', mobile = 'nc700m' },
['Sheriff'] = { personality = 'sheriff' },
['Fire Department'] = { personality = 'fire' },
['EMS'] = { personality = 'ems' },
},
Default = nil, -- profile for unmapped departments; {} = callsign only
UseCallSign = true, -- "1-A-12" in the CAD becomes the alias suffix
OffDuty = 'release', -- or 'keep': leave the last profile standing
IntervalSeconds = 15, -- shares one request with Pull.Status; faster wins
Notify = true, -- tell the player when their radio is set up or released
}
personality must be a key of Config.Personalities.
portable and mobile are chassis ids from
Config.Models, and you can leave them out to keep whatever chassis
the personality normally issues.
- Matching is by Discord id. That is how CDE keys its unit
reads, so a player who has not linked Discord cannot be matched. Same
limitation as
Pull.Status. - The agency prefix still comes from the personality. The
CAD names the unit, never the agency, which is the same rule
/radioaliasand the hosted console follow. Nobody gets on the air as LSPD by editing a callsign in a CAD. - Precedence is pin, then duty, then job. A personality
pinned with the
SetPlayerPersonalityexport still wins, the duty profile beats job resolution, and the job is the fallback. - Item gates still apply. A duty personality that requires a radio item the player is not carrying refuses the radio exactly as a job-resolved one would.
- A bad personality name is refused; a bad chassis id is not.
An unknown
personalityfails the whole profile, loudly, because a typo in a mapping must not leave a unit quietly on the wrong codeplug. An unknown chassis id is dropped with a console warning and the rest of the profile still applies, because the codeplug is the load-bearing half and should not be lost to a cosmetic typo. - A department that matches nothing changes nothing, and the
console says so once, so a misspelling cannot hide.
Default = {}is useful on its own: no codeplug change, but the CAD callsign still lands on the radio. - The pager is separate. It resolves from
Config.Pager.Personalitiesand is not touched by a duty profile. UseSetPlayerPagerPersonalityif your CAD should drive that too.
A CAD that runs its own resource on your server, or any duty script of yours,
can drive the same machinery directly with no Config.CAD at all:
exports['sals_kewldispatch']:SetPlayerDutyProfile(src, {
personality = 'police', -- codeplug for the shift
portable = 'cx8000', -- chassis issued for the shift (optional)
mobile = 'nc700m',
suffix = '1-A-12', -- PTT alias suffix (optional)
})
exports['sals_kewldispatch']:SetPlayerDutyProfile(src, nil) -- off duty; releases
exports['sals_kewldispatch']:GetPlayerDutyProfile(src) -- what is applied, or nil
Every apply and release also fires
sals_kewldispatch:cad:duty with
{ src, onDuty, unit, callSign, department, profile }, so your own
scripts can follow along.
Pages and station tone-outs
When the CAD assigns units to a call, Pull.Dispatch pages the
mapped talkgroup so units hear the assignment on the radio instead of only
seeing it on a screen. A department listed in ToneOuts gets the real
thing: a two-tone station tone-out, identical to a dispatcher pressing TONES on
the console. Every pager subscribed to that tone set decodes and alerts, and the
call (type, location, postal) is the announcement text on every alerted pager and
radio.
Config.CAD.Pull.Dispatch = {
Enabled = true,
Talkgroup = 'LSPD1', -- plain pages for everyone else
Alias = 'CAD',
ByDepartment = { ['Sheriff'] = 'BCSO1', ['EMS'] = 'EMS1' },
ToneOuts = {
['Fire Department'] = { Tones = { 'STATION1' }, Talkgroups = { 'FIRE1' } },
['EMS'] = { Tones = { 'MEDIC1' } }, -- Talkgroups defaults to the page talkgroup
},
}
- Any service with station tones can be mapped, not just fire and EMS. Wreckers, lifeguard, search and rescue.
- Tone ids come from the tone-set catalog (
Config.Pager.ToneSets, or what you last saved from the Pager tab), and raw{ a = Hz, b = Hz }pairs work too, up to four. NeedsConfig.Pager.Enabled. - This runs whether or not a dispatcher is signed on. The CAD assigning units is what drops the tones, so a fully CAD-run department never needs a live console position for its stations to turn out.
- If the tone-out cannot fire (pager off, or a tone set that does not exist), the plain text page goes out instead. Never silence, and the console warns once naming the reason.
- For calls nobody answers at all,
Config.Calls.Fallback.ToneOutdrops one fixed tone set, typically an all-call. One fixed set on purpose: an unanswered call cannot be triaged.
Who owns 911
CDE ships its own LB Phone 911 bridge, so a server can end up running two
systems that each believe they own 911. The symptom is duplicate incidents, or
none, depending on start order. We warn at boot when we see one of those
resources running. Config.CAD.Pull.Calls.Ownership settles it:
'we-own' (recommended) means this resource takes the call and
creates the CAD record, and calls the CAD created are not mirrored back.
'cad-owns' means the CAD's own bridge takes the call, we create
nothing, its calls ring the 911 queue here, and we only ever add notes and
close.
cad-owns with Pull.Calls off is half an
arrangement. The CAD takes the calls and they never come back to a
dispatcher here. The boot log calls this one out by name.
Events and exports
| Event | Fires when |
|---|---|
sals_kewldispatch:cad:duty | A duty profile is applied to or released from a player. |
sals_kewldispatch:cad:unitStatus | A unit's status changed in the CAD (Pull.Status). An event, not an action. |
sals_kewldispatch:cad:call, :call_update, :call_close, :unit, :location, :radio, :panic | Every outbound payload, mirrored locally. These fire in every provider mode, so you can watch exactly what the driver is handed before you send anything anywhere. |
| Export | What it is for |
|---|---|
SetPlayerDutyProfile(src, profile) | Apply or release a duty profile. nil releases. |
GetPlayerDutyProfile(src) | The profile currently applied, or nil. |
CadPush(kind, payload) | Push a fact at the CAD through the port's queue, deadline and capability gating. Use this rather than your own PerformHttpRequest. |
CadReference(callId) | The CAD's own id for one of our calls. nil until the create round trip finishes, which is normal for the first second or two of a call. |
CadUnitStatus(payload) | A deliberate unit status change, correlated to the assigned incident, with an honest answer about whether anything was sent. |
CadStatus() | The posture and counters /cadtest prints, as a table. |
SendToneOut(tgIds, tones, text, alias), SendPagerText(src, text, alias) | Drop tones or a pager text directly, through the same validated path the console's TONES button uses. |
Reading /cadtest
Run it from an admin account. It exists because the failure this integration
actually has is silence, and silence looks identical whether the
cause is a disabled capability, a missing key, a driver with no endpoint for that
kind, a full queue, or a CAD that is quietly returning 401. One command separates
all five. It prints the provider and base URL, where each credential came from,
which capabilities are enabled, how many on-duty units currently match a duty
mapping, and then one payload of each enabled kind with what was sent and what
came back. /radiodiag carries the same posture plus running
counters, and it is what to attach to a support ticket.
Troubleshooting
| Symptom | Where to look |
|---|---|
| Nothing arrives in the CAD | /cadtest first, always. It distinguishes the five causes that all look like silence. |
| The driver refuses to start | A BaseUrl ending in /api. Every endpoint path already includes it, and the console says so by name. |
401 or 403 in /cadtest | The key or the community id is wrong, or not authorized. Re-copy the fvm_ key from Admin Panel → FiveM Settings. |
| 429, rate limited | Lower your push rates, usually Push.Location. Units × 60 ÷ interval is requests per minute, spent from the same limit as your 911 calls. |
| Requests arrive with an empty body | Some Cloudflare configurations strip FiveM request bodies. Set Config.CAD.Auth.XPayload = true, which is CDE's documented workaround. |
Nobody's radio changes with Pull.Duty on | The match count in /cadtest. Zero means the departments in ByDepartment do not match what the CAD reports, or those players have no linked Discord. |
| A profile applied, but the chassis did not change | An unknown chassis id is dropped with a console warning while the rest of the profile applies. Check the id against Config.Models. |
| Assignments page, but tones never drop | The department name in ToneOuts has to match what the CAD reports, Config.Pager.Enabled has to be on, and the tone ids have to exist in Config.Pager.ToneSets. A failed tone-out warns once and sends a text page instead. |
| Duplicate 911 incidents, or none | Two systems both think they own 911. See above, and the boot warning that names the other resource. |
| Calls exist only in the CAD | Ownership = 'cad-owns' with Pull.Calls off. |
A CAD outage is never a game outage. Outbound messages queue (bounded, least important dropped first), every request owns its own deadline, and failures are summarized every five minutes rather than flooding your console.
Any other CAD
Two supported ways in, neither of which needs us to have written a driver.
Provider = 'event' emits sals_kewldispatch:cad:* and
sends nothing, which is both a first-class integration route and the way to see
exactly what your endpoint will receive. Provider = 'custom' posts
those same payloads, as JSON and unchanged, to endpoints you describe in
Config.CAD.Custom. Run 'event' first and look at what
comes out, then describe your CAD. A kind with no endpoint listed simply is not
supported, and the boot log says so.
Appendix: designing your own chassis
A chassis is a picture and a list of rectangles. That's the whole idea, and it's why you can build one with nothing but an image editor and a text editor. This appendix is the entire format.
What you're actually making
The picture is the radio. The resource draws nothing on top of it except the live display and a set of invisible buttons, each sitting exactly over the control it stands for. So a chassis is a front-on picture of a control head with the background removed, plus a list saying this rectangle is the display, this one is the push-to-talk, these three are the soft keys.
Nothing is drawn for you and nothing is guessed. If a rectangle sits two pixels left of the key it represents, the button works and looks two pixels off. If you forget a rectangle, that button simply isn't there. Both are easy to fix and neither breaks anything else.
The picture
| Front on, flat | A three-quarter view can't have rectangles laid over it honestly — the buttons and the picture will disagree more the further from center they sit. |
|---|---|
| Transparent background | PNG with an alpha channel. Whatever isn't transparent is the radio, including any drop shadow you leave behind, so knock it all out. |
| About 900 px wide or more | For a portable. Below that it looks soft in game. JPEG and WebP work too, but a photograph with a knocked-out background wants PNG. |
| Under 768 KB | The ceiling the server enforces. It travels to every player who equips the radio, so smaller is genuinely better. |
| Yours to use | A photograph of a real radio belongs to whoever took it, and a manufacturer's product shot belongs to the manufacturer. Use renders you made, photographs you took, or artwork you licensed. |
Enlarging a small picture doesn't add detail. A 300 px photo scaled to 900 has 300 px of information in it however it's resampled, and it will look exactly that soft on the radio. The answer to a blurry chassis is a bigger original, not a bigger file.
Coordinates
Every number is in image pixels, measured on the picture you're shipping, with 0,0 at
its top-left corner. A box is x, y (its top-left corner) and w,
h (its size).
To read them off, use any editor whose rectangular selection reports position and size: in GIMP, the Rectangle Select tool's Tool Options shows Position and Size as you drag; in Photoshop it's the Info panel; in Paint.NET it's the status bar. Drag a box round each control, write down four numbers, move on. A control head is usually fifteen to twenty of them and takes about twenty minutes the first time.
imageScale is image pixels per on-screen pixel, and it's how you size the radio in game
without touching the picture. 1 draws it at its own size; 2 draws a 2× picture
at half, which is how you use a large source crisply; 0.8 draws a small picture a little larger.
It scales the boxes with the picture, so you never re-measure.
The file
One JSON file, named <id>.chassis.json. Here is a complete, minimal control head:
{
"format": "sals_kewldispatch.chassis/1",
"id": "mk700m",
"kind": "mobile",
"brand": "Marlowe",
"label": "MK-700M",
"note": "Single-DIN control head. Three soft keys, no keypad.",
"version": "1.0.0",
"author": "Your name",
"width": 446, "height": 164,
"imageScale": 0.8,
"softKeys": 3,
"keypad": false,
"screen": { "x": 118, "y": 57, "w": 127, "h": 61, "bg": "#e6eae2" },
"hotspots": [
{ "act": "ptt", "x": 55, "y": 109, "w": 44, "h": 44, "shape": "circle",
"title": "Push to talk" },
{ "act": "emerg", "x": 388, "y": 51, "w": 22, "h": 22, "shape": "circle" },
{ "act": "knob-chan", "x": 372, "y": 102, "w": 68, "h": 62, "shape": "circle",
"title": "Scroll to change channel" },
{ "act": "knob-vol", "x": 292, "y": 55, "w": 70, "h": 100 },
{ "act": "menu", "x": 264, "y": 134, "w": 21, "h": 22 },
{ "act": "back", "x": 220, "y": 135, "w": 32, "h": 20 },
{ "act": "up", "x": 264, "y": 54, "w": 21, "h": 20 },
{ "act": "down", "x": 264, "y": 78, "w": 21, "h": 20 },
{ "led": true, "x": 69, "y": 77, "w": 19, "h": 9 },
{ "mark": true, "x": 150, "y": 41, "w": 68, "h": 14 },
{ "labels": true, "x": 120, "y": 100, "w": 123, "h": 14 },
{ "soft": 0, "x": 113, "y": 135, "w": 33, "h": 20 },
{ "soft": 1, "x": 150, "y": 135, "w": 32, "h": 20 },
{ "soft": 2, "x": 185, "y": 135, "w": 32, "h": 20 }
],
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…"
}
image is the picture itself, base64-encoded, with that data:image/png;base64,
prefix in front of it. Most languages will do it in one line; on Windows,
certutil -encode face.png out.txt writes the base64 and you strip the header and newlines. It's
the last field on purpose — it's hundreds of kilobytes of one line, and everything you'll actually edit
is above it.
| Field | What it is |
|---|---|
format | Exactly sals_kewldispatch.chassis/1. It's how the server
knows what it's been handed. |
id | Lowercase letters and digits, starting with a letter. It's how the chassis is stored and referred to forever, so pick one you won't want to change. It can't be the id of a chassis that ships with the resource. |
kind | portable (carried on foot) or mobile (a control
head in a vehicle). A chassis is only ever offered for its own slot. |
brand, label | Maker and model designation, as shown in the picker and printed on the chassis. |
note | One line in the picker, under the name. |
version, author | Shown on the Chassis page. Bump the version when you re-issue a corrected package; that's what tells players' radios to fetch the new picture. |
width, height | The picture's real size in pixels. Get these wrong and every box lands in the wrong place. |
imageScale | Image pixels per on-screen pixel. See Coordinates above. |
softKeys | How many soft keys the display has. The radio adapts its menus to
this, so it must match the number of soft boxes. |
keypad | true if the radio has a DTMF keypad. Drives unit call and
directed page. |
screen | The display. Required — without it the chassis is a dead
lump of plastic. bg is the panel color as #rrggbb; the resource works out
readable text colors from it, and a light panel gets light-LCD treatment automatically. |
hotspots | Everything else, up to 64 entries. Each is a box plus exactly one role from the role table below. |
Any box may also carry "shape": "circle" (round button, round press highlight) and
"title", which is the tooltip a player sees when the mouse is over it. Use titles for anything
that isn't obvious — especially a control with no button of its own, like a mic socket that transmits or
a speaker grille you scroll for volume.
The roles
A box says which of the radio's existing controls it is. It can never say what a control does — that's fixed, and it's why installing a chassis is safe.
| Role | The control |
|---|---|
"act": "ptt" | Push to talk. Every chassis needs one, and the server refuses a package without it. On a control head, put it over the microphone socket or the hand mic. |
"act": "emerg" | Emergency. Held, not clicked; the hold ring draws itself around whatever shape you gave the box. |
"act": "up" / "down" | Channel up and down — and menu movement while a menu is open. |
"act": "zone-prev" / "zone-next" | Zone down and up; menu back and select while a menu is open. |
"act": "menu" | Open and close the main menu. |
"act": "back", "sel" | Menu back, menu select. |
"act": "scan" | Toggle scan. |
"act": "power" | Toggle power. |
"act": "keypad" | Expand and collapse the DTMF keypad, on a chassis that draws one as a separate panel. |
"act": "knob-chan" | Scroll the wheel over this to step channels. Put it over the channel dial. |
"act": "knob-vol" | Scroll to change volume. Over the volume knob — or over the speaker grille, on a head that has no volume control of its own. |
"soft": 0… | A soft key, numbered left to right from zero, with no gaps. One box per physical key. |
"key": "1" | One keypad key. 0–9,
* and #. |
"led": true | The transmit/receive lamp. By default it paints a dark lens over the
picture and lights from there, because product shots are taken with the radio powered up and a lit lamp
would otherwise look permanently on. Add "unlit": "none" if your picture's lamp is genuinely
dark. |
"mark": true | Where the maker's mark is printed. Your picture already shows it,
so nothing is drawn here — until a server uses Config.Models.Marks to hide it or
replace it with their own badge, and then this is the patch that covers it. |
"labels": true | Not a control: the strip on the display where the soft-key labels are printed. Mark it and the live labels land exactly there, each one centered over its own key. Leave it out and they go along the bottom of the screen, which usually looks fine and occasionally doesn't. It must sit inside the screen box. |
Things that will bite
| No PTT | Refused outright. It's the one control a radio can't be without, and it's the failure nobody notices until somebody can't talk. |
| Soft keys numbered with a gap | 0, 1, 3 leaves a hole. Number them left to
right from zero, and make softKeys match how many there are. |
A labels box outside the screen | The label strip is part of the display. Outside it, the labels are drawn over the case. |
Colors that aren't #rrggbb | Silently dropped — six hex digits with
a leading hash, nothing else. red and #fff don't count. |
| Boxes far off the picture | A control may overhang the faceplate by up to a quarter of its size, because real ones do. Further out, it's clamped back. |
| Re-using an id | Installing a package whose id is already installed replaces it,
which is how you ship a correction. Bump version when you do, so players' radios know to
fetch the new picture. |
Trying it
Install it the same way you'd install anybody else's: /radioadmin › Chassis, then
either paste a URL or drop the file in data/chassis/ and type its name. It's live immediately, so
the loop is fast — fix a number, re-install, look again. Reinstalling replaces what was there.
Then pick it with /radiomodel and take the tour — MAIN MENU › RADIO GUIDE
on the radio itself, or the first button in /radioguide. It reads the chassis actually on screen and
walks you through every control it finds, naming each one, which is the quickest way to discover you've labeled
the volume knob as the channel knob. After that, press everything.
When the server refuses it
Refusals are deliberately specific, and they all appear on the status line of the Chassis page.
| What it says | What to do |
|---|---|
bad_format | The format field is missing or isn't
sals_kewldispatch.chassis/1. |
bad_id | The id isn't lowercase letters and digits starting with a letter. |
id_is_shipped, id_is_legacy | That id belongs to a chassis that ships with the resource. Pick another. |
no_screen | No screen box. The radio needs somewhere to paint. |
bad_dimensions | width or height is missing, or under
40 px. |
no_ptt | Nothing is marked "act": "ptt". |
face_not_data_uri | image doesn't start with
data:image/png;base64,. |
face_bad_type | PNG, JPEG and WebP only. An SVG is a document, not a picture, and is never accepted. |
face_too_large | Over MaxFaceBytes. The message gives both
numbers. |
not_json | The file isn't valid JSON — usually a trailing comma or a smart quote from a word processor. Use a text editor. |
refused_private_host | The URL points inside your own network. Put the package
somewhere publicly reachable, or install it from data/chassis/ instead. |
fetch_failed | The download didn't return 200. Check the link in a browser — a lot of file hosts serve a preview page rather than the file. |
file_not_found | No such file in data/chassis/. Check the spelling
and that the upload finished. |
packages_disabled, url_install_disabled | Switched off in
Config.Models.Packages. |
Anything the server doesn't recognize is dropped, not refused. A box with a role from a newer version of the resource, or a field we haven't invented yet, is quietly ignored and the rest of the chassis installs. So if a control is missing rather than wrong, check its role against the table above first.
Support
Support, release news and coupon codes are on
Discord.
Purchases are handled by Tebex, who own the checkout, billing support and
refunds. The resource ships with its own README and CHANGELOG — the CHANGELOG
calls out breaking config changes explicitly, and /radiodiag
prints the version you are actually running.
In-game radio and dispatch console are separate products — all pricing is on the main site.