Part 1 of The Hunt, a 14-part deep dive into how GopherTrunk finds trunked systems you didn’t know were there. Every earlier series started from something known — a control channel to decode, a grant to record. This one starts from a blank band and works forward to a named, mapped, exportable system. This opener is the map of that whole journey, and it plants a thread we follow the rest of the way: a stray carrier that shows up in a routine survey.
TL;DR: Discovery is a pipeline, not a magic button: sweep a band for carriers, identify what each one is, and map the trunked ones into a single
DiscoveredSystem. GopherTrunk factors that into one package (internal/hunt) with one contract — feed it captures or anIQSource, get back a system map plus a per-carrierCaptureReportexplaining every outcome. The offline CLI, the live daemonManager, and the web cockpit all drive the same pipeline, so what you find on a recording is what you’d find on the air.
Key takeaways
- Discovery has three stages — sweep → identify → map — and, like the decode pipeline, the details change per band but the order never does.
- The unit of a find is a
DiscoveredSystem, accumulated from many captures; every capture also produces aCaptureReportso nothing is a silent failure. - Not-trunked is skipped, not errored. A wideband sweep surfaces analog, paging, and noise; the pipeline degrades gracefully instead of aborting.
- One engine, three drivers. The
hunt.Managerwraps the same functions the offline CLI calls, behind anAcquirerseam so the package never imports the SDR pool.
Cheat sheet
| Stage | What it does | Where it lives |
|---|---|---|
| Sweep | scan a band, list candidate carriers by power | internal/hunt/wideband_sweep.go, internal/carriers |
| Classify | analog / digital / encrypted / trunked? | internal/survey, internal/hunt/enctype.go |
| Identify | which protocol, at what confidence | internal/hunt/discover.go (Discover) |
| Decode | lock the control channel, read grants | internal/hunt/decode.go |
| Accumulate | fold captures into one system map | internal/hunt/accumulate.go |
| Name / export | alias it, write RR / TrunkRecorder / SigMF | internal/hunt/naming.go, export_*.go |
| Orchestrate | own the live run, publish hunt.* events |
internal/hunt/manager.go (Manager) |
In this post
- What “discovery” actually is — and why it’s a search, not a decode.
- The three stages — sweep, identify, map — and where each one lives.
- The
DiscoveredSystem— the thing a hunt produces, and the report beside it. - One engine, three drivers — how the CLI, daemon, and cockpit share it.
- The carrier we’re chasing — the thread that runs the series.
What “discovery” actually is
Every other GopherTrunk series has had the luxury of a known starting point. Protocol Decoders began with a control channel to decode. The Trunking Engine began with a grant already on the bus. Even Signal Lab began with a capture someone had already centered on the interesting thing.
Discovery has none of that. You point a radio at 851–869 MHz in a county you’ve
never scanned and ask a genuinely open question: is there a trunked system here,
and if so, what and where is it? That is a search problem wrapped around the
decode problem. The decode is the easy part once you know where to point — the
hard part is the pointing, and the pointing is what internal/hunt automates.
The instinct to name the shape is the same one the decoder series leaned on: it tells you where to look when a hunt comes back empty. No carriers in the sweep is a front-end problem (gain, antenna, band). Carriers but nothing classifies as digital is a classification problem. A digital carrier that never locks is a decode problem. Naming the stages turns “it didn’t find anything” into a question with an address.
The three stages
A hunt is three stages in a fixed order. The middle one is where a band’s identity lives; the outer two are shared machinery — exactly the pattern the decode pipeline uses one layer down.
Sweep turns a band into a ranked list of carriers. It steps a wide receiver
across the range, estimates a power spectrum, and pulls peaks out of the noise
floor — the occupancy grid and peak detector in internal/carriers, driven by
internal/hunt/wideband_sweep.go. Part 2 and Part 3 are entirely about this
stage.
Identify asks, of each candidate, what is this? First a coarse
classification — analog vs digital vs encrypted vs empty (internal/survey) —
then, for the digital ones, an actual protocol identification that tries to decode
a prefix and reports a confidence. That’s the job of Discover:
// internal/hunt/discover.go (shape)
// Discover folds every capture into a single DiscoveredSystem. Captures whose
// protocol can't be identified with sufficient confidence are skipped, not
// errored, so a wideband sweep that surfaced non-trunked carriers degrades
// gracefully. Per-capture reports are always returned, even on a nil error.
func Discover(inputs []CaptureInput, cfg DiscoverConfig) (*DiscoveredSystem, []CaptureReport, error)
Note the contract in that doc comment: a capture that can’t be identified is a
skip, not a failure. A wideband sweep hands Discover a pile of carriers, and
most of them will be a paging transmitter or an analog repeater or plain noise.
The pipeline classifies each, sets the non-trunked ones aside with a reason, and
keeps going. That single decision — degrade, don’t abort — is what makes an
unattended survey usable.
Map folds the trunked captures into one system. A trunked system has a
control channel, a set of talkgroups, a band plan, maybe multiple sites; a single
capture only ever sees a slice of that. internal/hunt/accumulate.go merges the
slices — dedups the control frequencies, unions the talkgroups, reconciles the
identity broadcasts — into the thing a hunt actually produces.
The DiscoveredSystem and its report
Every capture that goes through the pipeline comes back with a CaptureReport,
and this struct is worth reading in full because it is the honesty contract of
the whole engine — every field is a way of not lying about what happened:
// internal/hunt/discover.go (shape)
type CaptureReport struct {
Path string // which capture this is
Protocol string // identified protocol, or "" if unidentified
Confidence float64 // identifier confidence [0,1]
Locked bool // did the control channel actually lock?
ControlHz uint32 // the decoded control frequency (not the tuned one)
Talkgroups int // distinct talkgroups seen on the CC
ErrorRate float64 // decode errors / 1000 symbols — a demod-quality proxy
Encrypted bool // any encrypted grant seen
EncType string // algorithm name when known (AES-256, ADP/RC4, …)
Skipped bool // set aside (e.g. not trunked), not an error
SkipReason string // why it was skipped, in words
Error string // hard failure, in words
// …Verdict, IdentityNote for wideband + partial-identity cases
}
Three outcomes, and the struct makes all three legible. Decoded: Locked is
true, ControlHz and Talkgroups are populated. Skipped: Skipped is true
with a human SkipReason (“not trunked”, “below min confidence”). Errored:
Error carries the message. There is no fourth, silent outcome — the CLI and the
cockpit render these reports verbatim, so a hunt that finds nothing still tells
you why on every carrier it looked at.
ErrorRate earns its place too: it’s the protocol-neutral demod-quality number
(decode-error events per 1000 symbols, straight from Signal Lab’s
Signal.DecodeErrorRate), and Part 5’s auto-gain sweep uses exactly this field
to decide whether nudging the front-end gain improved the lock.
One engine, three drivers
Discover is a pure function — captures in, system out. That’s perfect for the
offline CLI, but a live hunt needs to acquire an SDR, run for minutes in the
background, stream progress to a cockpit, and be cancellable. That’s the
Manager, and its design is the same decoupling trick the decoder used with the
event bus: the package must not import the SDR pool, so the daemon injects the
acquisition behind a function seam.
// internal/hunt/manager.go (shape)
// Acquirer obtains an IQSource for one run plus a release callback. The daemon
// supplies this so the hunt package stays free of SDR/pool dependencies;
// release is invoked exactly once when the run ends (success, error, or stop).
type Acquirer func(ctx context.Context, opts LiveHuntOptions) (IQSource, func(), error)
// Manager owns the daemon's single live-hunt run: acquire an SDR, run the
// sweep→identify→map pipeline in the background, publish hunt.* bus events,
// and hold the latest DiscoveredSystem for export/commit.
type Manager struct {
acquire Acquirer
bus *events.Bus
// …mu, state, progress, sys, reports, cancel
}
The Manager carries a RunState — idle → running → done | stopped | failed —
that the REST and TUI cockpits poll, and it publishes hunt.* events on the same
event bus
every other subsystem uses. So there are three drivers over one engine:
- The offline CLI (
gophertrunk hunt …) callsDiscover/DiscoverWidebanddirectly on captures or a live tune, prints reports, and exits. - The live daemon builds a
Manager, hands it anAcquirerbacked by the SDR pool, and lets the sweep run while the rest of the daemon keeps scanning. - The web/TUI cockpit drives that
Managerover REST + SSE — start, watchhunt.*progress, stop, then commit or export the result.
How that principle shaped the Go code
- The package never imports the pool.
huntdepends onevents,storage,survey, andsiglab— not on any SDR driver. TheAcquirerseam is the only door to hardware, and the daemon owns it, so the whole engine is testable with a cannedIQSource. - Pure core, stateful shell.
Discoveris a function;Manageris the goroutine-owning, mutex-guarded shell around it. The core has no lifecycle to get wrong, and the shell has no decoding logic to get wrong. - Reports are returned even on success.
Discoverreturns(*DiscoveredSystem, []CaptureReport, error)— the reports come back beside a nil error, so progress and outcomes are never trapped inside an error path. - Skips keep the run alive. Encoding “not trunked” as a
Skippedreport rather than anerroris what lets one bad carrier out of forty not sink the survey.
The carrier we’re chasing
📡 One carrier we keep coming back to. Somewhere in a routine 851–869 MHz survey there’s a strong digital carrier that classifies as digital, not analog, probably not encrypted — and doesn’t obviously belong to any system we’ve named. Over the next thirteen posts we sweep it up (Part 2–3), classify it (Part 4), settle the gain until it locks (Part 5), hunt and confirm its control channel (Part 6–7), reconstruct its neighbours and sites (Part 9), name it (Part 11), harvest the aliases riding its traffic (Part 12), and export the finished find (Part 13). Keep it in mind; it’s the thread.
Discovery is where GopherTrunk stops being a scanner you configure and starts being one that tells you what’s out there. The rest of the series is the machinery that makes that claim true, one stage at a time.
Where this goes next
Part 2 drops into the first stage: the wideband sweep engine — how GopherTrunk steps a receiver across megahertz of spectrum, estimates a power spectrum from short IQ grabs, and stitches the pieces into one occupancy picture without missing a carrier at a tile boundary. From there we pull peaks out of the noise (Part 3) and start classifying what we found (Part 4).
FAQ
What’s the difference between hunting and scanning? Scanning follows a system you’ve already configured — its control channel, its talkgroups. Hunting starts without that: it searches a band for carriers, identifies which are trunked systems, and produces the configuration a scanner would then use. The Hunt series is about that upstream search.
Does discovery need a radio?
No. Discover takes IQ captures just as happily as a live tune — offline surveys
replay recordings through the exact same sweep→identify→map pipeline. Part 10 is
entirely about the offline-vs-live symmetry, and it’s why a find reproduces.
What is a DiscoveredSystem?
The accumulated result of a hunt: one trunked system’s control frequencies, band
plan, talkgroups, identity (WACN/System ID for P25, and so on), and — after Part
11 — a name. It’s built by folding many partial captures together, because no
single capture sees the whole system.
Why does a hunt “skip” carriers instead of failing?
A wideband sweep surfaces everything with power — analog repeaters, pagers, noise.
Only some are trunked. Encoding a non-trunked carrier as a Skipped report with a
reason (rather than an error) lets an unattended survey look at forty carriers and
still finish, explaining each one.
Is the same engine used live and offline?
Yes. Discover is the pure core; the daemon’s Manager wraps it for live runs
behind an Acquirer seam so the package never imports the SDR pool. CLI, daemon,
and cockpit are three drivers over one engine — which is why lab results transfer
to the air.
Series navigation
Part 1 of 14 · Next → Part 2: The Wideband Sweep Engine