Community project

ESP32 Plane Radar Display

Photo of ESP32 Plane Radar Display
Generated with AI

Robin Birtles

Published November 4, 2026

This project turns an ESP32 microcontroller into a live aircraft radar display using ADS-B aircraft tracking data. A 2.1-inch round TFT screen plots nearby planes in real time, with the BOOT button cycling through radar ranges of 5, 10, 15, and 25 kilometers. The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions to get the radar operational in minutes.

The firmware handles Wi-Fi setup through a captive portal on first boot, stores location and credentials in non-volatile memory, and continuously fetches aircraft positions from public ADS-B data sources. Builders will receive the full source code, pin assignments, and configuration details needed to customize the display and integrate it into their own aviation tracking setup.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

2.1 inch GC9B72 round SPI TFT display

A 360 by 360 pixel round color screen that draws the live aircraft radar.

1

Female-to-female jumper wires

Short 2.54 mm jumper leads for wiring the ESP32-C3 board to the GC9A01 display.

1

USB-C data cable

USB-C cable that carries data for flashing and serial monitoring, not charge-only.

Assemble it in 7 steps

1. Place the board and round screen

Set the ESP32-C3 Super Mini and the 2.1 inch GC9B72 round display on the bench with their printed pin labels facing up. Do not plug in USB power while adding the jumper wires.

  • Keep the jumper wires short and do not let bare metal ends touch each other.

2. Connect display power

Connect display VCC to ESP32-C3 3V3 (power), then connect display GND to an ESP32-C3 GND pin (ground).

  • Do not connect the display VCC wire to 5V; swapped or excessive power can damage the display.

3. Connect the display signal wires

Connect display RST to GPIO0 (reset), CS to GPIO1 (display select), DC to GPIO10 (command/data signal), SDA to GPIO3 (display data), and SCL to GPIO4 (display clock).

  • On this display, SDA means MOSI and SCL means SCLK; they are not I2C wires.
  • If the picture flickers or is scrambled, shorten these five signal wires and recheck each printed display label.

4. Use the BOOT button for radar range

Leave the board's built-in BOOT button connected to GPIO9 (range control). A quick press changes the radar range between 5, 10, 15, and 25 km; holding it for 3 seconds erases saved Wi-Fi and location so setup starts again.

  • Do not hold BOOT while starting a normal reset unless you intend to enter the board's download mode.

5. Plug in USB and flash

Connect the ESP32-C3 Super Mini to your computer with the USB-C data cable. In Schematik, press Deploy to compile and flash the radar. If flashing does not begin, hold BOOT while you press Deploy, then release BOOT when flashing starts.

  • A charge-only USB cable will power the board but cannot flash it; use a cable that carries data.

6. Enter Wi-Fi and radar location

After the first flash, join the Wi-Fi network named PlaneRadar-Setup on a phone or computer and open http://192.168.4.1. Enter your Wi-Fi name, password, latitude, and longitude; the two coordinates are the centre point from which aircraft distance and direction are calculated. Save the form and the radar restarts.

  • You can get coordinates from a map by selecting your location; use decimal values such as 52.3676 and 4.9041.
  • If a digit is wrong, hold BOOT for 3 seconds and repeat this step.

7. Check the radar screen

The 360 by 360 radar screen redraws when fresh aircraft data arrives, about every five seconds. If it stays black, first check VCC to 3V3 and GND to GND, then check RST GPIO0, CS GPIO1, DC GPIO10, SDA GPIO3, and SCL GPIO4 in that order. If rings appear but no aircraft do, confirm Wi-Fi and coordinates, then choose a wider range with a short BOOT press.

  • The project uses public ADS-B data from opendata.adsb.fi. Sparse air traffic near the selected coordinates can result in no dots.
  • The reference project is https://github.com/MatixYo/ESP32-Plane-Radar.

Review all connections

1. Connections between "round-display" and "ESP32"

Functionround-displayESP32
powerVCC3V3
groundGNDGND
digitalRSTGPIO 0
spiCSGPIO 1
digitalDCGPIO 10
spiSDAGPIO 3
spiSCLGPIO 4

Deploy the firmware

schematik_esp32.inoOpen in Schematik
/*
 * ESP32 Plane Radar
 * Plots live ADS-B aircraft on a GC9B72 2.1-inch 360×360 round display.
 * First-boot softAP portal (192.168.4.1) stores Wi-Fi + location in NVS.
 * BOOT button (GPIO9) cycles radar range 5 → 10 → 15 → 25 km.
 * Hold BOOT for 3 s to clear saved credentials and relaunch the portal.
 *
 * Inspired by MatixYo's ESP32-Plane-Radar: https://github.com/MatixYo/ESP32-Plane-Radar
 */

#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 <math.h>

// ── Pin assignments ──────────────────────────────────────────────────────────
#define RADAR_RST   0
#define RADAR_CS    1
#define RADAR_DC    10
#define RADAR_MOSI  3
#define RADAR_SCLK  4
#define BOOT_BUTTON 9

// ── Radar settings ───────────────────────────────────────────────────────────
#define FETCH_INTERVAL_MS 5000
#define HOLD_CLEAR_MS     3000      // hold BOOT this long to wipe credentials
#define AP_SSID           "PlaneRadar-Setup"
#define NVS_NAMESPACE     "planeradar"
#define DISPLAY_SIZE      360
#define DISPLAY_CENTER    180
#define DISPLAY_RADIUS    160       // outermost radar ring

// ── Math helpers ─────────────────────────────────────────────────────────────
static const double DEG2RAD = M_PI / 180.0;

// Returns great-circle distance in kilometres between two lat/lon points.
double haversineKm(double lat1, double lon1, double lat2, double lon2) {
  double dlat = (lat2 - lat1) * DEG2RAD;
  double dlon = (lon2 - lon1) * DEG2RAD;
  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));
}

// Returns initial bearing in degrees (0 = north, clockwise) from p1 to p2.
double initialBearing(double lat1, double lon1, double lat2, double lon2) {
  double dlon = (lon2 - lon1) * DEG2RAD;
  double y = sin(dlon) * cos(lat2 * DEG2RAD);
  double x = cos(lat1 * DEG2RAD) * sin(lat2 * DEG2RAD)
           - sin(lat1 * DEG2RAD) * cos(lat2 * DEG2RAD) * cos(dlon);
  double bearing = atan2(y, x) / DEG2RAD;
  return fmod(bearing + 360.0, 360.0);
}

// ── LovyanGFX display class ──────────────────────────────────────────────────
class RadarDisplay : public lgfx::LGFX_Device {
  lgfx::Bus_SPI   _bus;
  lgfx::Panel_GC9B72 _panel;
public:
  RadarDisplay() {
    auto busCfg = _bus.config();
    busCfg.spi_host    = SPI2_HOST;
    busCfg.freq_write  = 40000000;
    busCfg.pin_sclk    = RADAR_SCLK;
    busCfg.pin_mosi    = RADAR_MOSI;
    busCfg.pin_miso    = -1;
    busCfg.pin_dc      = RADAR_DC;
    _bus.config(busCfg);
    _panel.setBus(&_bus);

    auto panelCfg = _panel.config();
    panelCfg.pin_cs    = RADAR_CS;
    panelCfg.pin_rst   = RADAR_RST;
    panelCfg.invert    = true;
    panelCfg.rgb_order = true;
    _panel.config(panelCfg);
    setPanel(&_panel);
  }
};

// ── Globals ──────────────────────────────────────────────────────────────────
RadarDisplay display;
Preferences  prefs;
WebServer    configServer(80);

float  radarRangeKm = 10.0f;
double radarLat     = 0.0;
double radarLon     = 0.0;
bool   configMode   = false;

unsigned long lastFetchMs   = 0;
unsigned long bootPressedAt = 0;
bool          bootWasLow    = false;

// ── Forward declarations ─────────────────────────────────────────────────────
void drawRadarShell();
void drawAircraft(float bearingDeg, float distanceKm, const char* label);
void fetchAircraft();
void startConfigPortal();
void handleConfigRoot();
void handleConfigSave();
void showMessage(const char* line1, const char* line2 = nullptr);

// ── Display helpers ──────────────────────────────────────────────────────────
void drawRadarShell() {
  display.fillScreen(TFT_BLACK);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, DISPLAY_RADIUS, TFT_GREEN);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, 120, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, 80, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, 40, TFT_DARKGREEN);
  display.drawLine(20, DISPLAY_CENTER, 340, DISPLAY_CENTER, TFT_DARKGREEN);
  display.drawLine(DISPLAY_CENTER, 20, DISPLAY_CENTER, 340, TFT_DARKGREEN);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString("N", 176, 12);
  display.drawString("S", 176, 340);
  display.drawString("W", 10, 176);
  display.drawString("E", 340, 176);
  // Range label bottom-right
  char rangeBuf[8];
  snprintf(rangeBuf, sizeof(rangeBuf), "%dkm", (int)radarRangeKm);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString(rangeBuf, 300, 334);
}

void drawAircraft(float bearingDeg, float distanceKm, const char* label) {
  if (distanceKm > radarRangeKm) return;
  // bearingDeg is 0=north, rotate so north = up (subtract 90° for screen coords)
  float angle  = (bearingDeg - 90.0f) * DEG_TO_RAD;
  float radius = (distanceKm / radarRangeKm) * (float)DISPLAY_RADIUS;
  int   px     = DISPLAY_CENTER + (int)(cosf(angle) * radius);
  int   py     = DISPLAY_CENTER + (int)(sinf(angle) * radius);
  // Small filled triangle pointing north
  display.fillTriangle(px, py - 5, px - 4, py + 4, px + 4, py + 4, TFT_RED);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString(label,
                     constrain(px + 6, 0, 300),
                     constrain(py - 8, 0, 350));
}

void showMessage(const char* line1, const char* line2) {
  display.fillScreen(TFT_BLACK);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString(line1, 40, 170);
  if (line2) display.drawString(line2, 40, 190);
}

// ── Config portal ────────────────────────────────────────────────────────────
// Small HTML form served over softAP — stores SSID/password/lat/lon in NVS.
// This replaces WiFiManager to keep binary size under the 1 280 KB app partition.
static const char CONFIG_HTML[] PROGMEM = R"rawhtml(
<!DOCTYPE html><html><head>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>PlaneRadar Setup</title>
<style>body{font-family:sans-serif;max-width:380px;margin:40px auto;padding:0 16px}
input{width:100%;box-sizing:border-box;padding:8px;margin:6px 0 14px;font-size:15px}
button{width:100%;padding:10px;background:#27ae60;color:#fff;border:none;font-size:16px;cursor:pointer}
h2{margin-bottom:4px}p{color:#555;font-size:13px;margin-top:0}</style>
</head><body>
<h2>PlaneRadar Setup</h2>
<p>Enter your Wi-Fi details and the coordinates for the radar centre.</p>
<form method='POST' action='/save'>
<label>Wi-Fi Name (SSID)</label><input name='ssid' required>
<label>Wi-Fi Password</label><input name='pass' type='password'>
<label>Latitude (e.g. 52.3676)</label><input name='lat' required placeholder='52.3676'>
<label>Longitude (e.g. 4.9041)</label><input name='lon' required placeholder='4.9041'>
<button type='submit'>Save &amp; Connect</button>
</form>
</body></html>
)rawhtml";

static const char SAVED_HTML[] PROGMEM = R"rawhtml(
<!DOCTYPE html><html><head>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Saved</title>
<style>body{font-family:sans-serif;max-width:380px;margin:40px auto;padding:0 16px}</style>
</head><body>
<h2>Saved!</h2>
<p>The radar is connecting to your Wi-Fi. You can close this page.</p>
</body></html>
)rawhtml";

void handleConfigRoot() {
  configServer.send(200, "text/html", CONFIG_HTML);
}

void handleConfigSave() {
  String ssid = configServer.arg("ssid");
  String pass = configServer.arg("pass");
  String lat  = configServer.arg("lat");
  String lon  = configServer.arg("lon");

  prefs.begin(NVS_NAMESPACE, false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.putDouble("lat",  lat.toDouble());
  prefs.putDouble("lon",  lon.toDouble());
  prefs.end();

  configServer.send(200, "text/html", SAVED_HTML);
  delay(1500);
  ESP.restart();
}

void startConfigPortal() {
  configMode = true;
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID);
  Serial.print("Config portal at: ");
  Serial.println(WiFi.softAPIP());

  configServer.on("/",     HTTP_GET,  handleConfigRoot);
  configServer.on("/save", HTTP_POST, handleConfigSave);
  configServer.begin();

  showMessage("PlaneRadar-Setup", "192.168.4.1");
}

// ── Aircraft fetch ────────────────────────────────────────────────────────────
void fetchAircraft() {
  if (WiFi.status() != WL_CONNECTED) return;

  // opendata.adsb.fi: dist is nautical miles; convert km → nm (1 nm = 1.852 km)
  // adsb.fi is HTTPS-only (plain HTTP gets a 301 the ESP32 HTTPClient will
  // not follow). setInsecure() skips certificate validation: this is public,
  // read-only flight data, and the C3's 1.25 MB app partition has no room
  // for a CA bundle on top of the TLS stack.
  float nm = radarRangeKm / 1.852f;
  WiFiClientSecure client;
  client.setInsecure();
  String url = "https://opendata.adsb.fi/api/v3/lat/"
             + String(radarLat, 5)
             + "/lon/"
             + String(radarLon, 5)
             + "/dist/"
             + String(nm, 1);

  HTTPClient http;
  http.begin(client, url);
  http.setTimeout(4000);
  int code = http.GET();

  if (code == HTTP_CODE_OK) {
    // Parse JSON — static allocation avoids heap fragmentation
    JsonDocument doc;
    DeserializationError err = deserializeJson(doc, http.getStream());
    if (err) {
      Serial.printf("JSON error: %s\n", err.c_str());
      http.end();
      return;
    }

    drawRadarShell();
    int plotted = 0;

    for (JsonObject plane : doc["ac"].as<JsonArray>()) {
      if (!plane["lat"].is<double>() || !plane["lon"].is<double>()) continue;

      double acLat = plane["lat"].as<double>();
      double acLon = plane["lon"].as<double>();

      double distKm   = haversineKm(radarLat, radarLon, acLat, acLon);
      double bearDeg  = initialBearing(radarLat, radarLon, acLat, acLon);

      if (distKm > radarRangeKm) continue;   // outside current range

      // Label preference: flight callsign → ICAO hex → fallback "AC"
      const char* label = plane["flight"] | plane["hex"] | "AC";

      drawAircraft((float)bearDeg, (float)distKm, label);
      plotted++;
    }

    Serial.printf("Plotted %d aircraft within %.0f km\n", plotted, radarRangeKm);
  } else {
    Serial.printf("HTTP error: %d\n", code);
  }
  http.end();
}

// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  pinMode(BOOT_BUTTON, INPUT_PULLUP);

  display.init();
  display.setRotation(0);
  display.setTextSize(1);
  drawRadarShell();

  // Load saved credentials from NVS
  prefs.begin(NVS_NAMESPACE, true);
  String savedSsid = prefs.getString("ssid", "");
  String savedPass = prefs.getString("pass", "");
  radarLat         = prefs.getDouble("lat", 0.0);
  radarLon         = prefs.getDouble("lon", 0.0);
  prefs.end();

  if (savedSsid.length() == 0) {
    Serial.println("No credentials — starting config portal");
    startConfigPortal();
    return;
  }

  showMessage("Connecting to WiFi...", savedSsid.c_str());
  WiFi.mode(WIFI_STA);
  WiFi.begin(savedSsid.c_str(), savedPass.c_str());

  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
    delay(250);
  }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Wi-Fi failed — starting config portal");
    startConfigPortal();
    return;
  }

  Serial.printf("Connected. IP: %s  Lat: %.4f  Lon: %.4f\n",
                WiFi.localIP().toString().c_str(), radarLat, radarLon);
  drawRadarShell();
}

// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
  if (configMode) {
    configServer.handleClient();
    delay(1);
    return;
  }

  // BOOT button: short press cycles range; hold 3 s clears credentials
  bool bootLow = (digitalRead(BOOT_BUTTON) == LOW);

  if (bootLow && !bootWasLow) {
    bootPressedAt = millis();
    bootWasLow = true;
  }

  if (!bootLow && bootWasLow) {
    unsigned long held = millis() - bootPressedAt;
    bootWasLow = false;

    if (held >= HOLD_CLEAR_MS) {
      // Long press: wipe saved credentials and reboot into portal
      Serial.println("Long press — clearing credentials");
      prefs.begin(NVS_NAMESPACE, false);
      prefs.clear();
      prefs.end();
      delay(200);
      ESP.restart();
    } else {
      // Short press: cycle range
      if      (radarRangeKm ==  5) radarRangeKm = 10;
      else if (radarRangeKm == 10) radarRangeKm = 15;
      else if (radarRangeKm == 15) radarRangeKm = 25;
      else                         radarRangeKm =  5;
      Serial.printf("Range → %.0f km\n", radarRangeKm);
      drawRadarShell();
    }
  }

  // Fetch every FETCH_INTERVAL_MS
  if (millis() - lastFetchMs >= FETCH_INTERVAL_MS) {
    lastFetchMs = millis();
    fetchAircraft();
  }
  delay(1);
}

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