Part 6 of Trunking Engine. Part 5
showed priority deciding which call keeps a radio — a policy that reads the
talkgroup’s Priority and Lockout fields. This post opens the database those
fields live in, and the scan modes that decide which grants are even eligible for
a radio in the first place.
TL;DR:
TalkgroupDBis a thread-safe map from a 32-bit talkgroup ID to aTalkGrouprecord — alias, priority, lockout, and per-talkgroup gates for scan, record, stream, and mute. It loads from Trunk-Recorder-style CSV or JSON, with every boolean gate defaulting to the backwards-compatible value. Two scan modes govern dispatch:ScanModeAllfollows every non-locked-out grant (the legacy default),ScanModeListfollows only talkgroups flaggedScan. The mode is read undermodeMuso the cockpit can flip it live —SetScanMode— without restarting the daemon.
Key takeaways
- A
TalkGroupis the numeric ID plus operator-meaningful metadata and four independent boolean gates:Scan,Record,Stream,Mute. - Every gate defaults to the legacy behavior so a plain community CSV keeps
working —
Scan/Record/Streamdefault true,Mute/Lockoutfalse. Lookupis anRWMutex-guarded map read; the loaders sanitize imported text and skip malformed rows rather than failing the whole file.- Scan mode is a runtime knob:
ScanModeListenforces the scan list,ScanModeAllignores it, andSetScanModeflips between them under a lock without a restart.
Cheat sheet
| Field / mode | Default | Effect |
|---|---|---|
TalkGroup.AlphaTag |
"" |
operator-facing name for the numeric ID |
TalkGroup.Priority |
0 (unset) |
preemption rank (Part 5) |
TalkGroup.Lockout |
false |
grant dropped before dispatch (Emergency bypasses) |
TalkGroup.Scan |
true |
participates in ScanModeList |
TalkGroup.Record / Stream / Mute |
true/true/false |
per-TG recorder, upload, live-audio gates |
ScanModeAll |
default | follow every non-locked-out grant |
ScanModeList |
— | follow only Scan==true talkgroups (+ Emergency) |
SetScanMode(m) |
— | flip mode at runtime under modeMu |
In this post
- The
TalkGrouprecord — metadata plus four independent gates. - The database —
Lookup, theRWMutex, and forgiving loaders. - Lockout and the scan-list gate — where a grant can be dropped.
- Scan modes at runtime —
modeMu,SetScanMode, and the live cockpit.
The TalkGroup record
A talkgroup is a numeric ID on the wire and nothing else — 32181 means nothing
to an operator. The TalkGroup record is what attaches meaning and policy to it:
// internal/trunking/talkgroup.go (shape)
type TalkGroup struct {
ID uint32 // the on-air talkgroup number
AlphaTag string // "PD Dispatch 1" — the operator-facing name
Description string
Tag string // department / category
Group string // top-level group
Priority int // 1 = highest, 10 = lowest, 0 = unset
Lockout bool // drop grants for this TG (Emergency bypasses)
Scan bool // participate in ScanModeList
Stream bool // upload completed calls to broadcast aggregators
Record bool // write calls to disk
Mute bool // suppress from the live audio player
Icon string // per-TG glyph for the UIs
}
The metadata half (AlphaTag, Description, Tag, Group, Icon) is what the
web UI and TUI render so a call shows “PD Dispatch 1” instead of 32181. The
policy half is four independent boolean gates, and the independence is the
point:
Scan— does this talkgroup participate when the engine runs inScanModeList? (Covered below.)Record— write the call’s WAV to disk? A talkgroup can be followed and played live but never recorded.Stream— upload completed calls to the outbound aggregators (Broadcastify Calls, RdioScanner, OpenMHz)? A sensitive talkgroup can record locally but stay off every public feed.Mute— suppress from the host’s speakers while still following, recording, and streaming.
Because they’re independent, an operator composes exactly the behavior they want:
record-but-don’t-stream for a sensitive channel, follow-but-mute for a chatty
one, and so on. Priority and Lockout are the two the rest of the engine reads
— priority in Part 5,
lockout below.
The database: Lookup and forgiving loaders
TalkgroupDB wraps a map behind an RWMutex:
// internal/trunking/talkgroup.go
type TalkgroupDB struct {
mu sync.RWMutex
tgs map[uint32]*TalkGroup
}
func (d *TalkgroupDB) Lookup(id uint32) *TalkGroup {
d.mu.RLock()
defer d.mu.RUnlock()
return d.tgs[id] // nil if unknown
}
Lookup returns nil for an unknown ID — a first-class state the engine handles
by discovering the talkgroup from the air (cataloguing it so the UI fills in
from the wire, the Trunk-Recorder behavior operators expect). The RWMutex lets
many concurrent lookups run while the API mutates a record: UpdateFields(id, fn)
applies a closure under the write lock so the cockpit can change Priority or
Lockout without exposing the raw pointer, and Add/Delete manage records.
The loaders are deliberately forgiving. LoadCSV reads a Trunk-Recorder /
RadioReference CSV — the only required column is a numeric Decimal/DEC — and
matches optional columns case-insensitively by header. An empty file is a
legitimate “no talkgroups” state, not an error; a malformed row is skipped, not
fatal; and every text column is run through a sanitizer that strips imported
mojibake. Critically, each gate defaults to the legacy value so a bare CSV
with no Scan/Stream/Record column keeps the old “follow, record, and stream
everything” behavior:
// internal/trunking/talkgroup.go — per-row defaults
tg := &TalkGroup{ID: uint32(idVal), Scan: true, Stream: true, Record: true}
// …then only an explicit "no"/"false"/"0"/"n" flips a gate off.
LoadJSON mirrors this with *bool pointers for the gates, so a missing key
resolves to the same defaults while an explicit "scan": false opts out. The
same conventions are shared with the RID database in
Part 7 —
the per-radio analogue of this file.
Lockout and the scan-list gate
A grant can be dropped in two places before it ever reaches allocation, both
early in HandleGrant.
Lockout is unconditional (except for emergencies):
// internal/trunking/engine.go (shape)
if tg != nil && tg.Lockout && !g.Emergency {
e.log.Info("grant locked out", "grant", g.String(), "tg", tg.AlphaTag)
return
}
A locked-out talkgroup is one the operator never wants — its grants are dropped
before priority, before allocation, before anything. Emergency is the one
override, the same exception lockout and the scan gate both honor.
The scan-list gate is conditional on the scan mode:
// internal/trunking/engine.go (shape)
if e.ScanMode() == ScanModeList && !g.Emergency {
scanned := tg != nil && tg.Scan
for _, m := range g.PatchedGroups { // a patch passes if any member is scanned
if mt := e.talkgroups.Lookup(m); mt != nil && mt.Scan {
scanned = true
break
}
}
if !scanned {
e.log.Debug("grant not in scan list", "grant", g.String())
return
}
}
In ScanModeAll this block is skipped entirely. In ScanModeList a grant passes
only if its talkgroup is flagged Scan — with two carve-outs that mirror the
policy elsewhere: Emergency always passes, and a patched super-group
(Part 9)
passes if the super-group or any member is scanned. An unknown talkgroup ID in
ScanModeList is dropped — there’s no way to know an uncatalogued TG is
scannable.
Scan modes at runtime
The two modes are a tiny enum with a safe parser:
// internal/trunking/scanmode.go
type ScanMode uint8
const (
ScanModeAll ScanMode = iota // default: follow every non-locked-out grant
ScanModeList // follow only Scan==true talkgroups (+ Emergency)
)
// ParseScanMode maps "" and any typo to ScanModeAll so a config mistake
// never silently silences the daemon.
ScanModeAll is the default for backwards compatibility — pre-scanner configs
see no change. ScanModeList turns the Scan flags into an active allow-list.
The safe-default parser matters: an empty or misspelled config value resolves to
all, so a typo can’t accidentally make the daemon follow nothing.
The interesting part is that the mode is a live knob, not a boot-time one. It’s
stored on the engine behind a dedicated RWMutex so the API cockpit can flip it
while the engine’s select loop is running:
// internal/trunking/engine.go
func (e *Engine) ScanMode() ScanMode { // read path (HandleGrant)
e.modeMu.RLock(); defer e.modeMu.RUnlock()
return e.scanMode
}
func (e *Engine) SetScanMode(m ScanMode) ScanMode { // write path (cockpit)
e.modeMu.Lock(); defer e.modeMu.Unlock()
prev := e.scanMode
e.scanMode = m
return prev
}
HandleGrant takes a snapshot with ScanMode() under the read lock — cheap, and
it never blocks the bus loop. The cockpit calls SetScanMode under the write
lock and gets the previous mode back so it can log/audit the change. This is a
narrow, deliberate exception to the single-writer rule from
Part 1:
the engine loop is still the only mutator of call state, but the scan mode is
a shared configuration value that an operator changes out-of-band, so it gets its
own lock rather than being funneled through the event bus. An operator toggling
scan_mode from all to list in the TUI sees the very next grant filtered,
with no restart and no dropped calls.
Where this goes next
The talkgroup database names the destination of a call; the next post names its
source. Part 7
opens the RID database — the per-radio analogue of this file, sharing the same
loaders and conventions — and the marquee current-work fix behind it: recovering
the source radio ID for the ~85% of calls whose grant arrived without one.
Part 9
returns to the PatchedGroups carve-out in the scan gate.
FAQ
What’s the difference between lockout and scan-off?
Lockout drops a talkgroup’s grants unconditionally (except emergencies),
regardless of scan mode — it’s “never follow this.” Scan-off (Scan == false)
only matters in ScanModeList, where it excludes the talkgroup from the active
allow-list; in ScanModeAll it’s ignored. Lockout is a blocklist; the scan flag
drives an allow-list.
What happens when a grant arrives for an unknown talkgroup?
In ScanModeAll the engine discovers it — catalogues a new record so the UI
fills in from the air — and follows the call. In ScanModeList an unknown ID is
dropped, because there’s no way to know an uncatalogued talkgroup is scannable.
Can I change scan mode without restarting the daemon?
Yes. SetScanMode swaps the mode under modeMu (an RWMutex), and HandleGrant
snapshots it under the read lock on every grant. The cockpit calls it live, and
the next grant is filtered under the new mode — no restart, no dropped calls.
Why do the boolean gates default to true?
For backwards compatibility. A plain community CSV with no Scan/Stream/
Record columns should behave exactly as it did before those gates existed —
follow, record, and stream every call. Only an explicit no/false in the file
turns a gate off.
Do Record, Stream, and Mute affect whether a call is followed?
No — those gate what happens to the audio after the call is followed. A call
can be followed (a radio bound, the call decoded) and still not recorded, not
streamed, or not played live. Only Scan (in list mode), Lockout, and priority
affect whether a radio is allocated at all.
Series navigation
Part 6 of 12 · ← Part 5: Priority & Preemption · Next → Part 7: Recovering the Source Radio ID