ADS-B Aircraft Display on ESP32 CYD.

A live aircraft tracking display built on the "Cheap Yellow Display" ESP32 board. A readsb decoder runs on a Raspberry Pi 5 listening on 1090 MHz, serving JSON over HTTP. The CYD polls it over WiFi and renders a scrollable aircraft list with ICAO-derived country codes, all local.

Started late 2025 Status Complete Hardware ESP32-2432S028, Raspberry Pi 5, discone antenna ADS-B ESP32 RTL-SDR 1090 MHz readsb

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.

ADS-B (Automatic Dependent Surveillance–Broadcast) is an unencrypted 1090 MHz transponder signal broadcast by most commercial and general aviation aircraft. It carries ICAO address, callsign, position, altitude, speed, and heading.

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.

The ILI9341 on the CYD requires the 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.

BASH set dongle serial numbers
# 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.

BASH build readsb from source
# 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.

PYTHON adsb_server.py
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()
INI /etc/systemd/system/adsb-server.service
[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
BASH
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:

C User_Setup.h (TFT_eSPI)
// 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

C++ fetch.cpp (simplified)
#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.

C++ icao_country.cpp (partial)
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.

C++ draw_list.cpp (simplified)
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.

Stable, fully offline, no subscriptions. Power cycle the Pi and the display reconnects and resumes within about 30 seconds of 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.