Community project
Local Flight Tracker
This project turns a Cheap Yellow Display into a real-time flight tracker that shows aircraft flying near your home. It connects to public flight data APIs to detect planes within a 10-nautical-mile radius, displaying the nearest aircraft on a radar-style screen with flight details, altitude, bearing, and route information.
The guide includes a complete parts list, wiring diagram, and step-by-step assembly instructions. You'll configure the ESP32 firmware with your home coordinates and Wi-Fi credentials, then watch live aircraft data stream to the display. The tracker refreshes every 30 seconds and shows distance, direction, aircraft type, and airline information for planes passing overhead.
Wiring diagram
Assemble it in 3 steps
1. Place the CYD where it can stay powered
Put the CYD ESP32 on a dry, stable surface with the screen facing up. Its screen, touch panel, and Wi-Fi radio are already built in, so do not add any jumper wires.
- Keep it within range of your home Wi-Fi network so it can fetch live aircraft positions.
- Do not place it where it can get wet or where the metal USB connector can touch loose wires; that can damage the board.
2. Connect the USB cable
Plug a data-capable USB cable into the CYD and then into your computer. This powers the board and lets Schematik install the flight-tracker firmware.
- If the screen remains dark after it is powered, check that the USB plug is fully pushed in.
- Use the computer’s USB power only; do not connect another power supply to the board at the same time.
3. Enter your home Wi-Fi details
After deploying, join the Wi-Fi network named Bamford-Flight-Setup on a phone or computer. Enter password radarsetup, open http://192.168.4.1 in the browser, then enter the name and password of your normal home Wi-Fi. The CYD restarts and begins the radar.
- If the setup page does not open by itself, type 192.168.4.1 into the browser address bar.
- The tracker is centred on Bamford, S33 0AU, and shows received aircraft within 30 nautical miles.
- Enter your home Wi-Fi password only on the setup page while connected to Bamford-Flight-Setup.
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <WebServer.h>
#include <TFT_eSPI.h>
#include <ArduinoJson.h>
#include <math.h>
// Bamford, Derbyshire — postcode S33 0AU.
struct NearestPlane {
bool valid = false;
float distanceNm = 0;
float bearing = 0;
float lat = 0;
float lon = 0;
String flight;
String company;
String hex;
String altitude;
String type;
String origin = "Looking up route...";
String destination = "Looking up route...";
};
// Forward declarations
String valueOr(JsonVariantConst value, const char *fallback);
String altitudeText(JsonVariantConst value);
String compassPoint(float bearing);
void useSmallFont();
void useBodyFont();
void useTitleFont();
String fitText(const String &text, uint8_t maxChars);
void statusLine(const String &message, uint16_t colour);
void drawRadarBase();
void drawNearestCard(const NearestPlane &plane, int aircraftCount);
String airportPlaceName(JsonObjectConst airport);
String companyName(const String &flight);
bool loadRoute(NearestPlane &plane);
void showSetupScreen();
void startSetupPortal();
bool connectSavedWiFi();
bool updateFlights();
constexpr float HOME_LAT = 53.346586f;
constexpr float HOME_LON = -1.693487f;
constexpr int RADIUS_NM = 10;
constexpr unsigned long REFRESH_MS = 30000UL;
constexpr int SCREEN_W = 320;
constexpr int RADAR_X = 83;
constexpr int RADAR_Y = 137;
constexpr int RADAR_R = 72;
constexpr uint16_t INK = TFT_WHITE;
constexpr uint16_t BACKGROUND = TFT_BLACK;
constexpr uint16_t PANEL = 0x1082;
constexpr uint16_t RING = 0x04D6;
constexpr uint16_t SUBTLE = 0x5AEB;
constexpr uint16_t ACCENT = 0x07FF;
constexpr uint16_t AIRCRAFT = 0xFFE0;
constexpr uint16_t GOOD = 0x07E0;
TFT_eSPI tft = TFT_eSPI();
Preferences preferences;
WebServer setupServer(80);
unsigned long lastRefresh = 0;
String valueOr(JsonVariantConst value, const char *fallback) {
if (value.is<const char*>()) {
String text = value.as<const char*>();
text.trim();
if (text.length()) return text;
}
return String(fallback);
}
String altitudeText(JsonVariantConst value) {
if (value.is<int>()) return String(value.as<int>()) + " ft";
return valueOr(value, "Altitude unknown");
}
String compassPoint(float bearing) {
static const char *points[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
int index = (int)((bearing + 22.5f) / 45.0f) % 8;
return String(points[index]);
}
void useSmallFont() { tft.setTextFont(2); }
void useBodyFont() { tft.setTextFont(4); }
void useTitleFont() { tft.setTextFont(4); }
String fitText(const String &text, uint8_t maxChars) {
if (text.length() <= maxChars) return text;
return text.substring(0, maxChars - 1) + ".";
}
void statusLine(const String &message, uint16_t colour = INK) {
tft.fillRect(0, 218, 320, 22, BACKGROUND);
tft.drawFastHLine(0, 217, 320, SUBTLE);
useSmallFont();
tft.setTextColor(colour, BACKGROUND);
tft.setTextDatum(MC_DATUM);
tft.drawString(message, 160, 232);
}
void drawRadarBase() {
tft.fillScreen(BACKGROUND);
tft.fillRect(0, 0, 320, 35, PANEL);
tft.fillRect(170, 43, 146, 168, PANEL);
tft.drawRoundRect(170, 43, 146, 168, 7, RING);
useBodyFont();
tft.setTextColor(ACCENT, PANEL);
tft.setTextDatum(TL_DATUM);
tft.drawString("BAMFORD", 8, 23);
useSmallFont();
tft.setTextColor(INK, PANEL);
tft.drawString("LIVE FLIGHT RADAR", 113, 22);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("10-mile local view", 214, 22);
tft.fillCircle(RADAR_X, RADAR_Y, RADAR_R, 0x0021);
tft.drawCircle(RADAR_X, RADAR_Y, RADAR_R, RING);
tft.drawCircle(RADAR_X, RADAR_Y, RADAR_R * 2 / 3, 0x03EF);
tft.drawCircle(RADAR_X, RADAR_Y, RADAR_R / 3, 0x03EF);
tft.drawFastHLine(RADAR_X - RADAR_R, RADAR_Y, RADAR_R * 2, 0x03EF);
tft.drawFastVLine(RADAR_X, RADAR_Y - RADAR_R, RADAR_R * 2, 0x03EF);
tft.fillCircle(RADAR_X, RADAR_Y, 5, GOOD);
tft.drawCircle(RADAR_X, RADAR_Y, 8, ACCENT);
useSmallFont();
tft.setTextDatum(MC_DATUM);
tft.setTextColor(ACCENT, BACKGROUND);
tft.drawString("N", RADAR_X, 48);
tft.drawString("E", 159, RADAR_Y);
tft.drawString("S", RADAR_X, 210);
tft.drawString("W", 8, RADAR_Y);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(SUBTLE, BACKGROUND);
tft.drawString("BAMFORD", 47, 146);
tft.setTextColor(ACCENT, PANEL);
tft.drawString("NEAREST AIRCRAFT", 181, 62);
}
void drawNearestCard(const NearestPlane &plane, int aircraftCount) {
if (!plane.valid) {
useBodyFont();
tft.setTextColor(SUBTLE, PANEL);
tft.setTextDatum(MC_DATUM);
tft.drawString("No aircraft", 243, 118);
useSmallFont();
tft.drawString("reported nearby", 243, 141);
return;
}
useSmallFont();
tft.setTextDatum(TL_DATUM);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("FLIGHT", 181, 82);
tft.setTextColor(AIRCRAFT, PANEL);
tft.drawString(fitText(plane.flight, 20), 181, 94);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("COMPANY", 181, 108);
tft.setTextColor(INK, PANEL);
tft.drawString(fitText(plane.company, 20), 181, 120);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("ALTITUDE", 181, 134);
tft.setTextColor(INK, PANEL);
tft.drawString(fitText(plane.altitude, 20), 181, 146);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("TO", 181, 160);
tft.setTextColor(INK, PANEL);
tft.drawString(fitText(plane.destination, 20), 181, 172);
tft.setTextColor(SUBTLE, PANEL);
tft.drawString("FROM", 181, 186);
tft.setTextColor(INK, PANEL);
tft.drawString(fitText(plane.origin, 20), 181, 198);
}
String airportPlaceName(JsonObjectConst airport) {
String name = valueOr(airport["name"], "");
String location = valueOr(airport["municipality"], "");
if (!location.length()) location = valueOr(airport["location"], "");
if (name.length()) return name;
if (location.length()) return location;
return "Airport unavailable";
}
String companyName(const String &flight) {
String prefix = flight.substring(0, 3);
prefix.toUpperCase();
if (prefix == "DAL") return "Delta Air Lines";
if (prefix == "BAW") return "British Airways";
if (prefix == "EZY") return "easyJet";
if (prefix == "RYR") return "Ryanair";
if (prefix == "VIR") return "Virgin Atlantic";
if (prefix == "TOM") return "TUI Airways";
if (prefix == "EXS") return "Jet2.com";
if (prefix == "AAL") return "American Airlines";
if (prefix == "UAL") return "United Airlines";
if (prefix == "AFR") return "Air France";
if (prefix == "DLH") return "Lufthansa";
if (prefix == "KLM") return "KLM";
if (prefix == "UAE") return "Emirates";
if (prefix == "QTR") return "Qatar Airways";
if (prefix == "THY") return "Turkish Airlines";
return "Airline " + (prefix.length() == 3 ? prefix : "unknown");
}
bool loadRoute(NearestPlane &plane) {
String callsign = plane.flight;
callsign.trim();
if (!callsign.length() || callsign == "UNKNOWN") {
plane.origin = "Route unavailable";
plane.destination = "Route unavailable";
return false;
}
// The live service is best for an aircraft that is currently airborne.
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
if (http.begin(client, "https://api.adsb.lol/api/0/routeset")) {
http.setTimeout(15000);
http.setUserAgent("Bamford-CYD-Flight-Tracker/1.7");
http.addHeader("Content-Type", "application/json");
http.addHeader("Accept", "application/json");
http.addHeader("Accept-Encoding", "identity");
http.addHeader("Connection", "close");
String request = "{\"planes\":[{\"callsign\":\"" + callsign + "\",\"lat\":" + String(plane.lat, 5) + ",\"lng\":" + String(plane.lon, 5) + "}]}";
int response = http.POST(request);
String payload = response == HTTP_CODE_OK ? http.getString() : "";
http.end();
JsonDocument routeDoc;
DeserializationError error = payload.length() ? deserializeJson(routeDoc, payload) : DeserializationError::EmptyInput;
JsonArrayConst routes = routeDoc.as<JsonArrayConst>();
if (!error && !routes.isNull() && routes.size() > 0) {
JsonArrayConst airports = routes[0]["_airports"].as<JsonArrayConst>();
if (airports.size() >= 2) {
plane.origin = airportPlaceName(airports[0].as<JsonObjectConst>());
plane.destination = airportPlaceName(airports[1].as<JsonObjectConst>());
return true;
}
}
}
// Scheduled flights missing from the live result get a second lookup by callsign.
WiFiClientSecure fallbackClient;
fallbackClient.setInsecure();
HTTPClient fallback;
String url = "https://api.adsbdb.com/v0/callsign/" + callsign;
if (fallback.begin(fallbackClient, url)) {
fallback.setTimeout(15000);
fallback.setUserAgent("Bamford-CYD-Flight-Tracker/1.7");
fallback.addHeader("Accept", "application/json");
fallback.addHeader("Accept-Encoding", "identity");
int response = fallback.GET();
String payload = response == HTTP_CODE_OK ? fallback.getString() : "";
fallback.end();
JsonDocument routeDoc;
DeserializationError error = payload.length() ? deserializeJson(routeDoc, payload) : DeserializationError::EmptyInput;
JsonObjectConst route = routeDoc["response"]["flightroute"].as<JsonObjectConst>();
JsonObjectConst origin = route["origin"].as<JsonObjectConst>();
JsonObjectConst destination = route["destination"].as<JsonObjectConst>();
if (!error && !origin.isNull() && !destination.isNull()) {
plane.origin = airportPlaceName(origin);
plane.destination = airportPlaceName(destination);
return true;
}
}
plane.origin = "Route unavailable";
plane.destination = "Route unavailable";
return false;
}
void showSetupScreen() {
tft.fillScreen(BACKGROUND);
tft.fillRect(0, 0, 320, 42, PANEL);
useTitleFont();
tft.setTextColor(ACCENT, PANEL);
tft.setTextDatum(MC_DATUM);
tft.drawString("FLIGHT RADAR", 160, 30);
useBodyFont();
tft.setTextColor(INK, BACKGROUND);
tft.drawString("Set up Wi-Fi on your phone", 160, 84);
useSmallFont();
tft.setTextColor(ACCENT, BACKGROUND);
tft.drawString("Join: Bamford-Flight-Setup", 160, 120);
tft.setTextColor(INK, BACKGROUND);
tft.drawString("Password: radarsetup", 160, 145);
tft.setTextColor(SUBTLE, BACKGROUND);
tft.drawString("Then open 192.168.4.1", 160, 180);
}
void startSetupPortal() {
WiFi.disconnect(true, false);
delay(200);
WiFi.mode(WIFI_AP);
WiFi.softAP("Bamford-Flight-Setup", "radarsetup");
showSetupScreen();
setupServer.on("/", HTTP_GET, []() {
const char page[] = "<!doctype html><html><meta name='viewport' content='width=device-width,initial-scale=1'><title>Flight Radar Wi-Fi</title><body style='font-family:sans-serif;max-width:32rem;margin:2rem auto'><h2>Bamford Flight Radar</h2><p>Enter the name and password of your normal home Wi-Fi.</p><form action='/save' method='post'><label>Wi-Fi name<br><input name='ssid' required style='width:100%'></label><br><br><label>Wi-Fi password<br><input name='pass' type='password' style='width:100%'></label><br><br><button type='submit'>Save and connect</button></form></body></html>";
setupServer.send(200, "text/html", page);
});
setupServer.on("/save", HTTP_POST, []() {
String ssid = setupServer.arg("ssid");
String pass = setupServer.arg("pass");
ssid.trim();
if (!ssid.length()) {
setupServer.send(400, "text/plain", "A Wi-Fi name is required. Go back and try again.");
return;
}
preferences.begin("flightwifi", false);
preferences.putString("ssid", ssid);
preferences.putString("pass", pass);
preferences.end();
setupServer.send(200, "text/html", "<h2>Saved</h2><p>The tracker is restarting and will join your home Wi-Fi.</p>");
delay(700);
ESP.restart();
});
setupServer.onNotFound([]() { setupServer.sendHeader("Location", "/"); setupServer.send(302, "text/plain", ""); });
setupServer.begin();
while (true) { setupServer.handleClient(); delay(2); }
}
bool connectSavedWiFi() {
preferences.begin("flightwifi", true);
String ssid = preferences.getString("ssid", "");
String pass = preferences.getString("pass", "");
preferences.end();
if (!ssid.length()) return false;
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
tft.fillScreen(BACKGROUND);
useBodyFont();
tft.setTextColor(ACCENT, BACKGROUND);
tft.setTextDatum(MC_DATUM);
tft.drawString("BAMFORD FLIGHT RADAR", 160, 96);
useSmallFont();
tft.setTextColor(INK, BACKGROUND);
tft.drawString("Joining home Wi-Fi...", 160, 132);
unsigned long started = millis();
while (WiFi.status() != WL_CONNECTED && millis() - started < 20000UL) delay(100);
return WiFi.status() == WL_CONNECTED;
}
bool updateFlights() {
drawRadarBase();
statusLine("Getting live aircraft data...", AIRCRAFT);
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
String url = "https://api.adsb.lol/v2/lat/" + String(HOME_LAT, 6) + "/lon/" + String(HOME_LON, 6) + "/dist/" + String(RADIUS_NM);
if (!http.begin(client, url)) { statusLine("Could not start data request", TFT_RED); return false; }
http.setTimeout(30000);
http.setUserAgent("Bamford-CYD-Flight-Tracker/1.6");
http.addHeader("Accept", "application/json");
http.addHeader("Accept-Encoding", "identity");
http.addHeader("Connection", "close");
int response = http.GET();
if (response != HTTP_CODE_OK) { statusLine("Data service unavailable: " + String(response), TFT_RED); http.end(); return false; }
String payload = http.getString();
http.end();
if (!payload.length()) { statusLine("Data read error: empty reply", TFT_RED); return false; }
JsonDocument filter;
JsonObject fields = filter["ac"][0].to<JsonObject>();
fields["lat"] = true; fields["lon"] = true; fields["flight"] = true; fields["hex"] = true;
fields["alt_baro"] = true; fields["track"] = true; fields["t"] = true;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload, DeserializationOption::Filter(filter));
if (error) { statusLine("Data read error: " + String(error.c_str()).substring(0, 15), TFT_RED); return false; }
NearestPlane nearest;
int aircraftCount = 0;
for (JsonObjectConst plane : doc["ac"].as<JsonArrayConst>()) {
if (plane["lat"].isNull() || plane["lon"].isNull()) continue;
float latitude = plane["lat"].as<float>();
float longitude = plane["lon"].as<float>();
float northNm = (latitude - HOME_LAT) * 60.0f;
float eastNm = (longitude - HOME_LON) * 60.0f * cosf(HOME_LAT * DEG_TO_RAD);
float distance = sqrtf(northNm * northNm + eastNm * eastNm);
if (distance > RADIUS_NM) continue;
aircraftCount++;
float bearing = atan2f(eastNm, northNm) * RAD_TO_DEG;
if (bearing < 0) bearing += 360.0f;
float track = plane["track"].isNull() ? 0.0f : plane["track"].as<float>();
float x = RADAR_X + eastNm * (RADAR_R / (float)RADIUS_NM);
float y = RADAR_Y - northNm * (RADAR_R / (float)RADIUS_NM);
float heading = (track - 90.0f) * DEG_TO_RAD;
tft.fillTriangle((int)(x + cosf(heading) * 8), (int)(y + sinf(heading) * 8), (int)(x + cosf(heading + 2.45f) * 5), (int)(y + sinf(heading + 2.45f) * 5), (int)(x + cosf(heading - 2.45f) * 5), (int)(y + sinf(heading - 2.45f) * 5), AIRCRAFT);
tft.drawCircle((int)x, (int)y, 9, ACCENT);
if (!nearest.valid || distance < nearest.distanceNm) {
nearest.valid = true;
nearest.distanceNm = distance;
nearest.bearing = bearing;
nearest.lat = latitude;
nearest.lon = longitude;
nearest.flight = valueOr(plane["flight"], valueOr(plane["hex"], "UNKNOWN").c_str());
nearest.company = companyName(nearest.flight);
nearest.hex = valueOr(plane["hex"], "------");
nearest.altitude = altitudeText(plane["alt_baro"]);
nearest.type = valueOr(plane["t"], "type unknown");
}
}
if (nearest.valid) {
statusLine("Looking up nearest flight route...", ACCENT);
loadRoute(nearest);
}
drawNearestCard(nearest, aircraftCount);
statusLine(String(aircraftCount) + " aircraft in 10 miles | next refresh 30 s", GOOD);
return true;
}
void setup() {
tft.init();
tft.setRotation(1);
tft.fillScreen(BACKGROUND);
if (!connectSavedWiFi()) startSetupPortal();
drawRadarBase();
lastRefresh = millis() - REFRESH_MS;
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
statusLine("Wi-Fi disconnected - restarting", TFT_RED);
delay(2000);
ESP.restart();
}
if (millis() - lastRefresh >= REFRESH_MS) {
lastRefresh = millis();
updateFlights();
}
delay(20);
}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.




