Trunking Engine, Part 1: From Grant to Recorded Call

Part 1 of Trunking Engine, a 12-part deep dive into the “brain” of GopherTrunk — the component that turns control-channel grants into recorded calls. The SDR Internals series spent one episode here and called it “worth its own series.” This is that series, and it starts with the loop at the center of everything.

TL;DR: The trunking engine is a single goroutine running a select loop. It subscribes to one thing — the event bus — and reacts to KindGrant events by looking up the talkgroup, asking the voice pool for an SDR, retuning it, and starting a call. It publishes KindCallStart / KindCallEnd and never calls the recorder, the database, or the UI directly. That one rule — publish, never call outward — is what makes the whole engine testable with a fake bus and no radio.

Key takeaways

  • The engine is the only writer of call state, so the hot path needs no locks — concurrency lives in the bus, not in shared memory.
  • It is ignorant of its consumers: no import of internal/api, internal/storage, or the recorder. They subscribe to it.
  • A grant is not a call. Turning one into the other means talkgroup lookup, a scarce-resource allocation, retuning, and a watchdog — the subjects of this series.
  • Everything downstream (recording, the call log, metrics, Broadcastify) is a subscriber to events the engine emits, added without touching the engine.

Cheat sheet

Concept Where it lives One-line role
The engine internal/trunking/engine.go single-goroutine select loop; the only mutator of call state
The event bus internal/events/bus.go typed pub/sub fanout the engine subscribes to and publishes on
A grant Grant (grant.go) “talkgroup T is up on frequency F” — the engine’s input
The voice pool VoicePool (voicepool.go) the scarce set of Voice-role SDRs the engine tunes
A call ActiveCall one talkgroup bound to one voice SDR, watched until it goes silent
Key events out KindCallStart, KindCallEnd what subscribers react to

In this post

  • What the engine is for — the gap between a grant and a recorded call.
  • The loop — one goroutine, one select, the classic Go event loop.
  • The one-way rule — why the engine publishes and never calls outward.
  • The design principle — single-writer state and observer decoupling, and how they shaped the Go.

The gap between a grant and a call

On a trunked system, voice doesn’t live on a fixed frequency. A control channel continuously announces assignments: talkgroup 1234 is now on 851.0125 MHz. That announcement is a grant. To actually hear the call, something has to notice the grant, find a spare radio, tune it to 851.0125 MHz, start capturing audio, and know when the call is over so it can free the radio for the next one. That “something” is the trunking engine.

The decoders from the Protocol Decoders series do the first half — symbols in, a structured Grant out. The engine does the second half. Everything between “a grant arrived” and “a .wav is on disk” is engine work, and it is more subtle than it looks: there are usually more talkgroups active than radios to follow them, grants repeat, calls end without an explicit “call over” message, and some calls are encrypted. This series is about how the engine handles all of that. Part 1 is about the shape it handles it in.

CC decoder emits Grant trunking engine select loop · 1 goroutine voice SDR retuned + call recorder · call log · metrics · UI (subscribers) publishes CallStart / CallEnd
The engine sits between the decoder and the radios. It acts on grants and publishes call events; everything that records or displays a call is a subscriber, not a caller.

The loop

The entire engine is one goroutine running a select. It drains the event bus and ticks a watchdog, and that’s the whole control structure:

// internal/trunking/engine.go (shape)
func (e *Engine) Run(ctx context.Context) error {
    tick := time.NewTicker(500 * time.Millisecond)
    defer tick.Stop()
    for {
        select {
        case <-ctx.Done():
            e.shutdown()
            return ctx.Err()
        case ev, ok := <-e.sub.C:
            if !ok {
                return nil
            }
            switch ev.Kind {
            case events.KindGrant:
                if g, ok := ev.Payload.(Grant); ok {
                    e.HandleGrant(g) // look up TG, allocate a voice SDR, start a call
                }
            case events.KindPatch:            // regroup/supergroup (Part 9)
                if p, ok := ev.Payload.(Patch); ok { e.handlePatch(p) }
            case events.KindCallEncryption:    // encrypted-mode policy (Part 11)
                if c, ok := ev.Payload.(CallEncryption); ok { e.handleCallEncryption(c) }
            case events.KindCallSourceUpdate:  // source RID backfill (Part 7)
                if c, ok := ev.Payload.(CallSourceUpdate); ok { e.handleCallSourceUpdate(c) }
            case events.KindTalkerAlias:
                if a, ok := ev.Payload.(TalkerAlias); ok { e.handleTalkerAlias(a) }
            }
        case <-tick.C:
            e.runWatchdog() // reap calls with no recent frames (Part 12)
        }
    }
}

Every branch of that switch is a future post in this series, which is a good map of where we’re going: grants (Parts 3–6), source RID (Part 7), patches (Part 9), encryption (Part 11), and the watchdog (Part 12). But notice what the loop does not have: no mutex around the call map on the hot path, no callbacks into other subsystems, no I/O. It receives an event, mutates its own state, and publishes. That is the entire discipline.

The one-way rule

The single most important design decision in the engine is stated in its own doc comment: the engine knows nothing about the demod pipeline, and nothing about its consumers. It imports internal/events and that’s essentially it. When a call starts, it does not call recorder.Start(). It publishes:

// internal/trunking/engine.go (shape) — inside HandleGrant, after a voice
// device is allocated and retuned:
e.bus.Publish(events.Event{
    Kind:    events.KindCallStart,
    Payload: call, // *ActiveCall: system, talkgroup, frequency, device
})

The recorder is a subscriber to KindCallStart. So is the SQLite call log. So is the metrics exporter, the web UI’s live feed, and the Broadcastify uploader. None of them is visible to the engine. This is the observer pattern applied at the scale of a whole subsystem, and it is what the layered, one-way dependency diagram from SDR Internals Part 1 looks like in practice. Part 2 takes the bus itself apart.

How that principle shaped the Go code

  • One writer, no hot-path locks. The select loop is the sole mutator of the active-call map, so the fast path — a grant arriving — never contends on a mutex. (There is an mu guarding the maps, but only so read-only API callers like the cockpit can snapshot state safely; the loop itself is the lone writer.)
  • Testable without a radio. Because the engine’s only input is a channel of events and its only output is published events, a test constructs a fake bus, publishes a synthetic Grant, and asserts a KindCallStart comes back — no SDR, no HTTP server, no database. Part 12 shows exactly that harness.
  • Subsystems bolt on, never in. Adding Broadcastify streaming was writing a new subscriber to KindCallEnd, not editing the engine. The same is true of the call log, the affiliation tracker (Part 8), and per-call metrics.
  • Back-pressure is contained. Delivery is asynchronous with overflow handling (Part 2), so a stalled WebSocket client degrades only its own feed; the decode-and-record path keeps running.
trunking engine imports: internal/events event bus recorder call log metrics web UI · upload
Dependencies point one way: consumers subscribe to the engine's events. The engine has no idea they exist, which is why new consumers never change it.

Where this goes next

Part 2 opens up the event bus the engine lives on — how a typed Kind + payload is fanned out to subscribers, how overflow is handled so a slow consumer can’t stall the engine, and why an in-process bus beats direct function calls for this design. After that we start following a single grant through the machine: the Grant type and the source-less-grant problem (Part 3), the voice pool that allocates a radio for it (Part 4), and the priority policy that decides what happens when there are more calls than radios (Part 5).

FAQ

What is the difference between a grant and a call? A grant is a control-channel announcement — “talkgroup T is on frequency F.” A call is what the engine creates from a grant: a voice SDR retuned to F, bound to talkgroup T, and watched until it goes silent. Many grants (repeats of the same assignment) map to one call.

Why is the engine a single goroutine instead of one per call? So there is exactly one writer of call state. A single select loop means the hot path — reacting to a grant — needs no locks and can’t race with itself. Per-call concurrency would reintroduce the shared-state bugs the design exists to avoid; the actual audio work happens in the recorder subscribers, off the loop.

How does the engine record calls if it never calls the recorder? It doesn’t record them itself. It publishes KindCallStart and KindCallEnd, and the recorder is a subscriber that reacts to those events. The engine has no import of the recorder at all — that decoupling is the whole point.

Does the control channel say when a call ends? Usually not — P25, for instance, has no explicit channel-release message. The engine infers the end from a watchdog: when a call’s voice SDR stops delivering frames for a timeout window, the watchdog reaps it and publishes KindCallEnd. Part 12 covers this.

Series navigation

Part 1 of 12 · Next → Part 2: The Event Bus