← cd ~/index

Overhead-The inspiration behind it

A Raspberry Pi radar that shows the planes above your house, chimes when one passes over, and keeps a log of every one it has ever seen

What is this project exactly?

It’s a radar scope that sits on my desk. Red sweep, black background, range rings, the whole air traffic control aesthetic. It shows the aircraft currently above my house, drawn as little silhouettes turned to face wherever they’re actually going.

When something passes directly overhead it chimes, opens a panel telling me what it was, and writes it down. As of this morning it has written down 1,197 of them.

No SDR dongle. No antenna. A Pi, a screen, and wifi.

Why bother making this?

I live under something. Not a flight path exactly, but something, a steady procession of small twin-engine aircraft that go over low enough to rattle the windows. I wanted to know what they were.

Answering that once is easy: there are websites. Answering it continuously, without picking up my phone, and accumulating an answer to “what usually flies over here”, that’s a different question, and it’s the kind that turns into a project.

The obvious build is an RTL-SDR receiving 1090 MHz directly. I started there. Then I decided I didn’t want to buy hardware for something I wasn’t sure I’d keep on my desk, which turned out to be the more interesting constraint.

The Process

Every free API is quietly closing

I assumed I’d pick from the several public ADS-B feeds and move on. Instead:

  • airplanes.live now returns API Key invalid unless you feed data back to them.
  • adsb.lol says a key will be required “in the future”, which is the kind of sentence that ages fast.
  • OpenSky moved to OAuth2 client credentials and a daily credit budget.
  • adsb.fi still works with no account at all, and returns the aircraft type alongside the position.

So adsb.fi is the default and OpenSky is the fallback. OpenSky is metered, 400 credits a day anonymous, 4,000 with a free account, which means the poll interval isn’t a number I get to pick:

budget = 4000 if client_id else 400
self.suggested_interval = max(6.0, 86400.0 / budget * 1.15)

One update every 22 seconds, derived from the allowance rather than guessed at, with 15% headroom so a restart doesn’t blow the day’s budget by lunchtime.

A radar that updates every 10 seconds is not a radar

This was the actual design problem. A real receiver gives you a position every second. A free API gives you one every 10 to 220 seconds. Between updates, a naive scope shows frozen dots that teleport, which looks less like a radar and more like a spreadsheet having a stroke.

So each aircraft is carried forward along its own track and groundspeed:

travelled = self.gs * (age / 3600.0)          # knots -> nautical miles
a = math.radians(self.track)
lat = self.lat + travelled * math.cos(a) / 60.0
cos_lat = max(0.05, math.cos(math.radians(self.lat)))
lon = self.lon + travelled * math.sin(a) / 60.0 / cos_lat

A 450-knot aircraft covers 7.5 nautical miles a minute, and now that gets drawn.

The part I’m actually pleased with is that the display admits what it’s doing. The corner shows how long since the last real update. Aircraft coasting on a stale fix are drawn dimmer. Anything unheard from for 90 seconds is removed instead of allowed to drift into pure fiction. And crucially, the extrapolated positions are never written to the log, only real fixes go in the database, so the data stays clean even though the picture is smoothed.

It’s an honest lie, which is the only acceptable kind in a measurement tool.

Testing a thing you cannot see

I wrote most of this without being able to run it. No pygame in my dev environment, no network. So I built a fake pygame, a module that implements the drawing API, records every call, and asserts on the arguments:

def _check_color(c):
    assert len(c) in (3, 4), f"color needs 3 or 4 channels, got {c!r}"
    for v in c:
        assert 0 <= v <= 255, f"color channel out of range: {c!r}"

Then I drove the whole render loop through it at nine screen sizes and six text scales. This caught three genuine bugs before a single pixel existed: the side panel overflowing on small screens, fonts scaling off the scope radius and overrunning the panel on 1080p, and labels clipping off the top edge.

It cannot tell me whether it looks good. That part still required putting it on a screen and staring at it. But “does anything render outside the display” is exactly the sort of question a machine should answer instead of me.

The chime is arithmetic

I didn’t want to ship an audio file, so the chime is synthesized at startup. A struck bell is a fundamental plus quieter, faster-decaying partials, three is enough to stop it sounding like a hearing test:

BELL_PARTIALS = ((1.0, 1.00, 4.5), (2.0, 0.30, 7.0), (3.01, 0.11, 10.0))

Two notes a fifth apart, the second entering while the first still rings. Helicopters get a lower pair. Roughly a tenth of a second of maths at boot, nothing to download.

The hard part wasn’t the sound, it was firing it once. At 30fps, “chime when something is overhead” chimes thirty times a second. Four guards later, per-aircraft arming, a cooldown, an anti-stacking gap, and a ceiling of eight per minute, an aircraft flickering across the boundary produces exactly one chime, and forty aircraft over a busy field can’t turn my desk into a smoke alarm.

The bug that had been lying to me for days

I’d bumped the font size to make the panel readable from across the room. Later I asked for aircraft names in the contact list, went to check the geometry, and found the panel had room for 21 monospace columns while a row needed 25.

My callsigns had been silently truncated the entire time and I hadn’t noticed, because a truncated callsign still looks like a callsign.

The fix was to widen the panel with the text scale, and, the non-obvious half, to stop deriving the font size from the panel width, since a wider panel then grew the font, which ate the space it had just gained. A fixed reference constant breaks the loop.

What it does now

  • Silhouettes turned to heading; helicopters get a rotor disc with turning blades
  • A chime, with quiet hours, because it’s next to a bedroom
  • Every sighting in SQLite: closest approach, altitude range, full tracks at 15-second resolution
  • A history page grouped by day, with a marker on the ones still in the air
  • Click any contact for details, including seen 8x since 09 Sep, best 0.9nm read back from my own log

That last line is the one that justifies the project. The scope is a nice object, but the database is the thing that accumulates. I now know that the aircraft rattling my windows is usually a Cessna 402 Businessliner belonging to Hyannis Air Service, going to the islands, and that it comes over eight times a day.

I did not know that a week ago. I would not have found it out by looking it up once.

Conclusion

Build the thing that answers the question continuously, not the thing that answers it once. The second one is a search query; the first one becomes a dataset, and datasets tell you things you didn’t know to ask.

Also: if your test suite can’t tell you whether it looks good, say so in the README. Nobody has ever been helped by a project that oversells its own guarantees.