diff --git a/src/config.h b/src/config.h index 5e060a9..6defb01 100644 --- a/src/config.h +++ b/src/config.h @@ -32,6 +32,7 @@ namespace Config { constexpr const char* SD_AIRLINES_CSV = "/Flightradar_cyd/airlines.csv"; constexpr const char* SD_AIRCRAFT_TYPES_CSV = "/Flightradar_cyd/aircraft_types.csv"; constexpr const char* SD_LOG_DIR = "/Flightradar_cyd/logs"; + constexpr const char* SD_SCREENSHOT_DIR = "/Flightradar_cyd/screenshots"; constexpr const char* SD_SETTINGS_FILE = "/Flightradar_cyd/config.txt"; constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt"; constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt"; @@ -58,10 +59,8 @@ namespace Config { constexpr uint16_t COLOR_LOW_ALT_THRESHOLD_FT = 10000; constexpr uint16_t COLOR_MID_ALT_THRESHOLD_FT = 30000; - // Notfall-Squawk-Codes: 7500 (Entfuehrung), 7600 (Funkausfall), 7700 (allg. Notfall) constexpr const char* EMERGENCY_SQUAWKS[] = {"7500", "7600", "7700"}; constexpr uint8_t EMERGENCY_SQUAWK_COUNT = 3; - // Maximal gespeicherte WLAN-Netzwerke (z.B. Zuhause, Auto-Hotspot, Arbeit) constexpr uint8_t MAX_WIFI_NETWORKS = 3; } \ No newline at end of file diff --git a/src/flight_logbook.cpp b/src/flight_logbook.cpp index f56e6c3..61e025a 100644 --- a/src/flight_logbook.cpp +++ b/src/flight_logbook.cpp @@ -13,7 +13,7 @@ namespace { constexpr uint16_t MAX_SEEN = 400; char seenHex[MAX_SEEN][7]; uint16_t seenCount = 0; - char currentDateStr[11] = {0}; // "YYYY-MM-DD" + char currentDateStr[11] = {0}; bool computeDateStr(char* out, size_t outSize) { time_t now = time(nullptr); @@ -140,4 +140,33 @@ void update() { } } +uint16_t todayCount() { return seenCount; } + +void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) { + totalAircraft = 0; + totalDays = 0; + + File dir = SD.open(Config::SD_LOG_DIR); + if (!dir || !dir.isDirectory()) return; + + File entry = dir.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + String name = String(entry.name()); + if (name.endsWith(".csv")) { + totalDays++; + uint32_t lines = 0; + while (entry.available()) { + entry.readStringUntil('\n'); + lines++; + } + if (lines > 0) totalAircraft += (lines - 1); + } + } + entry.close(); + entry = dir.openNextFile(); + } + dir.close(); +} + } \ No newline at end of file diff --git a/src/flight_logbook.h b/src/flight_logbook.h index 439c321..c734346 100644 --- a/src/flight_logbook.h +++ b/src/flight_logbook.h @@ -19,4 +19,12 @@ namespace FlightLogbook { // heutige Logbuch. Kuemmert sich auch um den Tageswechsel (neue Datei, // neue "gesehen"-Liste). void update(); + + // Anzahl der HEUTE bereits geloggten (unterschiedlichen) Flugzeuge. + 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); } \ No newline at end of file diff --git a/src/menu_screen.cpp b/src/menu_screen.cpp index dc1fef6..a3739c7 100644 --- a/src/menu_screen.cpp +++ b/src/menu_screen.cpp @@ -2,6 +2,7 @@ #include "touch_input.h" #include "calibration_screen.h" #include "wifi_manage_screen.h" +#include "stats_screen.h" #include "settings_store.h" #include "config.h" @@ -43,7 +44,8 @@ void run(TFT_eSPI& tft) { Rect emergencyBtn = rowRect(3); Rect proximityBtn = rowRect(4); Rect logbookBtn = rowRect(5); - Rect backBtn = rowRect(6); + Rect statsBtn = rowRect(6); + Rect backBtn = rowRect(7); bool done = false; while (!done) { @@ -64,6 +66,7 @@ void run(TFT_eSPI& tft) { drawButton(tft, emergencyBtn, "Emergency alert: " + onOff(SettingsStore::emergencyAlertEnabled())); drawButton(tft, proximityBtn, "Proximity LED: " + onOff(SettingsStore::proximityAlertEnabled())); drawButton(tft, logbookBtn, "Flight logbook: " + onOff(SettingsStore::flightLogbookEnabled())); + drawButton(tft, statsBtn, "Statistics"); drawButton(tft, backBtn, "Back"); @@ -87,6 +90,8 @@ void run(TFT_eSPI& tft) { SettingsStore::setProximityAlertEnabled(!SettingsStore::proximityAlertEnabled()); } else if (logbookBtn.contains(tap.x, tap.y)) { SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled()); + } else if (statsBtn.contains(tap.x, tap.y)) { + StatsScreen::run(tft); } else if (backBtn.contains(tap.x, tap.y)) { done = true; } diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 8646c31..94ebf67 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -58,6 +58,12 @@ namespace { char selectedHex[7] = {0}; + uint32_t lastEmptyTapMs = 0; + int16_t lastEmptyTapX = -1000; + int16_t lastEmptyTapY = -1000; + constexpr uint32_t DOUBLE_TAP_MS = 400; + constexpr int16_t DOUBLE_TAP_RADIUS = 25; + bool ledBlinkOn = true; bool isEmergencySquawk(const char* squawk) { @@ -78,6 +84,79 @@ namespace { return TFT_RED; } + // Gedaempfte Version der Hoehenfarbe fuer die Flugbahn-Linie (Trail) - + // deutlich dunkler als der Punkt selbst, damit der Trail nicht mit dem + // aktuellen Punkt/Label verwechselt wird. + uint16_t dimColorForAltitude(int32_t altFt) { + if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT) return TFT_DARKGREEN; + if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT) return TFT_OLIVE; + return TFT_MAROON; + } + + // --- Flugbahn-Trail: merkt sich die letzten paar Bildschirm-Positionen + // pro Flugzeug (per Hex-Code), damit man die grobe Flugrichtung ueber die + // letzten Datenupdates hinweg sieht. Wird nur bei render() (Vollbild- + // Redraw) aktualisiert/gezeichnet, nicht bei jedem Sweep-Tick. + constexpr uint8_t TRAIL_LEN = 4; + constexpr uint32_t TRAIL_STALE_MS = Config::FETCH_INTERVAL_MS * 3; + + struct TrailEntry { + char hex[7] = {0}; + bool active = false; + int16_t xs[TRAIL_LEN] = {0}; + int16_t ys[TRAIL_LEN] = {0}; + uint8_t count = 0; + uint32_t lastUpdateMs = 0; + }; + constexpr uint8_t MAX_TRAILS = Config::MAX_TRACKED_AIRCRAFT; + TrailEntry trails[MAX_TRAILS]; + + void pruneStaleTrails() { + uint32_t now = millis(); + for (uint8_t i = 0; i < MAX_TRAILS; i++) { + if (trails[i].active && now - trails[i].lastUpdateMs > TRAIL_STALE_MS) { + trails[i] = TrailEntry{}; + } + } + } + + TrailEntry* findOrCreateTrail(const char* hex) { + int16_t freeIdx = -1; + for (uint8_t i = 0; i < MAX_TRAILS; i++) { + if (trails[i].active && strcmp(trails[i].hex, hex) == 0) return &trails[i]; + if (!trails[i].active && freeIdx < 0) freeIdx = (int16_t)i; + } + if (freeIdx < 0) return nullptr; + trails[freeIdx] = TrailEntry{}; + strncpy(trails[freeIdx].hex, hex, sizeof(trails[freeIdx].hex) - 1); + trails[freeIdx].active = true; + return &trails[freeIdx]; + } + + void pushTrailPoint(TrailEntry* t, int16_t x, int16_t y) { + if (!t) return; + if (t->count < TRAIL_LEN) { + t->xs[t->count] = x; + t->ys[t->count] = y; + t->count++; + } else { + for (uint8_t i = 1; i < TRAIL_LEN; i++) { + t->xs[i - 1] = t->xs[i]; + t->ys[i - 1] = t->ys[i]; + } + t->xs[TRAIL_LEN - 1] = x; + t->ys[TRAIL_LEN - 1] = y; + } + t->lastUpdateMs = millis(); + } + + void drawTrail(TFT_eSPI& gfx, const TrailEntry* t, uint16_t color) { + if (!t || t->count < 2) return; + for (uint8_t i = 1; i < t->count; i++) { + gfx.drawLine(t->xs[i - 1], t->ys[i - 1], t->xs[i], t->ys[i], color); + } + } + void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) { String s = text; if (gfx.textWidth(s) > maxWidth) { @@ -99,9 +178,6 @@ namespace { gfx.setTextDatum(TL_DATUM); } - // Kleine Farb-Legende (Hoehenband -> Farbe), damit auf den ersten Blick - // klar ist, was Gruen/Gelb/Rot bei den Flugzeug-Punkten bedeuten. Zeigt - // Meter statt Fuss, wenn die Region (per Geolocation) metrisch ist. void drawLegend(TFT_eSPI& gfx, int16_t y) { bool metric = LocationManager::useMetricUnits(); @@ -309,6 +385,8 @@ void render(TFT_eSPI& tft, int16_t top) { for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false; + pruneStaleTrails(); + for (uint8_t i = 0; i < count && i < MAX_HIT_POINTS; i++) { Aircraft& a = snapshot[i]; if (a.distanceKm > rangeKm * 1.05f) continue; @@ -320,6 +398,12 @@ void render(TFT_eSPI& tft, int16_t top) { bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0; bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk); + // Flugbahn-Trail: erst die bisherige Spur zeichnen, DANN den neuen + // Punkt anhaengen und den Punkt/Ring/Label darueber legen. + TrailEntry* trail = findOrCreateTrail(a.hex); + drawTrail(tft, trail, dimColorForAltitude(a.altBaroFt)); + pushTrailPoint(trail, pt.x, pt.y); + if (isSelected) { tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); selectionStillPresent = true; @@ -462,6 +546,21 @@ bool handleTap(int16_t x, int16_t y, int16_t top) { } } + uint32_t nowMs = millis(); + bool isDoubleTap = (nowMs - lastEmptyTapMs <= DOUBLE_TAP_MS) && + (abs((int)x - (int)lastEmptyTapX) <= DOUBLE_TAP_RADIUS) && + (abs((int)y - (int)lastEmptyTapY) <= DOUBLE_TAP_RADIUS); + lastEmptyTapMs = nowMs; + lastEmptyTapX = x; + lastEmptyTapY = y; + + if (isDoubleTap) { + uint8_t idx = SettingsStore::rangeIndex(); + idx = (idx == 0) ? (Config::RANGE_STEP_COUNT - 1) : (idx - 1); + SettingsStore::setRangeIndex(idx); + lastEmptyTapMs = 0; + } + return true; } diff --git a/src/radar_screen.h b/src/radar_screen.h index 771595b..3654b84 100644 --- a/src/radar_screen.h +++ b/src/radar_screen.h @@ -3,30 +3,9 @@ #include namespace RadarScreen { - // Zeichnet den kompletten Radar-Frame neu (Ringe, Flugzeuge als Punkte, - // Rufzeichen-Label, Sweep-Strahl, Auswahl-Infoleiste, Reichweiten-Button) - // im Bereich von y=top bis zum unteren Bildschirmrand. Wird nur bei - // tatsaechlichen Datenaenderungen oder nach Touch-Interaktion aufgerufen - // (siehe main.cpp), NICHT bei jedem Sweep-Tick - sonst wuerde es flackern. void render(TFT_eSPI& tft, int16_t top); - - // Guenstiger, haeufiger Aufruf (z.B. alle 100ms): dreht NUR den - // Sweep-Strahl ein Stueck weiter und zeichnet ihn (plus dezente - // Nachzieh-Linien), OHNE den Bildschirm vorher zu loeschen. So entsteht - // eine fluessige Drehbewegung ohne Vollbild-Neuzeichnen (= kein Flackern). - // deltaMs = vergangene Zeit seit dem letzten tick()-Aufruf. void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs); - - // Verarbeitet einen Touch-Tap (Reichweite umschalten, Flugzeug auswaehlen, - // leere Flaeche antippen = Auswahl aufheben). Gibt true zurueck, wenn der - // Tap im Radar-Bereich (y >= top) verarbeitet wurde. bool handleTap(int16_t x, int16_t y, int16_t top); - - // Haeufig aufrufen (unabhaengig davon, ob ein Detail-Fenster offen ist): - // prueft, ob ein Flugzeug innerhalb des Alarmradius ist ODER ein - // Notfall-Squawk sendet, steuert die LED entsprechend (Notfall hat - // Vorrang, blinkt rot statt gruen) und merkt sich den Blink-Zustand, - // damit tick() den betroffenen Punkt synchron mitblinken lassen kann. void updateProximityAlert(uint32_t nowMs); struct EmergencyInfo { @@ -35,7 +14,5 @@ namespace RadarScreen { char squawk[5] = {0}; }; - // Reine Abfrage (kein Seiteneffekt): gibt das erste Flugzeug mit einem - // Notfall-Squawk zurueck, falls vorhanden. Fuer das Banner im Header. EmergencyInfo checkEmergency(); } \ No newline at end of file diff --git a/src/screenshot.cpp b/src/screenshot.cpp new file mode 100644 index 0000000..8086130 --- /dev/null +++ b/src/screenshot.cpp @@ -0,0 +1,78 @@ +#include "screenshot.h" +#include "config.h" +#include +#include + +namespace Screenshot { + +namespace { + void putU32(uint8_t* buf, uint32_t v) { + buf[0] = v & 0xFF; + buf[1] = (v >> 8) & 0xFF; + buf[2] = (v >> 16) & 0xFF; + buf[3] = (v >> 24) & 0xFF; + } +} + +String save(TFT_eSPI& tft) { + if (!SD.exists(Config::SD_SCREENSHOT_DIR)) { + SD.mkdir(Config::SD_SCREENSHOT_DIR); + } + + time_t now = time(nullptr); + struct tm tmNow; + localtime_r(&now, &tmNow); + char filename[32]; + snprintf(filename, sizeof(filename), "%04d%02d%02d_%02d%02d%02d.bmp", + tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday, + tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec); + + char fullPath[64]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", Config::SD_SCREENSHOT_DIR, filename); + + File f = SD.open(fullPath, FILE_WRITE); + if (!f) return String(); + + int32_t w = tft.width(); + int32_t h = tft.height(); + + uint32_t rowSize = ((uint32_t)w * 3 + 3) & ~3u; + uint32_t dataSize = rowSize * (uint32_t)h; + uint32_t fileSize = 54 + dataSize; + + uint8_t header[54] = {0}; + header[0] = 'B'; header[1] = 'M'; + putU32(&header[2], fileSize); + putU32(&header[10], 54); + putU32(&header[14], 40); + putU32(&header[18], (uint32_t)w); + putU32(&header[22], (uint32_t)h); + header[26] = 1; + header[28] = 24; + putU32(&header[34], dataSize); + + f.write(header, 54); + + uint16_t rowPixels[Config::SCREEN_WIDTH]; + uint8_t rowBuf[Config::SCREEN_WIDTH * 3 + 3]; + + for (int32_t y = h - 1; y >= 0; y--) { + tft.readRect(0, y, w, 1, rowPixels); + for (int32_t x = 0; x < w; x++) { + uint16_t px = rowPixels[x]; + uint8_t r5 = (px >> 11) & 0x1F; + uint8_t g6 = (px >> 5) & 0x3F; + uint8_t b5 = px & 0x1F; + rowBuf[x * 3 + 0] = (b5 * 255) / 31; + rowBuf[x * 3 + 1] = (g6 * 255) / 63; + rowBuf[x * 3 + 2] = (r5 * 255) / 31; + } + for (uint32_t p = (uint32_t)w * 3; p < rowSize; p++) rowBuf[p] = 0; + f.write(rowBuf, rowSize); + } + + f.close(); + return String(filename); +} + +} \ No newline at end of file diff --git a/src/screenshot.h b/src/screenshot.h new file mode 100644 index 0000000..66c6295 --- /dev/null +++ b/src/screenshot.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include + +// Liest den aktuell angezeigten Bildschirminhalt per SPI-Readback zurueck +// (TFT_MISO ist am CYD verkabelt) und speichert ihn als 24-Bit-BMP-Datei auf +// der SD-Karte. Funktioniert mit allem, was gerade angezeigt wird (Radar, +// Detail-Fenster, Menue, ...). +namespace Screenshot { + // Blockierend (dauert ca. 1-2 Sekunden). Gibt bei Erfolg den Dateinamen + // (ohne Pfad) zurueck, sonst einen leeren String. + String save(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/sd_storage.cpp b/src/sd_storage.cpp index a27c39d..9294072 100644 --- a/src/sd_storage.cpp +++ b/src/sd_storage.cpp @@ -8,7 +8,7 @@ namespace SdStorage { namespace { bool mounted = false; - SPIClass sdSpi(HSPI); + SPIClass sdSpi(HSPI); const char* kDefaultAirlinesCsv = "icao,name\n" "BAW,British Airways\n" @@ -64,26 +64,14 @@ namespace { } bool init() { - Serial.printf("[SD] Pins: CLK=%d MISO=%d MOSI=%d CS=%d\n", - Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN, - Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN); - - Serial.println("[SD] vor sdSpi.begin()"); sdSpi.begin(Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN, Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN); - Serial.println("[SD] sdSpi.begin() fertig"); - - Serial.println("[SD] vor SD.begin()"); mounted = SD.begin(Config::SD_SPI_CS_PIN, sdSpi, 4000000); - Serial.printf("[SD] SD.begin() fertig, mounted=%d\n", mounted); - if (!mounted) return false; - Serial.println("[SD] vor ensureDir(ROOT)"); ensureDir(Config::SD_ROOT_DIR); - Serial.println("[SD] vor ensureDir(LOG)"); ensureDir(Config::SD_LOG_DIR); - Serial.println("[SD] init() komplett fertig"); + ensureDir(Config::SD_SCREENSHOT_DIR); return true; } diff --git a/src/stats_screen.cpp b/src/stats_screen.cpp new file mode 100644 index 0000000..3a8aedb --- /dev/null +++ b/src/stats_screen.cpp @@ -0,0 +1,73 @@ +#include "stats_screen.h" +#include "flight_logbook.h" +#include "touch_input.h" +#include "config.h" + +namespace StatsScreen { + +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); + } + + void drawStatRow(TFT_eSPI& tft, int16_t y, const String& label, const String& value) { + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.setCursor(10, y); + tft.print(label); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setTextSize(2); + tft.setCursor(10, y + 14); + tft.print(value); + tft.setTextSize(1); + } +} + +void run(TFT_eSPI& tft) { + uint16_t today = FlightLogbook::todayCount(); + uint32_t allTimeAircraft = 0; + uint16_t allTimeDays = 0; + FlightLogbook::computeAllTimeStats(allTimeAircraft, allTimeDays); + + 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, 10); + tft.println("Statistics"); + + drawStatRow(tft, 44, "Aircraft logged today", String(today)); + drawStatRow(tft, 90, "Aircraft logged all-time", String(allTimeAircraft)); + drawStatRow(tft, 136, "Days with sightings", String(allTimeDays)); + + if (allTimeDays > 0) { + float avgPerDay = (float)allTimeAircraft / (float)allTimeDays; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", avgPerDay); + drawStatRow(tft, 182, "Average per day", String(buf)); + } + + 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); + } +} + +} \ No newline at end of file diff --git a/src/stats_screen.h b/src/stats_screen.h new file mode 100644 index 0000000..57861d6 --- /dev/null +++ b/src/stats_screen.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include + +namespace StatsScreen { + // Blockierend: zeigt einfache Statistiken aus dem Flight Logbook + // (heute gesehen, insgesamt gesehen, Anzahl Tage geloggt). Kehrt zurueck, + // sobald "Back" angetippt wird. + void run(TFT_eSPI& tft); +} \ No newline at end of file