Community project

ESP32 Plane Radar Display

Photo of ESP32 Plane Radar Display

Marcel van Essen

Published November 11, 2026

Build a live aircraft radar display powered by an ESP32 and a 4-inch touchscreen. The system fetches real-time ADS-B aircraft data over Wi-Fi and plots planes on a circular radar view, with range rings showing distance from your location. A setup portal on first boot stores your Wi-Fi credentials and coordinates in flash memory, and the BOOT button cycles through radar ranges from 5 to 25 kilometers.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for connecting the Binghe ST7796S display to the ESP32. You'll also get the full firmware with Wi-Fi provisioning, aircraft data fetching, and touchscreen support, plus details on configuring the ILI9488 display driver and calibrating the resistive touch input.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

Binghe 4 inch ST7796S SPI display with XPT2046 resistive touch

320x480 SPI module with onboard regulator and backlight control, MSP4021-style 14-pin interface.

Assemble it in 5 steps

1. Disconnect USB and replace the round screen

Remove the GC9A01 and its wires. Use short jumper leads for the Binghe 4 inch ST7796S resistive-touch module. This wiring assumes the standard 14-pin VCC, GND, CS, RESET, DC/RS, SDI, SCK, LED, SDO, T_CLK, T_CS, T_DIN, T_OUT, T_IRQ interface. Match the printed labels, not connector position.

  • Do not use this wiring for a parallel-interface or capacitive-touch screen.

2. Connect screen power and backlight

Connect VCC to the ESP32-S3 board 3V3 rail, GND to GND, and LED to 3V3 (backlight enable). The existing wiring assumes VCC and LED accept 3.3V; their requirements have not been verified for this exact module. Confirm the module specifications before powering it. All logic signals must be 3.3V; if the board resets during Wi-Fi activity, stop and use a suitable regulated 3.3V supply rather than overloading the board regulator.

  • Never connect a GPIO or touch signal to 5V. The LED pin function is unverified; do not power it until the module specification confirms it is a compatible backlight input.

3. Connect display data and control

Connect RESET (RST) to GPIO7, CS to GPIO1, DC/RS to GPIO10, SDI (MOSI, formerly SDA) to GPIO11, and SCK (formerly SCL) to GPIO4. Leave display SDO disconnected. These are SPI signals, not I2C.

  • Keep jumper wires short; firmware uses a conservative 4 MHz SPI clock.

4. Connect resistive touch

Connect T_CLK to GPIO4 and T_DIN to GPIO11, sharing the screen clock and data wires. Connect T_OUT to GPIO5 and T_CS to GPIO6. Leave T_IRQ and any SD-card connector unwired. On the first startup after this update, tap the three calibration crosses with the stylus. Calibration is saved separately from Wi-Fi settings. Tap a plane marker to open its details, then tap anywhere to return. Empty radar space does nothing. Unavailable data is labelled Not available. Details refresh with aircraft data. Departure and destination airports are looked up by callsign using adsbdb when you tap a plane. The lookup may take a few seconds. Successful routes are cached for 30 minutes and missing routes for 5 minutes. Route unavailable means no matching route or a failed lookup; tap again later to retry. Routes are database records, not confirmed live flight plans. Airport codes and city names are shown; long names are shortened. The onboard BOOT button on GPIO0 still changes range.

5. Check the screen with the ILI9488 driver

This build tests an ILI9488 controller instead of ST7796, without changing wiring or the 4 MHz clock. The exact controller is not yet confirmed. Check each wire before reconnecting USB. A dark screen suggests missing LED enable, VCC or ground. A lit but blank screen suggests swapped CS, DC, RESET, SCK or SDI. Glitches suggest long or loose leads. If touch does not respond, check T_CS and T_OUT first and press gently with the supplied stylus. No SD-card support is configured.

Review all connections

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

Functionradar-displayESP32
powerVCC3V3
groundGNDGND
digitalRESETGPIO 7
spiCSGPIO 1
digitalDCGPIO 10
spiSDIGPIO 11
spiSCKGPIO 4
powerLED3V3
spiT_CLK → Binghe 4 inch ST7796S SPI display with XPT2046 resistive touch SCKEXT
spiT_DIN → Binghe 4 inch ST7796S SPI display with XPT2046 resistive touch SDIEXT
spiT_OUTGPIO 5
spiT_CSGPIO 6

Deploy the firmware

/*
 * ESP32 Plane Radar
 * Plots live ADS-B aircraft on a 4-inch touchscreen; testing ILI9488 controller.
 * First-boot softAP portal (192.168.4.1) stores Wi-Fi + location in NVS.
 * BOOT button (GPIO0) 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 "map_data.h"

// ── Pin assignments ──────────────────────────────────────────────────────────
#define RADAR_RST   7
#define RADAR_CS    1
#define RADAR_DC    10
#define RADAR_MOSI  11
#define RADAR_SCLK  4
#define BOOT_BUTTON 0

// ── 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      320
#define DISPLAY_CENTER_X  240
#define DISPLAY_CENTER_Y  160
#define DISPLAY_RADIUS    137       // 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_ILI9488 _panel; // Controller compatibility test; wiring unchanged
  lgfx::Touch_XPT2046 _touch;
public:
  RadarDisplay() {
    auto busCfg = _bus.config();
    busCfg.spi_host    = SPI2_HOST;
    busCfg.freq_write  = 4000000; // Short jumper-wire bench wiring
    busCfg.pin_sclk    = RADAR_SCLK;
    busCfg.pin_mosi    = RADAR_MOSI;
    busCfg.pin_miso    = 5;
    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.panel_width = 320;
    panelCfg.panel_height = 480;
    panelCfg.memory_width = 320;
    panelCfg.memory_height = 480;
    panelCfg.bus_shared = true;
    panelCfg.invert    = false;
    panelCfg.rgb_order = true;
    _panel.config(panelCfg);
    auto touchCfg = _touch.config();
    touchCfg.spi_host = SPI2_HOST;
    touchCfg.freq = 1000000;
    touchCfg.pin_sclk = RADAR_SCLK;
    touchCfg.pin_mosi = RADAR_MOSI;
    touchCfg.pin_miso = 5;
    touchCfg.pin_cs = 6;
    touchCfg.pin_int = -1;
    touchCfg.bus_shared = true;
    _touch.config(touchCfg);
    _panel.setTouch(&_touch);
    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;

// Retain only displayed aircraft, so touch selection matches visible markers.
struct AircraftInfo {
  String hex, flight, registration, type, altitude, speed, heading;
  float distance, bearing;
  int x, y;
};
AircraftInfo aircraft[160];
int aircraftCount = 0;
String selectedHex;
bool detailOpen = false;
String lastFrame;
AircraftInfo paintedAircraft[160];
int paintedCount = 0;
bool radarPainted = false;
String paintedDetails[10];
bool detailsPainted = false;
float touchMap[6];

// Three targets establish an affine raw-touch -> landscape-screen mapping.
void calibrateRadarTouch() {
  prefs.begin("radartouch", true);
  bool saved = prefs.getBytesLength("map") == sizeof(touchMap);
  if (saved) prefs.getBytes("map", touchMap, sizeof(touchMap));
  prefs.end();
  if (saved) return;
  const int tx[3] = {30, 450, 30}, ty[3] = {30, 30, 290};
  float rx[3], ry[3];
  for (int i = 0; i < 3; ++i) {
    display.fillScreen(TFT_BLACK);
    display.setTextColor(TFT_WHITE, TFT_BLACK);
    display.drawString("Touch calibration: tap cross with stylus", 35, 140);
    display.drawLine(tx[i]-10, ty[i], tx[i]+10, ty[i], TFT_YELLOW);
    display.drawLine(tx[i], ty[i]-10, tx[i], ty[i]+10, TFT_YELLOW);
    lgfx::touch_point_t pt;
    while (display.getTouchRaw(&pt, 1)) delay(20);
    while (!display.getTouchRaw(&pt, 1)) delay(20);
    rx[i] = pt.x; ry[i] = pt.y;
    while (display.getTouchRaw(&pt, 1)) delay(20);
    delay(150);
  }
  float ax=rx[1]-rx[0], ay=ry[1]-ry[0];
  float bx=rx[2]-rx[0], by=ry[2]-ry[0];
  float det=ax*by-ay*bx;
  if (fabsf(det)<100) { calibrateRadarTouch(); return; }
  touchMap[0]=420*by/det; touchMap[1]=-420*bx/det;
  touchMap[2]=30-touchMap[0]*rx[0]-touchMap[1]*ry[0];
  touchMap[3]=-260*ay/det; touchMap[4]=260*ax/det;
  touchMap[5]=30-touchMap[3]*rx[0]-touchMap[4]*ry[0];
  prefs.begin("radartouch", false);
  prefs.putBytes("map", touchMap, sizeof(touchMap));
  prefs.end();
}

String fieldText(JsonVariant field, const char* unit = "") {
  if (field.isNull()) return "Not available";
  return field.as<String>() + unit;
}

// Routes are database matches by callsign, not live flight plans.
struct RouteEntry {
  String callsign, origin, destination;
  unsigned long checkedAt = 0;
  bool available = false;
};
RouteEntry routeCache[24];
unsigned int nextRouteSlot = 0;
String selectedOrigin = "Route unavailable", selectedDestination;

String airportLabel(JsonVariant airport) {
  String code = airport["iata_code"] | "";
  if (!code.length()) code = airport["icao_code"] | "";
  String place = airport["municipality"] | "";
  if (!place.length()) place = airport["name"] | "";
  if (!code.length()) return "";
  String label = code;
  if (place.length()) label += " - " + place;
  // Keep the entire line within the 480-pixel details screen.
  if (label.length() > 31) label = label.substring(0, 28) + "...";
  return label;
}

void loadSelectedRoute(String callsign) {
  callsign.trim(); callsign.toUpperCase();
  selectedOrigin = "Route unavailable"; selectedDestination = "";
  if (callsign.length() < 3 || callsign.length() > 10) return;
  for (unsigned int i=0; i<callsign.length(); ++i)
    if (!isAlphaNumeric(callsign[i])) return;
  for (auto &r : routeCache) {
    unsigned long lifetime = r.available ? 1800000UL : 300000UL;
    if (r.callsign == callsign && millis()-r.checkedAt < lifetime) {
      if (r.available) { selectedOrigin=r.origin; selectedDestination=r.destination; }
      return;
    }
  }
  if (WiFi.status() != WL_CONNECTED) return;
  RouteEntry &r = routeCache[nextRouteSlot++ % 24];
  r.callsign=callsign; r.available=false; r.origin=""; r.destination="";
  WiFiClientSecure client;
  // Public read-only data; same TLS policy as the existing aircraft feed.
  client.setInsecure();
  client.setHandshakeTimeout(4);
  HTTPClient http;
  http.setConnectTimeout(3000);
  http.setTimeout(4000);
  if (http.begin(client, "https://api.adsbdb.com/v0/callsign/" + callsign)) {
    int status = http.GET();
    if (status == HTTP_CODE_OK) {
      JsonDocument doc;
      DeserializationError error = deserializeJson(doc, http.getString());
      if (!error) {
        JsonVariant route = doc["response"]["flightroute"];
        r.origin=airportLabel(route["origin"]);
        r.destination=airportLabel(route["destination"]);
        r.available=r.origin.length() && r.destination.length();
      }
    }
    http.end();
  }
  r.checkedAt=millis();
  if (r.available) { selectedOrigin=r.origin; selectedDestination=r.destination; }
}

void showAircraftDetails() {
  const AircraftInfo* found = nullptr;
  for (int i=0; i<aircraftCount; ++i)
    if (aircraft[i].hex == selectedHex) { found=&aircraft[i]; break; }
  String frame = "detail:" + selectedHex + selectedOrigin + selectedDestination;
  if (found) frame += found->flight+found->registration+found->type+found->altitude+
      found->speed+found->heading+String(found->distance,1)+String(found->bearing,0);
  if (frame == lastFrame) return;
  lastFrame=frame;
  if (!detailsPainted) {
    display.fillScreen(TFT_BLACK);
    for (auto &line : paintedDetails) line="";
  }
  radarPainted=false;
  display.setTextSize(2);
  display.setTextColor(TFT_CYAN, TFT_BLACK);
  display.drawString("Aircraft details", 15, 12);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  String lines[10];
  if (found) {
    lines[0]="Callsign: " + found->flight;
    lines[1]="Registration: " + found->registration;
    lines[2]="Type: " + found->type + "  ICAO: " + found->hex;
    lines[3]="Altitude: " + found->altitude;
    lines[4]="Ground speed: " + found->speed;
    lines[5]="Track: " + found->heading;
    lines[6]="Distance: " + String(found->distance,1) + " km";
    lines[7]="Bearing: " + String(found->bearing,0) + " deg";
    lines[8]="From: " + selectedOrigin;
    if (selectedDestination.length()) lines[9]="To: " + selectedDestination;
  } else lines[0]="No longer in current radar data";
  for (int i=0;i<10;++i) {
    if (!detailsPainted || lines[i] != paintedDetails[i]) {
      display.fillRect(15, 43+i*23, 465, 23, TFT_BLACK);
      display.drawString(lines[i], 15, 43+i*23);
      paintedDetails[i]=lines[i];
    }
  }
  detailsPainted=true;
  display.setTextSize(1);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString("Routes: adsbdb (not live flight plans)", 15, 283);
  display.drawString("Tap anywhere to return to radar", 15, 300);
}

// ── 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 ──────────────────────────────────────────────────────────
// Geographic background uses the same distance/bearing projection as aircraft.
// Clip each outline to the radar circle; display clipping also preserves dirty tiles.
void drawMapBackground() {
  const uint16_t mapColor = display.color565(90, 145, 175);
  auto drawOutlines = [&](int centerX, int centerY, float radius, float rangeKm) {
  for (const auto &seg : mapSegments) {
    float x[2], y[2];
    for (int i=0;i<2;++i) {
      double distance=haversineKm(radarLat,radarLon,seg[i*2+1],seg[i*2]);
      double angle=(initialBearing(radarLat,radarLon,seg[i*2+1],seg[i*2])-90)*DEG2RAD;
      x[i]=cos(angle)*distance/rangeKm*radius;
      y[i]=sin(angle)*distance/rangeKm*radius;
    }
    float dx=x[1]-x[0], dy=y[1]-y[0];
    float a=dx*dx+dy*dy;
    if (a<0.0001f) continue;
    float b=2*(x[0]*dx+y[0]*dy);
    float c=x[0]*x[0]+y[0]*y[0]-radius*radius;
    float disc=b*b-4*a*c;
    if (disc<0) continue;
    float lo=max(0.0f,(-b-sqrtf(disc))/(2*a));
    float hi=min(1.0f,(-b+sqrtf(disc))/(2*a));
    if(lo>hi) continue;
    display.drawLine(lroundf(centerX+x[0]+lo*dx),
                     lroundf(centerY+y[0]+lo*dy),
                     lroundf(centerX+x[0]+hi*dx),
                     lroundf(centerY+y[0]+hi*dy),mapColor);
  }
  };
  drawOutlines(DISPLAY_CENTER_X, DISPLAY_CENTER_Y, DISPLAY_RADIUS, radarRangeKm);
  // A regional inset keeps coast/province context visible at close zooms.
  // All drawing retains the current dirty-tile clip.
  drawOutlines(44, 56, 34, 80.0f);
  display.drawCircle(44, 56, 34, TFT_DARKGREY);
  display.fillCircle(44, 56, 2, TFT_YELLOW);
  display.setTextSize(1);
  display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
  display.drawString("Region 80km", 8, 10);
}

void drawRadarShell() {
  detailsPainted=false;
  radarPainted=false;
  display.fillRect(0, 0, 480, 320, TFT_BLACK);
  drawMapBackground();
  display.drawCircle(DISPLAY_CENTER_X, DISPLAY_CENTER_Y, DISPLAY_RADIUS, TFT_GREEN);
  display.drawCircle(DISPLAY_CENTER_X, DISPLAY_CENTER_Y, 103, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER_X, DISPLAY_CENTER_Y, 69, TFT_DARKGREEN);
  display.drawCircle(DISPLAY_CENTER_X, DISPLAY_CENTER_Y, 34, TFT_DARKGREEN);
  display.drawLine(103, DISPLAY_CENTER_Y, 377, DISPLAY_CENTER_Y, TFT_DARKGREEN);
  display.drawLine(DISPLAY_CENTER_X, 23, DISPLAY_CENTER_X, 297, TFT_DARKGREEN);
  display.setTextColor(TFT_WHITE, TFT_BLACK);
  display.setTextSize(1);
  display.drawString("N", 236, 8);
  display.drawString("S", 236, 303);
  display.drawString("W", 88, 156);
  display.drawString("E", 385, 156);
  display.drawString("Tap plane: details", 8, 300);
  // Range label bottom-right
  char rangeBuf[8];
  snprintf(rangeBuf, sizeof(rangeBuf), "%dkm", (int)radarRangeKm);
  display.setTextColor(TFT_YELLOW, TFT_BLACK);
  display.drawString(rangeBuf, 420, 300);
}

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_X + (int)(cosf(angle) * radius);
  int   py     = DISPLAY_CENTER_Y + (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, 420),
                     constrain(py - 8, 0, 300));
}

// Repaint only tiles occupied by moved, relabelled, added or removed aircraft.
// Redrawing all intersecting objects inside the clip restores rings and overlaps.
void updateRadar() {
  bool dirty[20][30] = {};
  auto mark = [&](const AircraftInfo &a) {
    int left=min(a.x-4, constrain(a.x+6,0,420));
    int top=min(a.y-5, constrain(a.y-8,0,300));
    int right=max(a.x+4, constrain(a.x+6,0,420)+(int)a.flight.length()*6+6);
    int bottom=max(a.y+4, constrain(a.y-8,0,300)+16);
    for(int y=max(0,top/16); y<=min(19,bottom/16); ++y)
      for(int x=max(0,left/16); x<=min(29,right/16); ++x) dirty[y][x]=true;
  };
  if (!radarPainted) {
    for(auto &row:dirty) for(bool &v:row) v=true;
  } else {
    for(int i=0;i<paintedCount;++i) {
      int j=0;
      for(;j<aircraftCount;++j) if(paintedAircraft[i].hex==aircraft[j].hex) break;
      if(j==aircraftCount || paintedAircraft[i].x!=aircraft[j].x ||
         paintedAircraft[i].y!=aircraft[j].y || paintedAircraft[i].flight!=aircraft[j].flight) {
        mark(paintedAircraft[i]);
        if(j<aircraftCount) mark(aircraft[j]);
      }
    }
    for(int j=0;j<aircraftCount;++j) {
      int i=0;
      for(;i<paintedCount;++i) if(aircraft[j].hex==paintedAircraft[i].hex) break;
      if(i==paintedCount) mark(aircraft[j]);
    }
  }
  for(int y=0;y<20;++y) for(int x=0;x<30;) {
    if(!dirty[y][x]) { ++x; continue; }
    int start=x;
    while(x<30 && dirty[y][x]) ++x;
    display.setClipRect(start*16,y*16,(x-start)*16,16);
    drawRadarShell();
    for(int i=0;i<aircraftCount;++i)
      drawAircraft(aircraft[i].bearing,aircraft[i].distance,aircraft[i].flight.c_str());
  }
  display.clearClipRect();
  paintedCount=aircraftCount;
  for(int i=0;i<aircraftCount;++i) paintedAircraft[i]=aircraft[i];
  radarPainted=true;
}

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 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);
  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) {
    // HTTPClient decodes chunked transfer framing; a raw stream does not.
    String payload = http.getString();
    JsonDocument doc;
    DeserializationError err = deserializeJson(doc, payload);
    if (err) {
      Serial.printf("JSON error: %s\n", err.c_str());
      http.end();
      return;
    }

    if (!doc["ac"].is<JsonArray>()) {
      Serial.println("Aircraft response missing ac array");
      http.end();
      return;
    }

    aircraftCount = 0;
    int plotted = 0;
    String frame = String(radarRangeKm);

    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";

      if (aircraftCount >= 160) break;
      AircraftInfo &a = aircraft[aircraftCount++];
      a.hex = plane["hex"] | "unknown";
      a.flight = label; a.flight.trim();
      a.registration = fieldText(plane["r"]);
      a.type = fieldText(plane["t"]);
      a.altitude = plane["alt_baro"].isNull() ? "Unknown"
          : String(plane["alt_baro"].as<double>() * 0.3048, 0) + " m";
      if (plane["alt_baro"].is<const char*>()) a.altitude=plane["alt_baro"].as<String>();
      a.speed = plane["gs"].isNull() ? "Unknown"
          : String(plane["gs"].as<double>() * 1.852, 0) + " km/h";
      a.heading = fieldText(plane["track"], " deg");
      a.distance=distKm; a.bearing=bearDeg;
      float angle=(bearDeg-90)*DEG_TO_RAD;
      float radius=distKm/radarRangeKm*DISPLAY_RADIUS;
      a.x=DISPLAY_CENTER_X+(int)(cosf(angle)*radius);
      a.y=DISPLAY_CENTER_Y+(int)(sinf(angle)*radius);
      frame += a.hex + a.flight + String(a.x) + "," + String(a.y) + ";";
      plotted++;
    }

    if (detailOpen) showAircraftDetails();
    else if (frame != lastFrame) {
      lastFrame=frame;
      updateRadar();
    }
    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);

  // Both devices share clock/data. Keep them deselected before bus startup.
  pinMode(RADAR_CS, OUTPUT);
  digitalWrite(RADAR_CS, HIGH);
  pinMode(6, OUTPUT); // XPT2046 T_CS, active low
  digitalWrite(6, HIGH);
  delay(150); // Allow the external display supply to settle.
  display.init();
  display.setRotation(1);
  calibrateRadarTouch();
  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();
    return;
  }

  static bool touchWasDown = false;
  lgfx::touch_point_t point;
  bool touchDown = display.getTouchRaw(&point, 1) != 0;
  if (touchDown && !touchWasDown) {
    if (detailOpen) {
      detailOpen=false;
      lastFrame="";
      updateRadar();
    } else {
      int x=lroundf(touchMap[0]*point.x+touchMap[1]*point.y+touchMap[2]);
      int y=lroundf(touchMap[3]*point.x+touchMap[4]*point.y+touchMap[5]);
      int nearest=-1, best=24*24;
      for (int i=0;i<aircraftCount;++i) {
        int dx=x-aircraft[i].x, dy=y-aircraft[i].y;
        int d=dx*dx+dy*dy;
        if (d<best) { best=d; nearest=i; }
      }
      if (nearest>=0) {
        selectedHex=aircraft[nearest].hex;
        detailOpen=true;
        selectedOrigin="Loading route..."; selectedDestination="";
        showAircraftDetails();
        loadSelectedRoute(aircraft[nearest].flight);
        showAircraftDetails();
      }
    }
  }
  touchWasDown = touchDown;

  // 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;
      detailOpen=false; aircraftCount=0; lastFrame="";
      lastFetchMs=millis()-FETCH_INTERVAL_MS;
      Serial.printf("Range → %.0f km\n", radarRangeKm);
      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