Part 11 of SDR Internals. A grant arrives: talkgroup 1234 is on 851.0125 MHz. Something has to tune a radio there, start a recording, and tell the world. This post is about the trunking engine and the event bus that keeps it decoupled.
In this post
- What the trunking engine does: grant handling, voice allocation, call watchdog.
- The in-process event bus that fans domain events out to subscribers.
- The observer / event-driven principle that keeps the engine ignorant of — and testable without — the API, storage, and UI.
What the engine and bus do
The decoders from
Part 10
emit grants. The trunking engine (internal/trunking) acts on them: it asks
the SDR pool for a voice device, retunes it to the granted frequency, starts a
call, and runs a watchdog that reaps calls that have gone silent. It also handles
priority and preemption when more calls are active than there are radios.
But many other parts of the system care about these events too — the recorder,
the web UI, the call-log database, the Prometheus metrics, the Broadcastify
uploader. Rather than have the engine call each of them, it publishes to an
event bus (internal/events) and they subscribe.
How GopherTrunk implements it in Go
The engine is a single goroutine running a select loop — the classic Go event
loop — draining the bus and ticking a watchdog:
// 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():
return ctx.Err()
case ev := <-e.sub.C:
if g, ok := ev.Payload.(Grant); ok && ev.Kind == events.KindGrant {
e.HandleGrant(g) // allocate a voice device, start a call
}
case <-tick.C:
e.runWatchdog() // reap calls with no recent frames
}
}
}
The bus is a typed pub/sub fanout. Events are tagged with a Kind —
KindCCLocked, KindGrant, KindCallStart, KindCallComplete,
KindAffiliation, and dozens more — and carry a payload. Subscribers register a
channel and receive the kinds they care about. Delivery is asynchronous with
overflow protection, so a slow subscriber can’t stall the engine.
// internal/events — kinds (excerpt)
const (
KindCCLocked Kind = "cc.locked"
KindGrant Kind = "grant"
KindCallStart Kind = "call.start"
KindCallComplete Kind = "call.complete"
)
The design principle: observer / event-driven decoupling
The defining rule: the engine publishes, it never calls outward. It has no
import of internal/api, internal/storage, or internal/broadcast. Those
subsystems are observers that subscribe to the bus. This is the observer
pattern at architecture scale, and it’s what makes the layered dependency
direction from
Part 1
real.
How that principle shaped the Go code
- The core is testable in isolation. You can run the engine with a fake bus and assert it emits the right events for a given grant — no database, no HTTP server, no SDR required.
- Subsystems bolt on without touching the engine. Adding Broadcastify
streaming meant writing a subscriber to
KindCallComplete, not editing the engine. The same is true for metrics, the call log, and the affiliation tracker. - One writer per piece of state. The engine’s
selectloop is the sole mutator of call state, so there are no locks around the hot logic — concurrency is handled by the bus, not by sharing. - Back-pressure is contained. Because delivery is async with overflow handling, a stalled WebSocket client degrades only its own feed; the decode and recording path keeps running.
Where this goes next
The engine has rich behavior worth its own series — priority/preemption policy,
multi-site roaming, control-channel hunting and backoff, and the affiliation/patch
tracking built from the event stream. A future deep dive will trace a single call
from grant to KindCallComplete. Next, we follow the voice frames that a grant
unlocks into the vocoders that turn them into audio.
FAQ
Why an in-process bus instead of just function calls? Function calls would make the engine depend on every consumer, breaking the one-way dependency rule and making the core impossible to test in isolation. The bus inverts that: consumers depend on the engine’s events, not the reverse.
What happens when there are more calls than radios? The engine applies priority and preemption — higher-priority talkgroups can take a voice device from a lower-priority active call. The policy lives entirely in the engine, decided from the event stream.
Can two subsystems react to the same event?
Yes — that’s the point. A single KindCallComplete can simultaneously trigger a
database write, a metrics increment, and an upload, each in its own subscriber.
Series navigation
Part 11 of 14 · ← Part 10 · Next → Part 12: Voice coding — IMBE, AMBE+2 & MBE