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
Gather all the parts
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"
2. Connections between "gps" and "ESP32"
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 & 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.




