Community project

ESP32 ADS-B Plane Radar

Photo of ESP32 ADS-B Plane Radar
Generated with AI

mate collia (mate)

Published November 7, 2026

Build a live aircraft radar on a compact 1.28-inch round display powered by an ESP32. This project fetches real-time ADS-B aircraft data from a public API and plots planes around your location on a circular radar view, with adjustable range from 5 to 25 kilometers via the BOOT button.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions. Flash the included firmware through Schematik Deploy, configure Wi-Fi and your radar location through a setup portal on first boot, then optionally add a NEO-6M GPS module for automatic position updates. Test the live radar display and verify API connectivity before final assembly.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

1.28 inch GC9A01 round TFT display

240 by 240 SPI round display used for the radar screen.

1

NEO-6M GPS Module

u-blox NEO-6M based GPS receiver module. Outputs NMEA sentences (GGA, RMC, etc.) over UART at 9600 baud by default. Provides latitude, longitude, altitude, speed, and time. The NEO-6M module itself is a 3.3V-class device; many GY-NEO6MV2 breakout boards accept 5V on their VCC header through an onboard regulator, but UART I/O remains 3.3V-domain and must not be driven above 3.6V. Features an on-board patch antenna footprint and an SMA/IPEX connector for external active antenna (preferred for faster lock acquisition). Supply current ~45 mA in acquisition, ~11 mA in tracking.

Assemble it in 8 steps

1. Wire the GC9A01 display — power and ground

Connect the display VCC pin to the ESP32-C3 Super Mini 3V3 rail and GND to GND. The GC9A01 runs on 3.3 V; do not connect VCC to 5 V.

  • Do not power the display from the 5 V pin — it will damage the display controller.

2. Wire the GC9A01 display — SPI and control signals

Connect the display SDA (MOSI) to GPIO3, SCL (SCLK) to GPIO4, CS to GPIO1, DC to GPIO10, and RST to GPIO0. Use short female-to-female jumper wires to keep the SPI signals clean.

  • Keep jumper leads as short as practical — long SPI wires at 40 MHz can cause display glitches.
  • Double-check the display label: SDA = MOSI, SCL = SCLK on most GC9A01 breakouts.

3. Connect the USB-C data cable

Plug a USB-C data cable (not a charge-only cable) into the ESP32-C3 Super Mini and your computer. Chrome and Edge support Web Serial; Firefox does not.

  • If your cable doesn't show up as a serial port, try a different cable — many USB-C cables carry power only.

4. Flash the firmware with Schematik Deploy

Open the Schematik Deploy panel and click Deploy. Your browser will ask you to choose the board's serial port. If the upload doesn't start, hold the BOOT button on the ESP32-C3 while clicking Deploy, then release it once flashing begins.

  • The ESP32-C3 Super Mini may need BOOT held during the first connection on some host machines.

5. Configure Wi-Fi and radar location

After flashing, the display shows 'PlaneRadar-Setup'. On your phone or computer, join the Wi-Fi network called PlaneRadar-Setup using password RadarSetup24, then open http://192.168.4.1 in a browser. Enter your Wi-Fi name, password, and official Flightradar24 API token. A separate paid API plan is required; a normal Flightradar24 app subscription is not the API plan. GPS supplies latitude and longitude automatically. Tap Save — the device restarts and connects to your network.

  • The API token is stored on the board. Never share it in chat or a public repository.
  • If you make a mistake, hold the BOOT button for 3 seconds at any time to clear credentials and relaunch the portal.

6. Test the live radar

Once connected, the radar rings appear and aircraft start plotting every minute. Each blip shows the callsign or ICAO hex code. Press the BOOT button briefly to cycle the range through 5, 10, 15, and 25 km. Aircraft are drawn at their correct bearing and distance from the current GPS coordinates.

  • If no aircraft appear, try increasing the range — very few aircraft fly below 5 km from many locations.
  • Check the Serial monitor at 115200 baud to see how many aircraft were plotted each update cycle.

7. Add the GPS with USB unplugged

Use a 3.3 V-compatible NEO-6M receiver with 3.3 V UART signals. Connect VCC to 3V3 (power), GND to GND (common ground), and TX to GPIO5 (location data into ESP32). Leave GPS RX and PPS disconnected. Attach its antenna and put it outdoors with a clear sky view. A first fix can take several minutes. A regulated breakout that requires 5 V input is not the specified 3.3 V-powered module.

  • Never connect 5 V UART signals to the ESP32.

8. Check GPS, API and display

The radar waits for a fresh GPS position and does not use old or manually entered coordinates. API polling is once per minute while a fix is available. HTTP 401, 402, 403 or 429 pauses requests until restart; check your token, credits or limits before restarting. For a blank display unplug USB and check 3V3, ground, RST GPIO0, CS GPIO1, DC GPIO10, SDA GPIO3 and SCL GPIO4. SDA and SCL here are SPI, not I2C. Use short jumper wires. If it glitches, lower busCfg.freq_write in firmware from 40000000 to 20000000 and Deploy again. BOOT is the built-in active-low GPIO9 button; no extra wire is needed. Do not hold BOOT during reset except to enter flashing mode.

  • Original inspiration: https://github.com/MatixYo/ESP32-Plane-Radar . This firmware is an adapted standalone implementation, not that repository's GPS/FR24 release.
  • This bench prototype encrypts HTTPS but does not verify the server certificate. A hostile network could steal the API token. Use only a trusted network and a revocable token with limited credits; do not use this unchanged for unattended production deployment.
  • Requests consume paid FR24 API credits, even when no useful aircraft appear. Unplug USB to stop usage. This is not an aviation navigation or safety device.

Review all connections

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

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

2. Connections between "gps" and "ESP32"

FunctiongpsESP32
powerVCC3V3
groundGNDGND
uartTXGPIO 5

Deploy the firmware

/*
 * ESP32 Plane Radar
 * Plots live ADS-B aircraft on a GC9A01 1.28-inch 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>
#include <TinyGPSPlus.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 60000
#define HOLD_CLEAR_MS     3000      // hold BOOT this long to wipe credentials
#define AP_SSID           "PlaneRadar-Setup"
#define NVS_NAMESPACE     "planeradar"
#define DISPLAY_SIZE      240
#define DISPLAY_CENTER    120
#define DISPLAY_RADIUS    105       // 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_GC9A01 _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 ──────────────────────────────────────────────────────────────────
TinyGPSPlus gps;
HardwareSerial gpsSerial(1);
String apiToken;
bool apiDisabled = false;
bool gpsReady = false;
String lastScene;
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, 78, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, 52, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER, DISPLAY_CENTER, 26, TFT_DARKGREEN);
  display.drawLine(15, DISPLAY_CENTER, 225, DISPLAY_CENTER, TFT_DARKGREEN);
  display.drawLine(DISPLAY_CENTER, 15, DISPLAY_CENTER, 225, TFT_DARKGREEN);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString("N", 116, 8);
  display.drawString("S", 116, 221);
  display.drawString("W", 6, 116);
  display.drawString("E", 225, 116);
  // Range label bottom-right
  char rangeBuf[8];
  snprintf(rangeBuf, sizeof(rangeBuf), "%dkm", (int)radarRangeKm);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString(rangeBuf, 195, 218);
}

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, 190),
                     constrain(py - 8, 0, 230));
}

void showMessage(const char* line1, const char* line2) {
  display.fillScreen(TFT_BLACK);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString(line1, 20, 110);
  if (line2) display.drawString(line2, 20, 125);
}

// ── 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 your FR24 API token.</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>FR24 API token (separate paid API plan)</label><input name='token' type='password' required>
<p>GPS supplies latitude and longitude. Requests run once per minute and consume API credits.</p>
<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);
  if (ssid.length() == 0 || configServer.arg("token").length() == 0) {
    configServer.send(400, "text/plain", "Wi-Fi name and FR24 token required"); return;
  }
  prefs.putString("token", configServer.arg("token"));
  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, "RadarSetup24");
  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;

  if (!gpsReady || apiDisabled || apiToken.isEmpty()) return;
  double dy = radarRangeKm / 111.0;
  double dx = dy / max(0.01, cos(radarLat * DEG2RAD));
  String bounds = String(min(90.0, radarLat+dy),3) + "," + String(max(-90.0,radarLat-dy),3)
    + "," + String(max(-180.0,radarLon-dx),3) + "," + String(min(180.0,radarLon+dx),3);
  WiFiClientSecure client;
  // WARNING: encrypted connection but server identity is not verified.
  // Use a limited-credit, revocable API token for this bench prototype.
  client.setInsecure();
  String url = "https://fr24api.flightradar24.com/api/live/flight-positions/light?bounds=" + bounds;

  HTTPClient http;
  http.begin(client, url);
  http.setTimeout(4000);
  http.useHTTP10(true);
  http.addHeader("Accept", "application/json");
  http.addHeader("Accept-Version", "v1");
  http.addHeader("Authorization", "Bearer " + apiToken);
  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;
    }

    if (!doc["data"].is<JsonArray>()) { http.end(); showMessage("Invalid FR24 response"); return; }
    String scene;
    serializeJson(doc["data"], scene);
    scene += String(radarLat,5) + String(radarLon,5) + String(radarRangeKm);
    if (scene == lastScene) { http.end(); return; }
    lastScene = scene;
    drawRadarShell();
    int plotted = 0;

    for (JsonObject plane : doc["data"].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["callsign"] | plane["hex"] | "AC";

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

    Serial.printf("Plotted %d aircraft within %.0f km\n", plotted, radarRangeKm);
  } else {
    Serial.printf("FR24 HTTP error: %d\n", code);
    if (code == 401 || code == 402 || code == 403 || code == 429) {
      apiDisabled = true;
      showMessage("FR24 paused: check", "token / credits / limit");
    } else showMessage("FR24 connection error");
    lastScene = "";
  }
  http.end();
}

// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  gpsSerial.setRxBufferSize(4096);
  gpsSerial.begin(9600, SERIAL_8N1, 5, -1);
  pinMode(BOOT_BUTTON, INPUT_PULLUP);

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

  // Load saved credentials from NVS
  prefs.begin(NVS_NAMESPACE, true);
  apiToken = prefs.getString("token", "");
  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 || apiToken.isEmpty()) {
    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);
  showMessage("Waiting for GPS", "Take antenna outdoors");
}

// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
  while (gpsSerial.available()) gps.encode(gpsSerial.read());
  bool fresh = gps.location.isValid() && gps.location.age() < 10000;
  if (!configMode && !apiDisabled && fresh != gpsReady) {
    showMessage(fresh ? "GPS fixed" : "Waiting for GPS", fresh ? "FR24 update pending" : "Take antenna outdoors");
    lastScene = "";
    if (fresh) lastFetchMs = millis() - FETCH_INTERVAL_MS;
  }
  gpsReady = fresh;
  if (fresh) { radarLat = gps.location.lat(); radarLon = gps.location.lng(); }

  if (configMode) {
    configServer.handleClient();
    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);
      lastScene = "";
      drawRadarShell();
    }
  }

  // Fetch every FETCH_INTERVAL_MS
  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