v2.7.3: Flight logbook safety net, radar/clock/timezone fixes

New:
- Flight logbook safety net: disabled by default, requires confirming
  a full-screen warning before it can be turned on, and automatically
  switches off again after exactly 24 hours - survives restarts and
  power cycles (no valid enable timestamp = guaranteed off). Each
  activation now writes its own CSV file instead of endlessly
  appending to one growing file, and every file can be deleted
  individually from Logbook files.
- Adjustable display brightness (10-100% in 10% steps, Menu -> System
  -> "Brightness") with a live preview.
- About screen (Menu -> System -> "About") showing project name,
  description, and firmware version.

Fixed:
- Radar's red altitude band (>9100m/30000ft) no longer gets dimmed at
  night - the previous dim tone looked orange-brown and was hard to
  tell apart from yellow. Legend and markers are consistent again.
- Radius change no longer leaves a stale "tail" trail behind each
  aircraft between the old and new position.
- Splash screen no longer shows a time-of-day greeting that appeared
  too late in the boot sequence to be useful; the star animation now
  starts immediately instead of only near the end of boot.
- Clock stayed on raw UTC after the first boot instead of local time
  (missing DST handling in some timezones) because the location wait
  loop skipped the timezone lookup once a location was already saved.
- Header clock no longer leaves stale digit fragments behind the
  current time, most visible on the last minute digit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-10 14:02:01 +02:00
parent d5eda78d2f
commit 7137e50d66
25 changed files with 841 additions and 120 deletions

View File

@@ -159,6 +159,18 @@ gefragt wird.
Sobald der Workflow explizit angefordert wurde, automatisch folgende Schritte Sobald der Workflow explizit angefordert wurde, automatisch folgende Schritte
in dieser Reihenfolge: in dieser Reihenfolge:
0. Versionsnummer festlegen: Die neue Versionsnummer kommt IMMER exakt von
Alex - er nennt sie im Push-Wunsch (z.B. über Claude/den Sandbox-
Assistenten: "Alex will auf Version X.Y.Z pushen"). Karl trägt GENAU
diese Nummer in `Config::APP_VERSION` (`src/config.h`) ein - niemals
selbst hochzählen, erraten oder von der letzten Version ableiten (auch
nicht bei kleinen Patches). Das gilt auch für größere Sprünge (z.B.
2.6 -> 3.0), die Alex bewusst und absichtlich machen kann - Karl
übernimmt in jedem Fall die genannte Nummer 1:1, ohne eigene Annahmen.
Falls im Push-Wunsch keine explizite Versionsnummer genannt wurde, bei
Alex nachfragen statt zu raten. Erst NACH dem Eintragen der korrekten
Nummer: einmal sauber `pio run` bauen, DANACH erst der Rest des
bekannten Workflows (README, Tag, index.html, Bin-Dateien, Commit/Push).
1. Prüfen, ob seit dem letzten Commit neue/geänderte Features hinzugekommen 1. Prüfen, ob seit dem letzten Commit neue/geänderte Features hinzugekommen
sind, die für Endnutzer sichtbar sind (neue Menüpunkte, geändertes sind, die für Endnutzer sichtbar sind (neue Menüpunkte, geändertes
Verhalten, neue Screens) - falls ja, **README.md entsprechend ergänzen** Verhalten, neue Screens) - falls ja, **README.md entsprechend ergänzen**

View File

@@ -45,7 +45,8 @@ You can flash the firmware directly from your browser to your CYD display withou
- **Dual-core design** – all networking (WiFi, ADS-B polling, aircraft detail lookups) runs on Core 0, while the display and touch input run on Core 1, so the UI never freezes during a network request - **Dual-core design** – all networking (WiFi, ADS-B polling, aircraft detail lookups) runs on Core 0, while the display and touch input run on Core 1, so the UI never freezes during a network request
- Data (airline names, aircraft-type seat estimates) loaded from CSV files on the SD card, auto-seeded on first boot - Data (airline names, aircraft-type seat estimates) loaded from CSV files on the SD card, auto-seeded on first boot
- **6 languages** (English, German, French, Turkish, Spanish, Italian), selectable on first boot or anytime from the menu - **6 languages** (English, German, French, Turkish, Spanish, Italian), selectable on first boot or anytime from the menu
- **Time-of-day greeting** – once WiFi and the timezone are set, the splash screen greets you with "Good morning" / "Good day" / "Good evening" based on your local time - **Adjustable brightness** (10-100% in 10% steps, Menu → System → "Brightness") with a live preview as you tap
- **About screen** (Menu → System → "About") shows the project name, a short description, and the current firmware version
## Feature Deep-Dive ## Feature Deep-Dive
@@ -126,7 +127,7 @@ Shows the last up to **7 days** from the flight logbook as a simple bar chart
A "?" info button on the History chart screen explains how it works directly on the device. A "?" info button on the History chart screen explains how it works directly on the device.
### 📁 Logbook files ### 📁 Logbook files
Lists the last several days from the logbook individually, with date and the number of aircraft logged that day. Handy for tracing the history over multiple days instead of only seeing the grand total. Lists the last several days from the logbook individually, with date and the number of aircraft logged that day. Handy for tracing the history over multiple days instead of only seeing the grand total. Each file has its own red "X" to delete it individually.
### 🌐 Web Export ### 🌐 Web Export
In addition to viewing the logbook on the device itself, a small built-in web page lets you export the **complete flight logbook** as a single merged CSV file – handy for opening in Excel, Numbers, or Google Sheets. It starts automatically in the background as soon as the device connects to WiFi. Deliberately **export-only, no live radar tracking over the web** – that would just burn resources for little benefit. In addition to viewing the logbook on the device itself, a small built-in web page lets you export the **complete flight logbook** as a single merged CSV file – handy for opening in Excel, Numbers, or Google Sheets. It starts automatically in the background as soon as the device connects to WiFi. Deliberately **export-only, no live radar tracking over the web** – that would just burn resources for little benefit.
@@ -136,7 +137,13 @@ In addition to viewing the logbook on the device itself, a small built-in web pa
Don't know the device's current IP? The "?" info button on the **Logbook files** screen shows it live on the device. Don't know the device's current IP? The "?" info button on the **Logbook files** screen shows it live on the device.
### 📖 Flight logbook (ON/OFF) ### 📖 Flight logbook (ON/OFF)
When enabled, the device logs **every newly sighted aircraft** (timestamp, hex code, callsign, registration, type, distance, altitude) into a daily CSV file on the SD card. An aircraft is only logged once per day, even if it crosses the radar multiple times. Survives a same-day restart without logging already-seen aircraft twice. Turning it off saves SD card write cycles if you don't care about the statistics/history. When enabled, the device logs **every newly sighted aircraft** (timestamp, hex code, callsign, registration, type, distance, altitude) into a CSV file on the SD card. An aircraft is only logged once per activation, even if it crosses the radar multiple times.
**Off by default, with a safety net:** because an unnoticed, permanently running logbook can quietly fill up the SD card and slow down the logbook menus, this is **disabled by default** and comes with two safeguards:
- Turning it on shows a full-screen warning explaining the behavior below, which you have to confirm.
- Once on, it **automatically switches off again after 24 hours** – and stays off after a restart unless it was actually turned on with a valid timestamp (so it can never silently keep running forever, even across power cycles or firmware updates).
Each time you turn it on, a **new CSV file** is created for that session (e.g. `2026-08-06.csv`, or `2026-08-06_2.csv` for a second activation on the same day) instead of endlessly appending to one growing file – see [Logbook files](#-logbook-files) above for deleting them individually. Turning it off saves SD card write cycles if you don't care about the statistics/history.
### 💚 LED heartbeat (ON/OFF) ### 💚 LED heartbeat (ON/OFF)
A brief **green flash** of the RGB LED on every successful ADS-B data fetch (every 8 seconds) – a quick visual confirmation that the device is actively receiving data and hasn't frozen. Automatically overridden by an active proximity or emergency alert (which take priority), so the indicators never mix. A brief **green flash** of the RGB LED on every successful ADS-B data fetch (every 8 seconds) – a quick visual confirmation that the device is actively receiving data and hasn't frozen. Automatically overridden by an active proximity or emergency alert (which take priority), so the indicators never mix.

70
src/about_screen.cpp Normal file
View File

@@ -0,0 +1,70 @@
#include "about_screen.h"
#include "touch_input.h"
#include "menu_stars.h"
#include "config.h"
#include "i18n.h"
namespace AboutScreen {
namespace {
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;
}
};
void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) {
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM);
}
}
void run(TFT_eSPI& tft) {
bool done = false;
MenuStars::reset();
while (!done) {
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.
tft.setCursor(10, 14);
tft.println("Eiswolfs Flightradar");
tft.setCursor(10, 40);
tft.println(I18n::t(StringId::ABOUT_DESC1));
tft.setCursor(10, 52);
tft.println(I18n::t(StringId::ABOUT_DESC2));
tft.setCursor(10, 74);
tft.println("(c) 2026 Eiswolf");
tft.setCursor(10, 86);
tft.print(I18n::t(StringId::ABOUT_VERSION_PREFIX));
tft.println(Config::APP_VERSION);
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50),
(int16_t)(Config::SCREEN_WIDTH - 20), 40};
drawButton(tft, backBtn, I18n::t(StringId::BACK));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (backBtn.contains(tap.x, tap.y)) {
done = true;
}
}
}
}

9
src/about_screen.h Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
// Einfacher Info-Screen: Projektname, Kurzbeschreibung, Copyright-Jahr und
// die aktuelle Versionsnummer (Config::APP_VERSION).
namespace AboutScreen {
void run(TFT_eSPI& tft);
}

92
src/brightness_screen.cpp Normal file
View File

@@ -0,0 +1,92 @@
#include "brightness_screen.h"
#include "settings_store.h"
#include "touch_input.h"
#include "menu_stars.h"
#include "config.h"
#include "i18n.h"
namespace BrightnessScreen {
namespace {
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;
}
};
void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) {
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
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").
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0;
void applyLive(uint8_t percent) {
uint8_t pwm = (uint8_t)((uint16_t)percent * 255 / 100);
ledcWrite(BACKLIGHT_PWM_CHANNEL, pwm);
}
}
void run(TFT_eSPI& tft) {
bool done = false;
MenuStars::reset();
while (!done) {
uint8_t percent = SettingsStore::brightnessPercent();
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::BRIGHTNESS_TITLE));
char buf[8];
snprintf(buf, sizeof(buf), "%d%%", percent);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(3);
tft.drawString(buf, Config::SCREEN_WIDTH / 2, 130);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
Rect minusBtn = {10, 190, 100, 60};
Rect plusBtn = {(int16_t)(Config::SCREEN_WIDTH - 110), 190, 100, 60};
drawButton(tft, minusBtn, "-");
drawButton(tft, plusBtn, "+");
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50),
(int16_t)(Config::SCREEN_WIDTH - 20), 40};
drawButton(tft, backBtn, I18n::t(StringId::BACK));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (minusBtn.contains(tap.x, tap.y)) {
uint8_t next = (percent > Config::BRIGHTNESS_MIN_PERCENT + Config::BRIGHTNESS_STEP_PERCENT - 1)
? (uint8_t)(percent - Config::BRIGHTNESS_STEP_PERCENT)
: Config::BRIGHTNESS_MIN_PERCENT;
SettingsStore::setBrightnessPercent(next);
applyLive(next);
} else if (plusBtn.contains(tap.x, tap.y)) {
uint8_t next = (percent + Config::BRIGHTNESS_STEP_PERCENT <= Config::BRIGHTNESS_MAX_PERCENT)
? (uint8_t)(percent + Config::BRIGHTNESS_STEP_PERCENT)
: Config::BRIGHTNESS_MAX_PERCENT;
SettingsStore::setBrightnessPercent(next);
applyLive(next);
} else if (backBtn.contains(tap.x, tap.y)) {
done = true;
}
}
}
}

10
src/brightness_screen.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#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.
namespace BrightnessScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -2,6 +2,25 @@
#include <Arduino.h> #include <Arduino.h>
namespace Config { 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.
constexpr const char* APP_VERSION = "2.7.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.
constexpr uint8_t BRIGHTNESS_MIN_PERCENT = 10;
constexpr uint8_t BRIGHTNESS_MAX_PERCENT = 100;
constexpr uint8_t BRIGHTNESS_STEP_PERCENT = 10;
// 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.
constexpr uint8_t NIGHT_DIM_REDUCTION_PERCENT = 40;
constexpr const char* IP_GEO_HOST = "ip-api.com"; constexpr const char* IP_GEO_HOST = "ip-api.com";
constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon,offset,countryCode"; constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon,offset,countryCode";

View File

@@ -14,19 +14,57 @@ namespace {
constexpr uint16_t MAX_SEEN = 400; constexpr uint16_t MAX_SEEN = 400;
char seenHex[MAX_SEEN][7]; char seenHex[MAX_SEEN][7];
uint16_t seenCount = 0; uint16_t seenCount = 0;
char currentDateStr[11] = {0};
bool computeDateStr(char* out, size_t outSize) { // Datei der aktuell laufenden Aufzeichnungs-Sitzung (ohne ".csv"), z.B.
time_t now = time(nullptr); // "2026-08-06" fuer die erste Sitzung eines Tages oder "2026-08-06_2"
if (now < 8 * 3600 * 2) return false; // fuer ein erneutes Einschalten am selben Tag. Leer = noch nicht
struct tm tmNow; // aufgeloest (Uhrzeit noch nicht synchronisiert oder Flugbuch aus).
localtime_r(&now, &tmNow); char currentSessionFile[16] = {0};
snprintf(out, outSize, "%04d-%02d-%02d", tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday);
return true; void formatDateFromEpoch(uint32_t epoch, char* out, size_t outSize) {
time_t t = (time_t)epoch;
struct tm tmv;
localtime_r(&t, &tmv);
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.
void resolveSessionFilename(uint32_t epoch, char* out, size_t outSize) {
char dateStr[11];
formatDateFromEpoch(epoch, dateStr, sizeof(dateStr));
char path[64];
snprintf(path, sizeof(path), "%s/%s.csv", Config::SD_LOG_DIR, dateStr);
if (!SD.exists(path)) {
strncpy(out, dateStr, outSize - 1);
out[outSize - 1] = 0;
return;
}
for (uint8_t suffix = 2; suffix <= 50; suffix++) {
char candidate[16];
snprintf(candidate, sizeof(candidate), "%s_%d", dateStr, suffix);
snprintf(path, sizeof(path), "%s/%s.csv", Config::SD_LOG_DIR, candidate);
if (!SD.exists(path)) {
strncpy(out, candidate, outSize - 1);
out[outSize - 1] = 0;
return;
}
}
// Sehr unwahrscheinlicher Fall (>50 Sitzungen an einem Tag): letzten
// Kandidaten weiterverwenden statt endlos zu suchen.
strncpy(out, dateStr, outSize - 1);
out[outSize - 1] = 0;
} }
void logFilename(char* out, size_t outSize) { void logFilename(char* out, size_t outSize) {
snprintf(out, outSize, "%s/%s.csv", Config::SD_LOG_DIR, currentDateStr); snprintf(out, outSize, "%s/%s.csv", Config::SD_LOG_DIR, currentSessionFile);
} }
bool alreadySeen(const char* hex) { bool alreadySeen(const char* hex) {
@@ -59,7 +97,7 @@ namespace {
return lines; return lines;
} }
void loadSeenFromTodayFile() { void loadSeenFromCurrentFile() {
seenCount = 0; seenCount = 0;
char filename[64]; char filename[64];
logFilename(filename, sizeof(filename)); logFilename(filename, sizeof(filename));
@@ -108,14 +146,32 @@ namespace {
f.close(); f.close();
} }
void ensureCurrentDate() { // Loest die Datei der aktuellen Sitzung auf (einmalig pro Sitzung) bzw.
char today[11]; // uebernimmt sie nach einem Neustart erneut aus den Einstellungen -
if (!computeDateStr(today, sizeof(today))) return; // nur aufrufen, wenn das Flugbuch gerade eingeschaltet ist.
void ensureSessionFile() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return; // Uhrzeit noch nicht synchronisiert
if (strcmp(today, currentDateStr) != 0) { String persisted = SettingsStore::flightLogbookSessionFile();
strncpy(currentDateStr, today, sizeof(currentDateStr) - 1); if (persisted.length() > 0) {
loadSeenFromTodayFile(); if (strcmp(currentSessionFile, persisted.c_str()) != 0) {
strncpy(currentSessionFile, persisted.c_str(), sizeof(currentSessionFile) - 1);
currentSessionFile[sizeof(currentSessionFile) - 1] = 0;
loadSeenFromCurrentFile();
} }
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.
uint32_t enabledAt = SettingsStore::flightLogbookEnabledAtEpoch();
if (enabledAt == 0) enabledAt = (uint32_t)now;
resolveSessionFilename(enabledAt, currentSessionFile, sizeof(currentSessionFile));
SettingsStore::setFlightLogbookSessionFile(currentSessionFile);
seenCount = 0;
} }
void writeLogLine(File& f, const Aircraft& a) { void writeLogLine(File& f, const Aircraft& a) {
@@ -140,16 +196,39 @@ namespace {
void init() { void init() {
SdMutex::Guard guard; SdMutex::Guard guard;
ensureCurrentDate(); if (SettingsStore::flightLogbookEnabled()) {
ensureSessionFile();
}
} }
void update() { void update() {
if (!SettingsStore::flightLogbookEnabled()) return; 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.
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.
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)nowCheck);
} else if ((uint32_t)nowCheck >= enabledAt &&
(uint32_t)nowCheck - enabledAt >= 24UL * 3600UL) {
SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile("");
return;
}
}
SdMutex::Guard guard; SdMutex::Guard guard;
ensureCurrentDate(); ensureSessionFile();
if (currentDateStr[0] == 0) return; if (currentSessionFile[0] == 0) return;
static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT]; static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT];
uint8_t count = 0; uint8_t count = 0;
@@ -195,7 +274,7 @@ TopAltitude todayMaxAltitude() {
TopAltitude result; TopAltitude result;
SdMutex::Guard guard; SdMutex::Guard guard;
if (currentDateStr[0] == 0) return result; // Uhrzeit noch nicht bekannt if (currentSessionFile[0] == 0) return result; // noch keine Sitzungsdatei bekannt
char filename[64]; char filename[64];
logFilename(filename, sizeof(filename)); logFilename(filename, sizeof(filename));
@@ -288,6 +367,7 @@ uint8_t listDays(DayEntry* out, uint8_t maxEntries) {
uint32_t lines = countLinesFast(entry); uint32_t lines = countLinesFast(entry);
strncpy(out[filled].date, dateOnly.c_str(), sizeof(out[filled].date) - 1); strncpy(out[filled].date, dateOnly.c_str(), sizeof(out[filled].date) - 1);
out[filled].date[sizeof(out[filled].date) - 1] = 0;
out[filled].count = (lines > 0) ? (lines - 1) : 0; out[filled].count = (lines > 0) ? (lines - 1) : 0;
filled++; filled++;
} }
@@ -300,6 +380,53 @@ uint8_t listDays(DayEntry* out, uint8_t maxEntries) {
return filled; return filled;
} }
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.
constexpr uint8_t MAX_RAW_SCAN = 90;
static DayEntry raw[MAX_RAW_SCAN];
uint8_t rawCount = listDays(raw, MAX_RAW_SCAN);
uint8_t outCount = 0;
for (uint8_t i = 0; i < rawCount; i++) {
char dayKey[11];
strncpy(dayKey, raw[i].date, 10);
dayKey[10] = 0;
int8_t existing = -1;
for (uint8_t j = 0; j < outCount; j++) {
if (strcmp(out[j].date, dayKey) == 0) { existing = j; break; }
}
if (existing >= 0) {
out[existing].count += raw[i].count;
} else if (outCount < maxEntries) {
strncpy(out[outCount].date, dayKey, sizeof(out[outCount].date) - 1);
out[outCount].date[sizeof(out[outCount].date) - 1] = 0;
out[outCount].count = raw[i].count;
outCount++;
}
}
return outCount;
}
bool deleteFile(const char* label) {
SdMutex::Guard guard;
char path[64];
snprintf(path, sizeof(path), "%s/%s.csv", Config::SD_LOG_DIR, label);
if (!SD.exists(path)) return false;
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.
seenCount = 0;
}
return ok;
}
void resetAllData() { void resetAllData() {
SdMutex::Guard guard; SdMutex::Guard guard;
@@ -323,8 +450,10 @@ void resetAllData() {
} }
seenCount = 0; seenCount = 0;
currentDateStr[0] = 0; currentSessionFile[0] = 0;
ensureCurrentDate(); if (SettingsStore::flightLogbookEnabled()) {
ensureSessionFile();
}
} }
} }

View File

@@ -14,21 +14,42 @@ namespace FlightLogbook {
int32_t altitudeFt = 0; int32_t altitudeFt = 0;
}; };
// Sucht in der heutigen Logbuch-Datei den Eintrag mit der hoechsten // Sucht in der Datei der aktuellen Sitzung den Eintrag mit der hoechsten
// geloggten Flughoehe (jeweils die Hoehe BEIM ERSTEN Sichten, nicht der // geloggten Flughoehe (jeweils die Hoehe BEIM ERSTEN Sichten, nicht der
// aktuelle Wert) und gibt dessen Rufzeichen + Hoehe zurueck. found=false, // aktuelle Wert) und gibt dessen Rufzeichen + Hoehe zurueck. found=false,
// wenn heute noch nichts geloggt wurde oder die Datei fehlt. // wenn noch nichts geloggt wurde oder die Datei fehlt.
TopAltitude todayMaxAltitude(); TopAltitude todayMaxAltitude();
void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays); void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays);
struct DayEntry { struct DayEntry {
char date[11] = {0}; // 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".
char date[16] = {0};
uint32_t count = 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.
uint8_t listDays(DayEntry* out, uint8_t maxEntries); 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.
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.
bool deleteFile(const char* label);
// Loescht ALLE Logbuch-CSV-Dateien auf der SD-Karte unwiderruflich und // Loescht ALLE Logbuch-CSV-Dateien auf der SD-Karte unwiderruflich und
// setzt die "heute schon gesehen"-Liste zurueck. Fuer den Reset-Button // setzt die "heute schon gesehen"-Liste zurueck. Fuer den Reset-Button
// im Statistik-Bildschirm gedacht. // im Statistik-Bildschirm gedacht.

View File

@@ -19,9 +19,6 @@ enum class StringId : uint8_t {
SPLASH_WIFI_FAILED, SPLASH_WIFI_FAILED,
SPLASH_GETTING_LOCATION, SPLASH_GETTING_LOCATION,
SPLASH_READY, SPLASH_READY,
SPLASH_GREETING_MORNING,
SPLASH_GREETING_DAY,
SPLASH_GREETING_EVENING,
SD_REQUIRED_LINE1, SD_REQUIRED_LINE1,
SD_REQUIRED_LINE2, SD_REQUIRED_LINE2,
@@ -186,6 +183,19 @@ enum class StringId : uint8_t {
AIRCRAFT_LIST_SORT_ALTITUDE, AIRCRAFT_LIST_SORT_ALTITUDE,
AIRCRAFT_LIST_SORT_CALLSIGN, AIRCRAFT_LIST_SORT_CALLSIGN,
MENU_ABOUT,
ABOUT_DESC1,
ABOUT_DESC2,
ABOUT_VERSION_PREFIX,
MENU_BRIGHTNESS_PREFIX,
BRIGHTNESS_TITLE,
// Grosse Warn-Ueberlage beim Einschalten des Flugbuchs (Menue >
// Flugoptionen > Flugbuch), erklaert die 24h-Sicherheitsabschaltung.
MENU_LOGBOOK_WARNING_TITLE,
MENU_LOGBOOK_WARNING_BODY,
COUNT COUNT
}; };

View File

@@ -19,9 +19,6 @@ static const char* const I18N_DE[] = {
"WLAN FEHLGESCHLAGEN", "WLAN FEHLGESCHLAGEN",
"Ermittle Standort...", "Ermittle Standort...",
"Bereit!", "Bereit!",
"Guten Morgen",
"Guten Tag",
"Guten Abend",
"Für diese App wird", "Für diese App wird",
"eine SD-Karte", "eine SD-Karte",
@@ -186,6 +183,17 @@ static const char* const I18N_DE[] = {
"Distanz", "Distanz",
"Höhe", "Höhe",
"Rufzeichen", "Rufzeichen",
"Info",
"Live-ADS-B-Flugradar für",
"das ESP32 CYD-Display.",
"Version: ",
"Helligkeit: ",
"Helligkeit",
"Achtung!!!",
"Das Flugbuch speichert dauerhaft alle sichtbaren Flugzeuge auf der SD-Karte. Damit sie sich nicht unbemerkt füllt und die Logbuch-Menüs nicht langsam werden, schaltet es sich nach 24 Stunden automatisch wieder aus.",
}; };
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -19,9 +19,6 @@ static const char* const I18N_EN[] = {
"WiFi FAILED", "WiFi FAILED",
"Getting location...", "Getting location...",
"Ready!", "Ready!",
"Good morning",
"Good day",
"Good evening",
"For this app a", "For this app a",
"SD card is", "SD card is",
@@ -187,6 +184,17 @@ static const char* const I18N_EN[] = {
"Distance", "Distance",
"Altitude", "Altitude",
"Callsign", "Callsign",
"About",
"Live ADS-B flight radar",
"for the ESP32 CYD board.",
"Version: ",
"Brightness: ",
"Brightness",
"Warning!!!",
"The logbook permanently saves every visible aircraft to the SD card. To stop it filling up unnoticed and slowing down the logbook menus, it automatically switches off again after 24 hours.",
}; };
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -19,9 +19,6 @@ static const char* const I18N_ES[] = {
"WiFi FALLÓ", "WiFi FALLÓ",
"Obteniendo ubicación...", "Obteniendo ubicación...",
"¡Listo!", "¡Listo!",
"Buenos días",
"Buenas tardes",
"Buenas noches",
"Esta app necesita", "Esta app necesita",
"una tarjeta SD", "una tarjeta SD",
@@ -186,6 +183,17 @@ static const char* const I18N_ES[] = {
"Distancia", "Distancia",
"Altitud", "Altitud",
"Indicativo", "Indicativo",
"Acerca de",
"Radar de vuelo ADS-B en",
"vivo para la ESP32 CYD.",
"Versión: ",
"Brillo: ",
"Brillo",
"¡Atención!!!",
"El registro de vuelos guarda permanentemente todos los aviones visibles en la tarjeta SD. Para evitar que se llene sin que lo note y que los menús se vuelvan lentos, se desactiva automáticamente después de 24 horas.",
}; };
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -19,9 +19,6 @@ static const char* const I18N_FR[] = {
"ÉCHEC WiFi", "ÉCHEC WiFi",
"Localisation...", "Localisation...",
"Prêt !", "Prêt !",
"Bonjour",
"Bonjour",
"Bonsoir",
"Pour cette appli une", "Pour cette appli une",
"carte SD est", "carte SD est",
@@ -186,6 +183,17 @@ static const char* const I18N_FR[] = {
"Distance", "Distance",
"Altitude", "Altitude",
"Indicatif", "Indicatif",
"À propos",
"Radar de vol ADS-B en",
"direct pour l'ESP32 CYD.",
"Version : ",
"Luminosité : ",
"Luminosité",
"Attention !!!",
"Le journal de vol enregistre en continu tous les avions visibles sur la carte SD. Pour éviter qu'elle se remplisse sans que vous le remarquiez, il se désactive automatiquement au bout de 24 heures.",
}; };
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -19,9 +19,6 @@ static const char* const I18N_IT[] = {
"WiFi FALLITO", "WiFi FALLITO",
"Rilevamento posizione...", "Rilevamento posizione...",
"Pronto!", "Pronto!",
"Buongiorno",
"Buon pomeriggio",
"Buonasera",
"Questa app richiede", "Questa app richiede",
"una scheda SD", "una scheda SD",
@@ -186,6 +183,17 @@ static const char* const I18N_IT[] = {
"Distanza", "Distanza",
"Altitudine", "Altitudine",
"Nominativo", "Nominativo",
"Informazioni",
"Radar di volo ADS-B live",
"per il display ESP32 CYD.",
"Versione: ",
"Luminosità: ",
"Luminosità",
"Attenzione!!!",
"Il diario di volo salva permanentemente tutti gli aerei visibili sulla scheda SD. Per evitare che si riempia senza che te ne accorga e che i menu diventino lenti, si disattiva automaticamente dopo 24 ore.",
}; };
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -19,9 +19,6 @@ static const char* const I18N_TR[] = {
"WiFi BAŞARISIZ", "WiFi BAŞARISIZ",
"Konum alınıyor...", "Konum alınıyor...",
"Hazır!", "Hazır!",
"Günaydın",
"İyi günler",
"İyi akşamlar",
"Bu uygulama için bir", "Bu uygulama için bir",
"SD kart", "SD kart",
@@ -186,6 +183,17 @@ static const char* const I18N_TR[] = {
"Mesafe", "Mesafe",
"İrtifa", "İrtifa",
"Çağrı işareti", "Çağrı işareti",
"Hakkında",
"ESP32 CYD ekranı için",
"canlı ADS-B uçuş radarı.",
"Sürüm: ",
"Parlaklık: ",
"Parlaklık",
"Dikkat!!!",
"Kayıt defteri, görüntülenen tüm uçakları SD karta sürekli kaydeder. Kartın fark edilmeden dolmaması ve menülerin yavaşlamaması için 24 saat sonra otomatik olarak kapanır.",
}; };
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -16,11 +16,12 @@ namespace {
} }
}; };
void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) { void drawButton(TFT_eSPI& tft, const Rect& r, const String& label, bool danger = false) {
uint16_t accent = danger ? TFT_RED : TFT_GREEN;
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK); tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN); tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, accent);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(accent, TFT_BLACK);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
} }
@@ -28,6 +29,10 @@ namespace {
constexpr uint8_t MAX_DAYS_QUERIED = 31; constexpr uint8_t MAX_DAYS_QUERIED = 31;
constexpr uint8_t VISIBLE_ROWS = 10; 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.
constexpr int16_t ROWS_START_Y = 50;
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth, 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 lineHeight, const String& text, int16_t scrollY,
int16_t viewTop, int16_t viewBottom, bool draw) { int16_t viewTop, int16_t viewBottom, bool draw) {
@@ -147,7 +152,7 @@ void run(TFT_eSPI& tft) {
tft.setCursor(10, 14); tft.setCursor(10, 14);
tft.println(I18n::t(StringId::LOGFILES_TITLE)); tft.println(I18n::t(StringId::LOGFILES_TITLE));
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK); tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, 30); tft.setCursor(10, ROWS_START_Y);
tft.print(I18n::t(StringId::LOADING)); tft.print(I18n::t(StringId::LOADING));
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED]; FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
@@ -165,13 +170,16 @@ void run(TFT_eSPI& tft) {
tft.println(I18n::t(StringId::LOGFILES_TITLE)); tft.println(I18n::t(StringId::LOGFILES_TITLE));
drawButton(tft, infoBtn, "?"); drawButton(tft, infoBtn, "?");
Rect deleteRects[VISIBLE_ROWS];
uint8_t deleteCount = 0;
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
if (count == 0) { if (count == 0) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK); tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, 40); tft.setCursor(10, ROWS_START_Y);
tft.println(I18n::t(StringId::LOGFILES_EMPTY)); tft.println(I18n::t(StringId::LOGFILES_EMPTY));
} else { } else {
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0; int16_t y = ROWS_START_Y;
int16_t y = 30;
if (count > VISIBLE_ROWS) { if (count > VISIBLE_ROWS) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK); tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
@@ -188,6 +196,15 @@ void run(TFT_eSPI& tft) {
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(110, y); tft.setCursor(110, y);
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX)); 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.
Rect delBtn = {(int16_t)(Config::SCREEN_WIDTH - 34), (int16_t)(y - 15), 30, 18};
drawButton(tft, delBtn, "X", true);
deleteRects[deleteCount++] = delBtn;
y += 20; y += 20;
} }
} }
@@ -201,9 +218,18 @@ void run(TFT_eSPI& tft) {
delay(20); delay(20);
} }
if (infoBtn.contains(tap.x, tap.y)) { bool handled = false;
for (uint8_t i = 0; i < deleteCount; i++) {
if (deleteRects[i].contains(tap.x, tap.y)) {
FlightLogbook::deleteFile(days[startIdx + i].date);
count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
handled = true;
break;
}
}
if (!handled && infoBtn.contains(tap.x, tap.y)) {
runInfoScreen(tft); runInfoScreen(tft);
} else if (backBtn.contains(tap.x, tap.y)) { } else if (!handled && backBtn.contains(tap.x, tap.y)) {
done = true; done = true;
} }
} }

View File

@@ -50,13 +50,28 @@ bool wasEmergency = false;
bool bannerBlinkOn = false; bool bannerBlinkOn = false;
constexpr uint8_t BACKLIGHT_FULL = 255; constexpr uint8_t BACKLIGHT_FULL = 255;
constexpr uint8_t BACKLIGHT_NIGHT_DIM = 90;
constexpr uint8_t BACKLIGHT_DIMMED = 12; constexpr uint8_t BACKLIGHT_DIMMED = 12;
constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0; constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0;
uint32_t lastInteractionMs = 0; uint32_t lastInteractionMs = 0;
bool screenDimmed = false; bool screenDimmed = false;
bool nightDimActive = 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 { struct Rect {
int16_t x, y, w, h; int16_t x, y, w, h;
bool contains(int16_t px, int16_t py) const { bool contains(int16_t px, int16_t py) const {
@@ -133,7 +148,22 @@ void drawHeader() {
void updateStatusLine() { void updateStatusLine() {
if (wasEmergency) return; if (wasEmergency) return;
tft.fillRect(0, HEADER_TITLE_H, Config::SCREEN_WIDTH, STATUS_LINE_H, TFT_BLACK); // 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 die Cam/Menu-Buttons
// hineinschneiden, die bis y=25 reichen.
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); tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
time_t now = time(nullptr); time_t now = time(nullptr);
@@ -171,7 +201,7 @@ void updateNightDimming() {
bool shouldDim = SettingsStore::nightDimmingEnabled() && isNightDimHours(); bool shouldDim = SettingsStore::nightDimmingEnabled() && isNightDimHours();
if (shouldDim != nightDimActive) { if (shouldDim != nightDimActive) {
ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldDim ? BACKLIGHT_NIGHT_DIM : BACKLIGHT_FULL); ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldDim ? nightDimBacklightPwm() : normalBacklightPwm());
nightDimActive = shouldDim; nightDimActive = shouldDim;
} }
} }
@@ -250,7 +280,7 @@ void setup() {
ledcSetup(BACKLIGHT_PWM_CHANNEL, 5000, 8); ledcSetup(BACKLIGHT_PWM_CHANNEL, 5000, 8);
ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL); ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL);
ledcWrite(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_FULL); ledcWrite(BACKLIGHT_PWM_CHANNEL, normalBacklightPwm());
TouchInput::begin(); TouchInput::begin();
LedAlert::begin(); LedAlert::begin();
@@ -266,6 +296,11 @@ void setup() {
SettingsStore::load(); SettingsStore::load();
tft.invertDisplay(SettingsStore::displayInverted()); 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(); WifiMgr::init();
LocationPresets::init(); LocationPresets::init();
@@ -273,6 +308,12 @@ void setup() {
AircraftWatchlist::init(); AircraftWatchlist::init();
SplashScreen::begin(tft); 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); SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
if (!TouchInput::loadCalibration()) { if (!TouchInput::loadCalibration()) {
@@ -302,6 +343,7 @@ void setup() {
if (isFirstRun) { if (isFirstRun) {
FirstRunLanguageScreen::run(tft); FirstRunLanguageScreen::run(tft);
SplashScreen::begin(tft); SplashScreen::begin(tft);
MenuStars::update(tft);
SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE); SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
} }
@@ -310,7 +352,15 @@ void setup() {
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_GETTING_LOCATION)); SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_GETTING_LOCATION));
LocationManager::init(); LocationManager::init();
uint32_t locStart = millis(); uint32_t locStart = millis();
while (LocationManager::currentSource() == LocationManager::Source::None && // 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) { millis() - locStart < 8000) {
LocationManager::requestIpLookupIfNeeded(); LocationManager::requestIpLookupIfNeeded();
MenuStars::update(tft); MenuStars::update(tft);
@@ -325,12 +375,6 @@ void setup() {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov"); configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
} }
// ERST JETZT die Begruessung zeichnen - vorher (siehe oben) war die
// Zeitzone noch nicht gesetzt, configTime() lief noch mit UTC statt
// Ortszeit, was in vielen Zeitzonen zu einer falschen Tageszeit-
// Einschaetzung gefuehrt haette (z.B. UTC-Nachmittag = Ortszeit-Abend).
SplashScreen::showGreeting(tft);
AircraftTable::init(); AircraftTable::init();
AirlineLookup::init(); AirlineLookup::init();
FlightLogbook::init(); FlightLogbook::init();
@@ -355,7 +399,7 @@ void loop() {
if (screenDimmed) { if (screenDimmed) {
bool shouldNightDim = SettingsStore::nightDimmingEnabled() && isNightDimHours(); bool shouldNightDim = SettingsStore::nightDimmingEnabled() && isNightDimHours();
ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldNightDim ? BACKLIGHT_NIGHT_DIM : BACKLIGHT_FULL); ledcWrite(BACKLIGHT_PWM_CHANNEL, shouldNightDim ? nightDimBacklightPwm() : normalBacklightPwm());
nightDimActive = shouldNightDim; nightDimActive = shouldNightDim;
screenDimmed = false; screenDimmed = false;
tapped = false; tapped = false;
@@ -409,7 +453,7 @@ void loop() {
if (!screenDimmed && timeoutMin > 0) { if (!screenDimmed && timeoutMin > 0) {
uint32_t timeoutMs = (uint32_t)timeoutMin * 60000UL; uint32_t timeoutMs = (uint32_t)timeoutMin * 60000UL;
if (nowMs - lastInteractionMs >= timeoutMs) { if (nowMs - lastInteractionMs >= timeoutMs) {
ledcWrite(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_DIMMED); ledcWrite(BACKLIGHT_PWM_CHANNEL, min(BACKLIGHT_DIMMED, normalBacklightPwm()));
screenDimmed = true; screenDimmed = true;
} }
} }

View File

@@ -9,6 +9,8 @@
#include "airline_filter_screen.h" #include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h" #include "aircraft_watchlist_screen.h"
#include "aircraft_list_screen.h" #include "aircraft_list_screen.h"
#include "about_screen.h"
#include "brightness_screen.h"
#include "language_screen.h" #include "language_screen.h"
#include "units_screen.h" #include "units_screen.h"
#include "settings_backup.h" #include "settings_backup.h"
@@ -16,6 +18,7 @@
#include "menu_stars.h" #include "menu_stars.h"
#include "i18n.h" #include "i18n.h"
#include "config.h" #include "config.h"
#include <time.h>
namespace MenuScreen { namespace MenuScreen {
@@ -76,6 +79,10 @@ namespace {
return prefix + String(minutes) + " min"; return prefix + String(minutes) + " min";
} }
String brightnessLabel(uint8_t percent) {
return String(I18n::t(StringId::MENU_BRIGHTNESS_PREFIX)) + String(percent) + "%";
}
void showBriefMessage(TFT_eSPI& tft, const String& msg, uint16_t color) { 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.fillRect(0, Config::SCREEN_HEIGHT - 18, Config::SCREEN_WIDTH, 18, TFT_BLACK);
tft.setTextColor(color, TFT_BLACK); tft.setTextColor(color, TFT_BLACK);
@@ -85,6 +92,138 @@ namespace {
delay(1200); 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).
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) {
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 (tft.textWidth(line) > maxWidth) {
int32_t lastSpace = line.lastIndexOf(' ');
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
if (draw) {
int16_t screenY = y - scrollY;
if (screenY >= viewTop && screenY <= viewBottom) {
tft.setCursor(x, screenY);
tft.print(line);
}
}
y += lineHeight;
start += line.length();
}
return y;
}
// Warn-Ueberlage, die praktisch den kompletten Bildschirm einnimmt (nur
// ein paar Pixel Rand) und vor dem Einschalten des Flugbuchs erklaert,
// warum es sich nach 24h automatisch wieder abschaltet. "Achtung!!!"
// steht ganz oben, mit einer Leerzeile Abstand zum Fliesstext 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".
bool confirmLogbookEnable(TFT_eSPI& tft) {
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 "Achtung!!!" und dem Fliesstext.
constexpr int16_t VIEW_TOP = TITLE_Y + 12 + LINE_H;
constexpr int16_t BTN_H = 36;
constexpr int16_t BTN_GAP = 8;
constexpr int16_t BOTTOM_MARGIN = 8;
constexpr int16_t CANCEL_Y = BOX_Y + BOX_H - BOTTOM_MARGIN - BTN_H;
constexpr int16_t OK_Y = CANCEL_Y - BTN_GAP - BTN_H;
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).
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;
String body = I18n::t(StringId::MENU_LOGBOOK_WARNING_BODY);
int16_t totalH = layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, 0, 0, 0, false);
bool scrollable = (totalH - VIEW_BOTTOM_NO_SCROLL) > 0;
int16_t viewBottom = scrollable ? VIEW_BOTTOM_SCROLL : VIEW_BOTTOM_NO_SCROLL;
int16_t maxScroll = totalH - viewBottom;
if (maxScroll < 0) maxScroll = 0;
int16_t scrollY = 0;
constexpr int16_t SCROLL_STEP = 48;
Rect okBtn = {(int16_t)(BOX_X + 10), OK_Y, (int16_t)(BOX_W - 20), BTN_H};
Rect cancelBtn = {(int16_t)(BOX_X + 10), CANCEL_Y, (int16_t)(BOX_W - 20), BTN_H};
int16_t scrollRowY = VIEW_BOTTOM_SCROLL + SCROLL_ROW_GAP;
Rect upBtn = {(int16_t)(BOX_X + BOX_W / 2 - 64), scrollRowY, 60, SCROLL_ROW_H};
Rect downBtn = {(int16_t)(BOX_X + BOX_W / 2 + 4), scrollRowY, 60, SCROLL_ROW_H};
MenuStars::reset();
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, TFT_RED);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.setTextSize(2);
tft.drawString(I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE), BOX_X + BOX_W / 2, TITLE_Y);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, scrollY, VIEW_TOP, viewBottom, true);
drawButton(tft, okBtn, I18n::t(StringId::OK));
drawButton(tft, cancelBtn, I18n::t(StringId::BACK));
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (okBtn.contains(tap.x, tap.y)) return true;
if (cancelBtn.contains(tap.x, tap.y)) return false;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
}
}
enum class Page { Main, Region, System, Flight }; enum class Page { Main, Region, System, Flight };
} }
@@ -167,11 +306,13 @@ void run(TFT_eSPI& tft) {
Rect calibBtn = rowRect(0); Rect calibBtn = rowRect(0);
Rect invertBtn = rowRect(1); Rect invertBtn = rowRect(1);
Rect timeoutBtn = rowRect(2); Rect brightnessBtn = rowRect(2);
Rect nightDimBtn = rowRect(3); Rect timeoutBtn = rowRect(3);
Rect backupBtn = rowRect(4); Rect nightDimBtn = rowRect(4);
Rect restoreBtn = rowRect(5); Rect backupBtn = rowRect(5);
Rect backBtn = rowRect(6); Rect restoreBtn = rowRect(6);
Rect aboutBtn = rowRect(7);
Rect backBtn = rowRect(8);
drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE)); drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE));
@@ -180,10 +321,12 @@ void run(TFT_eSPI& tft) {
: I18n::t(StringId::MENU_DISPLAY_NORMAL); : I18n::t(StringId::MENU_DISPLAY_NORMAL);
drawButton(tft, invertBtn, invertLabel); drawButton(tft, invertBtn, invertLabel);
drawButton(tft, brightnessBtn, brightnessLabel(SettingsStore::brightnessPercent()));
drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes())); drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes()));
drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled())); drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP)); drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP));
drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE)); drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE));
drawButton(tft, aboutBtn, I18n::t(StringId::MENU_ABOUT));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW)); drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap; TouchInput::Point tap;
@@ -199,6 +342,8 @@ void run(TFT_eSPI& tft) {
bool newState = !SettingsStore::displayInverted(); bool newState = !SettingsStore::displayInverted();
SettingsStore::setDisplayInverted(newState); SettingsStore::setDisplayInverted(newState);
tft.invertDisplay(newState); tft.invertDisplay(newState);
} else if (brightnessBtn.contains(tap.x, tap.y)) {
BrightnessScreen::run(tft);
} else if (timeoutBtn.contains(tap.x, tap.y)) { } else if (timeoutBtn.contains(tap.x, tap.y)) {
uint8_t current = SettingsStore::screenTimeoutMinutes(); uint8_t current = SettingsStore::screenTimeoutMinutes();
uint8_t next = (current >= 10) ? 0 : (current + 1); uint8_t next = (current >= 10) ? 0 : (current + 1);
@@ -215,6 +360,8 @@ void run(TFT_eSPI& tft) {
showBriefMessage(tft, I18n::t(ok ? StringId::MENU_RESTORED : StringId::MENU_RESTORE_FAILED), showBriefMessage(tft, I18n::t(ok ? StringId::MENU_RESTORED : StringId::MENU_RESTORE_FAILED),
ok ? TFT_GREEN : TFT_RED); ok ? TFT_GREEN : TFT_RED);
} }
} else if (aboutBtn.contains(tap.x, tap.y)) {
AboutScreen::run(tft);
} else if (backBtn.contains(tap.x, tap.y)) { } else if (backBtn.contains(tap.x, tap.y)) {
page = Page::Main; page = Page::Main;
} }
@@ -275,7 +422,19 @@ void run(TFT_eSPI& tft) {
} else if (logFilesBtn.contains(tap.x, tap.y)) { } else if (logFilesBtn.contains(tap.x, tap.y)) {
LogbookFilesScreen::run(tft); LogbookFilesScreen::run(tft);
} else if (logbookBtn.contains(tap.x, tap.y)) { } else if (logbookBtn.contains(tap.x, tap.y)) {
SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled()); if (SettingsStore::flightLogbookEnabled()) {
// Ausschalten ist immer unbedenklich - keine Bestaetigung noetig.
SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile("");
} else if (confirmLogbookEnable(tft)) {
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.
SettingsStore::setFlightLogbookSessionFile("");
}
} else if (heartbeatBtn.contains(tap.x, tap.y)) { } else if (heartbeatBtn.contains(tap.x, tap.y)) {
SettingsStore::setLedHeartbeatEnabled(!SettingsStore::ledHeartbeatEnabled()); SettingsStore::setLedHeartbeatEnabled(!SettingsStore::ledHeartbeatEnabled());
} else if (proximityBtn.contains(tap.x, tap.y)) { } else if (proximityBtn.contains(tap.x, tap.y)) {

View File

@@ -107,18 +107,23 @@ namespace {
return SettingsStore::nightDimmingEnabled() && isNightHours(); return SettingsStore::nightDimmingEnabled() && isNightHours();
} }
// Farbe je nach Flughoehe - gedaempft (dunkleres Gruen/Oliv/Rot) waehrend // Farbe je nach Flughoehe - gedaempft (dunkleres Gruen/Oliv) waehrend der
// der Nachtdimmung. Bewusst eigene, etwas hellere Toene als // Nachtdimmung. Bewusst eigene, etwas hellere Toene als
// dimColorForAltitude() weiter unten (das ist die IMMER dunklere // dimColorForAltitude() weiter unten (das ist die IMMER dunklere
// Trail-Farbe hinter jedem Flugzeug) - sonst waeren Kopf-Marker und // Trail-Farbe hinter jedem Flugzeug) - sonst waeren Kopf-Marker und
// Trail nachts nicht mehr auseinanderzuhalten. // Trail nachts nicht mehr auseinanderzuhalten.
//
// 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.
uint16_t colorForAltitude(TFT_eSPI& gfx, int32_t altFt) { uint16_t colorForAltitude(TFT_eSPI& gfx, int32_t altFt) {
bool dim = nightDimActiveNow(); bool dim = nightDimActiveNow();
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT) if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT)
return dim ? gfx.color565(0, 160, 0) : TFT_GREEN; return dim ? gfx.color565(0, 160, 0) : TFT_GREEN;
if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT) if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT)
return dim ? gfx.color565(160, 160, 0) : TFT_YELLOW; return dim ? gfx.color565(160, 160, 0) : TFT_YELLOW;
return dim ? gfx.color565(160, 30, 0) : TFT_RED; return TFT_RED;
} }
uint16_t sweepLineColor(TFT_eSPI& gfx) { uint16_t sweepLineColor(TFT_eSPI& gfx) {
@@ -220,6 +225,16 @@ namespace {
constexpr uint8_t MAX_TRAILS = Config::MAX_TRACKED_AIRCRAFT; constexpr uint8_t MAX_TRAILS = Config::MAX_TRACKED_AIRCRAFT;
TrailEntry trails[MAX_TRAILS]; TrailEntry trails[MAX_TRAILS];
// Alle Trails verwerfen, z.B. wenn sich der Radius (rangeKm) aendert:
// die gespeicherten Punkte sind Bildschirm-Pixelkoordinaten, die unter
// der ALTEN Skalierung berechnet wurden. Ohne diesen Reset zieht jedes
// Flugzeug fuer ein paar Heartbeat-Takte eine falsche "Schwanz"-Linie
// von der alten zur neuen Position, bis sich der kurze Ringpuffer
// (TRAIL_LEN=4) mit neuen, konsistenten Punkten gefuellt hat.
void clearTrails() {
for (uint8_t i = 0; i < MAX_TRAILS; i++) trails[i] = TrailEntry{};
}
void pruneStaleTrails() { void pruneStaleTrails() {
uint32_t now = millis(); uint32_t now = millis();
for (uint8_t i = 0; i < MAX_TRAILS; i++) { for (uint8_t i = 0; i < MAX_TRAILS; i++) {
@@ -303,10 +318,16 @@ namespace {
snprintf(highLabel, sizeof(highLabel), ">30k ft"); 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 (22-6 Uhr) 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.
struct { uint16_t color; const char* label; } items[3] = { struct { uint16_t color; const char* label; } items[3] = {
{TFT_GREEN, lowLabel}, {colorForAltitude(gfx, 0), lowLabel},
{TFT_YELLOW, midLabel}, {colorForAltitude(gfx, Config::COLOR_LOW_ALT_THRESHOLD_FT), midLabel},
{TFT_RED, highLabel}, {colorForAltitude(gfx, Config::COLOR_MID_ALT_THRESHOLD_FT), highLabel},
}; };
int16_t segW = Config::SCREEN_WIDTH / 3; int16_t segW = Config::SCREEN_WIDTH / 3;
gfx.setTextColor(TFT_WHITE, TFT_BLACK); gfx.setTextColor(TFT_WHITE, TFT_BLACK);
@@ -774,6 +795,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
if (L.rangeBtn.contains(x, y)) { if (L.rangeBtn.contains(x, y)) {
uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT; uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT;
SettingsStore::setRangeIndex(idx); SettingsStore::setRangeIndex(idx);
clearTrails();
return true; return true;
} }
@@ -800,6 +822,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
uint8_t idx = SettingsStore::rangeIndex(); uint8_t idx = SettingsStore::rangeIndex();
idx = (idx == 0) ? (Config::RANGE_STEP_COUNT - 1) : (idx - 1); idx = (idx == 0) ? (Config::RANGE_STEP_COUNT - 1) : (idx - 1);
SettingsStore::setRangeIndex(idx); SettingsStore::setRangeIndex(idx);
clearTrails();
lastEmptyTapMs = 0; lastEmptyTapMs = 0;
} }

View File

@@ -2,16 +2,23 @@
#include "config.h" #include "config.h"
#include "sd_mutex.h" #include "sd_mutex.h"
#include <SD.h> #include <SD.h>
#include <cstring>
namespace SettingsStore { namespace SettingsStore {
namespace { namespace {
uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX; uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX;
bool inverted = true; // Dieses Board braucht invertDisplay(true) fuer korrekte Farben (siehe main.cpp) bool inverted = true; // Dieses Board braucht invertDisplay(true) fuer korrekte Farben (siehe main.cpp)
uint8_t brightnessPct = Config::BRIGHTNESS_MAX_PERCENT;
bool emergencyAlertOn = true; bool emergencyAlertOn = true;
bool proximityAlertOn = true; bool proximityAlertOn = true;
bool watchlistAlertOn = true; bool watchlistAlertOn = true;
bool flightLogbookOn = 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).
bool flightLogbookOn = false;
uint32_t logbookEnabledAtEpoch = 0;
char logbookSessionFile[16] = {0};
bool ledHeartbeatOn = true; bool ledHeartbeatOn = true;
uint8_t screenTimeoutMin = 0; uint8_t screenTimeoutMin = 0;
bool nightDimmingOn = true; bool nightDimmingOn = true;
@@ -27,6 +34,11 @@ namespace {
} }
} else if (key == "invert") { } else if (key == "invert") {
inverted = (value.toInt() != 0); inverted = (value.toInt() != 0);
} else if (key == "brightness_percent") {
int v = value.toInt();
if (v >= Config::BRIGHTNESS_MIN_PERCENT && v <= Config::BRIGHTNESS_MAX_PERCENT) {
brightnessPct = (uint8_t)v;
}
} else if (key == "emergency_alert") { } else if (key == "emergency_alert") {
emergencyAlertOn = (value.toInt() != 0); emergencyAlertOn = (value.toInt() != 0);
} else if (key == "proximity_alert") { } else if (key == "proximity_alert") {
@@ -35,6 +47,11 @@ namespace {
watchlistAlertOn = (value.toInt() != 0); watchlistAlertOn = (value.toInt() != 0);
} else if (key == "flight_logbook") { } else if (key == "flight_logbook") {
flightLogbookOn = (value.toInt() != 0); flightLogbookOn = (value.toInt() != 0);
} else if (key == "logbook_enabled_at") {
logbookEnabledAtEpoch = (uint32_t)value.toInt();
} else if (key == "logbook_session_file") {
strncpy(logbookSessionFile, value.c_str(), sizeof(logbookSessionFile) - 1);
logbookSessionFile[sizeof(logbookSessionFile) - 1] = 0;
} else if (key == "led_heartbeat") { } else if (key == "led_heartbeat") {
ledHeartbeatOn = (value.toInt() != 0); ledHeartbeatOn = (value.toInt() != 0);
} else if (key == "screen_timeout_min") { } else if (key == "screen_timeout_min") {
@@ -77,6 +94,15 @@ void load() {
applyKeyValue(key, value); applyKeyValue(key, value);
} }
f.close(); 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).
if (flightLogbookOn && logbookEnabledAtEpoch == 0) {
flightLogbookOn = false;
}
} }
void save() { void save() {
@@ -86,10 +112,13 @@ void save() {
if (!f) return; if (!f) return;
f.printf("range_index=%d\n", rangeIdx); f.printf("range_index=%d\n", rangeIdx);
f.printf("invert=%d\n", inverted ? 1 : 0); f.printf("invert=%d\n", inverted ? 1 : 0);
f.printf("brightness_percent=%d\n", brightnessPct);
f.printf("emergency_alert=%d\n", emergencyAlertOn ? 1 : 0); f.printf("emergency_alert=%d\n", emergencyAlertOn ? 1 : 0);
f.printf("proximity_alert=%d\n", proximityAlertOn ? 1 : 0); f.printf("proximity_alert=%d\n", proximityAlertOn ? 1 : 0);
f.printf("watchlist_alert=%d\n", watchlistAlertOn ? 1 : 0); f.printf("watchlist_alert=%d\n", watchlistAlertOn ? 1 : 0);
f.printf("flight_logbook=%d\n", flightLogbookOn ? 1 : 0); f.printf("flight_logbook=%d\n", flightLogbookOn ? 1 : 0);
f.printf("logbook_enabled_at=%lu\n", (unsigned long)logbookEnabledAtEpoch);
f.printf("logbook_session_file=%s\n", logbookSessionFile);
f.printf("led_heartbeat=%d\n", ledHeartbeatOn ? 1 : 0); f.printf("led_heartbeat=%d\n", ledHeartbeatOn ? 1 : 0);
f.printf("screen_timeout_min=%d\n", screenTimeoutMin); f.printf("screen_timeout_min=%d\n", screenTimeoutMin);
f.printf("night_dimming=%d\n", nightDimmingOn ? 1 : 0); f.printf("night_dimming=%d\n", nightDimmingOn ? 1 : 0);
@@ -115,6 +144,15 @@ void setDisplayInverted(bool inv) {
save(); save();
} }
uint8_t brightnessPercent() { return brightnessPct; }
void setBrightnessPercent(uint8_t percent) {
if (percent >= Config::BRIGHTNESS_MIN_PERCENT && percent <= Config::BRIGHTNESS_MAX_PERCENT) {
brightnessPct = percent;
save();
}
}
bool emergencyAlertEnabled() { return emergencyAlertOn; } bool emergencyAlertEnabled() { return emergencyAlertOn; }
void setEmergencyAlertEnabled(bool on) { void setEmergencyAlertEnabled(bool on) {
@@ -143,6 +181,21 @@ void setFlightLogbookEnabled(bool on) {
save(); save();
} }
uint32_t flightLogbookEnabledAtEpoch() { return logbookEnabledAtEpoch; }
void setFlightLogbookEnabledAtEpoch(uint32_t epoch) {
logbookEnabledAtEpoch = epoch;
save();
}
String flightLogbookSessionFile() { return String(logbookSessionFile); }
void setFlightLogbookSessionFile(const String& label) {
strncpy(logbookSessionFile, label.c_str(), sizeof(logbookSessionFile) - 1);
logbookSessionFile[sizeof(logbookSessionFile) - 1] = 0;
save();
}
bool ledHeartbeatEnabled() { return ledHeartbeatOn; } bool ledHeartbeatEnabled() { return ledHeartbeatOn; }
void setLedHeartbeatEnabled(bool on) { void setLedHeartbeatEnabled(bool on) {

View File

@@ -11,6 +11,10 @@ namespace SettingsStore {
bool displayInverted(); bool displayInverted();
void setDisplayInverted(bool inverted); void setDisplayInverted(bool inverted);
// Display-Helligkeit in Prozent (Config::BRIGHTNESS_MIN_PERCENT..MAX_PERCENT).
uint8_t brightnessPercent();
void setBrightnessPercent(uint8_t percent);
bool emergencyAlertEnabled(); bool emergencyAlertEnabled();
void setEmergencyAlertEnabled(bool on); void setEmergencyAlertEnabled(bool on);
@@ -23,6 +27,20 @@ namespace SettingsStore {
bool flightLogbookEnabled(); bool flightLogbookEnabled();
void setFlightLogbookEnabled(bool on); 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).
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().
String flightLogbookSessionFile();
void setFlightLogbookSessionFile(const String& label);
bool ledHeartbeatEnabled(); bool ledHeartbeatEnabled();
void setLedHeartbeatEnabled(bool on); void setLedHeartbeatEnabled(bool on);

View File

@@ -1,6 +1,5 @@
#include "splash_screen.h" #include "splash_screen.h"
#include "menu_stars.h" #include "menu_stars.h"
#include "i18n.h"
#include <math.h> #include <math.h>
namespace SplashScreen { namespace SplashScreen {
@@ -132,30 +131,6 @@ void begin(TFT_eSPI& tft) {
tft.setTextSize(1); tft.setTextSize(1);
} }
void showGreeting(TFT_eSPI& tft) {
// Ohne per NTP synchronisierte Uhrzeit koennen wir die Tageszeit nicht
// bestimmen - dann lieber nichts anzeigen als zu raten (gleiches Muster
// wie bei der Nachtdimmung in main.cpp::isNightDimHours()).
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return;
struct tm tmNow;
localtime_r(&now, &tmNow);
int h = tmNow.tm_hour;
StringId greetingId;
if (h < 12) greetingId = StringId::SPLASH_GREETING_MORNING;
else if (h < 18) greetingId = StringId::SPLASH_GREETING_DAY;
else greetingId = StringId::SPLASH_GREETING_EVENING;
int16_t cx = tft.width() / 2;
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setTextSize(1);
tft.drawString(I18n::t(greetingId), cx, 84);
tft.setTextDatum(TL_DATUM);
}
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) { void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) {
if (slot >= MAX_STATUS_LINES) return; if (slot >= MAX_STATUS_LINES) return;

View File

@@ -7,13 +7,6 @@ namespace SplashScreen {
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE); void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE);
// Zeigt eine tageszeit-abhaengige Begruessung (Morgen/Tag/Abend) unter dem
// "Flightradar"-Titel an. Braucht eine per NTP synchronisierte Uhrzeit -
// ist sie noch nicht verfuegbar, wird nichts gezeichnet (kein Raten).
// Am besten NACH der WLAN-Verbindung + Zeit-Synchronisierung aufrufen,
// NICHT direkt in begin() (die Uhrzeit steht zu dem Zeitpunkt noch nicht).
void showGreeting(TFT_eSPI& tft);
// Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind. // Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind.
// Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend // Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend
// des Wartens dieselbe Sterne-Animation wie die Menues. // des Wartens dieselbe Sterne-Animation wie die Menues.

View File

@@ -136,7 +136,10 @@ void run(TFT_eSPI& tft) {
tft.print(I18n::t(StringId::LOADING)); tft.print(I18n::t(StringId::LOADING));
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED]; FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t count = FlightLogbook::listDays(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.
uint8_t count = FlightLogbook::listDaySummaries(days, MAX_DAYS_QUERIED);
uint8_t barCount = (count > MAX_BARS) ? MAX_BARS : count; uint8_t barCount = (count > MAX_BARS) ? MAX_BARS : count;
uint8_t startIdx = count - barCount; uint8_t startIdx = count - barCount;