Community project
Build An Esp32-c3 Plane Radar Using Super Mini
GoofyGrain 7672
Published July 14, 2026
This guide builds a portable aircraft radar display powered by a Raspberry Pi Pico 2W. The project fetches live ADS-B aircraft data over Wi-Fi and plots planes on a 1.8" ST7735 TFT display, showing their position and distance from your location. A tactile button cycles through detection ranges (5–25 km), and the whole system runs on a rechargeable 18650 battery with USB-C charging.
You'll get a complete wiring diagram, parts list, and step-by-step assembly instructions for breadboard prototyping. The included firmware handles Wi-Fi setup via a captive portal on first boot, stores your location and credentials, and continuously updates the radar display with nearby aircraft. No soldering required—everything connects with jumper wires.
Wiring diagram
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| 1.8" ST7735 TFT 128×160 SPI Display | 1 | 1.8" / 1.77" 128×160 ST7735 SPI TFT display with LED (backlight) and A0 (data/command) pin labelling. |
| Female-to-female jumper wires | 1 | Short 2.54 mm jumper leads for wiring the ESP32-C3 board to the GC9A01 display. |
| Tactile push button | 1 | Momentary push button for radar range cycling and credential reset (replaces BOOTSEL on Pico 2W). |
| 18650 Li-ion Cell3000 mAh | 1 | 18650 lithium-ion cell, nominal 3.7 V, ~2500 mAh. Common for higher-capacity portable / battery-bank style projects; needs a holder and protection / charger circuit. |
| TP4056 USB-C Li-ion Charger Module | 1 | Single-cell Li-ion/LiPo charger IC on a breakout module with USB-C input, 1A charge current, over-charge and over-discharge protection (DW01A), BAT+/BAT- pads for the cell, and OUT+/OUT- pads for the load. |
Assembly
10 stepsWerkzeug & Teile bereitstellen
Lege alle Teile auf einem antistatischen Untergrund bereit: Raspberry Pi Pico 2W, ST7735 TFT-Display (128×160), TP4056 USB-C Lademodul, 18650 Li-Ion-Zelle mit Halter, Taktschalter, weiblich-weibliche Jumper-Kabel.
- Tip: Überprüfe die Polung des 18650-Halters (+ und −) bevor du die Zelle einlegst.
- Tip: Das Display hat 8 Pins: GND, VCC, SCL, SDA, A0, RES, CS, LED — merke dir die Reihenfolge.
Pico 2W auf Steckbrett stecken
Stecke den Pico 2W so ins Steckbrett, dass die USB-Buchse zur Seite zeigt. Achte darauf, dass die goldene WLAN-Antenne (am USB-Ende) freibleibt — kein Metall in der Nähe.
- Tip: Die physischen Pinnummern stehen neben den Pads auf der Unterseite des Pico-Boards.
Display-Stromversorgung anschließen
Verbinde: • Display GND → Pico GND (Pin 38) • Display VCC → Pico 3V3 OUT (Pin 36)
- ⚠ Niemals VCC an 5V anlegen — das Display verträgt nur 3,3 V!
SPI-Datenleitungen verdrahten
Verbinde die SPI-Pins des Displays mit dem Pico 2W: • SCL → GP2 (Pin 4) • SDA → GP3 (Pin 5)
- Tip: SCL = Takt, SDA = Daten (MOSI). Richtung: Pico → Display.
Steuer-Pins verdrahten
Verbinde die restlichen Display-Steuerpins: • **RES** → GP0 (Pin 1) — Reset • **CS** → GP1 (Pin 2) — Chip Select • **A0** → GP4 (Pin 6) — Data/Command (auf manchen Modulen auch D/C) • **LED** → GP5 (Pin 7) — Hintergrundbeleuchtung (HIGH = an)
- Tip: A0 entspricht dem DC-Pin anderer Module — gleiche Funktion, anderer Aufdruck.
- Tip: LED entspricht dem BLK-Pin anderer Module — HIGH schaltet die Beleuchtung ein.
Bereichstaste verdrahten
Verbinde einen Taktschalter: • Ein Bein → GP15 (Pin 20) • Anderes Bein → GND (Pin 18 oder 23) Kein externer Widerstand nötig — interner Pull-up ist in der Firmware aktiviert.
- Tip: Kurzer Druck: Bereich umschalten (5 → 10 → 15 → 25 km → 5 km)
- Tip: Lang halten (3 s): gespeicherte WLAN-Daten löschen und Setup-Portal neu starten
TP4056 Lademodul anschließen
Verdrahtung des Lademoduls: • TP4056 B+ → 18650-Halter + (rotes Kabel) • TP4056 B− → 18650-Halter − (schwarzes Kabel) • TP4056 OUT+ → Pico VSYS (Pin 39) • TP4056 OUT− → Pico GND (Pin 38)
- ⚠ Polung genau prüfen! Verpolung kann Akku, Modul oder Pico beschädigen.
- ⚠ Pico-USB und TP4056 OUT+ niemals gleichzeitig anschließen — nur einen Pfad zur Zeit nutzen.
18650-Zelle einlegen
Schiebe die 18650-Zelle in den Halter. Achte auf korrekte Polung (+/−). Schließe das Lademodul an USB-C an — rote LED leuchtet während des Ladevorgangs, grüne/blaue LED bei vollem Akku.
- ⚠ Zelle erst einlegen, wenn alle anderen Verbindungen geprüft sind.
Verdrahtung prüfen & Inbetriebnahme
Überprüfe alle Verbindungen anhand dieser Tabelle vor dem Einschalten: | Display-Pin | Pico 2W GPIO | Phys. Pin | |------------|-------------|----------| | GND | GND | 38 | | VCC | 3V3 OUT | 36 | | SCL | GP2 | 4 | | SDA | GP3 | 5 | | RES | GP0 | 1 | | CS | GP1 | 2 | | A0 | GP4 | 6 | | LED | GP5 | 7 | Drücke dann den BOOTSEL-Knopf, halte ihn gedrückt und schließe Micro-USB an — der Pico erscheint als Laufwerk. Schematik Deploy flasht die Firmware automatisch.
- Tip: Falls der Bildschirm leer bleibt: LED-Pin (GP5) auf HIGH prüfen, SPI-Verkabelung kontrollieren.
- Tip: Falls das Bild verschoben ist: offset_x/offset_y im Code anpassen (0–4 probieren).
- Tip: Falls Farben falsch sind: rgb_order oder invert im Panel-Config ändern.
WLAN einrichten
Beim ersten Start öffnet der Pico ein WLAN-Netz namens **PlaneRadar-Setup**. Verbinde dein Smartphone damit und öffne http://192.168.4.1 im Browser. Trage SSID, Passwort und deine GPS-Koordinaten ein und klicke 'Speichern'. Der Pico verbindet sich automatisch und startet das Radar.
- Tip: GPS-Koordinaten (Breiten-/Längengrad) findest du z. B. bei maps.google.com — Rechtsklick auf deinen Standort.
- Tip: Nach dem Speichern startet der Pico neu und verbindet sich mit deinem WLAN.
Firmware
Raspberry Pi Pico/*
* Pico 2W Plane Radar
* Plots live ADS-B aircraft on a 1.8" ST7735 128×160 SPI TFT display.
* First-boot softAP portal (192.168.4.1) stores Wi-Fi + location in NVS/LittleFS.
* Range button on GP15: short press cycles 5→10→15→25 km.
* Hold range button for 3 s to clear saved credentials and relaunch the portal.
*
* Board : Raspberry Pi Pico 2W (RP2350 + CYW43439)
* Display: 1.8" / 1.77" 128×160 ST7735 TFT (makershop / generic module)
* 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 // GP0 — display RES (reset)
#define RADAR_CS 1 // GP1 — SPI0 CSn
#define RADAR_SCLK 2 // GP2 — SPI0 SCK
#define RADAR_MOSI 3 // GP3 — SPI0 TX (MOSI / SDA)
#define RADAR_A0 4 // GP4 — display A0 (data/command, same as DC)
#define RADAR_LED 5 // GP5 — display LED backlight (HIGH = on)
#define RANGE_BUTTON 15 // GP15 — range cycle / credential reset (active LOW)
// ── Display geometry (128 wide × 160 tall, portrait) ────────────────────────
#define DISP_W 128
#define DISP_H 160
// Radar circle centred at (64, 90); radius 58 px leaves room for labels
#define RADAR_CX 64
#define RADAR_CY 90
#define RADAR_RADIUS 56
// ── Radar settings ───────────────────────────────────────────────────────────
#define FETCH_INTERVAL_MS 5000
#define HOLD_CLEAR_MS 3000
#define AP_SSID "PlaneRadar-Setup"
#define NVS_NAMESPACE "planeradar"
// ── LovyanGFX display class (ST7735, SPI0) ───────────────────────────────────
class RadarDisplay : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus;
lgfx::Panel_ST7735 _panel;
public:
RadarDisplay() {
auto busCfg = _bus.config();
busCfg.spi_host = 0; // SPI0
busCfg.freq_write = 27000000; // 27 MHz — safe for most ST7735 modules
busCfg.pin_sclk = RADAR_SCLK;
busCfg.pin_mosi = RADAR_MOSI;
busCfg.pin_miso = -1;
busCfg.pin_dc = RADAR_A0;
_bus.config(busCfg);
_panel.setBus(&_bus);
auto panelCfg = _panel.config();
panelCfg.pin_cs = RADAR_CS;
panelCfg.pin_rst = RADAR_RST;
panelCfg.panel_width = 128;
panelCfg.panel_height = 160;
panelCfg.memory_width = 132; // ST7735 has 132-column RAM
panelCfg.memory_height = 162; // and 162-row RAM
panelCfg.offset_x = 2; // common for black-tab modules
panelCfg.offset_y = 1;
panelCfg.invert = false;
panelCfg.rgb_order = false;
_panel.config(panelCfg);
setPanel(&_panel);
}
};
// ── Math helpers ─────────────────────────────────────────────────────────────
// Forward declarations
double haversineKm(double lat1, double lon1, double lat2, double lon2);
double initialBearing(double lat1, double lon1, double lat2, double lon2);
static const double DEG2RAD = M_PI / 180.0;
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));
}
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);
}
// ── 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 btnPressedAt = 0;
bool btnWasLow = 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);
// Three range rings at 1/4, 1/2, 3/4 and full radius
display.drawCircle(RADAR_CX, RADAR_CY, RADAR_RADIUS, TFT_GREEN);
display.drawCircle(RADAR_CX, RADAR_CY, (RADAR_RADIUS*3)/4, TFT_DARKGREEN);
display.drawCircle(RADAR_CX, RADAR_CY, RADAR_RADIUS/2, TFT_DARKGREEN);
display.drawCircle(RADAR_CX, RADAR_CY, RADAR_RADIUS/4, TFT_DARKGREEN);
// Cross-hair lines
display.drawLine(RADAR_CX - RADAR_RADIUS, RADAR_CY,
RADAR_CX + RADAR_RADIUS, RADAR_CY, TFT_DARKGREEN);
display.drawLine(RADAR_CX, RADAR_CY - RADAR_RADIUS,
RADAR_CX, RADAR_CY + RADAR_RADIUS, TFT_DARKGREEN);
// Cardinal labels
display.setTextColor(TFT_WHITE, TFT_BLACK);
display.setTextSize(1);
display.drawString("N", RADAR_CX - 3, RADAR_CY - RADAR_RADIUS - 10);
display.drawString("S", RADAR_CX - 3, RADAR_CY + RADAR_RADIUS + 2);
display.drawString("W", RADAR_CX - RADAR_RADIUS - 10, RADAR_CY - 4);
display.drawString("E", RADAR_CX + RADAR_RADIUS + 2, RADAR_CY - 4);
// Title bar
display.setTextColor(TFT_CYAN, TFT_BLACK);
display.drawString("PLANE RADAR", 14, 4);
// Range label bottom-right
char rangeBuf[10];
snprintf(rangeBuf, sizeof(rangeBuf), "%dkm", (int)radarRangeKm);
display.setTextColor(TFT_YELLOW, TFT_BLACK);
display.drawString(rangeBuf, DISP_W - 30, DISP_H - 12);
}
void drawAircraft(float bearingDeg, float distanceKm, const char* label) {
if (distanceKm > radarRangeKm) return;
float angle = (bearingDeg - 90.0f) * (float)DEG2RAD;
float radius = (distanceKm / radarRangeKm) * (float)RADAR_RADIUS;
int px = RADAR_CX + (int)(cosf(angle) * radius);
int py = RADAR_CY + (int)(sinf(angle) * radius);
display.fillTriangle(px, py - 4, px - 3, py + 3, px + 3, py + 3, TFT_RED);
display.setTextColor(TFT_WHITE, TFT_BLACK);
display.setTextSize(1);
display.drawString(label,
constrain(px + 5, 0, DISP_W - 20),
constrain(py - 8, 0, DISP_H - 10));
}
void showMessage(const char* line1, const char* line2) {
display.fillScreen(TFT_BLACK);
display.setTextColor(TFT_WHITE, TFT_BLACK);
display.setTextSize(1);
display.setCursor(4, 70);
display.print(line1);
if (line2) {
display.setCursor(4, 85);
display.print(line2);
}
}
// ── Config portal ─────────────────────────────────────────────────────────────
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);
rp2040.reboot();
}
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;
float nm = radarRangeKm / 1.852f;
WiFiClientSecure client;
client.setInsecure(); // public read-only data; no CA bundle on Pico
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) {
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;
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(RANGE_BUTTON, INPUT_PULLUP);
// Backlight on
pinMode(RADAR_LED, OUTPUT);
digitalWrite(RADAR_LED, HIGH);
display.init();
display.setRotation(0); // portrait: 128 wide × 160 tall
display.setTextSize(1);
drawRadarShell();
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 < 20000) {
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();
return;
}
// Range button: short press cycles range; hold 3 s clears credentials
bool btnLow = (digitalRead(RANGE_BUTTON) == LOW);
if (btnLow && !btnWasLow) {
btnPressedAt = millis();
btnWasLow = true;
}
if (!btnLow && btnWasLow) {
unsigned long held = millis() - btnPressedAt;
btnWasLow = false;
if (held >= HOLD_CLEAR_MS) {
Serial.println("Long press — clearing credentials");
prefs.begin(NVS_NAMESPACE, false);
prefs.clear();
prefs.end();
delay(200);
rp2040.reboot();
} else {
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();
}
}
if (millis() - lastFetchMs >= FETCH_INTERVAL_MS) {
lastFetchMs = millis();
fetchAircraft();
}
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
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.