Hardware Setup

GopherTrunk ships with pure-Go drivers for three SDR families — no librtlsdr, libhackrf, libairspy, libusb, or C toolchain on the build host. All three drivers share the same pure-Go USB transport (USBDEVFS on Linux, IOKit on macOS, WinUSB on Windows).

Supported devices

Family Driver USB IDs Status
RTL-SDR (RTL2832U / RTL2838U + R820T / R820T2 / R828D / E4000 / FC0012 / FC0013 / FC2580) rtlsdr 0x0bda:0x2832 · 0x0bda:0x2838 Production — on-air-validated across Linux / macOS / Windows.
HackRF One / Jawbreaker / Rad1o hackrf 0x1d50:0x6089 · 0x1d50:0x604b · 0x1d50:0xcc15 Wire-protocol-complete; on-air validation against attached hardware is the documented follow-up.
Airspy R2 / Airspy Mini airspy 0x1d50:0x60a1 Wire-protocol-complete; on-air validation against attached hardware is the documented follow-up.
Airspy HF+ Discovery / HF+ Dual Port / legacy HF+ airspyhf 0x03eb:0x800c Wire-protocol-complete; HF (9 kHz – 31 MHz) + VHF (60 – 260 MHz). On-air validation against attached hardware is the documented follow-up.
rtl_tcp remote (any librtlsdr-shipped server) rtltcp TCP Remote RTL-SDR mounted over the network. See Remote rtl_tcp SDRs.
SoapySDRServer remote (USRP / LimeSDR / bladeRF / HackRF / Airspy / RTL-SDR / SDRplay …) soapyremote TCP Any SoapySDR-supported radio mounted over the network with 16/32-bit IQ + control. Wire-protocol-complete; on-air validation against a live SoapySDRServer is the documented follow-up. See Remote SoapySDRServer SDRs.

The HackRF and Airspy / Airspy HF+ drivers speak the documented libhackrf, libairspy, and libairspyhf USB vendor protocols directly (transceiver / receiver mode, frequency, sample rate, LNA / VGA / mixer / amp / attenuator / bias-tee, bulk-IN sample reaper with real-time decode of HackRF int8 IQ and Airspy / HF+ INT16_IQ into complex64). Their wire protocols are exercised by unit tests against usb.MockTransport. SDRPlay, USRP and BladeRF require vendor C libraries and are out of scope for the zero-CGO build.

At enumeration time each driver reports the canonical model name rather than echoing whatever the USB descriptor happens to carry: HackRF maps the PID to HackRF One / HackRF Jawbreaker / Rad1o, Airspy R2/Mini detects the MINI substring in the descriptor to emit R820T (Airspy R2) or R820T (Airspy Mini), and the HF+ driver distinguishes Discovery / Dual Port / legacy units the same way. On Open, the HackRF driver also reads the firmware’s BOARD_ID_READ + VERSION_STRING_READ control transfers, so the operator-visible TunerName field carries the running firmware version and a + PortaPack tag when a PortaPack / Mayhem build is detected. The HF+ driver appends its firmware version the same way.

RTL-SDR tested combinations

Device Tuner Notes
NooElec NESDR Smart v5 R820T2 0.5 ppm TCXO, software-controllable bias-tee. Use bias_tee: true in config to power an external LNA via the SMA.
NooElec NESDR Smart (v4 and earlier) R820T2 TCXO; no bias-tee on early units.
Generic RTL-SDR Blog v3 / v4 R820T2 / R828D Bias-tee on most units.
Plain RTL2832U DVB-T sticks R820T No TCXO; expect a few ppm offset — set ppm: in config after measuring.

“RTL2838U” dongles are already supported. Many economy SDRs are marketed or labelled by their demodulator/USB-bridge chip — the RTL2838U — which is a variant of the RTL2832U, not a tuner. These units enumerate as 0x0bda:0x2838 (and usually report RTL2838UHIDIR in gophertrunk sdr list); the actual tuner inside is an R820T2 or R828D, both supported above. If your dongle says “RTL2838”, it works out of the box — no extra driver needed.

If you have a v5 (or any modern dongle with a bias-tee) and want to power an LNA, the config snippet looks like:

sdr:
  devices:
    - serial: "00000001"      # whatever `gophertrunk sdr list` shows
      role: control            # or voice / auto
      ppm: 0                   # 0 is fine for TCXO-equipped units
      gain: "auto"             # TENTHS of a dB, not dB — "496" = 49.6 dB
      bias_tee: true           # 5V on the SMA — only enable if you want it

Always set gain: explicitly. A device listed in sdr.devices[] with no gain: key opens at whatever the librtlsdr default chose for the tuner — typically a middle-range fixed value that’s too low for many LNA + antenna combinations. The field symptom is “voice grants land on a Voice SDR but every call ends reason=timeout with an empty WAV” (issue #356 follow-up). The daemon now surfaces this at startup with sdr: no gain configured for device ...; if you see that line, set gain: "auto" for AGC or pick a tenth-dB value that matches your front-end.

gain: is in TENTHS of a dB, not whole dB. This is the most common first-run footgun for operators coming from SDRTrunk / OP25 / gqrx, which all take whole dB. In GopherTrunk "320" = 32 dB and "496" = 49.6 dB; a bare "32" is parsed as 3.2 dB, which the driver then snaps to the bottom of the tuner’s gain ladder, leaving the radio effectively deaf (no control-channel lock, no decodes). Multiply your usual dB figure by 10, or use "auto". The daemon now warns at startup (gain looks like dB, not tenths-of-dB ...) when a bare integer gain parses to ≤ 5.0 dB, and logs the applied gain in dB on every device (sdr: gain set ... gain_db=...). A decimal form like "32.0" is taken as whole dB, so that works too.

Multi-site role: wideband dongles: prefer gain: "auto". Every tap on a wideband dongle shares one antenna, one centre and one gain. A single fixed gain can’t serve sites of differing strength: a value chosen so the strongest site doesn’t clip leaves weaker co-tenants under-amplified, sitting dead and flat at the ADC noise floor — and the receiver’s own post-discriminator AGC can’t recover SNR that was lost at the converter. AGC (gain: "auto") lets the front end run the band hot enough for weak sites to clear the floor; in field testing it took a co-tenant control channel from zero decoded TSBKs to hundreds (issue #749). The daemon warns at startup when a multi-tap wideband dongle is pinned to a fixed gain. A genuinely weak/distant site may still not survive a shared capture even under AGC — give it its own dongle.

HackRF tested combinations

Device PID Coverage Gain chain Bias-tee Notes
HackRF One 0x6089 1 MHz – 6 GHz, half-duplex 8/10/20 MSPS RF amp (on/off, +14 dB) + LNA (0–40 dB / 8 dB steps) + VGA (0–62 dB / 2 dB steps) +3.3 V on ANT (HW rev 6+) Single SMA antenna port. PortaPack add-on (Mayhem firmware) is auto-detected via the VERSION_STRING_READ control transfer — gophertrunk sdr list then shows HackRF One + PortaPack.
HackRF Jawbreaker 0x604b 30 MHz – 6 GHz prototype Same as One (MAX2837 + MAX5864) None Pre-production batch; functional but rarely seen in the field.
Rad1o 0xcc15 50 MHz – 4 GHz Same as One None Chaos Communication Camp 2015 badge; same firmware family as HackRF One, identical wire protocol.

The HackRF has no hardware AGC. Passing gain: "auto" selects a safe fixed split (LNA = 16 dB, VGA = 20 dB, RF amp off); positive tenth-dB values are distributed across the three stages. The firmware-reported board ID (BOARD_ID_READ) is the canonical model identifier and takes precedence over the USB descriptor when the two disagree.

sdr:
  sample_rate: 8_000_000          # HackRF baseband; filter follows automatically
  devices:
    - serial: "0000000000000000a06064c8333819cf"
      role: control
      gain: "400"                  # 40 dB target distributed across LNA + VGA
      bias_tee: false              # set true to power an external LNA

Airspy tested combinations

Device PID Coverage Max sample rate Gain chain Bias-tee Notes
Airspy R2 0x60a1 (airspy) 24 – 1700 MHz 10 MSPS R820T LNA + Mixer + VGA (each 0–15) with per-stage AGC +4.5 V on SMA Most common variant. Identified by the USB Product string Airspy R2.
Airspy Mini 0x60a1 (airspy) 24 – 1700 MHz 6 MSPS Same as R2 +4.5 V on SMA Identified by the MINI substring in the USB Product string. Same R820T tuner; smaller form factor and lower max rate.
Airspy HF+ Discovery 0x800c (airspyhf) 9 kHz – 31 MHz HF + 60 – 260 MHz VHF 768 kSPS HF AGC (firmware-managed) + HF attenuator 0–48 dB (6 dB steps) + +6 dB LNA preamp +4.5 V on HF SMA Most popular HF receiver. gain: "auto" enables firmware HF AGC; numeric values map to attenuator step + LNA preamp.
Airspy HF+ Dual Port 0x800c (airspyhf) Same as Discovery 768 kSPS Same as Discovery +4.5 V on the HF SMA only Identified by the DUAL substring. The VHF SMA does not carry bias voltage.
Legacy Airspy HF+ 0x800c (airspyhf) Same as Discovery 768 kSPS Same as Discovery Hardware-revision dependent Pre-Discovery hardware; uncommon.

The R2 / Mini driver pins the device to INT16_IQ sample mode at open-time and reads the firmware’s advertised sample-rate table — SetSampleRate then picks the closest available rate, so a sample_rate: 10_000_000 config selects the 10 MSPS slot on R2 and a sample_rate: 6_000_000 config selects the 6 MSPS slot on Mini without per-device overrides. For a single narrowband control channel, don’t reach for the top native rate — see “Choosing a sample rate” below. The HF+ driver does the same; it also reads VERSION_STRING_READ to expose the firmware version in TunerName.

sdr:
  sample_rate: 768_000             # HF+ Discovery max
  devices:
    - serial: "3652b46d6e6f8867"
      role: control
      gain: "auto"                 # firmware HF AGC handles the HF band well
      bias_tee: false              # set true to power an active HF antenna

Choosing a sample rate

A wideband front-end like the Airspy R2 advertises native rates up to 10 MSPS, but a trunking control channel is only ~12.5 kHz wide and the down-converter normalises it to 48 kHz before decode. Capturing at the top native rate buys nothing for a single narrowband channel — and on some units it actively hurts.

On an R2 captured at its native 10 MSPS, the same control channel — same antenna, gain, and centre frequency — carried roughly 16 dB worse in-channel SNR than the identical signal captured at 2.5 MSPS (issue #771: EVM 22.5% / demod SNR 9.5 dB versus 7.4% / 19.7 dB). The penalty sits inside the channel, co-band with the signal, so no downstream filtering removes it, and it is independent of gain (the same deficit shows at 60 dB and 30 dB). The cause is front-end clock phase noise / reciprocal mixing at the higher native clock — not clipping, and not GopherTrunk’s DSP, which is rate-invariant. The 2.5 MSPS capture locked; the 10 MSPS one did not.

For a control-channel role, prefer a moderate native rate (~2.4–2.5 MSPS). The gophertrunk capture default is 2_400_000 for this reason, and the tool prints a one-line hint if you ask for a high rate on a narrowband capture. Reach for a high native rate only when you genuinely need the wide IQ window — a wideband survey or several repeaters at once — and even then verify lock, because the same phase-noise penalty can still bite a narrowband channel sitting inside a wide capture. (TETRA’s 25 kHz channels still decode fine at a moderate rate; the down-converter normalises them to 144 kHz.)

sdr:
  sample_rate: 2_400_000           # moderate native rate for narrowband CC decode
  devices:
    - serial: "..."                # whatever `gophertrunk sdr list` shows
      role: control
      gain: "auto"

Linux

No package install is needed for the build itself; the driver only needs USB-device permissions at runtime.

Add a udev rule so non-root processes can claim each dongle. One file per family is fine:

# /etc/udev/rules.d/20-rtlsdr.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"

# /etc/udev/rules.d/21-hackrf.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="6089", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="604b", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="cc15", MODE="0666"

# /etc/udev/rules.d/22-airspy.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="60a1", MODE="0666"

# /etc/udev/rules.d/23-airspyhf.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="800c", MODE="0666"

Reload udev (sudo udevadm control --reload && sudo udevadm trigger) and unplug/replug the dongle.

Blacklist the kernel’s DVB driver — only matters for RTL-SDR, but the file is harmless to ship even on HackRF / Airspy-only hosts:

# /etc/modprobe.d/blacklist-dvb_usb_rtl28xxu.conf
blacklist dvb_usb_rtl28xxu

See install-linux.md for the full step-by-step including systemd service setup.

macOS

IOKit lets user-space claim USB devices without rebinding the kernel driver, so there’s no kext to install and no driver swap to perform. Plug in the dongle and run gophertrunk sdr list. See install-macos.md for the full step-by-step including the Gatekeeper bypass and launchd service setup.

Windows

Bind each dongle to WinUSB with Zadig (bundled in the Windows installer — Start Menu → GopherTrunk → “Install RTL-SDR driver (Zadig)”) once per device. The same Zadig walkthrough also works for HackRF, Airspy and Airspy HF+ — pick the device in the dropdown, choose the WinUSB driver, click Replace. See install-windows.md for the click-by-click walkthrough.

Airspy R2 / Mini in particular: the official Airspy installer typically binds libusbK, which is not the in-box WinUSB.sys GopherTrunk talks to. If sdr list shows the device but gophertrunk -config … fails on open with winusb: device rejected request (ERROR_GEN_FAILURE …), re-bind to WinUSB via Zadig — the hardware is reachable but the function driver is mismatched.

Verifying the build

make build
./bin/gophertrunk sdr list

You should see one row per attached dongle with index, serial, tuner type, and the supported gain values:

  • RTL-SDR dongles report a R820T2 / R828D / E4000 / FC0012 / FC0013 / FC2580 TunerName depending on what the driver detects on the RTL2832U.
  • HackRF dongles report MAX2839+MAX5864 (fw <version>)<version> is what the firmware returns via VERSION_STRING_READ, e.g. git-2024.02.1. A + PortaPack suffix appears in Product when a Mayhem build is detected.
  • Airspy R2 / Mini report R820T (Airspy R2) or R820T (Airspy Mini); the variant is inferred from the USB descriptor’s Product string.
  • Airspy HF+ reports Airspy HF+ Discovery / Airspy HF+ Dual Port / Airspy HF+ (with a firmware suffix when available) for both Product and TunerName — there’s no conventional tuner chip to name.

The Driver column reads rtlsdr, hackrf, airspy, or airspyhf for each row.

Sharing one dongle across multiple repeaters

A single SDR can monitor several conventional DMR Tier II repeaters as long as every carrier falls inside the dongle’s IQ bandwidth. The dongle is pinned to a centre frequency; an internal channelizer extracts one narrow-band IQ stream per repeater and feeds an independent T2 decoder for each. No extra hardware is needed beyond the one dongle, and there is no per-repeater hardware re-tune.

Add a role: wideband entry to sdr.devices in your config:

sdr:
  sample_rate: 2_400_000
  devices:
    - serial: "00000003"
      role: wideband
      center_freq_hz: 453_500_000
      channels:
        - frequency_hz: 453_125_000
          system: "regional-dmr-t2"
        - frequency_hz: 453_275_000
          system: "regional-dmr-t2"
        - frequency_hz: 453_775_000
          system: "regional-dmr-t2"
        - frequency_hz: 454_100_000
          system: "regional-dmr-t2"

trunking:
  systems:
    - name: "regional-dmr-t2"
      protocol: dmr-tier2
      # Tier II is conventional, but trunking.System.Validate()
      # requires a non-empty control_channels list. List the same
      # repeater carriers - the wideband engine ignores them when
      # choosing the state machine.
      control_channels:
        - 453_125_000
        - 453_275_000
        - 453_775_000
        - 454_100_000
      talkgroup_file: "/etc/gophertrunk/talkgroups-dmr.csv"

The daemon programs the dongle’s tuner to center_freq_hz once, opens a single IQ stream, and routes one decimated 48 kHz IQ stream per configured channels[].frequency_hz into a separate DMR state machine. Grants and cc.locked events fire per repeater frequency just like they do for a dedicated dongle.

Mixing DMR Tier II and Tier III on one dongle

A wideband dongle can host a DMR Tier III control-channel tap alongside Tier II conventional carriers. Use protocol: dmr for the T3 system, list the T3 control frequency under control_channels, and have one of the dongle’s channels[] entries point at that frequency:

sdr:
  devices:
    - serial: "00000003"
      role: wideband
      center_freq_hz: 851_500_000
      channels:
        - frequency_hz: 851_037_500    # T3 CC
          system: "regional-dmr-t3"
        - frequency_hz: 852_125_000    # T2 conventional carrier
          system: "neighbour-dmr-t2"

trunking:
  systems:
    - name: "regional-dmr-t3"
      protocol: dmr                    # Tier III trunked
      control_channels: [851_037_500]  # MUST include the wideband channel above
      dmr_band_plan:                   # REQUIRED for T3 voice (LCN → Hz)
        linear: { base_hz: 851_012_500, spacing_hz: 25_000, offset: 1 }
    - name: "neighbour-dmr-t2"
      protocol: dmr-tier2              # Tier II conventional
      control_channels: [852_125_000]

The engine picks the right state machine per channel (Tier III’s ControlChannel for protocol: dmr, Tier II’s ConventionalChannel for protocol: dmr-tier2).

A DMR Tier III voice-grant CSBK identifies its traffic channel by a 12-bit Logical Channel Number (LCN), not an absolute frequency, so the T3 system must carry a dmr_band_plan to resolve LCN → downlink Hz. Provide exactly one of:

  • linear — a regular grid: freq = base_hz + (lcn - offset) × spacing_hz. Set offset: 1 for sites that number LCNs from 1 (the common case).
  • table — an explicit list of { lcn, freq_hz } pairs for irregular sites.

Without it, T3 control-channel monitoring still works but every voice grant is dropped with decode.error stage=no-bandplan (and the daemon logs a warning at startup). Tier II carries its repeater frequency directly and needs no band plan.

P25 trunked control channel on a wideband dongle

A wideband dongle can also host a P25 control channel — Phase 1 (C4FM or CQPSK / LSM simulcast) or Phase 2 (H-DQPSK TDMA). The configuration mirrors the DMR Tier III case: the wideband channel sits on the system’s declared control_channels, and the engine decodes the TSBK (Phase 1) or MAC PDU (Phase 2) chain inline. Voice grants ride on the existing physical voice pool — see the “Limits” section below.

sdr:
  devices:
    - serial: "00000003"
      role: wideband
      center_freq_hz: 851_500_000
      channels:
        - frequency_hz: 851_037_500       # P25 Phase 1 CC
          system: "regional-p25"

trunking:
  systems:
    - name: "regional-p25"
      protocol: p25                       # Phase 1
      control_channels: [851_037_500]     # MUST include the wideband channel above
      # On a simulcast site whose CC is transmitted as Linear
      # Simulcast Modulation rather than C4FM, opt in to the
      # linear-CQPSK demod path:
      # p25_phase1_demod_mode: cqpsk

For P25 Phase 2 use protocol: p25-phase2; the same per-system p25_phase2_* knobs (trellis / RS / interleave / scrambler / clock mode) that apply to a dedicated CC SDR apply to the wideband channel too — see the trunking systems section of config.example.yaml.

One SDR for control + voice (virtual voice pool)

Voice grants can also be decoded on the same wideband dongle that’s hosting the CC tap, as long as the grant frequency falls inside the dongle’s IQ window. Set voice_taps: N on the wideband entry; the daemon spins up N per-grant DDC tuners that subscribe to the dongle’s IQ stream on demand and emit 48 kHz IQ centred on the grant frequency — exactly what the existing P25 / DMR voice composer chains expect. The result is a single SDR doing both jobs, with no physical role: voice dongle required (for grants that fit in the window).

sdr:
  devices:
    - serial: "00000003"
      role: wideband
      center_freq_hz: 851_500_000
      voice_taps: 4               # allow up to 4 concurrent voice calls
      channels:
        - frequency_hz: 851_037_500
          system: "regional-p25"

trunking:
  systems:
    - name: "regional-p25"
      protocol: p25
      control_channels: [851_037_500]

This works identically for DMR Tier III — point the channel at a protocol: dmr system instead. The only extra requirement is that the T3 system carry a dmr_band_plan (see “Mixing DMR Tier II and Tier III on one dongle” above): the voice composer can only follow a grant once the decoder has resolved its LCN to a frequency, so a T3 system without a band plan emits no voice grants for the taps to serve.

Timeslots count as separate calls. A DMR carrier is 2-slot TDMA, so one 12.5 kHz repeater can run two independent voice calls at once — one on TS1, one on TS2. GopherTrunk treats (frequency, timeslot) as the call identity: each slot’s grant binds its own voice tap (or role: voice SDR) and is tracked, recorded, and logged as a distinct call. To follow both slots of a carrier simultaneously you therefore need at least two voice taps/devices that cover the frequency; with only one, the engine serves whichever slot it bound first and the other slot waits for a free tap (or preempts by priority). Per-slot recordings are disambiguated by a _ts1 / _ts2 suffix on the WAV filename, and the slot is carried through the call log (timeslot column) and the REST/SSE/gRPC call APIs.

An experimental per-system dmr_interleaved_voice: true additionally turns on a 2-slot interleaved voice decoder: rather than relying on the tap to isolate one slot, each call decodes its own timeslot from the carrier’s interleaved burst stream and is routed by the embedded Link Control’s talkgroup. It defaults off and its on-air constants are still pending a real-capture cross-check (see docs/status.md) — leave it off for normal operation.

How spillover works: when a grant’s frequency lands outside the wideband IQ window (more common on geographically spread P25 systems than on a single-site DMR T3 cluster), the virtual tuner returns out-of-band and the engine binds a physical role: voice SDR instead — if one is configured. So a typical mixed setup keeps a single physical voice SDR around as the spillover fallback while the wideband dongle handles the in-window majority. With no physical voice SDR present, out-of-window grants are dropped and the daemon logs a one-shot warning that the grant frequency falls outside every voice device’s tuning window — widen sample_rate or move center_freq_hz so the repeaters fit, or add a role: voice SDR to cover the spillover (issues #379, #422).

CPU is roughly linear in voice_taps: each tap runs one NCO mixer + polyphase resampler at the SDR rate during its call’s lifetime; between calls the tap consumes no CPU. The validator caps the value at 8 to keep one wideband dongle bounded.

Picking a centre frequency and bandwidth

The usable IQ band is center_freq_hz ± sample_rate/2 with a 5 % guard at each edge. At sample_rate: 2_400_000 that’s ±1.08 MHz of usable spectrum either side of the centre. Put the centre frequency such that every repeater you care about fits inside that window. The config validator rejects out-of-band channels at load time with a message that names the offending entry.

Tuner strategy

tuner_strategy chooses how the dongle’s wide IQ stream is sliced:

  • auto (default) — picks ddc for ≤ 6 channels, polyphase above.
  • ddc — one independent NCO mixer + rational resampler per channel. Linear cost in channel count; no constraint on the spacing between repeaters. Best for a handful (≤ 6) of repeaters.
  • polyphase — one shared M-channel polyphase channelizer amortises the wide-band filter across all channels; a per-channel fine-tune DDC cleans up the residual. Wins on CPU once you have 7+ channels.

Limits

  • DMR + P25. Wideband supports protocol: dmr-tier2 (Tier II conventional), protocol: dmr (Tier III trunked control channel), protocol: p25 (P25 Phase 1 trunked control channel — C4FM and CQPSK / LSM simulcast), and protocol: p25-phase2 (P25 Phase 2 H-DQPSK trunked control channel). Other protocols (NXDN, TETRA, …) are not in scope yet.
  • Trunked voice on the same wideband dongle. With voice_taps set, the daemon allocates per-grant DDC tuners from the dongle’s IQ stream so DMR T3 / P25 Phase 1 / P25 Phase 2 voice grants decode inline without a physical role: voice SDR — see the “One SDR for control + voice” section above. Voice grants whose frequency falls outside the wideband IQ window still spill over to a physical role: voice SDR (when configured), so a single backup voice dongle covers the edge cases on geographically spread systems. Setups with voice_taps: 0 (or unset) keep the legacy behaviour where every voice grant routes to the physical pool. DMR Tier III additionally requires the system’s dmr_band_plan (LCN → frequency) before any voice grant — virtual or physical — can be followed.
  • DDC-with-real-signal RX limits. The wideband engine’s per-tap DDC (when the SDR sample rate is higher than 48 kHz) uses the same Kaiser anti-alias prototype as the single-channel ccdecoder path, so live captures with the same SNR characteristics that lock on a dedicated dongle also lock here. The in-package end-to-end test exercises the engine wiring at the bank’s native per-tap rate (48 kHz) where the resampler is a no-op; full validation at a decimating wideband rate against a TX-side filter cascade that mirrors the RX matched filter is a planned follow-up.
  • CPU scales with channel count. Eight DDC taps at 2.4 MS/s is a few percent of one modern x86 core; the polyphase mode lands lower at the same count.

Remote rtl_tcp SDRs

rtl_tcp ships with librtlsdr and exposes a single RTL-SDR dongle over a TCP socket. GopherTrunk’s rtltcp driver consumes the same wire protocol SDR++, Gqrx, and OpenWebRX speak, so any host with a USB-attached RTL-SDR can publish its radio to the daemon.

Typical layout:

  • Antenna host (Raspberry Pi / Mac mini at the antenna, USB range from the antenna minimised): run rtl_tcp -a 0.0.0.0 -p 1234 against the local dongle.
  • Daemon host (the box with CPU + storage for decode): list the endpoint under sdr.rtl_tcp in config.yaml.
sdr:
  sample_rate: 2_400_000
  rtl_tcp:
    - addr: "192.168.1.50:1234"
      serial: "antenna-pi"   # generator fills this from addr when blank
      role: control           # control | voice | auto
      ppm: 0
      gain: "auto"            # "auto" or tenths-of-dB ("496" = 49.6 dB)
      bias_tee: false
      connect_timeout_ms: 3000

Each entry becomes a pool device alongside any local USB dongles — the engine roles them via the same hint matcher (control / voice / auto) and the broker / fan-out path is the same as local SDRs, so the live spectrum panel, baseband recorder, and CC decoder all work against remote sources.

Limitations:

  • One client per rtl_tcp endpoint (the upstream protocol is single-tuner).
  • Plaintext over TCP. Keep it on a trusted network, or tunnel it through SSH / WireGuard / Tailscale before exposing it.
  • Bias-tee + advanced rtlsdr-only knobs (direct sampling, offset tuning, IF gain) are wired through but rely on the remote running librtlsdr ≥ 0.7. Servers that ignore those commands silently no-op them.

Diagnostics: the daemon logs rtltcp: connected addr=... tuner=... on each successful Open, dial: connection refused if the remote isn’t listening, and header magic = "..." if the address points at something that isn’t an rtl_tcp server.

Remote SoapySDRServer SDRs

rtl_tcp is hardcoded to 8-bit unsigned IQ, so it throws away the dynamic range of professional hardware. For high-bit-depth radios — Ettus USRP, LimeSDR, bladeRF, HackRF, Airspy, RTL-SDR, SDRplay, and anything else with a SoapySDR driver — GopherTrunk’s soapyremote driver speaks the SoapyRemote wire protocol directly, in pure Go with no CGO and no SoapySDR C libraries. It carries 16-bit (CS16) or 32-bit float (CF32) IQ and controls frequency, sample rate, and gain over SoapyRemote’s RPC.

Typical layout:

  • Antenna host: install SoapySDR + the device’s Soapy module and run SoapySDRServer --bind against the local radio (default port 55132).
  • Daemon host: list the endpoint under sdr.soapy_remote in config.yaml.
sdr:
  sample_rate: 2_400_000
  soapy_remote:
    - addr: "192.168.1.60:55132"  # bare host gets :55132 appended
      driver: "uhd"               # SoapySDR device key (blank = first device)
      args: ""                    # extra make() kwargs "k=v,k=v" (see below)
      master_clock_rate: 0        # USRP master clock in Hz; 0 = device default
      serial: "usrp-roof"         # generator fills this from addr when blank
      role: control               # control | voice | auto
      format: "CS16"              # CS16 (16-bit, default) or CF32 (float)
      stream_protocol: "tcp"      # tcp (default/only for now)
      stream_mtu: 0               # stream endpoint MTU in bytes; 0 = default 1500
      ppm: 0                      # best-effort (driver-dependent)
      gain: "auto"                # "auto" or tenths-of-dB ("300" = 30.0 dB)
      bias_tee: false             # best-effort (driver-dependent)
      connect_timeout_ms: 3000

Each entry becomes a pool device alongside any local USB dongles and rtl_tcp endpoints, roled through the same hint matcher, with the same broker / fan-out path.

Limitations:

  • Receive only, single channel (channel 0).
  • The IQ stream uses SoapyRemote’s in-order TCP transport (stream_protocol: tcp), which opens two sockets to the server (a stream socket and a status socket, matching SoapySDRServer’s setup). UDP streaming with the windowed flow-control is a planned follow-up.
  • stream_mtu sets the SoapyRemote stream endpoint MTU in bytes. It is sent to the server as the remote:mtu stream argument (the equivalent of SoapySDR’s remote:mtu knob) and used to size the client’s flow-control window so both ends agree. Because it’s a stream argument, not a make() kwarg, it cannot be expressed via args. Leave it 0 for SoapyRemote’s default of 1500; raise it (e.g. 8192) on jumbo-frame or high-throughput links.
  • args passes extra SoapySDR device kwargs to the remote make() as a "key=value,key2=value2" string, merged with driver (an explicit driver= in args wins). Use it for server-side device selection and configuration that driver alone can’t express — e.g. a USRP TwinRX needs args: "rx_subdev_spec=A:0,antenna=RX1". This is distinct from the top-level serial, which only names the virtual pool device locally.
  • ppm (frequency correction) and bias_tee map to SoapySDR’s setFrequencyCorrection / writeSetting and silently no-op on drivers that don’t implement them.
  • USRP sample rates and the master clock. A USRP only delivers rates that are integer decimations of its master clock, so UHD silently coerces a request that isn’t an exact divisor to the nearest achievable rate. GopherTrunk reads the delivered rate back over the RPC (getSampleRate) and builds every per-channel down-converter / symbol clock from it — not from the requested value — so a coerced rate stays decode-correct instead of drifting the symbol clock (issues #402, #550). For a clean exact-divisor stream, pick a sample_rate that divides the master clock, and set master_clock_rate when the default clock doesn’t divide your target: an X310’s 200 MHz default already divides 6_250_000 (÷32), while a B210 needs master_clock_rate: 61_440_000 to stream 6_144_000 (÷10) exactly. master_clock_rate is a convenience for putting master_clock_rate=… in args; an explicit value in args wins.
  • gain is applied as a manual overall gain. AGC is disabled first on a best-effort basis, so a numeric gain still applies on front-ends that have no AGC at all (e.g. a USRP TwinRX, which rejects set_rx_agc()); use gain: "auto" to request AGC where the radio supports it.
  • Plaintext over TCP. Keep it on a trusted network, or tunnel it through SSH / WireGuard / Tailscale.
  • The RPC, stream framing, and TCP stream-setup choreography are byte-matched to SoapyRemote’s source and exercised by the driver’s tests; validate against a live SoapySDRServer before production use.

Diagnostics: the daemon logs soapyremote: connected addr=... format=... proto=... on each successful Open, make device: with the remote exception text when the server can’t open the requested device, and dial: connection refused if the server isn’t listening.

Note — upstream server crashes. Some radios were observed crashing SoapySDRServer itself (a zsh: segmentation fault inside libuhd) when the client mis-spoke the stream protocol. A server should never crash on a client RPC, so that is an upstream UHD / SoapySDRServer robustness bug. GopherTrunk no longer provokes it now that the TCP SETUP_STREAM handshake and stream flow-control ACKs are byte-matched to SoapyRemote (issue #542). If you can still reproduce a server crash, please report it upstream at https://github.com/pothosware/SoapyRemote/issues with the server-side log.

USB disconnect recovery

A dongle that physically disconnects from the USB bus mid-run (flaky cable, marginal hub power, EMI burst, a brief brown-out on a laptop running on battery) no longer takes the daemon down: the disconnect is recoverable in-process when the device re-enumerates under the same serial. Three independent paths cover the three states a device can be in when the disconnect lands.

1. Control SDR — in-stream IQ death

The ccdecoder’s Decoder.Run surfaces the stream death as ErrIQStreamClosed. The retry loop backs off, calls Pool.Reacquire, swaps the fresh Device into ccDecoderOpts.IQ/Tuner, and tells the cchunt supervisor to swap its tuner via SwapTuner so the next hunt round picks up the new handle:

WARN  daemon: ccdecoder: IQ stream died; retrying attempt=1 max_attempts=4 backoff=1s
       err="ccdecoder: IQ stream closed unexpectedly: rtl2832u: write block=1 ... usb: device disconnected"
INFO  daemon: ccdecoder: control SDR reacquired serial=76361606
INFO  sdr: reacquired driver=rtlsdr serial=76361606 role=control old_index=0 new_index=1
INFO  cchunt: control tuner swapped (reacquired)

Bounded by the existing 1 s / 2 s / 5 s / 10 s retry budget and the 60 s “healthy run” window that resets the attempt counter.

2. Voice SDR — stale at next call

A voice dongle that disconnected while idle leaves the trunking engine holding a stale Tuner handle. The next time the engine calls VoicePool.Bind, SetCenterFreq fails. The pool’s reacquire hook (wired by the daemon to sdr.Pool.Reacquire) opens a fresh handle for the same serial, swaps it into the VoiceDevice, and retries the tune once before the call drops:

INFO  sdr: reacquired driver=rtlsdr serial=00000002 role=voice old_index=1 new_index=2

If the reacquire fails (device truly gone), Bind returns the original SetCenterFreq error joined with the reacquire failure so the operator gets the full story. The call drops and the next grant on that talkgroup will retry.

3. Periodic watchdog — idle devices

A background watchdog ticks every sdr.watchdog_interval_ms (default 30 s, opt-out via -1), re-enumerates every registered driver, and acts on serial-level state transitions:

  • A serial the pool expects but the enumerate doesn’t see transitions to “missing”; one KindSDRDetached event surfaces the gap so the API / TUI / web snapshot reflect it.
  • A serial that was missing in the previous tick and is now back triggers Pool.Reacquire so the next consumer (a voice call or a ccdecoder retry) touches a live handle instead of paying the reacquire round-trip mid-use.
WARN  sdr: watchdog: device missing from USB enumerate serial=76361606
INFO  sdr: watchdog: device reappeared; reacquiring serial=76361606
INFO  sdr: reacquired driver=rtlsdr serial=76361606 role=control old_index=0 new_index=1

The watchdog never reacquires a device that’s been continuously present (no spurious churn on healthy hardware) and never proactively closes an in-use stream (the IQ-death path owns the in-use case).

Common contract

What happens inside every Pool.Reacquire call, regardless of the caller:

  1. The original Device handle is Closed best-effort — a dead handle’s Close return is logged and ignored.
  2. The driver’s Enumerate() re-scans the USB bus and finds the matching serial at its new index.
  3. Driver.Open(idx) returns a fresh Device against the re-enumerated USB transport.
  4. The configured sample rate (sdr.sample_rate in YAML) and the original hint state (PPM, gain, bias-tee) are re-applied to the fresh handle — the operator’s tuning survives the disconnect.
  5. The new Device is swapped into the PoolEntry in place, so any later Pool.FindBySerial / API snapshot returns the live handle.
  6. KindSDRDetached then KindSDRAttached are published so the API (GET /api/v1/devices), the TUI device line, and the web console reflect the gap and the recovery.

Unrecoverable cases

If the device stays gone after re-enumerate, or if Driver.Open() fails (kernel hasn’t finished re-binding the USB descriptor, a permissions race, the dongle is genuinely dead), the in-stream paths exhaust their retry budget and the daemon escalates to a clean fatal exit. A process supervisor (systemd, docker, the GopherTrunk launcher’s -headless mode) then restarts the daemon, which re-discovers the SDR by serial on next pool.Open(). The watchdog keeps logging “missing from USB enumerate” until the device returns but never escalates on its own — it’s the safety net, not the authority.

Operator note

If you see these log shapes on a hardware unit, the daemon is doing the right thing — but the cause is almost always physical: a marginal USB-A cable, an unpowered hub, EMI from a nearby switching supply, or a thermal trip on a poorly-ventilated dongle. The on-board recovery keeps the stream live across one or two events per hour, but it isn’t a substitute for fixing the underlying USB-link instability.

Capturing IQ for replay

GopherTrunk has two replay paths.

Wideband baseband replay — record the full IQ stream of any attached tuner with a baseband.record config entry (see opt-in-features.md), then mount the resulting two-channel 16-bit WAV (or one of SDRtrunk’s baseband recordings — same layout) as a virtual tuner via baseband.replay. The replay loops on EOF, so a short capture becomes a continuous source.

Raw cfile mock driver — the legacy mock driver replays raw u8-IQ files (.cfile format) generated with gqrx, csdr, or any tool that produces interleaved unsigned-8-bit samples. Drop .cfile files under testdata/iq/ to use them through the mock driver. The baseband replay path above is the preferred option for new work; the cfile mock stays around for the existing integration tests.

P25 Phase 1 offline decodergophertrunk replay runs a raw IQ capture through the production receiver + control-channel chain and prints every lock / grant / decode-error event the daemon would emit, plus the per-frame NID-decoder diagnostics. Reuses the real internal/radio/p25/phase1/receiver and phase1.ControlChannel, so what it decodes is what the daemon decodes — a replay-lock implies an on-air lock, and a replay-fail makes the capture a reproducible test fixture for the protocol path. Built for issue #275 investigations where on-site retests round-trip too slowly.

gophertrunk replay -in capture.iq -sample-rate 960000 -demod c4fm
gophertrunk replay -in cbd.cfile -format f32 -sample-rate 960000 -demod cqpsk

Flags:

  • -format u8|f32u8 is the rtl_sdr default (interleaved 8-bit unsigned IQ); f32 is GNU Radio’s interleaved-float32 cfile.
  • -sample-rate Hz — the capture’s sample rate. The decoder prints the effective baud at EOF; if it deviates >2% from 4800 the capture’s true sample rate doesn’t match this flag.
  • -demod c4fm|cqpsk — modulation. C4FM restricts the FSW + NID rotation search to physically-meaningful {0,2} rotations; CQPSK / π/4-DQPSK keeps the all-rotation default.
  • -freq Hz — informational; reported alongside the events.
  • -nid-search-span N — NID-alignment grid radius in dibits (default 6, matching the production ccdecoder). Widen to 12 / 18 / 36 on a stubborn capture to bisect a span-bounded failure (errs drop at the new optimum) from a demod-quality-bounded one (errs stay at the BCH(63,16,11) correction ceiling regardless of alignment).
  • -diag — after EOF, dump the dibit-value histogram, the pre-slicer soft-sample magnitude distribution, the FSW correlation landscape per rotation (Hamming-distance histogram + hit count), the FSW positions and inter-hit deltas, the raw NID + first 24 TSBK dibits at the first 5 perfect-distance FSWs, and the trellis-decoded 12-byte TSBK info block with its augmented-CRC check (a clean TSBK shows crc=0x0000). The off-path diagnostic that pinpoints which stage of the C4FM demod chain produces wrong dibits — see the cmd/gophertrunk/iqdiag.go package comment for the failure-mode interpretation table.