Add flight route lookup, helicopter marker, and QR code for WebUI

Detail panel now shows origin/destination via adsbdb.com callsign
lookup. Rotorcraft (ADS-B category A7) get a distinct rotor-cross
marker instead of a heading arrowhead, same rationale as the existing
ground-vehicle marker. WebUI info screen gets a QR button (when WiFi
is connected) that renders the local WebUI URL as a scannable code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-12 16:52:22 +02:00
parent 467d30d8cd
commit 32f8e873a9
14 changed files with 186 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ namespace {
SemaphoreHandle_t mutex = nullptr;
char pendingHex[7] = {0};
char pendingCallsign[9] = {0};
bool hasPending = false;
char cachedHex[7] = {0};
@@ -33,11 +34,12 @@ namespace {
}
}
void request(const char* hex) {
void request(const char* hex, const char* callsign) {
ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY);
if (strcmp(cachedHex, hex) != 0 && strcmp(pendingHex, hex) != 0) {
strncpy(pendingHex, hex, sizeof(pendingHex) - 1);
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
hasPending = true;
}
xSemaphoreGive(mutex);
@@ -60,11 +62,13 @@ void update() {
ensureMutex();
char hex[7] = {0};
char callsign[9] = {0};
bool doWork = false;
xSemaphoreTake(mutex, portMAX_DELAY);
if (hasPending) {
strncpy(hex, pendingHex, sizeof(hex) - 1);
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1);
doWork = true;
}
xSemaphoreGive(mutex);
@@ -116,6 +120,28 @@ void update() {
}
}
// Flugroute (Start-/Zielflughafen) - derselbe adsbdb.com-Dienst wie oben,
// aber ueber den Callsign-Endpunkt statt Hex-Code abgefragt (liefert
// dafuer origin/destination-Flughafendaten zurueck). Nur versuchen, wenn
// ueberhaupt ein Rufzeichen bekannt ist - Sichtflug-Maschinen ohne
// Callsign haben ohnehin keine darueber auswertbare Route.
String trimmedCallsign = String(callsign);
trimmedCallsign.trim();
if (trimmedCallsign.length() > 0) {
client.setTimeout(4000);
String body3;
if (httpGetString(client, String("https://api.adsbdb.com/v0/callsign/") + trimmedCallsign, body3)) {
JsonDocument doc3;
DeserializationError err3 = deserializeJson(doc3, body3);
if (!err3) {
const char* originIcao = doc3["response"]["flightroute"]["origin"]["icao_code"] | "";
const char* destIcao = doc3["response"]["flightroute"]["destination"]["icao_code"] | "";
strncpy(result.routeOrigin, originIcao, sizeof(result.routeOrigin) - 1);
strncpy(result.routeDest, destIcao, sizeof(result.routeDest) - 1);
}
}
}
xSemaphoreTake(mutex, portMAX_DELAY);
strncpy(cachedHex, hex, sizeof(cachedHex) - 1);
cached = result;
@@ -123,4 +149,4 @@ void update() {
xSemaphoreGive(mutex);
}
}
}

View File

@@ -10,11 +10,19 @@ namespace AircraftDetails {
struct Info {
bool loading = false;
char model[40] = {0}; // e.g. "Airbus A320 216", empty if unknown
// Departure/destination airport (ICAO code, e.g. "KIAH"/"EDDF") of
// the current flight route, looked up by callsign via the same
// adsbdb.com service used for the model fallback above - empty if
// no callsign is known or no route was found for it.
char routeOrigin[8] = {0};
char routeDest[8] = {0};
};
// Called from Core 1 (touch selection): marks that details should be
// fetched for this aircraft (if not already done).
void request(const char* hex);
// fetched for this aircraft (if not already done). callsign may be
// empty (no route lookup is attempted in that case).
void request(const char* hex, const char* callsign);
// Called from Core 1 to get the current (possibly still incomplete)
// state for 'hex'.
@@ -24,4 +32,4 @@ namespace AircraftDetails {
// (blocking HTTPS call, but that's fine - runs in the background and
// only briefly delays the next ADS-B poll).
void update();
}
}

View File

@@ -214,7 +214,7 @@ bool run(TFT_eSPI& tft) {
} else {
for (uint8_t i = 0; i < visibleRowCount; i++) {
if (rowRects[i].contains(tap.x, tap.y)) {
RadarScreen::selectAircraft(snapshot[scrollTop + i].hex);
RadarScreen::selectAircraft(snapshot[scrollTop + i].hex, snapshot[scrollTop + i].callsign);
selected = true;
break;
}

View File

@@ -303,6 +303,19 @@ enum class StringId : uint8_t {
// instead of waiting it out.
FIRST_RUN_COMPLETE_TAP_TO_SKIP,
// Zeilenpraefix im Radar-Detailpanel (radar_screen.cpp::drawDetailPanel)
// fuer die Flugroute (Start- -> Zielflughafen, ICAO-Code), abgefragt
// ueber AircraftDetails per Rufzeichen. Faellt wie MODEL/TYPE/SQUAWK auf
// DETAIL_UNKNOWN zurueck, wenn kein Rufzeichen bekannt ist oder keine
// Route gefunden wurde.
DETAIL_ROUTE,
// Hinweiszeile ueber dem QR-Code auf dem neuen QR-Unterscreen der
// Logbuch/WebUI-Seite (webui_screen.cpp::runQrScreen) - der WEBUI_TITLE-
// String wird fuer die Kopfzeile wiederverwendet, kein eigener Titel
// noetig.
WEBUI_QR_HINT,
COUNT
};

View File

@@ -238,6 +238,8 @@ static const char* const I18N_DE[] = {
"Lösche alle Daten...",
"Zurücksetzen fehlgeschlagen.",
"Tippen zum Überspringen",
"Route: ",
"Mit dem Handy scannen:",
};
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -239,6 +239,8 @@ static const char* const I18N_EN[] = {
"Deleting all data...",
"Reset failed.",
"Tap to skip",
"Route: ",
"Scan with your phone:",
};
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -238,6 +238,8 @@ static const char* const I18N_ES[] = {
"Eliminando todos los datos...",
"Error al reiniciar.",
"Toca para saltar",
"Ruta: ",
"Escanea con el móvil:",
};
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -238,6 +238,8 @@ static const char* const I18N_FR[] = {
"Suppression de toutes les données...",
"Échec de la réinitialisation.",
"Toucher pour passer",
"Trajet : ",
"Scanner avec le téléphone :",
};
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -238,6 +238,8 @@ static const char* const I18N_IT[] = {
"Eliminazione di tutti i dati...",
"Reimpostazione fallita.",
"Tocca per saltare",
"Rotta: ",
"Scansiona con il telefono:",
};
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -238,6 +238,8 @@ static const char* const I18N_TR[] = {
"Tüm veriler siliniyor...",
"Sıfırlama başarısız.",
"Atlamak için dokun",
"Rota: ",
"Telefonla tarayın:",
};
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -57,7 +57,12 @@ namespace {
return L;
}
constexpr int16_t DETAIL_PANEL_H = 264;
// +22px gegenueber frueher (264) fuer die neue Route-Zeile (siehe
// drawDetailPanel()) - beeinflusst NUR die Groesse des Detail-Panel-
// Overlays, nicht das normale Radar-Layout (computeLayout() oben
// richtet sich ausschliesslich nach infoBarHeight(), nicht nach
// DETAIL_PANEL_H).
constexpr int16_t DETAIL_PANEL_H = 286;
struct HitPoint {
int16_t x, y;
@@ -70,6 +75,7 @@ namespace {
bool isEmergency;
bool isWatched;
bool isGroundVehicle;
bool isRotorcraft;
};
constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT;
HitPoint hitPoints[MAX_HIT_POINTS];
@@ -333,6 +339,19 @@ namespace {
gfx.fillRect((int16_t)(x - HALF), (int16_t)(y - HALF), (int16_t)(2 * HALF), (int16_t)(2 * HALF), color);
}
// Eigener Marker fuer Hubschrauber (ADS-B-Emitter-Kategorie "A7" =
// Rotorcraft) - ein gefuellter Kreis mit durchgehendem Rotorkreuz statt
// Pfeilkopf, da Hubschrauber im Schwebeflug keinen aussagekraeftigen
// "nach vorne"-Kurs wie ein Flugzeug haben (gleiche Ueberlegung wie beim
// Bodenfahrzeug-Marker, der aus demselben Grund ebenfalls ohne
// Kurslinie auskommt).
void drawHelicopterMarker(TFT_eSPI& gfx, int16_t x, int16_t y, uint16_t color) {
constexpr int16_t ROTOR_LEN = 8;
gfx.fillCircle(x, y, 4, color);
gfx.drawLine((int16_t)(x - ROTOR_LEN), y, (int16_t)(x + ROTOR_LEN), y, color);
gfx.drawLine(x, (int16_t)(y - ROTOR_LEN), x, (int16_t)(y + ROTOR_LEN), color);
}
void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) {
String s = text;
if (gfx.textWidth(s) > maxWidth) {
@@ -507,7 +526,7 @@ namespace {
bool valid = false;
char hex[7] = {0};
String callsignText;
LineMarquee airline, model, type, alt, speed, climb, distHeading, squawk, seats;
LineMarquee airline, model, type, route, alt, speed, climb, distHeading, squawk, seats;
};
PanelState lastPanel;
@@ -580,6 +599,7 @@ namespace {
advanceAndDrawMarqueeLine(gfx, lastPanel.airline);
advanceAndDrawMarqueeLine(gfx, lastPanel.model);
advanceAndDrawMarqueeLine(gfx, lastPanel.type);
advanceAndDrawMarqueeLine(gfx, lastPanel.route);
advanceAndDrawMarqueeLine(gfx, lastPanel.alt);
advanceAndDrawMarqueeLine(gfx, lastPanel.speed);
advanceAndDrawMarqueeLine(gfx, lastPanel.climb);
@@ -633,6 +653,24 @@ namespace {
updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.type, typeLine, forceFull);
y += LINE_H;
// Start-/Zielflughafen (ICAO-Code) ueber AircraftDetails::get() -
// wird zusammen mit dem Modell abgefragt (siehe aircraft_details.cpp),
// aber ueber das Rufzeichen statt den Hex-Code aufgeloest. Ohne
// Rufzeichen (z.B. manche Sichtflug-Maschinen) bleibt die Route
// grundsaetzlich unbekannt, kein Abruf noetig.
String routeLine;
if (!a.callsign[0]) {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN);
} else if (details.loading) {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_LOADING_DOTS);
} else if (details.routeOrigin[0] && details.routeDest[0]) {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + details.routeOrigin + " -> " + details.routeDest;
} else {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN);
}
updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.route, routeLine, forceFull);
y += LINE_H;
char buf[48];
snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT),
Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt);
@@ -860,6 +898,7 @@ void render(TFT_eSPI& tft, int16_t top) {
// oben) - eigene Farbe/Marker statt der hoehenbasierten Flugzeug-
// Darstellung, siehe drawGroundVehicleMarker()/colorForGroundVehicle().
bool isGroundVehicle = a.category[0] == 'C';
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
uint16_t color = isGroundVehicle ? colorForGroundVehicle(tft) : colorForAltitude(tft, a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk);
@@ -873,6 +912,8 @@ void render(TFT_eSPI& tft, int16_t top) {
}
if (isGroundVehicle) {
drawGroundVehicleMarker(tft, pt.x, pt.y, color);
} else if (isRotorcraft) {
drawHelicopterMarker(tft, pt.x, pt.y, color);
} else {
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
}
@@ -898,6 +939,7 @@ void render(TFT_eSPI& tft, int16_t top) {
hitPoints[i].isEmergency = isEmergency;
hitPoints[i].isWatched = isWatched;
hitPoints[i].isGroundVehicle = isGroundVehicle;
hitPoints[i].isRotorcraft = isRotorcraft;
strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1);
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
}
@@ -1007,6 +1049,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
// beruehren.
if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK);
} else if (hp.isRotorcraft) {
drawHelicopterMarker(tft, hp.x, hp.y, TFT_BLACK);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK);
}
@@ -1023,6 +1067,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color);
} else if (hp.isRotorcraft) {
drawHelicopterMarker(tft, hp.x, hp.y, hp.color);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color);
}
@@ -1108,7 +1154,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
int16_t dy = y - hitPoints[i].y;
if (dx * dx + dy * dy <= 12 * 12) {
strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hitPoints[i].hex);
AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign);
return true;
}
}
@@ -1129,7 +1175,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
int16_t dy = y - hitPoints[i].y;
if (dx * dx + dy * dy <= 12 * 12) {
strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hitPoints[i].hex);
AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign);
return true;
}
}
@@ -1152,9 +1198,9 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
return true;
}
void selectAircraft(const char* hex) {
void selectAircraft(const char* hex, const char* callsign) {
strncpy(selectedHex, hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hex);
AircraftDetails::request(hex, callsign);
// Erzwingt einen kompletten Neuaufbau des Detail-Panels beim naechsten
// render() - wichtig, falls der Bildschirm zwischenzeitlich von einem
// anderen Screen (z.B. der Flugzeugliste) ueberschrieben wurde, sonst

View File

@@ -12,7 +12,7 @@ namespace RadarScreen {
// aus, nicht per Antippen auf dem Radar) - damit beim naechsten render()
// sofort das Detail-Panel fuer dieses Flugzeug erscheint, so als haette
// man es direkt im Radar angetippt.
void selectAircraft(const char* hex);
void selectAircraft(const char* hex, const char* callsign);
struct EmergencyInfo {
bool active = false;

View File

@@ -4,6 +4,7 @@
#include "config.h"
#include "i18n.h"
#include <WiFi.h>
#include <qrcode.h>
namespace WebUiScreen {
@@ -53,6 +54,63 @@ namespace {
}
return y;
}
// QR-Code-Unterscreen: zeigt die WebUI-URL als QR-Code, damit man sie
// nicht mehr von Hand ins Handy tippen muss. Nutzt die ricmoo/QRCode-
// Bibliothek (kein dynamisches Allozieren, Version fest auf 4/33x33
// Module mit niedrigster Fehlerkorrektur, reicht locker fuer eine kurze
// "http://192.168.x.x/"-URL).
void runQrScreen(TFT_eSPI& tft, const String& url) {
QRCode qrcode;
uint8_t qrData[qrcode_getBufferSize(4)];
qrcode_initText(&qrcode, qrData, 4, ECC_LOW, url.c_str());
constexpr int16_t MODULE_PX = 6;
constexpr int16_t QUIET_ZONE = 4;
int16_t qrPx = qrcode.size * MODULE_PX;
int16_t boxPx = qrPx + 2 * QUIET_ZONE * MODULE_PX;
int16_t boxX = (Config::SCREEN_WIDTH - boxPx) / 2;
int16_t boxY = 30;
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::WEBUI_TITLE));
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(I18n::t(StringId::WEBUI_QR_HINT), Config::SCREEN_WIDTH / 2, boxY - 6);
tft.setTextDatum(TL_DATUM);
tft.fillRect(boxX, boxY + 10, boxPx, boxPx, TFT_WHITE);
for (uint8_t y = 0; y < qrcode.size; y++) {
for (uint8_t x = 0; x < qrcode.size; x++) {
if (qrcode_getModule(&qrcode, x, y)) {
int16_t px = boxX + QUIET_ZONE * MODULE_PX + x * MODULE_PX;
int16_t py = boxY + 10 + QUIET_ZONE * MODULE_PX + y * MODULE_PX;
tft.fillRect(px, py, MODULE_PX, MODULE_PX, TFT_BLACK);
}
}
}
int16_t urlY = boxY + 10 + boxPx + 16;
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawString(url, Config::SCREEN_WIDTH / 2, urlY);
tft.setTextDatum(TL_DATUM);
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
drawButton(tft, backBtn, I18n::t(StringId::BACK));
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (backBtn.contains(tap.x, tap.y)) return;
}
MenuStars::update(tft);
delay(20);
}
}
}
void run(TFT_eSPI& tft) {
@@ -90,6 +148,8 @@ void run(TFT_eSPI& tft) {
Rect downBtn = {190, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40};
constexpr int16_t SCROLL_STEP = 48;
Rect qrBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24};
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
@@ -117,6 +177,9 @@ void run(TFT_eSPI& tft) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
if (wifiConnected) {
drawButton(tft, qrBtn, "QR");
}
};
redraw();
@@ -125,7 +188,10 @@ void run(TFT_eSPI& tft) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (backBtn.contains(tap.x, tap.y)) return;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
if (wifiConnected && qrBtn.contains(tap.x, tap.y)) {
runQrScreen(tft, urlLine);
redraw();
} else if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();