ADS-B / Aviation
May 31, 2026 · View on GitHub
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 astorage.AircraftReportcarrying the 24-bit ICAO address + message-kind-specific fields.
Storage
- New SQLite table
aircraft_log(append-only migration alongsidevessel_log,dsc_log,aprs_log,pager_log):received_at,icao,icao_hex,kind,body,crc_valid,callsign,category,latitude,longitude,altitude_ft,has_position,has_altitude,ground_speed_kn,track_deg,vertical_rate_fpm,raw_hex- Indexes on
(received_at)and(icao, received_at)
storage.AircraftLogbus subscriber writes one row perKindAircraftReportevent.
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.
Web panel
/adsb— live list with columns:- Received (HH:MM:SS, daemon clock)
- ICAO (6-char hex, the standard "tail identifier")
- Kind (ident / airborne-pos / surface-pos / velocity / ...)
- Callsign (for identification messages)
- 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 overmessage || 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:
- Identification
8D4840D6202CC371C32CE0576098→ ICAO 4840D6, callsign "KLM1023". - Airborne-position CPR pair
8D40621D58C382D690C8AC2863A78D40621D58C386435CC412692AD6→ ICAO 40621D, lat 52.2572 N, lon 3.91937 E, alt 38,000 ft.
- Airborne velocity
8D485020994409940838175B284F→ ICAO 485020, GS 159 kn, track ≈ 183°, VR -832 fpm.
Spec references:
- ICAO Annex 10 Volume IV (Aeronautical Telecommunications — Surveillance and Collision Avoidance Systems), Chapter 3 (Mode-S).
- RTCA DO-260B / EUROCAE ED-102A — ADS-B 1090 ES Minimum Operational Performance Standards.
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.
adsb:
beast_upstreams:
- addr: "127.0.0.1:30005" # dump1090 default BEAST port
name: "local-dump1090"
- addr: "rooftop-pi:30005" # pi-at-the-antenna setup
name: "rooftop"
Frame layout (0x1A <type> <timestamp 6B> <signal 1B> <payload>) — 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.
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.
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.
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 <PositionMap> 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.