Part 9 of Trunking Engine, a 12-part deep dive into the “brain” of GopherTrunk. Part 8 built a roster from the event stream. This one deals with two places where the neat one-grant-one-call model bends: a patch, where one RF call belongs to many talkgroups at once, and a compressed grant, where the talkgroup label on the wire lies about which call the source RID belongs to.
TL;DR: A patch (P25 dynamic regroup / supergroup) merges member talkgroups onto one RF channel, so a call on the supergroup physically is the members’ traffic — following it means attributing, not retuning. The engine keeps a live
PatchRegistryfromKindPatchevents. Separately, the #915 follow-up recovers a call’s source RID by physical channel: a frequency + timeslot hosts exactly one transmission, so a source-carrying grant landing on an active call’s exact channel is folded onto that call regardless of its talkgroup label — which also suppresses the phantom duplicate call a mis-aliased compressed grant would otherwise spawn.
Key takeaways
- A patch is an attribution relationship, not a tuning one: the supergroup and its members share one channel, so one recording serves all of them.
PatchRegistryis a thread-safe live table keyed by supergroup, maintained fromKindPatchadd/cancel events — derived state, exactly like Part 8’s roster.- Physical-channel RID recovery keys on
(frequency, timeslot)instead of talkgroup, so a source RID arriving under a different label than the call was bound with still lands on the right call. - The same rule suppresses a phantom duplicate: a mis-aliased grant that would otherwise look like a new call is recognized as the running one and folded in.
Cheat sheet
| Concept | Where it lives | One-line role |
|---|---|---|
Patch |
patch.go |
KindPatch payload — a patch add or cancel the system announced |
PatchGroup |
patch.go |
supergroup → member talkgroups + vendor |
PatchRegistry |
patch.go |
thread-safe live table of active patches, keyed by supergroup |
handlePatch(p) |
engine.go |
applies an add (record) or cancel (delete) to the registry |
BackfillSourceForChannel(...) |
voicepool.go |
folds a source RID onto the active call on (freq, timeslot) |
republishCallSource(...) |
engine.go |
re-emits the recovered RID so the webhook + live view patch |
In this post
- What a patch is — supergroups, dynamic regroup, and why following one means attributing, not tuning.
- The
PatchRegistry— a derived table maintained fromKindPatchevents. - The problem we hit — a mis-aliased compressed grant spawning a phantom duplicate call and losing the source RID.
- Physical-channel RID recovery — keying on
(freq, timeslot)to fold the RID and kill the duplicate.
What a patch actually is
On P25 (and its Motorola and Harris variants) a dispatcher can patch several talkgroups together, or spin up a dynamic regroup supergroup, so that units scattered across those talkgroups all hear one another. The system announces this on the control channel, and the important consequence for a scanner is physical: the patched talkgroups now share one RF channel. A voice call on the supergroup address is the traffic for every member simultaneously.
That flips the usual engine job on its head. For an ordinary grant, “following”
means allocating a voice SDR and retuning it. For a patch there is nothing extra
to tune — the call is already on the air on one channel — so “following” a patch
means attribution: recording the call once and crediting it to the supergroup
and every member talkgroup. The PatchGroup doc comment states it plainly:
following a patch “means attributing the call to every member, not retuning.”
The PatchRegistry
The registry is small and unglamorous on purpose — a mutex-guarded map from
supergroup address to PatchGroup, with the operations the engine needs to
answer “is this address a supergroup, and who are its members?”:
// internal/trunking/patch.go (shape)
type PatchGroup struct {
SuperGroup uint32
Members []uint32
Vendor string // "motorola" | "harris"
UpdatedAt time.Time
}
type PatchRegistry struct {
mu sync.Mutex
groups map[uint32]PatchGroup
}
func (r *PatchRegistry) Apply(pg PatchGroup) { /* record or replace */ }
func (r *PatchRegistry) Delete(superGroup uint32) { /* patch cancelled */ }
func (r *PatchRegistry) MembersOf(group uint32) []uint32 // copy, or nil if not a supergroup
The engine owns one registry and drives it from the bus. A KindPatch event
carries an Add flag — true when a patch goes active, false when it is
cancelled — and handlePatch is a two-line dispatch:
// internal/trunking/engine.go (shape)
func (e *Engine) handlePatch(p Patch) {
if p.Add {
e.patches.Apply(PatchGroup{
SuperGroup: p.SuperGroup, Members: p.Members,
Vendor: p.Vendor, UpdatedAt: e.now(),
})
return
}
e.patches.Delete(p.SuperGroup)
}
This is the same derived-state pattern as Part 8: the registry is a materialized
view of the patch announcements on the control channel, not something the engine
computes independently. MembersOf returns a copy of the member slice so a
caller can’t mutate the live table, and Active() snapshots the whole set for the
API. The scan-list gate in HandleGrant already respects it — a grant is
followed when the supergroup or any member is scanned, so patching a scanned
talkgroup into a supergroup doesn’t accidentally silence it.
The problem we hit
Now the harder half, and it starts with a real field failure logged in #915.
GopherTrunk had just shipped a fix so the completed-call webhook carried the
source RID on nearly every call, by folding a later same-call grant’s RID onto the
bound call. The matching key was (System, talkgroup, timeslot) — find the active
call with the same talkgroup and stamp its source. On most systems that worked.
On a heavily-compressed P25 Phase 2 system it reached only ~12% coverage, and the reason was nasty: on those systems the RID-bearing grant frequently arrives under a different talkgroup label than the source-less compressed grant that originally bound the call. A mis-aliased compressed grant, or a supergroup/patch remap, means the second grant says “talkgroup 1202, source 0x4A21” while the call is running as “talkgroup 1201.” Talkgroup-keyed matching misses it entirely, so two bad things happen at once:
- The source RID is never associated back to the running call — the webhook ships without it.
- With a free voice device available, the engine treats the mismatched grant as a brand-new call and binds a second SDR — a phantom duplicate of a call already on the air, on the same frequency.
(freq, timeslot) both recovers the RID and kills the duplicate call.Physical-channel RID recovery
The fix leans on a fact the wire can’t fake: a frequency plus a timeslot hosts
exactly one in-progress transmission. Two radios cannot key the same FDMA
channel — or the same TDMA slot on it — at the same instant. So a source-carrying
grant that lands on an active call’s exact channel belongs to that call, whatever
talkgroup label it is wearing. In HandleGrant, after the talkgroup-keyed dedup
has had its chance, a second pass keys on the physical channel:
// internal/trunking/engine.go (shape) — after the talkgroup dedup loop
if g.SourceID != 0 && g.FrequencyHz != 0 {
if serial, upd, filled := e.pool.BackfillSourceForChannel(
g.System, g.FrequencyHz, g.Timeslot, g.SourceID); filled {
e.pool.Touch(serial, e.now())
e.republishCallSource(serial, upd) // patch webhook + live view
return // treat as a repeat of that call — do NOT allocate a new device
}
}
Two effects fall out of that one block. BackfillSourceForChannel folds the RID
onto the call on (system, freq, timeslot) — but only when the source is still
unknown, so an in-call call.source update (the radio actually keyed on the
traffic channel, from Part 7)
always wins. And the early return means the mismatched grant is treated as a
repeat, not a new call — the phantom second bind never happens. republishCallSource
re-emits the recovered RID through the same KindCallSourceUpdate path the in-call
update uses, so the recorder patches its completed-call webhook and the SSE/TUI
live view light up. The republish fires only on the first fill, so a
heavily-repeated control-channel grant stream never floods the bus.
How that principle shaped the Go code
The through-line is identity by physical resource, not by label. The engine
already learned this lesson once — the duplicate-grant guard keys a logical call
on (System, talkgroup, timeslot) rather than frequency, because a call’s
frequency can change mid-call. Physical-channel recovery is the mirror image:
when the label is the unreliable field, fall back to the physical channel as the
identity of last resort. Both are the same instinct — pick the key the wire can’t
corrupt — and both live in HandleGrant as ordered fallbacks, cheapest and most
specific match first. It stays consistent with the engine’s single-writer rule:
all of this runs on the one select goroutine, mutating pool state no one else
writes.
Where this goes next
Part 10
zooms out from one channel to the whole system: tracking every site the control
channel advertises, folding KindSiteUpdate events into a network map, and
following a radio as it roams between sites. If you want the protocol background
first, the P25 Phase 2 and
talkgroup references cover the
compressed-grant and regroup mechanics this post relied on.
FAQ
What is the difference between a patch and a supergroup?
In GopherTrunk they’re handled by the same machinery. A patch merges existing
talkgroups so their members interoperate; a dynamic-regroup supergroup is a
system-created address that member talkgroups are folded into. Either way one RF
channel carries traffic for several talkgroups, and the PatchRegistry maps the
supergroup address to its members.
Does following a patch use extra voice SDRs? No. A patched call is already a single call on one channel, so it’s recorded once and attributed to the supergroup and every member through the registry. There is no second tune and no second tuner.
Why match a grant to a call by frequency and timeslot instead of talkgroup? Because under heavy compression the talkgroup label on a grant can differ from the call it belongs to (a mis-aliased compressed grant or a patch remap). A frequency plus timeslot hosts exactly one transmission, so it’s an identity the wire can’t misreport — matching on it recovers the source RID and prevents a duplicate call.
How does this stop a phantom duplicate call? When a source-carrying grant matches an active call by physical channel, the engine treats it as a repeat of that call and returns without allocating a device. Without the check, the mismatched talkgroup would look like a new call and bind a second SDR to a channel already being recorded.
Series navigation
Part 9 of 12 · ← Part 8: Affiliation Tracking · Next → Part 10: Sites, Topology & Roaming