Part 1 of The Field Notebook, a 14-part operator’s tutorial that reads
GopherTrunk’s debug.log one line family at a time — what each field
measures, what a healthy rig prints, which number is a noise meter and which
one means traffic, and which deep dive to open when a line goes wrong.
The Operator’s Cookbook
built rigs and showed the lines that prove each one alive;
From Spec to Shipping Part 13
explained why those lines are designed as instruments. This series is the
reading guide between them, starting with the block between launch and the
first control-channel lock — where most “it doesn’t decode” reports are
already decided.
TL;DR: A healthy startup prints, in order:
gophertrunk starting version=…(cmd/gophertrunk/main.go), adiagnostics banner=block, onedevice opened+ onesdr: gain setper dongle (internal/sdr/pool.go),daemon: config summaryplus onedaemon: system configper system, a secondgophertrunk startingcarryingsystems=andvoice_devices=,api: listening, thenccdecoder: digital down-converter configuredas the hunt begins. The WARNs between them are config verdicts, not RF:gain looks like dB, not tenths-of-dB(gain: 50is 5 dB —did_you_mean=500),N systems (…) share ONE control SDR(time-multiplexed, never concurrent), andchannels:/voice_taps/tuner_strategy … are being IGNOREDoff a wideband role. The dangerous case logs nothing: a source list missing from the pool-construction gate indaemon.goregisters no driver, and the daemon starts quietly with no radio.
Key takeaways
- Startup is a checklist the daemon reads aloud. Each stage announces itself once, so a missing line localises the fault before you touch RF.
gain:is in tenths of a dB, and the log tells you.gain: 50parses to 5.0 dB; the WARN printsparsed_db=5 did_you_mean=500.- Two systems on one
role: controltuner never decode at once. The hunter camps on the first lock and hunts the second only after losing it; the concurrent path isrole: wideband. - Silence can be the symptom. A source list left out of the pool gate
registers no driver and logs nothing; a block with no
device openedline is the tell.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Process banner | version, host and dongle diagnostics | cmd/gophertrunk/main.go (gophertrunk starting, diagnostics) |
| Dongle claim | driver, serial, role, rate, ppm | internal/sdr/pool.go (device opened, sdr: gain set) |
| Gain-unit trap | bare integer ≤ 50 tenths reads as dB | cmd/gophertrunk/daemon.go (warnGainUnits, gainLooksLikeDBMistake) |
| One control tuner, N systems | time-multiplexed hunt, WARN at startup | daemon.go (cchuntSystems block), TestTwoSystemsOnOneControlSDRWarns |
| Dead wideband keys | channels:/voice_taps/tuner_strategy off a wideband role |
daemon.go (being IGNORED WARN) |
| The silent gate | every source list must be named or no driver registers | daemon.go (pool construction), TestRemoteOnlySDRConfigsRegisterTheirDriver |
In this post
- What this line family is telling you — the startup block, stage by stage.
- What healthy looks like — a real block and its fields.
- The WARNs that mean config, not RF — gain units, shared tuners, dead keys.
- The gate that logs nothing — why an absent line is the loudest symptom.
- When it doesn’t look like that — symptom → cause → fix.
What this line family is telling you
Startup is a sequence of claims, each logged by the layer that made it — the
daemon knows what it opened, parsed and wired, not whether the radio will
lock. main.go logs gophertrunk starting version=… the moment
the logger exists, then diagnostics banner= — version, os, host
and the dongles the pool claimed, so a pasted log carries its own context
(Running It For Real Part 6).
The pool prints one device opened driver= serial= role= rate_hz= ppm=
bias_tee= receipt per dongle — kept so an operator can grep whether the
ppm they typed landed on the serial driving the hunt (issue #264) — then
sdr: gain set … gain_db=, the dB figure the tenths value became.
daemon: config summary and one daemon: system config name= protocol=
control_channels= enhancements= per system restate the config as the
receiver was built, not as the empty YAML strings that mean “default”.
Daemon.Run then logs the second gophertrunk starting with http_addr=
grpc_addr= systems= voice_devices=, the API binds (api: listening addr=
tls=), and the hunt begins with ccdecoder: digital down-converter
configured sdr_rate_hz= pipeline_rate_hz= lo_offset_hz= autotune_hz=.
Everything after that belongs to
Part 2.
What healthy looks like
A one-dongle P25 rig from Cookbook Part 1 at log.level: info starts like
this (long fields trimmed with …; every field name is real):
INF gophertrunk starting version=v1.1.5
INF diagnostics banner="GopherTrunk diagnostics\nversion : v1.1.5 …"
INF device opened driver=rtlsdr serial=00000001 role=wideband rate_hz=2400000 ppm=0 bias_tee=false
INF sdr: gain set serial=00000001 role=wideband gain_db=49.6
INF daemon: system config name=Metro-P25 protocol=p25 control_channels=2 enhancements=demod=c4fm …
INF gophertrunk starting http_addr=127.0.0.1:8080 systems=1 voice_devices=2
INF api: listening addr=127.0.0.1:8080 tls=false
INF ccdecoder: digital down-converter configured sdr_rate_hz=2400000 pipeline_rate_hz=48000 lo_offset_hz=0 autotune_hz=0
Read three things off it. role=wideband with voice_devices=2 means
the virtual voice taps auto-enabled. gain_db=49.6 is the tenths value
"496" in dB — if it matches the number you typed, you typed dB.
pipeline_rate_hz=48000 is the per-protocol channel rate (48 kHz for the
4800-baud family, 144 kHz for TETRA), which is why decode is invariant to
your capture rate
(Weak-Signal Engineering Part 12).
| Field | Measures | Healthy | Worry when |
|---|---|---|---|
version= |
the binary actually running | the release you installed | stale — diagnostic playbook rung 0 |
device opened … serial= role= ppm= |
the pool’s claim on one dongle | one per configured device | a device missing, or configured SDR not present on the bus |
sdr: gain set … gain_db= |
the applied gain in dB | 15–49.6 for a manual RTL-SDR | single digits, or no line |
system config … enhancements= |
what the receiver was built with | the flags you expect | unrecognised …; falling back to WARNs above it |
starting … voice_devices= |
voice sources the composer can bind | ≥ 1 per trunked system | 0 with systems>0 — grants drop |
… pipeline_rate_hz= |
the DDC’s output rate | 48000 or 144000 by protocol | absent — no hunt started |
The WARNs that mean config, not RF
Every startup WARN in daemon.go is a verdict on the file, reached before a
single sample was demodulated. Fix these before touching the antenna.
Gain in tenths. gain: is tenths of a dB — "320" is 32 dB — because
that is the unit the tuner driver speaks. SDRTrunk, OP25 and gqrx take whole
dB, and the habit lands gain: 32 in first-run configs.
gainLooksLikeDBMistake flags a bare integer at or below 50 tenths:
WRN daemon: gain looks like dB, not tenths-of-dB — radio may be effectively deaf serial=00000001 configured=50 parsed_db=5 did_you_mean=500 hint="gain: is in TENTHS of a dB (\"320\" = 32 dB). SDRTrunk/OP25/gqrx users multiply dB by 10. …"
That line was in a 13 Sep field log: a TETRA DMO rig ran gain: 50, its
control channel sat at −70 dBFS, and gain: 500 was the first thing to try.
The sibling warnLowGain covers 51–149 tenths, and its hint adds that a
non-zero iq_clip_ratio means the radio is already too hot — see
Analog Edge Part 3
and SDR gain overload.
Two systems, one control tuner. The cchunt.Supervisor is a time
multiplexer: it hunts systems round-robin and parkUntilUnlocked on the
first that locks — on a healthy site, forever. A 10 Sep report listed two
TETRA systems inside one 200 kHz span and saw only one ever decoding, with
no line saying why. Now the daemon says so once (wrapped for width):
WRN daemon: 2 systems (250_013, 250_208) share ONE control SDR: the single-tuner hunter decodes them one at a time and camps on the first that locks, so the others are hunted only after it loses lock. To decode them concurrently host every control channel on a `role: wideband` device with one `channels:` entry per system …
WRN daemon: sdr.devices[00000001] has role "auto" but sets channels:/voice_taps/tuner_strategy — those keys only apply to role: wideband and are being IGNORED …
The second line is the companion the same rig carried: a channels: plan on
a role: auto device that nothing read, because those keys are gated on
Role == "wideband". TestTwoSystemsOnOneControlSDRWarns pins both; the
concurrent recipe is
Cookbook Part 7.
No voice source. no voice source configured but trunking systems are
defined; voice grants will be dropped — no audio and no recordings (issue
#379) fires once from Daemon.Run when systems>0 and the voice pool is
empty — error-shaped, but also a legitimate monitor-only rig. Its cousin trunking.systems configured but sdr.devices is empty is
the pool gate’s else branch, the setup for the next section.
The gate that logs nothing
The most instructive startup failure prints no WARN. The SDR pool is built
inside one condition in daemon.go, and the network drivers are registered
inside that block:
// cmd/gophertrunk/daemon.go (shape) — the pool-construction gate
// EVERY source list has to be named here: a source missing from the
// condition is never registered at all.
if len(cfg.SDR.Devices) > 0 || len(cfg.Baseband.Replay) > 0 ||
len(cfg.SDR.RTLTCP) > 0 || len(cfg.SDR.SoapyRemote) > 0 ||
len(cfg.SDR.Ka9qRadio) > 0 || len(cfg.SDR.Sidecar) > 0 {
d.pool = sdr.NewPool(log) // … drivers registered, pool.OpenWith …
}
The gate once read SDR.Devices || Baseband.Replay || SDR.RTLTCP. A config
with only sdr.soapy_remote (or only ka9q_radio) registered no driver, and
the daemon came up quietly with no radio — no device opened, no pool error,
and not even the sdr.devices is empty WARN, because SoapyRemote was
non-empty and the else never ran. The unhealthy block’s signature is what
is missing:
INF gophertrunk starting version=v1.1.5
INF diagnostics banner="GopherTrunk diagnostics\n…\ndongles : none detected"
INF gophertrunk starting http_addr=127.0.0.1:8080 systems=1 voice_devices=0
WRN no voice source configured but trunking systems are defined; voice grants will be dropped — … systems=1
No device opened. No sdr: gain set. No ccdecoder: digital down-converter
configured, because the hunt supervisor is only built when d.pool != nil.
TestRemoteOnlySDRConfigsRegisterTheirDriver walks each network-only config
through NewDaemon and asserts on driver registration, not on d.pool,
which is nil whenever the pool fails to open.
The reading rule behind this series is From the Issue Tracker Part 21’s: a success-only log carries no information, so an absent line is evidence. Startup is where it applies without a counter, because the healthy sequence is fixed.
When it doesn’t look like that
| Symptom | Likely cause | Fix / read |
|---|---|---|
sdr: gain set … gain_db=5 plus the gain looks like dB WARN |
gain: 50 meant 50 dB |
gain: "500" or auto; Cookbook 1 explains the unit |
share ONE control SDR WARN; one system never locks |
two systems on a role: control/auto tuner are time-multiplexed |
Both CCs on a role: wideband device, one channels: entry each — Cookbook 7 |
configured SDR not present on the bus; check the cable / dmesg / lsusb |
serial typo, or wrong kernel driver bound | gophertrunk sdr list for the serial, sdr doctor for the binding — Running It For Real 7 |
Two commands belong beside this table. gophertrunk sdr list prints
DRIVER IDX SERIAL TUNER PRODUCT gains(0.1 dB) — the ladder in the config’s
own tenths unit. gophertrunk sdr doctor inspects the USB bus
and driver binding (Zadig on
Windows); a dongle that appears then vanishes is
USB recovery territory.
How this line shapes operator practice
- Paste the whole startup block into a report. A reader diagnoses by what is missing; a trimmed excerpt hides it.
- Read
gain_db=before any dBFS figure. A single-digit gain explains a −70 dBFS control channel completely; no equalizer or antenna will. - Treat every startup WARN as a config bug. They are computed from the file alone; RF troubleshooting starts once the block prints clean.
Where this goes next
Once the down-converter line prints, the hunt supervisor owns the log.
Part 2
reads the lock family — cc-hunt: trying, cc-hunt: locked, the
per-protocol cc locked lines, cchunt: hunt failed with its diagnosis
field, and the camped on conventional channel idle state.
FAQ
Why does GopherTrunk log “gophertrunk starting” twice?
Two layers announce themselves: main.go logs gophertrunk starting
version=… as soon as the logger exists, and Daemon.Run logs gophertrunk
starting http_addr= systems= voice_devices= once the pool, systems and voice
devices are built. The second line says what the daemon will do.
What does the “gain looks like dB, not tenths-of-dB” warning mean?
GopherTrunk’s gain: value is tenths of a dB, so gain: 50 is 5 dB and the
radio is nearly deaf. The WARN prints parsed_db and did_you_mean (the
value ×10). Set gain: "500" for 50 dB, or gain: auto, and check the
ladder with sdr list.
Can two trunked systems share one control SDR in GopherTrunk?
Only one at a time. A single role: control or auto tuner is hunted
round-robin and parks on the first system that locks; the second is hunted
only after that lock is lost, and the daemon warns at startup. For concurrent
decode, host every control channel on a role: wideband device, one
channels: entry per system.
The daemon starts but never prints “device opened” — what happened?
No SDR driver registered. Either the configured serial is not on the bus
(configured SDR not present follows), the pool failed to open (SDR pool
failed to open), or — on older builds — a network-only source such as
soapy_remote was missing from the pool gate and registered nothing
silently. Upgrade first, then check sdr list.
Do startup warnings appear anywhere besides the log?
Yes. Every addWarning message is collected into startupWarnings, printed
by the launcher as !-prefixed lines on stderr, and shown on the TUI
dashboard, so a headless operator sees the same config verdicts the log
carries; preflight.go’s warnings join the same list.
Series navigation
Part 1 of 14 · Next → Part 2: Lock Lines — Locked, Lost & Transitions