Trunking Engine, Part 8: Affiliation Tracking — Building the Roster From the Event Stream

Part 8 of Trunking Engine, a 12-part deep dive into the “brain” of GopherTrunk. Parts 1–7 followed a single grant from the control channel into a recorded call. This one steps sideways: while the engine is busy tuning radios, a second subscriber is quietly reading the same event stream and building a roster of every unit on the air — without touching a single SDR.

TL;DR: The AffiliationTracker is a subscriber, not a decoder. It watches the bus for KindGrant, KindAffiliation, and KindUnitRegistration events and folds them into a live table of which radio ID is active on which talkgroup. Because grants already carry SourceID + GroupID for every protocol, the roster works uniformly across P25, DMR, NXDN and the rest with no per-protocol code. The table is derived state — a materialized view of the event log — so it costs no extra radio I/O and can be rebuilt from the stream alone.

Key takeaways

  • The roster is derived, not measured: every row comes from events the engine and decoders already publish. Adding the tracker added zero RF work.
  • Three event kinds feed it — a grant (the source is transmitting on a TG), an explicit affiliation response, and a unit registration — plus talker aliases and in-call source backfills as refinements.
  • It is protocol-agnostic because it keys on the protocol-neutral SourceID and GroupID fields, not on any wire format.
  • It is a textbook observer: like the recorder and the call log, it subscribes to the engine’s output and never calls back in.

Cheat sheet

Concept Where it lives One-line role
AffiliationTracker affiliation_tracker.go subscriber that folds events into a live unit table
UnitActivity affiliation_tracker.go one row: radio ID → talkgroup, first/last seen, call count
Affiliation affiliation.go KindAffiliation payload; P25 opcode 0x28 Group Affiliation Response
UnitRegistration affiliation.go KindUnitRegistration payload; P25 opcode 0x2C Unit Registration Response
observe(...) affiliation_tracker.go the single fold function every event kind routes through
Snapshot() / UnitsOnTalkgroup() affiliation_tracker.go read the materialized view; power /rids and the RID list

In this post

  • What “affiliation” means on a trunked system, and why a grant already tells you most of it.
  • The three (plus two) event sources the tracker folds, and how it stays protocol-agnostic.
  • Derived state — the design principle — and how a materialized view keeps the hot path clean.
  • Explicit vs observed associations, TTL expiry, and reading the snapshot.

What affiliation is

On a trunked system a radio doesn’t just start transmitting. Before it can use a talkgroup it usually affiliates with it — tells the control channel “unit 0x4A21 wants to work talkgroup 1234” — and when it powers on it registers on the site. Those are explicit control-channel transactions, and P25 answers them with a Group Affiliation Response (opcode 0x28) and a Unit Registration Response (opcode 0x2C). Decode those and you know exactly who is on what.

But there is a second, cruder signal that is just as much ground truth: a voice grant names its source. When the control channel announces “talkgroup 1234 is up on 851.0125 MHz, source 0x4A21,” that grant proves unit 0x4A21 is transmitting on talkgroup 1234 right now. You don’t need the affiliation message at all — the grant carries SourceID and GroupID, and it does so on every protocol GopherTrunk decodes. That is the observation that makes a single, tiny tracker work across P25, DMR, NXDN, dPMR and the rest without a line of protocol-specific code.

KindGrant (src+TG) KindAffiliation KindUnitRegistration AffiliationTracker observe() folds each event units map[RadioID] 0x4A21 → tg 1234 · calls 7 0x4A22 → tg 1234 · registered 0x51F0 → tg 9001 · alias "M-14" no SDR, no retune, no radio I/O — the tracker only ever reads the bus the roster is a fold over events the engine already publishes
The tracker subscribes to the same bus the engine does. Every event kind routes through one observe() fold that upserts a row keyed by radio ID.

The three (plus two) sources

The tracker’s Run loop is the same shape as the engine’s: a select over the subscription channel and a ticker. Every event is handed to one handle switch, and every branch collapses to a single observe call:

// internal/trunking/affiliation_tracker.go (shape)
func (t *AffiliationTracker) handle(ev events.Event) {
    switch ev.Kind {
    case events.KindGrant:
        if g, ok := ev.Payload.(Grant); ok && g.SourceID != 0 {
            t.observe(g.SourceID, g.GroupID, g.System, g.Protocol, false, false, true)
        }
    case events.KindAffiliation:
        if a, ok := ev.Payload.(Affiliation); ok && a.SourceID != 0 &&
            a.Response == AffiliationAccepted {
            t.observe(a.SourceID, a.GroupID, a.System, a.Protocol, true, false, false)
        }
    case events.KindUnitRegistration:
        if r, ok := ev.Payload.(UnitRegistration); ok && r.SourceID != 0 &&
            r.Response == RegistrationAccepted {
            t.observe(r.SourceID, 0, r.System, r.Protocol, false, true, false)
        }
    // ... KindTalkerAlias and KindCallSourceUpdate refine existing rows
    }
}

Read the three primary branches as a hierarchy of evidence. A grant is implicit but certain — a radio is keyed right now — so it counts a call (countCall=true) but marks the association non-explicit. An affiliation response is the explicit, decoded “I want this talkgroup,” so it sets Explicit=true. A unit registration proves the radio is on the site but names no talkgroup, so it passes talkgroup=0, which observe treats as “refresh this unit without clobbering a known talkgroup association.” Only accepted responses count — a denied or refused affiliation is not membership.

Two more event kinds refine rows rather than create the “who’s on what” association. KindTalkerAlias stamps a unit’s over-the-air display name (reassembled from P25 Phase 2 vendor MAC PDUs), and KindCallSourceUpdate — the in-call source-RID backfill from Part 7 — records the real source when the original grant fired with src=0. Both keep the unit alive in the table and fill in identity without inventing a new call.

Explicit vs observed, and why both are truth

The UnitActivity row carries an Explicit flag, and it is worth dwelling on because it is a subtle distinction that a lot of scanners get wrong. Both an affiliation message and a grant are ground truth about membership — a grant naming a source as the transmitter is arguably stronger evidence than a stale affiliation from twenty minutes ago. The flag doesn’t rank confidence; it records provenance, so a UI can show “affiliated” versus “seen transmitting” without pretending one is a guess:

// internal/trunking/affiliation_tracker.go (shape)
type UnitActivity struct {
    RadioID    uint32
    Talkgroup  uint32
    Explicit   bool // from a decoded affiliation, not just a grant
    Registered bool // seen in a unit-registration message
    FirstSeen  time.Time
    LastSeen   time.Time
    CallCount  uint64 // grants observed since the unit entered the table
    TalkerAlias           string // over-the-air display name
    TalkerAliasUnreliable bool   // CRC-passed but held non-printable bytes (#711)
}

CallCount is the “recurring radio” signal from issue #376 — a simple count of grants seen for this unit since it entered the table, so the RID list can sort by who is actually busy. TalkerAliasUnreliable is the corruption guard from #711: an alias that passed CRC but decoded to non-ASCII-printable bytes is surfaced as suspect rather than as a confirmed name.

How that principle shaped the Go code — derived state

The design idea here is derived (materialized) state: the roster is not a thing the system has, it is a thing the system can recompute from its event log. Nothing in the decode path knows the tracker exists. The engine tunes radios; the decoders emit grants and affiliation responses; the tracker is a pure downstream fold. That buys three concrete properties:

  • Zero extra RF cost. The roster adds no tune, no dwell, no second receiver. Every input already existed on the bus for the recorder and the call log. The tracker is, quite literally, free intelligence.
  • Reconstructable. Because state is a fold over events, the same stream replays to the same table. A test publishes a handful of synthetic grants and asserts Snapshot() — no radio, exactly the harness Part 12 builds out.
  • Bounded without coordination. A materialized view can be lossy on purpose. Entries expire after an idle TTL (30 minutes by default) via a once-a-minute sweep, so the table tracks the live population instead of growing forever. Nothing upstream has to tell it a radio left — absence of events is the signal.

The concurrency story is the same one-writer discipline the engine uses. The tracker’s Run goroutine is the only mutator; Snapshot, UnitsOnTalkgroup, and Len take the mutex only to copy out a consistent view for read-only API callers.

event log (append-only) grant affil reg grant observe() materialized unit table Snapshot() /rids · RID list sweep() drops rows idle > TTL absence of events is the "unit left" signal — no coordination needed
The roster is a left-fold of the event log; a periodic idle sweep keeps the derived view bounded to the live radio population.

Reading the roster

Two query methods cover the surfaces that use the tracker. Snapshot() returns every unit most-recently-seen first — the data behind the /rids RID list. UnitsOnTalkgroup(tg) inverts the index to answer “who is on this talkgroup right now,” which is what lets a UI expand a live call into its participants. Both return copies under the mutex, so a slow API consumer never blocks the fold. The P25-specific serving-site fields on Affiliation and UnitRegistration (RFSSID, SiteID, NAC) go further — because an affiliation is handled by the radio’s actual serving site rather than every site in a wide-area call, they’re a genuine RID→site location fix, which is where multi-site roaming in Part 10 picks up. For the protocol background on what an affiliation transaction is, see the affiliation reference.

Where this goes next

Part 9 stays in “derived from the stream” territory but tackles a harder case: patches and supergroups, where one physical call belongs to several talkgroups at once, and the physical-channel RID recovery that suppresses a phantom duplicate call when a compressed grant arrives under a mis-aliased talkgroup. After that, Part 10 uses the serving-site fields introduced here to build a full multi-site network map.

FAQ

Does affiliation tracking need its own radio or a second tuner? No. It is a pure subscriber to the event bus. Every input — grants, affiliation responses, unit registrations — is already published by the engine and the decoders for other reasons, so the roster costs zero additional RF work.

How does one tracker work for P25, DMR, and NXDN at once? It keys on the protocol-neutral SourceID and GroupID fields that every grant carries, not on any wire format. Explicit affiliation and registration messages are folded when available, but the grant path alone gives a working roster on every protocol.

What’s the difference between an “explicit” and an “observed” association? An explicit association came from a decoded Group Affiliation Response; an observed one was inferred from a voice grant that named the unit as the source. Both are ground truth — the flag records provenance, not confidence — and a grant is often the stronger evidence.

Why do units disappear from the roster? Entries expire after a configurable idle TTL (30 minutes by default), swept once a minute. The table is a materialized view of recent activity, not a permanent log, so a radio that goes quiet ages out — absence of events is the signal that it left.

Series navigation

Part 8 of 12 · ← Part 7: Source RID Recovery · Next → Part 9: Patches & Supergroups