Files
flyradar/src/main.cpp
Eiswolf-BG a7f14b3125 v3.3.0: Welcome screen, factory reset, calibration/touch fixes, flash partition fix
## New
- "Welcome" screen shown on first-time setup (before touch
  calibration), with the splash screen's animated radar graphic and a
  Start button.
- The "Setup complete" screen now counts down 7 seconds with a button
  matching the Welcome screen's style, then automatically continues
  into the app (previously required a tap).
- System menu "Backup & Reset" page (Backup, Restore, and a new
  "Reset settings" option that wipes the on-device data and re-runs
  first-time setup - useful for testing or handing the device to
  someone else).
- Touch calibration targets are now green mini radar reticles instead
  of red crosshairs.

## Improved
- The SD card's data folder is now only created after tapping Start
  on the Welcome screen, instead of on every boot regardless.
- Flash partition layout changed to free up roughly 850 KB of
  firmware space (no OTA update slot needed, since firmware is always
  flashed via cable/USB) - lowers flash usage from ~95% to ~59%.

## Fixed
- The Start button and the WiFi scanning screen now show a loading
  indicator instead of appearing frozen during SD-card/WiFi
  operations.
- A touch-input bug during first-time setup that could corrupt touch
  calibration and cause taps to land on the wrong on-screen element
  afterward (most noticeable in the WiFi network selection list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 06:57:48 +02:00

673 lines
25 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_welcome_screen.h"
#include "first_run_language_screen.h"
#include "first_run_location_screen.h"
#include "first_run_complete_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_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.
// 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.
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);
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[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.
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");
}
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;
}
bool isFirstRun = !SD.exists(Config::SD_SETTINGS_FILE);
// The Flightradar folder structure and default data files are
// deliberately NOT created yet on a first boot - that happens further
// down, after the Start button on the Welcome screen has been tapped
// (the button is "the gate to the app": nothing should exist on the
// card before that). On every later boot the structure already
// exists anyway - creating it immediately here is harmless,
// ensureDir()/writeIfAbsent() are idempotent.
if (!isFirstRun) {
SdStorage::createStructure();
SdStorage::seedDefaultDataFiles();
}
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);
// 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.
if (isFirstRun) {
// Creating the folder structure, seeding default data files, AND
// showing the loading indicator during those (noticeably slow) SD
// accesses now all happen directly inside
// FirstRunWelcomeScreen::run() (triggered by the Start tap) - see
// there for why (button otherwise looked frozen, repeated tapping
// let a tap leak into calibration).
FirstRunWelcomeScreen::run(tft);
}
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);
// 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.
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.
FirstRunCompleteScreen::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) {
// Bewusst ganz aus (0) statt nur gedimmt - fuer den
// Bildschirm-Timeout (Menue > System > Timeout). Dimmen
// uebernimmt bereits der separate Nachtmodus
// (updateNightDimming()); der Inaktivitaets-Timeout soll das
// Display wirklich abschalten, z.B. fuer Plane-Spotter, die nur
// das Flugbuch mitlaufen lassen wollen, ohne den Screen zu
// brauchen. Ein Antippen weckt ihn ueber den screenDimmed-Zweig
// oben wieder auf die eingestellte Helligkeit (bzw. Nachtmodus-
// Helligkeit, falls gerade Nachtstunden sind).
ledcWrite(BACKLIGHT_PWM_CHANNEL, 0);
screenDimmed = true;
}
}
}