Part 13 of The Hunt. Our 851–869 MHz carrier has been swept up, classified,
locked, mapped across sites, named as far as the RF allows, and mined for aliases.
The find is complete — and completely stuck on one machine. This post is about
getting it *out: turning the DiscoveredSystem into files the rest of the world
reads. A RadioReference submission so the system gets documented. A TrunkRecorder
config so another tool can record it. GopherTrunk’s own bundle so it re-imports
without loss. And a SigMF-tagged capture bundle so the raw IQ is portable too.*
TL;DR: One
DiscoveredSystem, oneWrite(w, sys, format, hints)call, four encodings. Bundle is a multi-section CSV that is the exact reverse of GopherTrunk’s importer — it round-trips straight back intoconfig.yaml. TrunkRecorder is a JSON system stanza with the control channels flattened. RR is a Markdown submission package the operator pastes into RadioReference’s web form (there is no write API), optionally carrying a live cross-reference diff that upgrades “is this a duplicate?” to “what’s new or off here?” A fifth path exports the raw capture as a SigMF-sidecar’d.gtbbundle so the IQ is interoperable with inspectrum and GNU Radio. Every format is honest about what a blind discovery can’t know.
Key takeaways
- One writer, four formats.
hunt.Writedispatches aFormat— bundle, trunk-recorder, rr, summary — so the CLI, daemon, and cockpit all export through one seam. - The bundle round-trips. Its section markers and column names match the
importer exactly, so a discovery exported as CSV re-imports into
config.yamlwithout loss — and site names with commas survive via realencoding/csv. - RR export can diff against reality.
DiffAgainstRRpairs discovered frequencies against an existing RadioReference system — exact matches are silent, near matches are flagged as tuning offsets, the rest are “new.” - Two artifacts leave the machine. The system map (bundle/TR/RR) and the raw
capture (a SigMF-tagged
.gtbbundle) — so both what you concluded and what you measured are portable.
Cheat sheet
| Format | What it is | Where it lives |
|---|---|---|
FormatBundle |
multi-section CSV, reverses the importer | internal/hunt/export_bundle.go |
FormatTrunkRecorder |
JSON system stanza | internal/hunt/export_trunkrecorder.go |
FormatRR |
Markdown RadioReference submission | internal/hunt/export_rr.go |
RRDiff / DiffAgainstRR |
cross-reference vs an existing RR system | internal/hunt/export.go |
SurveyJSON / SurveyCSV |
the full classified inventory | internal/hunt/export_survey.go |
| SigMF sidecar | portable capture metadata in a .gtb bundle |
internal/gtbundle/sigmf.go |
In this post
- One writer, four formats — the
Writeseam and theFormatenum. - The bundle that round-trips — CSV as the exact inverse of the importer.
- TrunkRecorder — flattening a multi-site map into one stanza.
- The RadioReference package — a submission, and a diff against reality.
- SigMF — making the raw capture portable too.
One writer, four formats
Every export goes through a single function. Write (and its diff-carrying
sibling WriteWithRRDiff) sorts the system for deterministic output, then
dispatches on a Format:
// internal/hunt/export.go (shape)
func WriteWithRRDiff(w io.Writer, sys *DiscoveredSystem, f Format, hints []DuplicateHint, diff *RRDiff) error {
sys.sortAll()
switch f {
case FormatBundle: return writeBundle(w, sys)
case FormatTrunkRecorder: return writeTrunkRecorder(w, sys)
case FormatRR: return writeRR(w, sys, hints, diff)
case FormatSummary: return writeSummary(w, sys)
}
return fmt.Errorf("hunt: unsupported format %d", f)
}
ParseFormat maps the strings operators type (bundle/csv, trunk-recorder/tr,
rr, summary) onto the enum, and FileExtension gives each its conventional
suffix (.csv, .json, .md, .txt). Because there is one seam, the offline
CLI, the daemon’s export endpoint, and the web cockpit all produce byte-identical
files — the same “one engine, many drivers” discipline the whole series has leaned
on, now at the exit.
The reason for four formats rather than one is that a find has more than one
audience. RadioReference wants a human-reviewed submission; TrunkRecorder wants a
machine-readable config; GopherTrunk itself wants something it can re-ingest; and
sometimes you just want a readable network report to skim. Rather than pick a
lowest common denominator, the exporter speaks each destination’s native dialect
— and sortAll runs first every time, so any two runs over the same system emit
identical bytes, which is what makes the golden tests and the round-trip possible
at all.
The bundle that round-trips
The default format is the one that matters most for GopherTrunk itself. A
discovery isn’t useful if it can only leave — it has to be able to come back, as
configuration you can scan. So writeBundle emits a multi-section CSV that is the
exact inverse of the importer:
// internal/hunt/export_bundle.go (shape)
// writeBundle emits the multi-section CSV import bundle that
// cmd/gophertrunk/import_csv.go parseCSVStream reads. The section markers and
// column names match parseMetadataSection / parseSitesSection /
// parseTalkgroupsSection exactly so a discovery round-trips back into
// config.yaml without loss.
func writeBundle(w io.Writer, sys *DiscoveredSystem) error {
bw := &bundleWriter{w: w}
bw.section("metadata")
bw.row("key", "value")
bw.row("name", sys.DisplayName())
// …protocol, sysid, wacn, location, county
bw.section("sites")
bw.row("rfss", "site_id", "site_name", "county", "frequencies")
// …one row per site, control channels carrying a trailing 'c' flag
bw.section("talkgroups")
// …decimal, hex, mode=D, blank alpha_tag/description, scan=Y
return bw.flush()
}
Two details make the round-trip real. Frequencies are rendered by formatMHz
into exactly the spelling the importer parses (851012500 → 851.0125), and the
whole thing is written through encoding/csv, whose dialect matches the
importer’s line splitter — so a site name or location containing a comma survives
the trip intact. The talkgroup rows ship with mode=D and scan=Y defaults but
blank alpha tags and descriptions, faithfully carrying forward the
naming post’s
rule: a blind discovery knows the numbers, not the names, so it leaves the names
for a human. Export it, edit it, import -csv it, and it lands in
config.yaml as a scannable system.
TrunkRecorder: flattening a map into a stanza
TrunkRecorder models a system more simply than GopherTrunk does — one
control_channels array, not a per-site topology — so writeTrunkRecorder
flattens the discovery into the shape it expects and is honest in the comment
about what it collapsed:
// internal/hunt/export_trunkrecorder.go (shape)
func writeTrunkRecorder(w io.Writer, sys *DiscoveredSystem) error {
var ccs []int64
for _, st := range sys.Sites {
for _, ch := range st.ControlChannels {
if ch.IsControl {
ccs = append(ccs, int64(ch.FrequencyHz)) // all sites → one array
}
}
}
comment := "Discovered by GopherTrunk hunt. Verify control channels and add recorders/talkgroupsFile before use."
if len(sys.Sites) > 1 {
comment += " Multi-site system: control_channels flattens all sites; split per-site if desired."
}
// …emit {shortName, type, control_channels, modulation, talkgroupsFile, comment}
}
trProtocol maps GopherTrunk’s spellings onto TrunkRecorder’s type enum (both
P25 phases → p25, all DMR tiers → dmr), and slug produces a
filesystem-safe shortName and a matching talkgroupsFile name. A protocol
TrunkRecorder doesn’t model at all falls back to the GopherTrunk name rather than
being dropped, so the stanza is always informative even when it needs a manual
edit. The stanza is deliberately a starting point — recorders and modulation
tuning are the operator’s to add — and the comment says so, because a config that
pretends to be complete is worse than one that flags its own gaps.
The RadioReference package — and a diff against reality
RadioReference has no public write API; new systems go through a reviewed web form. So the RR “export” is not an upload — it’s a Markdown submission package the operator pastes into that form, with every field laid out and a standing warning that a blind discovery can’t name talkgroups or confirm geography. But the genuinely clever part is optional and lives one layer up: a cross-reference diff.
// internal/hunt/export.go (shape)
// DiffAgainstRR compares the system's discovered frequencies and talkgroups
// against a flattened RadioReference list. Frequencies are paired greedily by
// nearest unused RR frequency: an exact pair is silent, a pair within
// rrFreqToleranceHz is an offset, and anything farther (or unmatched) is "not in RR".
const rrFreqToleranceHz = 5_000 // below the 6.25 kHz raster, so adjacent channels don't pair
func DiffAgainstRR(sys *DiscoveredSystem, sid int, name string, rrFreqs, rrTGs []uint32) RRDiff
This upgrades the RR step from a yes/no duplicate check to a what’s-different
report. An exact frequency match is silent (nothing to submit). A match within 5
kHz is flagged as a frequency offset — “your capture reads 851.0130, RR has
851.0125; check your tuning/PPM.” Anything farther, or unmatched, is a frequency
not in RR — genuinely new. Talkgroups you’ve observed that RR hasn’t get the
same treatment. The submission package renders all of it under a “Differences vs
RadioReference” header, alongside any DuplicateHints from the read-only RR
lookup, so you contribute what’s new and correct what’s off instead of
re-submitting a system that already exists.
How that principle shaped the Go code
- Formats are data, dispatch is one switch. Adding an encoding is a
Formatconstant plus awriteXfunction and aParseFormatalias — the CLI, daemon, and cockpit inherit it with no wiring. - The bundle is defined by its inverse.
writeBundle’s section names and columns are pinned to the importer’s parser, and a golden test round-trips a discovery out and back — so the two can’t drift apart silently. - The diff is kept out of the package.
DiffAgainstRRtakes flattened frequency/talkgroup slices, not a RadioReference client, sointernal/huntnever imports the RR API — the CLI flattens and passes them in.
SigMF: making the capture portable
The system map is only half of a find. The other half is the raw IQ it came from —
and a capture is only as useful as it is portable. GopherTrunk’s answer is to
package a survey capture into a .gtb bundle with a SigMF metadata sidecar:
// internal/gtbundle/sigmf.go (shape)
// GopherTrunk does not read SigMF natively, but siglab.Metadata is a near-subset
// of the SigMF global object, so emitting one makes a bundled capture/slice
// interoperable with SigMF-aware tooling (inspectrum, GNU Radio, sigmf-python).
const sigmfVersion = "1.0.0"
// RoleCaptureSigMF → capture/<stem>.sigmf-meta — optional SigMF sidecar
The capture-bundle assembler (driven from
survey-capture)
writes the raw IQ, GopherTrunk’s own metadata, a carved narrowband slice, and this
SigMF sidecar into one .gtb.tar.gz, tagged with a capture intent (cc-map for a
hunt, crypto for a cipher case, survey for a wideband grab). So the artifact that
leaves your machine isn’t just “a system I think exists” — it’s “a system I think
exists, and the exact samples that convinced me, in a format inspectrum can
open.” Both the conclusion and the evidence travel. That is the payoff of the
whole offline/live symmetry:
a reviewer can re-derive your find from your bytes.
Where this goes next
Everything so far has been the machinery of a find — engine parts described from the outside. Part 14, the finale, closes the loop two ways: the interactive hunt cockpit that drives all of this live from a browser or terminal, and the thing that makes the whole series trustworthy — testing the entire discovery pipeline with canned IQ sources and captures, no radio required.
FAQ
Which export format should I use?
Bundle (the default) if you want the find back inside GopherTrunk — it round-trips
into config.yaml. TrunkRecorder if another tool will record the system. RR to
document it publicly. Summary for a human-readable network report. They’re all the
same Write call with a different Format.
Does the CSV bundle really re-import losslessly?
Yes — its section markers and column names are pinned to the importer’s parser,
and it’s written through encoding/csv, so commas in site names survive. A golden
test round-trips a discovery out and back to keep the two from drifting.
Why is the RadioReference export a Markdown file, not an upload? RadioReference has no public write API; new systems are added through a reviewed web Submit form. So the export is a submission package you paste in — with a duplicate check and an optional diff against any existing RR system so you don’t re-submit or mis-tune.
What does the RR diff actually tell me? It pairs your discovered frequencies against an existing RR system: exact matches are silent, matches within 5 kHz are flagged as tuning/PPM offsets to verify, and anything unmatched is genuinely new. Talkgroups get the same treatment. It turns “is this a duplicate?” into “here’s exactly what’s new or off.”
What is the SigMF bundle for?
Portability of the raw IQ. GopherTrunk doesn’t read SigMF natively, but its
capture metadata is a near-subset of SigMF’s, so emitting a .sigmf-meta sidecar
inside the .gtb bundle lets inspectrum, GNU Radio, and sigmf-python open your
capture. The find and the samples that prove it travel together.
Series navigation
Part 13 of 14 · ← Part 12: Alias Harvesting — Following Traffic for Talker Aliases · Next → Part 14: The Hunt Cockpit & Testing Discovery Without Radios