ADS-B: a split receiver, and Zabbix watching the sky

ADS-B: a split receiver, and Zabbix watching the sky

spectroman
spectroman
22 views

Once I had a software-defined radio (RTL-SDR) on the desk, cracking the protocol of my DucoBox ventilation, the obvious next target was a much bigger signal, and a completely public one: every aircraft overhead shouting its position at 1090 MHz.

So now there is an antenna on the roof, a feed to FlightAware and ADSBexchange, a local map, and — this being me — Zabbix watching all of it. The Zabbix part grew into its own tool, and that one is now public.

Repo


The unusual bit, first

Most ADS-B receivers are one box: a Raspberry Pi with a dongle, running dump1090, feeding the aggregators. Mine is split in two, and the split is not where people usually put it.

The Pi at the antenna does not run dump1090. It runs the dongle and a demodulator, and ships the decoded raw messages over TCP to a virtual machine elsewhere, which does everything else: tracking, the map, history, MLAT and all the feeding.

flowchart TB ANT(["1090 MHz antenna"]) --> DONGLE["RTL2832U / FlightAware Pro Stick"] subgraph MICHI["michibiki — Pi 4B at the antenna"] DONGLE -->|"raw IQ, 2.4 MSps"| RTL[rtl_sdr] RTL -->|pipe| SM[stream1090] SM -->|"AVR + 12 MHz timestamps"| SOC[socat] end SOC -->|"TCP :30001"| DUMP subgraph APOC["apoc — KVM virtual machine"] DUMP["dump1090-fa --device-type none"] DUMP --> AJ["aircraft.json"] DUMP -->|":30005 Beast"| PIA[piaware + fa-mlat-client] DUMP -->|":30005 Beast"| ADSBX[adsbexchange feed + mlat] AJ --> TAR[tar1090 map] AJ --> GR[graphs1090] AJ --> COL[adsb-collector] end PIA -->|feeds| FA[(FlightAware)] ADSBX -->|feeds| AX[(ADSBexchange)] COL -->|"trapper, 30 s"| ZBX[(Zabbix)] COL -->|sightings| DB[(MariaDB)]

Why do it this way? The antenna site stays trivial: a dongle, two small programs and a pipe. Everything with state, credentials and upgrades lives on a VM with the CPU and the room to run it. The Pi at the end of the coax can be reflashed in ten minutes and carries nothing precious. Further I don't need to overheat, run into the chances of locking it up or having the need for a harcore cooling.

What it does not cost is timing. stream1090 emits AVR with timestamps — @07a1a3fa6db828000b27f0bb3e, a 12-hex-digit 12 MHz counter taken from the Pi's own sample clock — so MLAT gets true receive times despite the network hop. Both MLAT clients report outlier_percent 0.0 and bad_sync_timeout 0 while synchronised against 80–500 nearby receivers.

The one thing the format drops is per-message signal level. More on that below.

The RPIs in the entire house are named after satellites: Michibiki is Japan's QZSS navigation constellation. Fitting for a machine whose whole job is knowing where things are.


The antenna side

The entire ADS-B function of the Pi is one line:

rtl_sdr -f 1090000000 -s 2400000 -g 49.6 - | stream1090 | socat -u - TCP4:<decoder>:30001
  • rtl_sdr tunes 1090 MHz, samples at 2.4 MSps and writes raw IQ to stdout.
  • `stream1090` demodulates that into raw ADS-B messages.
  • socat ships them to dump1090-fa's raw input port on the VM.

A systemd unit keeps that pipeline alive inside a GNU screen session, which is how I get to see rtl_sdr's live statistics when I want them.

graphs1090 runs there too. With no decoder on the Pi its ADS-B panels are empty — by design — but its system graphs (CPU, temperature, network, disk) are the hardware history of the machine at the end of the coax, which nothing on the VM can tell you.

After eight days of uptime: 36.5 °C, throttled=0x0, load 0.38. stream1090 costs about 35 % of one core; demodulation is the expensive half of the whole system, and it happens here.

The decoder side

dump1090-fa, run without a radio:

dump1090-fa --device-type none --net-ri-port 30001 --net-bo-port 30005 ...

--device-type none is the key: there is no dongle on this machine, everything arrives on the network.

port direction what
30001 in raw messages — where the Pi connects
30003 out SBS / BaseStation
30004, 30104 in Beast — 30104 is where the MLAT clients return their results
30005 out Beast — what the feeders consume

On top of that: piaware + fa-mlat-client feeding FlightAware, the ADSBexchange feed and MLAT clients, and lighttpd serving tar1090 (the live map), graphs1090 (long-run history) and SkyAware.

None of this is novel. The ADS-B community documents itself well, and I followed the guides:


Zabbix: ads-b-zabbix

This is the part I wrote, and the part that is now open.

It started as a small daemon pushing aircraft counts to Zabbix. It turned into two jobs, kept deliberately separate:

  • Live monitoring — per-snapshot counts pushed to Zabbix as trapper items, with triggers and Grafana panels. Answers what is in the sky right now.
  • The sighting log — one row per sighting in MariaDB, kept for years, read directly by Grafana. Answers which aircraft passed, when, how close, and why it was flagged.

Zabbix is a metrics system: it stores numbers over time, not records. Asking it "which military aircraft flew past in March" was never going to work, which is why the log has its own store.

flowchart LR AJ["dump1090-fa
aircraft.json"] -->|every 1 s| CL subgraph COL["adsb-collector"] CL[classify] --> TR[track] CL --> AL[alert] CL --> AG[aggregate] end AG -->|"trapper, every 30 s"| ZBX[("Zabbix
triggers, graphs")] TR -->|"finished sightings, every 60 s"| DB[("MariaDB adsb
track / point / event")] DB --> LOG[adsb-log CLI] DB --> GRAF[Grafana]

Three binaries:

Binary What it does
adsb-collector The daemon. Reads aircraft.json, classifies, turns snapshots into sightings, alerts on approaches, pushes metrics to Zabbix, writes the log.
adsb-log Queries the log: list sightings, inspect one in full, test classification rules, reclassify history.
adsb-top A top-style terminal view of what is currently in range.

What Zabbix gets

Aircraft counts sliced every useful way — airborne, climbing, descending, on the ground, military, emergency, with and without position, MLAT, TIS-B, LADD, PIA — plus altitude and speed statistics, message rate, unique tracks over 24 hours, and counts per wake-turbulence category and per operator through low-level discovery. All pushed every 30 seconds with zabbix_sender, PSK-encrypted.

Triggers that matter: emergency aircraft on frequency, military aircraft in range, no ADS-B data, message rate dropped to zero.

A typical sample: 51 aircraft tracked, 22 with position, 1 military, mean altitude 31 620 ft, mean speed 423 kt, 292 messages/s, 2 804 unique tracks in 24 hours.

Who is that aircraft?

dump1090-fa, unlike readsb, does not put registration, type or operator in aircraft.json — its web interface looks them up client-side. Without help every sighting would record a hex address and nothing else.

The collector fills that in from databases the receiver already has: SkyAware's aircraft database (~365 000 airframes) and tar1090's operator list (~6 300 operators). The operator comes from the three-letter callsign prefix, which also gives the country: RCH705 is the US Air Mobility Command, LSV202 the Slovenian Ministry of Defence. That says who is flying, not where the airframe happens to be registered.

Classification is five independent sources, OR'd, each recording why: dump1090's military flag, military ICAO address ranges, emergency squawks (7500/7600/7700), the ADS-B emergency field, and a personal watchlist. Everything is logged, interesting or not — so a rule added tomorrow can be applied to what flew past last year (adsb-log reclassify).

Warn me before it is overhead

An aircraft overhead is one you already missed. The alert engine projects each watched aircraft onto a local plane centred on the receiver, extrapolates its ground track to the closest point of approach, and fires once, early:

ALERT RCH285 (C17) inbound: 42.3 km out on bearing 188°, passing within 1.2 km of you in 6m30s

It only fires when the aircraft is within 50 km, actually closing, predicted to pass within 2 km, within 15 minutes — and three consecutive polls agree, because at 50 km two degrees of heading noise moves the predicted track by almost two kilometres. Every alert stores its prediction next to what the aircraft actually did, so the predictor can be scored against reality before anything cleverer gets built.


What I measured: the front end is being shouted at by GSM

The receiver runs at maximum gain (49.6) on a FlightAware Pro Stick — the blue one, with an LNA but no SAW filter. Maximum is a reasonable starting point; it is rarely optimal. So I swept 700–1300 MHz with rtl_power, at the operating gain and again at 25:

band peak @ gain 49.6 peak @ gain 25
LTE800 downlink (band 20), 791–821 MHz +13.9 dB +1.3 dB
GSM/LTE900 downlink (band 8), 925–960 MHz +21.8 dB (937 MHz) +13.4 dB
DME/TACAN, 1000–1080 MHz +2.9 dB −9.0 dB
ADS-B, 1085–1095 MHz +6.3 dB −6.0 dB
DME/TACAN, 1100–1215 MHz +1.2 dB −11.4 dB

The strongest signal reaching the dongle is a mobile base station at 937 MHz, about 15 dB stronger than anything in the ADS-B band. It is 150 MHz away, which a wideband front end does not care about: the mixer and the ADC see everything the antenna gives them. And both interferers are European mobile allocations — a rooftop antenna has line of sight to whichever mast is nearest.

The front end is also visibly compressing. Raising the gain by 24.6 dB raised the GSM peak by only 8.4 dB and the noise floor at 1090 MHz by 11.6 dB. Roughly 16 dB of missing gain is the signature of a saturated front end: the extra gain is spent on GSM and clipped, not on aircraft.

So, in order:

  1. Fit a 1090 MHz SAW band-pass filter between the coax and the stick. It attenuates GSM900 and LTE800 by tens of dB and costs ~2 dB in band.
  2. Do not add another LNA. The stick already has one; a second makes the overload worse.
  3. Re-tune gain afterwards, a full day per step, comparing like with like. A quiet hour looks identical to a broken antenna.

There is a catch in step 3: the AVR stream carries no signal level, so the usual tuning signal — how many messages arrive near full scale — does not exist here. Tuning has to be done on outcomes: message rate, aircraft seen, positions and, above all, range.


Things I got wrong first

I surveyed the running system before writing any of this down, and a few conclusions did not survive testing. They are worth keeping, because they are the kind that sound right.

"Splitting the receiver breaks MLAT." It does not. I had inferred the wire format instead of looking at it — and it carries 12 MHz timestamps from the Pi's own clock. Zero outliers and zero sync timeouts are the evidence; jittered timestamps produce exactly those.

"Move decoding to the Pi so MLAT gets proper timestamps." Wrong twice: MLAT already has them, and the Pi is already doing the expensive half. Tracking and feeding are the cheap part.

"The screen-based supervision is fragile." One restart in its whole life. Not where effort belongs.

Still to do

  • The filter. Highest-value change available, by the measurement above.

  • Ansible. The ADS-B stack on both hosts was installed by hand; the playbooks only do base preparation. Pi SD cards fail, and right now the rebuild lives in my head and in a wiki page.

  • stream1090's statistics are trapped in a screen buffer — the one number that would show the antenna side degrading. It belongs in the journal and in Zabbix.

    Repo

Related Posts

Zabbix Alerts on Magic Mirror
MagicMirrorJavaScriptzabbix

Zabbix Alerts on Magic Mirror

Magic Mirror Module that reads and displays Zabbix Alerts / Triggers

3/27/2026Read More →
Zabbix Conference 2013
zabbixlinuxperlmonitoring

Zabbix Conference 2013

Long-Short story of Perobbix a Oracle Database Monitoring tool for Zabbix

4/30/2026Read More →
Zabbix Summit 2019
monitoringzabbixlinuxMysqlElasticsearch

Zabbix Summit 2019

Describing a Multi-datacenter project with Zabbix and FOSS eco-system

5/23/2026Read More →