Über deinem Kopf fliegen ständig Flugzeuge – aber welche eigentlich, und wohin? In diesem Projekt baust du dir ein eigenes Flugradar, das genau das zeigt: Alle Maschinen im Umkreis deines Standorts erscheinen auf einem runden Radarschirm, und wenn du eines der Echos antippst, erfährst du Flugnummer, Flugzeugtyp, Höhe, Geschwindigkeit – und sogar, von wo nach wo der Flug gerade unterwegs ist.
Das Beste daran: Du brauchst dafür weder einen Funkempfänger noch einen Raspberry Pi mit Antenne auf dem Dach. Es genügt ein einziges Bauteil für rund 20 Euro – das sogenannte Cheap Yellow Display, ein ESP32 mit fest verbautem Touchscreen. Gelötet wird nichts, verkabelt auch nicht.
Diese Bauteile brauchst du
- 1× Cheap Yellow Display (ESP32-2432S028R) mit 2,8-Zoll-Touchscreen Genau diese Variante verwenden; keine ESP32-S3- oder JC2432W328C-Variante.
- 1× USB-Datenkabel zum Programmieren und zur Stromversorgung Oft im Lieferumfang enthalten; Anschluss passend zum Board prüfen.
Affiliate-Links: Kaufst du über diese Links, erhält Pollux Labs eine kleine Provision – für dich ohne Mehrkosten.
Das war es auch schon. Achte beim Kauf genau auf die Bezeichnung ESP32-2432S028R. Es gibt ähnlich aussehende Boards mit anderen Displaytreibern (etwa das JC2432W328C) und Varianten mit ESP32-S3 – bei denen passt die Konfiguration in diesem Tutorial nicht.

Woher kommen die Flugdaten?
Fast alle Verkehrsflugzeuge senden permanent ihre Position, Höhe, Geschwindigkeit und Kennung per Funk aus – dieses Verfahren heißt ADS-B. Diese Signale kann im Prinzip jeder empfangen, und weltweit tun das tausende Freiwillige mit kleinen Empfängern und speisen die Daten in offene Netzwerke ein.
Genau davon profitierst du: Statt selbst eine Antenne aufzustellen, holst du dir die fertig aufbereiteten Daten aus dem Internet. Wir nutzen dafür adsb.lol – kostenlos, ohne Anmeldung und ohne API-Schlüssel. Du schickst einfach deine Koordinaten und einen Radius hin und bekommst alle Flugzeuge in diesem Umkreis zurück.
Damit dein Radar auch dann weiterläuft, wenn dieser Dienst einmal überlastet ist, kennt der Sketch zwei weitere Quellen und wechselt bei Bedarf automatisch:
| Dienst | Aufgabe |
| adsb.lol | Flugpositionen (Hauptquelle) |
| adsb.fi | Flugpositionen (Ausweichquelle) |
| airplanes.live | Flugpositionen (Ausweichquelle) |
| adsbdb.com | Flugrouten: Start- und Zielflughafen |
Alle vier Dienste sind kostenlos und brauchen keine Registrierung.
Die Arduino IDE vorbereiten
Falls du noch nie einen ESP32 programmiert hast: In der Arduino IDE musst du einmalig die ESP32-Boards nachinstallieren. Öffne Datei > Einstellungen und trage bei Zusätzliche Boardverwalter-URLs diese Adresse ein:
https://espressif.github.io/arduino-esp32/package_esp32_index.json
Danach suchst du im Boardverwalter nach esp32 und installierst das Paket von Espressif. Als Board wählst du anschließend ESP32 Dev Module aus.
___STEADY_PAYWALL___
Diese Bibliotheken brauchst du
Installiere über den Bibliotheksverwalter (Sketch > Bibliothek einbinden > Bibliotheken verwalten) diese drei Bibliotheken:
| Bibliothek | Autor | Wofür |
|---|---|---|
| TFT_eSPI | Bodmer | steuert das Display an |
| XPT2046_Touchscreen | Paul Stoffregen | liest den Touchscreen aus |
| ArduinoJson | Benoit Blanchon | wertet die Daten der Flug-APIs aus |
Bei ArduinoJson achte darauf, dass du Version 7 installierst – der Sketch nutzt deren Syntax.
Der wichtigste Schritt: TFT_eSPI konfigurieren
Hier scheitern die meisten Projekte mit dem Cheap Yellow Display, deshalb aufgepasst: Die Bibliothek TFT_eSPI weiß nicht von allein, welches Display an welchen Pins hängt. Du musst ihr das über eine Konfigurationsdatei mitteilen.
Suche dazu im Ordner deiner Arduino-Bibliotheken den Unterordner TFT_eSPI und öffne darin die Datei User_Setup.h. Lösche ihren kompletten Inhalt und ersetze ihn durch diesen hier:
// TFT_eSPI-Konfiguration fuer das Cheap Yellow Display (ESP32-2432S028R)
// Diese Datei ersetzt die User_Setup.h im TFT_eSPI-Bibliotheksordner
// (Arduino/libraries/TFT_eSPI/User_Setup.h)
#define USER_SETUP_INFO "CYD ESP32-2432S028R"
#define ILI9341_2_DRIVER
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
// Falls die Farben invertiert wirken (z. B. gelber Hintergrund statt schwarz),
// probiere TFT_INVERSION_ON bzw. _OFF aus – es gibt zwei Panel-Varianten.
#define TFT_INVERSION_OFF
// Manche Boards haben Rot und Blau vertauscht – dann TFT_RGB_ORDER umstellen
#define TFT_RGB_ORDER TFT_BGR
#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST -1
#define TFT_BL 21
#define TFT_BACKLIGHT_ON HIGH
#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF
#define SMOOTH_FONT
#define SPI_FREQUENCY 55000000
#define SPI_READ_FREQUENCY 20000000
#define SPI_TOUCH_FREQUENCY 2500000
// WICHTIG: Das Display muss auf den HSPI-Bus, sonst belegt es denselben
// Bus wie der Touch-Controller – dann bleibt der Touchscreen stumm.
#define USE_HSPI_PORT
Zwei Zeilen darin verdienen besondere Aufmerksamkeit. Die erste ist USE_HSPI_PORT ganz unten: Der ESP32 hat zwei SPI-Busse, und ohne diese Zeile würden sich Display und Touchscreen denselben Bus teilen. Das Ergebnis wäre ein einwandfreies Bild – aber ein Touchscreen, der überhaupt nicht reagiert. Ein Fehler, den man ewig sucht, wenn man ihn nicht kennt.
Die zweite ist TFT_RGB_ORDER: Es gibt zwei Panel-Varianten des CYD. Falls bei dir später alle Farben vertauscht sind (Rot wird zu Blau), tausche hier TFT_BGR gegen TFT_RGB. Und sollte der Hintergrund hell statt schwarz sein, ersetze TFT_INVERSION_OFF durch TFT_INVERSION_ON.
Erster Test: Flugzeuge im Seriellen Monitor
Bevor wir uns um die Grafik kümmern, prüfen wir, ob die Datenverbindung steht. Dieser kleine Sketch braucht kein Display – er holt die Flugzeuge in deiner Umgebung und gibt sie als Tabelle im Seriellen Monitor aus. Du kannst ihn auch auf jedem anderen ESP32 laufen lassen.
Trage oben dein WLAN und deine Koordinaten ein. Deinen Standort findest du zum Beispiel, indem du in Google Maps mit der rechten Maustaste auf deine Position klickst – die beiden Zahlen kannst du direkt übernehmen.
/*
Flugradar – Serieller Testlauf (ohne Display)
---------------------------------------------
Testet die ADS-B-Abfrage auf einem beliebigen ESP32:
Alle 30 Sekunden werden die Flugzeuge im Umkreis als Tabelle
im seriellen Monitor (115200 Baud) ausgegeben.
Datenquellen (alle kostenlos, ohne API-Key):
adsb.lol -> Fallback adsb.fi -> Fallback airplanes.live
Einzige Bibliothek: ArduinoJson (Version 7)
Ein Projekt von Pollux Labs – polluxlabs.net
*/
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
// ----------------------- Einstellungen -----------------------
const char* WLAN_SSID = "DEIN_WLAN";
const char* WLAN_PASSWORT = "DEIN_PASSWORT";
const double HOME_LAT = 49.0069; // Beispiel: Karlsruhe
const double HOME_LON = 8.4037;
const float RADIUS_NM = 25; // Umkreis in nautischen Meilen (25 nm ≈ 46 km)
const uint32_t ABFRAGE_INTERVALL = 30000; // alle 30 Sekunden – schont die kostenlosen APIs
// Mehrere Datenquellen: Liefert die erste keine Antwort, springt der
// Sketch automatisch zur naechsten. Alle nutzen dasselbe Datenformat,
// nur der Name des Flugzeug-Arrays unterscheidet sich.
struct ApiQuelle {
const char* name;
const char* urlMuster; // wird mit lat, lon, radius gefuellt
const char* arrayKey; // "ac" oder "aircraft"
};
const ApiQuelle QUELLEN[] = {
{"adsb.lol", "https://api.adsb.lol/v2/lat/%.4f/lon/%.4f/dist/%.0f", "ac"},
{"adsb.fi", "https://opendata.adsb.fi/api/v2/lat/%.4f/lon/%.4f/dist/%.0f", "aircraft"},
{"airplanes.live", "https://api.airplanes.live/v2/point/%.4f/%.4f/%.0f", "ac"},
};
const uint8_t ANZAHL_QUELLEN = sizeof(QUELLEN) / sizeof(QUELLEN[0]);
// ----------------------- Routen-Cache -----------------------
// Abflug- und Zielort holen wir von adsbdb.com. Die Route eines Callsigns
// aendert sich waehrend des Flugs nicht – deshalb fragen wir jedes Callsign
// nur einmal ab und merken uns das Ergebnis.
struct RouteEintrag {
char callsign[9];
char route[10]; // z. B. "FRA>EZE", "-" wenn unbekannt
};
RouteEintrag routenCache[24];
uint8_t naechsterCachePlatz = 0;
// --------------------------------------------------------------
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\nFlugradar - serieller Testlauf");
Serial.print("Verbinde mit WLAN");
// Reduzierte Sendeleistung gegen Brownout-Resets bei schwacher USB-Versorgung –
// muss gesetzt werden, BEVOR die Verbindung aufgebaut wird
WiFi.mode(WIFI_STA);
WiFi.setTxPower(WIFI_POWER_8_5dBm);
WiFi.begin(WLAN_SSID, WLAN_PASSWORT);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
Serial.print(".");
}
Serial.println("\nVerbunden, IP: " + WiFi.localIP().toString());
}
void loop() {
flugzeugeAbrufen();
delay(ABFRAGE_INTERVALL);
}
// Alle Quellen der Reihe nach probieren, bis eine antwortet
void flugzeugeAbrufen() {
for (uint8_t q = 0; q < ANZAHL_QUELLEN; q++) {
if (abfrageVersuchen(QUELLEN[q])) return;
Serial.printf("%s nicht erreichbar, probiere naechste Quelle...\n", QUELLEN[q].name);
}
Serial.println("Keine Datenquelle erreichbar - naechster Versuch in 30 s");
}
bool abfrageVersuchen(const ApiQuelle &quelle) {
WiFiClientSecure client;
client.setInsecure(); // Zertifikatspruefung aus – fuer diesen Zweck ok
char url[140];
snprintf(url, sizeof(url), quelle.urlMuster, HOME_LAT, HOME_LON, RADIUS_NM);
HTTPClient http;
http.begin(client, url);
http.useHTTP10(true); // noetig, um die Antwort als Stream zu parsen
http.setUserAgent("PolluxLabs-Flugradar/1.0 (ESP32)");
http.setConnectTimeout(5000); // 5 s fuer den Verbindungsaufbau
http.setTimeout(15000); // 15 s Geduld, falls der Datenstrom kurz stockt
int status = http.GET();
if (status != 200) {
Serial.printf("API-Fehler bei %s: %d\n", quelle.name, status);
http.end();
return false;
}
// Nur die Felder behalten, die wir wirklich brauchen
JsonDocument filter;
JsonObject f = filter[quelle.arrayKey].add<JsonObject>();
f["flight"] = true; // Callsign
f["r"] = true; // Kennung (Registrierung)
f["t"] = true; // Muster (Flugzeugtyp)
f["alt_baro"] = true; // Hoehe in Fuss
f["gs"] = true; // Geschwindigkeit in Knoten
f["track"] = true; // Kurs in Grad
f["dst"] = true; // Entfernung in nm (rechnet die API fuer uns aus)
f["dir"] = true; // Peilung in Grad
JsonDocument doc;
DeserializationError fehler =
deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
http.end();
if (fehler) {
Serial.printf("JSON-Fehler bei %s: %s\n", quelle.name, fehler.c_str());
return false;
}
JsonArray flugzeuge = doc[quelle.arrayKey].as<JsonArray>();
Serial.println();
Serial.printf("Datenquelle: %s\n", quelle.name);
Serial.println("Callsign Typ Kennung Hoehe Speed Entf. Richtung Route");
Serial.println("---------------------------------------------------------------------------");
int anzahl = 0;
for (JsonObject ac : flugzeuge) {
if (ac["dst"].isNull()) continue;
anzahl++;
// Callsign kommt mit Leerzeichen aufgefuellt -> fuer die Routen-Abfrage trimmen
char callsign[9];
strlcpy(callsign, ac["flight"] | "?", sizeof(callsign));
for (int i = strlen(callsign) - 1; i >= 0 && callsign[i] == ' '; i--)
callsign[i] = '\0';
const char* kennung = ac["r"] | "-";
const char* typ = ac["t"] | "-";
float speedKmh = (float)(ac["gs"] | 0.0f) * 1.852;
float distKm = (float)(ac["dst"] | 0.0f) * 1.852;
float richtung = ac["dir"] | 0.0f;
// alt_baro ist bei Flugzeugen am Boden der String "ground"
char hoehe[10];
if (ac["alt_baro"].is<const char*>()) {
strlcpy(hoehe, "Boden", sizeof(hoehe));
} else {
snprintf(hoehe, sizeof(hoehe), "%d m", (int)lroundf((float)(ac["alt_baro"] | 0.0f) * 0.3048));
}
Serial.printf("%-9s %-5s %-9s %-9s %4.0f km/h %5.1f km %3.0f° %-3s %s\n",
callsign, typ, kennung, hoehe, speedKmh, distKm,
richtung, himmelsrichtung(richtung), routeErmitteln(callsign));
}
Serial.printf("=> %d Flugzeuge im Umkreis von %.0f km\n", anzahl, RADIUS_NM * 1.852);
return true;
}
// Peilung in eine grobe Himmelsrichtung uebersetzen
const char* himmelsrichtung(float grad) {
const char* richtungen[] = {"N", "NO", "O", "SO", "S", "SW", "W", "NW"};
int index = (int)lroundf(grad / 45.0) % 8;
return richtungen[index];
}
// ----------------------- Routen-Abfrage (adsbdb.com) -----------------------
// Liefert die Route zu einem Callsign, z. B. "FRA>EZE" – erst aus dem Cache,
// sonst per API-Abfrage. "-" bedeutet: keine Route bekannt (z. B. Privatjets).
const char* routeErmitteln(const char* callsign) {
if (callsign[0] == '\0') return "-";
// Schon im Cache?
for (uint8_t i = 0; i < 24; i++) {
if (strcmp(routenCache[i].callsign, callsign) == 0)
return routenCache[i].route;
}
// Neuer Eintrag – aeltesten Platz im Ring ueberschreiben
RouteEintrag &eintrag = routenCache[naechsterCachePlatz];
naechsterCachePlatz = (naechsterCachePlatz + 1) % 24;
strlcpy(eintrag.callsign, callsign, sizeof(eintrag.callsign));
strlcpy(eintrag.route, "-", sizeof(eintrag.route)); // Standard: unbekannt
WiFiClientSecure client;
client.setInsecure();
char url[80];
snprintf(url, sizeof(url), "https://api.adsbdb.com/v0/callsign/%s", callsign);
HTTPClient http;
http.begin(client, url);
http.useHTTP10(true);
http.setUserAgent("PolluxLabs-Flugradar/1.0 (ESP32)");
http.setConnectTimeout(5000);
http.setTimeout(10000);
if (http.GET() == 200) {
// Uns interessieren nur die Flughafen-Kuerzel von Start und Ziel
JsonDocument filter;
filter["response"]["flightroute"]["origin"]["iata_code"] = true;
filter["response"]["flightroute"]["destination"]["iata_code"] = true;
JsonDocument doc;
if (!deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter))) {
const char* von = doc["response"]["flightroute"]["origin"]["iata_code"];
const char* nach = doc["response"]["flightroute"]["destination"]["iata_code"];
if (von && nach)
snprintf(eintrag.route, sizeof(eintrag.route), "%s>%s", von, nach);
}
}
http.end();
return eintrag.route;
}
Lade den Sketch hoch und öffne den Seriellen Monitor mit 115200 Baud. Nach ein paar Sekunden sollte so etwas erscheinen:
Datenquelle: adsb.lol
Callsign Typ Kennung Hoehe Speed Entf. Richtung Route
---------------------------------------------------------------------------
DLH500 B789 D-ABPT 5471 m 798 km/h 9.5 km 305° NW MUC>GIG
RYR926T B738 9H-QBU 145 m 265 km/h 33.0 km 224° SW BGY>FKB
AIC2018 B789 VT-TSN 10668 m 974 km/h 22.4 km 220° SW DEL>ORD
=> 3 Flugzeuge im Umkreis von 46 km
Schon das ist erstaunlich unterhaltsam: Du siehst nicht nur, was gerade über dir kreist, sondern auch, woher es kommt und wohin es fliegt. Wenn das funktioniert, steht dem Radar nichts mehr im Weg.
Der Sketch für das Flugradar
Jetzt kommt das eigentliche Projekt. Trage auch hier oben dein WLAN und deine Koordinaten ein – dann kann es losgehen.
/*
Flugradar auf dem Cheap Yellow Display (ESP32-2432S028R)
--------------------------------------------------------
Zeigt Flugzeuge im Umkreis deines Standorts als animiertes Radar an.
Tippe ein Echo an, um Details und die Flugroute zu sehen.
Datenquellen (alle kostenlos, ohne API-Key):
Positionen: adsb.lol -> Fallback adsb.fi -> Fallback airplanes.live
Flugrouten: adsbdb.com
Hardware: ESP32-2432S028R ("CYD"), 2,8" ILI9341, resistiver Touch (XPT2046)
Bibliotheken (Arduino-Bibliotheksverwalter):
- TFT_eSPI (Bodmer) -> User_Setup.h aus diesem Ordner verwenden!
- XPT2046_Touchscreen (Paul Stoffregen)
- ArduinoJson (Benoit Blanchon, Version 7)
Ein Projekt von Pollux Labs – polluxlabs.net
*/
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
// ----------------------- Einstellungen -----------------------
const char* WLAN_SSID = "DEIN_WLAN";
const char* WLAN_PASSWORT = "DEIN_PASSWORT";
// Dein Standort (Mittelpunkt des Radars)
const double HOME_LAT = 49.0069; // Beispiel: Karlsruhe
const double HOME_LON = 8.4037;
const float RADIUS_NM = 25; // Radar-Reichweite in nautischen Meilen (25 nm ≈ 46 km)
const uint32_t ABFRAGE_INTERVALL = 30000; // API-Abfrage alle 30 Sekunden – schont die kostenlosen APIs
const uint8_t MAX_FLUGZEUGE = 20;
// Mehrere Datenquellen: Liefert die erste keine Antwort, springt der Sketch
// automatisch zur naechsten. Alle nutzen dasselbe Datenformat, nur der Name
// des Flugzeug-Arrays unterscheidet sich.
struct ApiQuelle {
const char* name;
const char* urlMuster; // wird mit lat, lon, radius gefuellt
const char* arrayKey; // "ac" oder "aircraft"
};
const ApiQuelle QUELLEN[] = {
{"adsb.lol", "https://api.adsb.lol/v2/lat/%.4f/lon/%.4f/dist/%.0f", "ac"},
{"adsb.fi", "https://opendata.adsb.fi/api/v2/lat/%.4f/lon/%.4f/dist/%.0f", "aircraft"},
{"airplanes.live", "https://api.airplanes.live/v2/point/%.4f/%.4f/%.0f", "ac"},
};
const uint8_t ANZAHL_QUELLEN = sizeof(QUELLEN) / sizeof(QUELLEN[0]);
// ----------------------- Display & Touch -----------------------
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite radar = TFT_eSprite(&tft); // Sprite fuer flimmerfreies Zeichnen
// Der Touch-Controller haengt beim CYD an einem eigenen SPI-Bus
#define XPT2046_IRQ 36
#define XPT2046_MOSI 32
#define XPT2046_MISO 39
#define XPT2046_CLK 25
#define XPT2046_CS 33
SPIClass touchSPI = SPIClass(VSPI);
XPT2046_Touchscreen touch(XPT2046_CS, XPT2046_IRQ);
// Grobe Touch-Kalibrierung (Rohwerte -> Pixel), bei Bedarf anpassen
const int TOUCH_X_MIN = 200, TOUCH_X_MAX = 3700;
const int TOUCH_Y_MIN = 240, TOUCH_Y_MAX = 3800;
// ----------------------- Bildschirm-Layout -----------------------
// Hier umschalten: true = Breitbild (quer, 320x240), false = Hochformat (240x320)
#define BREITBILD true
// Das Radar sitzt links, die Infoleiste daneben bzw. darunter. Im Breitbild
// faellt das Radar etwas kleiner aus, damit die Infospalte breit genug fuer
// Werte wie "824 km/h" und lange Staedtenamen bleibt.
#if BREITBILD
const uint8_t ROTATION = 1; // Querformat, 320 x 240
const int RADAR_GROESSE = 200;
const int RADAR_RADIUS = 94;
const int RADAR_X = 0, RADAR_Y = 20; // senkrecht mittig
const int INFO_X = 200, INFO_Y = 0;
const int INFO_B = 120, INFO_H = 240;
#else
const uint8_t ROTATION = 0; // Hochformat, 240 x 320
const int RADAR_GROESSE = 240;
const int RADAR_RADIUS = 112;
const int RADAR_X = 0, RADAR_Y = 0;
const int INFO_X = 0, INFO_Y = 240;
const int INFO_B = 240, INFO_H = 80;
#endif
const int RADAR_MITTE = RADAR_GROESSE / 2;
// Radar-Farben (klassischer Gruen-Look)
#define C_HINTERGRUND TFT_BLACK
#define C_RING 0x03E0 // dunkles Gruen
#define C_SWEEP 0x07E0 // sattes Gruen
#define C_ECHO 0x9FE6 // helles Gruen
#define C_AUSGEWAEHLT TFT_YELLOW
#define C_TEXT 0x9FE6
// ----------------------- Flugzeugdaten -----------------------
struct Flugzeug {
char callsign[9];
char kennung[9]; // Registrierung, z. B. D-AINE
char typ[5]; // Muster, z. B. A20N
float x, y; // Position relativ zum Standort in nm (x = Ost, y = Nord)
float hoeheFt; // barometrische Hoehe in Fuss
float speedKt; // Geschwindigkeit ueber Grund in Knoten
float kurs; // Flugrichtung in Grad
bool amBoden;
};
Flugzeug flugzeuge[MAX_FLUGZEUGE];
uint8_t anzahlFlugzeuge = 0;
int8_t ausgewaehlt = -1; // Index des angetippten Flugzeugs, -1 = keins
// Die Route holen wir erst, wenn ein Flugzeug angetippt wird – und merken uns
// das Ergebnis, damit dasselbe Callsign nicht zweimal abgefragt wird.
struct RouteEintrag {
char callsign[9];
char vonStadt[24]; // "Frankfurt am Main"
char nachStadt[24]; // "Buenos Aires"
char airline[20]; // "Lufthansa"
// Koordinaten der beiden Flughaefen – damit laesst sich pruefen, ob die
// hinterlegte Route ueberhaupt zur Flugrichtung passt
float vonLat, vonLon;
float nachLat, nachLon;
bool koordinatenBekannt;
};
RouteEintrag routenCache[12];
uint8_t naechsterCachePlatz = 0;
RouteEintrag* aktuelleRoute = nullptr; // Route des ausgewaehlten Flugzeugs
uint32_t naechsteAbfrage = 0; // Zeitpunkt der naechsten API-Abfrage
uint32_t letzterFrame = 0;
float sweepWinkel = 0;
bool infoNeuZeichnen = true;
// Nach einem Fehlschlag warten wir nicht das volle Intervall ab, sondern
// probieren es schnell wieder – und verlaengern die Pause bei wiederholten
// Fehlern (5 s, 10 s, 20 s, dann normal weiter).
bool datenGueltig = false; // false = noch keine erfolgreiche Abfrage
uint8_t fehlversuche = 0;
// ----------------------- Setup -----------------------
void setup() {
Serial.begin(115200);
touchSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
touch.begin(touchSPI);
touch.setRotation(0);
tft.init();
tft.setRotation(ROTATION);
tft.fillScreen(C_HINTERGRUND);
radar.setColorDepth(8); // 8 Bit sparen RAM (240x240x8 = ca. 58 KB)
radar.createSprite(RADAR_GROESSE, RADAR_GROESSE);
tft.setTextColor(C_TEXT, C_HINTERGRUND);
tft.drawCentreString("Verbinde mit WLAN...", tft.width() / 2, tft.height() / 2, 2);
// Reduzierte Sendeleistung gegen Brownout-Resets bei schwacher Versorgung –
// muss gesetzt werden, BEVOR die Verbindung aufgebaut wird
WiFi.mode(WIFI_STA);
WiFi.setTxPower(WIFI_POWER_8_5dBm);
WiFi.begin(WLAN_SSID, WLAN_PASSWORT);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
Serial.print(".");
}
Serial.println("\nWLAN verbunden: " + WiFi.localIP().toString());
// Kurz durchatmen: Direkt nach dem Verbinden ist der Netzwerk-Stack
// (vor allem die Namensaufloesung) oft noch nicht bereit – ohne diese
// Pause schlaegt die erste HTTPS-Abfrage haeufig fehl.
delay(1000);
tft.fillScreen(C_HINTERGRUND);
flugzeugeAbrufen();
}
// ----------------------- Hauptschleife -----------------------
void loop() {
// Neue Positionen holen, sobald der naechste Abfragezeitpunkt erreicht ist
if ((int32_t)(millis() - naechsteAbfrage) >= 0) {
flugzeugeAbrufen();
}
// Beruehrung auswerten
touchAuswerten();
// Radar mit ca. 30 fps zeichnen
if (millis() - letzterFrame > 33) {
float dt = (millis() - letzterFrame) / 1000.0;
letzterFrame = millis();
positionenFortschreiben(dt);
sweepWinkel = fmodf(sweepWinkel + 90 * dt, 360); // 90 Grad pro Sekunde
// Solange keine Daten da sind, laeuft unten ein Countdown – der muss
// jede Sekunde neu gezeichnet werden
static uint32_t letzterCountdown = 0;
if (!datenGueltig && millis() - letzterCountdown > 1000) {
letzterCountdown = millis();
infoNeuZeichnen = true;
}
radarZeichnen();
if (infoNeuZeichnen) {
infoleisteZeichnen();
infoNeuZeichnen = false;
}
}
}
// ----------------------- API-Abfrage -----------------------
// Alle Quellen der Reihe nach probieren, bis eine antwortet
void flugzeugeAbrufen() {
for (uint8_t q = 0; q < ANZAHL_QUELLEN; q++) {
if (abfrageVersuchen(QUELLEN[q])) {
datenGueltig = true;
fehlversuche = 0;
naechsteAbfrage = millis() + ABFRAGE_INTERVALL;
return;
}
Serial.printf("%s nicht erreichbar, probiere naechste Quelle...\n", QUELLEN[q].name);
}
// Keine Quelle hat geantwortet: bald erneut versuchen, statt das volle
// Intervall abzuwarten. Bei wiederholten Fehlern wird die Pause laenger.
if (fehlversuche < 3) fehlversuche++;
uint32_t pause = min((uint32_t)5000 << (fehlversuche - 1), ABFRAGE_INTERVALL);
naechsteAbfrage = millis() + pause;
Serial.printf("Keine Datenquelle erreichbar - neuer Versuch in %lu s\n", pause / 1000);
infoNeuZeichnen = true;
}
bool abfrageVersuchen(const ApiQuelle &quelle) {
WiFiClientSecure client;
client.setInsecure(); // Zertifikatspruefung aus – fuer diesen Zweck ok
char url[140];
snprintf(url, sizeof(url), quelle.urlMuster, HOME_LAT, HOME_LON, RADIUS_NM);
HTTPClient http;
http.begin(client, url);
http.useHTTP10(true); // noetig, um die Antwort als Stream zu parsen
http.setUserAgent("PolluxLabs-Flugradar/1.0 (ESP32)");
http.setConnectTimeout(5000); // 5 s fuer den Verbindungsaufbau
http.setTimeout(15000); // 15 s Geduld, falls der Datenstrom kurz stockt
int status = http.GET();
if (status != 200) {
Serial.printf("API-Fehler bei %s: %d\n", quelle.name, status);
http.end();
return false;
}
// Die Antwort enthaelt viele Felder, die wir nicht brauchen.
// Ein Filter sorgt dafuer, dass ArduinoJson nur das Noetige behaelt.
JsonDocument filter;
JsonObject f = filter[quelle.arrayKey].add<JsonObject>();
f["flight"] = true;
f["r"] = true;
f["t"] = true;
f["alt_baro"] = true;
f["gs"] = true;
f["track"] = true;
f["dst"] = true; // Entfernung in nm – rechnet adsb.lol praktischerweise fuer uns aus
f["dir"] = true; // Peilung in Grad ab unserem Standort
JsonDocument doc;
DeserializationError fehler =
deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
http.end();
if (fehler) {
Serial.printf("JSON-Fehler bei %s: %s\n", quelle.name, fehler.c_str());
return false;
}
// Merken, was gerade ausgewaehlt ist, damit die Auswahl erhalten bleibt
char merkeCallsign[9] = "";
if (ausgewaehlt >= 0) strcpy(merkeCallsign, flugzeuge[ausgewaehlt].callsign);
ausgewaehlt = -1;
anzahlFlugzeuge = 0;
for (JsonObject ac : doc[quelle.arrayKey].as<JsonArray>()) {
if (anzahlFlugzeuge >= MAX_FLUGZEUGE) break;
if (ac["dst"].isNull() || ac["dir"].isNull()) continue;
Flugzeug &fz = flugzeuge[anzahlFlugzeuge];
// Callsign kommt mit Leerzeichen aufgefuellt -> abschneiden
strlcpy(fz.callsign, ac["flight"] | "", sizeof(fz.callsign));
for (int i = strlen(fz.callsign) - 1; i >= 0 && fz.callsign[i] == ' '; i--)
fz.callsign[i] = '\0';
if (fz.callsign[0] == '\0') strlcpy(fz.callsign, ac["r"] | "???", sizeof(fz.callsign));
strlcpy(fz.kennung, ac["r"] | "-", sizeof(fz.kennung));
strlcpy(fz.typ, ac["t"] | "-", sizeof(fz.typ));
// Position aus Entfernung + Peilung berechnen
float dst = ac["dst"];
float dir = radians((float)ac["dir"]);
fz.x = dst * sinf(dir);
fz.y = dst * cosf(dir);
// alt_baro ist bei Flugzeugen am Boden der String "ground"
fz.amBoden = ac["alt_baro"].is<const char*>();
fz.hoeheFt = fz.amBoden ? 0 : (float)(ac["alt_baro"] | 0.0f);
fz.speedKt = ac["gs"] | 0.0f;
fz.kurs = ac["track"] | 0.0f;
if (merkeCallsign[0] && strcmp(merkeCallsign, fz.callsign) == 0)
ausgewaehlt = anzahlFlugzeuge;
anzahlFlugzeuge++;
}
// Ist das ausgewaehlte Flugzeug aus dem Umkreis verschwunden, faellt auch
// seine Route weg
if (ausgewaehlt < 0) aktuelleRoute = nullptr;
Serial.printf("%d Flugzeuge im Umkreis (Quelle: %s)\n", anzahlFlugzeuge, quelle.name);
infoNeuZeichnen = true;
return true;
}
// Zwischen zwei API-Abfragen bewegen wir die Flugzeuge rechnerisch weiter
// (Kurs + Geschwindigkeit) – so wirkt das Radar fluessig statt ruckartig.
void positionenFortschreiben(float dt) {
for (int i = 0; i < anzahlFlugzeuge; i++) {
float strecke = flugzeuge[i].speedKt * dt / 3600.0; // Knoten -> nm pro Sekunde
flugzeuge[i].x += strecke * sinf(radians(flugzeuge[i].kurs));
flugzeuge[i].y += strecke * cosf(radians(flugzeuge[i].kurs));
}
}
// ----------------------- Darstellung -----------------------
void radarZeichnen() {
radar.fillSprite(C_HINTERGRUND);
// Entfernungsringe + Fadenkreuz
for (int r = RADAR_RADIUS; r > 0; r -= RADAR_RADIUS / 3)
radar.drawCircle(RADAR_MITTE, RADAR_MITTE, r, C_RING);
radar.drawLine(RADAR_MITTE - RADAR_RADIUS, RADAR_MITTE,
RADAR_MITTE + RADAR_RADIUS, RADAR_MITTE, C_RING);
radar.drawLine(RADAR_MITTE, RADAR_MITTE - RADAR_RADIUS,
RADAR_MITTE, RADAR_MITTE + RADAR_RADIUS, C_RING);
// Radar-Sweep (der rotierende "Scheibenwischer")
float sw = radians(sweepWinkel);
radar.drawLine(RADAR_MITTE, RADAR_MITTE,
RADAR_MITTE + RADAR_RADIUS * sinf(sw),
RADAR_MITTE - RADAR_RADIUS * cosf(sw), C_SWEEP);
// Flugzeuge einzeichnen
float massstab = (float)RADAR_RADIUS / RADIUS_NM; // Pixel pro nm
for (int i = 0; i < anzahlFlugzeuge; i++) {
int px = RADAR_MITTE + lroundf(flugzeuge[i].x * massstab);
int py = RADAR_MITTE - lroundf(flugzeuge[i].y * massstab);
if (px < 0 || px >= RADAR_GROESSE || py < 0 || py >= RADAR_GROESSE) continue;
uint16_t farbe = (i == ausgewaehlt) ? C_AUSGEWAEHLT : C_ECHO;
dreieckZeichnen(px, py, flugzeuge[i].kurs, farbe);
if (i == ausgewaehlt) {
radar.drawCircle(px, py, 9, C_AUSGEWAEHLT);
radar.setTextColor(C_AUSGEWAEHLT);
radar.drawString(flugzeuge[i].callsign, px + 12, py - 4, 1);
}
}
// Reichweite unten links einblenden
radar.setTextColor(C_RING);
radar.drawString(String(lroundf(RADIUS_NM * 1.852)) + " km", 4, RADAR_GROESSE - 12, 1);
radar.pushSprite(RADAR_X, RADAR_Y);
}
// Kleines Dreieck, das in Flugrichtung zeigt
void dreieckZeichnen(int x, int y, float kursGrad, uint16_t farbe) {
float k = radians(kursGrad);
float s = sinf(k), c = cosf(k);
// Spitze + zwei hintere Ecken, jeweils um den Kurs gedreht
int x1 = x + lroundf( 6 * s), y1 = y - lroundf( 6 * c);
int x2 = x + lroundf(-4 * s + 3 * c), y2 = y - lroundf(-4 * c - 3 * s);
int x3 = x + lroundf(-4 * s - 3 * c), y3 = y - lroundf(-4 * c + 3 * s);
radar.fillTriangle(x1, y1, x2, y2, x3, y3, farbe);
}
// Kuerzt einen Text so weit, dass er in die verfuegbare Breite passt.
// Ohne das koennten lange Werte oder Staedtenamen ueber den Rand laufen.
String passendGekuerzt(const String &text, int maxBreite, uint8_t schrift) {
if (tft.textWidth(text, schrift) <= maxBreite) return text;
String gekuerzt = text;
while (gekuerzt.length() > 1 &&
tft.textWidth(gekuerzt + ".", schrift) > maxBreite) {
gekuerzt.remove(gekuerzt.length() - 1);
}
return gekuerzt + ".";
}
// Zeichnet einen Text ueber bis zu zwei Zeilen und bricht dabei an einem
// Leerzeichen um – schoener als "Frankfurt am" mitten im Namen abzuschneiden.
void textUmbrochen(int x, int y, const String &text, int maxBreite,
uint8_t schrift, int zeilenhoehe) {
if (tft.textWidth(text, schrift) <= maxBreite) {
tft.drawString(text, x, y, schrift);
return;
}
// Die letzte Trennstelle suchen, die noch in die Zeile passt
int trenn = -1;
for (int i = 0; i < (int)text.length(); i++) {
if (text[i] != ' ') continue;
if (tft.textWidth(text.substring(0, i), schrift) <= maxBreite) trenn = i;
else break;
}
if (trenn < 0) { // ein einzelnes, sehr langes Wort
tft.drawString(passendGekuerzt(text, maxBreite, schrift), x, y, schrift);
return;
}
tft.drawString(text.substring(0, trenn), x, y, schrift);
tft.drawString(passendGekuerzt(text.substring(trenn + 1), maxBreite, schrift),
x, y + zeilenhoehe, schrift);
}
// Beschriftetes Wertefeld: kleine Ueberschrift, darunter der Wert.
// breite = 0 bedeutet: bis zum rechten Rand des Infobereichs.
void feldZeichnen(int x, int y, const char* label, const String &wert,
uint8_t schrift, int breite = 0) {
int platz = breite > 0 ? breite : (INFO_X + INFO_B - 4) - x;
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawString(passendGekuerzt(label, platz, 1), x, y, 1);
tft.setTextColor(C_TEXT, C_HINTERGRUND);
tft.drawString(passendGekuerzt(wert, platz, schrift), x, y + 10, schrift);
}
// Hoehe des ausgewaehlten Flugzeugs als Text
String hoeheText(const Flugzeug &fz) {
if (fz.amBoden) return "am Boden";
return String(lroundf(fz.hoeheFt * 0.3048)) + " m";
}
String tempoText(const Flugzeug &fz) {
return String(lroundf(fz.speedKt * 1.852)) + " km/h";
}
String entfernungText(const Flugzeug &fz) {
return String(sqrtf(fz.x * fz.x + fz.y * fz.y) * 1.852, 1) + " km";
}
// ----------------------- Routen-Plausibilitaet -----------------------
//
// Die Routen von adsbdb sind einem Rufzeichen fest zugeordnet und stammen
// nicht vom Flug selbst. Weil Airlines Rufzeichen fuer Hin- und Rueckflug
// wiederverwenden, steht die Strecke deshalb manchmal verkehrt herum in der
// Datenbank. Das laesst sich pruefen: Wir vergleichen den tatsaechlichen Kurs
// des Flugzeugs mit der Richtung, in der Start- und Zielflughafen liegen.
// Peilung von einem Punkt zu einem anderen, in Grad (0 = Norden)
float peilung(double lat1, double lon1, double lat2, double lon2) {
double dLon = radians(lon2 - lon1);
double y = sin(dLon) * cos(radians(lat2));
double x = cos(radians(lat1)) * sin(radians(lat2)) -
sin(radians(lat1)) * cos(radians(lat2)) * cos(dLon);
float grad = degrees(atan2(y, x));
return grad < 0 ? grad + 360 : grad;
}
// Kleinster Winkel zwischen zwei Richtungen (0 bis 180 Grad)
float winkelAbstand(float a, float b) {
float d = fabsf(a - b);
if (d > 180) d = 360 - d;
return d;
}
// true, wenn das Flugzeug offensichtlich zum hinterlegten Startflughafen
// unterwegs ist – dann sind Start und Ziel in der Datenbank vertauscht.
bool routeVerkehrtHerum(const Flugzeug &fz, const RouteEintrag *route) {
if (!route || !route->koordinatenBekannt) return false;
if (fz.amBoden || fz.speedKt < 100) return false; // Kurs waere unzuverlaessig
// Position des Flugzeugs aus Entfernung und Peilung zurueckrechnen:
// eine Bogenminute entspricht einer nautischen Meile
double lat = HOME_LAT + fz.y / 60.0;
double lon = HOME_LON + (fz.x / 60.0) / cos(radians(lat));
float zumZiel = peilung(lat, lon, route->nachLat, route->nachLon);
float zumStart = peilung(lat, lon, route->vonLat, route->vonLon);
// Nur tauschen, wenn der Startflughafen deutlich besser zum Kurs passt
return winkelAbstand(fz.kurs, zumStart) + 40 < winkelAbstand(fz.kurs, zumZiel);
}
void infoleisteZeichnen() {
tft.fillRect(INFO_X, INFO_Y, INFO_B, INFO_H, C_HINTERGRUND);
// Trennlinie zwischen Radar und Infobereich
#if BREITBILD
tft.drawFastVLine(INFO_X, 0, INFO_H, C_RING);
#else
tft.drawFastHLine(0, INFO_Y, INFO_B, C_RING);
#endif
// Noch keine Daten? Dann ehrlich sagen, woran es liegt – "0 Flugzeuge"
// waere hier irrefuehrend.
if (!datenGueltig) {
zustandZeichnen();
return;
}
if (ausgewaehlt < 0) {
uebersichtZeichnen();
return;
}
detailsZeichnen(flugzeuge[ausgewaehlt]);
}
#if BREITBILD
// ---------- Breitbild: schmale Spalte rechts neben dem Radar ----------
void zustandZeichnen() {
const int x = INFO_X + 6;
tft.setTextColor(C_TEXT, C_HINTERGRUND);
if (fehlversuche == 0) {
tft.drawString("Suche", x, 100, 2);
tft.drawString("Flugzeuge", x, 118, 2);
} else {
tft.drawString("Keine", x, 92, 2);
tft.drawString("Verbindung", x, 110, 1);
int32_t restMs = (int32_t)(naechsteAbfrage - millis());
int rest = restMs > 0 ? (restMs + 999) / 1000 : 0;
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawString("Neu in " + String(rest) + "s", x, 128, 1);
}
}
void uebersichtZeichnen() {
const int x = INFO_X + 6;
tft.setTextColor(C_TEXT, C_HINTERGRUND);
tft.drawString(String(anzahlFlugzeuge), x, 96, 4);
tft.drawString("Flugzeuge", x, 126, 1);
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawString("Echo antippen", x, 150, 1);
tft.drawString("fuer Details", x, 162, 1);
}
void detailsZeichnen(const Flugzeug &fz) {
const int x = INFO_X + 6;
tft.setTextColor(C_AUSGEWAEHLT, C_HINTERGRUND);
tft.drawString(fz.callsign, x, 4, 2);
tft.drawFastHLine(INFO_X + 4, 24, INFO_B - 8, C_RING);
feldZeichnen(x, 28, "MUSTER", fz.typ, 2);
feldZeichnen(x, 56, "KENNUNG", fz.kennung, 2);
feldZeichnen(x, 84, "HOEHE", hoeheText(fz), 2);
feldZeichnen(x, 112, "TEMPO", tempoText(fz), 2);
feldZeichnen(x, 140, "ENTFERNUNG", entfernungText(fz), 2);
// Flugroute, sofern adsbdb.com sie kennt – Ortsnamen duerfen umbrechen.
// Passt die Richtung nicht zum Kurs, drehen wir die Strecke um und
// kennzeichnen sie mit einem Fragezeichen.
if (aktuelleRoute && aktuelleRoute->vonStadt[0] != '\0') {
bool getauscht = routeVerkehrtHerum(fz, aktuelleRoute);
const char* von = getauscht ? aktuelleRoute->nachStadt : aktuelleRoute->vonStadt;
const char* nach = getauscht ? aktuelleRoute->vonStadt : aktuelleRoute->nachStadt;
tft.drawFastHLine(INFO_X + 4, 170, INFO_B - 8, C_RING);
ortZeichnen(x, 174, getauscht ? "VON ?" : "VON", von);
ortZeichnen(x, 206, getauscht ? "NACH ?" : "NACH", nach);
}
}
// Start- bzw. Zielort: Beschriftung und darunter der Ortsname,
// der bei Bedarf auf zwei Zeilen umbricht
void ortZeichnen(int x, int y, const char* label, const char* ort) {
int platz = (INFO_X + INFO_B - 4) - x;
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawString(label, x, y, 1);
tft.setTextColor(C_SWEEP, C_HINTERGRUND);
textUmbrochen(x, y + 10, ort, platz, 1, 12);
}
#else
// ---------- Hochformat: breite Leiste unter dem Radar ----------
void zustandZeichnen() {
tft.setTextColor(C_TEXT, C_HINTERGRUND);
if (fehlversuche == 0) {
tft.drawCentreString("Suche Flugzeuge...", 120, INFO_Y + 20, 2);
} else {
tft.drawCentreString("Keine Verbindung zur API", 120, INFO_Y + 20, 2);
int32_t restMs = (int32_t)(naechsteAbfrage - millis());
int rest = restMs > 0 ? (restMs + 999) / 1000 : 0;
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawCentreString("Neuer Versuch in " + String(rest) + " s", 120, INFO_Y + 45, 2);
}
}
void uebersichtZeichnen() {
tft.setTextColor(C_TEXT, C_HINTERGRUND);
tft.drawCentreString(String(anzahlFlugzeuge) + " Flugzeuge im Umkreis", 120, INFO_Y + 20, 2);
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawCentreString("Tippe auf ein Echo fuer Details", 120, INFO_Y + 45, 2);
}
void detailsZeichnen(const Flugzeug &fz) {
// Zeile 1: Callsign gross, rechts daneben Muster und Kennung
tft.setTextColor(C_AUSGEWAEHLT, C_HINTERGRUND);
tft.drawString(fz.callsign, 6, INFO_Y + 2, 4);
feldZeichnen(130, INFO_Y + 2, "MUSTER", fz.typ, 2, 52);
feldZeichnen(186, INFO_Y + 2, "KENNUNG", fz.kennung, 2, 50);
// Zeile 2: Hoehe, Tempo, Entfernung – jeweils beschriftet
feldZeichnen( 6, INFO_Y + 30, "HOEHE", hoeheText(fz), 2, 76);
feldZeichnen( 86, INFO_Y + 30, "TEMPO", tempoText(fz), 2, 76);
feldZeichnen(166, INFO_Y + 30, "ENTFERNUNG", entfernungText(fz), 2, 70);
// Zeile 3: Flugroute, sofern adsbdb.com sie kennt. Passt die Richtung nicht
// zum Kurs, drehen wir die Strecke um und haengen ein Fragezeichen an.
if (aktuelleRoute && aktuelleRoute->vonStadt[0] != '\0') {
bool getauscht = routeVerkehrtHerum(fz, aktuelleRoute);
String strecke = getauscht
? String(aktuelleRoute->nachStadt) + " > " + aktuelleRoute->vonStadt + " ?"
: String(aktuelleRoute->vonStadt) + " > " + aktuelleRoute->nachStadt;
tft.setTextColor(C_SWEEP, C_HINTERGRUND);
tft.drawString(passendGekuerzt(strecke, 150, 1), 6, INFO_Y + 62, 1);
if (aktuelleRoute->airline[0] != '\0') {
tft.setTextColor(C_RING, C_HINTERGRUND);
tft.drawRightString(aktuelleRoute->airline, 234, INFO_Y + 62, 1);
}
}
}
#endif
// ----------------------- Touch -----------------------
void touchAuswerten() {
static uint32_t letzteBeruehrung = 0;
if (!touch.tirqTouched() || !touch.touched()) return;
if (millis() - letzteBeruehrung < 300) return; // entprellen
letzteBeruehrung = millis();
TS_Point p = touch.getPoint();
// Der Touch-Controller kennt die Bildschirmdrehung nicht – im Breitbild
// muessen wir die Achsen selbst tauschen.
#if BREITBILD
int px = map(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 320);
int py = map(p.x, TOUCH_X_MAX, TOUCH_X_MIN, 0, 240);
#else
int px = map(p.x, TOUCH_X_MIN, TOUCH_X_MAX, 0, 240);
int py = map(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 320);
#endif
// Zum Kalibrieren: Rohwerte der Ecken ablesen und oben eintragen
Serial.printf("Touch roh: x=%d y=%d -> Pixel: %d/%d\n", p.x, p.y, px, py);
// Von Bildschirm- auf Radarkoordinaten umrechnen (das Radar sitzt im
// Breitbild nicht ganz oben links)
px -= RADAR_X;
py -= RADAR_Y;
// Nur Tipps auf die Radarflaeche interessieren uns
if (px < 0 || px >= RADAR_GROESSE || py < 0 || py >= RADAR_GROESSE) return;
// Das Echo suchen, das der Beruehrung am naechsten liegt
float massstab = (float)RADAR_RADIUS / RADIUS_NM;
int bester = -1;
float besteDistanz = 25 * 25; // maximal 25 Pixel Abstand
for (int i = 0; i < anzahlFlugzeuge; i++) {
int fx = RADAR_MITTE + lroundf(flugzeuge[i].x * massstab);
int fy = RADAR_MITTE - lroundf(flugzeuge[i].y * massstab);
float d = (fx - px) * (fx - px) + (fy - py) * (fy - py);
if (d < besteDistanz) { besteDistanz = d; bester = i; }
}
ausgewaehlt = (bester == ausgewaehlt) ? -1 : bester; // erneutes Tippen hebt Auswahl auf
// Route nur fuer das gerade ausgewaehlte Flugzeug holen – das spart Abfragen
aktuelleRoute = (ausgewaehlt >= 0) ? routeErmitteln(flugzeuge[ausgewaehlt].callsign) : nullptr;
infoNeuZeichnen = true;
}
// ----------------------- Routen-Abfrage (adsbdb.com) -----------------------
// adsbdb liefert Ortsnamen teils mit Zusatz in Klammern, z. B.
// "Graz (Feldkirchen)" oder "Buenos Aires (Ezeiza)". Fuer die Anzeige
// interessiert nur der Stadtname davor.
void stadtnameKuerzen(const char* roh, char* ziel, size_t groesse) {
strlcpy(ziel, roh ? roh : "", groesse);
char* klammer = strstr(ziel, " (");
if (klammer) *klammer = '\0';
// Leerzeichen am Ende entfernen
for (int i = strlen(ziel) - 1; i >= 0 && ziel[i] == ' '; i--) ziel[i] = '\0';
}
// Liefert die Route zu einem Callsign – erst aus dem Cache, sonst per API.
RouteEintrag* routeErmitteln(const char* callsign) {
if (callsign[0] == '\0') return nullptr;
// Schon einmal abgefragt?
for (uint8_t i = 0; i < 12; i++) {
if (strcmp(routenCache[i].callsign, callsign) == 0)
return &routenCache[i];
}
// Neuer Eintrag – aeltesten Platz im Ring ueberschreiben
RouteEintrag &eintrag = routenCache[naechsterCachePlatz];
naechsterCachePlatz = (naechsterCachePlatz + 1) % 12;
strlcpy(eintrag.callsign, callsign, sizeof(eintrag.callsign));
eintrag.vonStadt[0] = eintrag.nachStadt[0] = eintrag.airline[0] = '\0';
eintrag.koordinatenBekannt = false;
WiFiClientSecure client;
client.setInsecure();
char url[80];
snprintf(url, sizeof(url), "https://api.adsbdb.com/v0/callsign/%s", callsign);
HTTPClient http;
http.begin(client, url);
http.useHTTP10(true);
http.setUserAgent("PolluxLabs-Flugradar/1.0 (ESP32)");
http.setConnectTimeout(5000);
http.setTimeout(10000);
if (http.GET() == 200) {
// Uns interessieren nur Flughafen-Kuerzel, Stadt und Airline
JsonDocument filter;
JsonObject fr = filter["response"]["flightroute"].to<JsonObject>();
fr["airline"]["name"] = true;
fr["origin"]["iata_code"] = true;
fr["origin"]["municipality"] = true;
fr["origin"]["latitude"] = true;
fr["origin"]["longitude"] = true;
fr["destination"]["iata_code"] = true;
fr["destination"]["municipality"] = true;
fr["destination"]["latitude"] = true;
fr["destination"]["longitude"] = true;
JsonDocument doc;
if (!deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter))) {
JsonObject route = doc["response"]["flightroute"];
const char* vonCode = route["origin"]["iata_code"];
const char* nachCode = route["destination"]["iata_code"];
const char* vonStadt = route["origin"]["municipality"] | vonCode;
const char* nachStadt = route["destination"]["municipality"] | nachCode;
if (vonCode && nachCode) {
stadtnameKuerzen(vonStadt, eintrag.vonStadt, sizeof(eintrag.vonStadt));
stadtnameKuerzen(nachStadt, eintrag.nachStadt, sizeof(eintrag.nachStadt));
strlcpy(eintrag.airline, route["airline"]["name"] | "", sizeof(eintrag.airline));
eintrag.vonLat = route["origin"]["latitude"] | 0.0f;
eintrag.vonLon = route["origin"]["longitude"] | 0.0f;
eintrag.nachLat = route["destination"]["latitude"] | 0.0f;
eintrag.nachLon = route["destination"]["longitude"] | 0.0f;
eintrag.koordinatenBekannt =
!(eintrag.vonLat == 0 && eintrag.vonLon == 0) &&
!(eintrag.nachLat == 0 && eintrag.nachLon == 0);
}
}
}
http.end();
return &eintrag;
}
So funktioniert der Sketch
Die Position auf dem Schirm
Eine Sache macht dieses Projekt einfacher, als man denkt: Die API rechnet die Entfernung und die Himmelsrichtung jedes Flugzeugs bereits für dich aus. Im Feld dst steht die Entfernung in nautischen Meilen, in dir die Peilung in Grad. Du musst also nicht mit Längen- und Breitengraden hantieren, sondern rechnest nur noch von Polarkoordinaten auf x und y um:
float dst = ac["dst"];
float dir = radians((float)ac["dir"]);
fz.x = dst * sinf(dir);
fz.y = dst * cosf(dir);
Warum die Flugzeuge flüssig wandern
Der Sketch fragt die API nur alle 30 Sekunden ab – öfter wäre unhöflich gegenüber einem kostenlosen Dienst. Trotzdem springen die Flugzeuge nicht alle halbe Minute ruckartig weiter. Der Trick: Zwischen zwei Abfragen berechnet der Sketch aus Kurs und Geschwindigkeit selbst, wo sich eine Maschine gerade befinden müsste.
void positionenFortschreiben(float dt) {
for (int i = 0; i < anzahlFlugzeuge; i++) {
float strecke = flugzeuge[i].speedKt * dt / 3600.0;
flugzeuge[i].x += strecke * sinf(radians(flugzeuge[i].kurs));
flugzeuge[i].y += strecke * cosf(radians(flugzeuge[i].kurs));
}
}
Das ist natürlich nur eine Schätzung – aber eine sehr gute, denn Flugzeuge fliegen auf ihrer Reiseflughöhe fast immer geradeaus. Bei der nächsten Abfrage werden die Positionen ohnehin wieder korrigiert.
Flimmerfreie Animation mit einem Sprite
Würde der Sketch bei jedem Bild zuerst den Schirm schwarz füllen und dann alles neu zeichnen, würde das Radar sichtbar flackern. Stattdessen wird das komplette Radarbild zunächst im Arbeitsspeicher zusammengesetzt – in einem sogenannten Sprite – und erst dann in einem Rutsch aufs Display geschoben.
radar.setColorDepth(8); // 8 Bit sparen Arbeitsspeicher
radar.createSprite(RADAR_GROESSE, RADAR_GROESSE);
Mit 8 Bit Farbtiefe belegt das Sprite etwa 58 Kilobyte – bei 16 Bit wäre es das Doppelte, und der Speicher würde knapp. Für die Radar-Optik reichen die Farben vollkommen aus.
Start und Ziel herausfinden
Die Flugroute liefert eine zweite API: adsbdb.com. Du schickst ihr die Flugnummer und bekommst Airline, Start- und Zielflughafen zurück. Damit das sparsam bleibt, fragt der Sketch eine Route erst dann ab, wenn du ein Flugzeug antippst – und merkt sich das Ergebnis anschließend, damit dieselbe Flugnummer nicht zweimal abgefragt wird.
Nicht jeder Flug ist dort hinterlegt: Privatjets, Militärmaschinen und Positionierungsflüge haben oft keine Route. Dann bleibt die Zeile einfach leer.
Warum Start und Ziel manchmal nicht stimmen
Position, Höhe, Geschwindigkeit und Kennung funkt das Flugzeug selbst per ADS-B – das sind gemessene Werte, und die stimmen. Die Flugroute wird dagegen nur nachgeschlagen: In der Datenbank von adsbdb.com steht zu jedem Rufzeichen die übliche Strecke nach dem Prinzip „DLH99F fliegt normalerweise von Graz nach Frankfurt“. Was diese Maschine gerade tatsächlich macht, weiß der Dienst nicht. Weil Airlines Rufzeichen mehrfach verwenden, Flugpläne wechseln und oft nur eine Richtung eines Umlaufs hinterlegt ist, liegt die Route deshalb ab und zu daneben.
Den häufigsten Fall fängt der Sketch ab: adsbdb liefert auch die Koordinaten beider Flughäfen mit, also lässt sich prüfen, ob die Strecke überhaupt zur Flugrichtung passt. Fliegt die Maschine eindeutig auf den hinterlegten Startflughafen zu, ist die Route verkehrt herum gespeichert – dann dreht der Sketch sie um und markiert sie mit einem Fragezeichen. Ist ein Rufzeichen komplett falsch zugeordnet, hilft das allerdings nicht. Ein bisschen Unschärfe bleibt also – dafür kostet dich das Ganze keinen Cent und keine Anmeldung.
Hochformat oder Breitbild?
Das Radar gibt es in zwei Varianten, und du entscheidest mit einer einzigen Zeile ganz oben im Sketch, welche du möchtest:
// Hier umschalten: true = Breitbild (quer, 320x240), false = Hochformat (240x320)
#define BREITBILD true

Im Hochformat sitzt der Radarschirm oben, darunter läuft ein breites Infoband. Im Breitbild steht das Radar links und die Details erscheinen als Spalte rechts daneben – dort untereinander, was gerade für lange Städtenamen angenehmer zu lesen ist.
Um die Bildschirmdrehung und das Vertauschen der Touch-Achsen kümmert sich der Schalter automatisch mit.
Das Radar anpassen
Ein paar Stellschrauben findest du oben im Sketch:
| Einstellung | Bedeutung |
|---|---|
| HOME_LAT / HOME_LON | dein Standort – der Mittelpunkt des Radars |
| RADIUS_NM | Reichweite in nautischen Meilen (25 entspricht rund 46 km) |
| ABFRAGE_INTERVALL | Abstand zwischen zwei Abfragen in Millisekunden |
| MAX_FLUGZEUGE | wie viele Echos höchstens angezeigt werden |
Wohnst du in der Nähe eines großen Flughafens, wird es auf dem Schirm mit 25 nautischen Meilen schnell voll – dann verkleinere den Radius. Lebst du eher ländlich, darfst du ihn ruhig auf 40 oder 50 erhöhen.
Wenn etwas nicht klappt
Der Upload bricht ab
Meldet die Arduino IDE mitten im Hochladen A fatal error occurred: The chip stopped responding, ist die Übertragungsgeschwindigkeit zu hoch. Stelle unter Werkzeuge > Upload Speed auf 115200 um.
Erscheint der Anschluss gar nicht erst in der Portliste, liegt es fast immer am Kabel: Viele USB-Kabel übertragen nur Strom und keine Daten. Das Display leuchtet dann zwar, aber der Computer sieht das Board nicht. Probiere ein anderes Kabel – und wenn dein Board eine USB-C-Buchse hat, verwende ein Kabel von USB-A auf USB-C.
Das Bild stimmt nicht
Bleibt der Bildschirm schwarz, sind die Farben vertauscht oder ist der Hintergrund hell? Dann passt eine Einstellung in der User_Setup.h nicht – siehe oben. Am schnellsten findest du das mit einem Testbild heraus, das drei beschriftete Farbbalken zeigt:
/*
Display-Test fuers Cheap Yellow Display
---------------------------------------
Zeigt ein Testbild, mit dem sich die richtige TFT_eSPI-Konfiguration
pruefen laesst. Erwartet wird von oben nach unten:
1. Ein weisser Rahmen genau am Bildschirmrand
2. Farbbalken in der Reihenfolge ROT - GRUEN - BLAU (beschriftet)
3. Weisser Text auf schwarzem Grund
Stimmen die Farben nicht mit der Beschriftung ueberein, ist in der
User_Setup.h TFT_RGB_ORDER falsch. Ist der Hintergrund hell statt
schwarz, muss TFT_INVERSION umgestellt werden. Bleibt der Schirm
ganz dunkel, ist der Displaytreiber falsch (ILI9341 vs. ST7789).
Ein Projekt von Pollux Labs – polluxlabs.net
*/
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("\nDisplay-Test gestartet");
// Hintergrundbeleuchtung sicherheitshalber selbst einschalten
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, HIGH);
tft.init();
tft.setRotation(0); // Hochformat, 240 x 320
tft.fillScreen(TFT_BLACK);
// 1. Rahmen – zeigt, ob die Aufloesung stimmt
tft.drawRect(0, 0, tft.width(), tft.height(), TFT_WHITE);
// 2. Farbbalken mit Beschriftung
const uint16_t farben[] = {TFT_RED, TFT_GREEN, TFT_BLUE};
const char* namen[] = {"ROT", "GRUEN", "BLAU"};
for (int i = 0; i < 3; i++) {
tft.fillRect(10, 20 + i * 60, 220, 50, farben[i]);
tft.setTextColor(TFT_BLACK, farben[i]);
tft.drawCentreString(namen[i], 120, 35 + i * 60, 4);
}
// 3. Text auf schwarzem Grund
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawCentreString("Pollux Labs", 120, 215, 4);
tft.drawCentreString("Flugradar", 120, 245, 2);
tft.drawCentreString(String(tft.width()) + " x " + String(tft.height()), 120, 275, 2);
Serial.printf("Aufloesung laut Bibliothek: %d x %d\n", tft.width(), tft.height());
}
void loop() {
// Kleiner Lebenszeiger, damit man sieht, dass der ESP32 laeuft
static bool an = false;
an = !an;
tft.fillCircle(220, 300, 6, an ? TFT_YELLOW : TFT_BLACK);
delay(500);
}
Der Touchscreen reagiert nicht
Dann fehlt mit hoher Wahrscheinlichkeit die Zeile USE_HSPI_PORT in deiner User_Setup.h. Prüfe außerdem, ob du die Datei wirklich im Bibliotheksordner geändert hast und nicht eine Kopie woanders.
Reagiert der Touchscreen zwar, greift aber daneben, hilft die Kalibrierung: Der Sketch gibt bei jeder Berührung die Rohwerte im Seriellen Monitor aus. Tippe einmal in jede Bildschirmecke, notiere die kleinsten und größten Werte und trage sie oben bei TOUCH_X_MIN bis TOUCH_Y_MAX ein.
Es werden keine Flugzeuge angezeigt
Direkt nach dem Start ist das kurz normal – der Sketch zeigt dann unten Suche Flugzeuge… an. Bleibt es dabei und erscheint Keine Verbindung zur API, prüfe deine WLAN-Zugangsdaten. Steht dort dagegen ehrlich 0 Flugzeuge im Umkreis, ist gerade tatsächlich nichts unterwegs – nachts über dünn besiedelten Gegenden kommt das durchaus vor.
Wie geht es weiter?
Dein Flugradar läuft – und lässt sich noch in viele Richtungen erweitern. Ein paar Ideen zum Weiterbasteln:
- eine Flugspur zeichnen, also die letzten Positionen jedes Flugzeugs als verblassende Punkte
- einen Alarm ausgeben, sobald ein besonders seltener Flugzeugtyp in der Nähe ist
- die Höhe farblich darstellen, damit du Steig- und Sinkflüge auf einen Blick erkennst
- das Ganze in ein 3D-gedrucktes Gehäuse setzen und als Schreibtisch-Gadget aufstellen
Und wenn du das nächste Mal ein Flugzeug am Himmel siehst, weißt du in ein paar Sekunden, wer da gerade unterwegs ist – und wohin.