Part 7 of The Hunt, a 14-part deep dive into how GopherTrunk finds trunked
systems you didn’t know were there. Part 6
got a control channel *locked — the decoder is reading TSBKs on the frequency under
our 851 MHz carrier. A lock is not yet a system, though. This part is the promotion
from candidate to confirmed: accumulating the decoded identity, band plan, sites, and
talkgroups into one DiscoveredSystem — and, crucially, being honest when a piece of
that identity simply never came over the air.*
TL;DR: A locked control channel is a stream of observations, not a finished map.
Accumulatefolds each observation into a singleDiscoveredSystem: identity (WACN / System ID / NAC), per-site control channels, neighbours, band plan, and talkgroups, all de-duplicated. The band plan (IDEN_UPslots) is what turns a neighbour’s(channel id, number)into a real frequency — resolved at finish, after the plan is fully accumulated. And when a P25 system locks but never broadcasts the Network Status Broadcast that carries its WACN, GopherTrunk doesn’t fabricate one — it emits anIdentityNoteexplaining exactly which identity messages did and didn’t decode.
Key takeaways
- A system is accumulated, not captured. No single dwell sees the whole system;
DiscoveredSystemis built incrementally and de-duplicated as observations fold in. - The band plan is the Rosetta Stone. Neighbours and grants arrive as
(channel id, number)pairs; only the accumulatedIDEN_UPband plan turns those into downlink frequencies — so resolution happens at finish, when the plan is complete. - Talkgroups have no fixed frequency. A trunked traffic channel is assigned per call, so a grant’s frequency is attributed to the site, never to the talkgroup.
- Missing identity is reported, not invented. The
IdentityNotenames which P25 identity broadcasts decoded, so “WACN unknown” comes with the reason — the system never emitted the NSB, not a decode bug.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| The system model | identity, sites, band plan, talkgroups | internal/hunt/system.go (DiscoveredSystem) |
| Frequency resolution | (channel id, number) → downlink Hz | internal/hunt/system.go (bandPlanFreq) |
| Neighbour backfill | resolve neighbour CCs at finish | internal/hunt/system.go (resolveNeighborFreqs) |
| Decode + fold | identify, decode, accumulate one capture | internal/hunt/decode.go (decodeAndAccumulate) |
| Identity honesty | explain a blank WACN/System ID | internal/hunt/decode.go (identityNote) |
| Report flatten | system → protocol-neutral report | internal/hunt/report.go (NetworkReport) |
In this post
- From lock to observation — what a decode actually hands the accumulator.
- The DiscoveredSystem — the incremental, de-duplicated model.
- The band plan — why neighbour frequencies resolve only at finish.
- The identity-note case — being honest about a WACN that never aired.
- Flattening to a report — the shape the exporters consume.
From lock to observation
The shared body that decodes one capture and folds it in is decodeAndAccumulate —
the same function the offline Discover, the live hunter, and the auto-gain sweep all
call. Once it has a decoded siglab.Result, it folds it into the system and fills a
report:
// internal/hunt/decode.go (shape) — decodeAndAccumulate fold
before := len(sys.Talkgroups)
Accumulate(sys, Observation{
Protocol: proto.String(),
Confidence: conf,
Result: res,
FallbackFreqHz: p.FrequencyHz,
At: time.Now(),
})
rep.Locked = res.Locked
if res.Lock != nil { rep.ControlHz = res.Lock.FrequencyHz }
if res.Signal != nil { rep.ErrorRate = res.Signal.DecodeErrorRate }
rep.IdentityNote = identityNote(res)
rep.Encrypted, rep.EncType = encryptionFromGrants(proto.String(), res.Grants)
rep.Talkgroups = len(sys.Talkgroups) - before
The decode deliberately runs the deep P25 path (CollectIQDiag: true), because
that’s what snapshots the system topology — WACN, System ID, RFSS, Site, neighbours,
band plan — onto Result.Topology. Without it, P25 runs the generic pipeline and the
map would be NAC-only. And note the decode tunes to the carrier identify locked
(idr.TuneHz), not a fresh dominant-carrier estimate: under auto-tune the control
channel may be off-centre and not the band’s loudest, so re-estimating would find the
wrong carrier and fail a decode that identify already proved works.
The DiscoveredSystem
The accumulator’s target is DiscoveredSystem: a protocol-neutral model built up
incrementally and shaped to round-trip cleanly back into GopherTrunk’s import bundle.
// internal/hunt/system.go (shape) — DiscoveredSystem
type DiscoveredSystem struct {
Name string
Protocol string
WACN uint32 // P25 / generic identity — zero ⇒ unknown
SystemID uint16
NAC uint16
Identity map[string]any // per-protocol extras (DMR ColorCode, TETRA MCC/MNC, …)
Sites []DiscoveredSite
Talkgroups []DiscoveredTalkgroup
BandPlan []BandPlanEntry
Confidence float64
// …State/County/Location, FirstSeen/LastSeen
}
Every mutation is de-duplicating by design. addControlChannel keeps one entry per
frequency, upgrading confidence. addNeighbor keeps the first identity for a
neighbour’s (RFSS, Site) but backfills a frequency once a later observation
resolves one. addBandPlanEntry keeps the first observation of a channel ID. This
matters because the same site is seen across many dwells, and a naive append would
produce a map full of duplicates. The one deliberate non-attribution is worth
calling out:
// internal/hunt/system.go (shape) — addTalkgroup
// The voice-channel frequency a grant landed on is NOT attributed to the
// talkgroup: on a trunked system the traffic channel is assigned dynamically per
// call, so a talkgroup has no fixed frequency. The distinct voice frequencies the
// site uses are recorded on the site (addVoiceChannel).
That’s the essence of trunking captured in a data-model decision: a talkgroup is a logical group, not a channel. Attributing the grant frequency to the talkgroup would encode a lie about how the system works. Instead the grant’s resolved frequency is recorded on the site as a voice channel.
The band plan is the Rosetta Stone
Here is the crux of confirming a P25 system. Neighbours and grants don’t arrive as
frequencies — they arrive as (channel id, channel number) pairs, and turning those
into a real downlink frequency requires the system’s band plan (its IDEN_UP
broadcasts). A single dwell may not have seen the band-plan entry for a channel ID
before it saw a neighbour using it, so resolution can’t happen eagerly — it happens
at finish, once the plan is fully accumulated:
// internal/hunt/system.go (shape) — bandPlanFreq + finish-time resolution
func (s *DiscoveredSystem) bandPlanFreq(channelID uint8, channelNumber uint16) (uint32, bool) {
for _, e := range s.BandPlan {
if e.ChannelID != channelID { continue }
hz := e.BaseHz + uint64(channelNumber)*uint64(e.SpacingHz) // P25 IDEN_UP math
if hz == 0 || hz > 0xFFFFFFFF { return 0, false }
return uint32(hz), true
}
return 0, false
}
// resolveNeighborFreqs runs from sortAll() on every finish/export — idempotent, so a
// neighbour already carrying a frequency is left untouched.
sortAll puts everything in deterministic order (stable golden output) and calls
resolveNeighborFreqs, so a neighbour advertised early by a system whose band plan
arrived late still gets its frequency once both are in hand. It deliberately applies
BaseHz + number*spacing and not the transmit offset — that offset is the uplink;
a control-channel reference is the downlink. Getting that direction wrong would put
every neighbour’s frequency on the wrong side of the duplex split.
How that principle shaped the Go code
- Resolution is deferred and idempotent.
resolveNeighborFreqsruns fromsortAllon every finish/export and skips neighbours that already have a frequency, so it’s safe to call repeatedly and never depends on observation order. - Sites are keyed, not appended.
site(rfss, siteID)returns a pointer to an existing site or creates one, so many dwells of the same RF site converge on oneDiscoveredSiterather than duplicating it. - The model is export-shaped.
DiscoveredSystem’s fields convert cleanly onto the importer’s parsed system, so a discovery exported as a bundle re-imports without loss — the accumulator and the exporter agree on the shape.
The identity-note case
Now the honest part, and the reason this series has an issue-closing policy about
not claiming things you can’t verify. A P25 system can lock cleanly, decode
thousands of TSBKs, and still have a blank WACN — because on P25 Phase 1 the
Network Status Broadcast (NSB, opcode 0x3B) is the only message that carries
the WACN, and some systems rarely or never transmit it. GopherTrunk refuses to
either fabricate a WACN or silently show it blank; instead identityNote explains
precisely what happened:
// internal/hunt/decode.go (shape) — identityNote
func identityNote(res *siglab.Result) string {
if res == nil || !res.Locked || res.Topology == nil { return "" }
if res.Topology.WACN != 0 && res.Topology.SystemID != 0 { return "" } // resolved
d, ok := res.Detail.(*siglab.P25P1Detail)
if !ok || d.CCStats == nil { return "" } // not the P25 deep path
cc := d.CCStats
if res.Topology.SystemID != 0 && res.Topology.WACN == 0 {
// System ID voted from adjacent-site broadcasts, but the NSB that carries
// WACN never decoded — widening the dwell won't help if it never airs.
return fmt.Sprintf("System ID resolved from adjacent-site broadcasts, but WACN is "+
"unavailable: the Network Status Broadcast (NSB, 0x3B) that carries it was never "+
"decoded (saw NSB×%d, RFSS×%d, adjacent×%d, …)", cc.NetStatusSeen, cc.RFSSStatusSeen, cc.AdjacentSeen)
}
// …NSB decoded but parsed zero ⇒ a parse problem, not a capture gap
// …no NSB at all ⇒ widen --dwell-seconds or --monitor-seconds to catch the periodic NSB
}
The note distinguishes three genuinely different situations, and the distinction is
actionable. If the System ID came from adjacent-site broadcasts but the NSB never
aired, widening the dwell is pointless — the WACN simply isn’t recoverable from this
system. If the NSB decoded but parsed to zero, that’s a parse bug, not a capture gap.
If no NSB decoded at all, a longer --monitor-seconds might catch the periodic one.
The identity_note_test.go table pins every branch: “locked but NSB never decoded”
expects the “Network Status Broadcast” text; “System ID resolved from adjacent, WACN
still missing” expects “WACN is unavailable”; “NSB decoded but no WACN parsed”
expects “parsed as zero”. This is the same discipline as the issue-closing policy —
say what you know, name what’s blocking, don’t claim a resolution you can’t back.
Flattening to a report
When it’s time to render or export, NetworkReport flattens the multi-site
DiscoveredSystem into the protocol-neutral trunking.NetworkReport the shared
renderer consumes — one ReportSite per site under a shared identity header and band
plan. Site control channels are stored as resolved downlink frequencies (the band
plan is already applied), while neighbours keep their (id, number) and get an
uplink derived from the matching band’s transmit offset — the one place the offset
is applied, because a neighbour reference legitimately carries both directions. The
report is the boundary between “what the hunt found” and “what gets written to a
RadioReference submission or a scanner import.”
Where this goes next
We’ve confirmed a single P25 system from one control channel. But real systems have neighbours, and a wide receiver can watch several of their channels at once. Part 8 turns to DMR Tier III, where the band plan isn’t broadcast at all — it has to be learned by correlating logical channel numbers to the physical carriers that key up in response. Then Part 9 returns to P25 to watch many channels across a wideband tune.
FAQ
What’s the difference between a locked control channel and a confirmed system?
A lock means the decoder is reading the control channel. A confirmed system is the
accumulated result of that decoding: identity (WACN/System ID/NAC), the band plan,
the sites and their control channels, the neighbours (with resolved frequencies), and
the talkgroups — folded and de-duplicated into one DiscoveredSystem.
Why can a P25 system lock but have no WACN?
Because on P25 Phase 1 only the Network Status Broadcast (NSB, opcode 0x3B) carries
the WACN, and some systems transmit it rarely or never. A lock decodes TSBKs and
grants fine without ever seeing an NSB, so the WACN stays blank — and GopherTrunk
emits an IdentityNote saying so rather than inventing a value.
Why resolve neighbour frequencies at finish instead of when they arrive?
Because a neighbour can be advertised before the band-plan entry that resolves its
(channel id, number) has decoded. Deferring resolution to sortAll — after the
whole band plan is accumulated — means every resolvable neighbour gets a frequency
regardless of the order observations arrived. The resolution is idempotent, so it’s
safe to run on every export.
Why isn’t a grant’s frequency attributed to its talkgroup? Because trunking assigns traffic channels dynamically per call — a talkgroup has no fixed frequency. Recording the grant frequency on the talkgroup would misrepresent how the system works. The frequency is attributed to the site as a voice channel, where it correctly describes which channels the site uses.
How is this the same engine offline and live?
decodeAndAccumulate is shared: offline Discover feeds it captures, the live
hunter feeds it captured buffers, and the streaming monitor feeds it a live stream —
all folding into the same DiscoveredSystem via the same Accumulate. What you
confirm from a recording is what you’d confirm on the air.
Series navigation
Part 7 of 14 · ← Part 6: Control-Channel Hunting — The Supervisor · Next → Part 8: DMR LCN Correlation — Rebuilding a Channel Map