GopherTrunk decodes Automatic Dependent Surveillance — Broadcast
(ADS-B) messages aircraft transponders broadcast on 1090 MHz.
ADS-B is the ICAO-mandated cooperative aviation surveillance
protocol — every commercial passenger flight, most general-
aviation, and all military aircraft over US / EU airspace
continuously broadcasts: ICAO 24-bit address, callsign,
barometric or GNSS altitude, lat/lon, ground speed, vertical
rate, true heading. This is the same data feeding every public
flight-tracking service (FlightRadar24, FlightAware, adsb.lol,
OpenSky); GopherTrunk now has the protocol layer to decode it
end-to-end on the operator’s own SDR.
This page documents the end-to-end pipeline: two ways frames
reach the bus — a native PPM DSP frontend on the operator’s own
1090 MHz SDR, and a BEAST upstream that consumes a separately-
running dump1090 / readsb — plus what’s persisted, queryable, and
rendered on the web panel.
What’s wired
Events bus
events.KindAircraftReport — published once per decoded
Mode-S frame off the 1090 MHz channel. Payload is a
storage.AircraftReport carrying the 24-bit ICAO address +
message-kind-specific fields.
storage.AircraftLog bus subscriber writes one row per
KindAircraftReport event.
REST
GET /api/v1/adsb/aircraft?limit=N — most recent reports,
newest first. Default 200, max 5000. ADS-B is the highest-
rate decoder (2-3 msg/s per aircraft on a busy channel) so
tighter limits make sense for live rendering.
Lat / Lon (for position messages with a successful CPR
global decode)
Alt (ft) (for airborne / surface position)
GS / Track (for velocity messages, subtype 1 / 2)
VR (fpm) (vertical rate, signed)
CRC-failed frames highlight yellow.
Protocol layer (internal/radio/adsb)
Pure-Go Mode-S parser. The bit-stream layer above (the native
PPM receiver or a BEAST upstream) hands it complete Mode-S
frames with the trailing 24-bit CRC included. Both sources go
through one shared path — adsb.ProcessFrame(frame, tracker,
now) decodes, gates on CRC, runs the CPR tracker, and returns
the storage.AircraftReport — so a frame off the air and the
same frame from dump1090 produce identical rows.
CRC-24 codec (parse.go) — Mode-S CRC with polynomial
0xFFF409. Verifies DF 11 / 17 / 18 frames directly (zero
residue over message || crc); for DF 0 / 4 / 5 / 20 / 21
the trailing 24 bits = CRC XOR ICAO, so the parser
recovers the ICAO address by XORing the computed CRC.
DF dispatch (adsb.go) — recognises every documented
downlink format; fully decodes DF 17 / 18 extended squitter
(the operator-visible majority) and tags the others with
the raw payload preserved.
TC dispatch for extended squitter ME payloads:
TC 1-4: Identification — 8-char callsign decoded from
the 6-bit ICAO alphabet (A-Z, space, 0-9, with trailing
spaces / underscores stripped).
TC 5-8: Surface position — CPR-encoded.
TC 9-18, 20-22: Airborne position — CPR-encoded
lat/lon + 12-bit Q-bit altitude (Q=1 = 25 ft resolution).
TC 19: Airborne velocity — subtypes 1/2 = ground
speed + track, subtypes 3/4 = air speed + heading; common
vertical-rate field across all subtypes.
CPR decode (cpr.go) — globally-unambiguous lat/lon
recovery from an even + odd CPR pair (DO-260B
§2.2.3.2.3.7). The NL (number of longitude zones) lookup
table mirrors the dump1090 reference implementation. The
locally-referenced decode (single message against a known
receiver location) is the obvious follow-up once the daemon
caches an operator-configured reference position.
Validated against the canonical dump1090 / mode-s.org reference
samples:
https://mode-s.org/decode/ — the de-facto reference parser
documentation, cross-checked against real on-the-air
payloads.
BEAST upstream (internal/radio/adsb/beast)
GopherTrunk consumes Mode-S frames from any BEAST-protocol
upstream — the de-facto wire format dump1090, readsb,
BeastSplitter, and every commercial ADS-B hub speak. Operators
keep their existing 1090 MHz receive chain (RTL-SDR + filter +
LNA + dump1090) and point GopherTrunk at it over TCP. No
native 1 Msps PPM demod required.
`) — type codes:
- `0x31` = Mode-AC (skipped)
- `0x32` = Mode-S short (56 bits)
- `0x33` = Mode-S long (112 bits)
`0x1A` bytes inside the body are escaped as `0x1A 0x1A` for
sync framing; the client un-escapes transparently. Reconnects
with backoff on TCP drops (default 2 s); each disconnect
resets the embedded CPR tracker so stale even/odd halves
don't pair across a gap.
## Native PPM receiver (`internal/radio/adsb/ppm`)
The alternative to a BEAST upstream: pin one of GopherTrunk's own
SDRs to 1090 MHz and demodulate Mode-S straight off the air, no
external decoder. A 1090 MHz SAW filter + LNA ahead of the SDR is
strongly recommended — Mode-S is a weak, bursty signal.
```yaml
adsb:
channels:
- serial: "1090-antenna" # SDR sampling >= 2 Msps
frequency_hz: 1_090_000_000 # 1090 MHz (default when omitted)
```
Pipeline (one goroutine per channel, subscribing to that SDR's
iqtap broker):
```
IQ → resample to 2 Msps → magnitude² envelope → 8 µs preamble
correlation (pulses at 0, 1, 3.5, 4.5 µs) → PPM bit slice
(1 µs/bit: "1" = high-then-low, "0" = low-then-high) → DF
frame-length (56 / 112 bit) → adsb.ProcessFrame
```
The detector and slicer follow the dump1090 magnitude-domain
baseline at a fixed 2 Msps; the receiver resamples to that rate
internally if the SDR runs faster. A magnitude carry buffer
spans chunk boundaries so a preamble split across two IQ chunks
still decodes. Phase-corrected re-detection and 2.4 Msps
operation are refinements left for later — the baseline locks
cleanly on the strong signals a filtered + amplified chain
delivers. Frames feed the same `adsb.ProcessFrame` path the
BEAST client uses, so storage, tracker, panel, and map are
shared.
## CPR pair tracker (`internal/radio/adsb.Tracker`)
ADS-B aircraft alternate between even-encoded (`CPRFormat=0`)
and odd-encoded (`CPRFormat=1`) position reports roughly every
0.5 s; recovering the global lat/lon needs both halves.
`Tracker.Update(msg, now)` buffers the most-recent half per
ICAO and calls `CPRDecodeGlobal` when both arrive within the
spec's 10 s window (DO-260B §2.2.3.2.3.7). Position rows show
globally-decoded lat/lon on the `/adsb` panel + map as soon as
the pair completes. `Prune()` evicts ICAOs that haven't
transmitted in > 10 s so the state map doesn't grow with every
aircraft ever seen.
## Currently-visible aircraft
`GET /api/v1/adsb/aircraft/current` returns the coalesced latest
state of each aircraft seen recently — one row per ICAO, distinct
from the raw per-message log at `/adsb/aircraft`. Because
identification, position, and velocity arrive as separate Mode-S
messages, `AircraftLog.CurrentAircraft(maxAge)` folds the most
recent non-empty value of each field group (callsign, position,
altitude, velocity) per ICAO over the horizon; `received_at` is the
aircraft's last-seen time, rows are newest-last-seen first. Optional
`?max_age_s=` bounds the horizon (default 300 s, max 3600 s). Powers
a "currently visible aircraft" panel distinct from the raw message
log.
## Address/parity frames are gated on a known ICAO (#1316)
DF 0 / 4 / 5 / 16 / 20 / 21 carry their ICAO address XORed into the
CRC field, so the address is recovered by *assuming* the frame is
error-free — there is no check in the frame itself, and any 56 or 112
bits of noise that pass the preamble detector "decode" to some address.
The receiver used to accept every such frame as `crc_valid: true`; on
an unfiltered dongle that was ~440 reports/s, nearly all `kind:
unknown` with ICAOs in unallocated blocks, and `aircraft_log` grew by
hundreds of thousands of rows in minutes.
`adsb.ProcessFrame` now applies dump1090 / readsb's rule: an
address/parity frame is reported only when its ICAO was proven by a
CRC-checked DF 11 / 17 / 18 within the last 60 s
(`Tracker.KnownICAO`). The preamble detector itself was measured and
left alone — it is a shape test that noise satisfies ~2 600 times a
second, and a 6 dB level margin over the running noise floor removed
under 1 % of those detections while costing weak real frames; the gate
removes 100 % of the resulting reports. The BEAST path runs the same
gate (dump1090 has already applied it upstream, so its DF 4/5 arrive
after the squitter that proves them). A DF 11 reply to a selective
interrogation (non-zero IID in the low 7 parity bits) is now accepted
and proves its address too; `Stats.KnownICAOs` counts the currently
trusted addresses.
A dongle named by `adsb.channels` opens as `role: dedicated` so no
other consumer (rigctld, hunt, the trunking voice pool) retunes it,
and may run at its own `sample_rate: 2000000` to skip the 2.4 → 2 Msps
resample.
## What's pending
- **Locally-referenced CPR.** Single-message position decode against
a configured receiver location, useful for the first seconds before
an even+odd CPR pair completes.
- **Bit error correction** on DF 17 / 18 (dump1090 fixes 1-bit, and
optionally 2-bit, errors), which would lift sensitivity on an
unamplified front end. Not built.
- **A preamble that lands one or two samples before a real one eats
the real frame.** `scan` skips past whatever frame it slices after a
detection; dump1090 scores candidates and keeps the best. Seen on
synthetic frames in noise at 23 dB SNR (one of five lost); not built.
## Live map
Aircraft positions (once the per-ICAO CPR pairing lands) render
as purple markers on the shared Leaflet map at the top of
`/adsb` — callsign + altitude on hover, camera auto-fits to the
current rowset. The same `` component renders on
`/aprs`, `/ais`, and `/dsc`.
## References
- ICAO Annex 10 Volume IV — Mode-S protocol.
- RTCA DO-260B / EUROCAE ED-102A — 1090 ES MOPS.
- `https://mode-s.org/decode/` — comprehensive worked
examples + CPR walk-through.
- dump1090 / readsb — open-source reference implementations.
Join the GopherTrunk community
Stuck on a system, IDing a mystery signal, or have a feature in mind? Ask on the forum or swap notes with other operators on Discord and Reddit — questions get answered fast.
GopherTrunk turns a cheap RTL-SDR dongle into a full digital-trunking
scanner — P25, DMR, TETRA, NXDN and more, decoded by one pure-Go static
binary with nothing else to install. Grab the latest build and follow
your first system tonight.
GopherTrunk is free and open source, built in the open by a tiny team. A
GitHub star costs you one click, helps other scanner operators find it, and
keeps the project moving. Would you drop one?
Analytics. This site uses Google Analytics to collect
site-usage statistics — such as which pages are visited, how long
visitors stay, and how they found the site — to measure site
performance and help us improve the documentation.
Affiliate links. As an Amazon Associate we earn from
qualifying purchases. Some hardware links on this site are affiliate
links: if you buy through them we may earn a commission at no
additional cost to you. This never changes which gear we recommend,
and the software stays free and open source either way.
How GopherTrunk is funded →
By continuing to use this site, you acknowledge these practices.