Files
flyradar/src/main.cpp
Eiswolf-BG 52f692e70c Weather info popup, larger System menu buttons, group Flight toggles
Tapping the weather icon in the header now shows a small info popup
explaining that the shown weather always follows the currently active
location (including an active location preset).

The System menu's buttons now fill the available screen height
instead of leaving unused space below, computed from a dedicated
systemRowRect() helper. "Info" (About) is pinned as the second-to-last
entry, directly above Back, so its position stays stable regardless of
how many other entries come before it.

In the Flight options menu, all plain ON/OFF toggles (LED heartbeat,
emergency alert, proximity LED, watchlist alert, hide ground vehicles)
are now grouped together at the end of the list, right above Back,
instead of being interleaved with the screen/list entries above them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 18:58:53 +02:00

612 lines
22 KiB
C++

#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <SD.h>
#include <WiFi.h>
#include <time.h>
#include "config.h"
#include "aircraft.h"
#include "radar_math.h"
#include "aircraft_table.h"
#include "airline_lookup.h"
#include "airline_filter.h"
#include "aircraft_watchlist.h"
#include "sd_storage.h"
#include "wifi_manager.h"
#include "location_manager.h"
#include "location_presets.h"
#include "adsb_client.h"
#include "touch_input.h"
#include "calibration_screen.h"
#include "wifi_setup_screen.h"
#include "menu_screen.h"
#include "settings_store.h"
#include "net_task.h"
#include "radar_screen.h"
#include "splash_screen.h"
#include "led_alert.h"
#include "flight_logbook.h"
#include "weather.h"
#include "i18n.h"
#include "first_run_language_screen.h"
#include "menu_stars.h"
#include "ui_font.h"
#include <math.h>
TFT_eSPI tft = TFT_eSPI();
constexpr int16_t HEADER_TITLE_H = 30;
constexpr int16_t STATUS_LINE_H = 12;
constexpr int16_t CONTENT_TOP = HEADER_TITLE_H + STATUS_LINE_H;
constexpr uint32_t POLL_INTERVAL_MS = 300;
constexpr uint32_t SWEEP_TICK_MS = 80;
constexpr uint32_t STATUS_LINE_UPDATE_MS = 1000;
uint32_t lastPollMs = 0;
uint32_t lastSweepMs = 0;
uint32_t lastStatusLineMs = 0;
uint32_t lastRenderedVersion = 0xFFFFFFFF;
Weather::Condition lastRenderedWeather = Weather::Condition::Unknown;
bool forceRedraw = false;
bool wasEmergency = false;
bool bannerBlinkOn = false;
constexpr uint8_t BACKLIGHT_FULL = 255;
constexpr uint8_t BACKLIGHT_DIMMED = 12;
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0;
uint32_t lastInteractionMs = 0;
bool screenDimmed = false;
bool nightDimActive = false;
// 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.
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.
uint8_t nightDimBacklightPwm() {
uint32_t normal = normalBacklightPwm();
return (uint8_t)(normal * (100 - Config::NIGHT_DIM_REDUCTION_PERCENT) / 100);
}
struct Rect {
int16_t x, y, w, h;
bool contains(int16_t px, int16_t py) const {
return px >= x && px < x + w && py >= y && py < y + h;
}
};
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()).
Rect weatherIconRect = {(int16_t)(menuBtn.x - 46), 3, 42, 22};
void drawMenuButton() {
tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_BLACK);
tft.drawRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_GREEN);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString("Menu", menuBtn.x + menuBtn.w / 2, menuBtn.y + menuBtn.h / 2);
tft.setTextDatum(TL_DATUM);
}
// Kleine, mit einfachen TFT_eSPI-Grundformen gezeichnete Wolke (kein
// Bild/Font noetig) - Basis fuer die Regen/Schnee/Gewitter-Varianten.
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);
tft.fillCircle(cx + 4, cy, 4, color);
tft.fillRect(cx - 8, cy, 13, 3, color);
}
// Sonne mit ein paar Strahlen ringsum, ebenfalls nur aus Grundformen.
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++) {
float angle = i * (PI / 4.0f);
int16_t x1 = cx + (int16_t)((r + 2) * cosf(angle));
int16_t y1 = cy + (int16_t)((r + 2) * sinf(angle));
int16_t x2 = cx + (int16_t)((r + 4) * cosf(angle));
int16_t y2 = cy + (int16_t)((r + 4) * sinf(angle));
tft.drawLine(x1, y1, x2, y2, 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.
void drawWeatherIcon() {
int16_t cx = weatherIconRect.x + weatherIconRect.w / 2;
int16_t cy = weatherIconRect.y + weatherIconRect.h / 2;
tft.fillRect(weatherIconRect.x, weatherIconRect.y, weatherIconRect.w, weatherIconRect.h, TFT_BLACK);
switch (Weather::current()) {
case Weather::Condition::Clear:
drawSunShape(cx, cy, 6, TFT_YELLOW);
break;
case Weather::Condition::PartlyCloudy:
drawSunShape((int16_t)(cx - 4), (int16_t)(cy - 3), 4, TFT_YELLOW);
drawCloudShape((int16_t)(cx + 3), (int16_t)(cy + 2), TFT_LIGHTGREY);
break;
case Weather::Condition::Cloudy:
drawCloudShape(cx, cy, TFT_LIGHTGREY);
break;
case Weather::Condition::Rain:
drawCloudShape(cx, (int16_t)(cy - 3), TFT_LIGHTGREY);
tft.drawLine(cx - 4, cy + 4, cx - 6, cy + 8, TFT_SKYBLUE);
tft.drawLine(cx, cy + 4, cx - 2, cy + 8, TFT_SKYBLUE);
tft.drawLine(cx + 4, cy + 4, cx + 2, cy + 8, TFT_SKYBLUE);
break;
case Weather::Condition::Snow:
drawCloudShape(cx, (int16_t)(cy - 3), TFT_LIGHTGREY);
tft.drawPixel(cx - 4, cy + 6, TFT_WHITE);
tft.drawPixel(cx, cy + 7, TFT_WHITE);
tft.drawPixel(cx + 4, cy + 6, TFT_WHITE);
break;
case Weather::Condition::Thunderstorm:
drawCloudShape(cx, (int16_t)(cy - 3), TFT_LIGHTGREY);
tft.drawLine(cx, cy + 3, cx - 3, cy + 7, TFT_YELLOW);
tft.drawLine(cx - 3, cy + 7, cx + 1, cy + 7, TFT_YELLOW);
tft.drawLine(cx + 1, cy + 7, cx - 2, cy + 11, TFT_YELLOW);
break;
case Weather::Condition::Unknown:
default:
break;
}
}
// 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.
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;
int32_t start = 0;
int32_t len = text.length();
while (start < len) {
while (start < len && text[start] == ' ') start++;
if (start >= len) break;
String line = text.substring(start, len);
while (tftRef.textWidth(line) > maxWidth) {
int32_t lastSpace = line.lastIndexOf(' ');
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
tftRef.setCursor(x, y);
tftRef.print(line);
y += lineHeight;
start += line.length();
}
return y;
}
void weatherInfoDrawButton(TFT_eSPI& tftRef, const Rect& r, const String& label) {
tftRef.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
tftRef.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
tftRef.setTextDatum(MC_DATUM);
tftRef.setTextColor(TFT_GREEN, TFT_BLACK);
tftRef.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
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).
void showWeatherInfo(TFT_eSPI& tftRef) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X;
constexpr int16_t BOX_H = Config::SCREEN_HEIGHT - 2 * BOX_Y;
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.
constexpr int16_t VIEW_TOP = TITLE_Y + 12 + LINE_H;
constexpr int16_t BTN_H = 36;
constexpr int16_t BOTTOM_MARGIN = 8;
constexpr int16_t BACK_Y = BOX_Y + BOX_H - BOTTOM_MARGIN - BTN_H;
String body = I18n::t(StringId::WEATHER_INFO_BODY);
Rect backBtn = {(int16_t)(BOX_X + 10), BACK_Y, (int16_t)(BOX_W - 20), BTN_H};
MenuStars::reset();
auto redraw = [&]() {
tftRef.fillScreen(TFT_BLACK);
tftRef.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, TFT_GREEN);
tftRef.setTextDatum(MC_DATUM);
tftRef.setTextColor(TFT_GREEN, TFT_BLACK);
tftRef.setTextSize(2);
tftRef.drawString(I18n::t(StringId::WEATHER_INFO_TITLE), BOX_X + BOX_W / 2, TITLE_Y);
tftRef.setTextSize(1);
tftRef.setTextDatum(TL_DATUM);
tftRef.setTextColor(TFT_GREEN, TFT_BLACK);
weatherInfoLayoutWrapped(tftRef, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body);
weatherInfoDrawButton(tftRef, backBtn, I18n::t(StringId::BACK));
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (backBtn.contains(tap.x, tap.y)) return;
}
MenuStars::update(tftRef);
delay(20);
}
}
void drawWifiIcon(int16_t rightX, int16_t rowTop, int16_t rowH, int8_t rssi) {
uint8_t level;
if (rssi >= -55) level = 4;
else if (rssi >= -65) level = 3;
else if (rssi >= -75) level = 2;
else if (rssi >= -85) level = 1;
else level = 0;
constexpr uint8_t BAR_W = 3;
constexpr uint8_t BAR_GAP = 2;
constexpr uint8_t BAR_COUNT = 4;
int16_t baseline = rowTop + rowH - 1;
int16_t x = rightX - BAR_COUNT * (BAR_W + BAR_GAP);
for (uint8_t i = 0; i < BAR_COUNT; i++) {
int16_t barH = 3 + i * 2;
uint16_t color = (i < level) ? TFT_GREEN : TFT_DARKGREY;
tft.fillRect(x, baseline - barH, BAR_W, barH, color);
x += BAR_W + BAR_GAP;
}
}
constexpr int16_t WIFI_ICON_W = 22;
constexpr int16_t WIFI_ICON_H = 14;
void updateWifiIcon() {
int16_t iconX = Config::SCREEN_WIDTH - WIFI_ICON_W - 2;
int16_t iconY = 2;
tft.fillRect(iconX, iconY, WIFI_ICON_W, WIFI_ICON_H, TFT_BLACK);
if (WiFi.status() == WL_CONNECTED) {
drawWifiIcon(Config::SCREEN_WIDTH - 4, iconY, WIFI_ICON_H, WiFi.RSSI());
}
}
void drawHeader() {
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1);
tft.setCursor(6, 10);
tft.println("Eiswolfs FR");
drawMenuButton();
updateWifiIcon();
drawWeatherIcon();
lastRenderedWeather = Weather::current();
}
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.
constexpr int16_t CLOCK_CLEAR_W = 50;
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);
tft.fillRect(CLOCK_CLEAR_W, HEADER_TITLE_H, Config::SCREEN_WIDTH - CLOCK_CLEAR_W, STATUS_LINE_H, TFT_BLACK);
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
time_t now = time(nullptr);
if (now > 8 * 3600 * 2) {
struct tm tmNow;
localtime_r(&now, &tmNow);
char timeBuf[6];
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tmNow.tm_hour, tmNow.tm_min);
tft.setCursor(6, HEADER_TITLE_H + 2);
tft.print(timeBuf);
}
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.
Weather::Condition weatherNow = Weather::current();
if (weatherNow != lastRenderedWeather) {
lastRenderedWeather = weatherNow;
drawWeatherIcon();
}
}
// Prueft, ob die aktuelle Lokalzeit im Nachtdimm-Fenster (22:00-06:00) liegt.
// Ueber Mitternacht hinweg gerechnet. Solange die Uhrzeit noch nicht per NTP
// synchronisiert ist, wird false zurueckgegeben statt zu raten (gleiche
// Pruefung wie in updateStatusLine()).
bool isNightDimHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false;
struct tm tmNow;
localtime_r(&now, &tmNow);
int hour = tmNow.tm_hour;
return (hour >= 22 || hour < 6);
}
// Sanfte Nachtdimmung des Backlights zwischen 22:00 und 06:00 Uhr, sofern in
// den Einstellungen aktiviert. Der Inaktivitaets-Timeout (screenDimmed) hat
// Vorrang und wird hier nicht ueberschrieben.
void updateNightDimming() {
if (screenDimmed) return;
bool shouldDim = SettingsStore::nightDimmingEnabled() && isNightDimHours();
if (shouldDim != nightDimActive) {
ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldDim ? nightDimBacklightPwm() : normalBacklightPwm());
nightDimActive = shouldDim;
}
}
void haltWithSdRequiredScreen() {
tft.invertDisplay(true);
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
int16_t cx = Config::SCREEN_WIDTH / 2;
int16_t cy = Config::SCREEN_HEIGHT / 2;
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(2);
tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE1), cx, cy - 40);
tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE2), cx, cy - 10);
tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE3), cx, cy + 20);
tft.setTextSize(1);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(I18n::t(StringId::SD_REQUIRED_HINT), cx, cy + 60);
tft.setTextDatum(TL_DATUM);
while (true) {
delay(1000);
}
}
void updateEmergencyBanner(uint32_t nowMs) {
RadarScreen::EmergencyInfo emergency = RadarScreen::checkEmergency();
if (emergency.active) {
bannerBlinkOn = !bannerBlinkOn;
uint16_t bg = bannerBlinkOn ? TFT_RED : TFT_BLACK;
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, bg);
tft.setTextColor(TFT_WHITE, bg);
tft.setTextSize(1);
tft.setCursor(4, 10);
tft.printf("EMERGENCY %s %s", emergency.squawk, emergency.callsign);
wasEmergency = true;
} else if (wasEmergency) {
wasEmergency = false;
drawHeader();
updateStatusLine();
}
}
void setup() {
Serial.begin(115200);
delay(300);
tft.init();
tft.setRotation(0);
tft.setFreeFont(&UiFont11pt);
tft.invertDisplay(true);
tft.fillScreen(TFT_BLACK);
ledcSetup(BACKLIGHT_PWM_CHANNEL, 5000, 8);
ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL);
ledcWrite(BACKLIGHT_PWM_CHANNEL, normalBacklightPwm());
TouchInput::begin();
LedAlert::begin();
bool sdOk = SdStorage::init();
if (!sdOk) {
haltWithSdRequiredScreen();
return;
}
SdStorage::seedDefaultDataFiles();
bool isFirstRun = !SD.exists(Config::SD_SETTINGS_FILE);
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.
ledcWrite(BACKLIGHT_PWM_CHANNEL, normalBacklightPwm());
WifiMgr::init();
LocationPresets::init();
AirlineFilter::init();
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.
MenuStars::update(tft);
SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
if (!TouchInput::loadCalibration()) {
CalibrationScreen::run(tft);
}
if (WifiMgr::networkCount() == 0) {
WifiSetupScreen::run(tft);
} else {
SplashScreen::setStatusLine(tft, 1, I18n::t(StringId::SPLASH_CONNECTING_WIFI));
WifiMgr::beginConnect();
uint32_t waitStart = millis();
while (WifiMgr::getState() == WifiMgr::State::Connecting && millis() - waitStart < 16000) {
WifiMgr::update();
MenuStars::update(tft);
delay(100);
}
if (WifiMgr::getState() == WifiMgr::State::Connected) {
SplashScreen::setStatusLine(tft, 1, String(I18n::t(StringId::SPLASH_WIFI_OK_PREFIX)) + WifiMgr::getIP());
} else {
SplashScreen::setStatusLine(tft, 1, I18n::t(StringId::SPLASH_WIFI_FAILED), TFT_RED);
}
}
if (isFirstRun) {
FirstRunLanguageScreen::run(tft);
SplashScreen::begin(tft);
MenuStars::update(tft);
SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
}
AdsbClient::primeTime();
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).
while ((LocationManager::currentSource() == LocationManager::Source::None ||
!LocationManager::hasUtcOffset()) &&
millis() - locStart < 8000) {
LocationManager::requestIpLookupIfNeeded();
MenuStars::update(tft);
delay(200);
}
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.
if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
}
AircraftTable::init();
AirlineLookup::init();
FlightLogbook::init();
NetTask::begin();
SplashScreen::waitRemaining(tft);
drawHeader();
updateStatusLine();
RadarScreen::render(tft, CONTENT_TOP);
lastInteractionMs = millis();
}
void loop() {
TouchInput::Point tap;
bool tapped = TouchInput::wasTapped(tap);
if (tapped) {
lastInteractionMs = millis();
if (screenDimmed) {
bool shouldNightDim = SettingsStore::nightDimmingEnabled() && isNightDimHours();
ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldNightDim ? nightDimBacklightPwm() : normalBacklightPwm());
nightDimActive = shouldNightDim;
screenDimmed = false;
tapped = false;
}
}
if (tapped) {
if (menuBtn.contains(tap.x, tap.y)) {
MenuScreen::run(tft);
drawHeader();
updateStatusLine();
forceRedraw = true;
} else if (weatherIconRect.contains(tap.x, tap.y)) {
showWeatherInfo(tft);
drawHeader();
updateStatusLine();
forceRedraw = true;
} else if (tap.y >= CONTENT_TOP) {
if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) {
forceRedraw = true;
}
}
}
if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) {
lastPollMs = millis();
uint32_t currentVersion = AircraftTable::version();
if (forceRedraw || currentVersion != lastRenderedVersion) {
lastRenderedVersion = currentVersion;
forceRedraw = false;
RadarScreen::render(tft, CONTENT_TOP);
lastSweepMs = millis();
}
}
uint32_t nowMs = millis();
RadarScreen::updateProximityAlert(nowMs);
if (nowMs - lastSweepMs >= SWEEP_TICK_MS) {
uint32_t deltaMs = nowMs - lastSweepMs;
lastSweepMs = nowMs;
RadarScreen::tick(tft, CONTENT_TOP, deltaMs);
updateEmergencyBanner(nowMs);
}
if (nowMs - lastStatusLineMs >= STATUS_LINE_UPDATE_MS) {
lastStatusLineMs = nowMs;
updateStatusLine();
updateNightDimming();
}
uint8_t timeoutMin = SettingsStore::screenTimeoutMinutes();
if (!screenDimmed && timeoutMin > 0) {
uint32_t timeoutMs = (uint32_t)timeoutMin * 60000UL;
if (nowMs - lastInteractionMs >= timeoutMs) {
ledcWrite(BACKLIGHT_PWM_CHANNEL, min(BACKLIGHT_DIMMED, normalBacklightPwm()));
screenDimmed = true;
}
}
}