Community project
stack dial flight radar
Build a real-time flight radar display on the M5Dial, a compact smart dial with a round screen and rotary encoder. This project fetches live aircraft data from a public aviation API, plots nearby planes on a circular radar view, and uses the dial's knob to zoom between range rings (10, 25, 50, and 100 miles). Audio feedback beeps confirm range changes and data refreshes.
The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions. You'll configure the ESP32 firmware with your WiFi credentials and home coordinates, then load it onto the M5Dial. Once powered via USB, rotate the knob to adjust radar range and press the button to interact with the display—all while live aircraft positions update every 60 seconds.
Wiring diagram
Assemble it in 3 steps
1. Set the M5Dial on your desk
Place the M5Dial face-up where its round screen, large turning knob, and push button are easy to reach. The screen, buzzer, knob, and button are already connected inside, so this project needs no breadboard or loose jumper wires.
- Remove any protective film from the round screen so aircraft labels are easy to read.
- Do not open the M5Dial case or connect wires to its internal screen, button, or buzzer pins; connecting the wrong wires can damage the device.
2. Connect USB power
Plug a data-capable USB-C cable into the M5Dial and a powered USB port. It supplies power and lets Schematik send the live-radar program to the device.
- Use a data-capable cable; a charge-only cable can power the unit but cannot send the program.
- Keep the M5Dial on a dry, non-metal surface so its connectors cannot be shorted.
3. Use the knob, button, and sounds
Turn the large centre knob clockwise to show aircraft farther away and counterclockwise to focus on nearby aircraft. Each range change makes one short high beep. Press the centre button once to request live aircraft data immediately instead of waiting for the next automatic update; it makes two short beeps. Automatic refreshes and completed refreshes are silent.
- The radar also refreshes automatically about once a minute when Wi-Fi is connected.
- If the screen says a feed is busy, wait for the next automatic update rather than repeatedly pressing the button.
- This is a public-data display that can be delayed, incomplete, or unavailable. Never use it for aviation navigation, traffic avoidance, or safety decisions.
Deploy the firmware
#include <Arduino.h>
#include <M5Dial.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
struct Aircraft {
float bearing;
float heading;
float distanceMiles;
char callsign[12];
};
// Forward declarations
float radiansf(float degrees);
float distanceMiles(float lat1, float lon1, float lat2, float lon2);
float bearingDegrees(float lat1, float lon1, float lat2, float lon2);
void drawStatus();
void drawRadar();
void copyLabel(JsonVariant aircraft, uint8_t slot);
bool readResponseHeaders(WiFiClientSecure &client);
void fetchTraffic();
void beginWifi();
const char *WIFI_SSID = "your wifi";
const char *WIFI_PASSWORD = "your password ";
const float HOME_LAT = lat;
const float HOME_LON = -lon;
const int rangesMiles[] = {10, 25, 50, 100};
const uint8_t RANGE_COUNT = sizeof(rangesMiles) / sizeof(rangesMiles[0]);
const uint8_t MAX_AIRCRAFT = 18;
const uint32_t FETCH_INTERVAL_MS = 60000;
const uint32_t BUSY_RETRY_MS = 90000;
const uint32_t WIFI_RETRY_MS = 15000;
const int CX = 120;
const int CY = 120;
const int RADAR_RADIUS = 116;
const uint16_t RADAR_GREEN = 0x07E0;
const uint16_t DIM_GREEN = 0x02A0;
// Buzzer pitches in Hz, not GPIO numbers.
const uint16_t BEEP_RANGE_HZ = 7000;
const uint16_t BEEP_REFRESH_HZ = 4200;
const uint16_t BEEP_LIVE_LOW_HZ = 5200;
const uint16_t BEEP_LIVE_HIGH_HZ = 6800;
Aircraft traffic[MAX_AIRCRAFT];
uint8_t trafficCount = 0;
int rangeIndex = 1;
long encoderPosition = 0;
float sweepDegrees = 0.0f;
uint32_t lastFrame = 0;
uint32_t lastFetch = 0;
uint32_t fetchDelayMs = FETCH_INTERVAL_MS;
uint32_t lastWifiAttempt = 0;
uint32_t lastEncoderMove = 0;
const uint32_t BUTTON_AFTER_TURN_GUARD_MS = 250;
String statusText = "JOINING WIFI";
float radiansf(float degrees) { return degrees * DEG_TO_RAD; }
float distanceMiles(float lat1, float lon1, float lat2, float lon2) {
const float earthRadiusKm = 6371.0f;
float dLat = radiansf(lat2 - lat1);
float dLon = radiansf(lon2 - lon1);
float a = sinf(dLat / 2.0f) * sinf(dLat / 2.0f) +
cosf(radiansf(lat1)) * cosf(radiansf(lat2)) *
sinf(dLon / 2.0f) * sinf(dLon / 2.0f);
return earthRadiusKm * 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a)) * 0.621371f;
}
float bearingDegrees(float lat1, float lon1, float lat2, float lon2) {
float dLon = radiansf(lon2 - lon1);
float y = sinf(dLon) * cosf(radiansf(lat2));
float x = cosf(radiansf(lat1)) * sinf(radiansf(lat2)) -
sinf(radiansf(lat1)) * cosf(radiansf(lat2)) * cosf(dLon);
return fmodf(atan2f(y, x) * RAD_TO_DEG + 360.0f, 360.0f);
}
void drawStatus() {
// A dedicated black strip keeps the message readable over the radar rings.
M5Dial.Display.fillRect(18, 218, 204, 20, TFT_BLACK);
M5Dial.Display.setTextFont(1);
M5Dial.Display.setTextDatum(middle_center);
M5Dial.Display.setTextSize(1);
uint16_t colour = statusText == "LIVE" ? TFT_CYAN : TFT_ORANGE;
M5Dial.Display.setTextColor(colour, TFT_BLACK);
M5Dial.Display.drawString(statusText, 120, 228);
M5Dial.Display.setTextFont(&fonts::Font2);
}
void drawRadar() {
M5Dial.Display.fillScreen(TFT_BLACK);
M5Dial.Display.drawCircle(CX, CY, RADAR_RADIUS, DIM_GREEN);
M5Dial.Display.drawCircle(CX, CY, RADAR_RADIUS * 3 / 4, DIM_GREEN);
M5Dial.Display.drawCircle(CX, CY, RADAR_RADIUS / 2, DIM_GREEN);
M5Dial.Display.drawCircle(CX, CY, RADAR_RADIUS / 4, DIM_GREEN);
M5Dial.Display.drawLine(CX - RADAR_RADIUS, CY, CX + RADAR_RADIUS, CY, DIM_GREEN);
M5Dial.Display.drawLine(CX, CY - RADAR_RADIUS, CX, CY + RADAR_RADIUS, DIM_GREEN);
// Keep the compass and selected zoom separate so the mile range is easy to read.
M5Dial.Display.setTextFont(1);
M5Dial.Display.setTextDatum(middle_center);
M5Dial.Display.setTextColor(RADAR_GREEN, TFT_BLACK);
M5Dial.Display.drawString("N", CX, 10);
M5Dial.Display.fillRoundRect(82, 19, 76, 20, 5, TFT_BLACK);
M5Dial.Display.drawRoundRect(82, 19, 76, 20, 5, DIM_GREEN);
M5Dial.Display.setTextFont(&fonts::Font2);
M5Dial.Display.setTextDatum(middle_center);
M5Dial.Display.drawString(String(rangesMiles[rangeIndex]) + " MI", CX, 29);
float sweep = radiansf(sweepDegrees - 90.0f);
M5Dial.Display.drawLine(CX, CY, CX + (int)(cosf(sweep) * RADAR_RADIUS),
CY + (int)(sinf(sweep) * RADAR_RADIUS), RADAR_GREEN);
const int maxRange = rangesMiles[rangeIndex];
// Remember label locations. In busy airspace every aircraft remains visible,
// but labels that would cover another label are left off for clarity.
int labelX[18];
int labelY[18];
uint8_t labelCount = 0;
const uint8_t MAX_VISIBLE_LABELS = 7;
for (uint8_t i = 0; i < trafficCount; ++i) {
if (traffic[i].distanceMiles > maxRange) continue;
float angle = radiansf(traffic[i].bearing - 90.0f);
float radius = traffic[i].distanceMiles / maxRange * (RADAR_RADIUS - 12);
int x = CX + (int)(cosf(angle) * radius);
int y = CY + (int)(sinf(angle) * radius);
// Small dots keep dense traffic readable; the callsign is shown only when
// there is room for it, so nearby aircraft do not turn into a text block.
M5Dial.Display.fillCircle(x, y, 2, RADAR_GREEN);
int proposedX = x > CX ? x - 6 : x + 6;
int proposedY = y - 2;
bool labelClear = true;
for (uint8_t j = 0; j < labelCount; ++j) {
if (abs(proposedY - labelY[j]) < 9 && abs(proposedX - labelX[j]) < 42) {
labelClear = false;
break;
}
}
if (labelClear && labelCount < MAX_VISIBLE_LABELS) {
// Font2 is only a little larger than the compact system font, making
// callsigns easier to read without turning busy traffic into a text block.
M5Dial.Display.setTextFont(&fonts::Font2);
M5Dial.Display.setTextColor(TFT_WHITE, TFT_BLACK);
if (x > CX) M5Dial.Display.setTextDatum(middle_right);
else M5Dial.Display.setTextDatum(middle_left);
M5Dial.Display.drawString(traffic[i].callsign, proposedX, proposedY);
labelX[labelCount] = proposedX;
labelY[labelCount++] = proposedY;
}
}
M5Dial.Display.fillCircle(CX, CY, 4, TFT_CYAN);
drawStatus();
}
void copyLabel(JsonVariant aircraft, uint8_t slot) {
const char *label = aircraft["flight"] | "";
if (strlen(label) == 0) label = aircraft["hex"] | "UNKNOWN";
snprintf(traffic[slot].callsign, sizeof(traffic[slot].callsign), "%.10s", label);
for (uint8_t i = 0; traffic[slot].callsign[i] != '\0'; ++i) {
if (traffic[slot].callsign[i] == ' ') traffic[slot].callsign[i] = '\0';
}
}
bool readResponseHeaders(WiFiClientSecure &client) {
String statusLine = client.readStringUntil('\n');
statusLine.trim();
if (!statusLine.startsWith("HTTP/") || statusLine.length() < 12 ||
statusLine.substring(9, 12) != "200") {
return false;
}
while (client.connected() || client.available()) {
String header = client.readStringUntil('\n');
header.trim();
if (header.length() == 0) return true;
}
return false;
}
void fetchTraffic() {
if (WiFi.status() != WL_CONNECTED) return;
statusText = "UPDATING";
drawStatus();
// HTTPClient handles response headers, chunking, and end-of-response cleanly
// before ArduinoJson reads the body stream.
const float requestedNm = rangesMiles[rangeIndex] / 1.15078f + 1.0f;
// ADSB.fi's public community-data endpoint accepts latitude, longitude,
// and a radius in nautical miles; it returns the compatible `ac` list.
String url = "https://opendata.adsb.fi/api/v3/lat/" + String(HOME_LAT, 6) +
"/lon/" + String(HOME_LON, 6) + "/dist/" + String(requestedNm, 1);
WiFiClientSecure secureClient;
secureClient.setInsecure();
HTTPClient http;
http.setConnectTimeout(15000);
http.setTimeout(30000);
http.useHTTP10(true);
if (!http.begin(secureClient, url)) {
statusText = "NETWORK ERROR";
lastFetch = millis();
drawRadar();
return;
}
http.addHeader("Accept", "application/json");
http.addHeader("Accept-Encoding", "identity");
http.addHeader("User-Agent", "M5Dial-FlightRadar/1.2 (+community-data)");
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
// Do not label every server reply as busy: show the actionable cause.
if (httpCode == 429 || httpCode == 503) {
statusText = "FEED BUSY - WAIT";
// Respect a busy public service instead of retrying immediately.
fetchDelayMs = BUSY_RETRY_MS;
lastFetch = millis();
} else if (httpCode == 401 || httpCode == 403) {
statusText = "ACCESS DENIED " + String(httpCode);
// Back off after a refusal rather than repeatedly hitting the service.
fetchDelayMs = BUSY_RETRY_MS;
lastFetch = millis();
} else if (httpCode >= 400) {
statusText = "FEED ERROR " + String(httpCode);
lastFetch = millis();
} else {
statusText = "NETWORK ERROR";
lastFetch = millis();
}
http.end();
drawRadar();
return;
}
// Read the completed response before decoding. This avoids a partial JSON
// document when the server closes a streamed HTTP/1.0 response at its end.
String payload = http.getString();
http.end();
fetchDelayMs = FETCH_INTERVAL_MS;
if (payload.length() == 0) {
statusText = "EMPTY FEED RESPONSE";
lastFetch = millis();
drawRadar();
return;
}
StaticJsonDocument<256> filter;
filter["ac"][0]["lat"] = true;
filter["ac"][0]["lon"] = true;
filter["ac"][0]["flight"] = true;
filter["ac"][0]["hex"] = true;
filter["ac"][0]["track"] = true;
// London can return several hundred nearby aircraft. The filter keeps only
// the four fields used below, while this larger document prevents NoMemory.
DynamicJsonDocument doc(65536);
DeserializationError error = deserializeJson(
doc, payload, DeserializationOption::Filter(filter),
DeserializationOption::NestingLimit(4));
if (error) {
statusText = error == DeserializationError::NoMemory ? "TOO MANY TRACKS" : "DATA PARSE ERROR";
} else if (!doc["ac"].is<JsonArray>()) {
statusText = "NO AIRCRAFT DATA";
} else {
uint8_t newCount = 0;
for (JsonVariant aircraft : doc["ac"].as<JsonArray>()) {
if (newCount >= MAX_AIRCRAFT) break;
if (aircraft["lat"].isNull() || aircraft["lon"].isNull()) continue;
float range = distanceMiles(HOME_LAT, HOME_LON, aircraft["lat"].as<float>(), aircraft["lon"].as<float>());
if (range > 100.0f) continue;
traffic[newCount].distanceMiles = range;
traffic[newCount].bearing = bearingDegrees(HOME_LAT, HOME_LON, aircraft["lat"].as<float>(), aircraft["lon"].as<float>());
// Use the reported ground track for the airplane symbol. If it is absent,
// point the symbol away from the radar centre as a clear fallback.
traffic[newCount].heading = aircraft["track"] | traffic[newCount].bearing;
copyLabel(aircraft, newCount++);
}
trafficCount = newCount;
statusText = trafficCount ? "LIVE" : "NO TRACKS NEARBY";
// A completed background refresh stays silent; only a button press beeps.
}
lastFetch = millis();
drawRadar();
}
void beginWifi() {
statusText = "JOINING WIFI";
WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
lastWifiAttempt = millis();
}
void setup() {
auto cfg = M5.config();
M5Dial.begin(cfg, true, false);
M5Dial.Display.setRotation(0);
M5Dial.Display.setTextFont(&fonts::Font2);
WiFi.mode(WIFI_STA);
drawRadar();
beginWifi();
}
void loop() {
M5Dial.update();
long newPosition = M5Dial.Encoder.read();
if (newPosition != encoderPosition) {
if (newPosition > encoderPosition && rangeIndex < RANGE_COUNT - 1) rangeIndex++;
if (newPosition < encoderPosition && rangeIndex > 0) rangeIndex--;
encoderPosition = newPosition;
lastEncoderMove = millis();
// A short high beep confirms a real zoom change. Background data refreshes
// stay silent, so sounds always come from a dial or button action.
M5Dial.Speaker.tone(BEEP_RANGE_HZ, 28);
drawRadar();
}
// Some dials can briefly report a press while their shaft is moving. Ignore
// those reports until the dial has been still for a quarter of a second.
if (M5Dial.BtnA.wasPressed() &&
millis() - lastEncoderMove >= BUTTON_AFTER_TURN_GUARD_MS) {
// Press the centre of the dial to request fresh aircraft data now.
// This is the only sound: two short beeps confirm the button press.
statusText = "REFRESHING";
lastFetch = 0;
M5Dial.Speaker.tone(BEEP_REFRESH_HZ, 35);
delay(55);
M5Dial.Speaker.tone(BEEP_REFRESH_HZ, 35);
drawRadar();
}
if (WiFi.status() != WL_CONNECTED) {
if (millis() - lastWifiAttempt >= WIFI_RETRY_MS) beginWifi();
} else if (lastFetch == 0 || millis() - lastFetch >= fetchDelayMs) {
fetchTraffic();
}
// The round display is redrawn only when the range, status, or aircraft list
// changes. Redrawing a full screen for every sweep-frame causes visible flicker.
}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.




