Trunking Engine, Part 3: Grants — The Engine's Only Input

Part 3 of Trunking Engine. Part 2 took apart the event bus the engine lives on. Now we follow the one event the engine treats as *input — the grant — from the wire shape the decoders hand it to the guard that stops the same grant from allocating two radios.*

TL;DR: A Grant is the protocol-agnostic “talkgroup T is up on frequency F” message that P25/DMR/NXDN control-channel decoders publish as KindGrant. It carries the talkgroup (GroupID), the voice frequency, an optional TDMA Timeslot, the originating radio (SourceID), and encryption flags (Encrypted, AlgorithmID, KeyID). Two things make grant handling subtle: some grants arrive source-less (a P25 Phase 2 compressed grant has no SourceID), and the control channel repeats a grant while a call is live — so the engine keys a logical call on (System, GroupID, Timeslot) and folds repeats into the existing call instead of binding a second radio.

Key takeaways

  • Grant is protocol-neutral: one struct fed by every control-channel decoder. FrequencyHz must be resolved by the protocol layer; a zero-frequency grant is logged and dropped.
  • A call’s identity is (System, GroupID, Timeslot), not frequency — a band-plan re-map can move a live call to a new channel without it becoming a new call.
  • The duplicate-grant guard treats a repeat as “this call is still going,” refreshes the bind’s LastHeardAt, and returns — one radio per logical call.
  • Source-less grants are the seam this series pulls on: P25 Phase 2 compressed grants land with SourceID == 0, which is exactly the gap Part 7 closes.

Cheat sheet

Grant field Type Meaning
System string which configured system granted the call
Protocol string "p25" / "dmr" / "nxdn"
GroupID uint32 talkgroup (or, if Individual, a subscriber address)
SourceID uint32 originating radio (RID); 0 = unknown
FrequencyHz uint32 voice-channel frequency; 0 → dropped
Timeslot uint8 TDMA slot, 1-based; 0 = N/A (Phase 1, NXDN, analog)
Encrypted bool privacy bit; AlgorithmID/KeyID fill in later
Emergency bool bypasses lockout and the scan-list gate
Individual bool GroupID is a 24-bit unit address, not a talkgroup
CallID uint64 assigned by the voice pool on Bind; fences audio bleed

In this post

  • What a grant is and the invariants the engine relies on.
  • The struct, field by field — identity, TDMA, encryption, and the flags.
  • Source-bearing vs source-less grants — and why Phase 2 arrives blank.
  • The duplicate-grant guardobservedKey and the repeat-TSBK fold.

What a grant is

On a trunked system voice doesn’t live on a fixed channel. The control channel announces assignments, and the decoders in the Protocol Decoders series turn each announcement into a Grant and publish it. The engine subscribes to KindGrant and does everything else — lookup, allocation, retune, watchdog. The grant is the only thing that drives a call into existence; everything else the engine does is a reaction to one.

Two invariants are stated right on the type. First, FrequencyHz is the protocol layer’s job: P25 derives it from IdentifierUpdate band-plan TSBKs, DMR/NXDN from the configured system. If it’s zero when the grant reaches the engine, the engine logs and drops it — a grant with no channel is not actionable. Second, the grant is protocol-agnostic: the same struct carries a P25 Phase 2 TDMA grant and an analog EDACS ProVoice grant, so the engine’s dispatch logic never branches on protocol.

The struct, field by field

// internal/trunking/grant.go (shape)
type Grant struct {
    System      string // matches trunking.System.Name
    Protocol    string // "p25" / "dmr" / "nxdn"
    GroupID     uint32 // talkgroup or destination subscriber address
    SourceID    uint32 // originator (subscriber unit) — 0 when unknown
    FrequencyHz uint32 // voice channel frequency (0 → engine drops)
    Timeslot    uint8  // 1-based TDMA slot; 0 = not applicable
    Encrypted   bool
    Emergency   bool
    AlgorithmID uint8  // meaningful only when Encrypted
    KeyID       uint16
    Individual  bool   // GroupID is a 24-bit unit address, not a TG
    PatchedGroups []uint32 // member TGs when GroupID is a patch super-group
    At          time.Time
    CallID      uint64 // stamped by VoicePool.Bind
    // …RFSSID/SiteID/NAC (site labelling), DMRInterleavedVoice, ProVoice,
    //   P25Phase1DemodMode, P25Phase2Decode (per-protocol decode hints)
}

The fields group into four jobs:

Identity. System, GroupID, and Timeslot together name a logical call. Timeslot is 1-based on purpose: 0 means “not applicable” for Phase 1, NXDN, and analog, where frequency alone identifies the call. DMR Tier III runs two independent calls on one 12.5 kHz carrier (TS1 + TS2), so the engine treats (FrequencyHz, Timeslot) as the physical channel and (System, GroupID, Timeslot) as the logical call. RFSSID/SiteID/NAC label a P25 grant by site (they stay zero until the first RFSS Status Broadcast lands — see Part 10).

Encryption. Encrypted is the raw privacy bit. AlgorithmID and KeyID are meaningful only when it’s set and often arrive after the grant — a P25 Phase 1 grant carries only the bit, and the LDU2 Encryption Sync with the real ALGID/KID lands mid-call (Part 11). Grant.String() renders E(alg=0x84,key=0x1A2B) only once those non-zero values have surfaced, so a log line is self-describing.

Classification flags. Emergency bumps a call to the top of the priority order and bypasses lockout and the scan-list gate. Individual marks a grant whose GroupID is a 24-bit subscriber address (a unit-to-unit target, interconnect, or SNDCP data unit) rather than a 16-bit talkgroup — recording those as “talkgroups” produces phantom >16-bit entries, so discovery skips them. DataCall and ProVoice route the call away from the standard voice recorder.

Handoff bookkeeping. CallID is a process-monotonic id the voice pool stamps on Bind (Part 4). A real handoff (Retune) preserves it, so a followed call keeps one identity and the recorder can reject a stale frame from the call that previously held a reused tap serial.

identity System · GroupID Timeslot · Freq encryption Encrypted · AlgID KeyID classification Emergency Individual · Data handoff CallID PatchedGroups HandleGrant(g) one struct, every protocol
One protocol-agnostic struct. Every control-channel decoder fills the same four field groups; the engine's dispatch never branches on protocol.

Source-bearing vs source-less grants

SourceID is the RID of the radio that keyed up — the “who” of a call. On a P25 Phase 1 system the group-voice-channel grant TSBK carries it, so the grant that starts a call already knows the source. On other systems it doesn’t.

The sharp case is P25 Phase 2. To conserve control-channel airtime, Phase 2 systems send a compressed grant form (an MMR — Multi-MAC Response) that omits SOURCE_ID and SVC_OPTIONS entirely. That grant reaches the engine with SourceID == 0 and Encrypted == false — not because the call is anonymous and clear, but because those fields simply weren’t on the wire. The real source surfaces later, either in the traffic-channel GROUP_VOICE_CHANNEL_USER PDU (handled in Part 7) or on a subsequent control-channel grant repeat that does carry it.

This is the problem the whole of Part 7 is about: on a busy Phase 2 system, the grant that binds a call is frequently the source-less one, and the RID-bearing grant arrives moments later — sometimes even under a different talkgroup label. For now the point is just that SourceID == 0 is a first-class, expected state, and the engine must not treat “no source yet” as “no source ever.”

The duplicate-grant guard

The control channel doesn’t announce a call once. Phase 1 repeats the voice-grant TSBK the whole time a call is live — the reporter’s log in issue #356 showed two grants for tg=32181 freq=773431250 arriving 20 ms apart. Naively, each repeat would allocate another radio: two tuners on one call, a duplicate WAV, and an operator view that can’t tell which device is serving it.

The guard is a logical-call key:

// internal/trunking/engine.go
// (System, talkgroup, timeslot) — the call's identity, NOT frequency.
func observedKey(g Grant) string {
    return fmt.Sprintf("%s|%d|%d", g.System, g.GroupID, g.Timeslot)
}

Inside HandleGrant, before allocating anything, the engine scans its active calls for one matching (System, GroupID, Timeslot). If it finds one:

  • Same frequency → the CC is just re-asserting a live call. The engine Touches the bind’s LastHeardAt (feeding the watchdog in Part 12) and returns. No second radio.
  • New frequency → a real handoff or a band-plan IdentifierUpdate re-mapping the channel. The engine retunes the same device in place (preserving StartedAt and CallID) rather than starting a new call.

Matching on identity rather than frequency is the subtle part, and it’s a fix in its own right: an earlier version keyed on frequency, so a mid-call band-plan re-map missed the match and bound a second tap to the same talkgroup — two “Active calls” rows for one call. System keeps two systems’ identical talkgroup numbers apart; Timeslot keeps a DMR Tier III carrier’s two per-slot calls apart. That same repeat-grant path is also where a later grant’s SourceID gets folded onto a call that bound source-less (BackfillSourceFromGrant) — the hook Part 7 builds on.

grant arrives (sys, tg, ts) match active call? scan pool by observedKey same freq → Touch LastHeardAt (refresh; no new radio) new freq → Retune in place (handoff; keep CallID) no match → allocate radio (new call — Part 4)
Only an unmatched grant becomes a new call. Repeats refresh; handoffs retune. One logical call, one radio.

The design principle

The grant is a value, not a command with side effects. HandleGrant takes a copy, and every mutation — the discovered talkgroup, the patch members, the backfilled CallID — happens on that copy or on the pool’s stored ActiveCall, never on caller state. That keeps the hot path free of aliasing surprises: a grant can be logged, matched, folded, or dropped without any of those paths racing on shared memory, because the only long-lived copy is the one the voice pool owns once a call is bound.

Where this goes next

An unmatched grant means the engine needs a radio. Part 4 opens the voice pool — how a scarce set of Voice-role SDRs is allocated, retuned, and bound to an ActiveCall, and what happens when a wideband rig’s tuning window doesn’t cover the granted frequency. Then Part 5 handles the case where every radio is already busy, and Part 7 returns to the source-less-grant problem introduced here.

FAQ

What is the difference between GroupID and SourceID? GroupID is the destination — the talkgroup the call is on (or, when Individual is set, a target radio’s 24-bit address). SourceID is the originator — the RID of the radio that keyed up. SourceID == 0 means the source is unknown, which is a normal, expected state on compressed P25 Phase 2 grants.

Why does the engine key a call on (System, GroupID, Timeslot) and not frequency? Because a call’s frequency can change mid-call — a band-plan IdentifierUpdate re-maps the channel, or the system hands the call to a new one. Keying on frequency made a re-grant miss the active call and bind a second radio. Timeslot is in the key so a DMR Tier III carrier’s two independent per-slot calls stay distinct.

What happens when the same grant arrives twice? The duplicate-grant guard matches it to the active call and, if the frequency is unchanged, just refreshes that call’s LastHeardAt and returns. The control channel repeats grant TSBKs continuously while a call runs; the guard treats each repeat as “still going,” not as a new call.

Why do P25 Phase 2 grants arrive without a source RID? To save control-channel airtime, Phase 2 uses a compressed grant form that omits SOURCE_ID and SVC_OPTIONS. The source surfaces later — in the traffic-channel GROUP_VOICE_CHANNEL_USER PDU or a subsequent grant repeat. Recovering it is the subject of Part 7.

What does a zero FrequencyHz mean? That the protocol layer couldn’t resolve the channel to a frequency — usually a missing or not-yet-seen band plan. The engine logs the grant and drops it; there is nothing to tune.

Series navigation

Part 3 of 12 · ← Part 2: The Event Bus · Next → Part 4: The Voice Pool