Community project

Live Flight Radar Scanner | MT5Stack

aliferous3

Published July 18, 2026 · Updated July 18, 2026

ESP320 components3 assembly steps
Remix this project
Photo of Live Flight Radar Scanner | MT5Stack

This project turns an M5Dial into a live flight radar scanner that displays aircraft in your airspace. Using the OpenSky Network API, it fetches real-time aircraft data including position, altitude, heading, and speed, then renders them on the dial's circular display with heading-oriented plane icons. The guide includes a complete parts list, wiring diagram, and step-by-step assembly instructions.

Builders will configure WiFi credentials and set their home location, then deploy the firmware to start monitoring nearby flights. The encoder dial lets you scroll through detected aircraft to view detailed information like callsign, origin country, and altitude. This is a practical introduction to aviation data APIs, ESP32 networking, and real-time sensor visualization on a compact display.

Wiring diagram

Interactive · read-only

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

Assembly

3 steps
  1. Power the M5Dial safely

    Use a known-good USB-C data/power cable from a computer or a normal 5 V USB supply to power the M5Dial. No external sensor, display, or wiring is required for this version.

    • Tip: Keep the Dial on a non-conductive surface while testing.
    • Tip: The board’s own USB-C port supplies both power and program connection.
    • Do not connect any external power source to the Grove pins for this project.
    • Do not use an unregulated supply.
  2. Set the radar location and credentials

    Before deployment, replace the clearly marked Wi-Fi, OpenSky client ID, OpenSky client secret, HOME_LATITUDE, and HOME_LONGITUDE values at the top of firmware.cpp. Use an OpenSky API client created for your own account; do not share its secret.

    • Tip: The supplied default coordinates are San Francisco International Airport, so replace them with your intended local centre.
    • Tip: OpenSky data is delayed/limited and may not show every aircraft.
    • The API secret is stored in the device firmware; treat the Dial as a personal device and do not publish a build containing your secret.
  3. Deploy and operate

    Deploy from Schematik. The Dial starts in demo mode until valid Wi-Fi and OpenSky credentials are entered, then refreshes the nearby-aircraft view every 15 seconds. Turn the rotary encoder to change the scan range from 10 to 150 km.

    • Tip: A green dot marks the configured centre; cyan triangles mark aircraft and point in their reported heading direction.
    • Tip: The bottom status line reports connection/API errors plainly.
    • This is an informational display, not a navigation, collision-avoidance, or safety device. Never use it for flight operations.

Firmware

ESP32
src/main.cppDeploy to device
#include <Arduino.h>
#include <M5Dial.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <math.h>
#include "plane_icon.h"
#include "secrets.h"

struct AirportMarker {
  const char* icao;
  const char* iata;
  float latitude;
  float longitude;
};

struct Aircraft {
  String icao24;
  String callsign;
  String originCountry;
  String squawk;
  float latitude;
  float longitude;
  float altitudeM;
  float geoAltitudeM;
  float heading;
  float speedMS;
  float verticalRateMS;
  uint32_t positionTime;
  uint32_t lastContact;
  bool onGround;
  bool spi;
  int positionSource;
  int category;
  int receiverCount;
};

// Forward declarations
static void drawPlaneIcon(int centerX, int centerY, float headingDegrees, uint16_t color);
String airportName(JsonVariant value);
String airportWithIata(const String &icao);
bool fetchAircraftForSelected();

const char* WIFI_SSID = SECRET_WIFI_SSID;
const char* WIFI_PASSWORD = SECRET_WIFI_PASSWORD;
const char* OPENSKY_CLIENT_ID = SECRET_OPENSKY_CLIENT_ID;
const char* OPENSKY_CLIENT_SECRET = SECRET_OPENSKY_CLIENT_SECRET;

const float HOME_LATITUDE = SECRET_HOME_LATITUDE;
const float HOME_LONGITUDE = SECRET_HOME_LONGITUDE;

const unsigned long POLL_INTERVAL_MS = 15000UL;
const unsigned long WIFI_CONNECT_TIMEOUT_MS = 30000UL;
const unsigned long WIFI_RECONNECT_INTERVAL_MS = 30000UL;
const int ENCODER_COUNTS_PER_TICK = 4;
const unsigned long RANGE_REFRESH_DELAY_MS = 700UL;
const int MAX_AIRCRAFT = 24;

// Published aerodrome reference points (ARP) for airports around Dubai.
const AirportMarker AIRPORTS[] = {
  {"OMDB", "DXB", 25.252778f, 55.364444f},
  {"OMDW", "DWC", 24.918333f, 55.175556f},
  {"OMSJ", "SHJ", 25.329167f, 55.516111f},
  {"OMAA", "AUH", 24.433056f, 54.651111f},
  {"OMRK", "RKT", 25.613333f, 55.938889f},
  {"OMFJ", "FJR", 25.112222f, 56.324167f}
};
const int AIRPORT_COUNT = sizeof(AIRPORTS) / sizeof(AIRPORTS[0]);

Aircraft aircraft[MAX_AIRCRAFT];
int aircraftCount = 0;
int selectedAircraft = 0;
String selectedIcao24;
int radarRadiusKm = 60;
bool detailMode = false;
int detailPage = 0;
String statusLine = "Starting...";
String accessToken;
unsigned long tokenObtainedAt = 0;
unsigned long lastPollAt = 0;
int32_t lastEncoder = 0;
int32_t encoderAccumulator = 0;
unsigned long lastRangeChangeAt = 0;
bool rangeRefreshPending = false;
bool wifiStarted = false;
unsigned long wifiStartedAt = 0;
unsigned long lastReconnectAt = 0;

String routeLookupIcao;
String departureAirport = "--";
String arrivalAirport = "--";
String routeStatus = "Tap a plane to look up route";
bool routeLookupInProgress = false;

String airlineName = "Unavailable";
String airlineIcao = "Unavailable";
String airlineIata = "Unavailable";
String airlineCountry = "Unavailable";
String airlineCallsign = "Unavailable";

String aircraftRegistration = "Unavailable";
String aircraftType = "Unavailable";
String aircraftModel = "Unavailable";
String aircraftManufacturer = "Unavailable";
String aircraftOwner = "Unavailable";
String aircraftMetadataStatus = "Not checked";
bool aircraftLookupInProgress = false;

bool configured(const char* value);
String trimCallsign(const char* raw);
bool connectWiFi();
bool obtainToken();
bool pollOpenSky();
bool fetchRouteForSelected();
bool fetchAircraftForSelected();
float distanceKm(const Aircraft &plane);
void sortAircraftByDistance();
uint16_t altitudeColor(const Aircraft &plane);
String altitudeBand(const Aircraft &plane);
void drawAirportMarkers(int centerX, int centerY, int radius);
void drawAircraftSymbol(int x, int y, float heading, uint16_t color, bool selected);
void drawRadar();
void drawDetail();
void selectAircraftAt(int tapX, int tapY);

bool configured(const char* value) { return String(value).indexOf("YOUR_") == -1 && strlen(value) > 0; }
String trimCallsign(const char* raw) { if (!raw) return "UNKNOWN"; String value(raw); value.trim(); return value.length() ? value : "UNKNOWN"; }

String airportName(JsonVariant value) {
  if (value.isNull()) return "--";
  if (value.is<const char*>()) return String(value.as<const char*>());
  if (value["icao"].is<const char*>()) return String(value["icao"].as<const char*>());
  if (value["ident"].is<const char*>()) return String(value["ident"].as<const char*>());
  return "--";
}

// OpenSky returns ICAO airport identifiers. Add the known local IATA hint when available.
String airportWithIata(const String &icao) {
  if (icao == "--" || icao.length() == 0) return "--";
  for (int i = 0; i < AIRPORT_COUNT; ++i) {
    if (icao.equalsIgnoreCase(AIRPORTS[i].icao)) return icao + " (" + AIRPORTS[i].iata + ")";
  }
  return icao;
}

bool connectWiFi() {
  if (!configured(WIFI_SSID)) return false;
  if (WiFi.status() == WL_CONNECTED) return true;
  unsigned long now = millis();
  if (!wifiStarted) { WiFi.persistent(false); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); wifiStarted = true; wifiStartedAt = now; lastReconnectAt = now; statusLine = "Connecting Wi-Fi"; return false; }
  if (now - wifiStartedAt < WIFI_CONNECT_TIMEOUT_MS) { statusLine = "Connecting Wi-Fi"; return false; }
  if (now - lastReconnectAt >= WIFI_RECONNECT_INTERVAL_MS) { WiFi.reconnect(); lastReconnectAt = now; statusLine = "Retrying Wi-Fi"; } else statusLine = "Wi-Fi unavailable";
  return false;
}

bool obtainToken() {
  if (!configured(OPENSKY_CLIENT_ID) || !configured(OPENSKY_CLIENT_SECRET)) { statusLine = "Add OpenSky keys"; return false; }
  if (accessToken.length() && millis() - tokenObtainedAt < 25UL * 60UL * 1000UL) return true;
  HTTPClient http; http.setTimeout(12000);
  http.begin("https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token");
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  String body = "grant_type=client_credentials&client_id=" + String(OPENSKY_CLIENT_ID) + "&client_secret=" + String(OPENSKY_CLIENT_SECRET);
  int code = http.POST(body); String payload = http.getString(); http.end();
  if (code != 200) { statusLine = "Token error " + String(code); return false; }
  JsonDocument doc;
  if (deserializeJson(doc, payload) || !doc["access_token"].is<const char*>()) { statusLine = "Bad token response"; return false; }
  accessToken = doc["access_token"].as<String>(); tokenObtainedAt = millis(); return true;
}

bool pollOpenSky() {
  if (!connectWiFi() || !obtainToken()) return false;
  float latSpan = radarRadiusKm / 111.32f;
  float lonSpan = radarRadiusKm / (111.32f * max(0.15f, cosf(HOME_LATITUDE * DEG_TO_RAD)));
  String url = "https://opensky-network.org/api/states/all?lamin=" + String(HOME_LATITUDE - latSpan, 4) + "&lamax=" + String(HOME_LATITUDE + latSpan, 4) + "&lomin=" + String(HOME_LONGITUDE - lonSpan, 4) + "&lomax=" + String(HOME_LONGITUDE + lonSpan, 4);
  HTTPClient http; http.setTimeout(12000); http.begin(url);
  http.addHeader("Authorization", "Bearer " + accessToken); http.addHeader("Accept", "application/json"); http.addHeader("User-Agent", "M5Dial-FlightRadar/2.0");
  int code = http.GET(); Serial.printf("OpenSky states HTTP: %d\n", code);
  if (code == 401) { accessToken = ""; http.end(); statusLine = "Token expired"; return false; }
  if (code != 200) { http.end(); statusLine = "OpenSky error " + String(code); return false; }
  int contentLength = http.getSize();
  if (contentLength > 180000) { http.end(); statusLine = "Area busy: reduce range"; return false; }
  String payload = http.getString(); http.end(); Serial.printf("OpenSky payload bytes: %u\n", (unsigned)payload.length());
  JsonDocument doc; DeserializationError error = deserializeJson(doc, payload);
  if (error) { Serial.println("OpenSky parse: " + String(error.c_str())); statusLine = "OpenSky reply invalid"; return false; }
  aircraftCount = 0;
  if (doc["states"].isNull()) { Serial.println("OpenSky states: valid empty result (states=null)"); statusLine = "No tracked aircraft"; return true; }
  if (!doc["states"].is<JsonArray>()) {
    String reason = doc["detail"].is<const char*>() ? String(doc["detail"].as<const char*>()) : doc["message"].is<const char*>() ? String(doc["message"].as<const char*>()) : "reply lacks states";
    Serial.println("OpenSky unexpected response: " + reason); statusLine = "API: " + reason.substring(0, 22); return false;
  }
  JsonArray states = doc["states"].as<JsonArray>(); Serial.printf("OpenSky states received: %u\n", (unsigned)states.size());
  for (JsonVariant item : states) {
    if (aircraftCount >= MAX_AIRCRAFT) break;
    JsonArray state = item.as<JsonArray>(); if (state.size() < 17 || state[5].isNull() || state[6].isNull()) continue;
    Aircraft &p = aircraft[aircraftCount++];
    p.icao24 = state[0].isNull() ? "UNKNOWN" : String(state[0].as<const char*>()); p.callsign = trimCallsign(state[1].as<const char*>()); p.originCountry = state[2].isNull() ? "Unknown" : String(state[2].as<const char*>());
    p.positionTime = state[3].isNull() ? 0 : state[3].as<uint32_t>(); p.lastContact = state[4].isNull() ? 0 : state[4].as<uint32_t>(); p.longitude = state[5].as<float>(); p.latitude = state[6].as<float>();
    p.altitudeM = state[7].isNull() ? -1.0f : state[7].as<float>(); p.onGround = !state[8].isNull() && state[8].as<bool>(); p.speedMS = state[9].isNull() ? 0.0f : state[9].as<float>(); p.heading = state[10].isNull() ? 0.0f : state[10].as<float>();
    p.verticalRateMS = state[11].isNull() ? 0.0f : state[11].as<float>(); p.receiverCount = state[12].is<JsonArray>() ? state[12].as<JsonArray>().size() : 0; p.geoAltitudeM = state[13].isNull() ? -1.0f : state[13].as<float>();
    p.squawk = state[14].isNull() ? "--" : String(state[14].as<const char*>()); p.spi = !state[15].isNull() && state[15].as<bool>(); p.positionSource = state[16].isNull() ? -1 : state[16].as<int>(); p.category = state.size() > 17 && !state[17].isNull() ? state[17].as<int>() : -1;
  }
  sortAircraftByDistance(); selectedAircraft = 0;
  for (int i = 0; i < aircraftCount; ++i) if (aircraft[i].icao24 == selectedIcao24) { selectedAircraft = i; break; }
  if (aircraftCount > 0) selectedIcao24 = aircraft[selectedAircraft].icao24;
  statusLine = aircraftCount ? String(aircraftCount) + " aircraft" : "No tracked aircraft"; Serial.printf("OpenSky usable aircraft: %d\n", aircraftCount); return true;
}

bool fetchRouteForSelected() {
  if (
    aircraftCount == 0 ||
    selectedAircraft < 0 ||
    selectedAircraft >= aircraftCount
  ) {
    return false;
  }

  const Aircraft &p = aircraft[selectedAircraft];

  String callsign = p.callsign;
  callsign.trim();
  callsign.toUpperCase();

  routeLookupIcao = p.icao24;
  departureAirport = "Unavailable";
  arrivalAirport = "Unavailable";
  routeStatus = "Route unavailable";
  routeLookupInProgress = false;
  airlineName = "Unavailable";
  airlineIcao = "Unavailable";
  airlineIata = "Unavailable";
  airlineCountry = "Unavailable";
  airlineCallsign = "Unavailable";

  if (
    callsign.length() < 3 ||
    callsign == "UNKNOWN"
  ) {
    routeStatus = "No usable callsign";
    return false;
  }

  if (WiFi.status() != WL_CONNECTED) {
    routeStatus = "Wi-Fi unavailable";
    return false;
  }

  routeLookupInProgress = true;
  routeStatus = "Looking up route...";
  drawDetail();

  String url =
      "https://api.adsbdb.com/v0/callsign/" +
      callsign;

  HTTPClient http;
  http.setTimeout(10000);
  http.begin(url);
  http.addHeader("Accept", "application/json");
  http.addHeader(
      "User-Agent",
      "M5Dial-FlightRadar/3.0"
  );

  int code = http.GET();
  String payload = http.getString();
  http.end();

  routeLookupInProgress = false;

  Serial.printf(
      "ADSBdb route HTTP: %d for %s\n",
      code,
      callsign.c_str()
  );

  if (code != 200) {
    routeStatus = "Route error " + String(code);
    return false;
  }

  JsonDocument doc;
  DeserializationError error =
      deserializeJson(doc, payload);

  if (error) {
    routeStatus = "Invalid route reply";
    return false;
  }

  JsonVariant route =
      doc["response"]["flightroute"];

  if (route.isNull()) {
    routeStatus = "No route record";
    return false;
  }

  String returnedCallsign =
      route["callsign_icao"] | "";

  if (returnedCallsign.length() == 0) {
    returnedCallsign =
        route["callsign"] | "";
  }

  returnedCallsign.trim();
  returnedCallsign.toUpperCase();

  if (returnedCallsign != callsign) {
    routeStatus = "Callsign mismatch";
    return false;
  }

  String fromIcao =
      route["origin"]["icao_code"] | "";

  String fromIata =
      route["origin"]["iata_code"] | "";

  String toIcao =
      route["destination"]["icao_code"] | "";

  String toIata =
      route["destination"]["iata_code"] | "";

  if (
    fromIcao.length() == 0 ||
    toIcao.length() == 0
  ) {
    routeStatus = "Airports unavailable";
    return false;
  }

  departureAirport = fromIcao;

  if (fromIata.length() > 0) {
    departureAirport += " (" + fromIata + ")";
  }

  arrivalAirport = toIcao;

  if (toIata.length() > 0) {
    arrivalAirport += " (" + toIata + ")";
  }

  airlineName = String((const char*)(route["airline"]["name"] | "Unavailable"));
  airlineIcao = String((const char*)(route["airline"]["icao"] | "Unavailable"));
  airlineIata = String((const char*)(route["airline"]["iata"] | "Unavailable"));
  airlineCountry = String((const char*)(route["airline"]["country"] | "Unavailable"));
  airlineCallsign = String((const char*)(route["airline"]["callsign"] | "Unavailable"));

  routeStatus = "SOURCE ADSBDB";
  return true;
}

bool fetchAircraftForSelected() {
  if (aircraftCount == 0 || selectedAircraft < 0 || selectedAircraft >= aircraftCount) return false;

  const Aircraft &p = aircraft[selectedAircraft];
  aircraftRegistration = "Unavailable";
  aircraftType = "Unavailable";
  aircraftModel = "Unavailable";
  aircraftManufacturer = "Unavailable";
  aircraftOwner = "Unavailable";
  aircraftMetadataStatus = "Aircraft unavailable";
  aircraftLookupInProgress = false;

  if (p.icao24.length() != 6 || p.icao24 == "UNKNOWN") {
    aircraftMetadataStatus = "Invalid ICAO24";
    return false;
  }

  if (WiFi.status() != WL_CONNECTED) {
    aircraftMetadataStatus = "Wi-Fi unavailable";
    return false;
  }

  aircraftLookupInProgress = true;
  aircraftMetadataStatus = "Looking up aircraft";
  drawDetail();

  HTTPClient http;
  http.setTimeout(10000);
  http.begin("https://api.adsbdb.com/v0/aircraft/" + p.icao24);
  http.addHeader("Accept", "application/json");
  http.addHeader("User-Agent", "M5Dial-FlightRadar/3.0");

  int code = http.GET();
  String payload = http.getString();
  http.end();
  aircraftLookupInProgress = false;

  Serial.printf("ADSBdb aircraft HTTP: %d for %s\n", code, p.icao24.c_str());

  if (code != 200) {
    aircraftMetadataStatus = "Aircraft error " + String(code);
    return false;
  }

  JsonDocument doc;
  if (deserializeJson(doc, payload)) {
    aircraftMetadataStatus = "Invalid aircraft reply";
    return false;
  }

  JsonVariant response = doc["response"];
  if (response.is<const char*>()) {
    aircraftMetadataStatus = "Aircraft unavailable";
    return false;
  }

  JsonVariant ac = response["aircraft"];
  if (ac.isNull()) ac = response;

  aircraftRegistration = String((const char*)(ac["registration"] | "Unavailable"));

  if (ac["icao_type"].is<const char*>()) aircraftType = String(ac["icao_type"].as<const char*>());
  else if (ac["type"].is<const char*>()) aircraftType = String(ac["type"].as<const char*>());

  if (ac["type"].is<const char*>()) aircraftModel = String(ac["type"].as<const char*>());
  else if (ac["model"].is<const char*>()) aircraftModel = String(ac["model"].as<const char*>());

  aircraftManufacturer = String((const char*)(ac["manufacturer"] | "Unavailable"));

  if (ac["registered_owner"].is<const char*>()) aircraftOwner = String(ac["registered_owner"].as<const char*>());
  else if (ac["owner"].is<const char*>()) aircraftOwner = String(ac["owner"].as<const char*>());

  bool any = aircraftRegistration != "Unavailable" || aircraftType != "Unavailable" ||
             aircraftModel != "Unavailable" || aircraftManufacturer != "Unavailable" ||
             aircraftOwner != "Unavailable";
  aircraftMetadataStatus = any ? "SOURCE ADSBDB" : "Aircraft unavailable";
  return any;
}

float distanceKm(const Aircraft &p) { float northKm = (p.latitude - HOME_LATITUDE) * 111.32f; float eastKm = (p.longitude - HOME_LONGITUDE) * 111.32f * cosf(HOME_LATITUDE * DEG_TO_RAD); return sqrtf(northKm * northKm + eastKm * eastKm); }
void sortAircraftByDistance() { for (int i = 0; i < aircraftCount - 1; ++i) for (int j = i + 1; j < aircraftCount; ++j) if (distanceKm(aircraft[j]) < distanceKm(aircraft[i])) { Aircraft temp = aircraft[i]; aircraft[i] = aircraft[j]; aircraft[j] = temp; } }
uint16_t altitudeColor(const Aircraft &plane) { float altitudeM = plane.altitudeM >= 0.0f ? plane.altitudeM : plane.geoAltitudeM; if (altitudeM < 0.0f) return TFT_LIGHTGREY; int altitudeFt = (int)(altitudeM * 3.28084f); if (altitudeFt < 10000) return TFT_CYAN; if (altitudeFt < 20000) return TFT_GREEN; if (altitudeFt < 30000) return TFT_YELLOW; if (altitudeFt < 34999) return TFT_ORANGE; return TFT_RED; }
String altitudeBand(const Aircraft &plane) { float altitudeM = plane.altitudeM >= 0.0f ? plane.altitudeM : plane.geoAltitudeM; if (altitudeM < 0.0f) return "ALT UNKNOWN / GREY"; int altitudeFt = (int)(altitudeM * 3.28084f); if (altitudeFt < 10000) return "0-9,999 FT / CYAN"; if (altitudeFt < 20000) return "10-19,999 FT / GREEN"; if (altitudeFt < 30000) return "20-29,999 FT / YELLOW"; if (altitudeFt < 34999) return "30-34,998 FT / ORANGE"; return "34,999+ FT / RED"; }

static void drawPlaneIcon(int centerX, int centerY, float headingDegrees, uint16_t color) {
  if (!isfinite(headingDegrees)) headingDegrees = 0.0f;
  float radians = fmodf(headingDegrees, 360.0f) * DEG_TO_RAD, c = cosf(radians), s = sinf(radians);
  const float sourceCenterX = (PLANE_ICON_WIDTH - 1) * 0.5f, sourceCenterY = (PLANE_ICON_HEIGHT - 1) * 0.5f;
  for (int row = 0; row < PLANE_ICON_HEIGHT; ++row) for (int col = 0; col < PLANE_ICON_WIDTH; ++col) {
    uint16_t pixel = pgm_read_word(&PLANE_ICON_DATA[row * PLANE_ICON_WIDTH + col]); if (pixel == 0x0000) continue;
    float sourceX = col - sourceCenterX, sourceY = row - sourceCenterY;
    M5Dial.Display.drawPixel(centerX + (int)lroundf(c * sourceX - s * sourceY), centerY + (int)lroundf(s * sourceX + c * sourceY), color);
  }
}
void drawAircraftSymbol(int x, int y, float heading, uint16_t color, bool selected) { if (selected) { M5Dial.Display.drawCircle(x, y, 15, TFT_DARKGREY); M5Dial.Display.drawCircle(x, y, 14, TFT_WHITE); } drawPlaneIcon(x, y, heading, color); }
void drawAirportMarkers(int centerX, int centerY, int radius) {
  M5Dial.Display.setTextDatum(middle_center); M5Dial.Display.setTextSize(1);
  for (int i = 0; i < AIRPORT_COUNT; ++i) {
    const AirportMarker &airport = AIRPORTS[i]; float northKm = (airport.latitude - HOME_LATITUDE) * 111.32f, eastKm = (airport.longitude - HOME_LONGITUDE) * 111.32f * cosf(HOME_LATITUDE * DEG_TO_RAD);
    int x = centerX + (int)(eastKm * radius / radarRadiusKm), y = centerY - (int)(northKm * radius / radarRadiusKm);
    if (sq(x - centerX) + sq(y - centerY) > sq(radius - 3)) continue;
    M5Dial.Display.drawCircle(x, y, 4, TFT_MAGENTA); M5Dial.Display.drawFastHLine(x - 6, y, 13, TFT_MAGENTA); M5Dial.Display.drawFastVLine(x, y - 6, 13, TFT_MAGENTA);
    int labelY = y + 10; if (sq(x - centerX) + sq(labelY - centerY) <= sq(radius - 5)) { M5Dial.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); M5Dial.Display.drawString(airport.icao, x, labelY); }
  }
}
void drawRadar() {
  const int cx = 120, cy = 113, radius = 72; M5Dial.Display.fillScreen(TFT_BLACK); M5Dial.Display.drawCircle(cx, cy, radius, TFT_DARKGREEN); M5Dial.Display.drawCircle(cx, cy, radius / 2, TFT_DARKGREEN); M5Dial.Display.drawFastHLine(cx - radius, cy, radius * 2, TFT_DARKGREEN); M5Dial.Display.drawFastVLine(cx, cy - radius, radius * 2, TFT_DARKGREEN); M5Dial.Display.fillCircle(cx, cy, 3, TFT_GREEN); drawAirportMarkers(cx, cy, radius);
  for (int i = 0; i < aircraftCount; ++i) { const Aircraft &p = aircraft[i]; float north = (p.latitude - HOME_LATITUDE) * 111.32f, east = (p.longitude - HOME_LONGITUDE) * 111.32f * cosf(HOME_LATITUDE * DEG_TO_RAD); int x = cx + (int)(east * radius / radarRadiusKm), y = cy - (int)(north * radius / radarRadiusKm); if (sq(x - cx) + sq(y - cy) <= sq(radius)) drawAircraftSymbol(x, y, p.heading, altitudeColor(p), i == selectedAircraft); }
  M5Dial.Display.setTextDatum(middle_center); M5Dial.Display.setTextSize(1); M5Dial.Display.setTextColor(TFT_WHITE, TFT_BLACK); M5Dial.Display.drawString(String(radarRadiusKm) + " KM    " + String(aircraftCount) + " PLANES", cx, 23);
  if (aircraftCount > 0) { const Aircraft &p = aircraft[selectedAircraft]; M5Dial.Display.setTextColor(TFT_YELLOW, TFT_BLACK); M5Dial.Display.drawString(p.callsign, cx, 201); M5Dial.Display.setTextColor(TFT_CYAN, TFT_BLACK); M5Dial.Display.drawString(String(distanceKm(p), 1) + " km   " + String((int)p.heading) + " deg", cx, 216); } else { M5Dial.Display.setTextColor(TFT_WHITE, TFT_BLACK); M5Dial.Display.drawString(statusLine.substring(0, 25), cx, 205); }
}
void drawDetail() {
  M5Dial.Display.fillScreen(TFT_BLACK);
  if (aircraftCount == 0 || selectedAircraft >= aircraftCount) return;

  const Aircraft &p = aircraft[selectedAircraft];
  const int cx = 120;
  M5Dial.Display.setTextDatum(middle_center);
  M5Dial.Display.setTextSize(1);

  auto shortText = [](String value, int maxLen) {
    if ((int)value.length() > maxLen) value = value.substring(0, maxLen);
    return value;
  };

  auto row = [&](const String &text, int y, uint16_t color) {
    M5Dial.Display.setTextColor(color, TFT_BLACK);
    M5Dial.Display.drawString(text, cx, y);
  };

  if (detailPage == 0) {
    row("FLIGHT / ROUTE  1/5", 26, TFT_GREEN);
    row(p.callsign, 45, TFT_YELLOW);
    row("ICAO " + p.icao24 + "  " + (p.onGround ? "GROUND" : "AIRBORNE"), 63, TFT_CYAN);
    row(shortText("FROM  " + departureAirport, 25), 84, TFT_WHITE);
    row(shortText("TO    " + arrivalAirport, 25), 102, TFT_WHITE);
    row(shortText(routeLookupInProgress ? "Looking up route..." : routeStatus, 25), 122, TFT_DARKGREY);
    String country = shortText(p.originCountry, 22);
    row("REGION  " + country, 145, TFT_CYAN);
  } else if (detailPage == 1) {
    row("MOTION  2/5", 26, TFT_GREEN);
    row(p.callsign, 45, TFT_YELLOW);
    row(String(distanceKm(p), 1) + " km   HDG " + String((int)p.heading) + " deg", 67, TFT_WHITE);
    row(p.altitudeM < 0 ? "BARO ALT  N/A" : "BARO ALT  " + String((int)(p.altitudeM * 3.28084f)) + " ft", 88, TFT_WHITE);
    row(p.geoAltitudeM < 0 ? "GEO ALT   N/A" : "GEO ALT   " + String((int)(p.geoAltitudeM * 3.28084f)) + " ft", 108, TFT_WHITE);
    row("SPEED  " + String((int)(p.speedMS * 1.94384f)) + " kt", 128, TFT_WHITE);
    row("VERT SPD " + String((int)(p.verticalRateMS * 196.85f)) + " ft/min", 148, TFT_WHITE);
    row(altitudeBand(p), 165, altitudeColor(p));
  } else if (detailPage == 2) {
    row("POSITION / DATA  3/5", 26, TFT_GREEN);
    row(p.callsign, 45, TFT_YELLOW);
    row("LAT " + String(p.latitude, 4), 67, TFT_WHITE);
    row("LON " + String(p.longitude, 4), 87, TFT_WHITE);
    row("SQUAWK " + p.squawk + (p.spi ? "  SPI" : ""), 108, TFT_WHITE);
    String source = p.positionSource == 0 ? "ADS-B" : p.positionSource == 1 ? "ASTERIX" : p.positionSource == 2 ? "MLAT" : "N/A";
    row("SOURCE " + source + "   CAT " + (p.category < 0 ? String("N/A") : String(p.category)), 128, TFT_WHITE);
    row("RX " + String(p.receiverCount) + "  POS " + (p.positionTime ? String(p.positionTime) : String("N/A")), 148, TFT_DARKGREY);
  } else if (detailPage == 3) {
    row("AIRCRAFT  4/5", 26, TFT_GREEN);
    row(p.callsign, 45, TFT_YELLOW);
    row(shortText("REG    " + aircraftRegistration, 25), 68, TFT_WHITE);
    row(shortText("TYPE   " + aircraftType, 25), 88, TFT_WHITE);
    row(shortText("MODEL  " + aircraftModel, 25), 108, TFT_WHITE);
    row(shortText("MAKER  " + aircraftManufacturer, 25), 128, TFT_WHITE);
    row(shortText("OWNER  " + aircraftOwner, 25), 148, TFT_WHITE);
    row(shortText(aircraftLookupInProgress ? "Looking up aircraft..." : aircraftMetadataStatus, 25), 165, TFT_DARKGREY);
  } else {
    row("OPERATOR  5/5", 26, TFT_GREEN);
    row(p.callsign, 45, TFT_YELLOW);
    row(shortText(airlineName, 25), 68, TFT_WHITE);
    row(shortText("ICAO  " + airlineIcao, 25), 88, TFT_WHITE);
    row(shortText("IATA  " + airlineIata, 25), 108, TFT_WHITE);
    row(shortText("CALL  " + airlineCallsign, 25), 128, TFT_WHITE);
    row(shortText("COUNTRY  " + airlineCountry, 25), 148, TFT_CYAN);
    row(shortText(routeStatus, 25), 165, TFT_DARKGREY);
  }

  M5Dial.Display.drawFastHLine(65, 171, 110, TFT_DARKGREY);
  row("TURN: PAGE    TAP: BACK", 190, TFT_DARKGREY);
}
void selectAircraftAt(int tapX, int tapY) {
  const int cx = 120, cy = 113, radius = 72; int closest = -1, closestSq = 15 * 15;
  for (int i = 0; i < aircraftCount; ++i) { const Aircraft &p = aircraft[i]; float north = (p.latitude - HOME_LATITUDE) * 111.32f, east = (p.longitude - HOME_LONGITUDE) * 111.32f * cosf(HOME_LATITUDE * DEG_TO_RAD); int x = cx + (int)(east * radius / radarRadiusKm), y = cy - (int)(north * radius / radarRadiusKm), hit = sq(tapX - x) + sq(tapY - y); if (hit <= closestSq && sq(x - cx) + sq(y - cy) <= sq(radius)) { closest = i; closestSq = hit; } }
  if (closest >= 0) { selectedAircraft = closest; selectedIcao24 = aircraft[closest].icao24; detailMode = true; detailPage = 0; fetchRouteForSelected(); fetchAircraftForSelected(); drawDetail(); }
}
void setup() { Serial.begin(115200); delay(150); auto cfg = M5.config(); M5Dial.begin(cfg, true, false); M5Dial.Display.setRotation(0); M5Dial.Display.setTextFont(1); lastEncoder = M5Dial.Encoder.read(); drawRadar(); }
void loop() {
  M5Dial.update();

  const auto &touch = M5Dial.Touch.getDetail();
  if (touch.wasClicked()) {
    if (detailMode) {
      detailMode = false;
      encoderAccumulator = 0;
      drawRadar();
    } else {
      selectAircraftAt(touch.x, touch.y);
      encoderAccumulator = 0;
    }
  }

  int32_t encoder = M5Dial.Encoder.read();
  int32_t rawDelta = encoder - lastEncoder;

  if (rawDelta != 0) {
    lastEncoder = encoder;
    encoderAccumulator += rawDelta;

    int completedTicks = encoderAccumulator / ENCODER_COUNTS_PER_TICK;

    if (completedTicks != 0) {
      encoderAccumulator -= completedTicks * ENCODER_COUNTS_PER_TICK;

      if (detailMode) {
        detailPage = (detailPage + completedTicks) % 5;
        if (detailPage < 0) detailPage += 5;
        drawDetail();
      } else {
        int previousRange = radarRadiusKm;

        radarRadiusKm = constrain(
          radarRadiusKm + completedTicks * 5,
          10,
          150
        );

        if (radarRadiusKm != previousRange) {
          statusLine = "Range " + String(radarRadiusKm) + " km";
          lastRangeChangeAt = millis();
          rangeRefreshPending = true;
          drawRadar();
        }
      }
    }
  }

  if (
    rangeRefreshPending &&
    millis() - lastRangeChangeAt >= RANGE_REFRESH_DELAY_MS
  ) {
    rangeRefreshPending = false;
    lastPollAt = 0;
  }

  if (millis() - lastPollAt >= POLL_INTERVAL_MS || lastPollAt == 0) {
    lastPollAt = millis();
    pollOpenSky();
    if (detailMode) drawDetail();
    else drawRadar();
  }

  delay(20);
}

“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