Community project

Live Flight Radar Scanner

Oliver Klos

Published August 21, 2026 · Updated August 21, 2026

ESP32
Photo of Live Flight Radar ScannerGenerated with AI

This project turns an M5Dial into a live flight radar scanner that displays aircraft positions around major airports worldwide. The device connects to Wi-Fi, fetches real-time flight data from public aviation APIs, and renders an animated radar display with a rotating sweep line and aircraft blips showing altitude and callsign information.

Builders will receive a complete firmware implementation, wiring setup instructions, and a parts list. The guide covers Wi-Fi configuration, radar rendering on the M5Dial's circular display, and how to switch between radar sites in San Francisco, London, and Tokyo using the device's encoder knob.

Wiring diagram

Interactive · read-only

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

Assembly

4 steps
  1. Use the M5Dial by itself

    Place the M5Stack M5Dial on a dry desk where you can see its round screen and turn its center knob. This project uses the screen, knob, and Wi‑Fi already built into the M5Dial, so do not connect any loose wires or extra modules.

    • Tip: Leave the protective screen film on until you are happy with the placement, then peel it away from one edge.
    • Do not connect anything to the exposed ports for this build; a misplaced power wire can damage the board.
  2. Power the board

    Plug a USB data cable into the M5Dial and a USB power source. The cable provides power and lets Schematik send the finished radar program to the board.

    • Tip: Use a cable known to transfer data, not a charge-only cable.
    • Keep the board away from liquid and metal objects while it is powered.
  3. Connect the radar to Wi-Fi

    After the program is deployed, use a phone or computer to join the temporary Wi‑Fi network named M5Dial-FlightRadar. Its sign-in page lets you choose your normal Wi‑Fi network and enter its password, so the radar can ask for live aircraft positions.

    • Tip: Use a normal 2.4 GHz Wi‑Fi network with internet access; the radar saves the connection after it is set once.
    • Do not share the temporary setup network password with strangers; it can be used to change the radar's Wi‑Fi connection.
  4. Read the radar

    Watch the green sweep line move around the round screen. Yellow dots are recently reported aircraft; turn the center knob one click at a time to change the airport area. The bottom line shows how many aircraft were found and when the next live update is expected.

    • Tip: The first aircraft list can take several seconds after Wi‑Fi connects.
    • Tip: If an area shows no dots, turn the knob to another area or wait for the next update.
    • Flight positions come from a public online feed and can be delayed, incomplete, or unavailable; do not use this display for navigation or safety decisions.

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <M5Dial.h>
#include <WiFi.h>
#include <WiFiManager.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <math.h>


// Hoisted type definitions
struct RadarSite {
  const char* name;
  float lat;
  float lon;
  float span;
};

struct Aircraft {
  float x;
  float y;
  int altitudeFt;
  char callsign[9];
};


// Forward declarations
void drawStaticRadar();
void drawAircraft();
void drawFooter();
void renderRadar();
String callsignFrom(JsonVariantConst value);
void fetchFlights();
void updateSweep();

const RadarSite SITES[] = {
  {"SAN FRANCISCO", 37.62f, -122.38f, 1.20f},
  {"LONDON", 51.47f, -0.45f, 1.20f},
  {"TOKYO", 35.55f, 139.78f, 1.20f}
};
const uint8_t SITE_COUNT = sizeof(SITES) / sizeof(SITES[0]);
const uint32_t FETCH_INTERVAL_MS = 30000;
const uint32_t SWEEP_INTERVAL_MS = 50;
const int CX = 120, CY = 128, RADAR_R = 91;


const uint8_t MAX_AIRCRAFT = 18;
Aircraft aircraft[MAX_AIRCRAFT];
uint8_t aircraftCount = 0;
uint8_t siteIndex = 0;
int32_t lastEncoder = 0;
uint32_t lastFetch = 0;
uint32_t lastSweep = 0;
uint16_t sweepAngle = 0;
bool haveNetwork = false;
char statusLine[24] = "Starting Wi-Fi...";

void drawStaticRadar() {
  auto& d = M5Dial.Display;
  d.fillScreen(TFT_BLACK);
  d.drawCircle(CX, CY, RADAR_R, TFT_DARKGREEN);
  d.drawCircle(CX, CY, RADAR_R * 2 / 3, TFT_DARKGREEN);
  d.drawCircle(CX, CY, RADAR_R / 3, TFT_DARKGREEN);
  d.drawFastHLine(CX - RADAR_R, CY, RADAR_R * 2, TFT_DARKGREEN);
  d.drawFastVLine(CX, CY - RADAR_R, RADAR_R * 2, TFT_DARKGREEN);
  d.setTextDatum(top_center);
  d.setTextColor(TFT_CYAN, TFT_BLACK);
  d.setTextSize(1);
  d.drawString(SITES[siteIndex].name, CX, 4);
  d.setTextColor(TFT_DARKGREEN, TFT_BLACK);
  d.drawString("FLIGHT RADAR", CX, 18);
  d.setTextDatum(top_left);
}

void drawAircraft() {
  auto& d = M5Dial.Display;
  for (uint8_t i = 0; i < aircraftCount; ++i) {
    int px = CX + (int)aircraft[i].x;
    int py = CY - (int)aircraft[i].y;
    d.fillCircle(px, py, 3, TFT_YELLOW);
    d.drawFastHLine(px - 5, py, 10, TFT_YELLOW);
    d.setTextColor(TFT_WHITE, TFT_BLACK);
    d.setTextSize(1);
    d.drawString(aircraft[i].callsign, px + 5, py - 5);
  }
}

void drawFooter() {
  auto& d = M5Dial.Display;
  d.fillRect(0, 221, 240, 19, TFT_BLACK);
  d.setTextColor(haveNetwork ? TFT_GREEN : TFT_ORANGE, TFT_BLACK);
  d.setTextSize(1);
  d.setTextDatum(top_center);
  char line[42];
  snprintf(line, sizeof(line), "%u aircraft  %s", aircraftCount, statusLine);
  d.drawString(line, CX, 224);
  d.setTextDatum(top_left);
}

void renderRadar() {
  drawStaticRadar();
  drawAircraft();
  drawFooter();
}

String callsignFrom(JsonVariantConst value) {
  if (value.is<const char*>()) {
    String text = value.as<const char*>();
    text.trim();
    if (text.length()) return text;
  }
  return "UNKNOWN";
}

void fetchFlights() {
  if (WiFi.status() != WL_CONNECTED) {
    haveNetwork = false;
    snprintf(statusLine, sizeof(statusLine), "Wi-Fi offline");
    renderRadar();
    return;
  }
  const RadarSite& site = SITES[siteIndex];
  char url[220];
  snprintf(url, sizeof(url),
           "https://opensky-network.org/api/states/all?lamin=%.3f&lomin=%.3f&lamax=%.3f&lomax=%.3f",
           site.lat - site.span / 2, site.lon - site.span / 2,
           site.lat + site.span / 2, site.lon + site.span / 2);

  snprintf(statusLine, sizeof(statusLine), "Fetching...");
  drawFooter();
  HTTPClient http;
  http.setTimeout(12000);
  if (!http.begin(url)) {
    snprintf(statusLine, sizeof(statusLine), "Connection failed");
    renderRadar();
    return;
  }
  int code = http.GET();
  if (code != HTTP_CODE_OK) {
    snprintf(statusLine, sizeof(statusLine), "Feed error %d", code);
    http.end();
    renderRadar();
    return;
  }

  DynamicJsonDocument doc(49152);
  DeserializationError error = deserializeJson(doc, http.getStream());
  http.end();
  if (error || !doc["states"].is<JsonArray>()) {
    snprintf(statusLine, sizeof(statusLine), "Bad feed data");
    renderRadar();
    return;
  }

  aircraftCount = 0;
  JsonArray states = doc["states"].as<JsonArray>();
  for (JsonVariant rowValue : states) {
    if (aircraftCount >= MAX_AIRCRAFT) break;
    JsonArray row = rowValue.as<JsonArray>();
    if (row.size() < 14 || row[5].isNull() || row[6].isNull()) continue;
    float lon = row[5].as<float>();
    float lat = row[6].as<float>();
    float x = (lon - site.lon) * RADAR_R / (site.span / 2);
    float y = (lat - site.lat) * RADAR_R / (site.span / 2);
    if (x * x + y * y > (RADAR_R - 5) * (RADAR_R - 5)) continue;
    Aircraft& plane = aircraft[aircraftCount++];
    plane.x = x;
    plane.y = y;
    plane.altitudeFt = row[7].isNull() ? 0 : (int)(row[7].as<float>() * 3.28084f);
    String call = callsignFrom(row[1]);
    call.substring(0, 8).toCharArray(plane.callsign, sizeof(plane.callsign));
  }
  haveNetwork = true;
  snprintf(statusLine, sizeof(statusLine), "live 30s refresh");
  renderRadar();
}

void updateSweep() {
  if (millis() - lastSweep < SWEEP_INTERVAL_MS) return;
  lastSweep = millis();
  auto& d = M5Dial.Display;
  float oldRadians = (sweepAngle - 4) * DEG_TO_RAD;
  float radians = sweepAngle * DEG_TO_RAD;
  d.drawLine(CX, CY, CX + cosf(oldRadians) * (RADAR_R - 2), CY - sinf(oldRadians) * (RADAR_R - 2), TFT_BLACK);
  d.drawLine(CX, CY, CX + cosf(radians) * (RADAR_R - 2), CY - sinf(radians) * (RADAR_R - 2), TFT_GREEN);
  sweepAngle = (sweepAngle + 4) % 360;
}

void setup() {
  auto cfg = M5.config();
  M5Dial.begin(cfg, true, false);
  M5Dial.Display.setRotation(0);
  M5Dial.Display.setBrightness(110);
  M5Dial.Display.setTextFont(1);
  M5Dial.Encoder.write(0);
  lastEncoder = 0;
  renderRadar();

  WiFiManager wifiManager;
  wifiManager.setConfigPortalTimeout(180);
  if (wifiManager.autoConnect("M5Dial-FlightRadar")) {
    haveNetwork = true;
    snprintf(statusLine, sizeof(statusLine), "Wi-Fi connected");
  } else {
    snprintf(statusLine, sizeof(statusLine), "Wi-Fi setup timeout");
  }
  renderRadar();
  fetchFlights();
  lastFetch = millis();
}

void loop() {
  M5Dial.update();
  int32_t encoder = M5Dial.Encoder.read() / 4;
  if (encoder != lastEncoder) {
    int direction = encoder > lastEncoder ? 1 : -1;
    siteIndex = (siteIndex + direction + SITE_COUNT) % SITE_COUNT;
    lastEncoder = encoder;
    aircraftCount = 0;
    snprintf(statusLine, sizeof(statusLine), "Changing area...");
    renderRadar();
    fetchFlights();
    lastFetch = millis();
  }
  updateSweep();
  if (millis() - lastFetch >= FETCH_INTERVAL_MS) {
    fetchFlights();
    lastFetch = millis();
  }
}

“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