Community project

Build An Esp32-c3 Plane Radar Using Super Mini

GoofyGrain 7672

Published July 14, 2026

Raspberry Pi Pico5 components10 assembly steps
Remix this project

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
Wiring diagram for Build An Esp32-c3 Plane Radar Using Super Mini

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
1.8" ST7735 TFT 128×160 SPI Display11.8" / 1.77" 128×160 ST7735 SPI TFT display with LED (backlight) and A0 (data/command) pin labelling.
Female-to-female jumper wires1Short 2.54 mm jumper leads for wiring the ESP32-C3 board to the GC9A01 display.
Tactile push button1Momentary push button for radar range cycling and credential reset (replaces BOOTSEL on Pico 2W).
18650 Li-ion Cell3000 mAh118650 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 Module1Single-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 steps
  1. Werkzeug & 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.
  2. 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.
  3. 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!
  4. 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.
  5. 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.
  6. 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
  7. 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.
  8. 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.
  9. 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.
  10. 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
firmware.cppDeploy to device
/*
 * 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 &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);
  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.

Open in Schematik