Overview
I already had a CYD display running my radiosonde project. The next logical step was a second display showing live air traffic — ADS-B transponders broadcast on 1090 MHz and are receivable with any RTL-SDR dongle and an antenna. The goal was a completely self-hosted, always-on display with no dependency on FlightAware, FlightRadar24, or any external service.
The architecture is simple: a Raspberry Pi 5 runs readsb as a systemd
service, decoding ADS-B frames from an RTL-SDR dongle and serving aircraft data as
JSON on a local HTTP port. The CYD polls that endpoint over WiFi every few seconds
and renders a scrollable list. Tap an aircraft, get the detail screen.
Hardware
The Display — ESP32-2432S028 ("CYD")
The Cheap Yellow Display is a ~£10 to £15 ESP32 board with a 2.8" 320×240 ILI9341 TFT, a resistive touchscreen, and a micro-USB power input. It's a surprisingly capable embedded display platform once you've fought through its hardware quirks — separate SPI buses for the display and touch controller being the main one.
ILI9341_2_DRIVER define in your
TFT_eSPI config — the standard ILI9341_DRIVER will compile fine but
produce a corrupted image. This caught me out on the radiosonde project first.
The Receiver — RTL-SDR Blog V4 on discone antenna
The ADS-B dongle lives on a discone antenna which covers 1090 MHz comfortably.
A separate RTL-SDR V4 dongle handles radiosonde duties on 403 MHz simultaneously
on the same Pi 5 — each dongle identified by serial number so readsb
and auto_rx don't fight over hardware.
# Tag each dongle so software can address them by name
rtl_eeprom -d 0 -s ADSB
rtl_eeprom -d 1 -s SONDE
# Verify
rtl_test -d ADSB
rtl_test -d SONDE
readsb — Pi-side Decoder
I used wiedehopf's fork of readsb, built from source with RTL-SDR support.
It's more actively maintained than the original dump1090 and produces cleaner JSON output.
# Dependencies
sudo apt install -y build-essential debhelper libusb-1.0-0-dev \
librtlsdr-dev pkg-config
# Clone and build
git clone https://github.com/wiedehopf/readsb.git
cd readsb
make RTLSDR=yes
# Install binary
sudo install -m 755 readsb /usr/local/bin/readsb
Serving JSON over HTTP
readsb writes its aircraft data to a JSON file on disk. The CYD needs
to poll it over HTTP, so I added a small Python HTTP server as a companion systemd
service. It serves the readsb output directory on port 8081.
import http.server
import socketserver
import os
PORT = 8081
DATA_DIR = "/run/readsb" # readsb writes aircraft.json here
os.chdir(DATA_DIR)
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # suppress access log noise
with socketserver.TCPServer(("", PORT), Handler) as httpd:
httpd.serve_forever()
[Unit]
Description=ADS-B JSON HTTP Server
After=readsb.service
[Service]
ExecStart=/usr/bin/python3 /home/pi/adsb_server.py
Restart=always
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now adsb-server
# Quick sanity check — should return JSON with an "aircraft" array
curl http://piradio.local:8081/aircraft.json | python3 -m json.tool | head -30
CYD Firmware
Architecture
The firmware uses a straightforward polling loop — no RTOS needed given the single display task. Every 5 seconds it hits the Pi's HTTP endpoint, parses the JSON aircraft list, and re-renders the display. TFT_eSPI handles all drawing.
Two screens: a scrollable list view showing up to ~10 aircraft per page with callsign, altitude, speed, and distance; and a detail view triggered by tapping any row, showing full telemetry plus the country flag derived from the ICAO address.
User_Setup.h — getting the CYD pins right
The CYD's display and touch controller share the SPI bus but need separate chip select and other pins. This tripped me up badly the first time. The working TFT_eSPI config:
// Driver — MUST be ILI9341_2_DRIVER, not ILI9341_DRIVER
#define ILI9341_2_DRIVER
// Display SPI pins (HSPI)
#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST -1 // tied to EN
#define TFT_BL 21 // backlight PWM
// Touch uses a separate VSPI bus
#define TOUCH_CS 33
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
// Invert colours — required on this hardware revision
#define TFT_INVERSION_ON
#define SPI_FREQUENCY 55000000
#define SPI_TOUCH_FREQUENCY 2500000
Fetching and parsing aircraft JSON
#include <HTTPClient.h>
#include <ArduinoJson.h>
const char* SERVER = "http://piradio.local:8081/aircraft.json";
struct Aircraft {
char icao[7];
char callsign[10];
int32_t altitude; // feet
int16_t speed; // knots
int16_t heading; // degrees
float lat, lon;
int8_t rssi;
};
int fetchAircraft(Aircraft* list, int maxCount) {
HTTPClient http;
http.begin(SERVER);
int code = http.GET();
if (code != 200) { http.end(); return 0; }
JsonDocument doc;
deserializeJson(doc, http.getStream());
http.end();
JsonArray arr = doc["aircraft"];
int count = 0;
for (JsonObject ac : arr) {
if (count >= maxCount) break;
// skip aircraft with no position
if (!ac.containsKey("lat")) continue;
Aircraft& a = list[count++];
strlcpy(a.icao, ac["hex"] | "", sizeof(a.icao));
strlcpy(a.callsign, ac["flight"] | "?", sizeof(a.callsign));
a.altitude = ac["alt_baro"] | 0;
a.speed = ac["gs"] | 0;
a.heading = ac["track"] | 0;
a.lat = ac["lat"];
a.lon = ac["lon"];
a.rssi = ac["rssi"] | -99;
}
return count;
}
ICAO Country Derivation
ICAO hex addresses are allocated to countries in contiguous blocks — the top bits of the 24-bit address identify the registering nation. There's no runtime lookup table needed; you can derive the country with a series of range checks. Useful for showing a flag or two-letter country code without calling an external API.
const char* icaoToCountry(uint32_t icao) {
// Ranges from ICAO Annex 10 / Mode S allocation blocks
if (icao >= 0x400000 && icao <= 0x43FFFF) return "ZA"; // South Africa
if (icao >= 0x440000 && icao <= 0x447FFF) return "EG"; // Egypt
if (icao >= 0x700000 && icao <= 0x77FFFF) return "CN"; // China
if (icao >= 0x780000 && icao <= 0x7BFFFF) return "AU"; // Australia
if (icao >= 0x7C0000 && icao <= 0x7FFFFF) return "NZ"; // New Zealand
if (icao >= 0xA00000 && icao <= 0xAFFFFF) return "US"; // United States
if (icao >= 0xC00000 && icao <= 0xC3FFFF) return "CA"; // Canada
if (icao >= 0xE00000 && icao <= 0xE3FFFF) return "AR"; // Argentina
// UK allocation
if (icao >= 0x400000 && icao <= 0x43FFFF) return "GB";
// ... (full table covers ~190 countries)
return "??";
}
The UK block runs from 0x400000 to 0x43FFFF, so the
vast majority of traffic overhead resolves to GB without any network
call. Transatlantic traffic from the US (0xA00000–0xAFFFFF) and
European carriers are also easily distinguished.
Display Layout
List view
The 320×240 screen (used in landscape) fits about 9 aircraft rows. Each row shows: callsign, altitude in feet (converted to FL above 18,000 ft), ground speed in knots, and a two-letter country code. A header row shows the column labels and total aircraft count. Touch anywhere on a row to open the detail view. Scroll up/down with top/bottom-third touch zones.
Detail view
Tap an aircraft in the list and the detail screen fills with all available
telemetry: ICAO hex, callsign, registration-derived country, altitude, vertical
rate (climb/descend indicator), ground speed, heading, last-seen age in seconds,
and the RSSI from readsb. Touch anywhere to return to the list.
void drawListView(Aircraft* list, int count, int scrollOffset) {
tft.fillScreen(TFT_BLACK);
// Header bar
tft.fillRect(0, 0, 320, 20, 0x1082); // dark grey
tft.setTextColor(0x7BEF);
tft.drawString("CALLSIGN", 4, 5);
tft.drawString("ALT", 110, 5);
tft.drawString("SPD", 180, 5);
tft.drawString("CC", 250, 5);
// Aircraft rows
int visibleRows = 9;
int rowH = 24;
for (int i = 0; i < visibleRows && (i + scrollOffset) < count; i++) {
Aircraft& a = list[i + scrollOffset];
int y = 22 + i * rowH;
// Alternate row shading
if (i % 2 == 0) tft.fillRect(0, y, 320, rowH, 0x0841);
tft.setTextColor(TFT_WHITE);
tft.drawString(a.callsign, 4, y + 5);
// Altitude: feet or flight level
char altBuf[12];
if (a.altitude > 18000)
snprintf(altBuf, sizeof(altBuf), "FL%d", a.altitude / 100);
else
snprintf(altBuf, sizeof(altBuf), "%dft", a.altitude);
tft.drawString(altBuf, 110, y + 5);
tft.drawString(String(a.speed), 180, y + 5);
// Country code in accent colour
tft.setTextColor(0x07FF); // cyan
tft.drawString(icaoToCountry(strtol(a.icao, nullptr, 16)), 250, y + 5);
}
}
Gotchas & Lessons Learned
Power supply noise
The ESP32's WiFi radio pulls significant current spikes during TX. Running the CYD from a cheap USB charger caused periodic resets when the WiFi was active. A decent quality supply with adequate bulk capacitance fixed this immediately. If the display resets randomly, suspect the PSU before the code.
readsb vs librtlsdr conflict
Building readsb with RTLSDR=yes links against
librtlsdr. If you also have the RTL-SDR Blog vendor driver installed
system-wide (as a .deb from their repo), you may end up with two
conflicting versions of librtlsdr.so. The symptom is readsb
silently failing to open the device. Resolve by ensuring readsb links
against the same library that the kernel driver expects — check with
ldd /usr/local/bin/readsb.
ldd /usr/local/bin/readsb | grep rtlsdr — if this shows a path under
/usr/local/lib but your dongle only works with the system
/usr/lib build (or vice versa), recompile pointing at the right one.
This is the same conflict that bites SDRangel builds.
JSON field absence
Not all fields are present for every aircraft — flight (callsign)
is absent for aircraft that haven't broadcast it, and lat/lon
are absent if no position has been decoded yet. Always check with
containsKey() before accessing, or use ArduinoJson's
| default syntax. Crashing on a null dereference every time a squawk
appears without a position gets old fast.
Screen addressing width
The ILI9341 on the CYD has a column offset quirk on certain revisions — drawing to x=319 wraps back to the left edge rather than clipping. Keep all drawing calls within x=0–318 to avoid the last-pixel wrap artefact.
Results
Running reliably as a permanent fixture. In good conditions (high pressure, dry air) the discone picks up aircraft at over 200 nm. Transatlantic traffic on the oceanic tracks occasionally appears at altitude. The list typically shows 10–30 aircraft depending on time of day.
readsb coming back up.
The companion radiosonde display runs on a separate CYD at the same time — both polling the same Pi 5 on different ports. They coexist without issue. Next step is probably combining both into a tabbed interface on a single display, but for now two screens is fine.