localization

This commit is contained in:
2026-08-16 14:33:27 +02:00
parent 9c634b819e
commit fb09b2b7a1
65 changed files with 1621 additions and 1679 deletions

View File

@@ -32,9 +32,9 @@ void run(TFT_eSPI& tft) {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
// Projektname bewusst hart codiert statt ueber i18n - genau wie
// schon im Splash-Screen (splash_screen.cpp), da ein Eigenname
// ohnehin nicht uebersetzt wird.
// Project name intentionally hardcoded instead of via i18n - just like
// in the splash screen (splash_screen.cpp), since a proper noun
// is not translated anyway.
tft.setCursor(10, 14);
tft.println("Leo's Flightradar");

View File

@@ -2,8 +2,8 @@
#include <Arduino.h>
#include <TFT_eSPI.h>
// Einfacher Info-Screen: Projektname, Kurzbeschreibung, Copyright-Jahr und
// die aktuelle Versionsnummer (Config::APP_VERSION).
// Simple info screen: project name, short description, copyright year and
// the current version number (Config::APP_VERSION).
namespace AboutScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -34,16 +34,12 @@ namespace {
tft.setTextDatum(TL_DATUM);
}
// Lokale Kopie (siehe Konvention in location_presets_screen.cpp - jeder
// Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen
// Moduls). Ohne Scroll-Unterstuetzung - hier immer nur kurze,
// vorab abgeschnittene Texte (siehe runConfirmScreen/showErrorRetry).
// Lokale Kopie (siehe Konvention in location_presets_screen.cpp - jeder
// Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen
// Moduls). Optionale Scroll-Unterstuetzung (scrollY/viewTop/viewBottom/
// draw) fuer runConfirmScreen() unten - bei den bestehenden Aufrufstellen
// (showErrorRetry, Kopfzeilen) bleiben die Defaults aktiv, es wird also
// wie bisher immer alles auf einmal gezeichnet.
// Local copy (see convention in location_presets_screen.cpp - each
// screen keeps its own small helpers instead of a shared
// module). Optional scroll support (scrollY/viewTop/viewBottom/
// draw) for runConfirmScreen() below - for the existing call sites
// (showErrorRetry, headers) the defaults remain active, so everything
// is still drawn at once as before.
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text,
int16_t scrollY = 0, int16_t viewTop = -32000, int16_t viewBottom = 32000,
@@ -73,8 +69,8 @@ namespace {
return y;
}
// ---- UTF-8-bewusste Puffer-Helfer (fuer die Sonderzeichen-Tasten wie
// "Ä", die als 2-Byte-UTF-8-Sequenz eingefuegt werden muessen) ----
// ---- UTF-8-aware buffer helpers (for special character keys like
// "Ä", which must be inserted as 2-byte UTF-8 sequences) ----
void appendUtf8(char* buf, uint8_t& len, uint8_t cap, const char* s) {
size_t sl = strlen(s);
if (len + sl >= cap) return;
@@ -83,9 +79,8 @@ namespace {
buf[len] = 0;
}
// Entfernt beim Loeschen eine ganze UTF-8-Zeichensequenz (nicht nur ein
// einzelnes Byte) - sonst bliebe bei Sonderzeichen ein kaputtes
// halbes Byte im Puffer stehen.
// When deleting, removes a whole UTF-8 character sequence (not just a
// single byte) - otherwise a broken half-byte would remain in the buffer.
void backspaceUtf8(char* buf, uint8_t& len) {
if (len == 0) return;
len--;
@@ -93,9 +88,9 @@ namespace {
buf[len] = 0;
}
// Zeigt nur so viel vom ENDE des Puffers, wie in maxWidth passt - der
// Nutzer tippt am Ende weiter, das sichtbare Fenster soll dem folgen
// (wie bei einem gewoehnlichen einzeiligen Texteingabefeld).
// Shows only as much of the END of the buffer as fits in maxWidth - the
// user types at the end, the visible window should follow
// (like a normal single-line text input field).
String visibleTail(TFT_eSPI& tft, const char* buf, int16_t maxWidth) {
String s(buf);
while (s.length() > 0 && tft.textWidth(s) > maxWidth) {
@@ -106,26 +101,26 @@ namespace {
return s;
}
// ---- Tastatur-Layout ----
// ---- Keyboard Layout ----
constexpr const char* DIGITS = "1234567890";
constexpr const char* ROW1 = "QWERTYUIOP";
constexpr const char* ROW2 = "ASDFGHJKL";
// Um Komma und Bindestrich erweitert (7->9 Tasten) - Adressen wie eine
// Hausnummer "45/3" oder "Strasse 12, Ort" waren sonst gar nicht
// eintippbar, da die Tastatur bisher keinerlei Satzzeichen enthielt.
// Extended with comma and hyphen (7->9 keys) - addresses like
// a house number "45/3" or "Street 12, City" were not
// typeable at all since the keyboard previously contained no punctuation.
constexpr const char* ROW3 = "ZXCVBNM,-";
// Sonderzeichen-Seite: gegenueber der ersten Version wurden die seltenen
// Â/Ë/Î/Û gegen die fuer Adressen wichtigen Satzzeichen "/", ".", "'", "-"
// getauscht (Hausnummern wie "45/3", Abkuerzungen wie "Str.", Apostroph-
// Namen).
// Special characters page: compared to the first version, the rare
// Â/Ë/Î/Û were swapped for the punctuation important for addresses
// "/", ".", "'", "-" (house numbers like "45/3", abbreviations like "Str.", apostrophe
// names).
constexpr const char* SPEC0[6] = {"À", "Á", "Ä", "Ç", "É", "È"};
constexpr const char* SPEC1[6] = {"Ê", "Í", "Ñ", "Ó", "Ò", "Ô"};
constexpr const char* SPEC2[6] = {"Ö", "Ù", "Ú", "Ü", "ß", "Ğ"};
constexpr const char* SPEC3[6] = {"İ", "Ş", "/", ".", "'", "-"};
// Gibt die eingegebene Adresse zurueck, oder einen leeren String, wenn
// der Nutzer abgebrochen hat.
// Returns the entered address, or an empty string if
// the user cancelled.
String runAddressKeyboard(TFT_eSPI& tft) {
MenuStars::reset();
constexpr uint8_t CAP = 64;
@@ -137,12 +132,12 @@ namespace {
constexpr int16_t KEY_GAP = 3;
constexpr int16_t FIELD_H = 34;
// Kopfbereich (Titel + Format-Hinweis) einmal zeichnen, um seine
// tatsaechliche Hoehe per getCursorY() zu messen (variiert je nach
// Sprache/Uebersetzungslaenge) - Eingabefeld und Tastatur-Start
// werden daraus abgeleitet statt wie zuvor eine feste Pixelposition
// (78) anzunehmen. redraw() unten zeichnet denselben Kopf bei jedem
// Aufruf identisch neu.
// Head area (title + format hint) draw once to measure its
// actual height via getCursorY() (varies by
// language/translation length) - input field and keyboard start
// are derived from it instead of assuming a fixed pixel position
// (78) as before. redraw() below draws the same header identically on every
// call.
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
@@ -293,10 +288,10 @@ namespace {
return searched ? String(buf) : String();
}
// Namens-Tastatur fuer den finalen Schritt (Preset-Name vergeben) -
// absichtlich eine eigene, einfache ASCII-Tastatur (keine
// Sonderzeichen noetig fuer einen Preset-Namen wie "Zuhause"),
// spiegelt runPresetNameKeypad() aus location_presets_screen.cpp.
// Name keypad for the final step (naming the preset) -
// intentionally a separate, simple ASCII keyboard (no
// special characters needed for a preset name like "Home"),
// mirrors runPresetNameKeypad() from location_presets_screen.cpp.
String runNameKeypad(TFT_eSPI& tft) {
MenuStars::reset();
constexpr const char* NDIGITS = "1234567890";
@@ -311,9 +306,9 @@ namespace {
constexpr int16_t KEY_GAP = 3;
constexpr int16_t FIELD_H = 34;
// Kopfbereich (Titel + Namens-Hinweis) einmal zeichnen, um seine
// tatsaechliche Hoehe per getCursorY() zu messen - selbes Muster wie
// in runAddressKeyboard() oben.
// Head area (title + name hint) draw once to measure its
// actual height via getCursorY() - same pattern as
// in runAddressKeyboard() above.
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
@@ -406,9 +401,9 @@ namespace {
return String(buf);
}
// Prozentkodiert einen UTF-8-String fuer die Nominatim-Query (jedes
// Roh-Byte einzeln - funktioniert damit auch fuer die mehrbytigen
// Sonderzeichen).
// Percent-encodes a UTF-8 string for the Nominatim query (each
// raw byte individually - works for multi-byte
// special characters too).
String urlEncode(const String& s) {
String out;
char hex[4];
@@ -426,18 +421,18 @@ namespace {
return out;
}
// ISO-639-1-Codes in derselben Reihenfolge wie I18n::TABLES (siehe
// i18n.cpp) - fuer staerker lokalisierte display_name-Ergebnisse
// (Nominatim accept-language-Parameter).
// ISO-639-1 codes in the same order as I18n::TABLES (see
// i18n.cpp) - for more localized display_name results
// (Nominatim accept-language parameter).
constexpr const char* NOMINATIM_LANG_CODES[6] = {"en", "de", "fr", "tr", "es", "it"};
enum class GeocodeResult { Ok, NoResults, NetworkError };
// Kostenloser, anmeldefreier Geokodierungs-Dienst (OpenStreetMap
// Nominatim, siehe Config::NOMINATIM_HOST). Blockierender Aufruf,
// ausgeloest durch Nutzer-Interaktion (Tap auf "Suchen") - laeuft im
// Vordergrund auf Core 1, exakt wie der bestehende Flugzeug-Detail-
// Abruf in aircraft_details.cpp.
// Free, no-registration geocoding service (OpenStreetMap
// Nominatim, see Config::NOMINATIM_HOST). Blocking call,
// triggered by user interaction (tap on "Search") - runs in the
// foreground on Core 1, exactly like the existing aircraft detail
// fetch in aircraft_details.cpp.
GeocodeResult geocode(const String& query, double& lat, double& lon, String& displayName) {
if (WiFi.status() != WL_CONNECTED) return GeocodeResult::NetworkError;
@@ -461,9 +456,9 @@ namespace {
return GeocodeResult::NetworkError;
}
// Body erst komplett einsammeln statt direkt aus http.getStream()
// zu parsen - siehe weather.cpp, gleicher Grund (Chunked-Transfer-
// Encoding fuehrte dort sonst zu ArduinoJson-"InvalidInput").
// Collect entire body first instead of parsing directly from http.getStream()
// - see weather.cpp, same reason (chunked transfer
// encoding caused ArduinoJson "InvalidInput" there otherwise).
String body = http.getString();
http.end();
@@ -486,8 +481,8 @@ namespace {
return GeocodeResult::Ok;
}
// Fehler-/Kein-Ergebnis-Screen mit "Erneut versuchen"/"Abbrechen".
// Gibt true zurueck, wenn der Nutzer es erneut versuchen will.
// Error/no-result screen with "Try again"/"Cancel".
// Returns true if the user wants to try again.
bool showErrorRetry(TFT_eSPI& tft, StringId msgId) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
@@ -507,9 +502,9 @@ namespace {
}
}
// Bestaetigungs-Screen mit dem von Nominatim gefundenen Ort. Gibt 1
// zurueck ("verwenden"), 0 fuer "erneut versuchen" (zurueck zur
// Adress-Tastatur).
// Confirmation screen with the location found by Nominatim. Returns 1
// ("use it"), 0 for "try again" (back to
// address keyboard).
int runConfirmScreen(TFT_eSPI& tft, const String& displayName) {
MenuStars::reset();
@@ -520,14 +515,14 @@ namespace {
constexpr int16_t USE_Y = (int16_t)(Config::SCREEN_HEIGHT - 50);
constexpr int16_t VIEW_BOTTOM = (int16_t)(TRY_AGAIN_Y - 10);
// Volle Adresse OHNE Kuerzung anzeigen (frueher wurde bei > 150
// Zeichen mit "..." abgeschnitten) - stattdessen wird der Absatz bei
// Bedarf vertikal scrollbar, exakt dasselbe Muster wie beim "Wie
// funktionieren Presets"-Infoscreen (location_presets_screen.cpp
// ::runInfoScreen). Bewusst KEIN Marquee hier: das ist ein
// mehrzeiliger, wortumgebrochener Absatz, keine einzelne Zeile, die
// in der Breite nicht passt - horizontales Scrollen waere hier die
// falsche Loesung.
// Show full address WITHOUT truncation (previously cut at > 150
// characters with "...") - instead, the paragraph becomes
// vertically scrollable if needed, exactly the same pattern as the "How
// do presets work" info screen (location_presets_screen.cpp
// ::runInfoScreen). Deliberately NOT a marquee here: this is a
// multi-line, word-wrapped paragraph, not a single line that
// does not fit in width - horizontal scrolling would be the
// wrong solution here.
int16_t totalH = layoutWrapped(tft, 10, VIEW_TOP, textMaxWidth, LINE_H, displayName, 0, 0, 0, false);
int16_t maxScroll = (int16_t)(totalH - VIEW_BOTTOM);
if (maxScroll < 0) maxScroll = 0;
@@ -611,9 +606,9 @@ bool run(TFT_eSPI& tft) {
String name = runNameKeypad(tft);
if (!LocationPresets::addPreset(lat, lon, name)) return false;
// Neu angelegtes Preset sofort aktivieren - sonst blieb z.B. der
// automatische IP-Standort aktiv, obwohl gerade extra eine genauere
// Adresse eingegeben wurde (siehe Alex' Feedback).
// Newly created preset is immediately activated - otherwise e.g. the
// automatic IP location would remain active, even though a more accurate
// address was just entered (see Alex' feedback).
LocationPresets::setActiveIndex((int8_t)(LocationPresets::count() - 1));
return true;
}

View File

@@ -80,9 +80,9 @@ void update() {
WiFiClientSecure client;
client.setInsecure();
// hexdb.io zuerst versuchen (bisherige Quelle) - aber mit kuerzerem
// Timeout (3s statt 5s), damit ein kompletter Ausfall des Dienstes die
// ADS-B-Abfrage nicht unnoetig lange blockiert, bevor der Fallback greift.
// Try hexdb.io first (previous source) - but with a shorter
// timeout (3s instead of 5s) so a complete service outage does not
// block the ADS-B query unnecessarily long before the fallback kicks in.
client.setTimeout(3000);
String body;
if (httpGetString(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) {
@@ -99,9 +99,9 @@ void update() {
}
}
// Fallback: hexdb.io war nicht erreichbar/lieferte kein Modell -
// adsbdb.com als zweite, unabhaengige Quelle versuchen (andere API-Form,
// aber inhaltlich aequivalent: Hersteller + Typ ueber den Hex-Code).
// Fallback: hexdb.io was unreachable/did not deliver a model -
// try adsbdb.com as a second, independent source (different API format,
// but equivalent in content: manufacturer + type via the hex code).
if (!result.model[0]) {
client.setTimeout(4000);
String body2;
@@ -120,26 +120,25 @@ void update() {
}
}
// Flugroute (Start-/Zielflughafen) - jetzt ueber eine Kette aus DREI
// unabhaengigen kostenlosen Quellen statt nur einer, absteigend nach in
// Tests beobachteter Trefferquote sortiert. Vorher lieferte adsbdb.com
// allein in ca. 80% der Faelle "unknown" (siehe Alex' Feedback) - die
// drei Quellen speisen sich aus unterschiedlichen, ueberlappenden aber
// nicht identischen Community-Datenbanken, daher deutlich bessere
// Gesamtabdeckung durch Verketten:
// 1. VRS-Standing-Data-Mirror (adsb.lol) - stuendlich aktualisierter
// Spiegel des Virtual-Radar-Server-Projekts, in Tests die mit
// Abstand zuverlaessigste Quelle. Pfad = erste 2 Zeichen des
// (GROSSGESCHRIEBENEN - der Dienst ist case-sensitiv) Rufzeichens
// als Ordner, liefert "airport_codes":"ORIG-DEST" (ICAO).
// 2. hexdb.io - eigener Route-Endpunkt (andere URL als der
// Aircraft-Endpunkt weiter oben), liefert "route":"ORIG-DEST".
// 3. adsbdb.com Callsign-Endpunkt - bisherige einzige Quelle, bleibt
// als letzter Fallback, da sie gelegentlich Daten hat, die die
// anderen beiden nicht haben.
// Nur versuchen, wenn ueberhaupt ein Rufzeichen bekannt ist -
// Sichtflug-Maschinen ohne Callsign haben ohnehin keine darueber
// auswertbare Route.
// Flight route (origin/destination airport) - now via a chain of THREE
// independent free sources instead of just one, sorted descending by
// observed hit rate in testing. Previously adsbdb.com alone returned
// "unknown" in about 80% of cases (see Alex' feedback) - the
// three sources draw from different, overlapping but
// not identical community databases, therefore much better
// overall coverage through chaining:
// 1. VRS-Standing-Data-Mirror (adsb.lol) - hourly updated
// mirror of the Virtual Radar Server project, by far the most
// reliable source in testing. Path = first 2 characters of the
// (UPPERCASE - the service is case-sensitive) callsign
// as folder, returns "airport_codes":"ORIG-DEST" (ICAO).
// 2. hexdb.io - own route endpoint (different URL from the
// aircraft endpoint above), returns "route":"ORIG-DEST".
// 3. adsbdb.com callsign endpoint - previous single source, remains
// as last fallback since it occasionally has data that the
// other two do not.
// Only try if a callsign is known at all -
// VFR aircraft without a callsign do not have an evaluable route anyway.
String trimmedCallsign = String(callsign);
trimmedCallsign.trim();
trimmedCallsign.toUpperCase();
@@ -165,7 +164,7 @@ void update() {
}
}
// 2. hexdb.io Route-Endpunkt, falls Quelle 1 nichts geliefert hat.
// 2. hexdb.io route endpoint, if source 1 did not deliver anything.
if (!result.routeOrigin[0] || !result.routeDest[0]) {
client.setTimeout(3000);
String body4;
@@ -179,7 +178,7 @@ void update() {
}
}
// 3. adsbdb.com Callsign-Endpunkt als letzter Fallback.
// 3. adsbdb.com callsign endpoint as last fallback.
if (trimmedCallsign.length() > 0 && (!result.routeOrigin[0] || !result.routeDest[0])) {
client.setTimeout(4000);
String body5;

View File

@@ -33,10 +33,10 @@ namespace {
tft.setTextDatum(TL_DATUM);
}
// Gleiche Hoehen-Farblogik wie RadarScreen (dort intern, hier bewusst
// dupliziert - siehe CLAUDE.md-Konvention "jeder Screen unabhaengig").
// Auf die Nachtdimmung wird hier verzichtet: diese Liste ist ein Menue-
// Screen wie jeder andere, kein Dauerbild wie das Radar.
// Same altitude color logic as RadarScreen (duplicated there internally, here deliberately
// duplicated - see CLAUDE.md convention "each screen independently runnable").
// Night dimming is omitted here: this list is a menu
// screen like any other, not a persistent image like the radar.
uint16_t colorForAltitude(int32_t altFt) {
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT) return TFT_GREEN;
if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT) return TFT_YELLOW;
@@ -52,8 +52,8 @@ namespace {
}
enum class SortMode : uint8_t { Distance, Altitude, Callsign };
// Bleibt bis zum naechsten Neustart erhalten (kein SD-Speichern noetig -
// das waere fuer eine reine Anzeige-Praeferenz unnoetiger Aufwand).
// Persists until the next restart (no SD storage needed -
// that would be unnecessary effort for a pure display preference).
SortMode sortMode = SortMode::Distance;
const char* sortModeLabel() {
@@ -72,7 +72,7 @@ bool run(TFT_eSPI& tft) {
constexpr int16_t LIST_BOTTOM = Config::SCREEN_HEIGHT - 56;
constexpr uint8_t ROWS_VISIBLE = (LIST_BOTTOM - LIST_TOP) / (ROW_H + ROW_GAP);
uint8_t scrollTop = 0; // Index des ersten sichtbaren Eintrags in der sortierten Liste
uint8_t scrollTop = 0; // Index of the first visible entry in the sorted list
bool selected = false;
bool done = false;
MenuStars::reset();
@@ -82,9 +82,9 @@ bool run(TFT_eSPI& tft) {
uint8_t count = 0;
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
// Gleiche Filter wie das Radar (Reichweite, Bodenfahrzeuge,
// Airline-Filter), damit die Liste genau die Flugzeuge zeigt, die
// gerade auch als Punkte auf dem Radar zu sehen sind.
// Same filters as the radar (range, ground vehicles,
// airline filter), so the list shows exactly the aircraft
// that are currently also visible as dots on the radar.
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
@@ -126,9 +126,9 @@ bool run(TFT_eSPI& tft) {
Rect rowRects[ROWS_VISIBLE];
uint8_t visibleRowCount = 0;
// Einheiten-Einstellung (Menue > Einheiten) einmal vor der Schleife
// lesen statt pro Zeile - vorher zeigten Hoehe/Distanz hier immer
// fest ft/km, auch bei Imperial eingestellt.
// Read unit setting (Menu > Units) once before the loop
// instead of per row - previously altitude/distance always showed
// fixed ft/km here, even when imperial was set.
bool listMetric = LocationManager::useMetricUnits();
if (count == 0) {
@@ -147,12 +147,12 @@ bool run(TFT_eSPI& tft) {
uint16_t borderColor = emergency ? TFT_RED : (watched ? TFT_CYAN : TFT_GREEN);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, borderColor);
// WICHTIG (siehe CLAUDE.md-Falle "Font ist Baseline-verankert"):
// Alle drei Textfelder ueber drawString() mit einem MITTIGEN
// Datum auf die Zeilenmitte zentrieren, statt Baseline- und
// Top-verankerte Aufrufe zu mischen - genau das hatte vorher
// dazu gefuehrt, dass Hoehe/Distanz unten aus der Box liefen,
// waehrend das Rufzeichen (Baseline-verankert) korrekt sass.
// IMPORTANT (see CLAUDE.md gotcha "Font is baseline-anchored"):
// All three text fields via drawString() with a MIDDLE
// datum centered on the row's midpoint, instead of mixing baseline- and
// top-anchored calls - exactly that had previously caused
// altitude/distance to run out of the bottom of the box,
// while the callsign (baseline-anchored) was positioned correctly.
int16_t midY = r.y + r.h / 2;
const char* label = a.callsign[0] ? a.callsign : a.hex;

View File

@@ -2,12 +2,12 @@
#include <Arduino.h>
#include <TFT_eSPI.h>
// Sortierbare Liste aller aktuell erkannten Flugzeuge (gleiche Filter wie
// das Radar: Reichweite, Bodenfahrzeuge, Airline-Filter). Antippen eines
// Eintrags springt zurueck zum Radar mit direkt geoeffnetem Detail-Panel.
// Sortable list of all currently detected aircraft (same filters as
// the radar: range, ground vehicles, airline filter). Tapping an
// entry jumps back to the radar with the detail panel directly open.
namespace AircraftListScreen {
// Rueckgabewert true = der Nutzer hat ein Flugzeug angetippt (Aufrufer
// soll bis zum Radar zurueckspringen, nicht nur die eigene Seite
// schliessen). false = nur "Zurueck" gedrueckt, normal weiter im Menue.
// Return value true = the user tapped an aircraft (caller
// should jump back to the radar, not just close its own page).
// false = only "Back" pressed, continue normally in menu.
bool run(TFT_eSPI& tft);
}

View File

@@ -4,13 +4,12 @@
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// WICHTIG: Diese Datei ruft absichtlich KEIN AirlineLookup::resolve() mehr auf!
// postFetchUpdate() wird vom NetTask auf Core 0 aufgerufen. AirlineLookup
// braucht SD-Kartenzugriff, und die SD-Karte wurde in setup() auf Core 1
// initialisiert - Zugriff von Core 0 aus fuehrte zu einem Haenger (Task
// Watchdog auf IDLE0). Die Aufloesung der Airline-Namen passiert deshalb
// jetzt in main.cpp/renderAircraftList() auf Core 1 (demselben Core, der
// die SD-Karte urspruenglich initialisiert hat).
// IMPORTANT: This file intentionally no longer calls AirlineLookup::resolve()!
// postFetchUpdate() is called from NetTask on Core 0. AirlineLookup
// needs SD card access, and the SD card was initialized in setup() on Core 1
// - access from Core 0 caused a hang (Task Watchdog on IDLE0). The
// resolution of airline names now happens in main.cpp/renderAircraftList()
// on Core 1 (the same core that originally initialized the SD card).
namespace AircraftTable {

View File

@@ -11,15 +11,15 @@ namespace AircraftTable {
uint8_t validCount();
void postFetchUpdate(double homeLat, double homeLon);
// Wird bei jedem postFetchUpdate() erhoeht. Damit koennen andere Teile des
// Programms (z.B. der Render-Loop) erkennen, ob sich die Daten seit dem
// letzten Mal ueberhaupt geaendert haben, statt stumpf auf Zeit zu pollen -
// das vermeidet unnoetiges (und flackerndes) Neuzeichnen.
// Incremented on every postFetchUpdate(). This lets other parts of the
// program (e.g. the render loop) detect whether the data has
// changed at all since the last time, instead of blindly polling by time -
// this avoids unnecessary (and flickering) redrawing.
uint32_t version();
// Schuetzt den Zugriff auf raw()/validCount()/postFetchUpdate() zwischen
// dem Netzwerk-Task (Core 0, schreibt) und dem Render-Loop (Core 1, liest).
// Aufrufer muss lock() vor und unlock() nach jedem Zugriff aufrufen.
// Protects access to raw()/validCount()/postFetchUpdate() between
// the network task (Core 0, writes) and the render loop (Core 1, reads).
// Caller must call lock() before and unlock() after every access.
void lock();
void unlock();

View File

@@ -16,19 +16,18 @@ namespace {
char watched[MAX_WATCHED][9] = {{0}};
uint8_t watchedCount = 0;
// Schuetzt watched[]/watchedCount - urspruenglich nur von Core 1 (Menue-
// Screens, Radar) verwendet, seit der WebUI-Listenverwaltung (siehe
// web_export_server.cpp) aber auch von Core 0 (NetTask) aus erreichbar.
// Gleiches Muster wie AircraftDetails::mutex.
// Protects watched[]/watchedCount - originally only used from Core 1 (menu
// screens, radar), but since the WebUI list management (see
// web_export_server.cpp) also reachable from Core 0 (NetTask).
// Same pattern as AircraftDetails::mutex.
SemaphoreHandle_t mutex = nullptr;
void ensureMutex() {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
}
// Ueberspringt fuehrende Leerzeichen, uebernimmt bis zu 8 Zeichen und
// bricht bei einem Leerzeichen ab (ADS-B-Rufzeichen haben oft Padding),
// alles in Grossbuchstaben.
// Skips leading spaces, copies up to 8 characters and stops at a space
// (ADS-B callsigns often have padding), all in uppercase.
void normalize(const char* callsign, char* out) {
int j = 0;
int i = 0;

View File

@@ -25,9 +25,9 @@ namespace {
tft.setTextDatum(TL_DATUM);
}
// Vier Tastaturreihen (Ziffern oben, dann QWERTYUIOP/ASDFGHJKL/ZXCVBNM),
// da Rufzeichen wie "DLH441" auch Zahlen enthalten. Buffer 8 Zeichen +
// Nullterminierung (volles Rufzeichen statt nur 3-stelligem ICAO-Praefix).
// Four keyboard rows (digits on top, then QWERTYUIOP/ASDFGHJKL/ZXCVBNM),
// since callsigns like "DLH441" also contain numbers. Buffer 8 characters +
// null terminator (full callsign instead of just 3-letter ICAO prefix).
String runCallsignKeypad(TFT_eSPI& tft) {
MenuStars::reset();
constexpr const char* DIGITS = "1234567890";
@@ -164,10 +164,10 @@ namespace {
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 20;
constexpr int16_t LINE_H = 16;
// VIEW_TOP haengt davon ab, wie viele Zeilen der Titel tatsaechlich
// braucht (je nach Sprache 1 oder 2 Zeilen) - vorher "trocken"
// (draw=false) berechnen, damit der Fliesstext nicht in eine
// zweizeilige Titelueberschrift hineinlaeuft.
// VIEW_TOP depends on how many lines the title actually needs
// (1 or 2 lines depending on language) - calculate "dry" first
// (draw=false) so the body text does not run into a
// two-line title heading.
int16_t titleEndY = layoutWrapped(tft, 10, 14, textMaxWidth, LINE_H, I18n::t(StringId::WATCHLIST_INFO_TITLE), 0, 0, 0, false);
const int16_t VIEW_TOP = titleEndY + 4;
constexpr int16_t VIEW_BOTTOM = Config::SCREEN_HEIGHT - 60;
@@ -192,10 +192,10 @@ namespace {
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
// Titel kann laenger als eine Zeile sein - ueber denselben
// layoutWrapped()-Mechanismus wie den Fliesstext zeichnen, statt
// println() (das keinen wortweisen Umbruch macht und mitten im
// Wort abschneidet/umbricht).
// Title can be longer than one line - draw via the same
// layoutWrapped() mechanism as the body text, instead of
// println() (which does not do word-wise wrapping and
// cuts/wraps mid-word).
layoutWrapped(tft, 10, 14, textMaxWidth, LINE_H, I18n::t(StringId::WATCHLIST_INFO_TITLE), 0, 0, Config::SCREEN_HEIGHT, true);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
@@ -243,11 +243,11 @@ void run(TFT_eSPI& tft) {
while (!done) {
tft.fillScreen(TFT_BLACK);
// Kleiner "?"-Info-Button oben rechts, gleiches Muster wie bei den
// Standort-Presets/dem WLAN-Manager. ZUERST zeichnen, damit Titel/
// Beschreibung bewusst unterhalb davon beginnen (der Titel + zwei
// Beschreibungszeilen liefen vorher UNTER dem Button durch und
// wurden dadurch teilweise verdeckt/abgeschnitten).
// Small "?" info button top right, same pattern as with the location
// presets/WiFi manager. Draw FIRST so that title/description
// consciously start below it (the title + two description lines
// previously ran UNDER the button and were partially
// hidden/clipped).
Rect infoBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24};
drawButton(tft, infoBtn, "?");

View File

@@ -16,10 +16,10 @@ namespace {
char hidden[MAX_HIDDEN][4] = {{0}};
uint8_t hiddenCount = 0;
// Schuetzt hidden[]/hiddenCount - urspruenglich nur von Core 1 (Menue-
// Screens) verwendet, seit der WebUI-Listenverwaltung (siehe
// web_export_server.cpp) aber auch von Core 0 (NetTask) aus erreichbar.
// Gleiches Muster wie AircraftDetails::mutex.
// Protects hidden[]/hiddenCount - originally only used from Core 1 (menu
// screens), but since the WebUI list management (see
// web_export_server.cpp) also reachable from Core 0 (NetTask).
// Same pattern as AircraftDetails::mutex.
SemaphoreHandle_t mutex = nullptr;
void ensureMutex() {

View File

@@ -24,9 +24,9 @@ namespace {
tft.setTextDatum(TL_DATUM);
}
// Gleicher PWM-Kanal wie in main.cpp (dort in setup() mit ledcSetup()
// initialisiert) - hier bewusst als eigene Konstante dupliziert statt
// geteilt, siehe CLAUDE.md-Konvention ("jeder Screen unabhaengig").
// Same PWM channel as in main.cpp (initialized there in setup() with
// ledcSetup()) - intentionally duplicated here as its own constant
// instead of shared, see CLAUDE.md convention ("each screen independent").
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0;
void applyLive(uint8_t percent) {

View File

@@ -2,9 +2,9 @@
#include <Arduino.h>
#include <TFT_eSPI.h>
// Display-Helligkeit einstellen (10-100% in 10%-Schritten). Wirkt sich
// sofort live auf die Hintergrundbeleuchtung aus, damit man die Aenderung
// beim Antippen direkt sieht.
// Display brightness setting (10-100% in 10% steps). Affects the
// backlight live immediately so you can see the change
// directly when tapping.
namespace BrightnessScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -3,8 +3,8 @@
#include <TFT_eSPI.h>
namespace CalibrationScreen {
// Blockierend: zeigt 4 Kreise (Ecken), wartet auf Antippen jeweils in
// Reihenfolge, berechnet die Kalibrierung und speichert sie auf der
// SD-Karte (via TouchInput::saveCalibration()).
// Blocking: shows 4 circles (corners), waits for tapping each in
// sequence, calculates the calibration and saves it on the
// SD card (via TouchInput::saveCalibration()).
void run(TFT_eSPI& tft);
}

View File

@@ -2,40 +2,40 @@
#include <Arduino.h>
namespace Config {
// Wird bei jedem Versions-Release von Karl aktualisiert (siehe
// CLAUDE.md-Workflow "Standard-Workflow: Push & Release") - erscheint
// im Info-Screen (Menue > System > Info) und muss zum jeweiligen
// Git-Tag passen.
// Updated by Karl on every version release (see
// CLAUDE.md workflow "Standard Workflow: Push & Release") - shown
// in the info screen (Menu > System > Info) and must match the
// corresponding Git tag.
constexpr const char* APP_VERSION = "3.6.3";
// Display-Helligkeit (Menue > System > Helligkeit), in Prozent.
// MIN bewusst nicht 0 - ein komplett dunkles Display koennte sonst wie
// ein Defekt wirken statt wie eine Einstellung.
// Display brightness (Menu > System > Brightness), in percent.
// MIN intentionally not 0 - a completely dark display could otherwise
// look like a defect rather than a setting.
constexpr uint8_t BRIGHTNESS_MIN_PERCENT = 10;
constexpr uint8_t BRIGHTNESS_MAX_PERCENT = 100;
constexpr uint8_t BRIGHTNESS_STEP_PERCENT = 10;
// Bildschirm-Timeout (Menue > System > Bildschirm-Timeout), in Minuten,
// per Schieberegler einstellbar (siehe timeout_screen.cpp) - danach
// folgt "Nie" (kein Timeout) als eigene Endposition. Vorher nur per
// wiederholtem Antippen 0-10 durchklickbar (0 = Nie), was bei z.B. 10
// Minuten zehn einzelne Tipps brauchte.
// Screen timeout (Menu > System > Screen Timeout), in minutes,
// adjustable via slider (see timeout_screen.cpp) - followed by
// "Never" (no timeout) as a separate end position. Previously only
// clickable 0-10 by repeated tapping (0 = Never), which needed e.g. ten
// individual taps for 10 minutes.
constexpr uint8_t SCREEN_TIMEOUT_MIN_MINUTES = 1;
constexpr uint8_t SCREEN_TIMEOUT_MAX_MINUTES = 15;
// Nachtmodus (22-6 Uhr) dimmt relativ zur jeweils eingestellten normalen
// Helligkeit, nicht auf einen festen Absolutwert - sonst waere der
// Dimm-Effekt bei niedrig eingestellter Normalhelligkeit wirkungslos
// oder wuerde das Display nachts sogar heller machen als tagsueber.
// Night mode (10pm-6am) dims relative to the currently set normal
// brightness, not to a fixed absolute value - otherwise the
// dimming effect would be useless at low normal brightness
// or would even make the display brighter at night than during the day.
constexpr uint8_t NIGHT_DIM_REDUCTION_PERCENT = 40;
constexpr const char* IP_GEO_HOST = "ip-api.com";
constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon,offset,countryCode";
// Adresssuche (AddressSearchScreen) - kostenloser, anmeldefreier
// Geokodierungs-Dienst (OpenStreetMap Nominatim). Deren Nutzungsregeln
// verlangen einen aussagekraeftigen User-Agent statt des HTTPClient-
// Standardwerts, siehe https://operations.osmfoundation.org/policies/nominatim/.
// Address search (AddressSearchScreen) - free, no-registration
// geocoding service (OpenStreetMap Nominatim). Their usage policies
// require a meaningful User-Agent instead of the HTTPClient
// default, see https://operations.osmfoundation.org/policies/nominatim/.
constexpr const char* NOMINATIM_HOST = "nominatim.openstreetmap.org";
constexpr const char* NOMINATIM_USER_AGENT = "EiswolfsFlightradarCYD (github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
@@ -50,9 +50,9 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
constexpr uint8_t RANGE_STEP_COUNT = 4;
constexpr uint8_t DEFAULT_RANGE_INDEX = 1;
// Eco-Mode: reduziert die ADS-B-Abfragefrequenz auf dieses Intervall
// (statt FETCH_INTERVAL_MS), wenn der Bildschirm durch Inaktivitaets-
// Timeout abgeschaltet ist (siehe main.cpp).
// Eco mode: reduces the ADS-B fetch frequency to this interval
// (instead of FETCH_INTERVAL_MS), when the screen is turned off by
// inactivity timeout (see main.cpp).
constexpr uint32_t ECO_FETCH_INTERVAL_MS = 60000;
constexpr uint8_t ALT_FILTER_COUNT = 3;
@@ -63,11 +63,11 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
constexpr uint32_t FETCH_INTERVAL_MS = 8000;
constexpr uint32_t HTTP_TIMEOUT_MS = 6000;
// Wetter-Icon im Header (siehe weather.cpp) - deutlich seltener
// abgefragt als die ADS-B-Daten, das Wetter aendert sich nicht
// minuetlich und die kostenlose Open-Meteo-API soll nicht unnoetig oft
// belastet werden.
constexpr uint32_t WEATHER_FETCH_INTERVAL_MS = 600000; // 10 Minuten
// Weather icon in the header (see weather.cpp) - queried much less
// frequently than ADS-B data, weather does not change
// every minute and the free Open-Meteo API should not be needlessly
// stressed.
constexpr uint32_t WEATHER_FETCH_INTERVAL_MS = 600000; // 10 minutes
constexpr float DEFAULT_PROXIMITY_ALERT_KM = 8.0f;
@@ -97,8 +97,8 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
constexpr uint8_t TOUCH_MISO_PIN = 39;
constexpr uint8_t TOUCH_IRQ_PIN = 36;
// Display-Rotation: 0 = normal (USB-Anschluss unten), 2 = 180 Grad
// gedreht (USB oben) - fuer Montage mit Ladebuchse oben.
// Display rotation: 0 = normal (USB connector at bottom), 2 = 180 degrees
// rotated (USB at top) - for mounting with charging port on top.
constexpr uint8_t DISPLAY_ROTATION = 2;
constexpr int16_t SCREEN_WIDTH = 240;

View File

@@ -24,7 +24,7 @@ namespace {
tft.setTextDatum(TL_DATUM);
}
// Lokale Kopie, siehe Konvention in location_presets_screen.cpp.
// Local copy, see convention in location_presets_screen.cpp.
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text) {
int16_t y = startY;
@@ -59,12 +59,11 @@ void run(TFT_eSPI& tft) {
int16_t textEndY = layoutWrapped(tft, 10, 40, (int16_t)(Config::SCREEN_WIDTH - 20), 18,
I18n::t(StringId::FIRST_RUN_LOCATION_BODY));
// Buttons folgen direkt unter dem Text (mit etwas Abstand) statt an
// einer fest einprogrammierten Position - eine laengere Uebersetzung
// hat sonst den Text bis unter/hinter die Buttons laufen lassen
// (auf Deutsch beobachtet: Text unlesbar hinter "Adresse eingeben"/
// "Ueberspringen"). Nach unten hin trotzdem an den Bildschirmrand
// geklemmt, falls ein Text ausnahmsweise doch sehr lang waere.
// Buttons follow directly below the text (with some spacing) instead of
// a hardcoded position - a longer translation would otherwise cause the
// text to run below/behind the buttons (observed in German: text
// unreadable behind "Enter address"/"Skip"). Clamped to the bottom of
// the screen in case a text happens to be very long.
constexpr int16_t BTN_H = 40;
constexpr int16_t BTN_GAP = 8;
int16_t setY = textEndY + 14;
@@ -82,9 +81,9 @@ void run(TFT_eSPI& tft) {
if (setBtn.contains(tap.x, tap.y)) {
if (AddressSearchScreen::run(tft)) {
// Direkt aktivieren - das ist der ganze Sinn dieses
// Screens (sofort beim ersten Start den praezisen
// Standort nutzen, statt weiter auf Auto/IP zu bleiben).
// Activate immediately - that is the whole point of this
// screen (use the precise location right on first start,
// instead of staying on auto/IP).
uint8_t idx = LocationPresets::count();
if (idx > 0) LocationPresets::setActiveIndex((int8_t)(idx - 1));
}

View File

@@ -3,9 +3,8 @@
#include <TFT_eSPI.h>
namespace FirstRunLocationScreen {
// Einmaliger Hinweis-Screen beim allerersten Start (nach der
// Sprachauswahl) - erklaert den Genauigkeitsvorteil eines per Adresse
// gesetzten Standorts, mit direktem Einstieg in die Adresssuche.
// Ueberspringbar.
// One-time info screen on very first start (after language selection)
// - explains the accuracy advantage of a location set by address,
// with direct entry into the address search. Skippable.
void run(TFT_eSPI& tft);
}

View File

@@ -15,10 +15,10 @@ namespace {
char seenHex[MAX_SEEN][7];
uint16_t seenCount = 0;
// Datei der aktuell laufenden Aufzeichnungs-Sitzung (ohne ".csv"), z.B.
// "2026-08-06" fuer die erste Sitzung eines Tages oder "2026-08-06_2"
// fuer ein erneutes Einschalten am selben Tag. Leer = noch nicht
// aufgeloest (Uhrzeit noch nicht synchronisiert oder Flugbuch aus).
// File of the currently active recording session (without ".csv"), e.g.
// "2026-08-06" for the first session of a day or "2026-08-06_2"
// for powering on again on the same day. Empty = not yet
// resolved (clock not yet synced or flight logbook off).
char currentSessionFile[16] = {0};
void formatDateFromEpoch(uint32_t epoch, char* out, size_t outSize) {
@@ -28,12 +28,12 @@ namespace {
snprintf(out, outSize, "%04d-%02d-%02d", tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday);
}
// Findet fuer den gegebenen Aktivierungszeitpunkt eine noch nicht
// existierende Logbuch-Datei: "<Datum>.csv" fuer die erste Sitzung
// eines Tages, "<Datum>_2.csv", "_3.csv" usw. fuer erneutes Einschalten
// am selben Tag - so bekommt jede Sitzung ihre eigene, im
// Logbuch-Dateien-Screen einzeln loeschbare Datei, statt in eine
// bestehende hineinzuschreiben.
// Finds a logbook file that does not yet exist for the given activation
// time: "<Date>.csv" for the first session of a day,
// "<Date>_2.csv", "_3.csv" etc. for powering on again the same day -
// this way each session gets its own file, individually deletable
// in the logbook files screen, instead of appending to
// an existing one.
void resolveSessionFilename(uint32_t epoch, char* out, size_t outSize) {
char dateStr[11];
formatDateFromEpoch(epoch, dateStr, sizeof(dateStr));
@@ -57,8 +57,8 @@ namespace {
}
}
// Sehr unwahrscheinlicher Fall (>50 Sitzungen an einem Tag): letzten
// Kandidaten weiterverwenden statt endlos zu suchen.
// Very unlikely case (>50 sessions in one day): reuse the last
// candidate instead of searching indefinitely.
strncpy(out, dateStr, outSize - 1);
out[outSize - 1] = 0;
}
@@ -92,7 +92,7 @@ namespace {
if (buf[i] == '\n') lines++;
}
blocksRead++;
if (blocksRead % 8 == 0) delay(1);
if (blocksRead % 8 == 0) delay(1); // yield to prevent watchdog timeout during bulk SD reads
}
return lines;
}
@@ -141,17 +141,17 @@ namespace {
}
}
blocksRead++;
if (blocksRead % 8 == 0) delay(1);
if (blocksRead % 8 == 0) delay(1); // yield during bulk SD reads
}
f.close();
}
// Loest die Datei der aktuellen Sitzung auf (einmalig pro Sitzung) bzw.
// uebernimmt sie nach einem Neustart erneut aus den Einstellungen -
// nur aufrufen, wenn das Flugbuch gerade eingeschaltet ist.
// Resolves the current session file (once per session) or
// adopts it again from settings after a restart -
// only call when the flight logbook is currently enabled.
void ensureSessionFile() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return; // Uhrzeit noch nicht synchronisiert
if (now <= 8 * 3600 * 2) return; // epoch still below reasonable minimum = RTC not yet synced
String persisted = SettingsStore::flightLogbookSessionFile();
if (persisted.length() > 0) {
@@ -163,10 +163,10 @@ namespace {
return;
}
// Keine Sitzungsdatei hinterlegt - entweder frisches Einschalten
// (menu_screen.cpp loescht den Eintrag beim Umschalten bewusst) oder
// Migration von einer alten Firmware ohne Sitzungslogik. In beiden
// Faellen jetzt eine neue, garantiert einzigartige Datei anlegen.
// No session file stored - either a fresh power-on
// (menu_screen.cpp deliberately clears the entry when toggling) or
// migration from an older firmware without session logic. In both
// cases, create a new, guaranteed unique file now.
uint32_t enabledAt = SettingsStore::flightLogbookEnabledAtEpoch();
if (enabledAt == 0) enabledAt = (uint32_t)now;
resolveSessionFilename(enabledAt, currentSessionFile, sizeof(currentSessionFile));
@@ -204,17 +204,17 @@ void init() {
void update() {
if (!SettingsStore::flightLogbookEnabled()) return;
// 24h-Sicherheitsabschaltung: verhindert, dass ein unbemerkt aktives
// Flugbuch die SD-Karte nach und nach vollschreibt (siehe
// Bestaetigungsdialog beim Einschalten in menu_screen.cpp). Nur pruefen,
// wenn die Uhrzeit schon synchronisiert ist.
// 24h safety shutoff: prevents an unnoticed active
// flight logbook from filling up the SD card over time (see
// confirmation dialog when enabling in menu_screen.cpp). Only check
// when the clock has already synced.
time_t nowCheck = time(nullptr);
if (nowCheck > 8 * 3600 * 2) {
uint32_t enabledAt = SettingsStore::flightLogbookEnabledAtEpoch();
if (enabledAt == 0) {
// Migrations-Fall: Flugbuch war schon vor diesem Update aktiv
// (alte Einstellungsdatei ohne Zeitstempel) - Startzeitpunkt
// jetzt setzen, damit die 24h-Grenze trotzdem sicher greift.
// Migration case: flight logbook was already active before this update
// (old settings file without timestamp) - set start time
// now so that the 24h limit still works safely.
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)nowCheck);
} else if ((uint32_t)nowCheck >= enabledAt &&
(uint32_t)nowCheck - enabledAt >= 24UL * 3600UL) {
@@ -274,7 +274,7 @@ TopAltitude todayMaxAltitude() {
TopAltitude result;
SdMutex::Guard guard;
if (currentSessionFile[0] == 0) return result; // noch keine Sitzungsdatei bekannt
if (currentSessionFile[0] == 0) return result; // no session file known yet
char filename[64];
logFilename(filename, sizeof(filename));
@@ -287,10 +287,10 @@ TopAltitude todayMaxAltitude() {
uint32_t lineIdx = 0;
while (f.available()) {
String line = f.readStringUntil('\n');
if (firstLine) { firstLine = false; continue; } // CSV-Header ueberspringen
if (firstLine) { firstLine = false; continue; } // skip CSV header
if (line.length() == 0) continue;
// Spalten: timestamp,hex,callsign,reg,type,distance_km,altitude_ft
// Columns: timestamp,hex,callsign,reg,type,distance_km,altitude_ft
int commaIdx[6];
int found = 0;
int searchFrom = 0;
@@ -301,7 +301,7 @@ TopAltitude todayMaxAltitude() {
searchFrom = idx + 1;
found++;
}
if (found < 6) continue; // unvollstaendige/kaputte Zeile ueberspringen
if (found < 6) continue; // skip incomplete/corrupt line
String callsign = line.substring(commaIdx[1] + 1, commaIdx[2]);
String altStr = line.substring(commaIdx[5] + 1);
@@ -383,9 +383,9 @@ uint8_t listDays(DayEntry* out, uint8_t maxEntries) {
}
uint8_t listDaySummaries(DayEntry* out, uint8_t maxEntries) {
// Scannt grosszuegiger als maxEntries, damit auch bei vielen einzelnen
// Sitzungs-Dateien pro Tag noch korrekt pro Kalendertag aufsummiert
// wird, bevor auf die angeforderte Anzahl Tage begrenzt wird.
// Scans more generously than maxEntries, so that even with many individual
// session files per day it still correctly sums per calendar day
// before limiting to the requested number of days.
constexpr uint8_t MAX_RAW_SCAN = 90;
static DayEntry raw[MAX_RAW_SCAN];
uint8_t rawCount = listDays(raw, MAX_RAW_SCAN);
@@ -421,9 +421,9 @@ bool deleteFile(const char* label) {
bool ok = SD.remove(path);
if (ok && strcmp(label, currentSessionFile) == 0) {
// Die gerade aktive Sitzungsdatei wurde geloescht - Dopplungs-Liste
// zuruecksetzen, damit neue Sichtungen wieder korrekt in die (beim
// naechsten Schreibvorgang neu angelegte) Datei geloggt werden.
// The currently active session file was deleted - reset the
// deduplication list so that new sightings are correctly logged
// into the (on the next write newly created) file.
seenCount = 0;
}
return ok;

View File

@@ -14,44 +14,43 @@ namespace FlightLogbook {
int32_t altitudeFt = 0;
};
// Sucht in der Datei der aktuellen Sitzung den Eintrag mit der hoechsten
// geloggten Flughoehe (jeweils die Hoehe BEIM ERSTEN Sichten, nicht der
// aktuelle Wert) und gibt dessen Rufzeichen + Hoehe zurueck. found=false,
// wenn noch nichts geloggt wurde oder die Datei fehlt.
// Searches the current session file for the entry with the highest
// logged altitude (each being the altitude AT FIRST SIGHTING, not the
// current value) and returns its callsign + altitude. found=false,
// if nothing has been logged yet or the file is missing.
TopAltitude todayMaxAltitude();
void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays);
struct DayEntry {
// Nicht mehr zwingend nur ein Kalenderdatum: bei mehrfachem
// Ein-/Ausschalten am selben Tag bekommt jede Sitzung eine eigene
// Datei mit Suffix (z.B. "2026-08-06_2") - siehe
// resolveSessionFilename() in flight_logbook.cpp. Puffer
// entsprechend groesser als ein reines "YYYY-MM-DD".
// No longer necessarily just a calendar date: when powering on/off
// multiple times on the same day, each session gets its own
// file with a suffix (e.g. "2026-08-06_2") - see
// resolveSessionFilename() in flight_logbook.cpp. Buffer
// accordingly larger than a plain "YYYY-MM-DD".
char date[16] = {0};
uint32_t count = 0;
};
// Eine Zeile pro Logbuch-DATEI (also ggf. mehrere pro Kalendertag, wenn
// das Flugbuch mehrfach am selben Tag ein-/ausgeschaltet wurde). Fuer
// den Logbuch-Dateien-Screen gedacht, wo jede Datei einzeln geloescht
// werden kann.
// One line per logbook FILE (so possibly multiple per calendar day, if
// the flight logbook was toggled on/off multiple times on the same day). Intended
// for the logbook files screen, where each file can be individually deleted.
uint8_t listDays(DayEntry* out, uint8_t maxEntries);
// Wie listDays(), fasst aber alle Sitzungs-Dateien desselben
// Kalendertags zu einem Eintrag zusammen (Summe der Anzahl) - fuer den
// 7-Tage-Verlauf im Statistik-Bildschirm, der weiterhin pro Tag statt
// pro einzelner Sitzung zaehlen soll.
// Like listDays(), but merges all session files of the same
// calendar day into one entry (sum of counts) - for the
// 7-day history in the statistics screen, which should still count per day
// rather than per individual session.
uint8_t listDaySummaries(DayEntry* out, uint8_t maxEntries);
// Loescht eine einzelne Logbuch-Datei (Label wie von listDays()
// zurueckgegeben, ohne ".csv"). Fuer die einzelnen Loesch-Buttons im
// Logbuch-Dateien-Screen gedacht. Wird gerade die aktive Sitzungsdatei
// geloescht, faengt die Aufzeichnung sauber neu in derselben Datei an.
// Deletes a single logbook file (label as returned by listDays(),
// without ".csv"). Intended for the individual delete buttons in the
// logbook files screen. If the currently active session file is
// deleted, logging restarts cleanly in the same file.
bool deleteFile(const char* label);
// Loescht ALLE Logbuch-CSV-Dateien auf der SD-Karte unwiderruflich und
// setzt die "heute schon gesehen"-Liste zurueck. Fuer den Reset-Button
// im Statistik-Bildschirm gedacht.
// Deletes ALL logbook CSV files on the SD card irreversibly and
// resets the "already seen today" list. Intended for the reset button
// in the statistics screen.
void resetAllData();
}

View File

@@ -188,43 +188,42 @@ enum class StringId : uint8_t {
MENU_BRIGHTNESS_PREFIX,
BRIGHTNESS_TITLE,
// Grosse Warn-Ueberlage beim Einschalten des Flugbuchs (Menue >
// Flugoptionen > Flugbuch), erklaert die 24h-Sicherheitsabschaltung.
// Large warning overlay when enabling the flight logbook (Menu >
// Flight Options > Flight Logbook), explains the 24h safety shutoff.
MENU_LOGBOOK_WARNING_TITLE,
MENU_LOGBOOK_WARNING_BODY,
// Preset-Name beim Anlegen (optional), "Presets voll"-Hinweis beim
// Antippen der Naechster-Flughafen-Laufschrift, sowie ein zusaetzlicher
// Info-Absatz dazu (Menue > Flugoptionen > Standort-Presets > "?").
// Preset name when creating (optional), "Presets full" hint when
// tapping the nearest-airport marquee, and an additional
// info paragraph about it (Menu > Flight Options > Location Presets > "?").
LOCATION_NAME_PROMPT,
LOCATION_NAME_SKIP,
LOCATION_PRESETS_FULL,
LOCATION_INFO_PARA5,
// Neuer, direkt im System-Menue erreichbarer Punkt "Logbuch/WebUI"
// (statt nur ueber das versteckte "?" im Logbuch-Dateien-Screen) -
// zeigt IP + Erklaerung der Weboberflaeche (Flugbuch
// ansehen/herunterladen/loeschen). Die urspruenglich hier mit
// enthaltene Screenshot-Verwaltung wurde wieder entfernt, siehe
// Entfernung von SCREENSHOT_SAVED_PREFIX/SCREENSHOT_FAILED oben - das
// Bildschirm-Auslesen (SPI-Readback) funktioniert auf diesem
// CYD-Board hardwareseitig nicht (TFT_MISO nicht angebunden).
// New item directly reachable in the System menu "Logbook/WebUI"
// (instead of only through the hidden "?" in the logbook files screen) -
// shows IP + explanation of the web interface (view logbook
// /download/delete). The originally included screenshot
// management was removed again, see
// removal of SCREENSHOT_SAVED_PREFIX/SCREENSHOT_FAILED above - the
// screen readback (SPI-readback) does not work on this
// CYD board at the hardware level (TFT_MISO not connected).
MENU_LOGBOOK_WEBUI,
WEBUI_TITLE,
WEBUI_INFO_PARA1,
WEBUI_INFO_PARA2,
// Kleines Info-Fenster, das beim Antippen des Wetter-Icons im Header
// erscheint (siehe main.cpp/showWeatherInfo()) - erklaert, dass das
// Wetter zum aktuell aktiven Standort (bzw. aktivem Standort-Preset)
// gehoert.
// Small info window that appears when tapping the weather icon in the header
// (see main.cpp/showWeatherInfo()) - explains that the
// weather belongs to the currently active location (or active location preset).
WEATHER_INFO_TITLE,
WEATHER_INFO_BODY,
// Adresssuche (AddressSearchScreen) - Standort per Adresseingabe statt
// manueller Koordinaten, per kostenlosem Nominatim-Geokodierungsdienst.
// Erreichbar sowohl beim Ersteinrichten (nach der Sprachauswahl) als
// auch jederzeit ueber Standort-Presets > "+".
// Address search (AddressSearchScreen) - location by address instead
// of manual coordinates, via the free Nominatim geocoding service.
// Reachable both during first-time setup (after language selection) and
// at any time via Location Presets > "+".
LOCATION_ADD_CHOICE_TITLE,
LOCATION_ADD_BY_COORDS,
LOCATION_ADD_BY_ADDRESS,
@@ -237,60 +236,60 @@ enum class StringId : uint8_t {
ADDRESS_SEARCH_TRY_AGAIN,
ADDRESS_SEARCH_CANCEL,
// Grosser Hinweis-Screen beim allerersten Start (nur einmalig, nach der
// Sprachauswahl) - erklaert den Genauigkeitsvorteil eines per Adresse
// gesetzten Standorts gegenueber der automatischen IP-Standort-
// bestimmung, mit direktem Einstieg in die Adresssuche. Ueberspringbar.
// Large info screen on very first start (only once, after language
// selection) - explains the accuracy advantage of a location set via
// address compared to automatic IP geolocation,
// with direct entry into the address search. Skippable.
FIRST_RUN_LOCATION_TITLE,
FIRST_RUN_LOCATION_BODY,
FIRST_RUN_LOCATION_SET_BTN,
FIRST_RUN_LOCATION_SKIP_BTN,
// Zusaetzlicher Info-Absatz im Standort-Presets-Screen (Menue >
// Flugoptionen > Standort-Presets > "?") - weist auf den "Per Adresse
// suchen"-Weg beim "+"-Button hin (deutlich genauer als die
// automatische IP-Standortbestimmung). Ergaenzt die knappe Erwaehnung
// in LOCATION_INFO_PARA3 um denselben anschaulichen Vergleich wie im
// Ersteinrichtungs-Screen (FIRST_RUN_LOCATION_BODY) und im README.
// Additional info paragraph in the location presets screen (Menu >
// Flight Options > Location Presets > "?") - points to the "Search by
// address" option on the "+" button (significantly more accurate than
// automatic IP geolocation). Complements the brief mention
// in LOCATION_INFO_PARA3 with the same illustrative comparison as in the
// first-run setup screen (FIRST_RUN_LOCATION_BODY) and in the README.
LOCATION_INFO_PARA6,
// Format-Hinweis unter dem Titel der Adresssuche (AddressSearchScreen)
// - ein Beispiel im lokalen Format (Strasse Hausnr., PLZ Ort), da das
// einzelne Freitextfeld sonst ohne jeden Hinweis war und Nutzer nicht
// wussten, was/in welcher Reihenfolge sie eingeben sollen.
// Format hint below the title of the address search (AddressSearchScreen)
// - an example in local format (Street No., Postal Code City), since the
// single free-text field had no hint at all and users did not
// know what / in which order to enter.
ADDRESS_SEARCH_HINT,
// Letzter Screen der Ersteinrichtung (nach der Standort-Adresssuche,
// main.cpp) - bestaetigt den Abschluss der Einrichtung, nennt den
// SD-Karten-Ordner, in dem alle Daten liegen, und weist darauf hin,
// dass sich das WLAN ab dem naechsten Start automatisch verbindet.
// Last screen of the first-time setup (after location address search,
// main.cpp) - confirms the completion of the setup, mentions the
// SD card folder where all data is stored, and points out
// that WiFi will connect automatically from the next start onward.
FIRST_RUN_COMPLETE_TITLE,
FIRST_RUN_COMPLETE_BODY1,
FIRST_RUN_COMPLETE_BODY2,
// Hinweis mit Beispielnamen unter dem Titel der Namens-Tastatur
// (location_presets_screen.cpp::runPresetNameKeypad UND
// address_search_screen.cpp::runNameKeypad, beide spiegeln sich) -
// ohne jeden Hinweis wussten Einsteiger nicht, was fuer ein Name hier
// gemeint ist (Preset-Name wie "Zuhause", nicht z.B. ein Ort/Adresse).
// Hint with example name below the title of the name keypad
// (location_presets_screen.cpp::runPresetNameKeypad AND
// address_search_screen.cpp::runNameKeypad, both mirror each other) -
// without any hint, beginners did not know what kind of name
// is meant here (preset name like "Home", not e.g. a city/address).
LOCATION_NAME_HINT,
// Legenden-Eintrag auf dem Radar-Screen (radar_screen.cpp::drawLegend)
// fuer die blauen Quadrat-Marker der Bodenfahrzeuge - nur sichtbar,
// wenn "Bodenfahrzeuge ausblenden" AUS ist (Flugoptionen), da die
// Fahrzeuge dann als eigene Legenden-Zeile unter den drei
// Hoehen-Farbstufen erscheinen.
// Legend entry on the radar screen (radar_screen.cpp::drawLegend)
// for the blue square markers of ground vehicles - only visible
// when "Hide ground vehicles" is OFF (Flight Options), since the
// vehicles then appear as their own legend line below the three
// altitude color levels.
LEGEND_GROUND_VEHICLE,
// Neue "Sicherung & Reset"-Unterseite im System-Menue (fasst die
// bisher einzeln im System-Menue stehenden Backup/Restore-Buttons
// zusammen und ergaenzt einen dritten Punkt "Einstellungen
// zuruecksetzen" - ein kompletter Werksreset fuer Entwickler/Tester,
// der den gesamten Flightradar-Ordner von der SD-Karte loescht und
// neu startet, siehe settings_backup.cpp::factoryReset()). Titel und
// Button-Label teilen sich MENU_BACKUP_RESET, genau wie
// MENU_CATEGORY_SYSTEM sowohl fuer den Menue-Button als auch den
// Bildschirmtitel der System-Seite verwendet wird.
// New "Backup & Reset" subsection in the System menu (combines the
// previously individually placed Backup/Restore buttons in the System menu
// and adds a third item "Reset
// settings" - a complete factory reset for developers/testers,
// which deletes the entire Flightradar folder from the SD card and
// restarts, see settings_backup.cpp::factoryReset()). Title and
// button label share MENU_BACKUP_RESET, just like
// MENU_CATEGORY_SYSTEM is used both for the menu button and the
// screen title of the System page.
MENU_BACKUP_RESET,
MENU_FACTORY_RESET,
MENU_FACTORY_RESET_WARNING_BODY,
@@ -303,26 +302,25 @@ 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.
// Line prefix in the radar detail panel (radar_screen.cpp::drawDetailPanel)
// for the flight route (departure -> destination airport, ICAO code), queried
// via AircraftDetails by callsign. Falls back like MODEL/TYPE/SQUAWK to
// DETAIL_UNKNOWN if no callsign is known or no
// route was found.
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.
// Hint line above the QR code on the new QR sub-screen of the
// Logbook/WebUI page (webui_screen.cpp::runQrScreen) - the WEBUI_TITLE
// string is reused for the header line, no separate title needed.
WEBUI_QR_HINT,
// Ruhebildschirm bei Inaktivitaets-Timeout (Menue > System >
// "Ruhebildschirm", siehe SettingsStore::screensaverEnabled() und
// Screensaver during inactivity timeout (Menu > System >
// "Screensaver", see SettingsStore::screensaverEnabled() and
// main.cpp).
MENU_SCREENSAVER,
// OTA-Firmware-Update ueber WLAN (Menue > System > "Nach Update
// suchen", siehe menu_screen.cpp::runOtaUpdateScreen() und
// OTA firmware update over WiFi (Menu > System > "Check for
// update", see menu_screen.cpp::runOtaUpdateScreen() and
// ota_update.h/.cpp).
MENU_CHECK_UPDATE,
OTA_CHECKING,
@@ -334,23 +332,23 @@ enum class StringId : uint8_t {
OTA_UPDATE_FAILED,
OTA_UPDATE_SUCCESS,
// Erfolgs-/Fehler-Ergebnis nach Download+Flash wird jetzt als
// dauerhafter Info-Screen mit explizitem Button angezeigt (statt
// automatischem Neustart bzw. kurzer, automatisch verschwindender
// Meldung) - der Nutzer muss bei einer sicherheitsrelevanten Aktion wie
// einem Firmware-Update IMMER aktiv informiert werden und selbst
// bestaetigen, siehe menu_screen.cpp::infoScreen().
// Success/error result after download+flash is now shown as
// a permanent info screen with explicit button (instead of
// automatic restart or a brief, auto-dismissing
// message) - the user MUST be actively informed about a security-relevant
// action like a firmware update and must
// confirm it themselves, see menu_screen.cpp::infoScreen().
OTA_SUCCESS_BODY,
OTA_RESTART_BUTTON,
OTA_FAILED_BODY,
// Eigener Bildschirm-Timeout-Screen (Menue > System > Bildschirm-
// Timeout, siehe timeout_screen.cpp) mit Schieberegler statt des
// vorherigen Durchklickens per wiederholtem Antippen - der
// Ruhebildschirm-Umschalter (vorher eigene Zeile im System-Menue,
// MENU_SCREENSAVER) zieht mit auf diesen Screen um, da er inhaltlich
// eng mit dem Timeout zusammenhaengt und hier genug Platz fuer eine
// kurze Erklaerung ist.
// Dedicated screen timeout screen (Menu > System > Screen
// Timeout, see timeout_screen.cpp) with slider instead of the
// previous clicking through by repeated tapping - the
// screensaver toggle (previously its own line in the System menu,
// MENU_SCREENSAVER) moves to this screen as well, since it is
// closely related to the timeout and there is enough space for a
// brief explanation here.
TIMEOUT_SCREEN_TITLE,
TIMEOUT_SCREENSAVER_DESC,

View File

@@ -1,9 +1,9 @@
#pragma once
#include <Arduino.h>
// Steuert die diskrete RGB-LED auf der Rueckseite des CYD (kein Lautsprecher
// vorhanden, daher ersetzt die LED den akustischen Alarm vom Cardputer-
// Projekt). Pins: Rot=GPIO4, Gruen=GPIO16, Blau=GPIO17, active-low (LOW = an).
// Controls the discrete RGB LED on the back of the CYD (no speaker
// present, therefore the LED replaces the acoustic alarm from the Cardputer
// project). Pins: Red=GPIO4, Green=GPIO16, Blue=GPIO17, active-low (LOW = on).
namespace LedAlert {
enum class Mode {
@@ -18,9 +18,9 @@ namespace LedAlert {
void pulseHeartbeat(uint32_t nowMs);
// Blockierendes, kurzes weisses Aufblitzen (alle 3 Farbkanaele an) als
// sofortige Bestaetigung fuer eine einmalige Nutzeraktion (z.B. "Cam"-
// Button getroffen). Bewusst blockierend (delay), da nur aus dem
// Haupt-Loop bei einem Tap aufgerufen, nicht aus der NetTask-Schleife.
// Blocking, short white flash (all 3 color channels on) as
// immediate confirmation for a one-time user action (e.g. "Cam"
// button hit). Deliberately blocking (delay), since only called from the
// main loop on a tap, not from the NetTask loop.
void flashWhite(uint32_t durationMs = 150);
}

View File

@@ -20,14 +20,13 @@ namespace LocationManager {
bool hasGpsFix();
// UTC-Offset in Sekunden (inkl. evtl. Sommerzeit), ermittelt bei der
// IP-Geolocation-Abfrage. 0/false, falls noch nicht bekannt.
// UTC offset in seconds (including possible DST), determined during the
// IP geolocation query. 0/false, if not yet known.
bool hasUtcOffset();
int32_t utcOffsetSeconds();
// Ob die Region (per IP-Geolocation-Laendercode) metrische Einheiten
// nutzt (Meter/km) statt Fuss/Meilen. Default true (metrisch), bis die
// IP-Abfrage etwas anderes ermittelt hat - nur die USA nutzen aktuell
// eine Ausnahme.
// Whether the region (per IP geolocation country code) uses metric units
// (meters/km) instead of feet/miles. Default true (metric), until the
// IP query determines otherwise - currently only the USA is an exception.
bool useMetricUnits();
}

View File

@@ -13,10 +13,9 @@ namespace {
struct Preset {
double lat = 0;
double lon = 0;
// Optionaler, vom Nutzer vergebener Name (z.B. "Zuhause") oder vom
// naechstgelegenen Flughafen uebernommen - leer, wenn keiner gesetzt
// wurde. 16 Zeichen + Nullterminierung reichen fuer eine gut lesbare
// Zeile in der Preset-Liste.
// Optional user-assigned name (e.g. "Home") or taken from the nearest
// airport - empty if none was set. 16 characters + null terminator
// are sufficient for a well-readable line in the preset list.
char name[17] = {0};
};
@@ -65,9 +64,9 @@ namespace {
if (comma1 < 0) continue;
if (presetCount >= MAX_PRESETS) break;
// Der Name ist ein optionales drittes Feld - aeltere,
// gespeicherte Presets (vor diesem Feature) haben nur
// "lat,lon" ohne zweites Komma, dann bleibt der Name leer.
// The name is an optional third field - older,
// saved presets (before this feature) only have
// "lat,lon" without a second comma, then the name stays empty.
int comma2 = line.indexOf(',', comma1 + 1);
presets[presetCount].lat = line.substring(0, comma1).toDouble();
if (comma2 < 0) {

View File

@@ -8,9 +8,8 @@ namespace LocationPresets {
uint8_t count();
void getLatLon(uint8_t index, double& lat, double& lon);
// Vom Nutzer vergebener Name (z.B. "Zuhause") - leerer String, wenn
// keiner gesetzt wurde (dann zeigt der Screen stattdessen die
// Koordinaten an).
// User-assigned name (e.g. "Home") - empty string if none was set
// (the screen will show the coordinates instead).
String getName(uint8_t index);
bool addPreset(double lat, double lon, const String& name = String());

View File

@@ -116,11 +116,11 @@ namespace {
return confirmed ? String(buf) : String();
}
// Vier Tastaturreihen wie bei der Rufzeichen-Eingabe (siehe
// aircraft_watchlist_screen.cpp::runCallsignKeypad), aber mit Leertaste
// (Ortsnamen enthalten oft Leerzeichen, z.B. "Bei Oma") und OHNE
// Eingabepflicht - der Name ist optional, "Ohne Namen" bestaetigt mit
// leerem Puffer statt die Eingabe abzubrechen.
// Four keyboard rows like for callsign input (see
// aircraft_watchlist_screen.cpp::runCallsignKeypad), but with space bar
// (location names often contain spaces, e.g. "Bei Oma") and WITHOUT
// mandatory input - the name is optional, "Without name" confirms with
// empty buffer instead of cancelling input.
String runPresetNameKeypad(TFT_eSPI& tft) {
MenuStars::reset();
constexpr const char* DIGITS = "1234567890";
@@ -135,9 +135,9 @@ namespace {
constexpr int16_t KEY_GAP = 3;
constexpr int16_t FIELD_H = 34;
// Kopfbereich (Titel + Namens-Hinweis) einmal zeichnen, um seine
// tatsaechliche Hoehe per getCursorY() zu messen - selbes Muster wie
// in address_search_screen.cpp::runAddressKeyboard().
// Draw header (title + name hint) once to measure its
// actual height via getCursorY() - same pattern as
// in address_search_screen.cpp::runAddressKeyboard().
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
@@ -258,17 +258,16 @@ namespace {
String name = runPresetNameKeypad(tft);
if (!LocationPresets::addPreset(lat, lon, name)) return false;
// Neu angelegtes Preset sofort aktivieren, gleiches Verhalten wie
// beim Anlegen per Adresssuche (siehe address_search_screen.cpp) -
// sonst blieb z.B. der automatische IP-Standort aktiv, obwohl gerade
// extra ein neuer Standort eingegeben wurde.
// Activate newly created preset immediately, same behavior as
// when creating via address search (see address_search_screen.cpp) -
// otherwise the automatic IP location would remain active even though
// a new location was just explicitly entered.
LocationPresets::setActiveIndex((int8_t)(LocationPresets::count() - 1));
return true;
}
// Erster Schritt beim Antippen von "+": manuelle Koordinaten oder
// Adresssuche (AddressSearchScreen, kuemmert sich dort bereits selbst
// um Namensvergabe + Speichern als Preset).
// First step when tapping "+": manual coordinates or
// address search (AddressSearchScreen handles naming + saving as preset itself).
bool addPresetFlow(TFT_eSPI& tft) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
@@ -293,8 +292,8 @@ namespace {
}
}
// Kurze Meldung unten am Bildschirmrand, z.B. wenn beim Antippen des
// Naechster-Flughafen-Textes bereits alle 3 Preset-Slots belegt sind.
// Brief message at the bottom of the screen, e.g. when tapping the
// nearest airport text but all 3 preset slots are already occupied.
void showBriefMessage(TFT_eSPI& tft, const String& msg, uint16_t color) {
tft.fillRect(0, Config::SCREEN_HEIGHT - 18, Config::SCREEN_WIDTH, 18, TFT_BLACK);
tft.setTextColor(color, TFT_BLACK);
@@ -334,50 +333,49 @@ namespace {
return y;
}
// Laufschrift fuer den "Naechster Flughafen"-Text: statt ihn mit "..."
// abzuschneiden, scrollt er horizontal durch, falls er nicht in die
// verfuegbare Breite passt. BEWUSST OHNE tft.setViewport() (das hatte
// im Zusammenspiel mit unserem eigenen Font dazu gefuehrt, dass der Text
// komplett unsichtbar wurde) - stattdessen wird bei jedem Schritt einfach
// der Text-Ausschnitt neu berechnet, der garantiert in die Breite passt.
// Marquee text for the "Nearest Airport" text: instead of truncating with "..."
// it scrolls horizontally if it does not fit the available width.
// DELIBERATELY WITHOUT tft.setViewport() (which had caused the text to become
// completely invisible when used with our custom font) - instead, at each step
// the text portion that is guaranteed to fit the width is simply recalculated.
struct Marquee {
String text;
String ring; // text + Luecke, doppelt aneinandergehaengt
String ring; // text + gap, doubled end-to-end
bool needsScroll = false;
int32_t charOffset = 0;
uint32_t lastStepMs = 0;
};
Marquee airportMarquee;
// Etwas kuerzeres Intervall als frueher fuer einen ruhigeren Lauf.
// WICHTIG: der Cursor wird beim Zeichnen IMMER exakt auf x gesetzt
// (nie davor/danach verschoben) - ein Versuch mit pixelgenauer
// Sub-Zeichen-Verschiebung (Cursor links von x) hat zu einem
// fehlerhaft gezeichneten Zeichen ausserhalb des geloeschten Bereichs
// gefuehrt (sichtbar als gruener Kasten links vom Text).
constexpr uint32_t MARQUEE_STEP_MS = 200; // alle 200ms ein Zeichen weiter
// Slightly shorter interval than before for a smoother run.
// IMPORTANT: the cursor is ALWAYS set exactly to x when drawing
// (never shifted before/after) - an attempt with pixel-precise
// sub-character shifting (cursor left of x) led to a
// incorrectly drawn character outside the erased area
// (visible as a green box left of the text).
constexpr uint32_t MARQUEE_STEP_MS = 200; // advance one character every 200ms
// Einmal aufrufen, wenn sich der anzuzeigende Text aendert (z.B. weil ein
// anderer Standort aktiviert wurde) - merkt sich Text + Ringpuffer und
// setzt den Scroll-Fortschritt zurueck.
// Call once when the displayed text changes (e.g. because a
// different location was activated) - stores text + ring buffer and
// resets the scroll progress.
void setupMarquee(TFT_eSPI& tft, const String& text, int16_t viewportW) {
// WICHTIG: textWidth() haengt von der aktuell gesetzten Textgroesse
// ab. Der Marquee wird in Size 2 gezeichnet (siehe drawMarquee),
// also muss hier ebenfalls Size 2 aktiv sein, sonst wird die
// Breite falsch (zu schmal) gemessen und der Text ragt beim
// Zeichnen ueber den verfuegbaren Platz hinaus.
// IMPORTANT: textWidth() depends on the currently set text size.
// The marquee is drawn in Size 2 (see drawMarquee),
// so Size 2 must also be active here, otherwise the
// width will be measured incorrectly (too narrow) and the text will
// protrude beyond the available space when drawn.
tft.setTextSize(2);
airportMarquee.text = text;
airportMarquee.needsScroll = tft.textWidth(text) > viewportW;
tft.setTextSize(1);
String withGap = text + " "; // 3 Leerzeichen Luecke vor der Wiederholung
String withGap = text + " "; // 3 spaces gap before the repeat
airportMarquee.ring = withGap + withGap;
airportMarquee.charOffset = 0;
airportMarquee.lastStepMs = millis();
}
// Liefert den laengsten Ausschnitt ab startIdx, der noch in maxWidth
// passt - OHNE "..." anzuhaengen (im Gegensatz zu truncateForWidth).
// Returns the longest substring from startIdx that still fits in maxWidth
// WITHOUT appending "..." (unlike truncateForWidth).
String marqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) {
String s = src.substring(startIdx);
while (s.length() > 1 && tft.textWidth(s) > maxWidth) {
@@ -386,25 +384,25 @@ namespace {
return s;
}
// Bei jedem Aufruf (auch in der Warteschleife) neu zeichnen - wenn der
// Text nicht scrollen muss, wird er einfach normal (fest) angezeigt.
// Redraw on every call (also in the wait loop) - if the
// text does not need to scroll, it is simply displayed normally (static).
void drawMarquee(TFT_eSPI& tft, int16_t x, int16_t y, int16_t w, int16_t h) {
if (airportMarquee.text.length() == 0) return;
// Der geloeschte Bereich muss zur tatsaechlichen Texthoehe bei
// Size 2 passen (~16px), nicht zur alten Size-1-Hoehe - sonst
// bleiben oben Reste vom vorherigen Frame stehen (sichtbar als
// Strich ueber dem Text). Etwas grosszuegiger nach oben (start
// bei y-20) als vorher, da ein einzelner Frame-Ausreisser sonst
// noch sichtbar blieb.
// The erased area must match the actual text height at
// Size 2 (~16px), not the old Size 1 height - otherwise
// remnants from the previous frame remain at the top (visible as
// a line above the text). Slightly more generous upward (start
// at y-20) than before, since a single frame outlier would otherwise
// remain visible.
constexpr int16_t MARQUEE_CLEAR_TOP = 20;
constexpr int16_t MARQUEE_CLEAR_H = 26;
tft.fillRect(x, y - MARQUEE_CLEAR_TOP, w, MARQUEE_CLEAR_H, TFT_BLACK);
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
// Groessere Schrift fuer den Nearest-Airport-Marquee (auf Wunsch
// vergroessert von Size 1 auf Size 2) - nach dem Zeichnen wieder auf
// Size 1 zurueckstellen, damit nachfolgender Code (z.B. der
// "Zurueck"-Button) nicht versehentlich auch vergroessert wird.
// Larger font for the Nearest Airport marquee (enlarged
// from Size 1 to Size 2 on request) - reset back to
// Size 1 after drawing so that subsequent code (e.g. the
// "Back" button) is not accidentally enlarged as well.
tft.setTextSize(2);
tft.setCursor(x, y);
@@ -418,8 +416,8 @@ namespace {
if (now - airportMarquee.lastStepMs >= MARQUEE_STEP_MS) {
airportMarquee.lastStepMs = now;
airportMarquee.charOffset++;
// Zurueck an den Anfang, sobald der erste (nicht doppelte)
// Text+Luecke-Block durchgelaufen ist.
// Back to the beginning once the first (non-duplicate)
// text+gap block has completed.
int32_t singleLen = (int32_t)airportMarquee.text.length() + 3;
if (airportMarquee.charOffset >= singleLen) airportMarquee.charOffset = 0;
}
@@ -428,11 +426,11 @@ namespace {
tft.setTextSize(1);
}
// Laufschrift-Zustand pro Preset-Zeile, analog zu airportMarquee oben,
// aber als Array (eine Instanz je Zeile) und LINKSBUENDIG gezeichnet
// statt zentriert - zentriertes Scrollen wuerde bei jedem Schritt
// sichtbar hin- und herspringen, da sich die Textbreite laufend
// aendert. Ersetzt die bisherige truncateForWidth()-Kuerzung mit "...".
// Marquee state per preset row, analogous to airportMarquee above,
// but as an array (one instance per row) and LEFT-ALIGNED
// instead of centered - centered scrolling would visibly jump
// back and forth at each step since the text width constantly
// changes. Replaces the previous truncateForWidth() shortening with "...".
struct RowMarquee {
String text;
String ring;
@@ -442,8 +440,8 @@ namespace {
};
RowMarquee rowMarquees[LocationPresets::MAX_PRESETS];
// Wie setupMarquee() oben, aber ohne Textgroessen-Umschaltung (die
// Preset-Zeilen nutzen durchgehend Size 1).
// Like setupMarquee() above, but without text size switching (the
// preset rows consistently use Size 1).
void setupRowMarquee(TFT_eSPI& tft, RowMarquee& m, const String& text, int16_t maxWidth) {
m.text = text;
m.needsScroll = tft.textWidth(text) > maxWidth;
@@ -453,12 +451,11 @@ namespace {
m.lastStepMs = millis();
}
// Zeichnet eine Preset-Zeile im selben Rahmen-/Fuellstil wie
// drawButton(), aber mit linksbuendiger Laufschrift statt zentriertem,
// ggf. abgeschnittenem Text. Wird sowohl beim ersten Bildschirmaufbau
// als auch (nur fuer Zeilen mit needsScroll) bei jedem Tick in der
// Warteschleife erneut aufgerufen, um den naechsten Scroll-Schritt zu
// zeichnen.
// Draws a preset row in the same frame/fill style as
// drawButton(), but with left-aligned marquee text instead of centered,
// potentially truncated text. Called both on initial screen build
// and (only for rows with needsScroll) on each tick in the
// wait loop to draw the next scroll step.
void drawRowMarquee(TFT_eSPI& tft, const Rect& r, RowMarquee& m, bool active) {
uint16_t bg = active ? TFT_GREEN : TFT_BLACK;
uint16_t fg = active ? TFT_BLACK : TFT_GREEN;
@@ -611,19 +608,19 @@ void run(TFT_eSPI& tft) {
String presetName = LocationPresets::getName(i);
String label;
if (presetName.length() > 0) {
// Benannte Presets (manuell vergeben oder vom
// Flughafen-Antippen uebernommen) zeigen den Namen statt
// der Koordinaten - besser lesbar in der Uebersicht.
// Named presets (manually assigned or taken over from
// tapping an airport) show the name instead of
// the coordinates - more readable in the overview.
label = (active == (int8_t)i ? "> " : "") + presetName;
} else {
char coords[24];
snprintf(coords, sizeof(coords), "%d: %.2f, %.2f", i + 1, lat, lon);
label = (active == (int8_t)i ? "> " : "") + presetWord + " " + coords;
}
// Laufschrift statt "..."-Kuerzung fuer lange Preset-Namen
// bzw. Koordinaten, die nicht in die Zeilenbreite passen
// (siehe RowMarquee oben) - kurze Labels bleiben unveraendert
// zentriert wie zuvor.
// Marquee instead of "..." truncation for long preset names
// or coordinates that do not fit the row width
// (see RowMarquee above) - short labels remain unchanged
// centered as before.
if (tft.textWidth(label) <= rowRect.w - 10) {
rowMarquees[i].needsScroll = false;
drawButton(tft, rowRect, label, active == (int8_t)i);
@@ -653,10 +650,10 @@ void run(TFT_eSPI& tft) {
constexpr int16_t AIRPORT_LINE_X = 10;
constexpr int16_t AIRPORT_LINE_W = Config::SCREEN_WIDTH - 20;
int16_t airportLineY = (int16_t)(Config::SCREEN_HEIGHT - 60);
// Deckt denselben Bereich ab, den drawMarquee() bei jedem Frame
// loescht (siehe MARQUEE_CLEAR_TOP/-H) - dient sowohl als Tap-Zone
// als auch als sichtbarer Rahmen, der anzeigt, dass man hier
// antippen kann, um den Flughafen als neuen Preset zu uebernehmen.
// Covers the same area that drawMarquee() clears on every frame
// (see MARQUEE_CLEAR_TOP/-H) - serves both as a tap zone
// and as a visible frame indicating that you can
// tap here to adopt the airport as a new preset.
Rect airportRect = {(int16_t)(AIRPORT_LINE_X - 4), (int16_t)(airportLineY - 20),
(int16_t)(AIRPORT_LINE_W + 8), 26};
AirportLookup::Nearest nearest;
@@ -669,8 +666,8 @@ void run(TFT_eSPI& tft) {
}
nearest = AirportLookup::findNearest(activeLat, activeLon);
if (nearest.found) {
// Respektiert jetzt die Einheiten-Einstellung (Menue >
// Einheiten) - vorher immer "(XX km)", auch bei Imperial.
// Now respects the units setting (Menu >
// Units) - previously always "(XX km)", even in imperial.
char buf[48];
if (LocationManager::useMetricUnits()) {
snprintf(buf, sizeof(buf), "%s %s (%.0f km)", nearest.icao, nearest.name, nearest.distanceKm);
@@ -726,8 +723,8 @@ void run(TFT_eSPI& tft) {
if (!handled && nearest.found && airportRect.contains(tap.x, tap.y)) {
if (canAdd) {
if (LocationPresets::addPreset(nearest.lat, nearest.lon, nearest.name)) {
// Gleiches Verhalten wie bei den anderen beiden Wegen,
// ein Preset anzulegen - sofort aktivieren.
// Same behavior as with the other two ways
// of creating a preset - activate immediately.
LocationPresets::setActiveIndex((int8_t)(LocationPresets::count() - 1));
}
} else {

View File

@@ -29,8 +29,8 @@ namespace {
constexpr uint8_t MAX_DAYS_QUERIED = 31;
constexpr uint8_t VISIBLE_ROWS = 10;
// Erste Inhaltszeile eine Zeile tiefer als frueher (30) ansetzen, damit
// sie nicht mit dem "?"-Info-Button oben rechts (y=2..26) kollidiert.
// Start the first content line one line lower than before (30), so that
// it does not collide with the "?" info button at top right (y=2..26).
constexpr int16_t ROWS_START_Y = 50;
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
@@ -197,10 +197,10 @@ void run(TFT_eSPI& tft) {
tft.setCursor(110, y);
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX));
// Jede Datei einzeln loeschbar (selber Stil wie das rote 'X'
// bei den WLAN-Netzwerken) - keine Bestaetigung noetig, das
// globale "Flugbuch zuruecksetzen" im Statistik-Screen
// bleibt fuer den Alles-loeschen-Fall.
// Each file individually deletable (same style as the red 'X'
// for WiFi networks) - no confirmation needed, the
// global "Reset flight logbook" in the statistics screen
// remains for deleting everything.
Rect delBtn = {(int16_t)(Config::SCREEN_WIDTH - 34), (int16_t)(y - 15), 30, 18};
drawButton(tft, delBtn, "X", true);
deleteRects[deleteCount++] = delBtn;

View File

@@ -3,7 +3,7 @@
#include <TFT_eSPI.h>
namespace LogbookFilesScreen {
// Blockierend: listet die Logbuch-Dateien (ein Tag pro Zeile, mit Anzahl
// Eintraegen) auf. Kehrt zurueck, sobald "Back" angetippt wird.
// Blocking: lists the logbook files (one day per line, with number
// of entries). Returns as soon as "Back" is tapped.
void run(TFT_eSPI& tft);
}

View File

@@ -61,26 +61,26 @@ uint32_t lastInteractionMs = 0;
bool screenDimmed = false;
bool nightDimActive = false;
// Ruhebildschirm (Menue > System > Ruhebildschirm, siehe SettingsStore::
// screensaverEnabled()) - true, waehrend statt des komplett dunklen
// Backlights ein gedimmter Sternenhimmel mit Uhrzeit angezeigt wird.
// Getrennt von screenDimmed, da NICHT jeder Timeout automatisch ein
// Ruhebildschirm ist (Default bleibt: Backlight komplett aus) - siehe
// loop() weiter unten.
// Screensaver (Menu > System > Screensaver, see SettingsStore::
// screensaverEnabled()) - true while a dimmed starry sky with clock is shown
// instead of the completely dark backlight.
// Separate from screenDimmed, since NOT every timeout automatically becomes a
// screensaver (default remains: backlight completely off) - see
// loop() below.
bool screensaverShowing = false;
uint32_t lastScreensaverClockMs = 0;
// Wandelt die Nutzer-Helligkeit (Menue > System > Helligkeit, 10-100%) in
// einen PWM-Wert 0-255 um - ersetzt das bisher fest verdrahtete
// BACKLIGHT_FULL als "normale" Helligkeit ueberall unten.
// Converts user brightness (Menu > System > Brightness, 10-100%) into
// a PWM value 0-255 - replaces the previously hardwired
// BACKLIGHT_FULL as the "normal" brightness everywhere below.
uint8_t normalBacklightPwm() {
return (uint8_t)((uint16_t)SettingsStore::brightnessPercent() * 255 / 100);
}
// Nachtmodus-Helligkeit RELATIV zur normalen Helligkeit (siehe
// Config::NIGHT_DIM_REDUCTION_PERCENT) statt eines festen Absolutwerts -
// so bleibt der Dimm-Effekt bei JEDER eingestellten Normalhelligkeit
// spuerbar, auch bei schon niedrig eingestellter Helligkeit.
// Night mode brightness RELATIVE to normal brightness (see
// Config::NIGHT_DIM_REDUCTION_PERCENT) instead of a fixed absolute value -
// so the dimming effect remains noticeable at EVERY set normal brightness,
// even when already set low.
uint8_t nightDimBacklightPwm() {
uint32_t normal = normalBacklightPwm();
return (uint8_t)(normal * (100 - Config::NIGHT_DIM_REDUCTION_PERCENT) / 100);
@@ -95,9 +95,9 @@ struct Rect {
Rect menuBtn = {Config::SCREEN_WIDTH - 90, 3, 54, 22};
// Platz, an dem frueher der Cam-Button war (siehe entfernte Screenshot-
// Funktion) - zeigt jetzt stattdessen ein kleines Wetter-Icon fuer den
// aktuell aktiven Standort (siehe weather.cpp/Weather::update()).
// Space where the camera button used to be (see removed screenshot
// function) - now shows a small weather icon for the
// currently active location (see weather.cpp/Weather::update()).
Rect weatherIconRect = {(int16_t)(menuBtn.x - 46), 3, 42, 22};
void drawMenuButton() {
@@ -109,8 +109,8 @@ void drawMenuButton() {
tft.setTextDatum(TL_DATUM);
}
// Kleine, mit einfachen TFT_eSPI-Grundformen gezeichnete Wolke (kein
// Bild/Font noetig) - Basis fuer die Regen/Schnee/Gewitter-Varianten.
// Small cloud drawn with simple TFT_eSPI basic shapes (no
// image/font needed) - base for the rain/snow/thunderstorm variants.
void drawCloudShape(int16_t cx, int16_t cy, uint16_t color) {
tft.fillCircle(cx - 5, cy + 1, 3, color);
tft.fillCircle(cx - 1, cy - 2, 4, color);
@@ -118,7 +118,7 @@ void drawCloudShape(int16_t cx, int16_t cy, uint16_t color) {
tft.fillRect(cx - 8, cy, 13, 3, color);
}
// Sonne mit ein paar Strahlen ringsum, ebenfalls nur aus Grundformen.
// Sun with a few rays around, also only from basic shapes.
void drawSunShape(int16_t cx, int16_t cy, int16_t r, uint16_t color) {
tft.fillCircle(cx, cy, r, color);
for (uint8_t i = 0; i < 8; i++) {
@@ -131,10 +131,10 @@ void drawSunShape(int16_t cx, int16_t cy, int16_t r, uint16_t color) {
}
}
// Zeichnet das Wetter-Icon passend zur zuletzt abgefragten Wetterlage
// (Weather::current()) - bei Weather::Condition::Unknown (noch keine
// erfolgreiche Abfrage, z.B. kurz nach dem Booten) bleibt die Flaeche
// einfach leer, statt einen Platzhalter anzuzeigen.
// Draws the weather icon matching the last queried weather condition
// (Weather::current()) - if Weather::Condition::Unknown (no successful
// query yet, e.g. shortly after boot) the area stays
// empty instead of showing a placeholder.
void drawWeatherIcon() {
int16_t cx = weatherIconRect.x + weatherIconRect.w / 2;
int16_t cy = weatherIconRect.y + weatherIconRect.h / 2;
@@ -176,11 +176,11 @@ void drawWeatherIcon() {
}
}
// Einfacher Zeilenumbruch fuer Fliesstext, analog zu layoutWrapped() in
// menu_screen.cpp/webui_screen.cpp (siehe "keine geteilten Module"-Konvention
// - jede Screen-Datei hat ihre eigene kleine Kopie). Ohne Scroll-Unterstuetzung,
// da der Infotext des Wetter-Popups kurz genug ist, um immer komplett in die
// verfuegbare Boxhoehe zu passen.
// Simple word wrapping for body text, analogous to layoutWrapped() in
// menu_screen.cpp/webui_screen.cpp (see "no shared module" convention
// - each screen file has its own small copy). No scroll support,
// since the weather popup info text is short enough to always fit completely in the
// available box height.
int16_t weatherInfoLayoutWrapped(TFT_eSPI& tftRef, int16_t x, int16_t startY,
int16_t maxWidth, int16_t lineHeight, const String& text) {
int16_t y = startY;
@@ -214,12 +214,12 @@ void weatherInfoDrawButton(TFT_eSPI& tftRef, const Rect& r, const String& label)
tftRef.setTextDatum(TL_DATUM);
}
// Kleines Info-Fenster beim Antippen des Wetter-Icons im Header - erklaert,
// dass das angezeigte Wetter immer zum aktuell aktiven Standort (bzw.
// aktivem Standort-Preset, siehe LocationManager::getHomeLocation())
// gehoert. Gleicher geboxter Overlay-Stil wie confirmLogbookEnable() in
// menu_screen.cpp, hier aber ohne Warnfarbe und nur mit einem
// Zurueck-Button, da rein informativ (keine Bestaetigung noetig).
// Small info window when tapping the weather icon in the header - explains
// that the displayed weather always belongs to the currently active location (or
// active location preset, see LocationManager::getHomeLocation()).
// Same boxed overlay style as confirmLogbookEnable() in
// menu_screen.cpp, but here without warning color and only a
// Back button, since purely informational (no confirmation needed).
void showWeatherInfo(TFT_eSPI& tftRef) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
@@ -228,7 +228,7 @@ void showWeatherInfo(TFT_eSPI& tftRef) {
constexpr int16_t TEXT_MAX_WIDTH = BOX_W - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
// Eine Leerzeile Abstand zwischen Titel und Fliesstext.
// One blank line gap between title and body text.
constexpr int16_t VIEW_TOP = TITLE_Y + 12 + LINE_H;
constexpr int16_t BTN_H = 36;
@@ -292,13 +292,13 @@ void drawWifiIcon(int16_t rightX, int16_t rowTop, int16_t rowH, int8_t rssi) {
}
}
// Grosse, zentrierte Uhrzeit fuer den Ruhebildschirm (siehe
// screensaverShowing oben) - eigene, groessere Variante der kleinen
// Kopfzeilen-Uhr aus updateStatusLine(), da diese bewusst kompakt gehalten
// ist. Loescht bei jedem Aufruf nur den eigenen schmalen Streifen in der
// Bildschirmmitte (nicht den ganzen Bildschirm), damit die Sternenanimation
// darum herum ungestoert weiterlaeuft. Zeichnet nichts, solange die Uhrzeit
// noch nicht per NTP synchronisiert ist (gleiche Pruefung wie
// Large, centered clock for the screensaver (see
// screensaverShowing above) - own larger variant of the small
// header clock from updateStatusLine(), since that one is deliberately kept compact.
// Only clears its own narrow strip in the
// middle of the screen (not the whole screen) so the star animation
// around it continues undisturbed. Draws nothing as long as the time
// has not been synchronized via NTP yet (same check as
// updateStatusLine()/isNightDimHours()).
void drawScreensaverClock() {
time_t now = time(nullptr);
@@ -306,15 +306,8 @@ void drawScreensaverClock() {
struct tm tmNow;
localtime_r(&now, &tmNow);
char timeBuf[9];
if (LocationManager::useMetricUnits()) {
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tmNow.tm_hour, tmNow.tm_min);
} else {
int hour12 = tmNow.tm_hour % 12;
if (hour12 == 0) hour12 = 12;
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d%s", hour12, tmNow.tm_min,
tmNow.tm_hour < 12 ? "AM" : "PM");
}
char timeBuf[9];
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tmNow.tm_hour, tmNow.tm_min);
constexpr int16_t CLOCK_BAND_H = 40;
int16_t cy = Config::SCREEN_HEIGHT / 2;
@@ -354,23 +347,23 @@ void drawHeader() {
void updateStatusLine() {
if (wasEmergency) return;
// Die Uhrzeit nutzt den global gesetzten 11pt-Font (setFreeFont() in
// setup()) ueber setCursor()+print() - bei GFXFF-Fonts ist das
// baseline-verankert, der Text waechst also nach OBEN (siehe
// CLAUDE.md-Hinweis zu diesem Pitfall). Die Ziffern-Glyphen sind laut
// Font-Metrik 8px hoch und reichen damit bis y=HEADER_TITLE_H-6 - also
// OBERHALB des schmalen STATUS_LINE_H-Bereichs, der hier bisher allein
// geloescht wurde. Dadurch blieben alte Ziffern-Reste stehen und
// ueberlagerten sich mit den neuen (am sichtbarsten bei der letzten
// Minutenziffer, die sich am haeufigsten aendert). Deshalb hier gezielt
// NUR unter der Uhrzeit einen hoeheren Bereich loeschen - nicht die
// ganze Zeile, sonst wuerde das jede Sekunde in den Menu-Button
// hineinschneiden, der bis y=25 reicht.
// Auf 80px verbreitert (vorher 50) - das 12h-Format mit AM/PM (siehe
// unten) ist mit bis zu 7 Zeichen ("12:59PM") laenger als das feste
// 5-Zeichen-24h-Format ("23:12") und wurde sonst nicht vollstaendig
// geloescht (Ziffernreste blieben stehen). Der Bereich rechts daneben
// war hier ohnehin leer, daher unkritisch.
// The clock uses the globally set 11pt font (setFreeFont() in
// setup()) via setCursor()+print() - with GFXFF fonts this is
// baseline-anchored, so the text grows UPWARD (see
// CLAUDE.md note about this pitfall). The digit glyphs are
// 8px high according to font metrics, thus reaching up to y=HEADER_TITLE_H-6 - i.e.
// ABOVE the narrow STATUS_LINE_H area that was previously cleared alone.
// This left old digit remnants standing and
// overlapped with the new ones (most visible on the last
// minute digit, which changes most frequently). Therefore, selectively
// clear a taller area ONLY under the clock - not the
// whole line, otherwise it would cut into the Menu button
// every second, which extends to y=25.
// Widened to 80px (was 50) - the 12h format with AM/PM (see
// below) is up to 7 characters ("12:59PM") longer than the fixed
// 5-character 24h format ("23:12") and would otherwise not be completely
// cleared (digit remnants remained). The area to the right
// was already empty here, so uncritical.
constexpr int16_t CLOCK_CLEAR_W = 80;
constexpr int16_t CLOCK_CLEAR_TOP = HEADER_TITLE_H - 10;
tft.fillRect(0, CLOCK_CLEAR_TOP, CLOCK_CLEAR_W, CONTENT_TOP - CLOCK_CLEAR_TOP, TFT_BLACK);
@@ -382,9 +375,9 @@ void updateStatusLine() {
struct tm tmNow;
localtime_r(&now, &tmNow);
char timeBuf[9];
// 12h mit AM/PM bei Imperial (in den USA ueblich), 24h bei
// Metrisch - dieselbe Einheiten-Einstellung (Menue > Einheiten),
// die sonst Distanz/Hoehe steuert. Vorher immer fest 24h.
// 12h with AM/PM in Imperial (common in the US), 24h in
// Metric - same unit setting (Menu > Units)
// that otherwise controls distance/altitude. Previously always fixed 24h.
if (LocationManager::useMetricUnits()) {
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tmNow.tm_hour, tmNow.tm_min);
} else {
@@ -399,9 +392,9 @@ void updateStatusLine() {
updateWifiIcon();
// Icon nur bei tatsaechlicher Aenderung neu zeichnen (Weather::update()
// laeuft im Hintergrund auf Core 0 und aktualisiert typischerweise nur
// alle paar Minuten) - vermeidet unnoetiges Neuzeichnen jede Sekunde.
// Only redraw the icon when the condition actually changes (Weather::update()
// runs in the background on Core 0 and typically only updates
// every few minutes) - avoids unnecessary redrawing every second.
Weather::Condition weatherNow = Weather::current();
if (weatherNow != lastRenderedWeather) {
lastRenderedWeather = weatherNow;
@@ -409,14 +402,14 @@ void updateStatusLine() {
}
}
// Prueft, ob gerade Nacht ist (fuer die Nachtdimmung). Nutzt den echten
// Sonnenauf-/untergang am aktiven Standort (siehe sun_times.h), NICHT mehr
// ein festes 22:00-06:00-Fenster - im Sommer war das vorher oft noch hell
// draussen, wenn schon gedimmt wurde, und im Winter blieb es nach 6 Uhr noch
// lange dunkel, ohne dass gedimmt wurde. Solange Standort oder Uhrzeit noch
// nicht bekannt sind (z.B. kurz nach dem Start, bevor NTP/GPS/IP-Geolocation
// fertig sind), faellt die Funktion auf das alte feste Fenster zurueck,
// damit die Nachtdimmung nicht komplett ausfaellt.
// Checks whether it is currently night (for night dimming). Uses the real
// sunrise/sunset at the active location (see sun_times.h), NOT anymore
// a fixed 22:00-06:00 window - in summer it was often still light
// outside when dimming already kicked in, and in winter it stayed
// dark long after 6am without dimming being active. As long as location or time are
// not yet known (e.g. shortly after startup, before NTP/GPS/IP-geolocation
// are ready), the function falls back to the old fixed window,
// so that night dimming does not fail completely.
bool isNightDimHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false;
@@ -437,15 +430,15 @@ bool isNightDimHours() {
}
}
// Fallback: Standort noch unbekannt.
// Fallback: location not yet known.
int hour = tmNow.tm_hour;
return (hour >= 22 || hour < 6);
}
// Sanfte Nachtdimmung des Backlights zwischen Sonnenuntergang und
// Sonnenaufgang (siehe isNightDimHours()), sofern in den Einstellungen
// aktiviert. Der Inaktivitaets-Timeout (screenDimmed) hat Vorrang und wird
// hier nicht ueberschrieben.
// Gentle night dimming of the backlight between sunset and
// sunrise (see isNightDimHours()), if enabled in
// settings. The inactivity timeout (screenDimmed) takes priority and is
// not overridden here.
void updateNightDimming() {
if (screenDimmed) return;
@@ -538,10 +531,10 @@ void setup() {
SettingsStore::load();
tft.invertDisplay(SettingsStore::displayInverted());
// Der erste ledcWrite() oben (vor SettingsStore::load()) kannte die
// gespeicherte Helligkeit noch nicht und hat den Default (100%)
// angewendet - hier mit dem jetzt geladenen Wert korrigieren, gleiches
// Nachziehen wie bei invertDisplay() direkt drueber.
// The first ledcWrite() above (before SettingsStore::load()) did not yet know the
// saved brightness and used the default (100%)
// - correct it here with the now loaded value, same
// follow-up as with invertDisplay() directly above.
ledcWrite(BACKLIGHT_PWM_CHANNEL, normalBacklightPwm());
WifiMgr::init();
@@ -550,19 +543,18 @@ void setup() {
AircraftWatchlist::init();
SplashScreen::begin(tft);
// Sofort einen ersten Sternen-Frame zeichnen, statt erst auf die naechste
// Warteschleife (WLAN/Standort) zu warten - sonst blieb der Sternenhimmel
// bei schnellem Boot (gespeichertes WLAN, schnelle Standortermittlung)
// fast die ganze Splash-Anzeige ueber unsichtbar und "poppte" erst kurz
// vor dem Radarscreen auf.
// Draw a first star frame immediately, instead of waiting for the next
// wait loop (WiFi/location) - otherwise the starry sky remained
// almost invisible during the entire splash display on fast boot (saved WiFi, quick location lookup)
// and only "popped up" shortly before the radar screen.
MenuStars::update(tft);
SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
// Willkommens-Screen laeuft nur beim allerersten Start (isFirstRun) und
// bewusst VOR der Touch-Kalibrierung - der erste Eindruck, bevor man
// ueberhaupt zum Kalibrieren aufgefordert wird. Englisch hart kodiert,
// da die Sprachauswahl (FirstRunLanguageScreen) erst danach kommt,
// siehe first_run_welcome_screen.cpp.
// Welcome screen only runs on the very first start (isFirstRun) and
// deliberately BEFORE touch calibration - first impression before
// even being prompted to calibrate. Hardcoded in English,
// since the language selection (FirstRunLanguageScreen) only comes after,
// see first_run_welcome_screen.cpp.
if (isFirstRun) {
// Creating the folder structure, seeding default data files, AND
// showing the loading indicator during those (noticeably slow) SD
@@ -599,14 +591,14 @@ void setup() {
if (isFirstRun) {
FirstRunLanguageScreen::run(tft);
// Erst NACH der Sprachauswahl (Texte erscheinen dann gleich in der
// richtigen Sprache) und NACH dem WLAN-Verbindungsversuch weiter
// oben (die Adresssuche braucht eine Internetverbindung) -
// ueberspringbar, siehe first_run_location_screen.cpp.
// Only AFTER language selection (texts then appear in the
// correct language immediately) and AFTER the WiFi connection attempt
// above (address search needs an internet connection) -
// skippable, see first_run_location_screen.cpp.
FirstRunLocationScreen::run(tft);
// Abschluss-Screen: bestaetigt das Ende der Ersteinrichtung, nennt
// den SD-Ordner mit allen gespeicherten Daten und weist auf den
// automatischen WLAN-Verbindungsaufbau ab dem naechsten Start hin.
// Completion screen: confirms the end of initial setup, names
// the SD folder with all saved data and points to the
// automatic WiFi connection from the next start onward.
FirstRunCompleteScreen::run(tft);
SplashScreen::begin(tft);
MenuStars::update(tft);
@@ -618,13 +610,13 @@ void setup() {
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_GETTING_LOCATION));
LocationManager::init();
uint32_t locStart = millis();
// Bewusst NICHT nur warten, solange noch GAR KEINE Position bekannt ist:
// ab dem zweiten Boot ist meistens schon eine gespeicherte Position da
// (Source::Persisted, siehe LocationManager::init()), wodurch diese
// Schleife sofort uebersprungen wurde und requestIpLookupIfNeeded() nie
// wieder lief. Genau die liefert aber auch die UTC-Zeitzonenverschiebung
// (inkl. Sommer-/Winterzeit) - ohne sie blieb die Uhr dauerhaft auf UTC
// stehen (in Mitteleuropa je nach Jahreszeit 1-2h falsch).
// Deliberately NOT only waiting while NO position is known at all:
// from the second boot onward there is usually already a saved position
// (Source::Persisted, see LocationManager::init()), which would cause this
// loop to be skipped immediately and requestIpLookupIfNeeded() to never
// run again. But requestIpLookupIfNeeded() also provides the UTC timezone offset
// (including DST) - without it the clock would permanently stay on UTC
// (in Central Europe 1-2h off depending on the season).
while ((LocationManager::currentSource() == LocationManager::Source::None ||
!LocationManager::hasUtcOffset()) &&
millis() - locStart < 8000) {
@@ -634,9 +626,9 @@ void setup() {
}
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
// Sobald die IP-Geolocation einen UTC-Offset geliefert hat, die
// Zeitzone entsprechend setzen - Zeitstempel (Flugbuch, Log-Dateinamen)
// zeigen dann die ECHTE Ortszeit statt UTC, automatisch weltweit richtig.
// Once IP geolocation has provided a UTC offset, set the
// timezone accordingly - timestamps (flight log, log file names)
// then show the REAL local time instead of UTC, automatically correct worldwide.
if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
}
@@ -697,14 +689,14 @@ if (menuBtn.contains(tap.x, tap.y)) {
}
}
// Waehrend der Ruhebildschirm aktiv ist (screensaverShowing), duerfen
// Radar-Rendering/Sweep/Statuszeile den Bildschirm NICHT mehr
// ueberschreiben - sonst wuerde der naechste Aircraft-Update-Zyklus
// (oder der Sweep-Tick) den gedimmten Sternenhimmel jederzeit wieder mit
// dem vollen Radarbild uebermalen, waehrend gleichzeitig
// drawScreensaverClock() im Sekundentakt seinen Streifen drueberzeichnet
// - das Ergebnis war sichtbares Geflacker zwischen Radarbild und Uhr
// statt eines ruhigen Sternenhimmels.
// While the screensaver is active (screensaverShowing), radar
// rendering/sweep/status line must NOT overwrite the display anymore
// - otherwise the next aircraft update cycle
// (or the sweep tick) would paint over the dimmed starry sky at any time with
// the full radar image, while at the same time
// drawScreensaverClock() draws its strip over it every second
// - the result was visible flickering between radar image and clock
// instead of a calm starry sky.
if (!screensaverShowing) {
if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) {
lastPollMs = millis();

View File

@@ -35,18 +35,17 @@ namespace {
}
};
// ROW_GAP/ROW_START_Y bleiben unveraendert (werden von flightRowRect()
// weiter unten mitbenutzt, siehe FLIGHT_ROW_H).
// ROW_GAP/ROW_START_Y remain unchanged (also used by flightRowRect()
// below, see FLIGHT_ROW_H).
constexpr int16_t ROW_GAP = 1;
constexpr int16_t ROW_START_Y = 18;
// Region-Unterseite (Sprache/Einheiten/Zurueck, nur 3 Eintraege):
// Zeilenhoehe/-abstand werden aus der tatsaechlich verfuegbaren
// Bildschirmflaeche errechnet (gleiches Muster wie bei SYSTEM_ROW_H
// weiter unten), statt die kleine, fuer volle Seiten (z.B.
// Flugoptionen: 14 Eintraege) gedachte feste Hoehe (vorher 22px) zu
// benutzen - die liess bei nur 3 Eintraegen fast den ganzen Bildschirm
// leer und machte die Buttons winzig und schwer zu treffen.
// Region sub-page (Language/Units/Back, only 3 entries): row
// height/spacing calculated from actual available screen area (same
// pattern as SYSTEM_ROW_H below), instead of the small fixed height
// (formerly 22px) intended for full pages (e.g. Flight Options: 14
// entries) - that left nearly the whole screen empty with only 3
// entries and made buttons tiny and hard to hit.
constexpr uint8_t REGION_ROW_COUNT = 3;
constexpr int16_t REGION_ROW_GAP = 10;
constexpr int16_t REGION_END_Y = Config::SCREEN_HEIGHT - 10;
@@ -67,33 +66,31 @@ namespace {
(int16_t)(Config::SCREEN_WIDTH - 20), CAT_ROW_H};
}
// Eigene, etwas kompaktere Zeilenhoehe NUR fuer die Flugoptionen-Seite:
// die normale rowRect()-Hoehe (22px) liess bei 13 Eintraegen schon keinen
// Platz mehr fuer einen weiteren - andere Seiten (Region/System) bleiben
// bei der normalen Hoehe unveraendert, um dort nichts zu verschieben.
// Custom, slightly more compact row height ONLY for the Flight Options
// page: the normal rowRect() height (22px) already left no room for
// another entry with 13 entries - other pages (Region/System) keep
// their normal height unchanged to avoid shifting anything there.
constexpr int16_t FLIGHT_ROW_H = 19;
Rect flightRowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (FLIGHT_ROW_H + ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), FLIGHT_ROW_H};
}
// Eigene, groesser berechnete Zeilenhoehe NUR fuer die System-Seite: mit
// nur 10 Eintraegen (vs. z.B. 14 auf der Flugoptionen-Seite) liess die
// normale rowRect()-Hoehe (22px) unten viel ungenutzten Platz frei. Die
// Hoehe wird hier stattdessen aus der tatsaechlich verfuegbaren
// Bildschirmflaeche errechnet, sodass die Buttons den Platz bis knapp
// ueber den Bildschirmrand ausfuellen - andere Seiten bleiben bei ihren
// eigenen, unveraenderten Zeilenhoehen.
// War 10, bevor "Sicherung"/"Wiederherstellen" in die neue
// "Sicherung & Reset"-Unterseite ausgelagert wurden (siehe
// BACKUP_RESET_ROW_COUNT unten) und dort durch einen einzelnen
// Ordner-Button ersetzt wurden: -2 (Backup/Restore raus) +1 (neuer
// Ordner-Button) = 9. Danach +1 fuer den neuen "Nach Update
// suchen"-Punkt (OTA-Update, siehe ota_update.h) = 10. Der
// Ruhebildschirm-Umschalter, der zwischenzeitlich hier eine eigene
// Zeile hatte, ist in den neuen Bildschirm-Timeout-Screen umgezogen
// (siehe timeout_screen.cpp) - Zeilenzahl bleibt dadurch bei 10.
constexpr uint8_t SYSTEM_ROW_COUNT = 11; // +1 fuer Eco-Mode
// Custom, larger calculated row height ONLY for the System page: with
// only 10 entries (vs. e.g. 14 on the Flight Options page), the
// normal rowRect() height (22px) left a lot of unused space at the
// bottom. Height is instead calculated from actual available screen
// area so buttons fill the space down to just above the screen
// edge - other pages keep their own unchanged row heights.
// Was 10 before "Backup"/"Restore" were moved to the new
// "Backup & Reset" sub-page (see BACKUP_RESET_ROW_COUNT below) and
// replaced there by a single folder button: -2 (Backup/Restore
// removed) +1 (new folder button) = 9. Then +1 for the new
// "Check for update" item (OTA update, see ota_update.h) = 10. The
// screensaver toggle, which temporarily had its own row here, moved
// to the new screen timeout screen (see timeout_screen.cpp) - row
// count therefore stays at 10.
constexpr uint8_t SYSTEM_ROW_COUNT = 11; // +1 for Eco-Mode
constexpr int16_t SYSTEM_ROW_GAP = 4;
constexpr int16_t SYSTEM_START_Y = 18;
constexpr int16_t SYSTEM_END_Y = Config::SCREEN_HEIGHT - 10;
@@ -104,11 +101,11 @@ namespace {
(int16_t)(Config::SCREEN_WIDTH - 20), SYSTEM_ROW_H};
}
// Eigene Zeilenhoehe fuer die neue "Sicherung & Reset"-Unterseite (4
// Eintraege: Sichern/Wiederherstellen/Zuruecksetzen/Zurueck) - gleiches
// "aus verfuegbarem Platz errechnen"-Muster wie bei SYSTEM_ROW_H.
// ROW_START_Y wird mitbenutzt (siehe oben, gemeinsam mit rowRect()/
// flightRowRect()), nur GAP/COUNT/H sind eigene Werte.
// Custom row height for the new "Backup & Reset" sub-page (4
// entries: Backup/Restore/Factory Reset/Back) - same "calculate from
// available space" pattern as SYSTEM_ROW_H.
// ROW_START_Y is reused (see above, shared with rowRect()/
// flightRowRect()), only GAP/COUNT/H are custom values.
constexpr uint8_t BACKUP_RESET_ROW_COUNT = 4;
constexpr int16_t BACKUP_RESET_ROW_GAP = 10;
constexpr int16_t BACKUP_RESET_END_Y = Config::SCREEN_HEIGHT - 10;
@@ -134,14 +131,15 @@ namespace {
String onOff(bool on) { return I18n::t(on ? StringId::ON : StringId::OFF); }
// Fortschrittspunkte-Anzeige waehrend SettingsBackup::backup()/restore()
// laufen (siehe Aufrufe unten in Page::BackupReset) - diese sind
// synchrone, SD-lastige Vorgaenge, die spuerbar dauern koennen und den
// Button vorher wie eingefroren wirken liessen. SettingsBackup ruft den
// hier uebergebenen Funktionszeiger vor jedem der beiden Kopiervorgaenge
// (erst Einstellungen, dann WLAN) auf. Namespace-globale Zeiger/Variablen
// statt Lambda-Capture, da ein einfacher C-Funktionszeiger uebergeben
// werden muss (kein std::function im Projekt).
// Progress dots display while SettingsBackup::backup()/restore()
// run (see calls below in Page::BackupReset) - these are
// synchronous, SD-intensive operations that can take noticeable
// time and previously made the button appear frozen.
// SettingsBackup calls the function pointer passed here before each
// of the two copy operations (first settings, then WiFi).
// Namespace-global pointers/variables instead of lambda capture,
// because a plain C function pointer must be passed (no
// std::function in the project).
TFT_eSPI* progressTft = nullptr;
Rect progressBtnRect;
String progressLabel;
@@ -174,11 +172,11 @@ namespace {
delay(1200);
}
// Wie die einfache layoutWrapped()-Variante, aber mit optionalem
// Scroll-Offset und Sichtfenster (scrollY/viewTop/viewBottom) - Zeilen
// ausserhalb des Sichtfensters werden uebersprungen. Mit draw=false wird
// nur die Gesamthoehe berechnet, ohne etwas zu zeichnen (fuer die
// Scroll-Bedarfspruefung vorab).
// Like the simple layoutWrapped() variant, but with optional scroll
// offset and viewport (scrollY/viewTop/viewBottom) - lines outside
// the viewport are skipped. With draw=false, only the total height
// is calculated without drawing anything (for pre-checking scroll
// need).
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text, int16_t scrollY,
int16_t viewTop, int16_t viewBottom, bool draw) {
@@ -209,10 +207,10 @@ namespace {
return y;
}
// Zerlegt einen Titel in bis zu maxLines Zeilen nach dem gleichen
// Wortumbruch-Prinzip wie layoutWrapped() - ein Titel wird aber nie
// gescrollt, sondern muss immer komplett sichtbar sein, daher eine
// eigene, einfachere Variante ohne Scroll-Unterstuetzung.
// Splits a title into up to maxLines lines using the same word-wrapping
// principle as layoutWrapped() - but a title is never scrolled and must
// always be fully visible, hence a separate, simpler variant without
// scroll support.
int wrapTitleLines(TFT_eSPI& tft, const String& text, int16_t maxWidth, String* outLines, int maxLines) {
int count = 0;
int32_t start = 0;
@@ -240,29 +238,27 @@ namespace {
return count;
}
// Warn-Ueberlage, die praktisch den kompletten Bildschirm einnimmt (nur
// ein paar Pixel Rand) - urspruenglich nur fuers Einschalten des
// Flugbuchs gebaut (erklaert, warum es sich nach 24h automatisch
// wieder abschaltet), jetzt generisch mit uebergebenen Titel-/Text-
// StringIds, damit sie auch fuer die "Einstellungen zuruecksetzen"-
// Bestaetigung (Werksreset) wiederverwendet werden kann - beides sind
// seltene, potenziell folgenreiche Aktionen, die dieselbe deutliche
// Warnung verdienen. "Achtung!!!" (titleId) steht ganz oben, mit einer
// Leerzeile Abstand zum Fliesstext (bodyId) darunter; der Text
// scrollt bei Bedarf (laengere Uebersetzungen) ueber eigene Pfeil-
// Buttons, OK/Zurueck bleiben dabei immer unten fix und
// kollisionsfrei sichtbar. Sternchen laufen im Hintergrund mit, wie auf
// allen anderen Menue-Screens (nur der Radar-Screen selbst spart sich
// das wegen der CPU-Last durch Abfragen/Zeichnen). Gibt true zurueck,
// wenn "OK" angetippt wurde, false bei "Zurueck".
// Warning overlay that covers nearly the entire screen (just a few
// pixels margin) - originally built only for enabling the flight
// logbook (explains why it auto-disables after 24h), now generic
// with passed-in title/text to also be reusable for the "Reset
// settings" confirmation (factory reset) - both are rare, potentially
// consequential actions that deserve the same clear warning.
// "Attention!!!" (titleId) appears at the top, with a blank line
// before the body text (bodyId) below; the text scrolls if needed
// (longer translations) via dedicated arrow buttons, OK/Back always
// stay fixed at the bottom and collision-free visible. Stars run in
// the background, as on all other menu screens (only the radar
// screen skips them due to CPU load from polling/drawing). Returns
// true if "OK" was tapped, false for "Back".
//
// title/body stehen VOR dem Aufruf per I18n::t() fest (statt StringIds
// entgegenzunehmen), damit auch dynamisch zusammengesetzte Texte (z.B.
// mit eingefuegter Versionsnummer bei OTA) moeglich sind. accentColor
// (Default TFT_RED fuer die beiden bestehenden, wirklich destruktiven
// Aufrufer) faerbt Rahmen und Titel - der OTA-Aufrufer uebergibt
// TFT_GREEN, da ein Update zwar bestaetigt werden sollte, aber keine
// "gefaehrliche" Loesch-Aktion wie Werksreset/Flugbuch ist.
// title/body are fixed BEFORE the call via I18n::t() (instead of
// accepting StringIds), so dynamically composed texts (e.g. with
// inserted version number for OTA) are possible. accentColor
// (default TFT_RED for the two existing, truly destructive callers)
// colors frame and title - the OTA caller passes TFT_GREEN, since an
// update should be confirmed but is not a "dangerous" deletion
// action like factory reset/flight logbook.
bool confirmWarningScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor = TFT_RED) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
@@ -272,11 +268,11 @@ namespace {
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
// Titel-Text vorab in so viele Zeilen umbrechen, wie bei Größe 2
// (oder bei zu langem Text Größe 1) nötig sind - siehe
// wrapTitleLines() oben. VIEW_TOP hängt dadurch von der
// tatsächlichen Zeilenzahl des Titels ab, ist also kein constexpr
// mehr wie vorher (wo immer nur eine Titelzeile angenommen wurde).
// Wrap title text into as many lines as needed at size 2 (or size 1
// if text is too long) - see wrapTitleLines() above. VIEW_TOP
// therefore depends on the actual number of title lines, so it is
// no longer constexpr as before (where only one title line was
// assumed).
tft.setTextSize(2);
uint8_t titleTextSize = 2;
if (tft.textWidth(title) > TEXT_MAX_WIDTH) {
@@ -286,13 +282,13 @@ namespace {
constexpr int MAX_TITLE_LINES = 3;
String titleLines[MAX_TITLE_LINES];
int titleLineCount = wrapTitleLines(tft, title, TEXT_MAX_WIDTH, titleLines, MAX_TITLE_LINES);
// Fließtext wird immer bei Größe 1 vermessen/gezeichnet (siehe
// layoutWrapped()-Aufrufe unten) - Größe hier zurücksetzen, falls
// obiger Titel-Breitentest sie auf 2 stehen gelassen hat, sonst
// würde die gleich folgende totalH-Berechnung (vor dem ersten
// redraw()) mit falscher (zu breiter) Schriftgröße rechnen.
// Body text is always measured/drawn at size 1 (see
// layoutWrapped() calls below) - reset size here in case the
// title width check above left it at 2, otherwise the
// immediately following totalH calculation (before the first
// redraw()) would use the wrong (too wide) font size.
tft.setTextSize(1);
// Eine Leerzeile Abstand zwischen Titel und Fließtext.
// One blank line between title and body text.
int16_t VIEW_TOP = (int16_t)(TITLE_Y + titleLineCount * LINE_H + 12);
constexpr int16_t BTN_H = 36;
@@ -303,9 +299,9 @@ namespace {
constexpr int16_t SCROLL_ROW_H = 28;
constexpr int16_t SCROLL_ROW_GAP = 8;
// Ohne Scroll-Pfeile verfuegbare Texthoehe zuerst pruefen - nur wenn
// der Text da nicht reinpasst, wird zusaetzlich Platz fuer die
// Pfeile reserviert (mehr Text-Platz bei kurzen Uebersetzungen).
// First check available text height without scroll arrows - only if
// the text does not fit, additional space for arrows is reserved
// (more text space for short translations).
constexpr int16_t VIEW_BOTTOM_NO_SCROLL = OK_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP;
@@ -332,13 +328,12 @@ namespace {
tft.setTextDatum(MC_DATUM);
tft.setTextColor(accentColor, TFT_BLACK);
// Statische Titel ("Achtung!!!") sind kurz genug fuer Groesse 2,
// aber der OTA-Aufrufer baut den Titel dynamisch mit
// Versionsnummer zusammen (z.B. "Update verfuegbar: v3.5.0") -
// das passt bei Groesse 2 nicht mehr in die Box und lief vorher
// links/rechts ueber den Bildschirmrand hinaus. Deshalb hier
// die Breite bei Groesse 2 pruefen und bei Bedarf auf Groesse 1
// zurueckfallen, statt eine feste Groesse anzunehmen.
// Static titles ("Attention!!!") are short enough for size 2, but
// the OTA caller builds the title dynamically with a version
// number (e.g. "Update available: v3.5.0") - this no longer
// fits in the box at size 2 and previously overflowed left/right
// beyond the screen edge. Hence check width at size 2 and fall
// back to size 1 if needed, instead of assuming a fixed size.
tft.setTextSize(titleTextSize);
for (int i = 0; i < titleLineCount; i++) {
tft.drawString(titleLines[i], BOX_X + BOX_W / 2, (int16_t)(TITLE_Y + i * LINE_H));
@@ -379,16 +374,15 @@ namespace {
}
}
// Einfacher Info-Screen mit nur EINEM Button (kein Abbrechen) - fuer
// Endzustaende, bei denen es nichts mehr zu entscheiden gibt, nur zu
// bestaetigen (z.B. Ergebnis eines OTA-Updates). Anders als
// showBriefMessage() (kurze Meldung unten am Bildschirmrand,
// verschwindet nach 1,2s automatisch von selbst) bleibt dieser Screen
// stehen, bis aktiv bestaetigt wird - wichtig bei sicherheitsrelevanten
// Meldungen wie einem fehlgeschlagenen oder erfolgreichen Firmware-
// Update, die der Nutzer auf keinen Fall verpassen darf. Gleicher
// Kasten-/Scroll-Aufbau wie confirmWarningScreen(), nur mit einem
// einzigen, ueber die volle Breite gehenden Button statt OK/Zurueck.
// Simple info screen with only ONE button (no cancel) - for
// terminal states where there is nothing left to decide, only to
// acknowledge (e.g. result of an OTA update). Unlike
// showBriefMessage() (short message at the bottom of the screen,
// auto-disappears after 1.2s), this screen stays until actively
// acknowledged - important for safety-relevant messages like a
// failed or successful firmware update that the user must not miss.
// Same box/scroll layout as confirmWarningScreen(), but with a
// single full-width button instead of OK/Back.
void infoScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor, const String& buttonLabel) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
@@ -398,11 +392,11 @@ namespace {
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
// Titel-Text vorab in so viele Zeilen umbrechen, wie bei Größe 2
// (oder bei zu langem Text Größe 1) nötig sind - siehe
// wrapTitleLines() oben. VIEW_TOP hängt dadurch von der
// tatsächlichen Zeilenzahl des Titels ab, ist also kein constexpr
// mehr wie vorher (wo immer nur eine Titelzeile angenommen wurde).
// Wrap title text into as many lines as needed at size 2 (or size 1
// if text is too long) - see wrapTitleLines() above. VIEW_TOP
// therefore depends on the actual number of title lines, so it is
// no longer constexpr as before (where only one title line was
// assumed).
tft.setTextSize(2);
uint8_t titleTextSize = 2;
if (tft.textWidth(title) > TEXT_MAX_WIDTH) {
@@ -412,13 +406,13 @@ namespace {
constexpr int MAX_TITLE_LINES = 3;
String titleLines[MAX_TITLE_LINES];
int titleLineCount = wrapTitleLines(tft, title, TEXT_MAX_WIDTH, titleLines, MAX_TITLE_LINES);
// Fließtext wird immer bei Größe 1 vermessen/gezeichnet (siehe
// layoutWrapped()-Aufrufe unten) - Größe hier zurücksetzen, falls
// obiger Titel-Breitentest sie auf 2 stehen gelassen hat, sonst
// würde die gleich folgende totalH-Berechnung (vor dem ersten
// redraw()) mit falscher (zu breiter) Schriftgröße rechnen.
// Body text is always measured/drawn at size 1 (see
// layoutWrapped() calls below) - reset size here in case the
// title width check above left it at 2, otherwise the
// immediately following totalH calculation (before the first
// redraw()) would use the wrong (too wide) font size.
tft.setTextSize(1);
// Eine Leerzeile Abstand zwischen Titel und Fließtext.
// One blank line between title and body text.
int16_t VIEW_TOP = (int16_t)(TITLE_Y + titleLineCount * LINE_H + 12);
constexpr int16_t BTN_H = 40;
@@ -490,22 +484,21 @@ namespace {
}
}
// Fortschrittsanzeige waehrend OtaUpdate::performUpdate() laeuft -
// gleiches Namespace-globale-Zeiger-Prinzip wie progressTft oben (siehe
// Settings-Backup-Fortschrittspunkte), da OtaUpdate::performUpdate()
// ebenfalls einen einfachen C-Funktionszeiger erwartet, keine
// Lambda-Capture erlaubt.
// Progress display while OtaUpdate::performUpdate() runs - same
// namespace-global pointer principle as progressTft above (see
// Settings-Backup progress dots), since OtaUpdate::performUpdate()
// also expects a plain C function pointer, no lambda capture
// allowed.
TFT_eSPI* otaProgressTft = nullptr;
void drawOtaProgress(uint8_t percent) {
if (!otaProgressTft) return;
TFT_eSPI& t = *otaProgressTft;
// Zwei Zeilen statt einer langen: der Praefix-Text
// (OTA_INSTALLING_PREFIX) ist in manchen Sprachen zu lang, um
// zusammen mit der Prozentzahl auf einer Zeile bei lesbarer
// Schriftgroesse zu passen (lief vorher links/rechts ueber den
// Bildschirmrand hinaus). Jetzt: Beschriftung klein oben, Prozent
// gross darunter.
// Two lines instead of one long one: the prefix text
// (OTA_INSTALLING_PREFIX) is too long in some languages to fit
// together with the percentage on one line at a readable font size
// (previously overflowed left/right beyond the screen edge).
// Now: label small at top, percentage large below.
constexpr int16_t BAND_H = 60;
int16_t cy = Config::SCREEN_HEIGHT / 2;
t.fillRect(0, (int16_t)(cy - BAND_H / 2), Config::SCREEN_WIDTH, BAND_H, TFT_BLACK);
@@ -523,18 +516,17 @@ namespace {
t.setTextDatum(TL_DATUM);
}
// Kompletter Ablauf fuer "Nach Update suchen" (System-Menue) - Pruefung
// gegen GitHub-Releases, bei verfuegbarem Update explizite Bestaetigung
// (confirmWarningScreen() mit gruenem statt rotem Akzent - ein Update
// ist keine destruktive Aktion wie Werksreset, verdient aber trotzdem
// eine bewusste Bestaetigung, da WLAN/Strom waehrend des Vorgangs nicht
// unterbrochen werden sollten), danach Fortschrittsanzeige waehrend
// Download+Flash. WICHTIG: startet NICHT mehr automatisch neu und
// springt bei einem Fehler auch nicht einfach stillschweigend zurueck
// ins Menue - jedes Ergebnis (Erfolg wie Fehler) wird ueber infoScreen()
// als eigener, stehenbleibender Screen angezeigt, den der Nutzer aktiv
// bestaetigen muss. Bei Erfolg startet erst ein expliziter Tap auf
// "Jetzt neu starten" tatsaechlich neu.
// Complete flow for "Check for update" (System menu) - checks
// against GitHub releases, explicit confirmation when update is
// available (confirmWarningScreen() with green instead of red
// accent - an update is not a destructive action like factory reset,
// but still deserves conscious confirmation since WiFi/power should
// not be interrupted during the process), then progress display
// during download+flash. IMPORTANT: no longer auto-restarts and no
// longer silently jumps back to the menu on error - every result
// (success as well as failure) is shown via infoScreen() as its own
// persistent screen that the user must actively acknowledge. On
// success, only an explicit tap on "Restart now" actually restarts.
void runOtaUpdateScreen(TFT_eSPI& tft) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
@@ -570,18 +562,18 @@ namespace {
otaProgressTft = nullptr;
if (ok) {
// Bewusst KEIN automatischer Neustart mehr - der Nutzer
// bestaetigt aktiv per Button, damit er den Erfolg auch wirklich
// mitbekommt (vorher lief die Meldung nur 1,5s an, dann
// Neustart - leicht zu verpassen).
// Deliberately NO automatic restart anymore - the user
// actively confirms via button so they actually notice the
// success (previously the message showed for only 1.5s,
// then restart - easy to miss).
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_SUCCESS), I18n::t(StringId::OTA_SUCCESS_BODY),
TFT_GREEN, I18n::t(StringId::OTA_RESTART_BUTTON));
ESP.restart();
} else {
// Bewusst ein stehenbleibender Info-Screen statt der alten
// showBriefMessage() (1,2s, dann automatisch zurueck ins Menue)
// - ein fehlgeschlagenes Firmware-Update ist keine
// Nebensaechlichkeit, die man verpassen darf.
// Deliberately a persistent info screen instead of the old
// showBriefMessage() (1.2s, then automatically back to menu)
// - a failed firmware update is not a minor detail that one
// can afford to miss.
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_FAILED), I18n::t(StringId::OTA_FAILED_BODY),
TFT_RED, I18n::t(StringId::OK));
}
@@ -673,12 +665,12 @@ void run(TFT_eSPI& tft) {
Rect timeoutBtn = systemRowRect(3);
Rect nightDimBtn = systemRowRect(4);
Rect webuiBtn = systemRowRect(6);
// "Sicherung & Reset" (Backup/Restore/Werksreset, siehe
// Page::BackupReset unten) und "Nach Update suchen" stehen
// bewusst direkt ueber "Info", das seinerseits als letzter
// Punkt direkt ueber dem Zurueck-Button steht - so bleiben
// beide Positionen stabil, egal wie viele weitere Punkte davor
// noch dazukommen.
// "Backup & Reset" (Backup/Restore/Factory Reset, see
// Page::BackupReset below) and "Check for update" are
// deliberately placed directly above "About", which in turn
// is the last item directly above the Back button - this
// keeps both positions stable regardless of how many further
// items come before them.
Rect ecoModeBtn = systemRowRect(5);
Rect backupResetBtn = systemRowRect(7);
Rect checkUpdateBtn = systemRowRect(8);
@@ -717,12 +709,12 @@ void run(TFT_eSPI& tft) {
} else if (brightnessBtn.contains(tap.x, tap.y)) {
BrightnessScreen::run(tft);
} else if (timeoutBtn.contains(tap.x, tap.y)) {
// Vorher: Durchklicken per wiederholtem Antippen (0-10, ein
// Tipp pro Minute - bei z.B. 10 Minuten also zehn Tipps).
// Jetzt: eigener Screen mit Schieberegler, siehe
// timeout_screen.cpp - dort lebt jetzt auch der
// Ruhebildschirm-Umschalter (inhaltlich eng verwandt, und
// dort ist Platz fuer eine kurze Erklaerung).
// Previously: cycling through values by repeated tapping
// (0-10, one tap per minute - e.g. 10 taps for 10 minutes).
// Now: dedicated screen with a slider, see
// timeout_screen.cpp - the screensaver toggle now also
// lives there (closely related in content, and there is
// room for a brief explanation).
TimeoutScreen::run(tft);
} else if (nightDimBtn.contains(tap.x, tap.y)) {
SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled());
@@ -752,9 +744,9 @@ void run(TFT_eSPI& tft) {
drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP));
drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE));
// Danger-Akzent (rot) - deutlich von Sichern/Wiederherstellen
// abgesetzt, da diese Aktion (nach Bestaetigung) ALLE Daten
// unwiderruflich loescht, siehe confirmWarningScreen() unten.
// Danger accent (red) - clearly distinguished from Backup/Restore
// since this action (after confirmation) irreversibly DELETES
// ALL data, see confirmWarningScreen() below.
drawButton(tft, resetBtn, I18n::t(StringId::MENU_FACTORY_RESET), false, true);
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
@@ -794,10 +786,9 @@ void run(TFT_eSPI& tft) {
tft.drawString(I18n::t(StringId::MENU_FACTORY_RESET_DELETING),
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
tft.setTextDatum(TL_DATUM);
// Erfolgsfall: factoryReset() startet das Geraet neu und
// kehrt nie zurueck - dieser Code danach laeuft nur im
// (seltenen) Fehlerfall (SD nicht eingehaengt) ueberhaupt
// weiter.
// On success: factoryReset() restarts the device and never
// returns - this code after it only continues in the
// (rare) error case (SD not mounted).
bool ok = SettingsBackup::factoryReset();
if (!ok) {
showBriefMessage(tft, I18n::t(StringId::MENU_FACTORY_RESET_FAILED), TFT_RED);
@@ -812,13 +803,13 @@ void run(TFT_eSPI& tft) {
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_CATEGORY_FLIGHT));
// Reine An/Aus-Schalter (Heartbeat, Notfall-Alarm, Naeherungs-LED,
// Flugbuch, Beobachtungsalarm, Bodenfahrzeuge-Filter) stehen
// bewusst gemeinsam am Ende der Liste, direkt ueber dem
// Zurueck-Button - zuerst die LED-gesteuerten Alarme (Heartbeat,
// Notfall, Naeherung; siehe LedAlert::Mode) zusammenhaengend,
// danach die beiden nicht-LED-basierten Schalter Flugbuch und
// Beobachtungsalarm sowie zuletzt der Bodenfahrzeuge-Filter.
// Pure on/off toggles (Heartbeat, Emergency Alert, Proximity LED,
// Flight Logbook, Watchlist Alert, Ground Vehicles Filter) are
// deliberately grouped together at the end of the list,
// directly above the Back button - first the LED-driven alerts
// (Heartbeat, Emergency, Proximity; see LedAlert::Mode)
// together, then the two non-LED-based toggles Flight Logbook
// and Watchlist Alert, and finally the Ground Vehicles Filter.
Rect aircraftListBtn = flightRowRect(0);
Rect statsBtn = flightRowRect(1);
Rect statsHistoryBtn = flightRowRect(2);
@@ -860,9 +851,9 @@ drawButton(tft, watchlistBtn, I18n::t(StringId::MENU_WATCHLIST));
if (aircraftListBtn.contains(tap.x, tap.y)) {
if (AircraftListScreen::run(tft)) {
// Ein Flugzeug wurde in der Liste ausgewaehlt - direkt bis
// zum Radar zurueckspringen (mit offenem Detail-Panel),
// statt in der Flugoptionen-Seite stehen zu bleiben.
// An aircraft was selected in the list - jump directly back
// to the radar (with detail panel open), instead of
// staying on the Flight Options page.
done = true;
}
} else if (statsBtn.contains(tap.x, tap.y)) {
@@ -887,7 +878,7 @@ drawButton(tft, watchlistBtn, I18n::t(StringId::MENU_WATCHLIST));
SettingsStore::setProximityAlertEnabled(!SettingsStore::proximityAlertEnabled());
} else if (logbookBtn.contains(tap.x, tap.y)) {
if (SettingsStore::flightLogbookEnabled()) {
// Ausschalten ist immer unbedenklich - keine Bestaetigung noetig.
// Turning off is always harmless - no confirmation needed.
SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile("");
@@ -895,9 +886,9 @@ drawButton(tft, watchlistBtn, I18n::t(StringId::MENU_WATCHLIST));
I18n::t(StringId::MENU_LOGBOOK_WARNING_BODY))) {
SettingsStore::setFlightLogbookEnabled(true);
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)time(nullptr));
// Leerer Eintrag erzwingt eine frische Sitzungsdatei beim
// naechsten FlightLogbook::update() statt eine evtl. noch
// vorhandene alte Datei weiterzuschreiben.
// Empty entry forces a fresh session file on the next
// FlightLogbook::update() instead of continuing to
// write to a possibly existing old file.
SettingsStore::setFlightLogbookSessionFile("");
}
} else if (watchlistAlertBtn.contains(tap.x, tap.y)) {

View File

@@ -3,8 +3,8 @@
#include <TFT_eSPI.h>
namespace MenuScreen {
// Blockierend: einfaches Menue (Kalibrierung, Anzeige-Invertierung,
// WLAN-Verwaltung, Alarm-Toggles, Statistik, Logbuch-Dateien).
// Kehrt zurueck, sobald "Zurueck" angetippt wird.
// Blocking: simple menu (calibration, display inversion,
// WiFi management, alert toggles, statistics, logbook files).
// Returns as soon as "Back" is tapped.
void run(TFT_eSPI& tft);
}

View File

@@ -1,17 +1,17 @@
#pragma once
#include <TFT_eSPI.h>
// Gemeinsames Sterne-Twinkle-Modul fuer ALLE Menue-/Untermenue-Bildschirme
// (schwarzer Hintergrund) sowie den Splash-Screen - dieselbe Optik wie im
// Flugzeug-Detail-Panel des Radars.
// Common star twinkle module for ALL menu/submenu screens
// (black background) as well as the splash screen - same look as in the
// aircraft detail panel of the radar.
namespace MenuStars {
// Verteilt die Sterne neu ueber den ganzen Bildschirm - einmal beim
// Betreten eines neuen Screens aufrufen (NICHT bei jedem Redraw
// innerhalb desselben Screens, sonst "springt" die Animation staendig).
// Redistributes the stars across the entire screen - call once when
// entering a new screen (NOT on every redraw within the same
// screen, otherwise the animation keeps "jumping").
void reset();
// In der Warte-/Idle-Schleife eines Screens aufrufen (bei jedem
// Schleifendurchlauf ist ok - die Funktion drosselt sich intern selbst
// auf ca. alle 60ms, um das Display nicht unnoetig oft anzusprechen).
// Call in the wait/idle loop of a screen (every
// loop iteration is fine - the function internally throttles itself
// to roughly every 60ms to avoid needlessly addressing the display).
void update(TFT_eSPI& tft);
}

View File

@@ -66,10 +66,9 @@ namespace {
FlightLogbook::update();
// Kurzer gruener LED-Blitz als "Herzschlag" - zeigt,
// dass gerade eine Abfrage gelaufen ist. Wird von
// LedAlert::update() automatisch ignoriert, solange
// ein Naeherungs-/Notfall-Alarm aktiv ist.
// Short green LED flash as "heartbeat" - indicates that a fetch
// just ran. Automatically ignored by LedAlert::update() as long as
// a proximity/emergency alert is active.
if (SettingsStore::ledHeartbeatEnabled()) {
LedAlert::pulseHeartbeat(millis());
}

View File

@@ -1,17 +1,17 @@
#pragma once
#include <Arduino.h>
// Hintergrund-Task fuer WLAN-Status, Standortbestimmung und ADS-B-Abfragen.
// Laeuft auf Core 0, damit Core 1 (Rendering + Touch im main-Loop) nie durch
// Netzwerk-Wartezeiten (WLAN, HTTPS) blockiert wird.
// Background task for WiFi status, geolocation and ADS-B queries.
// Runs on Core 0 so that Core 1 (rendering + touch in main loop) is never
// blocked by network wait times (WiFi, HTTPS).
namespace NetTask {
void begin();
void pause();
void resume();
// Eco-Mode: aendert das ADS-B-Abfrage-Intervall zur Laufzeit.
// Wird von main.cpp beim Bildschirm-Timeout (screenDimmed) auf
// Config::ECO_FETCH_INTERVAL_MS und beim Aufwachen wieder auf
// Config::FETCH_INTERVAL_MS gesetzt.
// Eco mode: changes the ADS-B fetch interval at runtime.
// Set by main.cpp on screen timeout (screenDimmed) to
// Config::ECO_FETCH_INTERVAL_MS and on wakeup back to
// Config::FETCH_INTERVAL_MS.
void setFetchInterval(uint32_t ms);
}

View File

@@ -14,9 +14,9 @@ namespace OtaUpdate {
namespace {
constexpr const char* RELEASES_API_URL =
"https://api.github.com/repos/Eiswolf-BG/eiswolfs-flightradar-CYD/releases/latest";
// GitHub verlangt bei API-Anfragen einen aussagekraeftigen User-Agent
// (sonst HTTP 403) - gleiches Prinzip wie Config::NOMINATIM_USER_AGENT
// fuer die Adresssuche.
// GitHub requires a meaningful User-Agent for API requests
// (otherwise HTTP 403) - same principle as Config::NOMINATIM_USER_AGENT
// for address search.
constexpr const char* USER_AGENT =
"EiswolfsFlightradarCYD-OTA/1.0 (+https://github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
@@ -27,10 +27,10 @@ namespace {
return sscanf(s, "%d.%d.%d", &major, &minor, &patch) == 3;
}
// > 0 wenn a neuer als b, 0 wenn gleich, < 0 wenn a aelter als b.
// Bewusst eine echte numerische Versionsvergleich statt eines simplen
// String-Vergleichs (der wuerde z.B. "3.10.0" faelschlich als "kleiner"
// als "3.9.0" einordnen).
// > 0 if a is newer than b, 0 if equal, < 0 if a is older than b.
// Deliberately a real numeric version comparison instead of a simple
// string comparison (which would e.g. wrongly rank "3.10.0" as "less"
// than "3.9.0").
int compareVersions(const char* a, const char* b) {
int aMaj, aMin, aPat, bMaj, bMin, bPat;
if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0;
@@ -43,7 +43,7 @@ namespace {
CheckInfo checkForUpdate() {
CheckInfo info;
Serial.printf("[OTA] Pruefe auf Update: url=%s freeHeap=%u RSSI=%ddBm\n", RELEASES_API_URL,
Serial.printf("[OTA] Checking for update: url=%s freeHeap=%u RSSI=%ddBm\n", RELEASES_API_URL,
(unsigned)ESP.getFreeHeap(), WiFi.RSSI());
WiFiClientSecure client;
@@ -53,7 +53,7 @@ CheckInfo checkForUpdate() {
HTTPClient http;
http.setTimeout(8000);
if (!http.begin(client, RELEASES_API_URL)) {
Serial.println("[OTA] Pruefung fehlgeschlagen: http.begin() lieferte false.");
Serial.println("[OTA] Check failed: http.begin() returned false.");
return info;
}
http.addHeader("User-Agent", USER_AGENT);
@@ -61,10 +61,10 @@ CheckInfo checkForUpdate() {
int code = http.GET();
if (code != HTTP_CODE_OK) {
if (code < 0) {
Serial.printf("[OTA] Pruefung fehlgeschlagen: HTTP-Fehler=%d (%s)\n", code,
Serial.printf("[OTA] Check failed: HTTP error=%d (%s)\n", code,
HTTPClient::errorToString(code).c_str());
} else {
Serial.printf("[OTA] Pruefung fehlgeschlagen: HTTP-Status=%d\n", code);
Serial.printf("[OTA] Check failed: HTTP status=%d\n", code);
}
http.end();
return info;
@@ -75,13 +75,13 @@ CheckInfo checkForUpdate() {
JsonDocument doc;
DeserializationError jsonErr = deserializeJson(doc, body);
if (jsonErr) {
Serial.printf("[OTA] Pruefung fehlgeschlagen: JSON-Fehler (%s)\n", jsonErr.c_str());
Serial.printf("[OTA] Check failed: JSON error (%s)\n", jsonErr.c_str());
return info;
}
const char* tag = doc["tag_name"] | "";
if (!tag[0]) {
Serial.println("[OTA] Pruefung fehlgeschlagen: kein tag_name im Release-JSON.");
Serial.println("[OTA] Check failed: no tag_name in release JSON.");
return info;
}
@@ -99,59 +99,58 @@ CheckInfo checkForUpdate() {
}
if (!info.downloadUrl[0]) {
Serial.printf("[OTA] Pruefung fehlgeschlagen: Release v%s hat keinen firmware.bin-Anhang.\n",
Serial.printf("[OTA] Check failed: Release v%s has no firmware.bin attachment.\n",
info.latestVersion);
return info; // Release ohne firmware.bin-Anhang
return info; // Release without firmware.bin attachment
}
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate;
Serial.printf("[OTA] Pruefung erfolgreich: installiert=v%s neuestes=v%s -> %s\n", Config::APP_VERSION,
Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config::APP_VERSION,
info.latestVersion,
info.result == CheckResult::UpdateAvailable ? "Update verfuegbar" : "bereits aktuell");
info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date");
return info;
}
bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
// Diagnose-Logging (nur ueber USB-Seriell sichtbar, kein Einfluss auf
// die UI) - vorher wurde bei einem Fehlschlag nur ein simples "true/
// false" nach aussen gegeben, ohne den eigentlichen Grund (Timeout,
// TLS-Fehler, HTTP-Statuscode...) festzuhalten. Damit laesst sich ein
// fehlgeschlagener OTA-Versuch am Seriell-Monitor nachvollziehen, statt
// erneut raten zu muessen.
Serial.printf("[OTA] Start: url=%s freeHeap=%u RSSI=%ddBm\n", url,
// Diagnostic logging (only visible over USB serial, no impact on
// the UI) - previously a failed update only returned a simple "true/
// false" externally, without recording the actual reason (timeout,
// TLS error, HTTP status code...). This allows tracing a failed
// OTA attempt on the serial monitor instead of having to guess again.
Serial.printf("[OTA] Starting: url=%s freeHeap=%u RSSI=%ddBm\n", url,
(unsigned)ESP.getFreeHeap(), WiFi.RSSI());
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(15000);
// WICHTIG: GitHubs "browser_download_url" fuer Release-Assets ist KEIN
// direkter Download-Link, sondern liefert erst ein HTTP 301/302-Redirect
// auf eine signierte objects.githubusercontent.com-URL. HTTPUpdate folgt
// Redirects standardmaessig NICHT (HTTPC_DISABLE_FOLLOW_REDIRECTS ist der
// Default) - ohne diese Zeile bricht der Download mit HTTP_UPDATE_FAILED
// ab, weil statt der .bin-Datei nur die Redirect-Antwort ankommt. Siehe
// z.B. espressif/arduino-esp32#3020. HTTPC_STRICT_FOLLOW_REDIRECTS
// reicht, da wir nur GET verwenden.
// IMPORTANT: GitHub's "browser_download_url" for release assets is NOT
// a direct download link, but first returns an HTTP 301/302 redirect
// to a signed objects.githubusercontent.com URL. HTTPUpdate does NOT
// follow redirects by default (HTTPC_DISABLE_FOLLOW_REDIRECTS is the
// default) - without this line the download aborts with HTTP_UPDATE_FAILED
// because only the redirect response arrives instead of the .bin file. See
// e.g. espressif/arduino-esp32#3020. HTTPC_STRICT_FOLLOW_REDIRECTS
// suffices since we only use GET.
httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
// Wir zeigen nach erfolgreicher Installation selbst noch eine kurze
// Erfolgsmeldung an, bevor das Geraet neu startet - siehe
// We show a brief success message ourselves after successful installation,
// before the device reboots - see
// menu_screen.cpp::runOtaUpdateScreen().
httpUpdate.rebootOnUpdate(false);
httpUpdate.onProgress([onProgress](int cur, int total) {
if (onProgress && total > 0) {
onProgress((uint8_t)((cur * 100) / total));
}
// Nur gelegentlich loggen (alle ~10%), sonst quillt der Seriell-
// Monitor bei grossen Dateien mit hunderten Zeilen ueber.
// Only log occasionally (every ~10%), otherwise the serial
// monitor overflows with hundreds of lines for large files.
static int8_t lastLoggedPercent = -1;
if (total > 0) {
int8_t pct = (int8_t)((cur * 100) / total);
if (pct != lastLoggedPercent && pct % 10 == 0) {
lastLoggedPercent = pct;
Serial.printf("[OTA] Fortschritt: %d%% (%d/%d Bytes) freeHeap=%u\n", pct, cur, total,
Serial.printf("[OTA] Progress: %d%% (%d/%d bytes) freeHeap=%u\n", pct, cur, total,
(unsigned)ESP.getFreeHeap());
}
}
@@ -160,11 +159,11 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
t_httpUpdate_return result = httpUpdate.update(client, url);
if (result != HTTP_UPDATE_OK) {
Serial.printf("[OTA] Fehlgeschlagen: result=%d error=%d (%s) freeHeap=%u\n", (int)result,
Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result,
httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str(),
(unsigned)ESP.getFreeHeap());
} else {
Serial.println("[OTA] Erfolgreich heruntergeladen und geflasht.");
Serial.println("[OTA] Successfully downloaded and flashed.");
}
return result == HTTP_UPDATE_OK;

View File

@@ -1,42 +1,42 @@
#pragma once
#include <Arduino.h>
// Firmware-Update ueber WLAN (Menue > System > "Nach Update suchen") -
// prueft gegen die neueste Version im GitHub-Repository (Releases-API) und
// installiert sie bei Bestaetigung direkt auf dem Geraet, ohne Kabel/
// Web-Flasher. Blockierende, synchrone HTTPS-Aufrufe - werden nur bei
// explizitem Tastendruck ausgefuehrt (kein Hintergrund-Polling), analog zu
// AircraftDetails' Modell-/Routen-Abfragen, nur eben auf Core 1 statt
// Core 0, da hier direkt auf Nutzer-Interaktion reagiert wird.
// Firmware update over WiFi (Menu > System > "Check for update") -
// checks against the latest version in the GitHub repository (Releases API) and
// installs it on the device directly upon confirmation, without cable/
// web flasher. Blocking, synchronous HTTPS calls - only executed on
// explicit button press (no background polling), analogous to
// AircraftDetails' model/route queries, just on Core 1 instead of
// Core 0, since it reacts directly to user interaction.
namespace OtaUpdate {
enum class CheckResult { Error, UpToDate, UpdateAvailable };
struct CheckInfo {
CheckResult result = CheckResult::Error;
// Neueste verfuegbare Version OHNE "v"-Praefix (z.B. "3.5.0") -
// sowohl bei UpToDate als auch bei UpdateAvailable gesetzt, damit
// der aufrufende Screen sie in beiden Faellen anzeigen kann.
// Latest available version WITHOUT "v" prefix (e.g. "3.5.0") -
// set for both UpToDate and UpdateAvailable, so
// the calling screen can display it in both cases.
char latestVersion[16] = {0};
// Direkter Download-Link zur firmware.bin des Releases - nur
// gesetzt, wenn result == UpdateAvailable.
// Direct download link to the firmware.bin of the release - only
// set when result == UpdateAvailable.
char downloadUrl[192] = {0};
};
// Fragt die GitHub-Releases-API nach dem neuesten Release ab, vergleicht
// dessen Versionsnummer (Tag-Name, "v"-Praefix wird ignoriert) gegen
// Config::APP_VERSION und sucht im Release den Anhang "firmware.bin".
// CheckResult::Error bei jedem Fehler unterwegs (kein WLAN, Zeitueber-
// schreitung, unerwartetes JSON, kein firmware.bin im Release).
// Queries the GitHub Releases API for the latest release, compares
// its version number (tag name, "v" prefix is ignored) against
// Config::APP_VERSION and looks for the "firmware.bin" attachment in the release.
// CheckResult::Error on any error along the way (no WiFi, timeout,
// unexpected JSON, no firmware.bin in the release).
CheckInfo checkForUpdate();
// Laedt die firmware.bin von 'url' herunter und flasht sie (ueber die
// Standard-Arduino-HTTPUpdate-Bibliothek). Ruft bei Erfolg BEWUSST
// NICHT selbst ESP.restart() auf - der Aufrufer zeigt zuerst eine
// kurze Erfolgsmeldung an und startet danach selbst neu. onProgress
// wird waehrend des Downloads wiederholt mit 0-100 (Prozent) aufgerufen
// (darf nullptr sein). Gibt false zurueck, wenn Download oder Flash-
// Vorgang fehlschlagen - das Geraet laeuft in dem Fall unveraendert mit
// der bisherigen Firmware weiter.
// Downloads the firmware.bin from 'url' and flashes it (via the
// standard Arduino HTTPUpdate library). Intentionally does NOT call
// ESP.restart() itself on success - the caller shows a
// brief success message first and then restarts itself. onProgress
// is called repeatedly during download with 0-100 (percent)
// (may be nullptr). Returns false if download or flash
// process fails - the device continues running unchanged with
// the previous firmware in that case.
bool performUpdate(const char* url, void (*onProgress)(uint8_t percent));
}

View File

@@ -41,11 +41,11 @@ int16_t cx, cy, radius;
};
constexpr int16_t INFO_BAR_H = 64;
// Zusaetzliche Hoehe fuer die zweite Legenden-Zeile (Bodenfahrzeug-
// Marker-Erklaerung), nur reserviert, wenn Bodenfahrzeuge tatsaechlich
// angezeigt werden (SettingsStore::hideGroundVehicles() == false) -
// ansonsten bleibt die Infoleiste genau so hoch wie bisher und der
// Radarkreis behaelt seine gewohnte Groesse.
// Additional height for the second legend row (ground vehicle
// marker explanation), only reserved when ground vehicles are actually
// displayed (SettingsStore::hideGroundVehicles() == false) -
// otherwise the info bar stays exactly as tall as before and the
// radar circle keeps its usual size.
constexpr int16_t INFO_BAR_GROUND_ROW_H = 18;
int16_t infoBarHeight() {
@@ -69,10 +69,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
return L;
}
// +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
// +22px compared to the previous value (264) for the new route line (see
// drawDetailPanel()) - only affects the size of the detail panel
// overlay, not the normal radar layout (computeLayout() above
// only depends on infoBarHeight(), not
// DETAIL_PANEL_H).
constexpr int16_t DETAIL_PANEL_H = 286;
@@ -103,15 +103,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
bool ledBlinkOn = true;
// "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein
// Flugzeug sichtbar war (nach allen Filtern) - namespace-weit statt
// lokal in render(), da tick() (siehe dort) den Wert bei jedem Tick
// (alle 80ms) braucht, um den Sekundenzaehler fluessig hochzuzaehlen,
// statt nur alle paar Sekunden bei einem render()-Aufruf.
// "Empty Sky" timer: remembers when at least one
// aircraft was last visible (after all filters) - namespace-wide instead of
// local to render(), since tick() (see there) needs the value on every tick
// (every 80ms) to count up the seconds smoothly,
// instead of only every few seconds on a render() call.
uint32_t lastAircraftSeenMs = millis();
// Wird von der Detail-Panel-Laufschrift (LineMarquee in drawDetailPanel)
// fuer das Scrollen von Routentext verwendet.
// Used by the detail panel marquee (LineMarquee in drawDetailPanel)
// for scrolling route text.
constexpr uint32_t INFO_MARQUEE_STEP_MS = 200;
String infoMarqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) {
@@ -133,16 +133,16 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
float prevSweepAngleDeg = -1.0f;
constexpr float SWEEP_DEGREES_PER_SEC = 45.0f;
// Gleiche Logik wie main.cpp::isNightDimHours() - Nacht = zwischen
// Sonnenuntergang und Sonnenaufgang am aktiven Standort (siehe
// sun_times.h), mit Rueckfall auf ein festes 22:00-06:00-Fenster,
// solange Standort/Uhrzeit noch nicht bekannt sind. Hier bewusst
// dupliziert statt geteilt, siehe CLAUDE.md-Konvention ("jeder Screen
// unabhaengig lauffaehig, kein gemeinsames Modul fuer solche
// Kleinigkeiten").
// Same logic as main.cpp::isNightDimHours() - night = between
// sunset and sunrise at the active location (see
// sun_times.h), with fallback to a fixed 22:00-06:00 window
// as long as location/time are not yet known. Deliberately
// duplicated instead of shared, see CLAUDE.md convention ("each screen
// independently runnable, no shared module for such
// small items").
bool isNightHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false; // Uhrzeit noch nicht per NTP synchronisiert
if (now <= 8 * 3600 * 2) return false; // time not yet synced via NTP
struct tm tmNow;
localtime_r(&now, &tmNow);
@@ -164,22 +164,23 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
return (hour >= 22 || hour < 6);
}
// Nur aktiv, wenn der Nutzer die Nachtdimmung (Menue > System) eingeschaltet
// hat UND wir gerade im Nachtfenster sind - dieselbe Einstellung, die sonst
// nur die Hintergrundbeleuchtung dimmt, dimmt jetzt zusaetzlich auch die
// Radar-Farben (Flugzeug-Marker + Sweep-Linie), damit das Display nachts
// insgesamt weniger blendet.
// Only active when the user has enabled night dimming (Menu > System)
// AND we are currently in the night window - the same setting that otherwise
// only dims the backlight, now additionally also dims the
// radar colors (aircraft markers + sweep line), so the display
// is less glaring overall at night.
bool nightDimActiveNow() {
return SettingsStore::nightDimmingEnabled() && isNightHours();
}
// Farbe je nach Flughoehe - gedaempft (dunkleres Gruen/Oliv) waehrend der
// Nachtdimmung.
// Color by altitude - dimmed (darker green/olive) during
// night dimming.
//
// Die rote Hoehenstufe wird bewusst NICHT gedaempft: der bisherige
// Dimm-Ton (160,30,0) sah auf dem Display eher orange-braun statt rot
// aus und war dadurch nicht mehr klar von der gelben Stufe zu
// unterscheiden. Rot bleibt deshalb Tag und Nacht der reine TFT_RED-Ton.
// The red altitude level is deliberately NOT dimmed: the previous
// dimmed tone (160,30,0) looked orange-brown instead of red
// on the display and was therefore no longer clearly distinguishable
// from the yellow level. Red therefore remains the pure TFT_RED tone
// day and night.
uint16_t colorForAltitude(TFT_eSPI& gfx, int32_t altFt) {
bool dim = nightDimActiveNow();
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT)
@@ -193,34 +194,34 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
return nightDimActiveNow() ? gfx.color565(0, 110, 0) : TFT_GREEN;
}
// Feste Blau-Farbe fuer Bodenfahrzeug-Marker (ADS-B-Emitter-Kategorie
// "C*", z.B. Flughafen-Fahrzeuge) - bewusst unabhaengig von
// colorForAltitude(), da Bodenfahrzeuge praktisch immer auf 0ft stehen
// und sonst dieselbe Farbe wie niedrig fliegende Flugzeuge haetten,
// was die optische Unterscheidung (Quadrat vs. Kreis) unnoetig
// erschweren wuerde. Bei Nachtdimmung gedaempft, gleiches Muster wie
// Fixed blue color for ground vehicle markers (ADS-B emitter category
// "C*", e.g. airport vehicles) - deliberately independent of
// colorForAltitude(), since ground vehicles are practically always at 0ft
// and would otherwise have the same color as low-flying aircraft,
// which would unnecessarily complicate the visual distinction
// (square vs. circle). Dimmed during night dimming, same pattern as
// colorForAltitude().
uint16_t colorForGroundVehicle(TFT_eSPI& gfx) {
return nightDimActiveNow() ? gfx.color565(0, 0, 160) : TFT_BLUE;
}
// Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading)
// zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom
// Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl
// direkt ausserhalb des Rings. Hilft dabei, das Flugzeug tatsaechlich am
// Himmel zu finden ("in diese Richtung schauen"). Nur fuer den Moment
// des Zeichnens relevant - beim naechsten Sweep-Tick/Redraw wird sie vom
// normalen Hintergrund-Redraw automatisch mit geloescht und neu gesetzt.
// Shows the BEARING (not to be confused with the heading)
// to the currently selected aircraft: a thin, dotted line from the
// radar center to the circle edge in the direction of bearingDeg, plus the degree number
// directly outside the ring. Helps to actually find the aircraft
// in the sky ("look in this direction"). Only relevant for the moment
// of drawing - on the next sweep tick/redraw it is automatically
// erased and redrawn by the normal background redraw.
void drawBearingIndicator(TFT_eSPI& gfx, const Layout& L, float bearingDeg) {
double rad = bearingDeg * PI / 180.0;
double s = sin(rad), c = cos(rad);
// Gepunktete Linie: kurze Segmente statt einer durchgezogenen Linie,
// damit sie sich optisch von den Kompass-Kreuzlinien und Ringen
// unterscheidet und nicht wie ein staendiges UI-Element wirkt.
// Dotted line: short segments instead of a solid line,
// so it visually differs from the compass cross lines and rings
// and does not look like a permanent UI element.
constexpr uint8_t DOT_COUNT = 10;
for (uint8_t i = 0; i < DOT_COUNT; i++) {
if (i % 2 != 0) continue; // nur jedes zweite Segment zeichnen
if (i % 2 != 0) continue; // only draw every second segment
float t0 = (float)i / DOT_COUNT;
float t1 = (float)(i + 1) / DOT_COUNT;
int16_t x0 = L.cx + (int16_t)lround(t0 * L.radius * s);
@@ -230,12 +231,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.drawLine(x0, y0, x1, y1, TFT_WHITE);
}
// Gradzahl knapp ausserhalb des Rings, an der Stelle wo die Peilung
// den Kreisrand durchstoesst. Der Radius-Kreis nutzt fast die volle
// Bildschirmbreite (siehe computeLayout(): nur 6px Rand) - ein Label
// ausserhalb des Rings wuerde bei Ost/West-Peilungen ueber den
// sichtbaren Bereich hinausragen. Deshalb: Label-Position an die
// Bildschirmgrenzen clampen statt stur dem Winkel zu folgen.
// Bearing degree just outside the ring, at the point where the bearing
// line intersects the circle edge. The radius circle uses almost the full
// screen width (see computeLayout(): only 6px margin) - a label
// outside the ring would extend beyond the visible area
// for east/west bearings. Therefore: clamp label position to
// screen boundaries instead of blindly following the angle.
int16_t labelX = L.cx + (int16_t)lround((L.radius + 10) * s);
int16_t labelY = L.cy - (int16_t)lround((L.radius + 10) * c);
labelX = constrain(labelX, (int16_t)14, (int16_t)(Config::SCREEN_WIDTH - 14));
@@ -248,11 +249,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.setTextDatum(TL_DATUM);
}
// kurzer Kursstrich in Flugrichtung (headingDeg) MIT Pfeilspitze am Ende -
// eine reine Linie ohne Spitze war nicht eindeutig lesbar (nicht erkennbar,
// welches Ende "vorne" ist). Die Spitze besteht aus zwei kurzen Strichen,
// die knapp vor der Linienspitze schraeg nach aussen abzweigen (klassisches
// Chevron-/Pfeilkopf-Symbol, wie bei ATC-Radardarstellungen ueblich).
// Short course line in flight direction (headingDeg) WITH arrowhead at the end -
// a plain line without a tip was not clearly readable (not recognizable
// which end is "front"). The tip consists of two short strokes
// that branch diagonally outward just before the line tip (classic
// chevron/arrowhead symbol, as typical in ATC radar displays).
void drawAircraftMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 5, color);
@@ -263,9 +264,9 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
int16_t tipY = y + dy;
gfx.drawLine(x, y, tipX, tipY, color);
// Pfeilspitze: zwei kurze Striche von der Spitze aus, je 150 Grad
// zur Kurslinie zurueckgeklappt (also leicht "nach hinten" zeigend,
// wie bei einem Pfeilkopf "^" ueblich).
// Arrowhead: two short strokes from the tip, each folded back 150 degrees
// to the course line (i.e. slightly pointing "backward",
// as is common for an arrowhead "^").
constexpr double WING_ANGLE_RAD = 150.0 * PI / 180.0;
constexpr int16_t WING_LEN = 4;
double wing1 = rad + WING_ANGLE_RAD;
@@ -278,21 +279,21 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.drawLine(tipX, tipY, w2x, w2y, color);
}
// Eigener Marker fuer Bodenfahrzeuge (ADS-B-Kategorie "C*") - ein
// gefuelltes Quadrat statt Kreis+Pfeilkopf, damit sie auf dem Radar
// klar von echten Flugzeugen zu unterscheiden sind (Heading/Kurs ist
// bei Bodenfahrzeugen ausserdem meist nicht aussagekraeftig).
// Separate marker for ground vehicles (ADS-B category "C*") - a
// filled square instead of circle+arrowhead, so they are clearly
// distinguishable from real aircraft on the radar (heading/course is
// also usually not meaningful for ground vehicles).
void drawGroundVehicleMarker(TFT_eSPI& gfx, int16_t x, int16_t y, uint16_t color) {
constexpr int16_t HALF = 4;
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).
// Separate marker for helicopters (ADS-B emitter category "A7" =
// Rotorcraft) - a filled circle with a solid rotor cross instead of
// an arrowhead, since helicopters in hover have no meaningful
// "forward" course like a fixed-wing aircraft (same reasoning as the
// ground vehicle marker, which for the same reason also
// does without a course line).
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);
@@ -300,14 +301,14 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.drawLine(x, (int16_t)(y - ROTOR_LEN), x, (int16_t)(y + ROTOR_LEN), color);
}
// Eigener Marker fuer "Heavy"-Flugzeuge (ADS-B-Emitter-Kategorie "A5" -
// Flugzeuge ueber 136t Starthoechstgewicht, z.B. A380/B747/B777/A330).
// Gleicher Aufbau wie drawAircraftMarker() (Kreis + Kurslinie mit
// Pfeilkopf), aber mit groesserem Kreis und einem zusaetzlichen duennen
// Aussenring - auf den ersten Blick als "groesser/schwerer" erkennbar,
// ohne eine komplett neue Formsprache einzufuehren. Die Farbe bleibt wie
// gewohnt die Hoehenfarbe (colorForAltitude()) - die Form transportiert
// "Heavy", kein Alarm-/Auffaelligkeitszustand.
// Separate marker for "Heavy" aircraft (ADS-B emitter category "A5" -
// aircraft over 136t maximum takeoff weight, e.g. A380/B747/B777/A330).
// Same structure as drawAircraftMarker() (circle + course line with
// arrowhead), but with a larger circle and an additional thin
// outer ring - recognizable at first glance as "larger/heavier",
// without introducing a completely new shape language. The color remains
// the usual altitude color (colorForAltitude()) - the shape conveys
// "Heavy", not an alarm/conspicuity state.
void drawHeavyMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 6, color);
gfx.drawCircle(x, y, 8, color);
@@ -368,12 +369,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
snprintf(highLabel, sizeof(highLabel), ">30k ft");
}
// Dieselbe colorForAltitude()-Funktion wie fuer die Flugzeug-Marker
// selbst verwenden (mit einer typischen Hoehe je Band) - sonst zeigt
// die Legende bei aktiver Nachtdimmung (Sonnenunter- bis -aufgang) die HELLEN Farben,
// waehrend die Marker/Beschriftungen auf dem Radar bereits gedaempft
// sind. Das fuehrte dazu, dass z.B. ein rotes Flugzeug nachts eher
// dunkelorange wirkte, obwohl die Legende noch reines Rot zeigte.
// Use the same colorForAltitude() function as for the aircraft markers
// themselves (with a typical altitude per band) - otherwise the
// legend would show the BRIGHT colors during active night dimming (sunset to sunrise),
// while the markers/labels on the radar are already dimmed.
// This caused e.g. a red aircraft to appear dark orange at night,
// even though the legend still showed pure red.
struct { uint16_t color; const char* label; } items[3] = {
{colorForAltitude(gfx, 0), lowLabel},
{colorForAltitude(gfx, Config::COLOR_LOW_ALT_THRESHOLD_FT), midLabel},
@@ -382,15 +383,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
int16_t segW = Config::SCREEN_WIDTH / 3;
gfx.setTextColor(TFT_WHITE, TFT_BLACK);
// Aussenspalten (gruen/rot) bleiben auf ihrer festen 1/3-Raster-
// Position (x0 = i*segW+6). Die mittlere (gelbe) Spalte wurde
// bisher genauso starr positioniert, sass dadurch aber optisch zu
// weit rechts: ihr Label ("3000-9100") ist deutlich laenger als
// die Aussenlabels, wodurch rechts kaum noch Luft zum roten
// Eintrag blieb, waehrend links viel Freiraum zum kurzen gruenen
// Label uebrig war. Jetzt wird die gelbe Spalte stattdessen mittig
// in die Luecke zwischen Ende des gruenen Textes und Anfang des
// roten Punkts gesetzt.
// Outer columns (green/red) stay at their fixed 1/3 grid
// position (x0 = i*segW+6). The middle (yellow) column was
// previously positioned just as rigidly, but visually sat too
// far right: its label ("3000-9100") is noticeably longer than
// the outer labels, leaving hardly any space on the right to the
// red entry, while lots of empty space remained on the left next to the short green
// label. Now the yellow column is instead placed
// centered in the gap between the end of the green text and the start of the
// red dot.
int16_t x0Low = 0 * segW + 6;
int16_t x0High = 2 * segW + 6;
@@ -412,10 +413,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.setCursor(x0Mid + 7, y);
gfx.print(items[1].label);
// Zweite Legenden-Zeile fuer die blauen Bodenfahrzeug-Quadrate - nur
// wenn sie ueberhaupt sichtbar sind (Flugoptionen >
// "Bodenfahrzeuge ausblenden" == aus). infoBarHeight() reserviert
// den dafuer noetigen zusaetzlichen Platz nur in diesem Fall, siehe
// Second legend row for the blue ground vehicle squares - only
// when they are actually visible (Flight Options >
// "Hide ground vehicles" == off). infoBarHeight() reserves
// additional space for this only in that case, see
// computeLayout().
if (!SettingsStore::hideGroundVehicles()) {
int16_t gy = (int16_t)(y + INFO_BAR_GROUND_ROW_H);
@@ -469,10 +470,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.drawString("S", L.cx, L.cy + L.radius - 10);
gfx.drawString("E", L.cx + L.radius - 10, L.cy);
gfx.drawString("W", L.cx - L.radius + 10, L.cy);
// Ring-Beschriftungen (Zwischenabstaende) respektieren jetzt die
// Einheiten-Einstellung (Menue > Einheiten) - vorher immer in km,
// auch wenn Imperial (nm) eingestellt war. Gleiches Umrechnungs-
// Muster wie beim Range-Button unten und der Legende oben.
// Ring labels (intermediate distances) now respect the
// unit setting (Menu > Units) - previously always in km,
// even when Imperial (nm) was set. Same conversion
// pattern as the range button below and the legend above.
bool metric = LocationManager::useMetricUnits();
float displayRange = metric ? rangeKm : Units::kmToNm(rangeKm);
char ringLabel[8];
@@ -483,14 +484,14 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.setTextDatum(TL_DATUM);
}
// Laufschrift-Zustand fuer EINE Detail-Panel-Zeile - gleiches
// Grundprinzip wie InfoMarquee oben, aber das Detail-Panel hat bis zu
// neun solcher Zeilen gleichzeitig (Airline, Modell, Typ, Hoehe,
// Geschw., Steig-/Sinkrate, Distanz/Peilung, Squawk, Sitzplaetze),
// deshalb ein eigener Zustand pro Zeile statt einer einzigen globalen
// Instanz. y/h/maxWidth/fg merken sich die Zeilen-Geometrie, damit
// tickDetailPanelMarquees() (siehe unten) ohne Zusatzparameter weiss,
// wo/wie jede Zeile neu zu zeichnen ist.
// Marquee state for ONE detail panel line - same basic
// principle as InfoMarquee above, but the detail panel has up to
// nine such lines simultaneously (airline, model, type, altitude,
// speed, climb/sink rate, distance/bearing, squawk, seats),
// hence a separate state per line instead of a single global
// instance. y/h/maxWidth/fg remember the line geometry so that
// tickDetailPanelMarquees() (see below) knows where/how to
// redraw each line without extra parameters.
struct LineMarquee {
String text;
String ring;
@@ -509,22 +510,22 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
};
PanelState lastPanel;
// Zeichnet eine Detail-Panel-Zeile NEU, wenn sich ihr Text geaendert
// hat (oder das Panel komplett neu aufgebaut wird) - merkt sich dabei
// auch, ob der Text zu breit fuer die verfuegbare Breite ist
// (needsScroll) und baut bei Bedarf den Ring-Puffer fuer die
// Laufschrift auf (gleiches Muster wie infoMarqueeWindow() oben).
// Passt zu breite Zeilen ("Modell: ...", "Distanz: ...") NICHT mehr
// mit "..." ab, sondern laesst sie horizontal durchscrollen - siehe
// tickDetailPanelMarquees() weiter unten fuer den Teil, der das
// tatsaechliche Weiterscrollen zwischen zwei Datenaktualisierungen
// uebernimmt (render() liefert i.d.R. nur alle ~300ms neue Werte,
// das waere fuer eine fluessige Laufschrift viel zu selten).
// Redraws a detail panel line when its text has changed
// (or the panel is being rebuilt) - remembers whether
// the text is too wide for the available width
// (needsScroll) and builds the ring buffer for the
// marquee if needed (same pattern as infoMarqueeWindow() above).
// Lines that are too wide ("Model: ...", "Distance: ...") are NO
// LONGER abbreviated with "..." but scrolled horizontally - see
// tickDetailPanelMarquees() below for the part that
// actually advances the scrolling between data updates
// (render() typically only supplies new values every ~300ms,
// which would be far too rare for smooth scrolling).
void updateMarqueeLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth,
uint16_t fg, LineMarquee& m, const String& newText, bool forceFull) {
// Geometrie/Farbe IMMER aktualisieren (auch ohne Textaenderung) -
// tickDetailPanelMarquees() braucht diese Werte, um beim naechsten
// Scroll-Schritt an der richtigen Stelle neu zu zeichnen.
// Always update geometry/color (even without text change) -
// tickDetailPanelMarquees() needs these values to redraw
// at the correct position on the next scroll step.
m.y = y;
m.h = h;
m.maxWidth = maxWidth;
@@ -534,7 +535,7 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
m.text = newText;
m.needsScroll = gfx.textWidth(newText) > maxWidth;
String withGap = newText + " "; // 3 Leerzeichen Luecke vor der Wiederholung
String withGap = newText + " "; // 3 space gap before repetition
m.ring = withGap + withGap;
m.charOffset = 0;
m.lastStepMs = millis();
@@ -545,11 +546,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.print(m.needsScroll ? infoMarqueeWindow(gfx, m.ring, 0, maxWidth) : newText);
}
// Laesst eine einzelne Detail-Panel-Zeile weiterscrollen, falls sie zu
// breit ist (needsScroll) und genug Zeit seit dem letzten Schritt
// vergangen ist - sonst passiert nichts (kein unnoetiges Neuzeichnen
// fuer Zeilen, die ohnehin komplett passen). Wird von
// tickDetailPanelMarquees() fuer alle neun Zeilen aufgerufen.
// Advances a single detail panel line if it is too
// wide (needsScroll) and enough time has passed since the last step
// - otherwise nothing happens (no unnecessary redrawing
// for lines that fit completely). Called by
// tickDetailPanelMarquees() for all nine lines.
void advanceAndDrawMarqueeLine(TFT_eSPI& gfx, LineMarquee& m) {
if (!m.needsScroll) return;
@@ -567,12 +568,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
gfx.print(infoMarqueeWindow(gfx, m.ring, m.charOffset, m.maxWidth));
}
// Laesst alle Detail-Panel-Zeilen weiterscrollen - waehrend das Panel
// offen ist, ruft tick() (alle ~80ms) das hier auf, DAZWISCHEN also
// viel oefter als render() (das nur bei neuen Flugzeug-Daten,
// ~alle 300ms, feuert) neue Werte liefert. Ohne diesen zusaetzlichen
// Aufruf wuerde eine lange Zeile bei jedem render()-Aufruf zwar einen
// Schritt weiterspringen, aber nicht fluessig durchlaufen.
// Lets all detail panel lines scroll - while the panel
// is open, tick() (every ~80ms) calls this, in BETWEEN much
// more often than render() (which only fires on new aircraft data,
// every ~300ms). Without this additional
// call, a long line would jump one step on each render() call
// but would not scroll smoothly.
void tickDetailPanelMarquees(TFT_eSPI& gfx) {
if (!lastPanel.valid) return;
advanceAndDrawMarqueeLine(gfx, lastPanel.airline);
@@ -632,11 +633,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Origin/destination airport (ICAO code) via AircraftDetails::get() -
// is queried together with the model (see aircraft_details.cpp),
// but resolved via the callsign instead of the hex code. Without
// a callsign (e.g. some VFR aircraft) the route remains
// fundamentally unknown, no lookup needed.
String routeLine;
if (!a.callsign[0]) {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN);
@@ -674,11 +675,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climb, climbLine, forceFull);
y += LINE_H;
// Zusaetzlich zu km/nm auch Meilen (mi) - nm allein war fuer
// Laien-Nutzer aus Ländern mit imperialen Einheiten (v.a. USA)
// nicht selbsterklaerend, "Meilen" sind dort die gebraeuchlichere
// Alltags-Distanzeinheit (nm bleibt trotzdem stehen, da es zur
// Geschwindigkeit in kt passt: 1 kt = 1 nm/h).
// In addition to km/nm also miles (mi) - nm alone was
// not self-explanatory for lay users from countries with
// imperial units (especially the US), "miles" is the more common
// everyday distance unit there (nm still remains since it matches
// speed in kt: 1 kt = 1 nm/h).
snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm / %.0fmi %s%.0f", I18n::t(StringId::DETAIL_DIST),
a.distanceKm, Units::kmToNm(a.distanceKm), Units::kmToMi(a.distanceKm),
I18n::t(StringId::DETAIL_HDG), a.headingDeg);
@@ -738,15 +739,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
}
}
// Hintergrund-Sterne AUSSERHALB des Radarkreises (auf Wunsch: dasselbe
// dekorative Sternenfunkeln wie im Detail-Panel/Menue, aber ohne mit
// dem staendig neu gezeichneten Kreisinhalt zu kollidieren - genau DAS
// wuerde "ruckeln" verursachen, da Sweep-Linie/Blips/Ringe innerhalb
// des Kreises bei jedem tick() sowieso neu gezeichnet werden und dabei
// die Sterne dort staendig wieder verschlucken wuerden). Ausserhalb
// des Kreises bleibt der Hintergrund dauerhaft schwarz und wird von
// sonst niemandem angefasst - dort koennen die Sterne frei vor sich
// hin twinkeln, voellig unabhaengig von Sweep-Linie/Blip-Redraws.
// Background stars OUTSIDE the radar circle (requested: the same
// decorative star twinkling as in the detail panel/menu, but without
// colliding with the constantly redrawn circle content - THAT would
// cause stuttering, since the sweep line/blips/rings within the
// circle are redrawn on every tick() anyway and would constantly
// swallow the stars there). Outside the circle the background remains
// permanently black and is touched by no one else - the stars can
// freely twinkle there, completely independent of sweep line/blip
// redraws.
constexpr uint8_t BG_STAR_COUNT = 20;
struct BgStar {
int16_t x, y;
@@ -756,15 +757,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
BgStar bgStars[BG_STAR_COUNT];
bool bgStarsInitialized = false;
// Platziert jeden Stern per Rejection-Sampling irgendwo im Bereich
// [top..L.infoTop) x [0..SCREEN_WIDTH), aber nur wenn der Punkt
// ausserhalb von Radius+MARGIN um den Kreismittelpunkt liegt (MARGIN
// haelt zusaetzlich Abstand zum Ring und zum "N"-Kompasslabel knapp
// ausserhalb des Kreises). Findet ein Versuch nach 25 Anlaeufen keinen
// passenden Punkt (praktisch nie, da links/rechts vom Kreis immer
// reichlich Platz ist), wird einfach der letzte Versuchspunkt
// uebernommen - rein kosmetisches Feature, kein Grund fuer eine
// Endlosschleife.
// Places each star via rejection sampling somewhere in the area
// [top..L.infoTop) x [0..SCREEN_WIDTH), but only if the point
// is outside radius+MARGIN around the circle center (MARGIN
// additionally keeps distance from the ring and the "N" compass label
// just outside the circle). If an attempt after 25 tries finds no
// suitable point (practically never, since there is always plenty
// of space left/right of the circle), the last attempted point is
// simply used - purely cosmetic feature, no reason for an
// infinite loop.
void initBgStarsIfNeeded(const Layout& L, int16_t top) {
if (bgStarsInitialized) return;
randomSeed((uint32_t)esp_random());
@@ -855,7 +856,7 @@ void render(TFT_eSPI& tft, int16_t top) {
bool selectionStillPresent = false;
Aircraft selected{};
uint8_t visibleCount = 0; // nach allen Filtern (Reichweite, Bodenfahrzeuge, Airline-Filter)
uint8_t visibleCount = 0; // after all filters (range, ground vehicles, airline filter)
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false;
@@ -876,14 +877,14 @@ if (AirlineFilter::isHidden(a.callsign)) continue;
RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg};
RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm);
// ADS-B-Emitter-Kategorie "C*" = Bodenfahrzeug (nur ueberhaupt
// sichtbar, wenn "Bodenfahrzeuge ausblenden" aus ist, siehe Filter
// oben) - eigene Farbe/Marker statt der hoehenbasierten Flugzeug-
// Darstellung, siehe drawGroundVehicleMarker()/colorForGroundVehicle().
// ADS-B emitter category "C*" = ground vehicle (only ever
// visible if "Hide ground vehicles" is off, see filter
// above) - separate color/marker instead of altitude-based
// rendering, see drawGroundVehicleMarker()/colorForGroundVehicle().
bool isGroundVehicle = a.category[0] == 'C';
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
// ADS-B-Emitter-Kategorie "A5" = "Heavy" - eigene Markerform (siehe
// drawHeavyMarker()), unabhaengig von jeglicher Ring-Markierung.
// ADS-B emitter category "A5" = "Heavy" - separate marker shape (see
// drawHeavyMarker()), independent of any ring marking.
bool isHeavy = a.category[0] == 'A' && a.category[1] == '5';
uint16_t color = isGroundVehicle ? colorForGroundVehicle(tft) : colorForAltitude(tft, a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
@@ -947,7 +948,7 @@ if (AirlineFilter::isHidden(a.callsign)) continue;
lastPanel.valid = false;
int16_t infoTop = L.infoTop;
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
// Info-Bar Button-Leiste: [?] [S] [L] [50km]
// Info bar button row: [?] [S] [L] [50km]
char rangeLabel[8];
bool rangeMetric = LocationManager::useMetricUnits();
if (rangeMetric) {
@@ -973,11 +974,11 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
if (selectedHex[0]) {
tft.startWrite();
updateDetailPanelStars(tft);
// Laesst zu breite Detail-Panel-Zeilen (z.B. "Modell: ..." oder
// "Distanz: ...") weiterscrollen, statt wie zuvor mit "..." starr
// abgeschnitten zu bleiben - render() liefert neue Werte nur alle
// ~300ms, das reicht fuer eine fluessige Laufschrift nicht aus,
// deshalb hier zusaetzlich im 80ms-Tick weiterschieben.
// Lets overly wide detail panel lines (e.g. "Model: ..." or
// "Distance: ...") scroll, instead of being statically truncated
// with "..." - render() only supplies new values every
// ~300ms, which is not enough for smooth scrolling,
// so it is additionally advanced here in the 80ms tick.
tickDetailPanelMarquees(tft);
tft.endWrite();
return;
@@ -988,13 +989,13 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
tft.startWrite();
// Twinkeln ausserhalb des Radarkreises - laeuft im selben 80ms-Takt wie
// die Sweep-Linie, damit es fluessig wirkt. render() (bei jedem Aircraft-
// Update, alle ~300ms) loescht den kompletten Inhaltsbereich per fillRect
// und zeichnet ihn neu, OHNE die Sterne erneut zu setzen - das ist
// bewusst so (wie beim Detail-Panel-Sternenfeld auch): die naechste
// tick()-Runde (spaetestens 80ms spaeter) laesst sie einfach wieder
// aufblitzen, das ist zu kurz, um als Ruckeln wahrgenommen zu werden.
// Twinkling outside the radar circle - runs at the same 80ms rate as
// the sweep line so it looks smooth. render() (on every aircraft
// update, every ~300ms) clears the entire content area with fillRect
// and redraws it WITHOUT re-setting the stars - this is
// intentional (just like the detail panel star field): the next
// tick() round (at most 80ms later) will simply make them
// twinkle again, which is too brief to be perceived as stuttering.
updateBgStars(tft, L, top);
if (prevSweepAngleDeg >= 0.0f) {
@@ -1015,16 +1016,16 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
bool inAlertRange = hp.distanceKm <= Config::LED_ALERT_RADIUS_KM;
if (inAlertRange && !ledBlinkOn) {
// Blink-Aus-Phase: frueher wurde hier ein pauschales 40x30px
// schwarzes Rechteck gezeichnet, um den Marker zu "verstecken" -
// das hat dabei aber auch die Radar-Ringe/Kompasslinien darunter
// ueberdeckt (bei jeder Hoehenfarbe gleichermassen, da diese
// Blink-Logik rein distanzbasiert ist). Stattdessen werden hier
// jetzt exakt dieselben Formen, die beim normalen Zeichnen weiter
// unten entstehen (Marker, Notfall-/Beobachtungs-Ring, Label),
// einfach in Schwarz nachgezeichnet - das macht sie pixelgenau
// wieder unsichtbar, ohne irgendetwas ausserhalb dieser Formen zu
// beruehren.
// Blink-off phase: previously a blanket 40x30px
// black rectangle was drawn to "hide" the marker -
// but that also covered the radar rings/compass lines
// underneath (equally for all altitude colors, since this
// blink logic is purely distance-based). Instead, exactly
// the same shapes that are drawn during normal rendering
// below (marker, emergency/watchlist ring, label) are
// now drawn in black - this makes them pixel-perfectly
// invisible again without touching anything outside
// those shapes.
if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK);
} else if (hp.isRotorcraft) {
@@ -1068,9 +1069,9 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
tft.setTextDatum(TL_DATUM);
}
// Info-Zeile unten (Tap-Hinweis bzw. "Leerer Himmel"-Timer) als
// Laufende Aktualisierung der letzten Sichtung fuer den "Leerer
// Himmel"-Zaehler.
// Info line at bottom (tap hint or "Empty Sky" timer) as
// continuous update of the last sighting for the "Empty Sky"
// counter.
uint8_t visibleCountNow = 0;
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
if (hitPoints[i].valid) visibleCountNow++;
@@ -1539,10 +1540,10 @@ void runHelp(TFT_eSPI& tft, int16_t top) {
void selectAircraft(const char* hex, const char* callsign) {
strncpy(selectedHex, hex, sizeof(selectedHex) - 1);
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
// wuerden unveraenderte Zeilen faelschlich als "schon da" uebersprungen.
// Forces a complete rebuild of the detail panel on the next
// render() - important if the screen was overwritten by another
// screen (e.g. the aircraft list), otherwise unchanged lines would
// be incorrectly skipped as "already there".
lastPanel.valid = false;
}

View File

@@ -21,10 +21,10 @@ void render(TFT_eSPI& tft, int16_t top);
// Shows a full-screen usage guide popup triggered by the "?" button.
void runHelp(TFT_eSPI& tft, int16_t top);
// Waehlt ein Flugzeug programmgesteuert aus (z.B. von der Flugzeugliste
// 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.
// Selects an aircraft programmatically (e.g. from the aircraft list,
// not by tapping on the radar) - so that the next render() immediately
// shows the detail panel for this aircraft, as if it were tapped directly
// on the radar.
void selectAircraft(const char* hex, const char* callsign);
struct EmergencyInfo {

View File

@@ -1,22 +1,21 @@
#pragma once
#include <Arduino.h>
// Gemeinsames Lock fuer ALLE SD-Kartenzugriffe im gesamten Projekt. Die
// SD-Bibliothek ist nicht thread-sicher: ohne dieses Lock koennen
// gleichzeitige Zugriffe von NetTask (Core 0, z.B. Flugbuch-Eintrag
// schreiben alle 8s) und dem Haupt-Loop (Core 1, z.B. Einstellungen im
// Menue speichern) das Geraet einfrieren, wenn sie zeitlich zusammentreffen.
// Rekursiv, damit verschachtelte Aufrufe (z.B. FlightLogbook::update() ruft
// intern ensureCurrentDate() auf, das seinerseits auch sperrt) nicht
// blockieren.
// Common lock for ALL SD card access in the entire project. The
// SD library is not thread-safe: without this lock,
// concurrent access by NetTask (Core 0, e.g. writing a logbook entry
// every 8s) and the main loop (Core 1, e.g. saving settings in the
// menu) can freeze the device if they coincide in time.
// Recursive, so that nested calls (e.g. FlightLogbook::update() calls
// ensureCurrentDate() internally, which also locks) do not
// block.
namespace SdMutex {
void init();
void lock();
void unlock();
// RAII-Helfer: sperrt im Konstruktor, gibt im Destruktor frei - so kann
// man das Lock nicht versehentlich vergessen freizugeben (z.B. bei
// einem fruehen "return").
// RAII helper: locks in constructor, unlocks in destructor - so you cannot
// accidentally forget to release the lock (e.g. on an early "return").
class Guard {
public:
Guard() { lock(); }

View File

@@ -153,11 +153,10 @@ bool deleteDirectoryRecursive(const char* path) {
return false;
}
// Gleiches vorsichtiges "entry.name() koennte relativ ODER absolut
// sein"-Muster wie in flight_logbook.cpp::resetAllData() - anders als
// dort aber mit echter Rekursion in Unterordner (statt sie zu
// ueberspringen), da der Flightradar-Ordner welche enthaelt (logs/,
// screenshots/).
// Same cautious "entry.name() could be relative OR absolute" pattern as
// in flight_logbook.cpp::resetAllData() - but unlike there, with actual
// recursion into subdirectories (instead of skipping them), since the
// Flightradar folder contains them (logs/, screenshots/).
File entry = dir.openNextFile();
while (entry) {
bool isDir = entry.isDirectory();

View File

@@ -16,12 +16,12 @@ namespace SdStorage {
void seedDefaultDataFiles();
void logEvent(const char* csvLine);
// Loescht rekursiv einen kompletten Ordner (alle Dateien und
// Unterordner) inklusive des Ordners selbst. Fuer den Menuepunkt
// "Einstellungen zuruecksetzen" (settings_backup.cpp::factoryReset()) -
// der einzige aktuelle Aufrufer, der damit den kompletten
// Flightradar-Ordner von der SD-Karte entfernt. Gibt false zurueck,
// wenn der Pfad nicht existiert/kein Ordner ist.
// Recursively deletes a complete folder (all files and
// subfolders) including the folder itself. For the menu item
// "Reset settings" (settings_backup.cpp::factoryReset()) -
// the only current caller, which removes the complete
// Flightradar folder from the SD card. Returns false
// if the path does not exist / is not a folder.
bool deleteDirectoryRecursive(const char* path);
}

View File

@@ -66,9 +66,9 @@ bool factoryReset() {
bool ok;
{
// Guard-Block bewusst vor dem Neustart wieder verlassen (statt
// ueber die Funktion hinweg zu halten) - reine Vorsicht, auch wenn
// ESP.restart() den Chip ohnehin sofort zuruecksetzt.
// Deliberately leave the guard block before the restart (instead of
// holding it across the function call) - purely precautionary, even
// though ESP.restart() resets the chip immediately anyway.
SdMutex::Guard guard;
ok = SdStorage::deleteDirectoryRecursive(Config::SD_ROOT_DIR);
}
@@ -76,7 +76,7 @@ bool factoryReset() {
if (ok) {
delay(200);
ESP.restart();
// Wird nie erreicht - ESP.restart() kehrt nicht zurueck.
// Never reached - ESP.restart() does not return.
}
return ok;
}

View File

@@ -2,21 +2,20 @@
#include <Arduino.h>
namespace SettingsBackup {
// onStep (falls angegeben) wird bei jedem der beiden Kopiervorgaenge
// (erst Einstellungen, dann WLAN-Zugangsdaten) direkt VOR dem
// jeweiligen Kopieren aufgerufen - der aufrufende Screen
// (menu_screen.cpp) nutzt das, um waehrend des SD-bedingt spuerbar
// langsamen Sicherns/Wiederherstellens Fortschrittspunkte auf dem
// Button anzuzeigen, statt dass der Button eingefroren wirkt.
// onStep (if provided) is called before each of the two copy operations
// (first settings, then WiFi credentials) - the calling screen
// (menu_screen.cpp) uses this to show progress dots on the button during
// the SD-card-related noticeably slow backup/restore, instead of the
// button appearing frozen.
bool backup(void (*onStep)() = nullptr);
bool restore(void (*onStep)() = nullptr);
bool hasBackup();
// Loescht den kompletten Flightradar-Ordner von der SD-Karte und
// startet das Geraet neu - beim naechsten Boot laeuft dadurch wieder
// die komplette Ersteinrichtung (siehe main.cpp: isFirstRun-Erkennung
// ueber Config::SD_SETTINGS_FILE). Kehrt nur im Fehlerfall zurueck
// (z.B. SD nicht eingehaengt) - im Erfolgsfall startet das Geraet
// neu, bevor die Funktion "zurueckkehrt" (ESP.restart()).
// Deletes the entire Flightradar folder from the SD card and
// restarts the device - on the next boot, the complete first-time
// setup will run again (see main.cpp: isFirstRun detection via
// Config::SD_SETTINGS_FILE). Only returns on error (e.g. SD not mounted)
// - on success, the device restarts before the function "returns"
// (ESP.restart()).
bool factoryReset();
}

View File

@@ -9,14 +9,14 @@ namespace SettingsStore {
namespace {
uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX;
uint8_t altFilterIdx = Config::DEFAULT_ALT_FILTER_INDEX;
bool inverted = true; // Dieses Board braucht invertDisplay(true) fuer korrekte Farben (siehe main.cpp)
bool inverted = true; // This board needs invertDisplay(true) for correct colors (see main.cpp)
uint8_t brightnessPct = Config::BRIGHTNESS_MAX_PERCENT;
bool emergencyAlertOn = true;
bool proximityAlertOn = true;
bool watchlistAlertOn = true;
// MUSS bei einer frischen Installation aus sein - sonst schreibt sich
// die SD-Karte unbemerkt voll (siehe Bestaetigungsdialog beim
// Einschalten in menu_screen.cpp + 24h-Auto-Aus in flight_logbook.cpp).
// MUST be off on a fresh installation - otherwise the SD card fills up
// unnoticed (see confirmation dialog at power-on in menu_screen.cpp
// + 24h auto-off in flight_logbook.cpp).
bool flightLogbookOn = false;
uint32_t logbookEnabledAtEpoch = 0;
char logbookSessionFile[16] = {0};
@@ -107,11 +107,10 @@ void load() {
}
f.close();
// Sicherheitsregel: das Flugbuch darf nach einem Neustart nur dann
// aktiv bleiben, wenn auch ein gueltiger Einschalt-Zeitstempel
// vorhanden ist - kein Zeitstempel bedeutet garantiert AUS, egal was in
// "flight_logbook" steht (verhindert unbemerktes Weiterlaufen z.B. nach
// einem Firmware-Update oder einer manuell bearbeiteten Datei).
// Safety rule: flight logbook may only stay active after a restart if
// a valid power-on timestamp is present - no timestamp means guaranteed
// OFF, regardless of what is in "flight_logbook" (prevents unnoticed
// continued operation e.g. after a firmware update or manually edited file).
if (flightLogbookOn && logbookEnabledAtEpoch == 0) {
flightLogbookOn = false;
}

View File

@@ -14,7 +14,7 @@ uint8_t rangeIndex();
bool displayInverted();
void setDisplayInverted(bool inverted);
// Display-Helligkeit in Prozent (Config::BRIGHTNESS_MIN_PERCENT..MAX_PERCENT).
// Display brightness in percent (Config::BRIGHTNESS_MIN_PERCENT..MAX_PERCENT).
uint8_t brightnessPercent();
void setBrightnessPercent(uint8_t percent);
@@ -30,17 +30,17 @@ uint8_t rangeIndex();
bool flightLogbookEnabled();
void setFlightLogbookEnabled(bool on);
// Unix-Zeitstempel (Sekunden), zu dem das Flugbuch zuletzt eingeschaltet
// wurde. 0 = unbekannt/nicht gesetzt. FlightLogbook::update() nutzt dies,
// um die Aufzeichnung nach genau 24 Stunden automatisch wieder
// auszuschalten (SD-Karten-Schutz, siehe Bestaetigungsdialog im Menue).
// Unix timestamp (seconds) when the flight logbook was last enabled.
// 0 = unknown/not set. FlightLogbook::update() uses this
// to automatically turn off recording after exactly 24 hours
// (SD card protection, see confirmation dialog in the menu).
uint32_t flightLogbookEnabledAtEpoch();
void setFlightLogbookEnabledAtEpoch(uint32_t epoch);
// Dateiname (ohne ".csv", z.B. "2026-08-06" oder "2026-08-06_2") der
// aktuell laufenden Flugbuch-Sitzung. "" = keine Sitzungsdatei
// hinterlegt (Flugbuch aus, oder naechste Aktivierung soll eine neue
// Datei anlegen). Siehe FlightLogbook::ensureSessionFile().
// Filename (without ".csv", e.g. "2026-08-06" or "2026-08-06_2") of the
// currently running flight logbook session. "" = no session file
// stored (logbook off, or next activation should create a new
// file). See FlightLogbook::ensureSessionFile().
String flightLogbookSessionFile();
void setFlightLogbookSessionFile(const String& label);
@@ -53,9 +53,9 @@ uint8_t rangeIndex();
bool nightDimmingEnabled();
void setNightDimmingEnabled(bool on);
// Ruhebildschirm bei Inaktivitaets-Timeout (siehe main.cpp) - AUS per
// Default, damit sich am bisherigen Verhalten (Backlight komplett aus)
// nichts aendert, wer es nicht explizit einschaltet.
// Screensaver during inactivity timeout (see main.cpp) - OFF by
// default, so the previous behavior (backlight completely off)
// does not change unless explicitly enabled.
bool screensaverEnabled();
void setScreensaverEnabled(bool on);
@@ -65,12 +65,12 @@ bool hideGroundVehicles();
bool ecoModeEnabled();
void setEcoModeEnabled(bool on);
// Sprache der Benutzeroberflaeche: 0=EN,1=DE,2=FR,3=TR,4=ES,5=IT.
// UI language: 0=EN,1=DE,2=FR,3=TR,4=ES,5=IT.
uint8_t language();
void setLanguage(uint8_t lang);
// Einheiten-Modus: 0=Auto (per IP-Standort geschaetzt), 1=Metrisch
// erzwingen, 2=Imperial (Fuss/Knoten/Meilen) erzwingen.
// Units mode: 0=Auto (estimated by IP location), 1=Metric
// forced, 2=Imperial (feet/knots/miles) forced.
uint8_t unitsMode();
void setUnitsMode(uint8_t mode);
}

View File

@@ -21,17 +21,17 @@ namespace {
tft.drawFastVLine(cx, cy - 80, 160, dim);
}
// Dreht einen Punkt (px, py) im lokalen Koordinatensystem der kleinen
// Deko-Symbole um angleDeg und verschiebt ihn nach (x, y).
// Rotates a point (px, py) in the local coordinate system of the small
// decorative symbols by angleDeg and translates it to (x, y).
void rotatePoint(int16_t x, int16_t y, float sinA, float cosA,
float px, float py, int16_t& outX, int16_t& outY) {
outX = x + (int16_t)lroundf(px * cosA - py * sinA);
outY = y + (int16_t)lroundf(px * sinA + py * cosA);
}
// Kleines Flugzeug-Silhouette aus gefuellten Dreiecken (Rumpf, Tragflaechen,
// Leitwerk), analog zum grossen Flugzeug in der Mitte, aber schlichter und
// in beliebiger Richtung (angleDeg, 0 = Nase zeigt nach oben).
// Small airplane silhouette made of filled triangles (fuselage, wings,
// tail), analogous to the large airplane in the center, but simpler and
// in any direction (angleDeg, 0 = nose pointing up).
void drawMiniJet(TFT_eSPI& tft, int16_t x, int16_t y, uint16_t color, float angleDeg) {
float rad = angleDeg * (PI / 180.0f);
float s = sinf(rad), c = cosf(rad);
@@ -55,8 +55,8 @@ namespace {
tft.fillTriangle(tlx, tly, trx, try_, ttx, tty, color);
}
// Kleiner Helikopter: Rotorkreis mit Blaettern, gefuellter Rumpf, Heckausleger
// mit Leitwerk. angleDeg bestimmt die Flugrichtung (0 = Nase nach oben).
// Small helicopter: rotor circle with blades, filled fuselage, tail boom
// with tail fin. angleDeg determines flight direction (0 = nose up).
void drawMiniHeli(TFT_eSPI& tft, int16_t x, int16_t y, uint16_t color, float angleDeg) {
float rad = angleDeg * (PI / 180.0f);
float s = sinf(rad), c = cosf(rad);

View File

@@ -7,8 +7,8 @@ namespace SplashScreen {
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE);
// Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind.
// Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend
// des Wartens dieselbe Sterne-Animation wie die Menues.
// Blocks until at least MIN_DISPLAY_MS have passed since begin().
// Call right before leaving the splash screen. Draws the same
// star animation as the menus while waiting.
void waitRemaining(TFT_eSPI& tft);
}

View File

@@ -136,9 +136,9 @@ void run(TFT_eSPI& tft) {
tft.print(I18n::t(StringId::LOADING));
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
// listDaySummaries() statt listDays(): mehrere Sitzungs-Dateien am
// selben Kalendertag (z.B. nach erneutem Einschalten) sollen hier
// weiterhin als EIN Balken pro Tag zaehlen statt als mehrere.
// Using listDaySummaries() instead of listDays(): multiple session files
// on the same calendar day (e.g., after restarting) should still count
// as ONE bar per day instead of multiple.
uint8_t count = FlightLogbook::listDaySummaries(days, MAX_DAYS_QUERIED);
uint8_t barCount = (count > MAX_BARS) ? MAX_BARS : count;

View File

@@ -43,7 +43,7 @@ namespace {
constexpr int16_t ROW3_Y = 106;
constexpr int16_t ROW4_Y = 140;
constexpr int16_t UPTIME_Y = 174;
constexpr int16_t TOP_ALT_Y = 194; // zweizeilig wie drawStatRow (Label + Wert)
constexpr int16_t TOP_ALT_Y = 194; // two lines like drawStatRow (label + value)
}
void run(TFT_eSPI& tft) {
@@ -100,8 +100,8 @@ void run(TFT_eSPI& tft) {
if (topAlt.found) {
String csign = topAlt.callsign[0] ? String(topAlt.callsign) : "?";
// Respektiert jetzt die Einheiten-Einstellung (Menue > Einheiten)
// - vorher immer "(XXXX ft)", auch bei Metrisch eingestellt.
// Now respects the units setting (Menu > Units)
// - previously always "(XXXX ft)", even when metric was set.
char altBuf[24];
if (LocationManager::useMetricUnits()) {
snprintf(altBuf, sizeof(altBuf), "%s (%ldm)", csign.c_str(), (long)Units::feetToMeters((float)topAlt.altitudeFt));

View File

@@ -3,8 +3,8 @@
#include <TFT_eSPI.h>
namespace StatsScreen {
// Blockierend: zeigt einfache Statistiken aus dem Flight Logbook
// (heute gesehen, insgesamt gesehen, Anzahl Tage geloggt). Kehrt zurueck,
// sobald "Back" angetippt wird.
// Blocking: shows simple statistics from the Flight Logbook
// (seen today, total seen, number of days logged). Returns as soon
// as "Back" is tapped.
void run(TFT_eSPI& tft);
}

View File

@@ -15,11 +15,10 @@ namespace {
return n;
}
// Klassischer "Sunrise/Sunset Algorithm" (Almanac for Computers, 1990,
// weit verbreitete Formel). sunrise=true fuer Sonnenaufgang, false fuer
// Sonnenuntergang. Gibt false zurueck, wenn die Sonne an diesem Tag/
// Breitengrad ueberhaupt nicht auf-/untergeht (Polartag/-nacht) - dann
// ist outHour unveraendert.
// Classic "Sunrise/Sunset Algorithm" (Almanac for Computers, 1990,
// widely used formula). sunrise=true for sunrise, false for sunset.
// Returns false if the sun does not rise/set at all on this day/latitude
// (polar day/night) - then outHour is unchanged.
bool calc(double lat, double lon, int N, int32_t utcOffsetSeconds, bool sunrise, float& outHour) {
double lngHour = lon / 15.0;
double t = sunrise ? (N + ((6.0 - lngHour) / 24.0)) : (N + ((18.0 - lngHour) / 24.0));
@@ -34,9 +33,9 @@ namespace {
RA = fmod(RA, 360.0);
if (RA < 0) RA += 360.0;
// RA muss im selben 90-Grad-Quadranten wie L liegen (atan() liefert
// nur Werte im Bereich -90..90 Grad, muss also entsprechend
// "zurueckgeklappt" werden).
// RA must be in the same 90-degree quadrant as L (atan() only returns
// values in the range -90..90 degrees, so it must be "folded back"
// accordingly).
double lQuadrant = floor(L / 90.0) * 90.0;
double raQuadrant = floor(RA / 90.0) * 90.0;
RA = RA + (lQuadrant - raQuadrant);
@@ -45,10 +44,10 @@ namespace {
double sinDec = 0.39782 * sin(L * DEG2RAD);
double cosDec = cos(asin(sinDec));
constexpr double ZENITH = 90.833; // offizieller Zenit inkl. atm. Refraktion
constexpr double ZENITH = 90.833; // official zenith including atmospheric refraction
double cosH = (cos(ZENITH * DEG2RAD) - (sinDec * sin(lat * DEG2RAD))) / (cosDec * cos(lat * DEG2RAD));
if (cosH > 1.0 || cosH < -1.0) return false; // Polartag/-nacht
if (cosH > 1.0 || cosH < -1.0) return false; // polar day/night
double H = sunrise ? (360.0 - RAD2DEG * acos(cosH)) : (RAD2DEG * acos(cosH));
H = H / 15.0;
@@ -80,12 +79,12 @@ Result compute(double lat, double lon, int year, int month, int day, int32_t utc
bool setOk = calc(lat, lon, N, utcOffsetSeconds, false, sunset);
if (!riseOk || !setOk) {
// Polartag (Sonne geht nicht unter) vs. Polarnacht (Sonne geht nicht
// auf): grobe Naeherung ueber "hohe Sonne im Sommerhalbjahr der
// jeweiligen Hemisphaere = Polartag, sonst Polarnacht" - reicht fuer
// eine Nachtdimmung an den seltenen Orten/Tagen, an denen das
// ueberhaupt vorkommt, voellig aus.
bool northernSummerHalf = (N > 79 && N < 265); // grob Fruehlings-/Herbst-Tagundnachtgleiche
// Polar day (sun does not set) vs. polar night (sun does not rise):
// rough approximation via "high sun in the summer half of the
// respective hemisphere = polar day, otherwise polar night" -
// completely sufficient for night dimming on the rare locations/days
// where this occurs at all.
bool northernSummerHalf = (N > 79 && N < 265); // roughly spring/autumn equinox
bool polarDay = (lat > 0) == northernSummerHalf;
r.valid = true;
r.alwaysDay = polarDay;

View File

@@ -1,24 +1,24 @@
#pragma once
#include <Arduino.h>
// Berechnet Sonnenauf-/untergang (lokale Zeit) fuer einen gegebenen Ort und
// Tag - Grundlage fuer die standortbasierte Nachtdimmung (siehe main.cpp::
// isNightDimHours()), die das vorherige feste 22:00-06:00-Fenster ersetzt.
// Calculates sunrise/sunset (local time) for a given location and
// day - basis for the location-based night dimming (see main.cpp::
// isNightDimHours()), which replaces the previous fixed 22:00-06:00 window.
namespace SunTimes {
struct Result {
bool valid = false; // false = Standort noch unbekannt (lat/lon == 0,0)
bool alwaysDay = false; // Polartag: Sonne geht heute an diesem Ort nicht unter
bool alwaysNight = false; // Polarnacht: Sonne geht heute an diesem Ort nicht auf
float sunriseHour = 0; // Lokale Dezimalstunde (0-24), nur gueltig wenn weder
float sunsetHour = 0; // alwaysDay noch alwaysNight gesetzt ist.
bool valid = false; // false = location still unknown (lat/lon == 0,0)
bool alwaysDay = false; // polar day: sun does not set today at this location
bool alwaysNight = false; // polar night: sun does not rise today at this location
float sunriseHour = 0; // local decimal hour (0-24), only valid if neither
float sunsetHour = 0; // alwaysDay nor alwaysNight is set.
};
// year/month/day sind lokale Kalenderdatumswerte (z.B. aus localtime_r()),
// utcOffsetSeconds die bekannte Zeitzonenverschiebung (siehe
// LocationManager::utcOffsetSeconds()). Nutzt den klassischen "Almanac"-
// Sonnenauf-/untergangsalgorithmus (Sonnenzenit 90.833 Grad, beruecksichtigt
// atmosphaerische Refraktion) - Genauigkeit liegt typischerweise im Bereich
// weniger Minuten, fuer eine sanfte Backlight-Dimmung mehr als ausreichend.
// year/month/day are local calendar date values (e.g. from localtime_r()),
// utcOffsetSeconds is the known timezone offset (see
// LocationManager::utcOffsetSeconds()). Uses the classic "Almanac"
// sunrise/sunset algorithm (solar zenith 90.833 degrees, accounting for
// atmospheric refraction) - accuracy is typically within a few minutes,
// more than sufficient for smooth backlight dimming.
Result compute(double lat, double lon, int year, int month, int day, int32_t utcOffsetSeconds);
}

View File

@@ -26,11 +26,11 @@ namespace {
String onOff(bool on) { return I18n::t(on ? StringId::ON : StringId::OFF); }
// Einfacher Zeilenumbruch-Helfer, gleiches Prinzip wie in
// menu_screen.cpp (dort mit zusaetzlicher Scroll-Unterstuetzung, hier
// bewusst ohne - der Beschreibungstext ist kurz genug, dass Scrollen
// nicht noetig ist). draw=false liefert nur die Gesamthoehe, ohne etwas
// zu zeichnen (fuer die vorab-Platzberechnung des Zurueck-Buttons).
// Simple line wrap helper, same principle as in
// menu_screen.cpp (with additional scroll support there, here
// deliberately without - the description text is short enough that scrolling
// is not needed). draw=false returns only the total height, without drawing
// anything (for the advance space calculation of the Back button).
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text, bool draw) {
int16_t y = startY;
@@ -57,10 +57,10 @@ namespace {
return (int16_t)(y - startY);
}
// Schieberegler-Positionen: 1..Config::SCREEN_TIMEOUT_MAX_MINUTES
// Minuten (je eine Position pro Minute) plus eine zusaetzliche
// Endposition ganz rechts fuer "Nie" (kein Timeout) - intern weiterhin
// als 0 gespeichert, wie schon vor diesem Screen.
// Slider positions: 1..Config::SCREEN_TIMEOUT_MAX_MINUTES
// minutes (one position per minute) plus one additional
// end position at the far right for "Never" (no timeout) - internally stored
// as 0, as before this screen.
constexpr uint8_t STEP_COUNT = Config::SCREEN_TIMEOUT_MAX_MINUTES + 1;
uint8_t indexFromMinutes(uint8_t minutes) {
@@ -91,18 +91,18 @@ void run(TFT_eSPI& tft) {
constexpr int16_t TRACK_W = Config::SCREEN_WIDTH - 2 * TRACK_X;
constexpr int16_t TRACK_H = 6;
constexpr int16_t THUMB_R = 12;
// Grosszuegige vertikale Trefferzone rund um den duennen Regler-Strich -
// sonst muesste man beim Ziehen extrem praezise genau auf die paar
// Pixel der Linie treffen.
// Generous vertical hit zone around the thin slider track -
// otherwise you would have to hit the few pixels of the line
// extremely precisely when dragging.
constexpr int16_t TRACK_HIT_Y_MIN = TRACK_Y - 24;
constexpr int16_t TRACK_HIT_Y_MAX = TRACK_Y + 24;
Rect screensaverBtn = {10, 135, (int16_t)(Config::SCREEN_WIDTH - 20), 36};
// Beschreibungstext-Hoehe (haengt von der jeweils eingestellten Sprache
// ab) einmalig vorab messen, damit der Zurueck-Button IMMER direkt
// darunter landet - nie mit dem Text ueberlappend und nie unnoetig viel
// Leerraum lassend. Gleiches Prinzip wie bei confirmWarningScreen()/
// Description text height (depends on the selected language)
// measured once in advance, so the Back button ALWAYS lands directly
// below it - never overlapping the text and never leaving unnecessarily
// much empty space. Same principle as confirmWarningScreen()/
// infoScreen() in menu_screen.cpp.
constexpr int16_t DESC_Y = 185;
constexpr int16_t DESC_LINE_H = 14;
@@ -110,9 +110,9 @@ void run(TFT_eSPI& tft) {
int16_t descH = layoutWrapped(tft, 10, DESC_Y, DESC_MAX_WIDTH, DESC_LINE_H,
I18n::t(StringId::TIMEOUT_SCREENSAVER_DESC), false);
int16_t backY = (int16_t)(DESC_Y + descH + 10);
// Sicherheitsnetz, falls eine Uebersetzung doch mal laenger ausfaellt,
// als der verfuegbare Platz hergibt - der Zurueck-Button bleibt so
// IMMER erreichbar, statt vom Bildschirmrand abgeschnitten zu werden.
// Safety net, if a translation ever turns out longer
// than the available space allows - the Back button stays
// ALWAYS reachable, instead of being cut off at the screen edge.
constexpr int16_t BACK_Y_MAX = Config::SCREEN_HEIGHT - 46;
if (backY > BACK_Y_MAX) backY = BACK_Y_MAX;
Rect backBtn = {10, backY, (int16_t)(Config::SCREEN_WIDTH - 20), 36};
@@ -174,19 +174,19 @@ void run(TFT_eSPI& tft) {
}
dragging = true;
} else if (dragging && !live.touched) {
// Loslassen: jetzt erst dauerhaft speichern (nicht bei jeder
// Zwischenposition waehrend des Ziehens selbst, um unnoetig
// viele SD-Kartenschreibvorgaenge zu vermeiden).
// Release: now save persistently (not during every
// intermediate slider position while dragging, to avoid unnecessary
// SD card writes).
SettingsStore::setScreenTimeoutMinutes(minutesFromIndex(index));
dragging = false;
}
// wasTapped() IMMER aufrufen (auch waehrend des Ziehens), damit ihr
// interner Loslassen-Erkennungszustand synchron bleibt - nur die
// AUSWERTUNG des Ergebnisses wird waehrend des Ziehens unterdrueckt.
// Wuerde man den Aufruf selbst waehrend des Ziehens auslassen,
// koennte beim Loslassen faelschlich ein "Tap" mit einer veralteten
// Position (von VOR Beginn des Ziehens) gemeldet werden.
// ALWAYS call wasTapped() (even while dragging), so its
// internal release detection state stays in sync - only the
// EVALUATION of the result is suppressed while dragging.
// If the call itself were omitted while dragging,
// releasing could incorrectly report a "Tap" with a stale
// position (from BEFORE the drag started).
TouchInput::Point tap;
bool tapped = TouchInput::wasTapped(tap);
if (!dragging && tapped) {

View File

@@ -2,12 +2,12 @@
#include <Arduino.h>
#include <TFT_eSPI.h>
// Bildschirm-Timeout einstellen (Menue > System > Bildschirm-Timeout) -
// Schieberegler von Config::SCREEN_TIMEOUT_MIN_MINUTES bis
// Config::SCREEN_TIMEOUT_MAX_MINUTES, danach "Nie" als eigene Endposition.
// Der Ruhebildschirm-Umschalter (siehe SettingsStore::screensaverEnabled())
// lebt ebenfalls hier, direkt unter dem Regler, da er inhaltlich eng mit
// dem Timeout zusammenhaengt (bestimmt nur, WAS beim Timeout passiert).
// Screen timeout setting (Menu > System > Screen Timeout) -
// Slider from Config::SCREEN_TIMEOUT_MIN_MINUTES to
// Config::SCREEN_TIMEOUT_MAX_MINUTES, then "Never" as a separate end position.
// The screensaver toggle (see SettingsStore::screensaverEnabled())
// also lives here, directly below the slider, since it is closely related to
// the timeout (only determines WHAT happens on timeout).
namespace TimeoutScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -10,11 +10,11 @@ namespace {
SPIClass touchSpi(VSPI);
XPT2046_Touchscreen touch(Config::TOUCH_CS_PIN, Config::TOUCH_IRQ_PIN);
constexpr int16_t MARGIN_PX = 24; // Abstand der Kalibrierungspunkte vom Rand
constexpr int16_t MARGIN_PX = 24; // Distance of calibration points from the edge
// Rohe Min/Max-Werte aus der Kalibrierung. Bis zur ersten Kalibrierung
// grosszuegige Standardwerte, damit die Kalibrierungs-Routine selbst schon
// grob nutzbare (wenn auch ungenaue) Koordinaten bekommt.
// Raw min/max values from calibration. Until first calibration,
// generous default values so the calibration routine itself already gets
// roughly usable (though inaccurate) coordinates.
int16_t calXmin = 200;
int16_t calXmax = 3900;
int16_t calYmin = 200;
@@ -116,7 +116,7 @@ bool wasTapped(Point& outPoint) {
bool fired = false;
if (!cur.touched && lastTouched) {
// Loslassen erkannt -> Tap an der zuletzt bekannten Position melden.
// Release detected -> report tap at last known position.
outPoint = lastRaw;
fired = true;
}

View File

@@ -11,22 +11,22 @@ namespace TouchInput {
void begin();
// Laedt calibration.txt von der SD-Karte. Gibt false zurueck, wenn keine
// vorhanden oder ungueltig ist.
// Loads calibration.txt from the SD card. Returns false if none exists
// or it is invalid.
bool loadCalibration();
void setCalibration(int16_t rawXmin, int16_t rawXmax, int16_t rawYmin, int16_t rawYmax);
void saveCalibration();
bool hasCalibration();
// Aktueller Touch-Zustand, roh (unkalibriert), z.B. fuer die
// Kalibrierungs-Routine selbst.
// Current touch state, raw (uncalibrated), e.g. for the calibration
// routine itself.
Point rawPoint();
// Aktueller Touch-Zustand, auf Bildschirmkoordinaten (0..SCREEN_WIDTH-1 /
// 0..SCREEN_HEIGHT-1) umgerechnet und geclampt.
// Current touch state, converted to screen coordinates (0..SCREEN_WIDTH-1 /
// 0..SCREEN_HEIGHT-1) and clamped.
Point mappedPoint();
// Liefert genau einmal pro physischer Beruehrung "true" (beim Loslassen),
// mitsamt der zuletzt bekannten Position. Fuer Buttons/Tastatur gedacht.
// Returns "true" exactly once per physical touch (on release), along with
// the last known position. Intended for buttons/keyboard use.
bool wasTapped(Point& outPoint);
}

View File

@@ -1,7 +1,7 @@
#pragma once
// Automatisch generierter Font (DejaVu Sans Mono, 11px) fuer TFT_eSPI GFXFF.
// Deckt Basis-ASCII + Umlaute/Akzente fuer DE/TR/FR/ES/IT ab (U+0020-U+015F).
// NICHT von Hand bearbeiten - siehe Kommentar in i18n.h fuer den Generator-Hinweis.
// Auto-generated font (DejaVu Sans Mono, 11px) for TFT_eSPI GFXFF.
// Covers basic ASCII + umlauts/accents for DE/TR/FR/ES/IT (U+0020-U+015F).
// DO NOT edit manually - see comment in i18n.h for the generator hint.
#include <pgmspace.h>
const uint8_t UiFont11ptBitmaps[] PROGMEM = {

View File

@@ -19,9 +19,9 @@ namespace {
double lastLon = 0;
bool hasLastLocation = false;
// Ordnet den WMO-Wettercode von Open-Meteo (Feld "weathercode", siehe
// https://open-meteo.com/en/docs) einer der wenigen Icon-Kategorien zu,
// die main.cpp zeichnen kann.
// Maps the WMO weather code from Open-Meteo (field "weathercode", see
// https://open-meteo.com/en/docs) to one of the few icon categories
// that main.cpp can draw.
Condition conditionFromWmoCode(int code) {
if (code == 0) return Condition::Clear;
if (code == 1 || code == 2) return Condition::PartlyCloudy;
@@ -56,10 +56,10 @@ namespace {
return;
}
// Body erst komplett als String einsammeln (getString() kuemmert
// sich zuverlaessig um Chunked-Transfer-Encoding), statt direkt aus
// http.getStream() zu parsen - Letzteres scheiterte bei Open-Meteo
// zuverlaessig mit einem ArduinoJson-"InvalidInput"-Fehler.
// Collect body as a complete string first (getString() reliably handles
// chunked transfer encoding) instead of parsing directly from
// http.getStream() - the latter reliably failed with Open-Meteo
// with an ArduinoJson "InvalidInput" error.
String body = http.getString();
http.end();
@@ -79,10 +79,9 @@ void update() {
LocationManager::getHomeLocation(lat, lon);
if (lat == 0 && lon == 0) return;
// Deutliche Standort-Aenderung (z.B. anderes Standort-Preset aktiviert)
// - sofort neu abfragen statt bis zum naechsten regulaeren Intervall zu
// warten, damit das Icon nicht minutenlang das Wetter des alten
// Standorts zeigt.
// Significant location change (e.g., another location preset activated)
// - query immediately instead of waiting for the next regular interval,
// so the icon does not show the old location's weather for minutes.
bool locationChanged = !hasLastLocation ||
fabs(lat - lastLat) > 0.01 || fabs(lon - lastLon) > 0.01;

View File

@@ -1,15 +1,15 @@
#pragma once
#include <Arduino.h>
// Sehr einfache Wetteranzeige (Icon im Header, dort wo frueher der
// Cam-Button war) - fragt periodisch die aktuelle Wetterlage fuer den
// gerade aktiven Standort ab (also inkl. aktivem Standort-Preset, siehe
// LocationManager::getHomeLocation() - wenn dort z.B. Mailand ausgewaehlt
// ist, zeigt das Icon das Wetter in Mailand). Nutzt die kostenlose
// Open-Meteo-API (kein API-Key noetig).
// Very simple weather display (icon in the header, where the camera button
// used to be) - periodically queries the current weather for the currently
// active location (including active location preset, see
// LocationManager::getHomeLocation() - if e.g. Milan is selected there,
// the icon shows the weather in Milan). Uses the free Open-Meteo API (no
// API key needed).
namespace Weather {
enum class Condition {
Unknown, // noch keine erfolgreiche Abfrage - Icon zeigt nichts an
Unknown, // no successful query yet - icon shows nothing
Clear,
PartlyCloudy,
Cloudy,
@@ -18,16 +18,14 @@ namespace Weather {
Thunderstorm
};
// Muss regelmaessig aus dem NetTask (Core 0) aufgerufen werden - kuemmert
// sich intern um das Abfrage-Intervall (Config::WEATHER_FETCH_INTERVAL_MS)
// und erkennt eine Standort-Aenderung (z.B. anderes aktives Preset), um
// dann sofort neu abzufragen statt bis zum naechsten reguleren Intervall
// zu warten.
// Must be called regularly from NetTask (Core 0) - handles the query
// interval internally (Config::WEATHER_FETCH_INTERVAL_MS) and detects
// location changes (e.g. different active preset) to immediately re-query
// instead of waiting for the next regular interval.
void update();
// Aktuell bekannte Wetterlage, threadsicher genug fuer diesen Zweck (ein
// einzelnes uint8-artiges Enum, das nur vom NetTask geschrieben und vom
// UI-Thread gelesen wird - kein Lock noetig, ein kurzfristig veraltetes
// Lesen ist unkritisch).
// Currently known weather condition, thread-safe enough for this purpose (a
// single uint8-like enum, only written by NetTask and read by the UI thread
// - no lock needed, a briefly stale read is harmless).
Condition current();
}

View File

@@ -19,9 +19,9 @@ namespace {
WebServer server(80);
// Nur reine Dateinamen/Labels aus Formularfeldern akzeptieren - kein
// "/" und kein ".." - damit ueber die WebUI kein Ausbruch aus dem
// jeweiligen SD-Verzeichnis moeglich ist (Pfad-Traversal).
// Only accept plain filenames/labels from form fields - no
// "/" and no ".." - so that no escape from the respective
// SD directory is possible via the WebUI (path traversal).
bool isSafeName(const String& name) {
if (name.length() == 0 || name.length() > 40) return false;
if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) return false;
@@ -29,14 +29,14 @@ namespace {
return true;
}
// Absichtliche Duplikate der gleichnamigen (lokalen/statischen) Funktionen
// aus radar_screen.cpp - dort nicht exportiert, und nach der im Projekt
// etablierten Konvention "jeder Screen/jedes Modul dupliziert seine
// eigenen kleinen Helfer statt eines gemeinsamen Moduls" (siehe z.B.
// timeout_screen.cpp) bewusst hier erneut definiert statt radar_screen.cpp
// umzubauen. Bei Aenderungen an der Logik in radar_screen.cpp bitte diese
// Kopie hier synchron halten, damit das WebUI-Radar dieselben Ringe/
// Markierungen zeigt wie das Geraete-Display.
// Intentional duplicates of the same-named (local/static) functions
// from radar_screen.cpp - not exported there, and following the project's
// established convention "each screen/module duplicates its own small
// helpers instead of a shared module" (see e.g. timeout_screen.cpp),
// deliberately redefined here instead of refactoring radar_screen.cpp.
// If the logic in radar_screen.cpp changes, please keep this copy
// in sync so that the WebUI radar shows the same rings/markers as the
// device display.
bool isEmergencySquawkWeb(const char* squawk) {
if (!squawk[0]) return false;
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
@@ -45,15 +45,15 @@ namespace {
return false;
}
// "Auffaellig" (oranger Ring) beschraenkt sich aktuell auf gar nichts -
// der Teil ueber Militaer-/Regierungs-Rufzeichen-Praefixe
// (Config::NOTABLE_CALLSIGN_PREFIXES) ist NICHT umgesetzt, weil diese
// Konstante nirgends im Projekt existiert (auch nicht in
// radar_screen.cpp) - das zugehoerige Feature wurde bisher nie
// tatsaechlich spezifiziert/implementiert. "notable" wird deshalb unten
// in handleRadarJson() fest auf false gesetzt. Sobald es eine echte
// Praefixliste gibt, hier eine isNotableCallsignWeb()-Funktion analog zu
// isEmergencySquawkWeb() ergaenzen.
// "Notable" (orange ring) is currently limited to nothing at all -
// the part about military/government callsign prefixes
// (Config::NOTABLE_CALLSIGN_PREFIXES) is NOT implemented, because this
// constant does not exist anywhere in the project (not even in
// radar_screen.cpp) - the associated feature has never actually been
// specified/implemented. "notable" is therefore hardcoded to false
// below in handleRadarJson(). As soon as there is a real prefix list,
// add an isNotableCallsignWeb() function analogous to
// isEmergencySquawkWeb() here.
bool isHeavyCategoryWeb(const char* category) {
return category[0] == 'A' && category[1] == '5';
}
@@ -73,10 +73,9 @@ namespace {
html += "button{background:#0a0f0d;color:#ff3b3b;border:1px solid #ff3b3b;border-radius:4px;padding:3px 10px;font-family:inherit;cursor:pointer;}";
html += "button:hover{background:#ff3b3b;color:#0a0f0d;}";
html += ".dl{color:#39ff14;text-decoration:none;border:1px solid #39ff14;border-radius:4px;padding:3px 10px;margin-right:6px;display:inline-block;}";
// Gruen statt Rot fuer "Hinzufuegen"-Buttons - die roten button{}-Regeln
// oben bleiben fuer alle destruktiven "Entfernen/Loeschen"-Buttons
// unveraendert, .addbtn ist ausschliesslich fuer die neuen Listen-
// Formulare (siehe handleLists()) gedacht.
// Green instead of red for "Add" buttons - the red button{} rules
// above stay unchanged for all destructive "Remove/Delete" buttons.
// .addbtn is exclusively for the new list forms (see handleLists()).
html += ".addbtn{background:#0a0f0d;color:#39ff14;border:1px solid #39ff14;border-radius:4px;padding:3px 10px;font-family:inherit;cursor:pointer;}";
html += ".addbtn:hover{background:#39ff14;color:#0a0f0d;}";
html += "input[type=text]{background:#0a0f0d;color:#39ff14;border:1px solid #39ff14;border-radius:4px;padding:5px 8px;font-family:inherit;}";
@@ -92,19 +91,19 @@ namespace {
return html;
}
// Live-Radar-Ansicht fuer die Startseite: ein <canvas>, das per JavaScript
// alle paar Sekunden /radar.json abruft und die Flugzeuge polar (Peilung/
// Distanz, genau wie auf dem Geraete-Display) zeichnet. Bewusst per
// fetch()-Polling statt WebSocket/SSE gehalten - deutlich weniger Code
// und Speicherbedarf auf dem ESP32, und fuer eine gelegentlich vom Handy
// aus aufgerufene Seite voellig ausreichend.
// Live radar view for the start page: a <canvas> that uses JavaScript
// to fetch /radar.json every few seconds and draw the aircraft polar
// (bearing/distance, exactly like on the device display). Intentionally
// kept with fetch()-polling instead of WebSocket/SSE - significantly less
// code and memory usage on the ESP32, and perfectly adequate for a page
// occasionally opened from a phone.
//
// Der Reichweiten-Waehler (<select>) ist rein clientseitig/pro Seiten-
// aufruf - er aendert NICHT die Geraete-Einstellung (SettingsStore::
// rangeIndex()), sondern wird als "range_km"-Query-Parameter an
// /radar.json mitgeschickt (siehe handleRadarJson()) und erlaubt so ein
// unabhaengiges Herein-/Herauszoomen auf dem Handy, ohne das Geraete-
// Display zu beeinflussen. Default ist die aktuelle Geraete-Reichweite.
// The range selector (<select>) is purely client-side/per page view -
// it does NOT change the device setting (SettingsStore::
// rangeIndex()), but is sent as a "range_km" query parameter to
// /radar.json (see handleRadarJson()) and allows independent zooming
// in/out on the phone without affecting the device display. Default is
// the current device range.
void appendRadarSection(String& html) {
float deviceRangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
@@ -131,10 +130,10 @@ namespace {
html += "var markers=[];";
html += "var selectedHex=null;";
// Hintergrund-Sterne AUSSERHALB des Radarkreises - gleiches Prinzip
// wie updateBgStars()/initBgStarsIfNeeded() in radar_screen.cpp
// (Rejection-Sampling, damit kein Stern innerhalb des Kreises
// landet), hier per Canvas/JS statt TFT_eSPI nachgebaut.
// Background stars OUTSIDE the radar circle - same principle
// as updateBgStars()/initBgStarsIfNeeded() in radar_screen.cpp
// (rejection sampling, so no star lands inside the circle),
// replicated here via Canvas/JS instead of TFT_eSPI.
html += "var stars=[];";
html += "(function(){var minDistSq=(R+6)*(R+6);for(var i=0;i<24;i++){var x,y,tries=0;";
html += "do{x=4+Math.random()*(W-8);y=4+Math.random()*(H-8);tries++;}";
@@ -155,9 +154,9 @@ namespace {
html += "for(var ring=1;ring<=3;ring++){var r=R*ring/3;ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.stroke();";
html += "ctx.fillText(Math.round(data.range_km*ring/3)+' km',cx+4,cy-r+10);}";
html += "ctx.strokeStyle='#12261a';ctx.beginPath();ctx.moveTo(cx-R,cy);ctx.lineTo(cx+R,cy);ctx.moveTo(cx,cy-R);ctx.lineTo(cx,cy+R);ctx.stroke();";
// Alle vier Himmelsrichtungen (N/E/S/W), genau wie
// drawStaticBackground() in radar_screen.cpp - vorher stand hier nur
// "N", was auf Nachfrage ergaenzt wurde.
// All four cardinal directions (N/E/S/W), exactly like
// drawStaticBackground() in radar_screen.cpp - previously only
// "N" was shown here, which was expanded upon request.
html += "ctx.fillStyle='#39ff14';ctx.textAlign='center';ctx.fillText('N',cx,cy-R-8);";
html += "ctx.fillText('S',cx,cy+R+16);";
html += "ctx.textAlign='left';ctx.fillText('E',cx+R+4,cy+3);";
@@ -180,26 +179,26 @@ namespace {
html += "if(a.emergency){ctx.strokeStyle='#ff3b3b';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
html += "else if(a.watched){ctx.strokeStyle='#00e5ff';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
html += "else if(a.notable){ctx.strokeStyle='#ff9f1a';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
// Ausgewaehltes Flugzeug (per Klick/Tap, siehe unten) bekommt einen
// weissen Auswahlring, gleiches Prinzip wie isSelected auf dem
// Geraete-Display (radar_screen.cpp render()).
// Selected aircraft (by click/tap, see below) gets a
// white selection ring, same principle as isSelected on the
// device display (radar_screen.cpp render()).
html += "if(a.hex===selectedHex){ctx.strokeStyle='#ffffff';ctx.beginPath();ctx.arc(x,y,11,0,Math.PI*2);ctx.stroke();}";
html += "ctx.fillStyle=color;ctx.textAlign='center';ctx.fillText(a.callsign,x,y-8);";
html += "markers.push({x:x,y:y,a:a});";
html += "});";
html += "status.textContent=(data.aircraft||[]).length+' aircraft \\u00b7 range '+data.range_km+' km';";
// Falls das ausgewaehlte Flugzeug in dieser Aktualisierung nicht
// mehr vorkommt (z.B. aus der Reichweite geflogen), Infobox wieder
// schliessen statt veraltete Daten stehen zu lassen.
// If the selected aircraft no longer appears in this update
// (e.g. flew out of range), close the info box instead of
// showing stale data.
html += "if(selectedHex){var found=markers.filter(function(m){return m.a.hex===selectedHex;})[0];";
html += "if(found){showInfo(found.a);}else{selectedHex=null;hideInfo();}}";
html += "}";
// Info-Panel fuer ein angetipptes Flugzeug - bewusst eine eigene,
// stehenbleibende Box (kein Tooltip/Popup, das beim naechsten
// Neuzeichnen einfach verschwindet), mit explizitem Schliessen-Link,
// gleiches Grundprinzip wie infoScreen() am Geraet: der Nutzer soll
// aktiv entscheiden, wann die Info wieder verschwindet.
// Info panel for a tapped aircraft - intentionally a dedicated,
// persistent box (not a tooltip/popup that disappears on the next
// redraw), with an explicit close link, same basic principle as
// infoScreen() on the device: the user should actively decide when
// the info disappears.
html += "function fmtNum(n,d){return (typeof n==='number')?n.toFixed(d):'?';}";
html += "function showInfo(a){";
html += "var lines=[];";
@@ -233,11 +232,11 @@ namespace {
html += "}";
html += "poll();";
html += "setInterval(poll,3000);";
// Schnellerer, rein lokaler Redraw-Takt (alle 150ms, ohne Netzwerk-
// Anfrage) nur fuer das Sternenfunkeln + die Auswahlmarkierung -
// gleiches Grundprinzip wie tick() vs. render() auf dem
// Geraete-Display: Flugzeugpositionen aktualisieren sich weiterhin
// nur alle 3s per poll(), die Sterne twinkeln aber fluessig dazwischen.
// Faster, purely local redraw interval (every 150ms, without network
// request) only for star twinkling + the selection marker -
// same basic principle as tick() vs. render() on the
// device display: aircraft positions still update
// only every 3s via poll(), but stars twinkle smoothly in between.
html += "setInterval(function(){draw(lastData);},150);";
html += "})();</script>";
}
@@ -297,12 +296,12 @@ namespace {
if (line.length() == 0) continue;
server.sendContent(String(days[i].date) + "," + line + "\n");
// Wichtig: Ohne regelmaessiges Abgeben der CPU haengt dieser
// Task (Core 0, Prioritaet 1) die Idle-Task aus, die den
// Task-Watchdog fuettert. Bei groesseren Logbuechern fuehrt
// das nach ca. 5s ohne Yield zu einem Watchdog-Reset -
// genau der Reboot mitten im CSV-Download, den der Nutzer
// beobachtet hat. delay(1) erzwingt einen Kontextwechsel.
// Important: Without regularly yielding the CPU, this
// task (Core 0, priority 1) starves the idle task that feeds
// the task watchdog. With larger logbooks, this causes a
// watchdog reset after approx. 5s without yield -
// exactly the reboot during CSV download that the user
// observed. delay(1) forces a context switch.
if (++linesSent % 10 == 0) {
delay(1);
}
@@ -348,14 +347,14 @@ namespace {
server.send(303);
}
// Verwaltung von Airline-Filter und Beobachtungsliste per Browser -
// beide Backend-Module (AirlineFilter/AircraftWatchlist) sind seit
// dieser Erweiterung mutex-geschuetzt (siehe dort), da sie jetzt sowohl
// von Core 1 (Radar-/Menue-Screens) als auch von hier aus - Core 0,
// WebExportServer laeuft innerhalb von NetTask - aufgerufen werden.
// Praktisch vor allem fuer laengere Eingaben (Rufzeichen, ICAO-Codes),
// die sich per Handy-Tastatur deutlich bequemer eintippen lassen als
// ueber die kleine Bildschirmtastatur des Geraets.
// Management of airline filter and watchlist via browser -
// both backend modules (AirlineFilter/AircraftWatchlist) are now
// mutex-protected (see there), since they are called both
// from Core 1 (radar/menu screens) and from here - Core 0,
// WebExportServer runs within NetTask.
// Practically most useful for longer inputs (callsigns, ICAO codes)
// that are much easier to type on a phone keyboard than
// via the device's small on-screen keyboard.
void handleLists() {
String html = htmlHeader("Leos Flightradar - Lists");
@@ -449,19 +448,18 @@ namespace {
server.send(303);
}
// Datenquelle fuer das Live-Radar auf der Startseite (siehe
// appendRadarSection()). Wendet dieselben Filter/Prioritaeten an wie das
// Geraete-Display (render() in radar_screen.cpp): Reichweite, "Boden-
// fahrzeuge ausblenden", Airline-Filter, sowie Notfall/Beobachtungsliste/
// "auffaellig" als sich gegenseitig ausschliessende Ring-Markierungen in
// genau dieser Prioritaet.
// Data source for the live radar on the start page (see
// appendRadarSection()). Applies the same filters/priorities as the
// device display (render() in radar_screen.cpp): range, "hide ground
// vehicles", airline filter, and emergency/watchlist/"notable" as
// mutually exclusive ring markers in exactly this priority.
void handleRadarJson() {
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
// Erlaubt der Web-Ansicht ein eigenes, unabhaengiges Zoomen (siehe
// Reichweiten-Waehler in appendRadarSection()), OHNE die Geraete-
// Einstellung zu veraendern. Nur einen der bekannten
// Config::RANGE_STEPS_KM-Werte akzeptieren - ein fehlender oder
// nicht erkannter Parameter faellt auf die Geraete-Reichweite zurueck.
// Allows the web view its own independent zooming (see
// range selector in appendRadarSection()), WITHOUT changing the
// device setting. Only accept one of the known
// Config::RANGE_STEPS_KM values - a missing or
// unknown parameter falls back to the device range.
if (server.hasArg("range_km")) {
float requested = server.arg("range_km").toFloat();
for (uint8_t i = 0; i < Config::RANGE_STEP_COUNT; i++) {
@@ -494,13 +492,13 @@ namespace {
bool isHeavy = isHeavyCategoryWeb(a.category);
bool isEmergency = emergencyOn && isEmergencySquawkWeb(a.squawk);
bool isWatched = watchOn && AircraftWatchlist::isWatched(a.callsign);
// "notable" (oranger Ring) ist fuer auffaellige Rufzeichen
// (Militaer/Regierung) reserviert - Heavy-Flugzeuge bekommen
// stattdessen die eigene Markerform (siehe "heavy" oben). Es
// gibt aktuell aber keine Militaer-/Regierungs-Praefixliste im
// Projekt (auch nicht am Geraete-Display, siehe
// radar_screen.cpp) - "notable" bleibt daher bis auf Weiteres
// immer false.
// "notable" (orange ring) is reserved for conspicuous callsigns
// (military/government) - heavy aircraft instead get their
// own marker shape (see "heavy" above). However, there is
// currently no military/government prefix list in the
// project (not even on the device display, see
// radar_screen.cpp) - "notable" therefore remains
// always false for now.
bool isNotable = false;
JsonObject o = arr.add<JsonObject>();
@@ -516,10 +514,10 @@ namespace {
o["emergency"] = isEmergency;
o["watched"] = isWatched;
o["notable"] = isNotable;
// Zusaetzliche Felder nur fuer das Info-Panel bei Klick/Tap auf
// ein Flugzeug (siehe showInfo() in appendRadarSection()) - beide
// stehen bereits verlustfrei im Aircraft-Snapshot, kein
// zusaetzlicher Netzwerk-/SD-Zugriff noetig.
// Additional fields only for the info panel on click/tap on
// an aircraft (see showInfo() in appendRadarSection()) - both
// are already losslessly in the Aircraft snapshot, no
// additional network/SD access needed.
o["speed_kt"] = a.groundSpeedKt;
o["squawk"] = a.squawk;
}

View File

@@ -63,13 +63,12 @@ void run(TFT_eSPI& tft) {
constexpr int16_t LINE_H = 16;
constexpr int16_t VIEW_TOP = 36;
// Wenn WLAN verbunden ist, bekommt der Absatztext nur noch ein kleines
// (bei Bedarf scrollbares) Fenster, weil darunter fest der QR-Code samt
// IP-Adresse Platz braucht (siehe Alex' Feedback: der vorherige separate
// "QR"-Button oben rechts sah seltsam aus - jetzt wird der Code direkt
// hier gezeigt, mittig, mit der IP-Adresse mittig darunter). Ohne WLAN
// gibt es keinen QR-Code, der Text bekommt dann wie vorher die volle
// Bildschirmhoehe.
// When WiFi is connected, the paragraph text gets only a small
// (scrollable if needed) window, because the QR code plus IP address
// need fixed space below (see Alex' feedback: the previous separate
// "QR" button at top right looked strange - now the code is shown
// directly here, centered, with the IP address centered below). Without
// WiFi there is no QR code, the text gets full screen height as before.
constexpr int16_t TEXT_VIEW_BOTTOM_CONNECTED = 84;
constexpr int16_t TEXT_VIEW_BOTTOM_DISCONNECTED = Config::SCREEN_HEIGHT - 60;
@@ -77,16 +76,15 @@ void run(TFT_eSPI& tft) {
String urlLine = wifiConnected ? ("http://" + WiFi.localIP().toString() + "/") : String();
int16_t viewBottom = wifiConnected ? TEXT_VIEW_BOTTOM_CONNECTED : TEXT_VIEW_BOTTOM_DISCONNECTED;
// QR-Code einmalig erzeugen (die URL aendert sich waehrend dieser
// Bildschirm offen ist nicht). Version 4 (33x33 Module) reicht mit
// deutlicher Reserve fuer eine "http://<lokale-IP>/"-URL (max. rund 24
// Zeichen) - ECC_LOW statt eines hoeheren Fehlerkorrektur-Levels, um die
// Module moeglichst gross (und damit leicht scannbar) darstellen zu
// koennen.
// Generate QR code once (URL doesn't change while this screen
// is open). Version 4 (33x33 modules) is sufficient with plenty of
// headroom for a "http://<local-IP>/" URL (max. ~24 chars) - ECC_LOW
// instead of a higher error correction level, to make the modules as
// large (and thus easily scannable) as possible.
constexpr uint8_t QR_VERSION = 4;
constexpr int16_t QR_SIZE_MODULES = 33; // Version 4: 4*4+17 = 33
constexpr int16_t QR_BLOCK = 4;
constexpr int16_t QR_QUIET = 2; // Ruhezone in Modulen rundherum, Scanner brauchen etwas Rand
constexpr int16_t QR_QUIET = 2; // Quiet zone in modules around it, scanners need some margin
constexpr int16_t QR_PIXEL_SIZE = (QR_SIZE_MODULES + 2 * QR_QUIET) * QR_BLOCK;
constexpr int16_t QR_X = (Config::SCREEN_WIDTH - QR_PIXEL_SIZE) / 2;
constexpr int16_t QR_GAP = 6;
@@ -99,9 +97,9 @@ void run(TFT_eSPI& tft) {
qrcode_initText(&qrcode, qrData, QR_VERSION, ECC_LOW, urlLine.c_str());
}
// Gesamthoehe des Absatztextes vorab berechnen (draw=false), um zu
// wissen, ob Scroll-Pfeile gebraucht werden - muss exakt zur
// Zeichenreihenfolge in redraw() unten passen.
// Pre-calculate total height of paragraph text (draw=false) to know
// whether scroll arrows are needed - must exactly match the drawing
// order in redraw() below.
int16_t totalH = VIEW_TOP;
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::WEBUI_INFO_PARA1), 0, 0, 0, false);
if (!wifiConnected) {
@@ -136,9 +134,9 @@ void run(TFT_eSPI& tft) {
}
if (wifiConnected) {
// QR-Code + IP-Adresse stehen fest unterhalb des (ggf.
// scrollbaren) Textes, unabhaengig von scrollY - beides mittig
// zentriert, direkt ueber dem Zurueck-Button.
// QR code + IP address are fixed below the (possibly scrollable)
// text, independent of scrollY - both centered, directly above
// the back button.
tft.fillRect(QR_X, QR_Y, QR_PIXEL_SIZE, QR_PIXEL_SIZE, TFT_WHITE);
for (uint8_t my = 0; my < qrcode.size; my++) {
for (uint8_t mx = 0; mx < qrcode.size; mx++) {

View File

@@ -3,9 +3,9 @@
#include <TFT_eSPI.h>
namespace WebUiScreen {
// Blockierend: zeigt die WLAN-IP und eine Erklaerung der kleinen
// eingebauten Webseite (Flugbuch ansehen/herunterladen/loeschen,
// Screenshots ansehen/herunterladen/loeschen). Kehrt zurueck, sobald
// "Zurueck" angetippt wurde.
// Blocking: shows the WiFi IP and an explanation of the small
// built-in web page (view logbook / download / delete,
// view screenshots / download / delete). Returns as soon as
// "Back" is tapped.
void run(TFT_eSPI& tft);
}

View File

@@ -3,9 +3,9 @@
#include <TFT_eSPI.h>
namespace WifiManageScreen {
// Blockierend: zeigt die bis zu 3 gespeicherten WLAN-Netzwerke, erlaubt
// Loeschen einzelner Eintraege und (falls noch Platz ist) das Hinzufuegen
// eines neuen ueber den Scan+Passwort-Bildschirm. Kehrt zurueck, sobald
// "Back" angetippt wird.
// Blocking: shows the up to 3 saved WiFi networks, allows
// deleting individual entries and (if there is still space) adding
// a new one via the scan+password screen. Returns as soon as
// "Back" is tapped.
void run(TFT_eSPI& tft);
}

View File

@@ -3,9 +3,9 @@
#include <TFT_eSPI.h>
namespace WifiSetupScreen {
// Blockierend: WLAN-Netzwerke suchen, per Touch auswaehlen, Passwort
// ueber Bildschirmtastatur eingeben (Klartext, keine Sterne), verbinden.
// Speichert bei Erfolg die Zugangsdaten via WifiMgr auf der SD-Karte.
// Rueckgabe: true = verbunden, false = abgebrochen/uebersprungen.
// Blocking: scan WiFi networks, select via touch, enter password
// via on-screen keyboard (plain text, no asterisks), connect.
// Saves credentials via WifiMgr on the SD card on success.
// Returns: true = connected, false = cancelled/skipped.
bool run(TFT_eSPI& tft);
}