Skip to content
← Projects

Telemetry · Geospatial · 2026

ADS-B Aircraft Tracker: From RF Hardware to Real-Time Detection

Every aircraft in the sky is constantly broadcasting itself over ADS-B, a protocol operating on frequency 1090 MHz. Most people use a service such as FlightRadar24 or ADS-B Exchange, but those services buy their data from networks of receivers and run it in the cloud. As one of the contributors to those networks, I wanted to know how much of that picture I could build entirely on my own, from hardware I owned, without paying for a feed or relying on anyone else's servers. So I started at the most basic level: pulling those 1090 MHz signals out of the air with my own antenna, decoding them on a Raspberry Pi, and saving every aircraft I detected into a PostGIS database I could query later.

Tom Shanks

Live ADS-B React dashboard showing aircraft positions over Denver
Live operations dashboard. It plots current aircraft positions and accumulates a heatmap layer of traffic density. Stats panel: 9,057 unique aircraft, 1,083,226 messages ingested.
Messages ingested
1.08M
Aircraft enriched
520K
Receive range
~200 km

System Architecture

To catch the signals, I used an RTL-SDR, a cheap USB radio stick originally designed as a digital TV tuner. It turned out to be capable of tuning to 1090 MHz as well, which helped fuel the recent growth in RF engineering and experimentation. I plugged it into a Raspberry Pi 3B so the receiving station could run quietly around the clock. After flashing my Raspberry Pi SD card with the ADS-B Exchange station image, I wrote a small Python service and packaged it in Docker so it would be easy to start, restart, and recover if the station lost connectivity. The service then takes each decoded ADS-B message and writes it into a PostGIS database.

I like PostGIS a lot. I work with PostgreSQL regularly, and with my background in GIS, I appreciate having spatial data readily accessible. So I chose PostgreSQL with the PostGIS extension. While you can store latitude and longitude as plain numbers in almost any database, PostGIS treats them as real locations with georeferencing, so I can run faster spatial queries. I can ask which aircraft passed within five kilometers of a specific point, or which compass directions my receiver is hearing best. A live map of moving points is not really the focus here, so this local PostGIS database does not need to be especially fast. I can use a nightly runner to pull data down into my local database. I wanted to be able to extract insights that accumulate over time, such as which aircraft keep returning day after day. I also loaded a reference table of about 520,000 aircraft from a public registry, because the identifiers a plane broadcasts, a short hex code and a callsign, say almost nothing on their own. Matching them against that table turns a bare code into the operator, the registration, and the aircraft type, and that context lets me ask more meaningful questions.

There was still a speed problem to solve. Since I was sending every message into one enormous raw table, re-running the same heavy geographic query against millions of rows each time the page loaded was becoming painfully slow. So I created materialized views, which are precomputed answers to queries that the database refreshes on a schedule. A handful of questions come up most often: where air traffic is concentrated, which directions my receiver covers best, and which aircraft behave differently from ordinary commercial routes. All of them now read from these views, so the dashboard stays fast.

On top of the database, I built a small API with FastAPI, using Python and an existing framework I already knew. I exposed twelve endpoints for the statistics, positions, routes, and range data I was interested in. The dashboard itself is a React app that draws the map and charts using Leaflet.js. The whole project runs together via Docker Compose inside a Linux environment (WSL2 Ubuntu), managed by Windows so it can start on its own and keep running without me forgetting to launch it.

ADS-B stack architecture diagram showing data flow from RTL-SDR through PostGIS to the React dashboard and detection feed
Stack architecture. Data flows from the RTL-SDR receiver through dump1090 and the Python ingest service into PostGIS. Materialized views pre-aggregate the spatial queries; FastAPI serves the results to the React dashboard and the rule-based detection feed.
Polar receiver-range chart showing ADS-B coverage by compass bearing
The Front Range terrain masks westward coverage. Generated from 179,854 messages across 4,437 unique aircraft over 8 days.

Rule-Based Flight Pattern Detection

Plotting every aircraft as a dot on a map is the easy part. Extracting meaningful explanations from the data is another matter. I wanted the system to do that filtering for me, so I built a Python rule-based detector that watches the live traffic and flags behavior that deviates from the baseline.

The detector runs on a simple cycle. Every two minutes, the service reads the latest messages and checks each aircraft against a set of rules; anything that matches is pushed to a detection feed on the dashboard. The rules only look at things I can measure directly: altitude, time of day, callsign patterns, registry information, and how a track moves across the map.

Rules in production:

  • low_altitude_ops : aircraft operating well below typical commercial altitudes in civil airspace
  • night_operations : sustained flight outside daylight hours
  • loitering_aircraft : circling within a bounded radius over a sustained interval
  • law_enforcement_callsigns : match against curated public-domain LE callsign prefixes
  • unregistered_aircraft : hex codes with no callsign and no matching entry in any public registry

Case Study: Low-Altitude Cluster, Denver Metro

One evening, the detector began flagging many messages simultaneously. Two rules fired at the same time across several aircraft in the same area: night_operations and low_altitude_ops (1,000 to 3,000 ft above ground level). The aircraft were flying low and repeatedly over the same urban corridors, with a single aircraft holding higher up.

One aircraft in the same airspace was stranger still. It broadcast only its position with no callsign attached, it flew back and forth at a constant altitude in a pattern that did not match any normal commercial route I could find, and its hex code returned no matching entry in any public registry I could find. In Colorado, that is not too unusual. Buckley Space Force Base is not far away, and there are many coordinated training and rescue operations involving national and state guardsmen in Colorado, given the state's strong aerospace presence and rugged terrain.

Low-altitude aircraft track cluster over Denver metro, color-coded by ICAO identity
Aircraft tracks from the flagged cluster over Denver metro, color-coded by identity. Contacts with a public registry match are distinguished from those with no registry resolution. All data from passive RF collection; no secondary sources.

Tech Stack

Hardware

RTL-SDR USB receiver (1090 MHz) · Raspberry Pi (decoder host)

Decoding & ingest

dump1090 (Mode S / ADS-B decoder) · Python ingest service

Data

PostgreSQL + PostGIS · 520K-row aircraft metadata index · materialized views

API

FastAPI (Python) · 12 endpoints (stats, positions, routes, receiver range, corridor density, hourly traffic, aircraft detail, detection pipeline)

Frontend

React + Vite · Leaflet.js

Detection

Custom Python pipeline (Docker) · rule-based detection engine, two-minute cadence

Deployment

Docker Compose · WSL2 Ubuntu · systemd user services · local network access only


Status & Access

Thanks for reading this little post. To summarize, this project runs as a live 24/7 production system. I keep the dashboard reachable only from devices on my own local network, and the ingest pipeline never sends data anywhere outside it. Contacts and detections keep building up locally in my database, with 1,083,226 position messages as of the last count.

Source available on request.