Community project
Include Arduino H // // Live Flight Scanner
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
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| M5Stack M5Dial | 1 | All-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 API | 1 | Free public ADS-B REST API providing live aircraft state vectors over WiFi. No API key required. Queried via HTTP GET. |
Assembly
5 stepsWhat 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.
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.
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.
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.
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#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¤t_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)&¤tInput.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.