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
Gather all the parts
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"
Deploy the firmware
/*
* 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 & 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.




