## 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>
673 lines
25 KiB
C++
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;
|
|
}
|
|
}
|
|
} |