Building an overhead flight display with an ESP32 and MicroPython
I live under the approach path for Leeds Bradford Airport. Planes pass over the house all day, and I kept wondering where each one had come from. So I built a small device to tell me. It is a 16 by 2 character LCD driven by an ESP32. When an aircraft is overhead, it shows the flight number and the city it is arriving from, or heading to.
This article covers the architecture, a couple of decisions I had to reverse, and the how-tos that took me longest to work out. It is aimed at engineers who are comfortable with the basics and want the detail rather than a beginner walkthrough.
What it does
The behaviour is simple to describe.
- Every few seconds, ask a public flight-data service what is nearby.
- Decide whether anything is directly overhead.
- If so, look up where that flight is coming from or going to.
- Show two lines on the LCD, for example
RYR282DandFrom BRISTOL.
The hard parts are hidden in steps 2 and 3, and in one architectural decision that I got wrong at first.
The first design: a cloud brain
My instinct was to keep the microcontroller as simple as possible. The plan was a small TypeScript service on Cloudflare Workers that did all the thinking, and an ESP32 that just polled it and printed whatever it was handed.
The data would come from two free, keyless services:
- adsb.lol for live aircraft positions in a radius around a point.
- adsbdb to turn a callsign such as
BAW1476into an origin and destination airport.
This felt right. The Worker could hold the messy logic, the device stayed dumb, and I could change the formatting without reflashing anything. I built it, deployed it, and tested it against live traffic. It worked.
Then it stopped working, and the reason taught me the main lesson of the project.
The problem: shared cloud IPs get rate limited
The Worker started returning empty results. Better logging showed why:
{ "on": 0, "err": "airplanes.live 429; adsb.fi 403; adsb.lol 429" }
Every flight-data mirror was refusing the request. A 429 is “too many requests” and a 403 is a flat refusal. All of it came from the Worker, not from me. The same requests from my laptop worked.
Community ADS-B feeds rate limit by IP address. Cloudflare Workers make outbound requests from a pool of shared IP addresses, used by a very large number of other people. From the point of view of adsb.lol, that shared IP is hammering it constantly, so it throttles it. My handful of requests were caught in the same net.
I assumed the route lookup would be fine, because it is a different service. It was not. adsbdb blocked the Worker too. A direct call returned the real airport, while the Worker received nothing for every callsign I tried. The entire data pipeline was unreachable from the cloud, and worked from a home connection.
The fix: move everything to the device
Once the cause was clear, the design almost rewrote itself. A residential IP address is not throttled, and the ESP32 already sits on one. So the device should do the whole job: fetch positions, filter them, look up the route, and format the result. There is no cloud component at all.
This is the opposite of my original instinct. “Keep the microcontroller dumb” is good advice most of the time. Here it was wrong, because the constraint was not compute or memory. The constraint was which network the requests came from. The ESP32 was the only part of the system on the right side of that line.
The lesson I took from this: when you choose where code runs, network identity can matter as much as raw capability. A cheap device on a home connection can be the most capable node in the system for a specific task.
Key decisions
Detecting “overhead” without doing any trigonometry
The obvious approach to “is this plane over my house” is to calculate the
distance between two latitude and longitude points. It turns out I did not need
to. The adsb.lol response includes a dst field, which is the distance in
nautical miles from the point you queried. Since I query around the house, dst
is already the distance from the house.
That reduces the overhead test to two cheap comparisons: close enough horizontally, and low enough to be on approach or departure rather than cruising.
def find_overhead(aircraft):
best, best_dst = None, 1e9
for a in aircraft:
alt = a.get("alt_baro")
if not isinstance(alt, (int, float)): # skip "ground" and missing values
continue
if alt >= ALT_MAX_FT: # ignore high cruise traffic
continue
dst = a.get("dst") # nautical miles, from the feed
if dst is None or dst > OVERHEAD_NM:
continue
if dst < best_dst:
best, best_dst = a, dst
return best
The altitude filter does more work than it looks. Aircraft directly overhead on approach are low, often around a thousand feet. Cruising traffic is at thirty thousand feet or more. A cut at five thousand feet separates the two cleanly, with a large margin either side.
One small thing to watch: alt_baro is sometimes the string "ground" rather
than a number, for parked aircraft. The isinstance check handles that without a
special case.
Arrivals or departures: reading the vertical rate
I sit north-west of the runway, which means I see landings when the wind favours one runway and take-offs when it favours the other. So “arriving from” is only right half the time. The other half of the time, what I want is where the plane is going.
The feed gives a baro_rate, the rate of climb or descent. A descending aircraft
is arriving, so I want its origin. A climbing aircraft is departing, so I want its
destination.
climbing = isinstance(rate, (int, float)) and rate > 100
label = "To" if climbing else "From"
airport = route["destination"] if climbing else route["origin"]
The small threshold of 100 rather than 0 avoids flapping when an aircraft is briefly level. It is a tiny detail, but it stops the display flickering between “From” and “To” on a plane that is holding altitude for a few seconds.
Talking HTTPS from MicroPython without urequests
This is where I lost the most time, so it gets its own how-to below. The short
version is that my first attempt read the response and passed the body straight
to json.loads, and it failed on the device with “syntax error in JSON”. The same
code worked against the same URL from my laptop.
The cause was chunked transfer encoding. Servers behind a CDN often stream the response in chunks, and the raw body then contains hexadecimal size markers around the JSON. My parser was handing those markers to the JSON decoder. The fix is to detect chunked encoding and reassemble the body before parsing.
A small display state machine
I wanted the display to feel alive rather than static, and I wanted a spotted aircraft to stay on screen for a while even after it had flown past. Rather than scatter timing logic through the main loop, I put it in a small class that returns a list of actions. The main loop just carries them out. This also made it testable without any hardware, which I will come back to.
The part I care about most is the hold. When an aircraft is found, I record a deadline. While that deadline is in the future and nothing new is overhead, the display keeps showing the last aircraft.
def after_poll(self, frame):
if frame: # something is overhead
actions = self._draw(frame)
self.hold_until = ticks_add(ticks_ms(), self.hold_ms)
return actions
if self._holding():
return [] # keep the last aircraft on screen
return self._idle_cycle() # "No Aircraft", then back to scanning
The default hold is three minutes. A plane is only overhead for perhaps half a minute, so without the hold you would glance up from your desk and miss it.
How to (the practical bits)
These are the specific tasks that were fiddly to get right. If you are building something similar, start here.
How to get live aircraft near a location for free
Use the adsb.lol point endpoint. It takes a latitude, a longitude and a radius in nautical miles, and needs no API key.
GET https://api.adsb.lol/v2/point/<lat>/<lon>/<radius_nm>
The response has an ac array of aircraft. Each entry includes flight (the
callsign, often with trailing spaces, so trim it), lat, lon, alt_baro,
baro_rate, and dst, the distance in nautical miles from the point you
queried.
Keep the radius small. A three nautical mile query returns a small payload, which matters on a microcontroller with limited memory. airplanes.live and adsb.fi offer the same data with the same schema if you want a fallback, though adsb.fi uses a slightly different URL shape.
How to turn a callsign into an origin and destination
Use adsbdb. It is free and keyless.
GET https://api.adsbdb.com/v0/callsign/<callsign>
The route lives at response.flightroute.origin and
response.flightroute.destination. Each airport has a municipality (the city),
an iata_code, and a full name. For a 16 character line I prefer the city, fall
back to the IATA code when the city is too long, and truncate as a last resort.
def shorten(airport):
if not airport:
return "---"
city = (airport.get("municipality") or "").split(",")[0].strip().upper()
if city and len(city) <= 11:
return city
if airport.get("iata_code"):
return airport["iata_code"].upper()
return (city or airport.get("name") or "---")[:11]
Be ready for aircraft with no route. Light aircraft and some private flights return an “unknown callsign” response, so treat a missing route as a normal case rather than an error.
How to make an HTTPS GET on an ESP32 in MicroPython
You do not strictly need a library for this. A socket, TLS and a little care are enough, which keeps the firmware self-contained.
import socket, ssl, json
ai = socket.getaddrinfo(host, 443, 0, socket.SOCK_STREAM)[0]
s = socket.socket(ai[0], ai[1], ai[2])
s.settimeout(15)
s.connect(ai[-1])
s = ssl.wrap_socket(s, server_hostname=host) # SNI is required by most hosts
s.write(("GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n"
% (path, host)).encode())
Two things will catch you out.
First, pass server_hostname. Without it there is no SNI, and many hosts will
refuse the handshake or serve the wrong certificate.
Second, handle chunked transfer encoding. Read the full response until the server
closes the connection, split the headers from the body, and if the headers say
Transfer-Encoding: chunked, reassemble the body before decoding.
def dechunk(body):
out = b""
while True:
i = body.find(b"\r\n")
if i < 0:
break
size = int(body[:i], 16)
if size == 0:
break
start = i + 2
out += body[start:start + size]
body = body[start + size + 2:] # skip the chunk data and its trailing CRLF
return out
This one detail is the difference between “works on my laptop” and “works on the device”. Build it in from the start.
How to find your I2C LCD address
An I2C LCD backpack has a fixed address, commonly 0x27 or 0x3F. Rather than
hard-code it, scan the bus and use what you find.
from machine import I2C, Pin
i2c = I2C(scl=Pin(14), sda=Pin(13), freq=400000)
print([hex(a) for a in i2c.scan()]) # e.g. ['0x27']
Print the result to the shell on boot. If the display stays blank, the scan tells you whether the board can see the LCD at all, which separates a wiring problem from an address problem straight away.
Testing without the hardware
Because the logic is plain Python, I can test almost all of it on a laptop with no board attached. The trick is to stand in for the MicroPython-only modules.
I stub machine, network and the LCD driver, and I replace the MicroPython
time.ticks_* functions with a fake clock I control. The state machine and the
parsing code then run under ordinary CPython with the standard unittest module.
The fake clock is what makes the three minute hold testable in milliseconds:
clock.advance(179)
self.assertEqual(display.after_poll(None), []) # still holding at 179s
clock.advance(2)
self.assertEqual(display.after_poll(None), idle_cycle) # released just past 180s
There are around fifty of these tests. They caught more than one mistake before it reached the device, and they run in well under a second. On an embedded project, where the loop of flashing and watching a screen is slow, that saves a lot of time.
What I would change next
The device polls on a fixed interval and keeps a single fallback in mind. If I wanted to make it more reliable, I would add a second position source on the device itself, so a wobble at one provider does not blank the display. I would also like to show a short trail of recent flights rather than just the current one.
None of that is essential. The device sits on the windowsill, and when something rumbles overhead it tells me it is the Jet2 from Alicante. That was the whole point.
Takeaways
- Public ADS-B data and callsign lookups are free and good enough for a hobby build.
- The
dstfield means you rarely need to compute distances yourself. - Vertical rate is a cheap, reliable way to tell arrivals from departures.
- Chunked transfer encoding will break a naive HTTPS client. Handle it early.
- Where code runs is an architectural decision in its own right. Sometimes the microcontroller on a home connection is the right place for the whole job.
Neil Charlton
Passionate about vegetable gardening, growing superhot chillies, producing EDM, and practicing mindfulness. Based in Otley, West Yorkshire — writing from the Wharfe Valley.
More about me →