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 announcing itself: where it is, how high it is, and who it is. It does this over a radio system called ADS-B, broadcast on a frequency of 1090 MHz. You would normally see this through a website like FlightRadar24, but those services buy their data from networks of receivers and run it in the cloud. 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 database I could question later.

Tom Shanks

Live ADS-B operations dashboard showing aircraft positions over Denver
Live operations dashboard. The dark-theme map plots current aircraft positions on top of an accumulated route heatmap. Stats panel: 9,057 unique aircraft, 1,083,226 messages ingested.
Messages ingested
1.08M
Aircraft enriched
520K
Receive range
~200 km

System Architecture

The whole system is a chain of parts, and the easiest way to understand it is to follow a single radio signal from the sky to the screen. To catch the signal I used an RTL-SDR, a cheap USB radio stick that was originally made for watching TV and can be retuned to listen on 1090 MHz. I plugged it into a Raspberry Pi, a small low-power computer, so the receiving station could run quietly around the clock. A raw radio signal on its own is meaningless, so I ran a program called dump1090 that decodes it into readable aircraft messages. Then I wrote a small Python service, packaged in a Docker container so it is easy to start and restart, that takes each decoded message and files it into a database.

I did not pick that database at random. I chose PostgreSQL with its PostGIS extension because PostGIS understands geography. An ordinary database can store a latitude and longitude as two plain numbers, but PostGIS treats them as real locations, so it can answer questions like which aircraft passed within five kilometers of a point, or which compass directions my receiver hears best. A live map of moving dots was never really the goal for me. I wanted to ask questions over time, like which aircraft keep coming back day after day, and those only make sense as spatial queries. I also loaded a reference table of about 520,000 aircraft, 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 changes what a track actually means to me.

There was still a speed problem to solve. Every message lands in one enormous raw table, and re-running the same heavy geographic query against millions of rows every time the page loads would be painfully slow. So I used materialized views, which are saved, pre-computed answers to a query that the database refreshes on a schedule. The questions I ask most often, such as where traffic concentrates, which directions my receiver covers best, and which contacts behave differently from ordinary commercial routes, all read from these views, so the dashboard stays fast.

On top of the database I built an API with FastAPI, a Python framework, exposing twelve endpoints that hand out things like statistics, positions, routes, and receiver range. The dashboard itself is a React app that draws the map and charts with a mapping library called Leaflet.js. The whole stack runs together with Docker Compose inside a Linux environment (WSL2 Ubuntu), managed by the operating system so it starts on its own and keeps running without me.

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
Polar receiver-range chart by compass bearing. Peak range: 116.8 km to the SE. The Front Range terrain masks westward coverage; clear eastern sectors reach 80–120 km. 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, but it does not tell you which of those contacts is worth a second look. I wanted the system to do that filtering for me, so I built a rule-based detector that watches the live traffic and flags anything that stands out.

The detector runs on a simple cycle. Every two minutes a Python service reads the latest messages and checks each aircraft against a set of rules, and 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. I deliberately left machine learning and imagery out of it, because I wanted every flag to have a plain reason behind it that I could explain and check by hand.

Rules in production:

  • low_altitude_mil_ops : military-registered airframes 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
  • military_rotary_wing : ICAO hex codes registered to military operators
  • mystery_military_aircraft : military-block hex codes with no callsign and no public registry resolution

Case Study: Rotary-Wing Cluster, Denver Metro

One evening the detector lit up. Three rules fired at the same time across several aircraft in the same area: military_rotary_wing, night_operations, and low_altitude_mil_ops (1,000–3,000 ft above ground level). I looked the broadcast hex codes up in public aircraft registries, and they came back as Army aviation airframes. The way the aircraft were flying, low and repeatedly over the same urban corridors with another contact holding higher up, matched the public examples I had read about of low-altitude survey or surveillance work. I want to be careful about what that means: it does not prove the mission. What I can say is that the system caught unusual rotary-wing activity on its own and put it in front of me to review.

A third 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 matched no normal commercial route, and its hex code traced to a military block with no matching entry in any public registry. That is worth flagging, but it is not enough to guess what it was doing. I cannot honestly say more than this: the aircraft was broadcasting its position without enough public information attached to identify it.

The point of the example is not the aircraft itself. It is that the system surfaced the pattern on its own, from radio signals I picked up locally and public reference data, and then left the actual interpretation to me.

Military aircraft track cluster over Denver metro, color-coded by ICAO identity
Military aircraft tracks over Denver metro, color-coded by identity. Named military contacts are distinguished from unregistered military-block contacts. 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

The whole thing runs as a small production system that stays on around the clock. 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, 1,083,226 position messages as of the last count.

Source available on request.