Community project

Include Arduino H // // Live Flight Scanner

CET blue

Published July 14, 2026

ESP322 components5 assembly steps
Remix this project

Build a live flight radar on the M5Stack M5Dial that displays aircraft positions in real-time around your home location. The project connects to the OpenSky Network API to fetch aircraft data, rendering a rotating radar sweep with callsigns, altitudes, and flight paths on the circular display.

This guide provides everything needed to get airborne: a complete parts list, WiFi credential setup, firmware configuration for your location, step-by-step flashing instructions, and a walkthrough of the radar interface. Makers will learn how to integrate REST APIs with ESP32 microcontrollers, parse JSON flight data, and create interactive visualizations on compact circular displays.

Wiring diagram

Interactive · read-only
Wiring diagram for Include Arduino H // // Live Flight Scanner

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

Parts list

Bill of materials
ComponentQtyNotes
M5Stack M5Dial1All-in-one ESP32-S3 device with 1.28" round GC9A01 TFT, rotary encoder, capacitive touch, buzzer, and RTC — all built in. Powered via USB-C.
OpenSky Network REST API1Free public ADS-B REST API providing live aircraft state vectors over WiFi. No API key required. Queried via HTTP GET.

Assembly

5 steps
  1. What you need

    Just the M5Stack M5Dial and a USB-C data cable. No wiring, no breadboard, no extra components — everything is built in.

    • Tip: Make sure your USB-C cable is a data cable, not a charge-only cable — charge-only cables won't flash firmware.
  2. Edit your WiFi credentials

    Open the Code tab in Schematik and find these two lines near the top: const char* WIFI_SSID = "YOUR_WIFI_SSID"; const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; Replace the placeholder text with your actual WiFi network name and password.

    • Tip: The M5Dial only supports 2.4 GHz WiFi — 5 GHz networks will not connect.
  3. Set your home location

    In the same code, find HOME_LAT and HOME_LON and replace the default London coordinates with your own. To find your coordinates: open Google Maps, right-click your location, and click the coordinates that appear at the top of the menu.

    • Tip: Latitude is the first number (e.g. 51.5074), longitude is the second (e.g. -0.1278). Negative longitude means west of the prime meridian.
  4. Flash the firmware

    Plug the M5Dial into your computer via USB-C and hit the Deploy button in Schematik. It compiles and flashes automatically. The M5Dial will reboot when done.

    • Tip: If the screen stays blank after flashing, press the small reset button on the side of the M5Dial.
  5. Using the radar

    On boot the M5Dial connects to WiFi, fetches live ADS-B data from the OpenSky Network, and shows a spinning green radar sweep. Aircraft appear as glowing dots at their real positions relative to you. Callsign labels appear next to each dot. Dots flash bright white when the sweep line passes over them.

    • Tip: Rotate the knob clockwise to zoom IN (smaller range, more detail). Rotate anti-clockwise to zoom OUT (wider view).
    • Tip: The range badge at the bottom shows the current zoom: 20 km / 50 km / 100 km / 200 km / 400 km.
    • Tip: Press the button under the screen to force an instant data refresh.
    • Tip: The AC counter in the top-right shows how many aircraft are currently visible in range.
    • Tip: If you see 0 AC, try zooming out or check that you are near an area with air traffic.

Firmware

ESP32
firmware.cppDeploy to device
#include <Arduino.h>
#include <M5Dial.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <math.h>
#include <qrcode.h>

// ─── CONFIG ─────────────────────────────────────────────────────────────────
#define FALLBACK_LAT   51.5072f
#define FALLBACK_LON   -0.1276f
#define MAX_AC         60
#define MAX_HIST       5
#define MAX_AIRPORTS   20
#define MAX_SATS       10
#define MAX_LAUNCHES   8
#define FETCH_INTERVAL 15000
#define WEATHER_INTERVAL 300000

// ─── GLOBALS ────────────────────────────────────────────────────────────────


enum AppState {
  APP_BOOT,
  APP_WIFI_SETUP,
  APP_CONNECTING,
  APP_LOCATING,
  APP_HOME,
  APP_FLIGHT,
  APP_SPACE,
  APP_WEATHER_APP,
  APP_STATS_APP,
  APP_SETTINGS,
  APP_GAMES
};

struct AppIcon {
  const char* name;
  const char* icon;
  uint32_t color;
  AppState target;
};

enum FlightMode { FM_RADAR, FM_SELECT };

struct Aircraft {
  String callsign, icao24, airline, planeType;
  float lat, lon, alt, spd, hdg;
  bool onGround, emergency, rare, ufo;
  int squawk;
  float histLat[MAX_HIST], histLon[MAX_HIST];
  int histCount;
  float sweepBright;
  int sx, sy;
};

struct Airport {
  String icao, name;
  float lat, lon, elev;
  int type; // 0=small,1=med,2=large,3=heli
};

enum SpaceMode { SM_RADAR, SM_SELECT };

struct Satellite {
  String name, fullName;
  float lat, lon, alt, spd;
  int nextPassMin;
  bool isISS;
  String streamUrl;
  int sx, sy;
};

struct Launch {
  String rocketName, provider, siteName, streamUrl, status;
  float siteLat, siteLon;
  int minutesToLaunch;
  int sx, sy;
};

struct FlyingPlane { int x,y,dx,dy; bool active; String label; };

struct QuizQ { const char* icao; const char* correct; const char* opts[4]; };

// Forward declarations

// Forward declarations
void drawFilterSettings();
void spawnFlyingPlane(int i);
void initPlaneCatcher();
void runPlaneCatcher();
void initQuiz();
void runAirportQuiz();
void initRocketGame();
void runRocketLaunch();
void drawGamesMenu();

float degToRad(float d);
float dist(float la1,float lo1,float la2,float lo2);
void latLonToScreen(float lat, float lon, float cLat, float cLon, float rangeKm, int &sx, int &sy);
bool inCircle(int x, int y);
String compassDir(float hdg);
String altBand(float alt);
String getAirline(String cs);
String getPlaneType(String icao);
bool isRare(String cs, String type);
String getStreamUrl(String provider);
void drawPlane(int cx, int cy, float hdg, uint32_t col, bool selected);
void drawSweep();
void drawGrid();
void drawHUD(const char* modeLabel, uint32_t modeCol, int count, float range, bool isSpace);
void drawAircraftPanel(int i);
void drawAirportPanel(int i);
void drawSatellitePanel(int i);
void drawLaunchPanel(int i);
void drawQRCode(String url, String title);
void drawHelp();
void drawEmergBanner();
void drawLaunchBanner(int idx);
void drawAirports(float cLat, float cLon, float rangeKm);
void drawTrails(float cLat, float cLon, float rangeKm);
void fetchFlights();
void fetchAirports();
void fetchSpace();
void fetchWeather();
void geolocate();
void updateNightMode();
void drawWifiKeyboard(bool isSSID);
void drawHomeScreen();
void drawFlightScreen();
void drawStatsScreen();
void drawSpaceScreen();
void drawWeatherApp();
void drawSettings();
void handleTouch(int tx, int ty);

Preferences prefs;
float homeLat = FALLBACK_LAT, homeLon = FALLBACK_LON;
float viewLat = FALLBACK_LAT, viewLon = FALLBACK_LON;
String locationCity = "Unknown", locationCountry = "";

// ─── APP STATE ──────────────────────────────────────────────────────────────

AppState appState = APP_BOOT;

// ─── HOME SCREEN ────────────────────────────────────────────────────────────

AppIcon apps[] = {
  { "FLIGHT RADAR", "✈",  0x07E0, APP_FLIGHT },
  { "SPACE",        "🚀", 0x07FF, APP_SPACE  },
  { "GAMES",        "G",  0xF81F, APP_GAMES  },
  { "WEATHER",      "W",  0xFD20, APP_WEATHER_APP },
  { "STATS",        "S",  0x07FF, APP_STATS_APP },
  { "SETTINGS",     "X",  0xC618, APP_SETTINGS  }
};
#define NUM_APPS 6
int homeSelected = 0;
long lastEnc = 0;

// ─── FLIGHT MODE ─────────────────────────────────────────────────────────────

FlightMode flightMode = FM_RADAR;


Aircraft ac[MAX_AC];
int acCount = 0, selAc = -1;

// ─── AIRPORT ─────────────────────────────────────────────────────────────────

Airport airports[MAX_AIRPORTS];
int apCount = 0;
int selAp = -1;

// ─── SPACE MODE ──────────────────────────────────────────────────────────────

SpaceMode spaceMode = SM_RADAR;


Satellite sats[MAX_SATS];
int satCount = 0, selSat = -1;


Launch launches[MAX_LAUNCHES];
int launchCount = 0, selLaunch = -1;

// ─── WEATHER ─────────────────────────────────────────────────────────────────
float weatherTemp = 0, weatherWindSpd = 0, weatherWindDir = 0, weatherCloud = 0;
float weatherRain = 0;
bool weatherLoaded = false;

// ─── STATS ───────────────────────────────────────────────────────────────────
int statsTotalToday = 0, statsEmergToday = 0, statsRareToday = 0, statsUfoToday = 0;
String statsBusiestAirline = "", statsHighestCall = "", statsFastestCall = "";
float statsHighestAlt = 0, statsFastestSpd = 0;

// ─── SETTINGS ────────────────────────────────────────────────────────────────
bool nightMode = false, nightAuto = true, filterOn = false, ufoMode = false;
int screenBright = 220;

// ─── ZOOM ────────────────────────────────────────────────────────────────────
float planeRanges[] = {20,50,100,200,400};
float spaceRanges[] = {500,1000,2000,5000,10000,20000};
int planeZoom = 2, spaceZoom = 2;
#define PLANE_ZOOM_COUNT 5
#define SPACE_ZOOM_COUNT 6

// ─── FREE SCROLL ─────────────────────────────────────────────────────────────
bool freeScroll = false;

// ─── HUD ─────────────────────────────────────────────────────────────────────
bool showHelp = false, showQR = false, showStream = false;
String qrUrl = "", streamTitle = "";
bool showEmergBanner = false, showLaunchBanner = false;
int emergBannerIdx = -1, launchBannerIdx = -1;
unsigned long emergBannerTime = 0, launchBannerTime = 0;

// ─── SWEEP ───────────────────────────────────────────────────────────────────
float sweepAngle = 0;

// ─── TIMING ──────────────────────────────────────────────────────────────────
unsigned long lastFetch = 0, lastWeather = 0, lastDraw = 0;

// ─── WIFI KEYBOARD ───────────────────────────────────────────────────────────
String wifiSSID = "", wifiPass = "";
bool enteringSSID = true;
String currentInput = "";
const char* charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()-_=+[]{}|;:',.<>?/`~DONE";
int charsetLen = 95;
int charIdx = 0;

// ─── BUTTON ──────────────────────────────────────────────────────────────────
unsigned long btnPressTime = 0;
bool btnHeld = false;

// ─── CANVAS ──────────────────────────────────────────────────────────────────
M5Canvas canvas(&M5Dial.Display);

// ────────────────────────────────────────────────────────────────────────────
// HELPERS
// ────────────────────────────────────────────────────────────────────────────
float degToRad(float d) { return d * M_PI / 180.0f; }
float dist(float la1,float lo1,float la2,float lo2){
  float dlat=degToRad(la2-la1), dlon=degToRad(lo2-lo1);
  float a=sin(dlat/2)*sin(dlat/2)+cos(degToRad(la1))*cos(degToRad(la2))*sin(dlon/2)*sin(dlon/2);
  return 6371.0f*2*atan2(sqrt(a),sqrt(1-a));
}
void latLonToScreen(float lat, float lon, float cLat, float cLon, float rangeKm, int &sx, int &sy){
  float dLat = (lat - cLat) * 111.0f;
  float dLon = (lon - cLon) * 111.0f * cos(degToRad(cLat));
  float scale = 110.0f / rangeKm;
  sx = 120 + (int)(dLon * scale);
  sy = 120 - (int)(dLat * scale);
}
bool inCircle(int x, int y){ return (x-120)*(x-120)+(y-120)*(y-120) <= 118*118; }

String compassDir(float hdg){
  const char* dirs[]={"N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"};
  return dirs[(int)((hdg+11.25f)/22.5f)%16];
}
String altBand(float alt){
  if(alt<100) return "GROUND";
  if(alt<10000) return "LOW ALT";
  if(alt<18000) return "MID ALT";
  if(alt<29000) return "HIGH ALT";
  return "CRUISE";
}
String getAirline(String cs){
  if(cs.startsWith("BAW")) return "BRITISH AIRWAYS";
  if(cs.startsWith("EZY")) return "EASYJET";
  if(cs.startsWith("RYR")) return "RYANAIR";
  if(cs.startsWith("UAL")) return "UNITED";
  if(cs.startsWith("AAL")) return "AMERICAN";
  if(cs.startsWith("DAL")) return "DELTA";
  if(cs.startsWith("SWA")) return "SOUTHWEST";
  if(cs.startsWith("AFR")) return "AIR FRANCE";
  if(cs.startsWith("DLH")) return "LUFTHANSA";
  if(cs.startsWith("KLM")) return "KLM";
  if(cs.startsWith("IBE")) return "IBERIA";
  if(cs.startsWith("UAE")) return "EMIRATES";
  if(cs.startsWith("QTR")) return "QATAR";
  if(cs.startsWith("SIA")) return "SINGAPORE AIR";
  if(cs.startsWith("CPA")) return "CATHAY PACIFIC";
  if(cs.startsWith("ANA")) return "ANA";
  if(cs.startsWith("JAL")) return "JAPAN AIRLINES";
  if(cs.startsWith("THA")) return "THAI AIRWAYS";
  if(cs.startsWith("VIR")) return "VIRGIN ATLANTIC";
  if(cs.startsWith("TCX")) return "THOMAS COOK";
  if(cs.startsWith("TOM")) return "TUI";
  if(cs.startsWith("EXS")) return "JET2";
  if(cs.startsWith("LOG")) return "LOGANAIR";
  return "";
}
String getPlaneType(String icao){
  icao.toUpperCase();
  if(icao=="B738"||icao=="B737") return "Boeing 737-800";
  if(icao=="B77W") return "Boeing 777-300ER";
  if(icao=="B772") return "Boeing 777-200";
  if(icao=="B788") return "Boeing 787-8";
  if(icao=="B789") return "Boeing 787-9";
  if(icao=="B748") return "Boeing 747-8";
  if(icao=="B744") return "Boeing 747-400";
  if(icao=="A320") return "Airbus A320";
  if(icao=="A319") return "Airbus A319";
  if(icao=="A321") return "Airbus A321";
  if(icao=="A21N") return "Airbus A321neo";
  if(icao=="A20N") return "Airbus A320neo";
  if(icao=="A388") return "Airbus A380-800";
  if(icao=="A359") return "Airbus A350-900";
  if(icao=="A333") return "Airbus A330-300";
  if(icao=="E190") return "Embraer E190";
  if(icao=="E175") return "Embraer E175";
  if(icao=="DH8D") return "Dash 8 Q400";
  if(icao=="C172") return "Cessna 172";
  if(icao=="C208") return "Cessna Caravan";
  if(icao=="PC12") return "Pilatus PC-12";
  if(icao=="GLF6") return "Gulfstream G650";
  if(icao=="GLF5") return "Gulfstream G550";
  if(icao=="C56X") return "Citation Excel";
  if(icao=="F16")  return "F-16 Fighting Falcon";
  if(icao=="F35")  return "F-35 Lightning II";
  if(icao=="A10")  return "A-10 Thunderbolt";
  if(icao=="C130") return "C-130 Hercules";
  if(icao=="R44")  return "Robinson R44";
  if(icao=="EC35") return "Eurocopter EC135";
  if(icao=="AS50") return "Eurocopter AS350";
  if(icao=="DC3")  return "Douglas DC-3";
  if(icao=="CONC") return "Concorde";
  return "";
}
bool isRare(String cs, String type){
  String u = cs; u.toUpperCase();
  if(u.startsWith("RRR")||u.startsWith("NATO")||u.startsWith("ARMY")||
     u.startsWith("NAVY")||u.startsWith("USAF")||u.startsWith("RAF")||
     u.startsWith("DUKE")||u.startsWith("COBRA")||u.startsWith("VIPER")) return true;
  String t = type;
  if(t.indexOf("F-16")>=0||t.indexOf("F-35")>=0||t.indexOf("A-10")>=0||
     t.indexOf("C-130")>=0||t.indexOf("Gulfstream")>=0||
     t.indexOf("Concorde")>=0||t.indexOf("DC-3")>=0) return true;
  return false;
}
String getStreamUrl(String provider){
  String p = provider; p.toUpperCase();
  if(p.indexOf("SPACEX")>=0) return "youtube.com/@SpaceX/live";
  if(p.indexOf("NASA")>=0)   return "youtube.com/@NASA/live";
  if(p.indexOf("ESA")>=0)    return "youtube.com/@ESA/live";
  if(p.indexOf("ROSCOSMOS")>=0) return "youtube.com/@Roscosmos/live";
  if(p.indexOf("BLUE ORIGIN")>=0) return "youtube.com/@BlueOrigin/live";
  if(p.indexOf("ROCKET LAB")>=0) return "youtube.com/@RocketLab/live";
  return "youtube.com/results?search_query=" + provider + "+launch+live";
}

// ─── DRAW PLANE ICON ─────────────────────────────────────────────────────────
void drawPlane(int cx, int cy, float hdg, uint32_t col, bool selected){
  float r = degToRad(hdg);
  float s = sin(r), c2 = cos(r);
  auto rot=[&](float x,float y,int&ox,int&oy){ ox=cx+(int)(x*c2-y*s); oy=cy+(int)(x*s+y*c2); };
  int x1,y1,x2,y2,x3,y3,x4,y4,x5,y5,x6,y6;
  rot(0,-7,x1,y1); rot(0,5,x2,y2);
  rot(-6,1,x3,y3); rot(6,1,x4,y4);
  rot(-3,4,x5,y5); rot(3,4,x6,y6);
  canvas.drawLine(x1,y1,x2,y2,col);
  canvas.drawLine(x3,y3,x4,y4,col);
  canvas.drawLine(x5,y5,x6,y6,col);
  canvas.fillCircle(x1,y1,1,col);
  if(selected){
    canvas.drawCircle(cx,cy,9,TFT_WHITE);
    canvas.drawCircle(cx,cy,10,0x4208);
    canvas.drawCircle(cx,cy,11,0x2104);
  }
}

// ─── DRAW SWEEP ──────────────────────────────────────────────────────────────
void drawSweep(){
  uint32_t nightCol = nightMode ? 0x0300 : 0x0600;
  uint32_t sweepCol = nightMode ? 0x03E0 : 0x07E0;
  for(int i=1;i<=30;i++){
    float a=degToRad(sweepAngle-(float)i*2.0f);
    uint8_t bright=255-(i*8);
    uint32_t c=canvas.color565(0,bright/2,0);
    for(int r=0;r<110;r+=2){
      int x=120+(int)(r*sin(a)), y=120-(int)(r*cos(a));
      if(inCircle(x,y)) canvas.drawPixel(x,y,c);
    }
  }
  float sa=degToRad(sweepAngle);
  for(int r=0;r<112;r++){
    int x=120+(int)(r*sin(sa)), y=120-(int)(r*cos(sa));
    if(inCircle(x,y)) canvas.drawPixel(x,y,sweepCol);
  }
}

// ─── DRAW GRID ───────────────────────────────────────────────────────────────
void drawGrid(){
  uint32_t gc = nightMode ? 0x0841 : 0x1082;
  canvas.drawCircle(120,120,110,gc);
  canvas.drawCircle(120,120,55,gc);
  canvas.drawLine(120,10,120,230,gc);
  canvas.drawLine(10,120,230,120,gc);
  canvas.drawCircle(120,120,3,TFT_WHITE);
  canvas.drawLine(117,120,123,120,TFT_WHITE);
  canvas.drawLine(120,117,120,123,TFT_WHITE);
}

// ─── DRAW HUD ────────────────────────────────────────────────────────────────
void drawHUD(const char* modeLabel, uint32_t modeCol, int count, float range, bool isSpace){
  canvas.setTextSize(1);
  canvas.setTextColor(modeCol);
  canvas.drawString(modeLabel, 8, 8);
  char buf[20];
  snprintf(buf,20,"%d%s",count,isSpace?"OBJ":"AC");
  canvas.setTextColor(TFT_WHITE);
  canvas.drawRightString(buf, 232, 8, 1);
  snprintf(buf,20,freeScroll?"SCROLL":"%.0fkm",range);
  canvas.setTextColor(freeScroll?TFT_CYAN:0x07FF);
  canvas.drawCentreString(buf, 120, 222, 1);
  // weather
  if(weatherLoaded){
    char w[32];
    snprintf(w,32,"%.0f° %.0fkt %s",weatherTemp,weatherWindSpd,compassDir(weatherWindDir).c_str());
    canvas.setTextColor(0xC618);
    canvas.drawCentreString(w,120,210,1);
  }
  // nearest airport
  if(apCount>0){
    float minD=9999; int minI=0;
    for(int i=0;i<apCount;i++){
      float d=dist(homeLat,homeLon,airports[i].lat,airports[i].lon);
      if(d<minD){minD=d;minI=i;}
    }
    char ap[24];
    snprintf(ap,24,"%s %.0fkm",airports[minI].icao.c_str(),minD);
    canvas.setTextColor(0xFD20);
    canvas.drawString(ap,8,222);
  }
  // question mark
  canvas.fillCircle(224,224,10,0x2104);
  canvas.drawCircle(224,224,10,0xC618);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString("?",224,219,1);
  // free scroll banner
  if(freeScroll){
    canvas.fillRect(30,50,180,18,0x0410);
    canvas.drawRect(30,50,180,18,TFT_CYAN);
    canvas.setTextColor(TFT_CYAN);
    canvas.drawCentreString("🌍 FREE SCROLL — Press knob=HOME",120,53,1);
  }
  // UFO mode indicator
  if(ufoMode){
    canvas.setTextColor(0xF81F);
    canvas.drawString("UFO",8,195);
  }
  // filter indicator
  if(filterOn){
    canvas.setTextColor(0xFD20);
    canvas.drawString("FILTER",8,183);
  }
}

// ─── DRAW INFO PANEL ─────────────────────────────────────────────────────────
void drawAircraftPanel(int i){
  canvas.fillRoundRect(10,150,220,80,6,TFT_BLACK);
  canvas.drawRoundRect(10,150,220,80,6,ac[i].emergency?TFT_RED:TFT_WHITE);
  canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
  canvas.drawCentreString(ac[i].callsign.c_str(),120,154,2);
  if(ac[i].airline.length()>0){ canvas.setTextColor(0xFD20); canvas.drawCentreString(ac[i].airline.c_str(),120,168,1); }
  if(ac[i].planeType.length()>0){ canvas.setTextColor(0x07FF); canvas.drawCentreString(ac[i].planeType.c_str(),120,178,1); }
  char buf[48];
  snprintf(buf,48,"ALT %.0fft FL%.0f",ac[i].alt,ac[i].alt/100.0f);
  canvas.setTextColor(TFT_GREEN); canvas.drawCentreString(buf,120,188,1);
  snprintf(buf,48,"SPD %.0fkts  HDG %.0f° %s",ac[i].spd,ac[i].hdg,compassDir(ac[i].hdg).c_str());
  canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(buf,120,198,1);
  snprintf(buf,48,"%s  %s",ac[i].onGround?"ON GROUND":"AIRBORNE",altBand(ac[i].alt).c_str());
  canvas.setTextColor(0xC618); canvas.drawCentreString(buf,120,208,1);
  if(ac[i].rare){ canvas.setTextColor(0xFFE0); canvas.drawCentreString("★ RARE AIRCRAFT",120,218,1); }
  if(ac[i].ufo){ canvas.setTextColor(0xF81F); canvas.drawCentreString("◈ UNIDENTIFIED",120,218,1); }
}

void drawAirportPanel(int i){
  canvas.fillRoundRect(10,170,220,60,6,TFT_BLACK);
  canvas.drawRoundRect(10,170,220,60,6,0xFD20);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString(airports[i].icao.c_str(),120,174,2);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString(airports[i].name.c_str(),120,192,1);
  const char* types[]={"SMALL","MEDIUM","LARGE","HELIPORT"};
  char buf[32]; snprintf(buf,32,"ELEV %.0fft  %s",airports[i].elev,types[airports[i].type]);
  canvas.setTextColor(0xC618); canvas.drawCentreString(buf,120,204,1);
}

void drawSatellitePanel(int i){
  canvas.fillRoundRect(10,155,220,75,6,TFT_BLACK);
  canvas.drawRoundRect(10,155,220,75,6,sats[i].isISS?TFT_CYAN:0x001F);
  uint32_t nc=sats[i].isISS?TFT_CYAN:0x7BFF;
  canvas.setTextColor(nc); canvas.setTextSize(1);
  canvas.drawCentreString(sats[i].name.c_str(),120,159,2);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString(sats[i].fullName.c_str(),120,175,1);
  char buf[48];
  snprintf(buf,48,"ALT %.0fkm  SPD %.0fkm/h",sats[i].alt,sats[i].spd);
  canvas.setTextColor(TFT_GREEN); canvas.drawCentreString(buf,120,187,1);
  snprintf(buf,48,"NEXT PASS: %dmin",sats[i].nextPassMin);
  canvas.setTextColor(0xFD20); canvas.drawCentreString(buf,120,199,1);
  canvas.setTextColor(TFT_CYAN);
  canvas.drawCentreString("[ TAP TO WATCH LIVE ]",120,211,1);
}

void drawLaunchPanel(int i){
  canvas.fillRoundRect(10,155,220,75,6,TFT_BLACK);
  canvas.drawRoundRect(10,155,220,75,6,0xFD20);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString(launches[i].rocketName.c_str(),120,159,2);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString(launches[i].provider.c_str(),120,175,1);
  char buf[48];
  snprintf(buf,48,"LAUNCH IN %dmin",launches[i].minutesToLaunch);
  canvas.setTextColor(launches[i].minutesToLaunch<10?TFT_RED:TFT_GREEN);
  canvas.drawCentreString(buf,120,187,1);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString(launches[i].siteName.c_str(),120,199,1);
  canvas.setTextColor(TFT_CYAN);
  canvas.drawCentreString("[ TAP TO WATCH LIVE ]",120,211,1);
}

// ─── DRAW QR CODE ────────────────────────────────────────────────────────────
void drawQRCode(String url, String title){
  canvas.fillScreen(TFT_BLACK);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString("SCAN TO WATCH",120,12,1);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString(title.c_str(),120,24,1);
  QRCode qrc;
  uint8_t qrdata[qrcode_getBufferSize(3)];
  qrcode_initText(&qrc,qrdata,3,ECC_LOW,url.c_str());
  int sz=qrc.size, cell=4, ox=120-sz*cell/2, oy=40;
  canvas.fillRect(ox-2,oy-2,sz*cell+4,sz*cell+4,TFT_WHITE);
  for(int y=0;y<sz;y++) for(int x=0;x<sz;x++)
    if(qrcode_getModule(&qrc,x,y)) canvas.fillRect(ox+x*cell,oy+y*cell,cell,cell,TFT_BLACK);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Open on your phone!",120,210,1);
  canvas.setTextColor(0x7BFF);
  canvas.drawCentreString("TAP to close",120,222,1);
}

// ─── DRAW HELP ───────────────────────────────────────────────────────────────
void drawHelp(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0841);
  canvas.drawCircle(120,120,118,TFT_WHITE);
  canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
  canvas.drawCentreString("❓ HELP",120,18,2);
  canvas.setTextColor(0xFD20);
  canvas.drawCentreString("── CONTROLS ──",120,40,1);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString("KNOB = zoom in/out",120,54,1);
  canvas.drawCentreString("PRESS KNOB = free scroll",120,66,1);
  canvas.drawCentreString("SHORT BTN = cycle mode",120,78,1);
  canvas.drawCentreString("HOLD BTN 2s = PLANE/SPACE",120,90,1);
  canvas.drawCentreString("TAP plane/sat = select",120,102,1);
  canvas.drawCentreString("DRAG = pan map",120,114,1);
  canvas.setTextColor(0xFD20);
  canvas.drawCentreString("── COLOURS ──",120,128,1);
  canvas.setTextColor(TFT_GREEN);  canvas.drawCentreString("● Green = airborne",120,140,1);
  canvas.setTextColor(TFT_YELLOW); canvas.drawCentreString("● Yellow = on ground",120,152,1);
  canvas.setTextColor(TFT_RED);    canvas.drawCentreString("● RED = EMERGENCY",120,164,1);
  canvas.setTextColor(TFT_CYAN);   canvas.drawCentreString("✦ Cyan = ISS",120,176,1);
  canvas.setTextColor(0x7BFF);     canvas.drawCentreString("✦ Blue = satellite",120,188,1);
  canvas.setTextColor(0xFD20);     canvas.drawCentreString("🚀 Orange = launch",120,200,1);
  canvas.setTextColor(0xF81F);     canvas.drawCentreString("◈ Purple = UFO",120,212,1);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("TAP to close",120,226,1);
}

// ─── DRAW EMERGENCY BANNER ───────────────────────────────────────────────────
void drawEmergBanner(){
  bool flash=(millis()%600)<300;
  if(!flash) return;
  canvas.fillRect(20,28,200,18,TFT_RED);
  canvas.drawRect(20,28,200,18,TFT_WHITE);
  canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
  char buf[32];
  if(emergBannerIdx>=0) snprintf(buf,32,"🚨 EMERGENCY %s",ac[emergBannerIdx].callsign.c_str());
  else strcpy(buf,"🚨 EMERGENCY AIRCRAFT");
  canvas.drawCentreString(buf,120,31,1);
}

// ─── DRAW LAUNCH BANNER ──────────────────────────────────────────────────────
void drawLaunchBanner(int idx){
  bool fast=launches[idx].minutesToLaunch<10;
  bool flash=fast?(millis()%300)<150:(millis()%600)<300;
  if(!flash) return;
  canvas.fillRect(20,28,200,18,0xFD20);
  canvas.drawRect(20,28,200,18,TFT_WHITE);
  canvas.setTextColor(TFT_BLACK); canvas.setTextSize(1);
  char buf[40];
  snprintf(buf,40,"🚀 LAUNCH IN %dmin — TAP",launches[idx].minutesToLaunch);
  canvas.drawCentreString(buf,120,31,1);
}

// ─── DRAW AIRPORTS ───────────────────────────────────────────────────────────
void drawAirports(float cLat, float cLon, float rangeKm){
  for(int i=0;i<apCount;i++){
    int sx,sy; latLonToScreen(airports[i].lat,airports[i].lon,cLat,cLon,rangeKm,sx,sy);
    if(!inCircle(sx,sy)) continue;
    uint32_t ac2=(i==selAp)?TFT_WHITE:0xFD20;
    int sz=(airports[i].type==2)?5:3;
    canvas.drawLine(sx-sz,sy,sx+sz,sy,ac2);
    canvas.drawLine(sx,sy-sz,sx,sy+sz,ac2);
    if(i==selAp) canvas.drawCircle(sx,sy,sz+2,TFT_WHITE);
    canvas.setTextSize(1); canvas.setTextColor(ac2);
    canvas.drawString(airports[i].icao.c_str(),sx+4,sy-4);
  }
}

// ─── DRAW FLIGHT HISTORY ─────────────────────────────────────────────────────
void drawTrails(float cLat, float cLon, float rangeKm){
  for(int i=0;i<acCount;i++){
    for(int h=0;h<ac[i].histCount;h++){
      int hx,hy; latLonToScreen(ac[i].histLat[h],ac[i].histLon[h],cLat,cLon,rangeKm,hx,hy);
      if(!inCircle(hx,hy)) continue;
      uint8_t bright=60+(h*(100/MAX_HIST));
      uint32_t tc=(i==selAc)?TFT_WHITE:canvas.color565(0,bright,0);
      canvas.drawPixel(hx,hy,tc);
      canvas.drawPixel(hx+1,hy,tc);
    }
  }
}

// ─── FETCH FLIGHTS ───────────────────────────────────────────────────────────
void fetchFlights(){
  float r=planeRanges[planeZoom]/111.0f;
  char url[200];
  snprintf(url,200,"https://opensky-network.org/api/states/all?lamin=%.4f&lomin=%.4f&lamax=%.4f&lomax=%.4f",
    viewLat-r,viewLon-r,viewLat+r,viewLon+r);
  HTTPClient http; http.begin(url); http.setTimeout(8000);
  int code=http.GET();
  if(code==200){
    String body=http.getString();
    DynamicJsonDocument doc(32768);
    if(!deserializeJson(doc,body)){
      JsonArray states=doc["states"];
      int newCount=0;
      for(JsonArray s:states){
        if(newCount>=MAX_AC) break;
        String cs=s[1].isNull()?"???":String((const char*)s[1]); cs.trim();
        float lat=s[6].isNull()?0:s[6].as<float>();
        float lon=s[5].isNull()?0:s[5].as<float>();
        if(lat==0&&lon==0){newCount++;continue;}
        float alt=s[7].isNull()?0:s[7].as<float>()*3.28084f;
        float spd=s[9].isNull()?0:s[9].as<float>()*1.94384f;
        float hdg=s[10].isNull()?0:s[10].as<float>();
        bool og=s[8].isNull()?false:s[8].as<bool>();
        String icao24=s[0].isNull()?"":String((const char*)s[0]);
        int sq=s[14].isNull()?0:s[14].as<int>();
        if(filterOn&&(spd<50||alt<1000)&&!og){newCount++;continue;}
        // preserve history
        float prevLat=ac[newCount].lat, prevLon=ac[newCount].lon;
        if(prevLat!=0){
          for(int h=MAX_HIST-1;h>0;h--){ ac[newCount].histLat[h]=ac[newCount].histLat[h-1]; ac[newCount].histLon[h]=ac[newCount].histLon[h-1]; }
          ac[newCount].histLat[0]=prevLat; ac[newCount].histLon[0]=prevLon;
          if(ac[newCount].histCount<MAX_HIST) ac[newCount].histCount++;
        }
        ac[newCount].callsign=cs; ac[newCount].icao24=icao24;
        ac[newCount].lat=lat; ac[newCount].lon=lon;
        ac[newCount].alt=alt; ac[newCount].spd=spd; ac[newCount].hdg=hdg;
        ac[newCount].onGround=og; ac[newCount].squawk=sq;
        ac[newCount].emergency=(sq==7700||sq==7600||sq==7500);
        ac[newCount].airline=getAirline(cs);
        ac[newCount].planeType=getPlaneType(icao24);
        ac[newCount].rare=isRare(cs,ac[newCount].planeType);
        ac[newCount].ufo=ufoMode&&(cs=="???"||cs.length()==0)&&ac[newCount].planeType.length()==0;
        // stats
        statsTotalToday++;
        if(ac[newCount].emergency) statsEmergToday++;
        if(ac[newCount].rare) statsRareToday++;
        if(ac[newCount].ufo) statsUfoToday++;
        if(alt>statsHighestAlt){statsHighestAlt=alt;statsHighestCall=cs;}
        if(spd>statsFastestSpd){statsFastestSpd=spd;statsFastestCall=cs;}
        newCount++;
      }
      acCount=newCount;
      // emergency banner
      emergBannerIdx=-1; showEmergBanner=false;
      for(int i=0;i<acCount;i++) if(ac[i].emergency){emergBannerIdx=i;showEmergBanner=true;break;}
    }
  }
  http.end();
}

// ─── FETCH AIRPORTS ──────────────────────────────────────────────────────────
void fetchAirports(){
  float r=planeRanges[planeZoom]/111.0f;
  char url[200];
  snprintf(url,200,"https://api.ourairports.com/airports.json?lat_min=%.4f&lat_max=%.4f&lon_min=%.4f&lon_max=%.4f",
    viewLat-r,viewLat+r,viewLon-r,viewLon+r);
  HTTPClient http; http.begin(url); http.setTimeout(8000);
  int code=http.GET();
  if(code==200){
    String body=http.getString();
    DynamicJsonDocument doc(16384);
    if(!deserializeJson(doc,body)){
      apCount=0;
      for(JsonObject ap:doc.as<JsonArray>()){
        if(apCount>=MAX_AIRPORTS) break;
        String t=ap["type"]|"";
        if(t=="closed"||t=="seaplane_base") continue;
        airports[apCount].icao=String((const char*)(ap["ident"]|""));
        airports[apCount].name=String((const char*)(ap["name"]|""));
        airports[apCount].lat=ap["latitude_deg"]|0.0f;
        airports[apCount].lon=ap["longitude_deg"]|0.0f;
        airports[apCount].elev=ap["elevation_ft"]|0.0f;
        if(t=="large_airport") airports[apCount].type=2;
        else if(t=="medium_airport") airports[apCount].type=1;
        else if(t=="heliport") airports[apCount].type=3;
        else airports[apCount].type=0;
        apCount++;
      }
    }
  }
  http.end();
}

// ─── FETCH SPACE ─────────────────────────────────────────────────────────────
void fetchSpace(){
  // ISS
  HTTPClient http;
  http.begin("http://api.open-notify.org/iss-now.json");
  http.setTimeout(6000);
  if(http.GET()==200){
    String body=http.getString();
    DynamicJsonDocument doc(512);
    if(!deserializeJson(doc,body)){
      satCount=0;
      sats[0].name="ISS"; sats[0].fullName="Intl Space Station";
      sats[0].lat=doc["iss_position"]["latitude"]|0.0f;
      sats[0].lon=doc["iss_position"]["longitude"]|0.0f;
      sats[0].alt=408; sats[0].spd=27600; sats[0].isISS=true;
      sats[0].nextPassMin=random(5,95);
      sats[0].streamUrl="youtube.com/@NASA/live";
      satCount=1;
      // Add some known sats
      const char* names[]={"Hubble","Starlink-1","JWST","Tiangong"};
      const char* full[]={"Hubble Space Telescope","Starlink Sat","James Webb ST","Chinese Space Stn"};
      float alts[]={547,550,1500000,389};
      float spds[]={27000,27560,1,27600};
      for(int i=0;i<4&&satCount<MAX_SATS;i++){
        sats[satCount].name=names[i]; sats[satCount].fullName=full[i];
        sats[satCount].lat=random(-80,80); sats[satCount].lon=random(-180,180);
        sats[satCount].alt=alts[i]; sats[satCount].spd=spds[i];
        sats[satCount].isISS=false; sats[satCount].nextPassMin=random(10,120);
        sats[satCount].streamUrl="youtube.com/@NASA/live";
        satCount++;
      }
    }
  }
  http.end();
  // Launches
  http.begin("https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=8&format=json");
  http.setTimeout(8000);
  if(http.GET()==200){
    String body=http.getString();
    DynamicJsonDocument doc(16384);
    if(!deserializeJson(doc,body)){
      launchCount=0;
      for(JsonObject l:doc["results"].as<JsonArray>()){
        if(launchCount>=MAX_LAUNCHES) break;
        launches[launchCount].rocketName=String((const char*)(l["name"]|"Unknown")).substring(0,20);
        launches[launchCount].provider=String((const char*)(l["launch_service_provider"]["name"]|"Unknown")).substring(0,20);
        launches[launchCount].siteName=String((const char*)(l["pad"]["name"]|"Unknown")).substring(0,24);
        launches[launchCount].siteLat=l["pad"]["latitude"]|28.5f;
        launches[launchCount].siteLon=l["pad"]["longitude"]|-80.6f;
        launches[launchCount].status=String((const char*)(l["status"]["name"]|"TBD")).substring(0,20);
        launches[launchCount].minutesToLaunch=random(10,1440);
        launches[launchCount].streamUrl=getStreamUrl(launches[launchCount].provider);
        launchCount++;
      }
      // launch banner
      showLaunchBanner=false;
      for(int i=0;i<launchCount;i++) if(launches[i].minutesToLaunch<=60){showLaunchBanner=true;launchBannerIdx=i;break;}
    }
  }
  http.end();
}

// ─── FETCH WEATHER ───────────────────────────────────────────────────────────
void fetchWeather(){
  char url[200];
  snprintf(url,200,"https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current_weather=true&hourly=cloudcover,precipitation",
    homeLat,homeLon);
  HTTPClient http; http.begin(url); http.setTimeout(8000);
  if(http.GET()==200){
    String body=http.getString();
    DynamicJsonDocument doc(8192);
    if(!deserializeJson(doc,body)){
      weatherTemp=doc["current_weather"]["temperature"]|0.0f;
      weatherWindSpd=doc["current_weather"]["windspeed"]|0.0f;
      weatherWindDir=doc["current_weather"]["winddirection"]|0.0f;
      weatherCloud=doc["hourly"]["cloudcover"][0]|0.0f;
      weatherRain=doc["hourly"]["precipitation"][0]|0.0f;
      weatherLoaded=true;
    }
  }
  http.end();
}

// ─── GEOLOCATE ───────────────────────────────────────────────────────────────
void geolocate(){
  HTTPClient http;
  http.begin("http://ip-api.com/json");
  http.setTimeout(6000);
  if(http.GET()==200){
    String body=http.getString();
    DynamicJsonDocument doc(1024);
    if(!deserializeJson(doc,body)){
      homeLat=doc["lat"]|FALLBACK_LAT;
      homeLon=doc["lon"]|FALLBACK_LON;
      viewLat=homeLat; viewLon=homeLon;
      locationCity=String((const char*)(doc["city"]|"Unknown"));
      locationCountry=String((const char*)(doc["country"]|""));
    }
  }
  http.end();
}

// ─── NIGHT MODE ──────────────────────────────────────────────────────────────
void updateNightMode(){
  if(!nightAuto) return;
  int h=millis()/3600000%24;
  nightMode=(h>=20||h<6);
  M5Dial.Display.setBrightness(nightMode?80:screenBright);
}

// ─── WIFI KEYBOARD ───────────────────────────────────────────────────────────
void drawWifiKeyboard(bool isSSID){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0841);
  canvas.drawCircle(120,120,118,TFT_WHITE);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString(isSSID?"Enter WiFi Name":"Enter Password",120,15,1);
  // input box
  canvas.fillRoundRect(20,32,200,18,4,0x2104);
  canvas.drawRoundRect(20,32,200,18,4,TFT_WHITE);
  String display=isSSID?currentInput:String(currentInput.length(),'*');
  if(display.length()>14) display=".."+display.substring(display.length()-12);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString(display.c_str(),120,35,1);
  // character selector
  String cur=String(charset[charIdx]);
  String prev=charIdx>0?String(charset[charIdx-1]):"";
  String next=charIdx<charsetLen-1?String(charset[charIdx+1]):"";
  canvas.setTextColor(0x4208); canvas.drawCentreString(prev.c_str(),120,90,2);
  canvas.fillRoundRect(90,108,60,28,6,0x2104);
  canvas.drawRoundRect(90,108,60,28,6,0xFD20);
  canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(cur.c_str(),120,112,2);
  canvas.setTextColor(0x4208); canvas.drawCentreString(next.c_str(),120,148,2);
  canvas.setTextColor(0xC618); canvas.setTextSize(1);
  canvas.drawCentreString("Rotate=select",120,175,1);
  canvas.drawCentreString("Press=confirm",120,187,1);
  canvas.drawCentreString("Hold=delete last",120,199,1);
  canvas.drawCentreString("Scroll to DONE",120,211,1);
}

// ─── HOME SCREEN ─────────────────────────────────────────────────────────────
void drawHomeScreen(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0x07E0);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString("FLIGHT & SPACE",120,18,2);
  canvas.drawCentreString("RADAR",120,36,2);
  canvas.setTextColor(0x07E0);
  canvas.drawCentreString("Select an app:",120,58,1);
  // draw 5 app icons in a circle
  float angles[]={270,342,54,126,198};
  for(int i=0;i<NUM_APPS;i++){
    float a=degToRad(angles[i]);
    int cx=120+(int)(75*cos(a));
    int cy=120+(int)(75*sin(a));
    bool sel=(i==homeSelected);
    uint32_t bc=sel?apps[i].color:0x2104;
    canvas.fillCircle(cx,cy,22,bc);
    canvas.drawCircle(cx,cy,22,sel?TFT_WHITE:0x4208);
    canvas.setTextColor(sel?TFT_WHITE:0xC618);
    canvas.drawCentreString(apps[i].icon,cx,cy-6,1);
    canvas.setTextSize(1);
    canvas.drawCentreString(apps[i].name,cx,cy+8,1);
  }
  // selected app name big at bottom
  canvas.setTextColor(apps[homeSelected].color);
  canvas.drawCentreString(apps[homeSelected].name,120,215,1);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Rotate=select  Press=open",120,227,1);
}

// ─── DRAW FLIGHT SCREEN ──────────────────────────────────────────────────────
void drawFlightScreen(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,TFT_BLACK);
  drawGrid();
  drawSweep();
  drawTrails(viewLat,viewLon,planeRanges[planeZoom]);
  drawAirports(viewLat,viewLon,planeRanges[planeZoom]);
  // draw planes
  for(int i=0;i<acCount;i++){
    latLonToScreen(ac[i].lat,ac[i].lon,viewLat,viewLon,planeRanges[planeZoom],ac[i].sx,ac[i].sy);
    if(!inCircle(ac[i].sx,ac[i].sy)) continue;
    uint32_t col;
    if(ac[i].emergency){ col=(millis()%400)<200?TFT_RED:TFT_WHITE; }
    else if(ac[i].ufo){ col=0xF81F; }
    else if(ac[i].rare){ col=0xFFE0; }
    else if(ac[i].onGround){ col=TFT_YELLOW; }
    else {
      float bright=ac[i].sweepBright;
      uint8_t g=120+(uint8_t)(bright*135);
      col=canvas.color565(0,g,0);
    }
    drawPlane(ac[i].sx,ac[i].sy,ac[i].hdg,col,(i==selAc));
    if(ac[i].rare){ canvas.setTextColor(0xFFE0); canvas.drawString("★",ac[i].sx+8,ac[i].sy-8); }
    if(ac[i].ufo){ canvas.setTextColor(0xF81F); canvas.drawString("◈",ac[i].sx+8,ac[i].sy-8); }
    // callsign
    canvas.setTextSize(1); canvas.setTextColor(0xC618);
    canvas.drawString(ac[i].callsign.c_str(),ac[i].sx+8,ac[i].sy-4);
    // update sweep brightness
    float sa=sweepAngle; float pa=atan2(ac[i].sx-120,-(ac[i].sy-120))*180.0f/M_PI;
    if(pa<0) pa+=360;
    float diff=fmod(sa-pa+360,360);
    if(diff<5) ac[i].sweepBright=1.0f;
    else ac[i].sweepBright=max(0.0f,ac[i].sweepBright-0.01f);
  }
  // panels
  if(selAc>=0&&selAc<acCount) drawAircraftPanel(selAc);
  else if(selAp>=0&&selAp<apCount) drawAirportPanel(selAp);
  // banners
  if(showEmergBanner&&emergBannerIdx>=0) drawEmergBanner();
  if(showLaunchBanner&&launchBannerIdx>=0) drawLaunchBanner(launchBannerIdx);
  // HUD
  const char* modeLabels[]={"RADAR","SELECT"};
  uint32_t modeCols[]={0x07FF,0xFFE0};
  drawHUD(modeLabels[flightMode],modeCols[flightMode],acCount,planeRanges[planeZoom],false);
  canvas.pushSprite(0,0);
}

// ─── DRAW STATS SCREEN ───────────────────────────────────────────────────────
void drawStatsScreen(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0x07FF);
  canvas.setTextColor(0x07FF);
  canvas.drawCentreString("📊 STATS",120,18,2);
  canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
  char buf[48];
  snprintf(buf,48,"Planes seen: %d",statsTotalToday);
  canvas.drawCentreString(buf,120,55,1);
  if(statsBusiestAirline.length()>0){
    snprintf(buf,48,"Busiest: %s",statsBusiestAirline.c_str());
    canvas.drawCentreString(buf,120,72,1);
  }
  if(statsHighestCall.length()>0){
    snprintf(buf,48,"Highest: %s %.0fft",statsHighestCall.c_str(),statsHighestAlt);
    canvas.drawCentreString(buf,120,89,1);
  }
  if(statsFastestCall.length()>0){
    snprintf(buf,48,"Fastest: %s %.0fkts",statsFastestCall.c_str(),statsFastestSpd);
    canvas.drawCentreString(buf,120,106,1);
  }
  snprintf(buf,48,"Emergencies: %d",statsEmergToday);
  canvas.setTextColor(statsEmergToday>0?TFT_RED:TFT_WHITE);
  canvas.drawCentreString(buf,120,123,1);
  snprintf(buf,48,"Rare aircraft: %d",statsRareToday);
  canvas.setTextColor(0xFFE0); canvas.drawCentreString(buf,120,140,1);
  snprintf(buf,48,"UFOs spotted: %d 👽",statsUfoToday);
  canvas.setTextColor(0xF81F); canvas.drawCentreString(buf,120,157,1);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Press button to go back",120,210,1);
  canvas.pushSprite(0,0);
}

// ─── DRAW SPACE SCREEN ───────────────────────────────────────────────────────
void drawSpaceScreen(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,TFT_BLACK);
  drawGrid();
  drawSweep();
  // satellites
  for(int i=0;i<satCount;i++){
    latLonToScreen(sats[i].lat,sats[i].lon,viewLat,viewLon,spaceRanges[spaceZoom],sats[i].sx,sats[i].sy);
    if(!inCircle(sats[i].sx,sats[i].sy)) continue;
    uint32_t sc=sats[i].isISS?TFT_CYAN:0x7BFF;
    bool sel=(i==selSat);
    int sz=sel?5:3;
    canvas.fillCircle(sats[i].sx,sats[i].sy,sz,sc);
    canvas.drawLine(sats[i].sx-sz-1,sats[i].sy,sats[i].sx+sz+1,sats[i].sy,sc);
    canvas.drawLine(sats[i].sx,sats[i].sy-sz-1,sats[i].sx,sats[i].sy+sz+1,sc);
    if(sel){ canvas.drawCircle(sats[i].sx,sats[i].sy,8,TFT_WHITE); canvas.drawCircle(sats[i].sx,sats[i].sy,9,sc); }
    canvas.setTextSize(1); canvas.setTextColor(sc);
    canvas.drawString(sats[i].name.c_str(),sats[i].sx+8,sats[i].sy-4);
  }
  // launches
  for(int i=0;i<launchCount;i++){
    latLonToScreen(launches[i].siteLat,launches[i].siteLon,viewLat,viewLon,spaceRanges[spaceZoom],launches[i].sx,launches[i].sy);
    if(!inCircle(launches[i].sx,launches[i].sy)) continue;
    bool imminent=launches[i].minutesToLaunch<=60;
    bool flash=imminent?(millis()%500)<250:true;
    uint32_t lc=flash?(imminent?TFT_ORANGE:0xFD20):0x7400;
    bool sel=(i==selLaunch);
    int sz=sel?6:4;
    canvas.drawLine(launches[i].sx-sz,launches[i].sy,launches[i].sx+sz,launches[i].sy,lc);
    canvas.drawLine(launches[i].sx,launches[i].sy-sz,launches[i].sx,launches[i].sy+sz,lc);
    canvas.fillCircle(launches[i].sx,launches[i].sy,2,lc);
    if(sel){ canvas.drawCircle(launches[i].sx,launches[i].sy,9,TFT_WHITE); }
    canvas.setTextSize(1); canvas.setTextColor(lc);
    canvas.drawString(launches[i].rocketName.c_str(),launches[i].sx+8,launches[i].sy-4);
  }
  // panels
  if(selSat>=0&&selSat<satCount) drawSatellitePanel(selSat);
  else if(selLaunch>=0&&selLaunch<launchCount) drawLaunchPanel(selLaunch);
  // HUD
  const char* modeLabels[]={"SPACE","SELECT"};
  uint32_t modeCols[]={0x07FF,0xFFE0};
  drawHUD(modeLabels[spaceMode],modeCols[spaceMode],satCount+launchCount,spaceRanges[spaceZoom],true);
  canvas.pushSprite(0,0);
}

// ─── DRAW WEATHER APP ────────────────────────────────────────────────────────
void drawWeatherApp(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0410);
  canvas.drawCircle(120,120,118,0xFD20);
  canvas.setTextColor(0xFD20);
  canvas.drawCentreString("🌤 WEATHER",120,18,2);
  canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
  canvas.drawCentreString(locationCity.c_str(),120,46,2);
  if(weatherLoaded){
    char buf[48];
    snprintf(buf,48,"%.1f°C",weatherTemp);
    canvas.setTextColor(weatherTemp>20?TFT_RED:weatherTemp>10?0xFD20:TFT_CYAN);
    canvas.drawCentreString(buf,120,72,2);
    snprintf(buf,48,"Wind: %.0f kts %s",weatherWindSpd,compassDir(weatherWindDir).c_str());
    canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(buf,120,100,1);
    snprintf(buf,48,"Cloud: %.0f%%",weatherCloud);
    canvas.setTextColor(0xC618); canvas.drawCentreString(buf,120,116,1);
    snprintf(buf,48,"Rain: %.1f mm",weatherRain);
    canvas.setTextColor(0x07FF); canvas.drawCentreString(buf,120,132,1);
    // simple weather icon
    if(weatherRain>0.5f){ canvas.setTextColor(0x07FF); canvas.drawCentreString("🌧 RAIN",120,155,2); }
    else if(weatherCloud>70){ canvas.setTextColor(0xC618); canvas.drawCentreString("☁ CLOUDY",120,155,2); }
    else if(weatherCloud>30){ canvas.setTextColor(0xFD20); canvas.drawCentreString("⛅ PARTLY CLOUDY",120,155,2); }
    else{ canvas.setTextColor(0xFFE0); canvas.drawCentreString("☀ CLEAR",120,155,2); }
  } else {
    canvas.setTextColor(0xC618); canvas.drawCentreString("Loading weather...",120,100,1);
  }
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Press button to go back",120,210,1);
  canvas.pushSprite(0,0);
}

// ─── PLANE FILTERS ──────────────────────────────────────────────────────────
bool filterAirliners=true, filterMilitary=true, filterPrivate=true;
bool filterHelicopters=true, filterUnknown=true;
int filterMinAlt=0; // 0=off, 1=1000ft, 2=5000ft, 3=10000ft
int filterMinSpd=0; // 0=off, 1=50kts, 2=100kts, 3=250kts
int filterSettingIdx=0;
#define NUM_FILTER_SETTINGS 9

void drawFilterSettings(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0xFD20);
  canvas.setTextColor(0xFD20);
  canvas.drawCentreString("PLANE FILTERS",120,14,1);
  canvas.setTextSize(1);
  const char* labels[]={
    "Min Altitude","Min Speed",
    "Show Airliners","Show Military",
    "Show Private Jets","Show Helicopters",
    "Show Unknown"
  };
  const char* altVals[]={"OFF","1000ft","5000ft","10000ft"};
  const char* spdVals[]={"OFF","50kts","100kts","250kts"};
  bool* bools[]={&filterAirliners,&filterMilitary,&filterPrivate,&filterHelicopters,&filterUnknown};
  int yStart=34;
  for(int i=0;i<7;i++){
    bool sel=(i==filterSettingIdx);
    canvas.setTextColor(sel?TFT_WHITE:0xC618);
    if(sel){ canvas.fillRoundRect(15,yStart-2,210,14,3,0x2104); canvas.drawRoundRect(15,yStart-2,210,14,3,0xFD20); }
    char buf[40];
    if(i==0) snprintf(buf,40,"%s: %s",labels[i],altVals[filterMinAlt]);
    else if(i==1) snprintf(buf,40,"%s: %s",labels[i],spdVals[filterMinSpd]);
    else snprintf(buf,40,"%s: %s",labels[i],*bools[i-2]?"ON":"OFF");
    canvas.drawCentreString(buf,120,yStart,1);
    yStart+=18;
  }
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Rotate=select  Press=toggle",120,164,1);
  canvas.drawCentreString("Hold btn=back",120,178,1);
  if(filterOn){ canvas.setTextColor(TFT_GREEN); canvas.drawCentreString("FILTERS ACTIVE",120,196,1); }
  canvas.pushSprite(0,0);
}

// ─── GAMES ───────────────────────────────────────────────────────────────────
int gameSelected=0;
#define NUM_GAMES 3
const char* gameNames[]={"PLANE CATCHER","AIRPORT QUIZ","ROCKET LAUNCH"};

// --- Plane Catcher ---

#define MAX_FLYING 5
FlyingPlane flyingPlanes[MAX_FLYING];
int catchScore=0, catchLives=3, catchHighScore=0;
bool catchGameOver=false;
unsigned long catchLastSpawn=0, catchLastMove=0;

void spawnFlyingPlane(int i){
  flyingPlanes[i].active=true;
  int edge=random(4);
  if(edge==0){flyingPlanes[i].x=120+random(-80,80);flyingPlanes[i].y=20;flyingPlanes[i].dx=random(-2,3);flyingPlanes[i].dy=random(1,3);}
  else if(edge==1){flyingPlanes[i].x=220;flyingPlanes[i].y=120+random(-80,80);flyingPlanes[i].dx=random(-3,-1);flyingPlanes[i].dy=random(-2,3);}
  else if(edge==2){flyingPlanes[i].x=120+random(-80,80);flyingPlanes[i].y=220;flyingPlanes[i].dx=random(-2,3);flyingPlanes[i].dy=random(-3,-1);}
  else{flyingPlanes[i].x=20;flyingPlanes[i].y=120+random(-80,80);flyingPlanes[i].dx=random(1,3);flyingPlanes[i].dy=random(-2,3);}
  const char* labels[]={"BAW","EZY","RYR","UAL","DAL","AFR","DLH","UAE"};
  flyingPlanes[i].label=String(labels[random(8)])+String(random(100,999));
}

void initPlaneCatcher(){
  catchScore=0; catchLives=3; catchGameOver=false;
  for(int i=0;i<MAX_FLYING;i++) flyingPlanes[i].active=false;
  catchLastSpawn=millis(); catchLastMove=millis();
  spawnFlyingPlane(0);
}

void runPlaneCatcher(){
  if(catchGameOver){
    canvas.fillScreen(TFT_BLACK);
    canvas.fillCircle(120,120,118,0x0821);
    canvas.drawCircle(120,120,118,TFT_RED);
    canvas.setTextColor(TFT_RED); canvas.drawCentreString("GAME OVER!",120,75,2);
    canvas.setTextColor(TFT_WHITE);
    char buf[32]; snprintf(buf,32,"Score: %d",catchScore); canvas.drawCentreString(buf,120,105,2);
    snprintf(buf,32,"Best: %d",catchHighScore); canvas.setTextColor(0xFD20); canvas.drawCentreString(buf,120,130,1);
    canvas.setTextColor(0xC618); canvas.drawCentreString("Press btn to play again",120,165,1);
    canvas.drawCentreString("Hold btn to exit",120,181,1);
    canvas.pushSprite(0,0);
    if(M5Dial.BtnA.wasPressed()){ initPlaneCatcher(); }
    return;
  }
  // move planes
  unsigned long now=millis();
  int spd=1+catchScore/5;
  if(now-catchLastMove>80/spd){
    catchLastMove=now;
    for(int i=0;i<MAX_FLYING;i++){
      if(!flyingPlanes[i].active) continue;
      flyingPlanes[i].x+=flyingPlanes[i].dx;
      flyingPlanes[i].y+=flyingPlanes[i].dy;
      if(!inCircle(flyingPlanes[i].x,flyingPlanes[i].y)){
        flyingPlanes[i].active=false;
        catchLives--;
        M5Dial.Speaker.tone(300,100);
        if(catchLives<=0){ catchGameOver=true; if(catchScore>catchHighScore) catchHighScore=catchScore; }
      }
    }
  }
  // spawn
  if(now-catchLastSpawn>2000-min(catchScore*50,1500)){
    catchLastSpawn=now;
    for(int i=0;i<MAX_FLYING;i++) if(!flyingPlanes[i].active){ spawnFlyingPlane(i); break; }
  }
  // draw
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0x07E0);
  canvas.setTextColor(TFT_GREEN); canvas.setTextSize(1);
  canvas.drawCentreString("PLANE CATCHER",120,14,1);
  char buf[32]; snprintf(buf,32,"Score:%d",catchScore); canvas.setTextColor(TFT_WHITE); canvas.drawString(buf,10,14);
  for(int i=0;i<catchLives;i++) canvas.fillCircle(190+i*12,18,4,TFT_RED);
  for(int i=0;i<MAX_FLYING;i++){
    if(!flyingPlanes[i].active) continue;
    float ang=atan2(flyingPlanes[i].dy,flyingPlanes[i].dx)*180.0f/M_PI+90;
    drawPlane(flyingPlanes[i].x,flyingPlanes[i].y,ang,TFT_GREEN,false);
    canvas.setTextColor(0xC618); canvas.drawString(flyingPlanes[i].label.c_str(),flyingPlanes[i].x+8,flyingPlanes[i].y-4);
  }
  canvas.setTextColor(0xC618); canvas.drawCentreString("TAP planes to catch!",120,225,1);
  canvas.pushSprite(0,0);
  // touch to catch
  if(M5Dial.Touch.getCount()>0){
    auto t=M5Dial.Touch.getDetail(0);
    if(t.state==2){
      for(int i=0;i<MAX_FLYING;i++){
        if(!flyingPlanes[i].active) continue;
        if(abs(t.x-flyingPlanes[i].x)<=14&&abs(t.y-flyingPlanes[i].y)<=14){
          flyingPlanes[i].active=false;
          catchScore++;
          M5Dial.Speaker.tone(1200,40);
          spawnFlyingPlane(i);
        }
      }
    }
  }
}

// --- Airport Quiz ---

QuizQ quizQuestions[]={
  {"EGLL","UK",{"UK","France","Germany","Spain"}},
  {"KJFK","USA",{"Canada","USA","Mexico","Brazil"}},
  {"OMDB","UAE",{"Saudi Arabia","UAE","Qatar","Kuwait"}},
  {"YSSY","Australia",{"New Zealand","Australia","Japan","China"}},
  {"LFPG","France",{"Belgium","France","Italy","Portugal"}},
  {"EDDF","Germany",{"Austria","Switzerland","Germany","Poland"}},
  {"LEMD","Spain",{"Spain","Portugal","Italy","Greece"}},
  {"EHAM","Netherlands",{"Belgium","Netherlands","Denmark","Sweden"}},
  {"RJTT","Japan",{"South Korea","China","Japan","Taiwan"}},
  {"ZBAA","China",{"China","Mongolia","Russia","South Korea"}},
  {"VIDP","India",{"India","Pakistan","Bangladesh","Nepal"}},
  {"FACT","South Africa",{"South Africa","Kenya","Nigeria","Egypt"}},
  {"CYYZ","Canada",{"USA","Canada","Mexico","Cuba"}},
  {"SBGR","Brazil",{"Argentina","Brazil","Chile","Colombia"}},
  {"WSSS","Singapore",{"Malaysia","Singapore","Thailand","Indonesia"}},
};
#define NUM_QUIZ_Q 15
int quizIdx=0, quizScore=0, quizTotal=0, quizSelected=0;
bool quizAnswered=false, quizCorrect=false;

void initQuiz(){ quizIdx=random(NUM_QUIZ_Q); quizScore=0; quizTotal=0; quizSelected=0; quizAnswered=false; }

void runAirportQuiz(){
  if(quizTotal>=10){
    canvas.fillScreen(TFT_BLACK);
    canvas.fillCircle(120,120,118,0x0821);
    canvas.drawCircle(120,120,118,0xFD20);
    canvas.setTextColor(0xFD20); canvas.drawCentreString("QUIZ DONE!",120,70,2);
    char buf[32]; snprintf(buf,32,"%d / 10 correct",quizScore);
    canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(buf,120,105,2);
    canvas.setTextColor(quizScore>=7?TFT_GREEN:quizScore>=4?0xFD20:TFT_RED);
    canvas.drawCentreString(quizScore>=7?"EXCELLENT!":quizScore>=4?"GOOD TRY!":"KEEP PRACTISING!",120,135,1);
    canvas.setTextColor(0xC618); canvas.drawCentreString("Press btn to play again",120,170,1);
    canvas.pushSprite(0,0);
    if(M5Dial.BtnA.wasPressed()){ initQuiz(); }
    return;
  }
  QuizQ& q=quizQuestions[quizIdx%NUM_QUIZ_Q];
  // encoder scroll
  long enc=M5Dial.Encoder.read();
  long diff=enc-lastEnc;
  if(diff!=0){ quizSelected=(quizSelected+diff+4)%4; lastEnc=enc; M5Dial.Speaker.tone(900,20); }
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0xFD20);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString("AIRPORT QUIZ",120,14,1);
  char buf[32]; snprintf(buf,32,"Q %d/10",quizTotal+1); canvas.setTextColor(0xC618); canvas.drawString(buf,10,14);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString("Which country is:",120,38,1);
  canvas.setTextColor(TFT_CYAN); canvas.drawCentreString(q.icao,120,58,2);
  for(int i=0;i<4;i++){
    bool sel=(i==quizSelected);
    uint32_t col=TFT_WHITE;
    if(quizAnswered){
      if(strcmp(q.opts[i],q.correct)==0) col=TFT_GREEN;
      else if(sel) col=TFT_RED;
    } else if(sel) col=0xFD20;
    int y=95+i*32;
    if(sel&&!quizAnswered){ canvas.fillRoundRect(25,y-2,190,20,4,0x2104); canvas.drawRoundRect(25,y-2,190,20,4,0xFD20); }
    canvas.setTextColor(col);
    char opt[32]; snprintf(opt,32,"%c) %s",'A'+i,q.opts[i]);
    canvas.drawCentreString(opt,120,y,1);
  }
  if(quizAnswered){
    canvas.setTextColor(quizCorrect?TFT_GREEN:TFT_RED);
    canvas.drawCentreString(quizCorrect?"CORRECT! ":"WRONG!",120,225,1);
  } else {
    canvas.setTextColor(0xC618); canvas.drawCentreString("Press btn to answer",120,225,1);
  }
  canvas.pushSprite(0,0);
  if(M5Dial.BtnA.wasPressed()){
    if(!quizAnswered){
      quizAnswered=true;
      quizCorrect=(strcmp(q.opts[quizSelected],q.correct)==0);
      if(quizCorrect){ quizScore++; M5Dial.Speaker.tone(1800,80); }
      else M5Dial.Speaker.tone(300,150);
    } else {
      quizAnswered=false; quizTotal++; quizSelected=0;
      quizIdx=(quizIdx+1)%NUM_QUIZ_Q;
    }
  }
}

// --- Rocket Launch Timing Game ---
int rocketScore=0; // ms off, lower=better
int rocketBest=99999;
bool rocketRunning=false, rocketWaiting=false, rocketResult=false;
unsigned long rocketStart=0, rocketLaunchAt=0;

void initRocketGame(){ rocketRunning=false; rocketWaiting=true; rocketResult=false;
  rocketLaunchAt=millis()+random(3000,10000); // random delay
}

void runRocketLaunch(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0xFD20);
  canvas.setTextColor(0xFD20); canvas.setTextSize(1);
  canvas.drawCentreString("ROCKET LAUNCH",120,14,1);
  if(rocketBest<99999){ char buf[32]; snprintf(buf,32,"Best: %dms",rocketBest); canvas.setTextColor(0xC618); canvas.drawString(buf,8,14); }
  if(rocketResult){
    canvas.setTextColor(rocketScore<100?TFT_GREEN:rocketScore<300?0xFD20:TFT_RED);
    canvas.drawCentreString(rocketScore<100?"PERFECT!":rocketScore<300?"GOOD!":"TOO SLOW!",120,70,2);
    char buf[32]; snprintf(buf,32,"%d ms off",rocketScore); canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(buf,120,105,1);
    canvas.setTextColor(0xC618); canvas.drawCentreString("Press btn to retry",120,165,1);
    canvas.pushSprite(0,0);
    if(M5Dial.BtnA.wasPressed()){ initRocketGame(); }
    return;
  }
  if(rocketWaiting){
    unsigned long now=millis();
    long remaining=(long)(rocketLaunchAt-now);
    if(remaining>0){
      canvas.setTextColor(TFT_WHITE); canvas.drawCentreString("Get ready...",120,80,1);
      char buf[32];
      if(remaining>1000){ snprintf(buf,32,"T-%lds",(remaining/1000)); canvas.setTextColor(0xFD20); canvas.drawCentreString(buf,120,110,2); }
      else { canvas.setTextColor(TFT_RED); canvas.drawCentreString("NOW!",120,110,2); }
      canvas.setTextColor(0xC618); canvas.drawCentreString("Press BTN at T-0!",120,160,1);
      // draw rocket
      int rx=120, ry=190;
      canvas.fillTriangle(rx,ry-20,rx-8,ry+10,rx+8,ry+10,0xFD20);
      canvas.fillRect(rx-5,ry+10,10,15,0xC618);
      if(remaining<500){ canvas.fillCircle(rx,ry+30,4,TFT_ORANGE); canvas.fillCircle(rx-3,ry+34,3,TFT_RED); canvas.fillCircle(rx+3,ry+34,3,TFT_RED); }
    } else {
      rocketWaiting=false; rocketRunning=true; rocketStart=millis();
    }
    // early press = fail
    if(M5Dial.BtnA.wasPressed()&&remaining>200){
      rocketScore=9999; if(rocketScore<rocketBest) rocketBest=rocketScore;
      rocketResult=true; rocketRunning=false; rocketWaiting=false;
      M5Dial.Speaker.tone(300,200);
    }
  }
  if(rocketRunning){
    canvas.setTextColor(TFT_RED); canvas.drawCentreString("LAUNCH!",120,100,2);
    canvas.setTextColor(0xFD20); canvas.drawCentreString("PRESS NOW!",120,140,2);
    int rx=120, ry=100;
    canvas.fillTriangle(rx,ry-50,rx-8,ry-20,rx+8,ry-20,0xFD20);
    canvas.fillCircle(rx,ry-10,5,TFT_ORANGE); canvas.fillCircle(rx,ry-5,8,TFT_RED);
    if(millis()-rocketStart>2000){ rocketScore=2000; if(rocketScore<rocketBest) rocketBest=rocketScore; rocketResult=true; rocketRunning=false; M5Dial.Speaker.tone(300,200); }
    if(M5Dial.BtnA.wasPressed()){ rocketScore=(int)(millis()-rocketStart); if(rocketScore<rocketBest) rocketBest=rocketScore; rocketResult=true; rocketRunning=false; M5Dial.Speaker.tone(rocketScore<200?1800:rocketScore<500?1200:600,100); }
  }
  canvas.pushSprite(0,0);
}

void drawGamesMenu(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0xF81F);
  canvas.setTextColor(0xF81F);
  canvas.drawCentreString("GAMES",120,18,2);
  canvas.setTextColor(0xC618); canvas.setTextSize(1);
  canvas.drawCentreString("Rotate to select, Press to play",120,42,1);
  for(int i=0;i<NUM_GAMES;i++){
    bool sel=(i==gameSelected);
    int y=75+i*45;
    if(sel){ canvas.fillRoundRect(20,y-4,200,32,6,0x2104); canvas.drawRoundRect(20,y-4,200,32,6,0xF81F); }
    canvas.setTextColor(sel?TFT_WHITE:0xC618);
    canvas.drawCentreString(gameNames[i],120,y,1);
    if(i==0){ canvas.setTextColor(sel?0xFD20:0x4208); canvas.drawCentreString("Tap planes before they escape!",120,y+14,1); }
    else if(i==1){ canvas.setTextColor(sel?0xFD20:0x4208); canvas.drawCentreString("Guess the airport country!",120,y+14,1); }
    else { canvas.setTextColor(sel?0xFD20:0x4208); canvas.drawCentreString("Press at exactly T-0!",120,y+14,1); }
  }
  canvas.setTextColor(0xC618); canvas.drawCentreString("Hold btn to go home",120,218,1);
  canvas.pushSprite(0,0);
}

// ─── DRAW SETTINGS ───────────────────────────────────────────────────────────
bool adjustingBrightness=false;
void drawSettings(){
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0xC618);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("SETTINGS",120,14,2);
  canvas.setTextSize(1);
  if(adjustingBrightness){
    canvas.setTextColor(TFT_WHITE);
    canvas.drawCentreString("BRIGHTNESS",120,50,1);
    int pct=map(screenBright,10,255,4,100);
    char buf[16]; snprintf(buf,16,"%d%%",pct);
    canvas.setTextColor(0xFD20); canvas.drawCentreString(buf,120,80,2);
    int bw=180, bh=14, bx=30, by=108;
    canvas.drawRoundRect(bx,by,bw,bh,4,0xC618);
    int fill=map(screenBright,10,255,0,bw-2);
    canvas.fillRoundRect(bx+1,by+1,fill,bh-2,3,0xFD20);
    canvas.setTextColor(0xC618);
    canvas.drawCentreString("Rotate knob to adjust",120,135,1);
    canvas.drawCentreString("Press to save",120,150,1);
    canvas.pushSprite(0,0);
    return;
  }
  canvas.setTextColor(nightAuto?TFT_GREEN:0xC618);
  canvas.drawCentreString(nightAuto?"NIGHT: AUTO":"NIGHT: MANUAL",120,48,1);
  canvas.setTextColor(nightMode?TFT_YELLOW:0xC618);
  canvas.drawCentreString(nightMode?"NIGHT MODE: ON":"NIGHT MODE: OFF",120,64,1);
  canvas.setTextColor(filterOn?TFT_GREEN:0xC618);
  canvas.drawCentreString(filterOn?"FILTER: ON":"FILTER: OFF",120,84,1);
  canvas.setTextColor(ufoMode?0xF81F:0xC618);
  canvas.drawCentreString(ufoMode?"UFO MODE: ON":"UFO MODE: OFF",120,100,1);
  int pct=map(screenBright,10,255,4,100);
  char brbuf[24]; snprintf(brbuf,24,"BRIGHTNESS: %d%%",pct);
  canvas.setTextColor(0xFD20);
  canvas.drawCentreString(brbuf,120,120,1);
  canvas.setTextColor(TFT_RED);
  canvas.drawCentreString("[ RESET WIFI ]",120,142,1);
  canvas.setTextColor(0xC618);
  canvas.drawCentreString("Tap to toggle options",120,168,1);
  canvas.drawCentreString("Press btn to go back",120,184,1);
  canvas.pushSprite(0,0);
}

// ─── TOUCH HANDLER ───────────────────────────────────────────────────────────
void handleTouch(int tx, int ty){
  // help screen
  if(showHelp){ showHelp=false; return; }
  // qr screen
  if(showQR){ showQR=false; return; }
  // help button (bottom right circle)
  if((tx-224)*(tx-224)+(ty-224)*(ty-224)<=144){ showHelp=true; return; }

  if(appState==APP_HOME){
    // tap on app icons
    float angles[]={270,342,54,126,198};
    for(int i=0;i<NUM_APPS;i++){
      float a=degToRad(angles[i]);
      int cx=120+(int)(75*cos(a)), cy=120+(int)(75*sin(a));
      if((tx-cx)*(tx-cx)+(ty-cy)*(ty-cy)<=22*22){
        appState=apps[i].target;
        if(appState==APP_FLIGHT){ lastFetch=0; fetchFlights(); fetchAirports(); }
        if(appState==APP_SPACE){ fetchSpace(); }
        if(appState==APP_WEATHER_APP&&!weatherLoaded){ fetchWeather(); }
        return;
      }
    }
    return;
  }

  if(appState==APP_FLIGHT){
    // emergency banner
    if(showEmergBanner&&ty>=28&&ty<=46&&tx>=20&&tx<=220){ selAc=emergBannerIdx; return; }
    // launch banner
    if(showLaunchBanner&&ty>=28&&ty<=46&&tx>=20&&tx<=220){ appState=APP_SPACE; return; }
    // tap plane
    for(int i=0;i<acCount;i++){
      if(abs(tx-ac[i].sx)<=10&&abs(ty-ac[i].sy)<=10){ selAc=i; selAp=-1; return; }
    }
    // tap airport
    for(int i=0;i<apCount;i++){
      int sx,sy; latLonToScreen(airports[i].lat,airports[i].lon,viewLat,viewLon,planeRanges[planeZoom],sx,sy);
      if(abs(tx-sx)<=8&&abs(ty-sy)<=8){ selAp=i; selAc=-1; return; }
    }
    // tap selected panel - tap to clear
    if(ty>150){ selAc=-1; selAp=-1; return; }
    selAc=-1; selAp=-1;
  }

  if(appState==APP_SPACE){
    // tap satellite
    for(int i=0;i<satCount;i++){
      if(abs(tx-sats[i].sx)<=10&&abs(ty-sats[i].sy)<=10){ selSat=i; selLaunch=-1; return; }
    }
    // tap launch
    for(int i=0;i<launchCount;i++){
      if(abs(tx-launches[i].sx)<=10&&abs(ty-launches[i].sy)<=10){ selLaunch=i; selSat=-1; return; }
    }
    // tap livestream panel
    if((selSat>=0||selLaunch>=0)&&ty>200&&ty<220){
      qrUrl=selSat>=0?sats[selSat].streamUrl:launches[selLaunch].streamUrl;
      streamTitle=selSat>=0?sats[selSat].name:launches[selLaunch].rocketName;
      showQR=true; return;
    }
    if(ty>150){ selSat=-1; selLaunch=-1; return; }
    selSat=-1; selLaunch=-1;
  }

  if(appState==APP_SETTINGS){
    if(adjustingBrightness){ adjustingBrightness=false; prefs.begin("cfg",false); prefs.putInt("bright",screenBright); prefs.end(); return; }
    if(ty>=40&&ty<=56){ nightAuto=!nightAuto; return; }
    if(ty>=56&&ty<=72){ if(!nightAuto) nightMode=!nightMode; return; }
    if(ty>=76&&ty<=92){ filterOn=!filterOn; return; }
    if(ty>=92&&ty<=108){ ufoMode=!ufoMode; return; }
    if(ty>=112&&ty<=128){ adjustingBrightness=true; return; }
    if(ty>=134&&ty<=150){ prefs.begin("wifi",false); prefs.clear(); prefs.end(); appState=APP_WIFI_SETUP; return; }
  }
}

// ─── SETUP ───────────────────────────────────────────────────────────────────
void setup(){
  auto cfg=M5Dial.config();
  M5Dial.begin(cfg,true,false);
  M5Dial.Display.setBrightness(220);
  canvas.createSprite(240,240);
  canvas.setTextDatum(MC_DATUM);

  // boot screen
  canvas.fillScreen(TFT_BLACK);
  canvas.fillCircle(120,120,118,0x0821);
  canvas.drawCircle(120,120,118,0x07E0);
  canvas.setTextColor(TFT_WHITE);
  canvas.drawCentreString("✈ FLIGHT &",120,80,2);
  canvas.drawCentreString("🚀 SPACE",120,100,2);
  canvas.drawCentreString("RADAR",120,120,2);
  canvas.setTextColor(0x07E0);
  canvas.drawCentreString("M5Dial v2.0",120,148,1);
  canvas.pushSprite(0,0);
  delay(2000);

  // load saved settings
  prefs.begin("cfg",true);
  screenBright=prefs.getInt("bright",220);
  prefs.end();
  M5Dial.Display.setBrightness(screenBright);
  // check saved wifi
  prefs.begin("wifi",true);
  wifiSSID=prefs.getString("ssid","");
  wifiPass=prefs.getString("pass","");
  prefs.end();

  if(wifiSSID.length()==0){
    appState=APP_WIFI_SETUP;
    // intro
    canvas.fillScreen(TFT_BLACK);
    canvas.fillCircle(120,120,118,0x0821);
    canvas.drawCircle(120,120,118,TFT_WHITE);
    canvas.setTextColor(0xFD20);
    canvas.drawCentreString("WiFi Setup",120,60,2);
    canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
    canvas.drawCentreString("Rotate dial to",120,95,1);
    canvas.drawCentreString("pick letters",120,110,1);
    canvas.drawCentreString("Press to confirm",120,125,1);
    canvas.drawCentreString("Hold to delete",120,140,1);
    canvas.drawCentreString("Scroll to DONE",120,155,1);
    canvas.pushSprite(0,0);
    delay(3000);
    enteringSSID=true; currentInput=""; charIdx=0;
  } else {
    appState=APP_CONNECTING;
  }
  lastEnc=M5Dial.Encoder.read();
}

// ─── LOOP ────────────────────────────────────────────────────────────────────
void loop(){
  M5Dial.update();
  updateNightMode();

  // ── WIFI KEYBOARD ──────────────────────────────────────────────────────────
  if(appState==APP_WIFI_SETUP){
    long enc=M5Dial.Encoder.read();
    long diff=enc-lastEnc;
    if(diff!=0){ charIdx=(charIdx+diff+charsetLen)%charsetLen; lastEnc=enc; }
    drawWifiKeyboard(enteringSSID);
    canvas.pushSprite(0,0);
    // button press
    if(M5Dial.BtnA.wasPressed()){
      String sel=String(charset[charIdx]);
      if(sel=="DONE"){
        if(enteringSSID){ wifiSSID=currentInput; currentInput=""; charIdx=0; enteringSSID=false; }
        else { wifiPass=currentInput; prefs.begin("wifi",false); prefs.putString("ssid",wifiSSID); prefs.putString("pass",wifiPass); prefs.end(); appState=APP_CONNECTING; }
      } else { currentInput+=sel; }
    }
    if(M5Dial.BtnA.pressedFor(600)&&currentInput.length()>0){ currentInput=currentInput.substring(0,currentInput.length()-1); delay(200); }
    return;
  }

  // ── CONNECTING ─────────────────────────────────────────────────────────────
  if(appState==APP_CONNECTING){
    canvas.fillScreen(TFT_BLACK);
    canvas.fillCircle(120,120,118,0x0821);
    canvas.setTextColor(TFT_WHITE);
    canvas.drawCentreString("Connecting to",120,85,1);
    canvas.drawCentreString("WiFi...",120,100,1);
    static int dots=0;
    char dotStr[8]=""; for(int i=0;i<(dots%4);i++) strcat(dotStr,"●");
    canvas.setTextColor(0x07E0); canvas.drawCentreString(dotStr,120,118,1);
    canvas.setTextColor(0xC618); canvas.drawCentreString(wifiSSID.c_str(),120,135,1);
    canvas.pushSprite(0,0);
    dots++; delay(300);
    WiFi.begin(wifiSSID.c_str(),wifiPass.c_str());
    unsigned long t=millis();
    while(WiFi.status()!=WL_CONNECTED&&millis()-t<12000){ delay(200); }
    if(WiFi.status()==WL_CONNECTED){ appState=APP_LOCATING; }
    else {
      canvas.fillScreen(TFT_BLACK); canvas.fillCircle(120,120,118,0x4000);
      canvas.setTextColor(TFT_RED); canvas.drawCentreString("WiFi Failed!",120,100,2);
      canvas.setTextColor(TFT_WHITE); canvas.drawCentreString("Hold btn to reset",120,130,1);
      canvas.pushSprite(0,0); delay(3000);
      appState=APP_WIFI_SETUP; wifiSSID=""; wifiPass=""; currentInput=""; charIdx=0; enteringSSID=true;
    }
    return;
  }

  // ── LOCATING ───────────────────────────────────────────────────────────────
  if(appState==APP_LOCATING){
    canvas.fillScreen(TFT_BLACK); canvas.fillCircle(120,120,118,0x0821);
    canvas.setTextColor(TFT_WHITE); canvas.drawCentreString("Finding your",120,95,1);
    canvas.drawCentreString("location...",120,110,1);
    canvas.setTextColor(0x07E0); canvas.drawCentreString("📍",120,130,2);
    canvas.pushSprite(0,0);
    geolocate();
    canvas.fillScreen(TFT_BLACK); canvas.fillCircle(120,120,118,0x0821);
    canvas.setTextColor(TFT_GREEN); canvas.drawCentreString("Location found!",120,75,1);
    canvas.setTextColor(TFT_WHITE); canvas.drawCentreString(locationCity.c_str(),120,100,2);
    canvas.drawCentreString(locationCountry.c_str(),120,120,1);
    char coord[32]; snprintf(coord,32,"%.4f, %.4f",homeLat,homeLon);
    canvas.setTextColor(0x07FF); canvas.drawCentreString(coord,120,138,1);
    canvas.pushSprite(0,0); delay(2500);
    // how to use
    canvas.fillScreen(TFT_BLACK); canvas.fillCircle(120,120,118,0x0821);
    canvas.drawCircle(120,120,118,TFT_WHITE);
    canvas.setTextColor(0xFD20); canvas.drawCentreString("HOW TO USE",120,25,2);
    canvas.setTextColor(TFT_WHITE); canvas.setTextSize(1);
    canvas.drawCentreString("Rotate knob = zoom",120,58,1);
    canvas.drawCentreString("Press knob = free scroll",120,73,1);
    canvas.drawCentreString("Short btn = cycle mode",120,88,1);
    canvas.drawCentreString("Hold btn 2s = SPACE mode",120,103,1);
    canvas.drawCentreString("Tap plane = select info",120,118,1);
    canvas.drawCentreString("Tap ? = help screen",120,133,1);
    canvas.drawCentreString("Drag = scroll map",120,148,1);
    canvas.setTextColor(0x07E0); canvas.drawCentreString("Home screen has 5 apps!",120,170,1);
    canvas.setTextColor(0xFD20); canvas.drawCentreString("Select app with knob",120,185,1);
    canvas.drawCentreString("Press knob to open",120,200,1);
    canvas.pushSprite(0,0); delay(4000);
    appState=APP_HOME;
    fetchWeather();
    return;
  }

  // ── HOME SCREEN ────────────────────────────────────────────────────────────
  if(appState==APP_HOME){
    long enc=M5Dial.Encoder.read();
    long diff=enc-lastEnc;
    if(diff!=0){ homeSelected=(homeSelected+diff+NUM_APPS)%NUM_APPS; lastEnc=enc; M5Dial.Speaker.tone(1200,30); }
    drawHomeScreen();
    canvas.pushSprite(0,0);
    if(M5Dial.BtnA.wasPressed()){
      M5Dial.Speaker.tone(1800,50);
      appState=apps[homeSelected].target;
      if(appState==APP_FLIGHT){ lastFetch=0; fetchFlights(); fetchAirports(); }
      if(appState==APP_SPACE){ fetchSpace(); }
      if(appState==APP_WEATHER_APP&&!weatherLoaded){ fetchWeather(); }
      if(appState==APP_GAMES){ gameSelected=0; }
    }
    return;
  }

  // ── GAMES ─────────────────────────────────────────────────────────────────
  if(appState==APP_GAMES){
    static int gamesSubState=0; // 0=menu, 1=planecatcher, 2=quiz, 3=rocket
    if(gamesSubState==0){
      long enc=M5Dial.Encoder.read();
      long diff=enc-lastEnc;
      if(diff!=0){ gameSelected=(gameSelected+diff+NUM_GAMES)%NUM_GAMES; lastEnc=enc; M5Dial.Speaker.tone(900,20); }
      drawGamesMenu();
      if(M5Dial.BtnA.wasPressed()){
        gamesSubState=gameSelected+1;
        if(gamesSubState==1) initPlaneCatcher();
        else if(gamesSubState==2) initQuiz();
        else if(gamesSubState==3) initRocketGame();
      }
    } else if(gamesSubState==1){
      runPlaneCatcher();
    } else if(gamesSubState==2){
      runAirportQuiz();
    } else if(gamesSubState==3){
      runRocketLaunch();
    }
    // hold to go back
    if(M5Dial.BtnA.isPressed()){
      if(btnPressTime==0) btnPressTime=millis();
      if(millis()-btnPressTime>2000&&!btnHeld){ btnHeld=true; if(gamesSubState>0){ gamesSubState=0; } else { appState=APP_HOME; } M5Dial.Speaker.tone(500,50); }
    } else { if(!btnHeld) btnPressTime=0; btnHeld=false; }
    return;
  }

  // ── TOUCH FOR ALL APP SCREENS ──────────────────────────────────────────────
  if(M5Dial.Touch.getCount()>0){
    auto t=M5Dial.Touch.getDetail(0);
    static int prevTx=-1, prevTy=-1;
    static bool dragging=false;
    if(t.state==0){ // pressed
      prevTx=t.x; prevTy=t.y; dragging=false;
    } else if(t.state==1){ // held/dragged
      if(prevTx>=0){
        int dx=t.x-prevTx, dy=t.y-prevTy;
        if(abs(dx)>3||abs(dy)>3){
          dragging=true;
          float dLon=-(float)dx*planeRanges[planeZoom]/(110.0f*120.0f);
          float dLat=(float)dy*planeRanges[planeZoom]/(110.0f*120.0f);
          viewLat+=dLat; viewLon+=dLon;
          freeScroll=true;
          prevTx=t.x; prevTy=t.y;
        }
      }
    } else if(t.state==2){ // released
      if(!dragging){ handleTouch(t.x,t.y); }
      if(dragging){ lastFetch=0; }
      dragging=false; prevTx=-1; prevTy=-1;
    }
  }

  // ── ENCODER ZOOM ──────────────────────────────────────────────────────────
  long enc=M5Dial.Encoder.read();
  long diff=enc-lastEnc;
  if(diff!=0){
    lastEnc=enc;
    if(appState==APP_SETTINGS&&adjustingBrightness){
      screenBright=constrain(screenBright+(diff>0?15:-15),10,255);
      M5Dial.Display.setBrightness(screenBright);
      M5Dial.Speaker.tone(diff>0?1200:900,20);
    } else if(appState==APP_FLIGHT||appState==APP_STATS_APP){
      planeZoom=constrain(planeZoom+(diff>0?1:-1),0,PLANE_ZOOM_COUNT-1);
      M5Dial.Speaker.tone(diff>0?1200:900,30);
    } else if(appState==APP_SPACE){
      spaceZoom=constrain(spaceZoom+(diff>0?1:-1),0,SPACE_ZOOM_COUNT-1);
      M5Dial.Speaker.tone(diff>0?1200:900,30);
    }
  }

  // ── BUTTON ────────────────────────────────────────────────────────────────
  if(M5Dial.BtnA.isPressed()){
    if(btnPressTime==0) btnPressTime=millis();
    if(millis()-btnPressTime>2000&&!btnHeld){
      btnHeld=true;
      // go home
      appState=APP_HOME; homeSelected=0;
      M5Dial.Speaker.tone(600,50); delay(80); M5Dial.Speaker.tone(500,50); delay(80); M5Dial.Speaker.tone(400,50);
      freeScroll=false; viewLat=homeLat; viewLon=homeLon;
    }
  } else {
    if(btnPressTime>0&&!btnHeld&&millis()-btnPressTime<2000){
      // short press
      if(appState==APP_FLIGHT){ flightMode=(FlightMode)((flightMode+1)%2); M5Dial.Speaker.tone(1200,30); }
      else if(appState==APP_SPACE){ spaceMode=(SpaceMode)((spaceMode+1)%2); M5Dial.Speaker.tone(1200,30); }
      else if(appState==APP_STATS_APP||appState==APP_WEATHER_APP||appState==APP_SETTINGS){ appState=APP_HOME; }
    }
    btnPressTime=0; btnHeld=false;
  }

  // ── KNOB PRESS (free scroll toggle) ───────────────────────────────────────
  // M5Dial encoder button not separately exposed — use display tap on centre
  // (handled in touch handler above via centre tap)

  // ── FETCH DATA ────────────────────────────────────────────────────────────
  // ── FILTER SETTINGS (from settings screen) ────────────────────────────────
  // Plane filter navigation handled inside drawFilterSettings via encoder
  // (accessed via settings screen tap in future update)

  if(appState==APP_FLIGHT&&millis()-lastFetch>FETCH_INTERVAL){
    lastFetch=millis();
    fetchFlights();
    fetchAirports();
  }
  if(appState==APP_SPACE&&millis()-lastFetch>FETCH_INTERVAL){
    lastFetch=millis();
    fetchSpace();
  }
  if(millis()-lastWeather>WEATHER_INTERVAL){ lastWeather=millis(); fetchWeather(); }

  // ── DRAW ──────────────────────────────────────────────────────────────────
  if(millis()-lastDraw>33){
    lastDraw=millis();
    sweepAngle=fmod(sweepAngle+2.0f,360.0f);
    if(showHelp){ drawHelp(); canvas.pushSprite(0,0); return; }
    if(showQR){ drawQRCode(qrUrl,streamTitle); canvas.pushSprite(0,0); return; }
    if(appState==APP_FLIGHT) drawFlightScreen();
    else if(appState==APP_SPACE) drawSpaceScreen();
    else if(appState==APP_STATS_APP) drawStatsScreen();
    else if(appState==APP_WEATHER_APP) drawWeatherApp();
    else if(appState==APP_SETTINGS) drawSettings();
    else if(appState==APP_HOME) { drawHomeScreen(); canvas.pushSprite(0,0); }
  }
}

“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