Part 10 of Trunking Engine, a 12-part deep dive into the “brain” of GopherTrunk. Parts 8 and 9 built derived tables from the event stream — a unit roster, a patch registry. This one builds the biggest derived table of all: a map of the whole radio system, site by site, as the control channel reveals it.
TL;DR: A trunked system is rarely one transmitter. The
SiteTrackersubscribes toKindSiteUpdateevents — published every time the decoder parses an RFSS Status Broadcast — and accumulates a table of every P25 site GT has heard, keyed by(system, RFSS, site). Entries never expire: a site roster is small and stable, and operators want the full list even for quiet sites. Alongside it, a per-systemTopologySnapshotcarries the identity, neighbours, and band plan that aren’t on any per-event payload, andRenderNetworkReportturns it into an SDRtrunk-style network-configuration report.
Key takeaways
- A site map is derived state, like everything else in this series — folded
from
KindSiteUpdateevents, not queried from the radio. SiteUpdateis the only place the control-channel frequency and the site identity are joined — grants carry the voice channel, not the CC.TopologySnapshotexists because identity fields (WACN/SYSID/RFSS/Site) are accumulated inside the decoder’s network model and never ride a per-event payload — so the snapshot is the bridge out.- Roaming falls out for free: as the hunter hops control channels, each site’s status broadcast lands a new row, so a multi-site system surfaces every site over time.
Cheat sheet
| Concept | Where it lives | One-line role |
|---|---|---|
SiteInfo |
site_tracker.go |
one discovered site row: RFSS/Site, CC freq, decode quality |
SiteTracker |
site_tracker.go |
subscriber that folds KindSiteUpdate into a live site table |
SiteUpdate |
grant.go |
KindSiteUpdate payload; from RFSS Status Broadcast (TSBK 0x3A) |
TopologySnapshot |
topology.go |
protocol-neutral system map: identity, neighbours, band plan |
NetworkReport / RenderNetworkReport |
network_report.go |
display-ready report the renderer prints |
SiteTracker.Report(system) |
site_tracker.go |
renders the network-config report for one system |
In this post
- Why a system is a graph of sites, not a single transmitter.
- The
SiteTracker— foldingKindSiteUpdateinto a never-expiring site table. TopologySnapshot— the bridge for identity the event stream can’t carry.- The network report — turning topology into an SDRtrunk-style dump — and how roaming emerges from control-channel hopping.
A system is a graph of sites
A P25 system the size of a state’s public-safety network is dozens of sites — individual transmitter locations — tied together as one logical system. Each site has its own RFSS (RF SubSystem) and Site ID, its own control channel, and a list of neighbours: adjacent sites it advertises so a roaming radio knows where to go next. A radio moving across the coverage area re-registers as it crosses site boundaries, and a wide-area call is repeated on every participating site.
For a scanner this matters because the interesting structure — who neighbours whom, which channel is which site’s control channel, what the band plan is — is only visible over time and only on the control channel. The decoder learns it by parsing the site’s periodic RFSS Status Broadcast (TSBK 0x3A) and related status messages. GopherTrunk’s job is to accumulate those observations into a map that outlives any single lock.
The SiteTracker
SiteTracker is the same subscriber shape as the affiliation tracker, minus the
TTL sweep. Its Run loop drains the bus and folds a single event kind:
// internal/trunking/site_tracker.go (shape)
func (t *SiteTracker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case ev, ok := <-t.sub.C:
if !ok {
return nil
}
if ev.Kind == events.KindSiteUpdate {
if u, ok := ev.Payload.(SiteUpdate); ok {
t.observe(u)
}
}
}
}
}
observe upserts a SiteInfo keyed by siteKey{system, rfss, site}. The row
carries more than identity — it records the control-channel frequency, the demod’s
measured carrier offset (a large value flags an adjacent site bleeding through at
12.5 kHz spacing, #815), and a cumulative TSBK error rate that tracks decode
quality independently of carrier lock (#858). The upsert is careful about
zero-valued fields: a fresh lock or a non-TSBK Phase 2 update carries no stats, so
observe only refreshes the error rate when the update actually has a TSBK count,
never clobbering a real reading with a zero.
The deliberate difference from Part 8 is that entries never expire. A unit roster tracks a live, churning population, so it ages out. A site roster is small and stable — you want the full list of a system’s sites even for one that has gone quiet at 3 a.m. — so the tracker accumulates forever. This is why it can’t just mirror the decoder’s camped-site model, which only ever knows the current site: as the hunter hops control channels the tracker keeps every site it has ever heard.
// internal/trunking/site_tracker.go (shape)
type SiteInfo struct {
System string
RFSSID, SiteID uint8
ControlChannelHz uint32
ControlChannelCarrierOffsetHz int32 // off-frequency lock flag (#815)
ControlChannelTSBKErrorRate float64 // decode quality (#858)
ControlChannelTSBKCount int64
WACN uint32
SystemID uint16
FirstSeen, LastSeen time.Time
}
Why TopologySnapshot exists
Here is a subtlety that is easy to miss and that the code calls out explicitly.
The identifying fields of a P25 system — WACN, System ID, RFSS, Site, and the band
plan — are not carried on any per-event payload. They are accumulated inside
the decoder’s network model from a run of periodic status broadcasts. A consumer
reading the event stream alone cannot reconstruct them. So TopologySnapshot is
the deliberate bridge: a protocol-neutral struct the decoder fills in and attaches,
carrying identity, secondary control channels, neighbours, and the band plan out
to anyone who needs the shape of the system rather than its moment-to-moment
activity.
It lives in package trunking (not in the signal-lab engine) specifically so the
per-protocol control-channel decoders can implement TopologyProvider without an
import cycle. Capability varies by protocol — P25 surfaces full identity plus
neighbours plus band plan; DMR Tier III, EDACS and Motorola add identity and
neighbours; NXDN and TETRA give single-site identity — and every field is optional,
so a decoder fills in only what it can observe. SiteUpdate carries the latest
snapshot when it has one, and observe stores it per system name so the report can
be rendered later without reaching back into the decoder.
The network report
The last mile turns a topology snapshot into GopherTrunk’s answer to SDRtrunk’s
P25 network-configuration dump. NetworkReport is a display-ready, protocol-neutral
struct deliberately kept separate from TopologySnapshot — the adapter
(ReportFromTopology) does all the band-plan math, resolving each channel to
absolute downlink and uplink frequencies, so the renderer stays pure and
golden-test friendly:
P25 Network Configuration — Metro Regional
Network
WACN:BEE00[781824] SYSTEM:2C2[706] NAC:293[659] LRA:5[5]
Current Site
RFSS:1[1] SITE:7[7] LRA:5[5]
PRI CONTROL CHANNEL:1-1754 DOWNLINK:851.012500 MHz UPLINK:806.012500 MHz
NEIGHBOR RFSS:1[1] SITE:9[9] CHANNEL:1-1782 DOWNLINK:851.362500 MHz ...
Frequency Bands
BAND:1 TDMA BASE:851.000000 MHz BANDWIDTH:12.5 kHz SPACING:12.5 kHz OFFSET:-45.000000 MHz
RenderNetworkReport sorts sites, neighbours, and bands into a stable order and
suppresses empty sections. When more than one site is present the header switches
from Current Site to Sites, which is exactly the roaming case: as the
hunter hops between a system’s control channels over a session, each site’s status
broadcast lands a fresh row, and the same renderer that prints one camped site
prints the whole discovered network. SiteTracker.Report(system) wires this up for
GET /api/v1/systems/{name}/report, and RenderNeighborLines reuses the same
band-plan resolution to annotate the decoded-message log the way SDRtrunk’s
“Neighbor Sites” block does.
How that principle shaped the Go code
Two seams keep this clean. First, separation of accumulation from rendering: the tracker accumulates raw observations, the snapshot carries the map, the report type resolves frequencies, and the renderer only formats. Each stage is testable in isolation, and the renderer never does band-plan arithmetic. Second, derived state again: the tracker adds no RF work — it reads the same bus the engine does. Multi-site roaming isn’t a feature that was built; it’s what the fold produces once the hunter is hopping control channels, because absence of a fresh update for a site simply leaves its last-known row in place.
Where this goes next
Part 11 turns to policy: what the engine does when a call it has tuned turns out to be encrypted — follow it, grab its metadata and release the tuner, or ignore it outright — and why tuner starvation makes that choice matter. For the multi-site background here, the roaming and control-channel references fill in the P25 mechanics.
FAQ
How does GopherTrunk discover a system’s other sites?
It folds every KindSiteUpdate — published when the decoder parses an RFSS Status
Broadcast — into a table keyed by (system, RFSS, site). As the control-channel
hunter hops frequencies over a session, each site it locks contributes its
identity and its advertised neighbours, so the roster fills in over time.
Why don’t site entries expire like affiliation entries do? A radio population churns constantly, so the unit roster ages out. A system’s site list is small and stable, and operators want the complete list even for a site that has gone quiet — so the site tracker accumulates entries permanently rather than sweeping them.
Why is there a separate TopologySnapshot instead of just using events? Because a P25 system’s identity (WACN, System ID, RFSS/Site) and band plan aren’t on any per-event payload — they accumulate inside the decoder’s network model from periodic status broadcasts. The snapshot is the bridge that carries that un-event-able state out to the API and the report.
What is the network report and how does it show roaming? It’s GopherTrunk’s SDRtrunk-style network-configuration dump — network identity, each site’s control channels and neighbours, and the band plan, with frequencies fully resolved. When more than one site has been heard the renderer switches its header from “Current Site” to “Sites”, so a multi-site session prints the whole discovered network.
Series navigation
Part 10 of 12 · ← Part 9: Patches & Supergroups · Next → Part 11: Encrypted-Mode Handling