Community project

ESP32 Plane Radar Display

ESP32
Photo of ESP32 Plane Radar Display
Generated with AI

cdmfordboy

Published September 25, 2026

This project turns an ESP32 with a built-in display into a live plane radar that shows aircraft in the sky above a given location. The radar pulls real-time ADS-B aircraft data from public aviation feeds and displays each plane's bearing, distance, and callsign on the screen.

The guide includes a wiring diagram, complete parts list, and step-by-step assembly instructions. After flashing the firmware via USB, the device opens a Wi-Fi setup portal where you enter your location coordinates. Once configured, the radar updates every few seconds and lets you touch the screen to zoom in and out—troubleshooting tips cover common issues like blank displays or missing aircraft data.

Wiring diagram

Gather all the parts

QtyComponent
1

USB cable - USB A to Micro-B

USB-A to Micro-B

Standard USB 2.0 Type-A to Micro-B cable for connecting a PC to microcontrollers and single-board computers such as Metro, Feather, or Raspberry Pi.

Assemble it in 6 steps

1. Use the built-in screen

This Cheap Yellow Display already contains the 240 by 320 colour screen and its touch layer. Do not connect the separate round GC9A01 display or any of its old jumper wires; the plane radar now uses the screen already fitted to the yellow board.

  • Leave the screen-side pins alone: they are already connected inside the board.
  • Connecting another display to the board’s built-in screen pins can make either screen stop working.

2. Plug in the Micro-USB data cable

Plug the Micro-B end of the USB data cable into the Cheap Yellow Display, then plug the other end into your computer. This one cable provides power and lets Schematik Deploy put the firmware on the board.

  • If the board does not appear for deployment, use a cable that transfers data; some USB cables provide power only.
  • Do not force the Micro-USB plug upside down because it can damage the socket.

3. Flash the radar

In Schematik, open the Deploy panel and press Deploy. Keep the board connected until the process finishes; the screen will restart into the radar setup page.

  • The display backlight should turn on when the board is powered.
  • Do not unplug the USB cable while firmware is being written because the board may be left without a complete program.

4. Enter Wi-Fi and your location

On a phone or computer, join the temporary Wi-Fi network named PlaneRadar-Setup. Open http://192.168.4.1, then enter your usual Wi-Fi name, password, latitude, and longitude. Latitude and longitude are the two map numbers for the point you want at the centre of the radar; for example, Amsterdam is about 52.3676 and 4.9041.

  • In Google Maps, right-click or press-and-hold your location to copy its two coordinates.
  • After saving, the radar restarts itself and joins your normal Wi-Fi network.
  • Use a 2.4 GHz Wi-Fi network: this board cannot join a 5 GHz-only network.

5. Use the live radar

The radar fetches public aircraft positions from opendata.adsb.fi about every five seconds. Touch RANGE in the lower-right area to cycle 5, 10, 15, and 25 km. Touch SETUP to erase the saved Wi-Fi and location, then the board restarts so you can enter them again.

  • The red triangles are aircraft; their position shows direction and distance from the coordinates you entered.
  • The design is based on MatixYo’s public ESP32 Plane Radar project: https://github.com/MatixYo/ESP32-Plane-Radar
  • Aircraft may not appear if there are none within the selected distance, or if the location coordinates are wrong.

6. Fix a blank screen or missing aircraft

If the screen is black, first make sure the board has USB power and redeploy with Schematik Deploy. If the screen shows the setup name, join PlaneRadar-Setup and enter the details again. If the radar screen appears but is empty, touch RANGE until 25 km is shown and check that the entered latitude and longitude are for your actual location.

  • A data connection failure on screen usually means the saved Wi-Fi password, Wi-Fi signal, or internet connection needs attention.
  • The board needs internet access because the aircraft positions come from an online public feed.
  • Do not add the old GC9A01 jumper wiring to troubleshoot this version; this CYD project uses its built-in display instead.

Review all connections

1. Connections between "usb-micro-data-cable" and "ESP32"

Functionusb-micro-data-cableESP32
powerVBUS5V
groundGNDGND
dataD+ / D- → Cheap Yellow Display Micro-USB data connectionEXT

Deploy the firmware

/*
 * Cheap Yellow Display Plane Radar
 * Live ADS-B data: https://opendata.adsb.fi/api/v3/
 * Inspired by MatixYo's ESP32 Plane Radar:
 * https://github.com/MatixYo/ESP32-Plane-Radar
 *
 * The first boot opens Wi-Fi network PlaneRadar-Setup. Join it, browse to
 * http://192.168.4.1, and enter Wi-Fi plus the radar latitude/longitude.
 * Touch the left bottom button to change range. Touch the right bottom button
 * to erase saved settings and reopen the setup portal.
 */
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <LovyanGFX.hpp>
#include <XPT2046_Touchscreen.h>
#include <math.h>

#define TFT_SCLK 14
#define TFT_MOSI 13
#define TFT_MISO 12
#define TFT_CS   15
#define TFT_DC   2
#define TFT_BL   21
#define TOUCH_SCLK 25
#define TOUCH_MOSI 32
#define TOUCH_MISO 39
#define TOUCH_CS   33
#define TOUCH_IRQ  36


// Forward declarations
double haversineKm(double lat1, double lon1, double lat2, double lon2);
double initialBearing(double lat1, double lon1, double lat2, double lon2);
void button(int x, int y, int w, const char *label, uint16_t color);
void drawRadarShell(const char *status);
void drawAircraft(float bearing, float distance, const char *label);
void showMessage(const char *first, const char *second);
void startPortal();
void fetchAircraft();
void cycleRange();
void handleTouch();

static constexpr uint32_t FETCH_INTERVAL_MS = 5000;
static constexpr char AP_SSID[] = "PlaneRadar-Setup";
static constexpr char NVS_NAMESPACE[] = "planeradar";
static constexpr int SCREEN_W = 320;
static constexpr int SCREEN_H = 240;
static constexpr int RADAR_X = 120;
static constexpr int RADAR_Y = 120;
static constexpr int RADAR_R = 100;

class CydDisplay : public lgfx::LGFX_Device {
  lgfx::Bus_SPI bus_;
  lgfx::Panel_ILI9341 panel_;
 public:
  CydDisplay() {
    auto busConfig = bus_.config();
    busConfig.spi_host = HSPI_HOST;
    busConfig.spi_mode = 0;
    busConfig.freq_write = 40000000;
    busConfig.freq_read = 16000000;
    busConfig.pin_sclk = TFT_SCLK;
    busConfig.pin_mosi = TFT_MOSI;
    busConfig.pin_miso = TFT_MISO;
    busConfig.pin_dc = TFT_DC;
    bus_.config(busConfig);
    panel_.setBus(&bus_);
    auto panelConfig = panel_.config();
    panelConfig.pin_cs = TFT_CS;
    panelConfig.pin_rst = -1;
    panelConfig.panel_width = 240;
    panelConfig.panel_height = 320;
    panelConfig.offset_x = 0;
    panelConfig.offset_y = 0;
    panelConfig.invert = false;
    panelConfig.rgb_order = false;
    panel_.config(panelConfig);
    setPanel(&panel_);
  }
};

CydDisplay display;
SPIClass touchSPI(VSPI);
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);
Preferences prefs;
WebServer portal(80);

bool configMode = false;
double radarLat = 0.0;
double radarLon = 0.0;
float radarRangeKm = 10.0f;
uint32_t lastFetchMs = 0;
uint32_t lastTouchMs = 0;

static const double DEG2RAD = M_PI / 180.0;

double haversineKm(double lat1, double lon1, double lat2, double lon2) {
  const double dLat = (lat2 - lat1) * DEG2RAD;
  const double dLon = (lon2 - lon1) * DEG2RAD;
  const double a = sin(dLat / 2) * sin(dLat / 2) +
                   cos(lat1 * DEG2RAD) * cos(lat2 * DEG2RAD) *
                   sin(dLon / 2) * sin(dLon / 2);
  return 6371.0 * 2.0 * atan2(sqrt(a), sqrt(1.0 - a));
}

double initialBearing(double lat1, double lon1, double lat2, double lon2) {
  const double dLon = (lon2 - lon1) * DEG2RAD;
  const double y = sin(dLon) * cos(lat2 * DEG2RAD);
  const double x = cos(lat1 * DEG2RAD) * sin(lat2 * DEG2RAD) -
                   sin(lat1 * DEG2RAD) * cos(lat2 * DEG2RAD) * cos(dLon);
  return fmod(atan2(y, x) / DEG2RAD + 360.0, 360.0);
}

void button(int x, int y, int w, const char *label, uint16_t color) {
  display.fillRoundRect(x, y, w, 28, 5, color);
  display.setTextColor(TFT_BLACK, color);
  display.setTextDatum(textdatum_t::middle_center);
  display.drawString(label, x + w / 2, y + 14, 2);
}

void drawRadarShell(const char *status = "ADS-B radar") {
  display.fillScreen(TFT_BLACK);
  display.fillRect(0, 0, SCREEN_W, 22, TFT_NAVY);
  display.setTextDatum(textdatum_t::middle_left);
  display.setTextColor(TFT_WHITE, TFT_NAVY);
  display.drawString(status, 7, 11, 2);
  for (int r = 25; r <= RADAR_R; r += 25) display.drawCircle(RADAR_X, RADAR_Y, r, TFT_DARKGREEN);
  display.drawCircle(RADAR_X, RADAR_Y, RADAR_R, TFT_GREEN);
  display.drawLine(RADAR_X - RADAR_R, RADAR_Y, RADAR_X + RADAR_R, RADAR_Y, TFT_DARKGREEN);
  display.drawLine(RADAR_X, RADAR_Y - RADAR_R, RADAR_X, RADAR_Y + RADAR_R, TFT_DARKGREEN);
  display.setTextDatum(textdatum_t::middle_center);
  display.setTextColor(TFT_GREEN, TFT_BLACK);
  display.drawString("N", RADAR_X, 27, 2);
  display.drawString("S", RADAR_X, 213, 2);
  display.drawString("W", 25, RADAR_Y, 2);
  display.drawString("E", 215, RADAR_Y, 2);
  char rangeText[20];
  snprintf(rangeText, sizeof(rangeText), "%.0f km", radarRangeKm);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString(rangeText, 275, 54, 2);
  button(226, 154, 88, "RANGE", TFT_YELLOW);
  button(226, 190, 88, "SETUP", TFT_CYAN);
}

void drawAircraft(float bearing, float distance, const char *label) {
  if (distance > radarRangeKm) return;
  const float angle = (bearing - 90.0f) * DEG_TO_RAD;
  const float radius = distance / radarRangeKm * RADAR_R;
  const int x = RADAR_X + (int)(cosf(angle) * radius);
  const int y = RADAR_Y + (int)(sinf(angle) * radius);
  display.fillTriangle(x, y - 5, x - 4, y + 4, x + 4, y + 4, TFT_RED);
  display.setTextDatum(textdatum_t::top_left);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.drawString(label, constrain(x + 5, 0, 175), constrain(y - 10, 23, 215), 1);
}

void showMessage(const char *first, const char *second = "") {
  display.fillScreen(TFT_BLACK);
  display.setTextDatum(textdatum_t::middle_center);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString(first, 160, 102, 2);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.drawString(second, 160, 134, 2);
}

const char CONFIG_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Plane Radar setup</title><style>body{font-family:Arial;max-width:420px;margin:35px auto;padding:0 18px}input,button{box-sizing:border-box;width:100%;padding:11px;margin:5px 0 15px;font-size:16px}button{background:#087f23;color:white;border:0;border-radius:5px}</style></head><body><h2>Plane Radar setup</h2><p>Enter your home Wi-Fi and the place at the center of the radar.</p><form method="post" action="/save"><label>Wi-Fi name</label><input name="ssid" required><label>Wi-Fi password</label><input name="pass" type="password"><label>Latitude</label><input name="lat" placeholder="52.3676" required><label>Longitude</label><input name="lon" placeholder="4.9041" required><button>Save and connect</button></form></body></html>
)HTML";

void startPortal() {
  configMode = true;
  WiFi.disconnect(true, true);
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID);
  portal.on("/", HTTP_GET, []() { portal.send(200, "text/html", CONFIG_HTML); });
  portal.on("/save", HTTP_POST, []() {
    prefs.begin(NVS_NAMESPACE, false);
    prefs.putString("ssid", portal.arg("ssid"));
    prefs.putString("pass", portal.arg("pass"));
    prefs.putDouble("lat", portal.arg("lat").toDouble());
    prefs.putDouble("lon", portal.arg("lon").toDouble());
    prefs.end();
    portal.send(200, "text/html", "<h2>Saved.</h2><p>The radar will restart now.</p>");
    delay(800);
    ESP.restart();
  });
  portal.begin();
  showMessage("PlaneRadar-Setup", "Open 192.168.4.1");
}

void fetchAircraft() {
  if (WiFi.status() != WL_CONNECTED) return;
  const float nauticalMiles = radarRangeKm / 1.852f;
  String url = "https://opendata.adsb.fi/api/v3/lat/" + String(radarLat, 5) +
               "/lon/" + String(radarLon, 5) + "/dist/" + String(nauticalMiles, 1);
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(6000);
  if (!http.begin(client, url) || http.GET() != HTTP_CODE_OK) {
    http.end();
    drawRadarShell("Data connection failed");
    return;
  }
  JsonDocument filter;
  filter["ac"][0]["lat"] = true;
  filter["ac"][0]["lon"] = true;
  filter["ac"][0]["flight"] = true;
  filter["ac"][0]["hex"] = true;
  JsonDocument doc;
  if (deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter))) {
    http.end();
    drawRadarShell("Data read failed");
    return;
  }
  http.end();
  drawRadarShell("Live aircraft");
  for (JsonObject aircraft : doc["ac"].as<JsonArray>()) {
    if (aircraft["lat"].isNull() || aircraft["lon"].isNull()) continue;
    const double lat = aircraft["lat"].as<double>();
    const double lon = aircraft["lon"].as<double>();
    const char *label = aircraft["flight"] | aircraft["hex"] | "AC";
    drawAircraft(initialBearing(radarLat, radarLon, lat, lon), haversineKm(radarLat, radarLon, lat, lon), label);
  }
}

void cycleRange() {
  if (radarRangeKm == 5) radarRangeKm = 10;
  else if (radarRangeKm == 10) radarRangeKm = 15;
  else if (radarRangeKm == 15) radarRangeKm = 25;
  else radarRangeKm = 5;
  drawRadarShell("Range changed");
  lastFetchMs = 0;
}

void handleTouch() {
  if (!touch.touched() || millis() - lastTouchMs < 350) return;
  TS_Point point = touch.getPoint();
  lastTouchMs = millis();
  // CYD touch mapping in landscape: raw X maps to screen Y; raw Y maps to screen X.
  const int x = map(point.y, 200, 3800, 0, SCREEN_W);
  const int y = map(point.x, 200, 3800, SCREEN_H, 0);
  if (x >= 220 && y >= 145 && y < 186) cycleRange();
  if (x >= 220 && y >= 186) {
    prefs.begin(NVS_NAMESPACE, false);
    prefs.clear();
    prefs.end();
    ESP.restart();
  }
}

void setup() {
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);
  display.init();
  display.setRotation(1);
  touchSPI.begin(TOUCH_SCLK, TOUCH_MISO, TOUCH_MOSI, TOUCH_CS);
  touch.begin(touchSPI);
  prefs.begin(NVS_NAMESPACE, true);
  const String ssid = prefs.getString("ssid", "");
  const String password = prefs.getString("pass", "");
  radarLat = prefs.getDouble("lat", 0.0);
  radarLon = prefs.getDouble("lon", 0.0);
  prefs.end();
  if (ssid.isEmpty()) { startPortal(); return; }
  showMessage("Connecting to Wi-Fi", ssid.c_str());
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid.c_str(), password.c_str());
  const uint32_t started = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) delay(250);
  if (WiFi.status() != WL_CONNECTED) { startPortal(); return; }
  drawRadarShell("Connected - loading data");
  lastFetchMs = 0;
}

void loop() {
  if (configMode) { portal.handleClient(); return; }
  handleTouch();
  if (millis() - lastFetchMs >= FETCH_INTERVAL_MS) {
    lastFetchMs = millis();
    fetchAircraft();
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik