v1.4: Header status line, compass rose, logbook files view, WiFi reconnect fix, SD-required screen color fix

This commit is contained in:
Eiswolf-BG
2026-08-03 15:13:42 +02:00
parent ec2f0aa3f2
commit ff2d3af420
9 changed files with 331 additions and 101 deletions

View File

@@ -1,9 +1,11 @@
#include <WiFi.h>
#include "adsb_client.h" #include "adsb_client.h"
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <time.h> #include <time.h>
namespace AdsbClient { namespace AdsbClient {
namespace { namespace {

View File

@@ -65,6 +65,7 @@ namespace {
: line.substring(firstComma + 1, secondComma); : line.substring(firstComma + 1, secondComma);
hex.trim(); hex.trim();
if (hex.length() > 0) markSeen(hex.c_str()); if (hex.length() > 0) markSeen(hex.c_str());
yield();
} }
f.close(); f.close();
} }
@@ -79,19 +80,7 @@ namespace {
} }
} }
void writeLogLine(const Aircraft& a) { void writeLogLine(File& f, const Aircraft& a) {
char filename[64];
logFilename(filename, sizeof(filename));
bool needsHeader = !SD.exists(filename);
File f = SD.open(filename, FILE_APPEND);
if (!f) return;
if (needsHeader) {
f.println("timestamp,hex,callsign,reg,type,distance_km,altitude_ft");
}
time_t now = time(nullptr); time_t now = time(nullptr);
struct tm tmNow; struct tm tmNow;
localtime_r(&now, &tmNow); localtime_r(&now, &tmNow);
@@ -108,7 +97,6 @@ namespace {
a.typeCode[0] ? a.typeCode : "", a.typeCode[0] ? a.typeCode : "",
a.distanceKm, a.distanceKm,
(int)a.altBaroFt); (int)a.altBaroFt);
f.close();
} }
} }
@@ -132,12 +120,32 @@ void update() {
} }
AircraftTable::unlock(); AircraftTable::unlock();
bool anyNew = false;
for (uint8_t i = 0; i < count; i++) {
if (snapshot[i].hex[0] && !alreadySeen(snapshot[i].hex)) { anyNew = true; break; }
}
if (!anyNew) return;
char filename[64];
logFilename(filename, sizeof(filename));
bool needsHeader = !SD.exists(filename);
yield();
File f = SD.open(filename, FILE_APPEND);
if (!f) return;
if (needsHeader) {
f.println("timestamp,hex,callsign,reg,type,distance_km,altitude_ft");
}
for (uint8_t i = 0; i < count; i++) { for (uint8_t i = 0; i < count; i++) {
if (!snapshot[i].hex[0]) continue; if (!snapshot[i].hex[0]) continue;
if (alreadySeen(snapshot[i].hex)) continue; if (alreadySeen(snapshot[i].hex)) continue;
markSeen(snapshot[i].hex); markSeen(snapshot[i].hex);
writeLogLine(snapshot[i]); writeLogLine(f, snapshot[i]);
yield();
} }
f.close();
} }
uint16_t todayCount() { return seenCount; } uint16_t todayCount() { return seenCount; }
@@ -159,6 +167,7 @@ void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) {
while (entry.available()) { while (entry.available()) {
entry.readStringUntil('\n'); entry.readStringUntil('\n');
lines++; lines++;
if (lines % 50 == 0) yield();
} }
if (lines > 0) totalAircraft += (lines - 1); if (lines > 0) totalAircraft += (lines - 1);
} }
@@ -169,4 +178,39 @@ void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) {
dir.close(); dir.close();
} }
uint8_t listDays(DayEntry* out, uint8_t maxEntries) {
uint8_t filled = 0;
File dir = SD.open(Config::SD_LOG_DIR);
if (!dir || !dir.isDirectory()) return 0;
File entry = dir.openNextFile();
while (entry && filled < maxEntries) {
if (!entry.isDirectory()) {
String name = String(entry.name());
if (name.endsWith(".csv")) {
String dateOnly = name.substring(0, name.length() - 4);
int slashIdx = dateOnly.lastIndexOf('/');
if (slashIdx >= 0) dateOnly = dateOnly.substring(slashIdx + 1);
uint32_t lines = 0;
while (entry.available()) {
entry.readStringUntil('\n');
lines++;
if (lines % 50 == 0) yield();
}
strncpy(out[filled].date, dateOnly.c_str(), sizeof(out[filled].date) - 1);
out[filled].count = (lines > 0) ? (lines - 1) : 0;
filled++;
}
}
entry.close();
entry = dir.openNextFile();
}
dir.close();
return filled;
}
} }

View File

@@ -9,22 +9,21 @@
// beim Start aus der heutigen CSV-Datei). // beim Start aus der heutigen CSV-Datei).
namespace FlightLogbook { namespace FlightLogbook {
// Einmalig beim Boot aufrufen, NACHDEM die lokale Uhrzeit (UTC-Offset)
// bekannt ist - liest die heutige CSV-Datei (falls vorhanden), um bereits
// geloggte Flugzeuge nicht doppelt einzutragen.
void init(); void init();
// Periodisch aufrufen (z.B. nach jeder erfolgreichen ADS-B-Abfrage):
// prueft auf neue, bisher ungesehene Flugzeuge und schreibt sie ins
// heutige Logbuch. Kuemmert sich auch um den Tageswechsel (neue Datei,
// neue "gesehen"-Liste).
void update(); void update();
// Anzahl der HEUTE bereits geloggten (unterschiedlichen) Flugzeuge.
uint16_t todayCount(); uint16_t todayCount();
// Summiert alle taeglichen Logbuch-Dateien auf der SD-Karte:
// 'totalAircraft' = Summe aller Eintraege ueber alle Tage,
// 'totalDays' = Anzahl der Tage, an denen ueberhaupt geloggt wurde.
void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays); void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays);
struct DayEntry {
char date[11] = {0}; // "YYYY-MM-DD"
uint32_t count = 0;
};
// Fuellt 'out' mit bis zu 'maxEntries' Tagen (Dateiname + Anzahl
// Eintraege), sortiert wie sie auf der SD-Karte liegen (i.d.R.
// chronologisch). Gibt die tatsaechliche Anzahl gefuellter Eintraege
// zurueck.
uint8_t listDays(DayEntry* out, uint8_t maxEntries);
} }

View File

@@ -0,0 +1,78 @@
#include "logbook_files_screen.h"
#include "flight_logbook.h"
#include "touch_input.h"
#include "config.h"
namespace LogbookFilesScreen {
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_NAVY);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM);
}
constexpr uint8_t MAX_DAYS_QUERIED = 31;
constexpr uint8_t VISIBLE_ROWS = 10;
}
void run(TFT_eSPI& tft) {
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 8);
tft.println("Logbook files");
if (count == 0) {
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
tft.setCursor(10, 40);
tft.println("No logbook entries yet.");
} else {
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
int16_t y = 30;
if (count > VISIBLE_ROWS) {
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
tft.setCursor(10, y);
tft.printf("Showing last %d of %d days", VISIBLE_ROWS, count);
y += 16;
}
for (uint8_t i = startIdx; i < count; i++) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, y);
tft.print(days[i].date);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(110, y);
tft.printf("%lu aircraft", (unsigned long)days[i].count);
y += 20;
}
}
drawButton(tft, backBtn, "Back");
bool done = false;
while (!done) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap) && backBtn.contains(tap.x, tap.y)) {
done = true;
}
delay(20);
}
}
}

View File

@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace LogbookFilesScreen {
// Blockierend: listet die Logbuch-Dateien (ein Tag pro Zeile, mit Anzahl
// Eintraegen) auf. Kehrt zurueck, sobald "Back" angetippt wird.
void run(TFT_eSPI& tft);
}

View File

@@ -2,6 +2,8 @@
#include <SPI.h> #include <SPI.h>
#include <TFT_eSPI.h> #include <TFT_eSPI.h>
#include <SD.h> #include <SD.h>
#include <WiFi.h>
#include <time.h>
#include "config.h" #include "config.h"
#include "aircraft.h" #include "aircraft.h"
@@ -22,15 +24,23 @@
#include "splash_screen.h" #include "splash_screen.h"
#include "led_alert.h" #include "led_alert.h"
#include "flight_logbook.h" #include "flight_logbook.h"
#include "screenshot.h"
TFT_eSPI tft = TFT_eSPI(); TFT_eSPI tft = TFT_eSPI();
constexpr int16_t CONTENT_TOP = 30; // schlanker Header, Radar bekommt den Rest des Screens // Schlanker Header (Titel + Buttons) PLUS eine zweite, duenne Statuszeile
constexpr uint32_t POLL_INTERVAL_MS = 300; // wie oft wir NACHSCHAUEN, ob sich Daten geaendert haben // (Uhrzeit + WLAN-Signalstaerke) darunter - der Radar-Kreis bekommt den Rest
constexpr uint32_t SWEEP_TICK_MS = 80; // wie oft der Sweep-Strahl ein Stueck weiterdreht // des Screens.
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 lastPollMs = 0;
uint32_t lastSweepMs = 0; uint32_t lastSweepMs = 0;
uint32_t lastRenderedVersion = 0xFFFFFFFF; // erzwingt einen ersten Render-Aufruf uint32_t lastStatusLineMs = 0;
uint32_t lastRenderedVersion = 0xFFFFFFFF;
bool forceRedraw = false; bool forceRedraw = false;
bool wasEmergency = false; bool wasEmergency = false;
bool bannerBlinkOn = false; bool bannerBlinkOn = false;
@@ -43,6 +53,7 @@ struct Rect {
}; };
Rect menuBtn = {Config::SCREEN_WIDTH - 60, 3, 54, 22}; Rect menuBtn = {Config::SCREEN_WIDTH - 60, 3, 54, 22};
Rect camBtn = {(int16_t)(menuBtn.x - 46), 3, 42, 22};
void drawMenuButton() { void drawMenuButton() {
tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_NAVY); tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_NAVY);
@@ -53,48 +64,96 @@ void drawMenuButton() {
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
} }
void drawCamButton() {
tft.fillRoundRect(camBtn.x, camBtn.y, camBtn.w, camBtn.h, 4, TFT_NAVY);
tft.drawRoundRect(camBtn.x, camBtn.y, camBtn.w, camBtn.h, 4, TFT_DARKGREY);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.drawString("Cam", camBtn.x + camBtn.w / 2, camBtn.y + camBtn.h / 2);
tft.setTextDatum(TL_DATUM);
}
void drawHeader() { void drawHeader() {
// Bis CONTENT_TOP loeschen, damit kein Bildrest vom Menue zwischen Header
// und Radar-Bildschirm haengen bleibt. Schlanker Header, damit der Radar-
// Kreis darunter maximal viel Platz bekommt.
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK); tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1); tft.setTextSize(1);
tft.setCursor(6, 10); tft.setCursor(6, 10);
tft.println("Eiswolfs Flightradar"); tft.println("Eiswolfs Flightradar");
drawCamButton();
drawMenuButton(); drawMenuButton();
} }
// Zeigt eine grosse, unmissverstaendliche Meldung und haelt das Geraet an - void updateStatusLine() {
// KEIN weiterer Bildschirm erscheint, solange keine SD-Karte steckt. Die App if (wasEmergency) return;
// braucht die Karte fuer Einstellungen, WLAN-Zugangsdaten und Nachschlage-
// tabellen, daher macht ein Weiterlaufen ohne sie keinen Sinn. tft.fillRect(0, HEADER_TITLE_H, Config::SCREEN_WIDTH, STATUS_LINE_H, TFT_BLACK);
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
time_t now = time(nullptr);
if (now > 8 * 3600 * 2) {
struct tm tmNow;
localtime_r(&now, &tmNow);
char timeBuf[6];
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tmNow.tm_hour, tmNow.tm_min);
tft.setCursor(6, HEADER_TITLE_H + 2);
tft.print(timeBuf);
}
if (WiFi.status() == WL_CONNECTED) {
char rssiBuf[14];
snprintf(rssiBuf, sizeof(rssiBuf), "WiFi %ddBm", WiFi.RSSI());
tft.setTextDatum(TR_DATUM);
tft.drawString(rssiBuf, Config::SCREEN_WIDTH - 4, HEADER_TITLE_H + 2);
tft.setTextDatum(TL_DATUM);
}
}
void takeScreenshotWithFeedback() {
String filename = Screenshot::save(tft);
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_NAVY);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.setCursor(6, 10);
if (filename.length() > 0) {
tft.print("Saved: " + filename);
} else {
tft.setTextColor(TFT_RED, TFT_NAVY);
tft.print("Screenshot failed");
}
delay(1200);
drawHeader();
updateStatusLine();
}
void haltWithSdRequiredScreen() { void haltWithSdRequiredScreen() {
// Ohne SD-Karte kann SettingsStore::load() nie laufen, also ist auch die
// Invertierungs-Einstellung dieses Geraets unbekannt. Dieses konkrete
// CYD-Board braucht invertDisplay(true), um Farben korrekt darzustellen
// (ohne das erscheint Schwarz als Weiss und Gruen als Rosa/Magenta) -
// hier fest setzen, damit Schwarz/Gruen GARANTIERT korrekt aussieht.
tft.invertDisplay(true);
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);
int16_t cx = Config::SCREEN_WIDTH / 2; int16_t cx = Config::SCREEN_WIDTH / 2;
int16_t cy = Config::SCREEN_HEIGHT / 2; int16_t cy = Config::SCREEN_HEIGHT / 2;
tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(2); tft.setTextSize(2);
tft.drawString("For this app a", cx, cy - 40); tft.drawString("For this app a", cx, cy - 40);
tft.drawString("SD card is", cx, cy - 10); tft.drawString("SD card is", cx, cy - 10);
tft.drawString("required", cx, cy + 20); tft.drawString("required", cx, cy + 20);
tft.setTextSize(1); tft.setTextSize(1);
tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString("Insert a card and restart the device", cx, cy + 60); tft.drawString("Insert a card and restart the device", cx, cy + 60);
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
while (true) { while (true) {
delay(1000); // haengen bleiben - absichtlich kein weiterer Screen delay(1000);
} }
} }
// Zeigt/versteckt ein blinkendes Notfall-Banner im Header-Bereich, wenn ein
// Flugzeug einen Notfall-Squawk (7500/7600/7700) sendet. Ersetzt kurzzeitig
// den normalen Titel; sobald der Notfall vorbei ist, wird drawHeader() genau
// einmal wieder aufgerufen, um den Titel sauber wiederherzustellen.
void updateEmergencyBanner(uint32_t nowMs) { void updateEmergencyBanner(uint32_t nowMs) {
RadarScreen::EmergencyInfo emergency = RadarScreen::checkEmergency(); RadarScreen::EmergencyInfo emergency = RadarScreen::checkEmergency();
@@ -110,6 +169,7 @@ void updateEmergencyBanner(uint32_t nowMs) {
} else if (wasEmergency) { } else if (wasEmergency) {
wasEmergency = false; wasEmergency = false;
drawHeader(); drawHeader();
updateStatusLine();
} }
} }
@@ -119,42 +179,30 @@ void setup() {
tft.init(); tft.init();
tft.setRotation(0); tft.setRotation(0);
// Sofort loeschen, bevor irgendetwas anderes passiert (SD-Init etc.
// braucht einen Moment) - sonst zeigt das Display kurz zufaelligen
// Bildspeicher-Muell an, bevor der erste echte Screen gezeichnet wird.
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
TouchInput::begin(); TouchInput::begin();
LedAlert::begin(); LedAlert::begin();
// --- SD-Karte: PFLICHT. Ohne Karte kein weiterer Screen. ---
bool sdOk = SdStorage::init(); bool sdOk = SdStorage::init();
if (!sdOk) { if (!sdOk) {
haltWithSdRequiredScreen(); haltWithSdRequiredScreen();
return; // unerreichbar (haltWithSdRequiredScreen() haengt fuer immer), nur zur Klarheit return;
} }
SdStorage::seedDefaultDataFiles(); SdStorage::seedDefaultDataFiles();
// Einstellungen (u.a. Display-Invertierung) VOR dem Splash laden und
// anwenden, damit der Splash selbst schon in der richtigen Ausrichtung
// gezeichnet wird (kein Farbwechsel mitten in der Anzeige).
SettingsStore::load(); SettingsStore::load();
tft.invertDisplay(SettingsStore::displayInverted()); tft.invertDisplay(SettingsStore::displayInverted());
WifiMgr::init(); // laedt die gespeicherten Netzwerke (bis zu 3) von der SD-Karte WifiMgr::init();
// --- Splash-Screen: schwarzer Hintergrund, gruenes Flugzeug, mind. 5s ---
SplashScreen::begin(tft); SplashScreen::begin(tft);
SplashScreen::setStatusLine(tft, 0, "SD Card: OK", TFT_WHITE); SplashScreen::setStatusLine(tft, 0, "SD Card: OK", TFT_WHITE);
// --- Touch-Kalibrierung (nur beim allerersten Start, oder wenn Datei fehlt) ---
if (!TouchInput::loadCalibration()) { if (!TouchInput::loadCalibration()) {
CalibrationScreen::run(tft); CalibrationScreen::run(tft);
} }
// --- WLAN: Ersteinrichtung (falls noch kein Netzwerk gespeichert ist)
// oder automatisches Verbinden mit dem ersten gerade sichtbaren
// gespeicherten Netzwerk (z.B. Zuhause ODER Auto-Hotspot). ---
if (WifiMgr::networkCount() == 0) { if (WifiMgr::networkCount() == 0) {
WifiSetupScreen::run(tft); WifiSetupScreen::run(tft);
} else { } else {
@@ -176,7 +224,6 @@ void setup() {
AdsbClient::primeTime(); AdsbClient::primeTime();
// --- Standort per IP-Geolocation (einmalig blockierend beim Start) ---
SplashScreen::setStatusLine(tft, 2, "Getting location..."); SplashScreen::setStatusLine(tft, 2, "Getting location...");
LocationManager::init(); LocationManager::init();
uint32_t locStart = millis(); uint32_t locStart = millis();
@@ -187,25 +234,20 @@ void setup() {
} }
SplashScreen::setStatusLine(tft, 2, "Ready!"); SplashScreen::setStatusLine(tft, 2, "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()) { if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov"); configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
} }
AircraftTable::init(); AircraftTable::init();
AirlineLookup::init(); AirlineLookup::init();
FlightLogbook::init(); // rekonstruiert die "heute schon geloggt"-Liste aus der SD-Karte FlightLogbook::init();
// --- Ab hier uebernimmt der Netzwerk-Task (Core 0) laufend WLAN-Status,
// Standort-Updates und ADS-B-Abfragen im Hintergrund. ---
NetTask::begin(); NetTask::begin();
// Splash bleibt mindestens 5 Sekunden sichtbar, egal wie schnell der Rest war.
SplashScreen::waitRemaining(); SplashScreen::waitRemaining();
drawHeader(); drawHeader();
updateStatusLine();
RadarScreen::render(tft, CONTENT_TOP); RadarScreen::render(tft, CONTENT_TOP);
} }
@@ -215,19 +257,17 @@ void loop() {
if (menuBtn.contains(tap.x, tap.y)) { if (menuBtn.contains(tap.x, tap.y)) {
MenuScreen::run(tft); MenuScreen::run(tft);
drawHeader(); drawHeader();
forceRedraw = true; // sofort neu zeichnen updateStatusLine();
forceRedraw = true;
} else if (camBtn.contains(tap.x, tap.y)) {
takeScreenshotWithFeedback();
} else if (tap.y >= CONTENT_TOP) { } else if (tap.y >= CONTENT_TOP) {
if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) { if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) {
forceRedraw = true; // sofort neu zeichnen nach Interaktion forceRedraw = true;
} }
} }
} }
// Nur alle POLL_INTERVAL_MS kurz nachschauen (billige Abfrage eines
// Zaehlers), statt staendig teuer neu zu zeichnen. Ein echtes Neuzeichnen
// (render()) passiert nur, wenn sich die Flugzeugdaten TATSAECHLICH
// geaendert haben (neue Abfrage im Hintergrund fertig) oder der Nutzer
// etwas angetippt hat - das vermeidet unnoetiges Flackern.
if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) { if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) {
lastPollMs = millis(); lastPollMs = millis();
uint32_t currentVersion = AircraftTable::version(); uint32_t currentVersion = AircraftTable::version();
@@ -235,22 +275,23 @@ void loop() {
lastRenderedVersion = currentVersion; lastRenderedVersion = currentVersion;
forceRedraw = false; forceRedraw = false;
RadarScreen::render(tft, CONTENT_TOP); RadarScreen::render(tft, CONTENT_TOP);
lastSweepMs = millis(); // Sweep-Delta nicht ueber den Vollbild-Redraw hinweg aufaddieren lastSweepMs = millis();
} }
} }
// Sweep-Strahl dreht sich unabhaengig von den Flugzeugdaten weiter -
// billige Linien-Zeichnung ohne Vollbild-Clear, daher kein Flackern.
uint32_t nowMs = millis(); uint32_t nowMs = millis();
// Naeherungs-/Notfall-Alarm (LED) laeuft IMMER, auch wenn gerade das
// Detail-Fenster offen ist - unabhaengig vom Sweep/Radar-Redraw.
RadarScreen::updateProximityAlert(nowMs); RadarScreen::updateProximityAlert(nowMs);
if (nowMs - lastSweepMs >= SWEEP_TICK_MS) { if (nowMs - lastSweepMs >= SWEEP_TICK_MS) {
uint32_t deltaMs = nowMs - lastSweepMs; uint32_t deltaMs = nowMs - lastSweepMs;
lastSweepMs = nowMs; lastSweepMs = nowMs;
RadarScreen::tick(tft, CONTENT_TOP, deltaMs); RadarScreen::tick(tft, CONTENT_TOP, deltaMs);
updateEmergencyBanner(nowMs); // gleiche Taktung wie der Sweep-Tick updateEmergencyBanner(nowMs);
}
if (nowMs - lastStatusLineMs >= STATUS_LINE_UPDATE_MS) {
lastStatusLineMs = nowMs;
updateStatusLine();
} }
} }

View File

@@ -3,6 +3,7 @@
#include "calibration_screen.h" #include "calibration_screen.h"
#include "wifi_manage_screen.h" #include "wifi_manage_screen.h"
#include "stats_screen.h" #include "stats_screen.h"
#include "logbook_files_screen.h"
#include "settings_store.h" #include "settings_store.h"
#include "config.h" #include "config.h"
@@ -16,9 +17,9 @@ namespace {
} }
}; };
constexpr int16_t ROW_H = 30; constexpr int16_t ROW_H = 26;
constexpr int16_t ROW_GAP = 4; constexpr int16_t ROW_GAP = 3;
constexpr int16_t ROW_START_Y = 26; constexpr int16_t ROW_START_Y = 24;
Rect rowRect(uint8_t index) { Rect rowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (ROW_H + ROW_GAP)), return {10, (int16_t)(ROW_START_Y + index * (ROW_H + ROW_GAP)),
@@ -45,13 +46,14 @@ void run(TFT_eSPI& tft) {
Rect proximityBtn = rowRect(4); Rect proximityBtn = rowRect(4);
Rect logbookBtn = rowRect(5); Rect logbookBtn = rowRect(5);
Rect statsBtn = rowRect(6); Rect statsBtn = rowRect(6);
Rect backBtn = rowRect(7); Rect logFilesBtn = rowRect(7);
Rect backBtn = rowRect(8);
bool done = false; bool done = false;
while (!done) { while (!done) {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 8); tft.setCursor(10, 6);
tft.println("Settings"); tft.println("Settings");
drawButton(tft, calibBtn, "Calibrate touch"); drawButton(tft, calibBtn, "Calibrate touch");
@@ -67,6 +69,7 @@ void run(TFT_eSPI& tft) {
drawButton(tft, proximityBtn, "Proximity LED: " + onOff(SettingsStore::proximityAlertEnabled())); drawButton(tft, proximityBtn, "Proximity LED: " + onOff(SettingsStore::proximityAlertEnabled()));
drawButton(tft, logbookBtn, "Flight logbook: " + onOff(SettingsStore::flightLogbookEnabled())); drawButton(tft, logbookBtn, "Flight logbook: " + onOff(SettingsStore::flightLogbookEnabled()));
drawButton(tft, statsBtn, "Statistics"); drawButton(tft, statsBtn, "Statistics");
drawButton(tft, logFilesBtn, "Logbook files");
drawButton(tft, backBtn, "Back"); drawButton(tft, backBtn, "Back");
@@ -92,6 +95,8 @@ void run(TFT_eSPI& tft) {
SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled()); SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled());
} else if (statsBtn.contains(tap.x, tap.y)) { } else if (statsBtn.contains(tap.x, tap.y)) {
StatsScreen::run(tft); StatsScreen::run(tft);
} else if (logFilesBtn.contains(tap.x, tap.y)) {
LogbookFilesScreen::run(tft);
} else if (backBtn.contains(tap.x, tap.y)) { } else if (backBtn.contains(tap.x, tap.y)) {
done = true; done = true;
} }

View File

@@ -84,22 +84,13 @@ namespace {
return TFT_RED; return TFT_RED;
} }
// Zeichnet ein kleines Dreieck, das in Flugrichtung (headingDeg) zeigt -
// wie das klassische "Ziel"-Symbol auf echten ATC-Radarschirmen. Ersetzt
// den vorherigen Kreis + separaten Kursstrich in einem Symbol.
void drawAircraftMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) { void drawAircraftMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 5, color);
double rad = headingDeg * PI / 180.0; double rad = headingDeg * PI / 180.0;
double backRad1 = (headingDeg + 150.0) * PI / 180.0; int16_t dx = (int16_t)(sin(rad) * 10);
double backRad2 = (headingDeg - 150.0) * PI / 180.0; int16_t dy = (int16_t)(-cos(rad) * 10);
gfx.drawLine(x, y, x + dx, y + dy, color);
int16_t tipX = x + (int16_t)(7 * sin(rad));
int16_t tipY = y - (int16_t)(7 * cos(rad));
int16_t backLX = x + (int16_t)(5 * sin(backRad1));
int16_t backLY = y - (int16_t)(5 * cos(backRad1));
int16_t backRX = x + (int16_t)(5 * sin(backRad2));
int16_t backRY = y - (int16_t)(5 * cos(backRad2));
gfx.fillTriangle(tipX, tipY, backLX, backLY, backRX, backRY, color);
} }
uint16_t dimColorForAltitude(int32_t altFt) { uint16_t dimColorForAltitude(int32_t altFt) {
@@ -237,6 +228,9 @@ namespace {
gfx.setTextColor(TFT_DARKGREY, TFT_BLACK); gfx.setTextColor(TFT_DARKGREY, TFT_BLACK);
gfx.setTextDatum(MC_DATUM); gfx.setTextDatum(MC_DATUM);
gfx.drawString("N", L.cx, L.cy - L.radius - 8); gfx.drawString("N", L.cx, L.cy - L.radius - 8);
gfx.drawString("S", L.cx, L.cy + L.radius - 10);
gfx.drawString("E", L.cx + L.radius - 10, L.cy);
gfx.drawString("W", L.cx - L.radius + 10, L.cy);
char ringLabel[8]; char ringLabel[8];
snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm / 3); snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm / 3);
gfx.drawString(ringLabel, L.cx, L.cy - L.radius / 3); gfx.drawString(ringLabel, L.cx, L.cy - L.radius / 3);

View File

@@ -25,6 +25,14 @@ namespace {
uint32_t lastReconnectAttemptMs = 0; uint32_t lastReconnectAttemptMs = 0;
constexpr uint32_t RECONNECT_RETRY_MS = 10000; constexpr uint32_t RECONNECT_RETRY_MS = 10000;
// Der Reconnect-Scan MUSS asynchron laufen: update() wird dauerhaft in
// der NetTask-Schleife (Core 0) aufgerufen, und ein blockierender Scan
// (WiFi.scanNetworks(false)) haelt dort mehrere Sekunden am Stueck fest -
// das hat frueher den Watchdog ausgeloest (Bootloop). Stattdessen wird
// der Scan hier gestartet und ueber mehrere update()-Aufrufe hinweg
// nicht-blockierend abgefragt.
bool reconnectScanPending = false;
SemaphoreHandle_t mutex = nullptr; SemaphoreHandle_t mutex = nullptr;
void setState(State s) { void setState(State s) {
@@ -66,6 +74,44 @@ namespace {
} }
f.close(); f.close();
} }
// Nicht-blockierender Ersatz fuer beginConnect() zur Verwendung INNERHALB
// von update() (NetTask). Startet beim ersten Aufruf einen asynchronen
// Scan; bei weiteren Aufrufen wird nur kurz nachgeschaut, ob er fertig
// ist (WiFi.scanComplete() ist eine billige, sofort zurueckkehrende
// Abfrage) - blockiert also nie.
void tryReconnectAsync() {
if (!reconnectScanPending) {
WiFi.scanNetworks(/*async=*/true);
reconnectScanPending = true;
return;
}
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) return;
reconnectScanPending = false;
if (n <= 0) {
WiFi.scanDelete();
return;
}
int8_t chosen = -1;
for (uint8_t i = 0; i < networkCountVal && chosen < 0; i++) {
for (int j = 0; j < n; j++) {
if (WiFi.SSID(j) == networks[i].ssid) {
chosen = (int8_t)i;
break;
}
}
}
WiFi.scanDelete();
if (chosen < 0) return;
connectTo(networks[chosen].ssid, networks[chosen].pass);
}
} }
void init() { void init() {
@@ -114,6 +160,12 @@ void beginConnect() {
return; return;
} }
// Blockierender Scan (ca. 2-3s) - passiert nur einmal beim Booten, VOR
// dem Start von NetTask, daher unkritisch (kein Watchdog-Risiko). Wir
// verbinden uns mit dem ERSTEN gespeicherten Netzwerk, das gerade
// sichtbar ist (Prioritaet = Speicherreihenfolge), statt blind das erste
// gespeicherte zu versuchen - so klappt es automatisch mit Zuhause-WLAN
// ODER dem Auto-Hotspot, je nachdem was gerade in Reichweite ist.
int visibleCount = WiFi.scanNetworks(/*async=*/false); int visibleCount = WiFi.scanNetworks(/*async=*/false);
int8_t chosen = -1; int8_t chosen = -1;
@@ -157,10 +209,16 @@ void update() {
return; return;
} }
// In Idle/Failed (auch nach einem fehlgeschlagenen Erstverbindungsversuch
// beim Booten) in regelmaessigen Abstaenden automatisch erneut versuchen,
// alle gespeicherten Netzwerke durchzuscannen - NICHT-blockierend (siehe
// tryReconnectAsync()), damit NetTask den Watchdog nicht verpasst.
if ((s == State::Idle || s == State::Failed) && networkCountVal > 0) { if ((s == State::Idle || s == State::Failed) && networkCountVal > 0) {
if (millis() - lastReconnectAttemptMs >= RECONNECT_RETRY_MS) { if (reconnectScanPending) {
tryReconnectAsync();
} else if (millis() - lastReconnectAttemptMs >= RECONNECT_RETRY_MS) {
lastReconnectAttemptMs = millis(); lastReconnectAttemptMs = millis();
beginConnect(); tryReconnectAsync();
} }
} }
} }