diff --git a/src/adsb_client.cpp b/src/adsb_client.cpp index 6b1d25a..f189741 100644 --- a/src/adsb_client.cpp +++ b/src/adsb_client.cpp @@ -1,11 +1,10 @@ -#include #include "adsb_client.h" +#include #include #include #include #include - namespace AdsbClient { namespace { @@ -71,6 +70,7 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm, filterAc["gs"] = true; filterAc["track"] = true; filterAc["squawk"] = true; + filterAc["category"] = true; JsonDocument doc; DeserializationError err = deserializeJson( @@ -119,6 +119,9 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm, const char* squawk = ac["squawk"] | ""; strncpy(a.squawk, squawk, sizeof(a.squawk) - 1); + const char* category = ac["category"] | ""; + strncpy(a.category, category, sizeof(a.category) - 1); + a.lastSeenMs = millis(); a.valid = (a.lat != 0.0f || a.lon != 0.0f); diff --git a/src/aircraft.h b/src/aircraft.h index 62fee3a..45241d2 100644 --- a/src/aircraft.h +++ b/src/aircraft.h @@ -5,24 +5,25 @@ struct Aircraft { char callsign[9] = {0}; char reg[9] = {0}; char typeCode[5] = {0}; - char squawk[5] = {0}; // 4-stelliger Transponder-Code, z.B. "7700" (Notfall) + char squawk[5] = {0}; + char category[3] = {0}; float lat = 0; float lon = 0; - int32_t altBaroFt = 0; // barometric altitude, ft - int16_t vertRateFtMin = 0; // vertical speed, ft/min (+climb / -descend) + int32_t altBaroFt = 0; + int16_t vertRateFtMin = 0; float groundSpeedKt = 0; - float headingDeg = 0; // track/heading + float headingDeg = 0; - float distanceKm = 0; // computed relative to home, via Haversine - float bearingDeg = 0; // computed relative to home + float distanceKm = 0; + float bearingDeg = 0; - uint32_t lastSeenMs = 0; // millis() at last update, for stale-entry eviction - bool alerted = false;// whether proximity beep already fired this pass + uint32_t lastSeenMs = 0; + bool alerted = false; uint32_t alertedAtMs = 0; bool valid = false; - char airlineName[24] = {0}; // resolved from callsign ICAO prefix - uint16_t estSeats = 0; // rough capacity estimate from typeCode, 0 = unknown + char airlineName[24] = {0}; + uint16_t estSeats = 0; }; \ No newline at end of file diff --git a/src/aircraft_details.cpp b/src/aircraft_details.cpp index 44dc68d..53fea5b 100644 --- a/src/aircraft_details.cpp +++ b/src/aircraft_details.cpp @@ -75,8 +75,11 @@ void update() { WiFiClientSecure client; client.setInsecure(); - client.setTimeout(5000); + // hexdb.io zuerst versuchen (bisherige Quelle) - aber mit kuerzerem + // Timeout (3s statt 5s), damit ein kompletter Ausfall des Dienstes die + // ADS-B-Abfrage nicht unnoetig lange blockiert, bevor der Fallback greift. + client.setTimeout(3000); String body; if (httpGetString(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) { JsonDocument doc; @@ -92,6 +95,27 @@ void update() { } } + // Fallback: hexdb.io war nicht erreichbar/lieferte kein Modell - + // adsbdb.com als zweite, unabhaengige Quelle versuchen (andere API-Form, + // aber inhaltlich aequivalent: Hersteller + Typ ueber den Hex-Code). + if (!result.model[0]) { + client.setTimeout(4000); + String body2; + if (httpGetString(client, String("https://api.adsbdb.com/v0/aircraft/") + hex, body2)) { + JsonDocument doc2; + DeserializationError err2 = deserializeJson(doc2, body2); + if (!err2) { + const char* manufacturer = doc2["response"]["aircraft"]["manufacturer"] | ""; + const char* type = doc2["response"]["aircraft"]["type"] | ""; + if (manufacturer[0] && type[0]) { + snprintf(result.model, sizeof(result.model), "%s %s", manufacturer, type); + } else if (type[0]) { + strncpy(result.model, type, sizeof(result.model) - 1); + } + } + } + } + xSemaphoreTake(mutex, portMAX_DELAY); strncpy(cachedHex, hex, sizeof(cachedHex) - 1); cached = result; diff --git a/src/airline_filter.cpp b/src/airline_filter.cpp new file mode 100644 index 0000000..6030031 --- /dev/null +++ b/src/airline_filter.cpp @@ -0,0 +1,114 @@ +#include "airline_filter.h" +#include "config.h" +#include "sd_mutex.h" +#include "sd_storage.h" +#include +#include +#include + +namespace AirlineFilter { + +namespace { + constexpr const char* HIDDEN_FILE = "/Flightradar_cyd/hidden_airlines.txt"; + + char hidden[MAX_HIDDEN][4] = {{0}}; + uint8_t hiddenCount = 0; + + void extractPrefix(const char* callsign, char* out) { + int i = 0; + for (; i < 3 && callsign[i] && isalpha((unsigned char)callsign[i]); i++) { + out[i] = (char)toupper((unsigned char)callsign[i]); + } + out[i] = '\0'; + } + + void saveToSd() { + if (!SdStorage::isMounted()) return; + SdMutex::Guard guard; + + File f = SD.open(HIDDEN_FILE, FILE_WRITE); + if (!f) return; + for (uint8_t i = 0; i < hiddenCount; i++) { + f.println(hidden[i]); + } + f.close(); + } + + void loadFromSd() { + hiddenCount = 0; + if (!SdStorage::isMounted()) return; + SdMutex::Guard guard; + + if (!SD.exists(HIDDEN_FILE)) return; + File f = SD.open(HIDDEN_FILE, FILE_READ); + if (!f) return; + + while (f.available() && hiddenCount < MAX_HIDDEN) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) continue; + strncpy(hidden[hiddenCount], line.c_str(), 3); + hidden[hiddenCount][3] = 0; + hiddenCount++; + } + f.close(); + } +} + +void init() { + loadFromSd(); +} + +uint8_t count() { return hiddenCount; } + +String icaoAt(uint8_t index) { + if (index >= hiddenCount) return String(); + return String(hidden[index]); +} + +bool addHidden(const char* icaoPrefix) { + if (hiddenCount >= MAX_HIDDEN) return false; + if (!icaoPrefix || !icaoPrefix[0]) return false; + + char normalized[4] = {0}; + uint8_t i = 0; + for (; i < 3 && icaoPrefix[i]; i++) { + normalized[i] = (char)toupper((unsigned char)icaoPrefix[i]); + } + normalized[i] = 0; + if (i == 0) return false; + + for (uint8_t j = 0; j < hiddenCount; j++) { + if (strcmp(hidden[j], normalized) == 0) return true; + } + + strncpy(hidden[hiddenCount], normalized, 3); + hidden[hiddenCount][3] = 0; + hiddenCount++; + saveToSd(); + return true; +} + +void removeHidden(uint8_t index) { + if (index >= hiddenCount) return; + for (uint8_t i = index; i < hiddenCount - 1; i++) { + strncpy(hidden[i], hidden[i + 1], 4); + } + hiddenCount--; + hidden[hiddenCount][0] = 0; + saveToSd(); +} + +bool isHidden(const char* callsign) { + if (hiddenCount == 0) return false; + char prefix[4]; + extractPrefix(callsign, prefix); + if (!prefix[0]) return false; + + for (uint8_t i = 0; i < hiddenCount; i++) { + if (strcmp(hidden[i], prefix) == 0) return true; + } + return false; +} + +} \ No newline at end of file diff --git a/src/airline_filter.h b/src/airline_filter.h new file mode 100644 index 0000000..57e25df --- /dev/null +++ b/src/airline_filter.h @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace AirlineFilter { + constexpr uint8_t MAX_HIDDEN = 10; + + void init(); + + uint8_t count(); + String icaoAt(uint8_t index); + + bool addHidden(const char* icaoPrefix); + void removeHidden(uint8_t index); + + bool isHidden(const char* callsign); +} \ No newline at end of file diff --git a/src/airline_filter_screen.cpp b/src/airline_filter_screen.cpp new file mode 100644 index 0000000..0d20942 --- /dev/null +++ b/src/airline_filter_screen.cpp @@ -0,0 +1,197 @@ +#include "airline_filter_screen.h" +#include "airline_filter.h" +#include "touch_input.h" +#include "config.h" +#include "i18n.h" + +namespace AirlineFilterScreen { + +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, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); + tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, bg); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + tft.setTextDatum(TL_DATUM); + } + + String runLetterKeypad(TFT_eSPI& tft) { + constexpr const char* ROW1 = "QWERTYUIOP"; + constexpr const char* ROW2 = "ASDFGHJKL"; + constexpr const char* ROW3 = "ZXCVBNM"; + + char buf[4] = {0}; + uint8_t len = 0; + + constexpr int16_t KEY_H = 32; + constexpr int16_t KEY_GAP = 3; + constexpr int16_t ROW0_Y = 90; + + auto layoutRow = [&](const char* row, int16_t y, Rect* outRects, uint8_t n) { + int16_t usableW = Config::SCREEN_WIDTH - 8; + int16_t keyW = (usableW - (n - 1) * KEY_GAP) / n; + int16_t x = 4; + for (uint8_t i = 0; i < n; i++) { + outRects[i] = {x, y, keyW, KEY_H}; + x += keyW + KEY_GAP; + } + }; + + Rect row1Rects[10], row2Rects[9], row3Rects[7]; + layoutRow(ROW1, ROW0_Y, row1Rects, 10); + layoutRow(ROW2, ROW0_Y + KEY_H + KEY_GAP, row2Rects, 9); + layoutRow(ROW3, ROW0_Y + 2 * (KEY_H + KEY_GAP), row3Rects, 7); + + Rect backspaceBtn = {4, (int16_t)(ROW0_Y + 3 * (KEY_H + KEY_GAP)), 100, KEY_H}; + Rect cancelBtn = {(int16_t)(Config::SCREEN_WIDTH - 100), (int16_t)(ROW0_Y + 3 * (KEY_H + KEY_GAP)), 96, KEY_H}; + Rect confirmBtn = {4, (int16_t)(ROW0_Y + 4 * (KEY_H + KEY_GAP)), (int16_t)(Config::SCREEN_WIDTH - 8), KEY_H}; + + bool done = false; + bool confirmed = false; + + auto redraw = [&]() { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println(I18n::t(StringId::AIRLINE_ADD_TITLE)); + + tft.fillRect(8, 40, Config::SCREEN_WIDTH - 16, 30, TFT_NAVY); + tft.drawRect(8, 40, Config::SCREEN_WIDTH - 16, 30, TFT_DARKGREY); + tft.setTextSize(2); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.setCursor(14, 47); + tft.print(buf); + tft.setTextSize(1); + + for (uint8_t i = 0; i < 10; i++) drawButton(tft, row1Rects[i], String(ROW1[i])); + for (uint8_t i = 0; i < 9; i++) drawButton(tft, row2Rects[i], String(ROW2[i])); + for (uint8_t i = 0; i < 7; i++) drawButton(tft, row3Rects[i], String(ROW3[i])); + + drawButton(tft, backspaceBtn, "<-"); + drawButton(tft, cancelBtn, I18n::t(StringId::CANCEL), TFT_MAROON); + drawButton(tft, confirmBtn, I18n::t(StringId::ADD)); + }; + + redraw(); + + while (!done) { + TouchInput::Point tap; + if (!TouchInput::wasTapped(tap)) { delay(20); continue; } + + bool handled = false; + for (uint8_t i = 0; i < 10 && !handled; i++) { + if (row1Rects[i].contains(tap.x, tap.y) && len < 3) { buf[len++] = ROW1[i]; handled = true; } + } + for (uint8_t i = 0; i < 9 && !handled; i++) { + if (row2Rects[i].contains(tap.x, tap.y) && len < 3) { buf[len++] = ROW2[i]; handled = true; } + } + for (uint8_t i = 0; i < 7 && !handled; i++) { + if (row3Rects[i].contains(tap.x, tap.y) && len < 3) { buf[len++] = ROW3[i]; handled = true; } + } + if (!handled && backspaceBtn.contains(tap.x, tap.y)) { + if (len > 0) { len--; buf[len] = 0; } + handled = true; + } + if (!handled && cancelBtn.contains(tap.x, tap.y)) { + done = true; + confirmed = false; + handled = true; + } + if (!handled && confirmBtn.contains(tap.x, tap.y) && len > 0) { + done = true; + confirmed = true; + handled = true; + } + + if (handled) redraw(); + } + + return confirmed ? String(buf) : String(); + } +} + +void run(TFT_eSPI& tft) { + constexpr int16_t ROW_H = 32; + constexpr int16_t ROW_GAP = 6; + constexpr int16_t REMOVE_BTN_W = 60; + + bool done = false; + while (!done) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 8); + tft.println(I18n::t(StringId::AIRLINE_FILTER_TITLE)); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.setCursor(10, 26); + tft.println(I18n::t(StringId::AIRLINE_FILTER_DESC1)); + tft.setCursor(10, 38); + tft.println(I18n::t(StringId::AIRLINE_FILTER_DESC2)); + + uint8_t count = AirlineFilter::count(); + int16_t y = 56; + + Rect rowRects[AirlineFilter::MAX_HIDDEN]; + Rect removeRects[AirlineFilter::MAX_HIDDEN]; + + for (uint8_t i = 0; i < count; i++) { + Rect rowRect = {10, y, (int16_t)(Config::SCREEN_WIDTH - 20 - REMOVE_BTN_W - 6), ROW_H}; + Rect removeRect = {(int16_t)(Config::SCREEN_WIDTH - 10 - REMOVE_BTN_W), y, REMOVE_BTN_W, ROW_H}; + rowRects[i] = rowRect; + removeRects[i] = removeRect; + + tft.fillRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_NAVY); + tft.drawRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.drawString(AirlineFilter::icaoAt(i), rowRect.x + rowRect.w / 2, rowRect.y + rowRect.h / 2); + tft.setTextDatum(TL_DATUM); + drawButton(tft, removeRect, "X", TFT_MAROON); + + y += ROW_H + ROW_GAP; + } + + Rect addBtn = {10, y, (int16_t)(Config::SCREEN_WIDTH - 20), 40}; + bool canAdd = count < AirlineFilter::MAX_HIDDEN; + if (canAdd) { + drawButton(tft, addBtn, I18n::t(StringId::AIRLINE_FILTER_ADD)); + y += 40 + 10; + } + + 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; + delay(20); + } + + bool handled = false; + for (uint8_t i = 0; i < count && !handled; i++) { + if (removeRects[i].contains(tap.x, tap.y)) { + AirlineFilter::removeHidden(i); + handled = true; + } + } + if (!handled && canAdd && addBtn.contains(tap.x, tap.y)) { + String code = runLetterKeypad(tft); + if (code.length() > 0) { + AirlineFilter::addHidden(code.c_str()); + } + handled = true; + } + if (!handled && backBtn.contains(tap.x, tap.y)) { + done = true; + } + } +} + +} \ No newline at end of file diff --git a/src/airline_filter_screen.h b/src/airline_filter_screen.h new file mode 100644 index 0000000..6ce46c2 --- /dev/null +++ b/src/airline_filter_screen.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +namespace AirlineFilterScreen { + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/calibration_screen.cpp b/src/calibration_screen.cpp index bab4bd6..f6a9e66 100644 --- a/src/calibration_screen.cpp +++ b/src/calibration_screen.cpp @@ -1,6 +1,7 @@ #include "calibration_screen.h" #include "touch_input.h" #include "config.h" +#include "i18n.h" namespace CalibrationScreen { @@ -20,8 +21,6 @@ namespace { tft.fillCircle(x, y, 3, TFT_WHITE); } - // Wartet auf eine Beruehrung, mittelt ein paar Rohwerte waehrend sie - // gehalten wird, und wartet danach auf das Loslassen, bevor es zurueckkehrt. RawAvg waitForTap() { RawAvg avg; @@ -39,7 +38,7 @@ namespace { while (TouchInput::rawPoint().touched) { delay(10); } - delay(150); // kleine Pause, damit der naechste Tap nicht sofort durchrutscht + delay(150); return avg; } @@ -54,11 +53,11 @@ void run(TFT_eSPI& tft) { const int16_t W = Config::SCREEN_WIDTH; const int16_t H = Config::SCREEN_HEIGHT; - struct { int16_t x, y; const char* label; } targets[4] = { - { M, M, "Oben links" }, - { W - M, M, "Oben rechts" }, - { W - M, H - M, "Unten rechts" }, - { M, H - M, "Unten links" }, + struct { int16_t x, y; StringId label; } targets[4] = { + { M, M, StringId::CALIB_TOP_LEFT }, + { W - M, M, StringId::CALIB_TOP_RIGHT }, + { W - M, H - M, StringId::CALIB_BOTTOM_RIGHT }, + { M, H - M, StringId::CALIB_BOTTOM_LEFT }, }; RawAvg samples[4]; @@ -67,24 +66,20 @@ void run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextSize(1); - // Text mittig zeichnen - weit weg von allen 4 Eck-Positionen, damit er - // nie vom Ziel-Kreis ueberdeckt wird. tft.setTextDatum(MC_DATUM); - tft.drawString("Touch-Kalibrierung", W / 2, H / 2 - 12); - char msg[40]; - snprintf(msg, sizeof(msg), "Bitte Kreis beruehren:"); - tft.drawString(msg, W / 2, H / 2 + 4); - tft.drawString(targets[i].label, W / 2, H / 2 + 20); + tft.drawString(I18n::t(StringId::CALIB_TITLE), W / 2, H / 2 - 12); + tft.drawString(I18n::t(StringId::CALIB_PROMPT), W / 2, H / 2 + 4); + tft.drawString(I18n::t(targets[i].label), W / 2, H / 2 + 20); tft.setTextDatum(TL_DATUM); drawTarget(tft, targets[i].x, targets[i].y); samples[i] = waitForTap(); } - int16_t xmin = (samples[0].avgX() + samples[3].avgX()) / 2; // links oben+unten - int16_t xmax = (samples[1].avgX() + samples[2].avgX()) / 2; // rechts oben+unten - int16_t ymin = (samples[0].avgY() + samples[1].avgY()) / 2; // oben links+rechts - int16_t ymax = (samples[3].avgY() + samples[2].avgY()) / 2; // unten links+rechts + int16_t xmin = (samples[0].avgX() + samples[3].avgX()) / 2; + int16_t xmax = (samples[1].avgX() + samples[2].avgX()) / 2; + int16_t ymin = (samples[0].avgY() + samples[1].avgY()) / 2; + int16_t ymax = (samples[3].avgY() + samples[2].avgY()) / 2; swapIfNeeded(xmin, xmax); swapIfNeeded(ymin, ymax); @@ -95,7 +90,7 @@ void run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(10, 10); - tft.println("Kalibrierung gespeichert!"); + tft.println(I18n::t(StringId::CALIB_SAVED)); delay(800); } diff --git a/src/first_run_language_screen.cpp b/src/first_run_language_screen.cpp new file mode 100644 index 0000000..2f2a1cb --- /dev/null +++ b/src/first_run_language_screen.cpp @@ -0,0 +1,63 @@ +#include "first_run_language_screen.h" +#include "settings_store.h" +#include "touch_input.h" +#include "i18n.h" +#include "config.h" + +namespace FirstRunLanguageScreen { + +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; + } + }; + + constexpr int16_t ROW_H = 36; + constexpr int16_t ROW_GAP = 6; + constexpr int16_t START_Y = 60; + + Rect rowRect(uint8_t index) { + return {20, (int16_t)(START_Y + index * (ROW_H + ROW_GAP)), + (int16_t)(Config::SCREEN_WIDTH - 40), ROW_H}; + } + + void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) { + 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) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(2); + tft.drawString("Language / Sprache", Config::SCREEN_WIDTH / 2, 24); + tft.setTextSize(1); + tft.setTextDatum(TL_DATUM); + + Rect langRects[I18n::LANG_COUNT]; + for (uint8_t i = 0; i < I18n::LANG_COUNT; i++) { + langRects[i] = rowRect(i); + drawButton(tft, langRects[i], I18n::languageName(i)); + } + + while (true) { + TouchInput::Point tap; + if (!TouchInput::wasTapped(tap)) { delay(20); continue; } + + for (uint8_t i = 0; i < I18n::LANG_COUNT; i++) { + if (langRects[i].contains(tap.x, tap.y)) { + SettingsStore::setLanguage(i); + return; + } + } + } +} + +} \ No newline at end of file diff --git a/src/first_run_language_screen.h b/src/first_run_language_screen.h new file mode 100644 index 0000000..a76630d --- /dev/null +++ b/src/first_run_language_screen.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +namespace FirstRunLanguageScreen { + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/flight_logbook.cpp b/src/flight_logbook.cpp index d9a4cfb..3078255 100644 --- a/src/flight_logbook.cpp +++ b/src/flight_logbook.cpp @@ -3,6 +3,7 @@ #include "aircraft.h" #include "aircraft_table.h" #include "settings_store.h" +#include "sd_mutex.h" #include #include #include @@ -42,6 +43,22 @@ namespace { seenCount++; } + uint32_t countLinesFast(File& f) { + constexpr size_t BUF_SIZE = 1024; + static uint8_t buf[BUF_SIZE]; + uint32_t lines = 0; + uint32_t blocksRead = 0; + while (f.available()) { + size_t n = f.read(buf, BUF_SIZE); + for (size_t i = 0; i < n; i++) { + if (buf[i] == '\n') lines++; + } + blocksRead++; + if (blocksRead % 8 == 0) yield(); + } + return lines; + } + void loadSeenFromTodayFile() { seenCount = 0; char filename[64]; @@ -51,21 +68,42 @@ namespace { File f = SD.open(filename, FILE_READ); if (!f) return; + constexpr size_t BUF_SIZE = 1024; + static uint8_t buf[BUF_SIZE]; + char lineBuf[48]; + size_t lineLen = 0; bool firstLine = true; - while (f.available() && seenCount < MAX_SEEN) { - String line = f.readStringUntil('\n'); - line.trim(); - if (line.length() == 0) continue; - if (firstLine) { firstLine = false; continue; } + uint32_t blocksRead = 0; - int firstComma = line.indexOf(','); - if (firstComma < 0) continue; - int secondComma = line.indexOf(',', firstComma + 1); - String hex = (secondComma < 0) ? line.substring(firstComma + 1) - : line.substring(firstComma + 1, secondComma); - hex.trim(); - if (hex.length() > 0) markSeen(hex.c_str()); - yield(); + auto processLine = [&]() { + if (lineLen == 0) return; + if (firstLine) { firstLine = false; return; } + lineBuf[lineLen] = 0; + char* firstComma = strchr(lineBuf, ','); + if (!firstComma) return; + char* secondComma = strchr(firstComma + 1, ','); + size_t hexLen = secondComma ? (size_t)(secondComma - (firstComma + 1)) + : strlen(firstComma + 1); + if (hexLen > 0 && hexLen < sizeof(seenHex[0])) { + char hexBuf[7] = {0}; + memcpy(hexBuf, firstComma + 1, hexLen); + markSeen(hexBuf); + } + }; + + while (f.available() && seenCount < MAX_SEEN) { + size_t n = f.read(buf, BUF_SIZE); + for (size_t i = 0; i < n && seenCount < MAX_SEEN; i++) { + char c = (char)buf[i]; + if (c == '\n' || c == '\r') { + if (lineLen > 0) processLine(); + lineLen = 0; + } else if (lineLen < sizeof(lineBuf) - 1) { + lineBuf[lineLen++] = c; + } + } + blocksRead++; + if (blocksRead % 8 == 0) yield(); } f.close(); } @@ -101,12 +139,15 @@ namespace { } void init() { + SdMutex::Guard guard; ensureCurrentDate(); } void update() { if (!SettingsStore::flightLogbookEnabled()) return; + SdMutex::Guard guard; + ensureCurrentDate(); if (currentDateStr[0] == 0) return; @@ -154,6 +195,8 @@ void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) { totalAircraft = 0; totalDays = 0; + SdMutex::Guard guard; + File dir = SD.open(Config::SD_LOG_DIR); if (!dir || !dir.isDirectory()) return; @@ -163,12 +206,7 @@ void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) { String name = String(entry.name()); if (name.endsWith(".csv")) { totalDays++; - uint32_t lines = 0; - while (entry.available()) { - entry.readStringUntil('\n'); - lines++; - if (lines % 50 == 0) yield(); - } + uint32_t lines = countLinesFast(entry); if (lines > 0) totalAircraft += (lines - 1); } } @@ -181,6 +219,8 @@ void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays) { uint8_t listDays(DayEntry* out, uint8_t maxEntries) { uint8_t filled = 0; + SdMutex::Guard guard; + File dir = SD.open(Config::SD_LOG_DIR); if (!dir || !dir.isDirectory()) return 0; @@ -193,12 +233,7 @@ uint8_t listDays(DayEntry* out, uint8_t maxEntries) { 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(); - } + uint32_t lines = countLinesFast(entry); strncpy(out[filled].date, dateOnly.c_str(), sizeof(out[filled].date) - 1); out[filled].count = (lines > 0) ? (lines - 1) : 0; @@ -213,4 +248,31 @@ uint8_t listDays(DayEntry* out, uint8_t maxEntries) { return filled; } +void resetAllData() { + SdMutex::Guard guard; + + File dir = SD.open(Config::SD_LOG_DIR); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + bool isDir = entry.isDirectory(); + String name = String(entry.name()); + entry.close(); + + if (!isDir) { + String fullPath = name.startsWith("/") + ? name + : String(Config::SD_LOG_DIR) + "/" + name; + SD.remove(fullPath); + } + entry = dir.openNextFile(); + } + dir.close(); + } + + seenCount = 0; + currentDateStr[0] = 0; + ensureCurrentDate(); +} + } \ No newline at end of file diff --git a/src/flight_logbook.h b/src/flight_logbook.h index d2e36a3..36437a0 100644 --- a/src/flight_logbook.h +++ b/src/flight_logbook.h @@ -1,12 +1,6 @@ #pragma once #include -// SD-Karten-Flugbuch: protokolliert jedes NEU gesichtete Flugzeug (Zeit, -// Hex-Code, Rufzeichen, Kennzeichen, Typ, Distanz, Hoehe) in eine taegliche -// CSV-Datei auf der SD-Karte. Kann in den Einstellungen ("Flight Logbook") -// an-/ausgeschaltet werden. Ueberlebt einen Neustart am selben Tag, ohne -// bereits geloggte Flugzeuge erneut einzutragen (rekonstruiert die Liste -// beim Start aus der heutigen CSV-Datei). namespace FlightLogbook { void init(); @@ -17,13 +11,14 @@ namespace FlightLogbook { void computeAllTimeStats(uint32_t& totalAircraft, uint16_t& totalDays); struct DayEntry { - char date[11] = {0}; // "YYYY-MM-DD" + char date[11] = {0}; 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); + + // Loescht ALLE Logbuch-CSV-Dateien auf der SD-Karte unwiderruflich und + // setzt die "heute schon gesehen"-Liste zurueck. Fuer den Reset-Button + // im Statistik-Bildschirm gedacht. + void resetAllData(); } \ No newline at end of file diff --git a/src/i18n.cpp b/src/i18n.cpp new file mode 100644 index 0000000..fc41869 --- /dev/null +++ b/src/i18n.cpp @@ -0,0 +1,37 @@ +#include "i18n.h" +#include "i18n_en.h" +#include "i18n_de.h" +#include "i18n_fr.h" +#include "i18n_tr.h" +#include "i18n_es.h" +#include "i18n_it.h" +#include "settings_store.h" + +namespace I18n { + +namespace { + const char* const* TABLES[LANG_COUNT] = { + I18N_EN, I18N_DE, I18N_FR, I18N_TR, I18N_ES, I18N_IT + }; + + const char* const LANGUAGE_NAMES[LANG_COUNT] = { + "English", "Deutsch", "Francais", "Turkce", "Espanol", "Italiano" + }; +} + +const char* t(StringId id) { + uint8_t lang = SettingsStore::language(); + if (lang >= LANG_COUNT) lang = 0; + + uint8_t idx = (uint8_t)id; + if (idx >= (uint8_t)StringId::COUNT) return "?"; + + return TABLES[lang][idx]; +} + +const char* languageName(uint8_t index) { + if (index >= LANG_COUNT) return "?"; + return LANGUAGE_NAMES[index]; +} + +} \ No newline at end of file diff --git a/src/i18n.h b/src/i18n.h new file mode 100644 index 0000000..22099ee --- /dev/null +++ b/src/i18n.h @@ -0,0 +1,155 @@ +#pragma once +#include + +// Zentrales Uebersetzungssystem: jeder feste UI-Text der App hat eine +// StringId. Die eigentlichen Uebersetzungen liegen in je einer Datei pro +// Sprache (i18n_en.h, i18n_de.h, i18n_fr.h, i18n_tr.h, i18n_es.h, i18n_it.h) - +// jede Datei enthaelt ein Array in GENAU derselben Reihenfolge wie dieses +// Enum. Neue Menuepunkte/Texte: hier eine neue StringId VOR COUNT einfuegen, +// dann in ALLEN 6 Sprachdateien an derselben Position ergaenzen. +enum class StringId : uint8_t { + OK = 0, + CANCEL, + BACK, + BACK_ARROW, + LOADING, + ADD, + + ON, + OFF, + NEVER, + + SPLASH_SD_OK, + SPLASH_CONNECTING_WIFI, + SPLASH_WIFI_OK_PREFIX, + SPLASH_WIFI_FAILED, + SPLASH_GETTING_LOCATION, + SPLASH_READY, + + SD_REQUIRED_LINE1, + SD_REQUIRED_LINE2, + SD_REQUIRED_LINE3, + SD_REQUIRED_HINT, + + CALIB_TITLE, + CALIB_PROMPT, + CALIB_TOP_LEFT, + CALIB_TOP_RIGHT, + CALIB_BOTTOM_RIGHT, + CALIB_BOTTOM_LEFT, + CALIB_SAVED, + + WIFI_SCANNING, + WIFI_NO_NETWORKS, + WIFI_SELECT, + WIFI_CONNECTED_BANG, + WIFI_CONNECTION_FAILED, + WIFI_BACK_TO_LIST_MSG, + WIFI_PASSWORD_LABEL, + WIFI_SPACE, + WIFI_CONNECT, + WIFI_BACK_TO_LIST_BTN, + WIFI_ALREADY_3, + WIFI_REMOVE_ONE_FIRST, + WIFI_CONNECTING, + WIFI_LABEL_PREFIX, + + MENU_SETTINGS, + MENU_SETTINGS_PAGE2, + MENU_CALIBRATE, + MENU_DISPLAY_INVERTED, + MENU_DISPLAY_NORMAL, + MENU_MANAGE_WIFI, + MENU_LOCATION_PRESETS, + MENU_SCREEN_TIMEOUT_PREFIX, + MENU_STATISTICS, + MENU_LOGBOOK_FILES, + MENU_MORE_SETTINGS, + MENU_EMERGENCY_ALERT, + MENU_PROXIMITY_LED, + MENU_LED_HEARTBEAT, + MENU_FLIGHT_LOGBOOK, + MENU_HIDE_GROUND, + MENU_AIRLINE_FILTER, + MENU_BACKUP, + MENU_RESTORE, + MENU_BACKUP_SAVED, + MENU_BACKUP_FAILED, + MENU_RESTORED, + MENU_RESTORE_FAILED, + MENU_LANGUAGE, + MENU_UNITS, + + AIRLINE_FILTER_TITLE, + AIRLINE_FILTER_DESC1, + AIRLINE_FILTER_DESC2, + AIRLINE_FILTER_ADD, + AIRLINE_ADD_TITLE, + + LOCATION_TITLE, + LOCATION_AUTO, + LOCATION_PRESET, + LOCATION_PRESET_EMPTY, + LOCATION_ADD, + LOCATION_LAT_PROMPT, + LOCATION_LON_PROMPT, + + STATS_TITLE, + STATS_TODAY, + STATS_ALLTIME, + STATS_DAYS, + STATS_AVG, + STATS_RESET_BTN, + STATS_RESET_CONFIRM, + STATS_RESET_DONE, + STATS_UPTIME_PREFIX, + + LOGFILES_TITLE, + LOGFILES_EMPTY, + LOGFILES_SHOWING_PREFIX, + LOGFILES_OF, + LOGFILES_DAYS_SUFFIX, + LOGFILES_AIRCRAFT_SUFFIX, + + RADAR_TAP_FOR_DETAILS, + + DETAIL_MODEL, + DETAIL_TYPE, + DETAIL_LOADING_DOTS, + DETAIL_UNKNOWN, + DETAIL_ALT, + DETAIL_SPEED, + DETAIL_DIST, + DETAIL_HDG, + DETAIL_SEATS_EST, + DETAIL_SEATS_UNKNOWN, + DETAIL_TAP_CLOSE, + DETAIL_CLIMB, + DETAIL_DESCENT, + DETAIL_LEVEL, + DETAIL_SQUAWK, + + SCREENSHOT_SAVED_PREFIX, + SCREENSHOT_FAILED, + + UNITS_TITLE, + UNITS_AUTO, + UNITS_METRIC, + UNITS_IMPERIAL, + + LANGUAGE_TITLE, + + COUNT // IMMER als letztes Element - markiert die Gesamtanzahl +}; + +namespace I18n { + constexpr uint8_t LANG_COUNT = 6; + + // Liefert den uebersetzten Text fuer 'id' in der aktuell in den + // Einstellungen gewaehlten Sprache (SettingsStore::language()). + const char* t(StringId id); + + // Eigennamen der Sprachen (fuer das Sprachauswahl-Menue), IMMER im + // jeweiligen Idiom selbst geschrieben (z.B. "Deutsch", nicht "German"). + const char* languageName(uint8_t index); +} \ No newline at end of file diff --git a/src/i18n_de.h b/src/i18n_de.h new file mode 100644 index 0000000..412d44e --- /dev/null +++ b/src/i18n_de.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_DE[] = { + "OK", + "Abbrechen", + "Zurueck", + "< Zurueck", + "Laedt...", + "Hinzufuegen", + + "AN", + "AUS", + "Nie", + + "SD-Karte: OK", + "Verbinde mit WLAN...", + "WLAN OK: ", + "WLAN FEHLGESCHLAGEN", + "Ermittle Standort...", + "Bereit!", + + "Fuer diese App wird", + "eine SD-Karte", + "benoetigt", + "SD-Karte einlegen und Geraet neu starten", + + "Touch-Kalibrierung", + "Bitte den Kreis beruehren:", + "Oben links", + "Oben rechts", + "Unten rechts", + "Unten links", + "Kalibrierung gespeichert!", + + "Suche WLAN...", + "Keine Netzwerke gefunden", + "WLAN waehlen:", + "Verbunden!", + "Verbindung fehlgeschlagen.", + "Zurueck zur Netzwerkliste...", + "Passwort:", + "Leerzeichen", + "Verbinden", + "Zurueck zur Liste", + "Bereits 3 Netzwerke gespeichert.", + "Erst eins entfernen.", + "Verbinde...", + "WLAN: ", + + "Einstellungen", + "Einstellungen (2/2)", + "Touch kalibrieren", + "Anzeige: invertiert (tippen)", + "Anzeige: normal (tippen)", + "WLAN-Netzwerke verwalten", + "Standort-Presets", + "Bildschirm-Timeout: ", + "Statistik", + "Logbuch-Dateien", + "Weitere Einstellungen >", + "Notfall-Alarm: ", + "Naeherungs-LED: ", + "LED-Herzschlag: ", + "Flugbuch: ", + "Bodenfahrzeuge ausblenden: ", + "Airline-Filter", + "Einstellungen sichern", + "Einstellungen wiederherstellen", + "Backup gespeichert.", + "Backup fehlgeschlagen.", + "Wiederhergestellt. Geraet neu starten!", + "Wiederherstellung fehlgeschlagen.", + "Sprache", + "Einheiten", + + "Ausgeblendete Airlines", + "Flugzeuge dieser Airlines werden", + "im Radar ausgeblendet.", + "Airline hinzufuegen", + "Airline hinzufuegen (ICAO-Code)", + + "Standort-Presets", + "Automatisch (GPS/IP)", + "Preset", + "(leer)", + "Preset hinzufuegen", + "Breitengrad (z.B. 48.6833)", + "Laengengrad (z.B. 9.8333)", + + "Statistik", + "Heute geloggte Flugzeuge", + "Insgesamt geloggte Flugzeuge", + "Tage mit Sichtungen", + "Durchschnitt pro Tag", + "Flugbuch zuruecksetzen", + "Nochmal tippen zum Bestaetigen!", + "Flugbuch-Daten zurueckgesetzt.", + "Laufzeit: ", + + "Logbuch-Dateien", + "Noch keine Flugbuch-Eintraege.", + "Zeige die letzten ", + " von ", + " Tagen", + " Flugzeuge", + + "Fuer Details antippen", + + "Modell: ", + "Typ: ", + "laedt...", + "unbekannt", + "Hoehe: ", + "Geschw.: ", + "Distanz: ", + "Kurs: ", + "Sitzplaetze (geschaetzt): ", + "Sitzplaetze: unbekannt", + "Woanders antippen zum Schliessen", + "Steigen: ", + "Sinken: ", + "Horizontalflug", + "Squawk: ", + + "Gespeichert: ", + "Screenshot fehlgeschlagen", + + "Einheiten", + "Automatisch (nach Standort)", + "Metrisch (km, m, km/h)", + "Imperial (ft, kt, nm)", + + "Sprache", +}; + +static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, + "I18N_DE table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/i18n_en.h b/src/i18n_en.h new file mode 100644 index 0000000..e3ae671 --- /dev/null +++ b/src/i18n_en.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_EN[] = { + "OK", + "Cancel", + "Back", + "< Back", + "Loading...", + "Add", + + "ON", + "OFF", + "Never", + + "SD Card: OK", + "Connecting WiFi...", + "WiFi OK: ", + "WiFi FAILED", + "Getting location...", + "Ready!", + + "For this app a", + "SD card is", + "required", + "Insert a card and restart the device", + + "Touch Calibration", + "Please touch the circle:", + "Top left", + "Top right", + "Bottom right", + "Bottom left", + "Calibration saved!", + + "Scanning WiFi...", + "No networks found", + "Select WiFi:", + "Connected!", + "Connection failed.", + "Back to network list...", + "Password:", + "Space", + "Connect", + "Back to list", + "Already 3 networks saved.", + "Remove one first.", + "Connecting...", + "WiFi: ", + + "Settings", + "Settings (2/2)", + "Calibrate touch", + "Display: inverted (tap)", + "Display: normal (tap)", + "Manage WiFi networks", + "Location presets", + "Screen timeout: ", + "Statistics", + "Logbook files", + "More settings >", + "Emergency alert: ", + "Proximity LED: ", + "LED heartbeat: ", + "Flight logbook: ", + "Hide ground vehicles: ", + "Airline filter", + "Backup settings", + "Restore settings", + "Backup saved.", + "Backup failed.", + "Restored. Restart device!", + "Restore failed.", + "Language", + "Units", + + "Hidden airlines", + "Aircraft from these airlines are", + "hidden from the radar.", + "Add airline", + "Add airline (ICAO code)", + + "Location presets", + "Auto (GPS/IP)", + "Preset", + "(empty)", + "Add preset", + "Latitude (e.g. 48.6833)", + "Longitude (e.g. 9.8333)", + + "Statistics", + "Aircraft logged today", + "Aircraft logged all-time", + "Days with sightings", + "Average per day", + "Reset logbook data", + "Tap again to confirm!", + "Logbook data reset.", + "Uptime: ", + + "Logbook files", + "No logbook entries yet.", + "Showing last ", + " of ", + " days", + " aircraft", + + "Tap for details", + + "Model: ", + "Type: ", + "loading...", + "unknown", + "Alt: ", + "Speed: ", + "Dist: ", + "Hdg: ", + "Seats (estimated): ", + "Seats: unknown", + "Tap elsewhere to close", + "Climb: ", + "Descent: ", + "Level flight", + "Squawk: ", + + "Saved: ", + "Screenshot failed", + + "Units", + "Auto (by location)", + "Metric (km, m, km/h)", + "Imperial (ft, kt, nm)", + + "Language", +}; + +static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, + "I18N_EN table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/i18n_es.h b/src/i18n_es.h new file mode 100644 index 0000000..d312063 --- /dev/null +++ b/src/i18n_es.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_ES[] = { + "OK", + "Cancelar", + "Atras", + "< Atras", + "Cargando...", + "Anadir", + + "ON", + "OFF", + "Nunca", + + "Tarjeta SD: OK", + "Conectando WiFi...", + "WiFi OK: ", + "WiFi FALLO", + "Obteniendo ubicacion...", + "Listo!", + + "Esta app necesita", + "una tarjeta SD", + "requerida", + "Inserte una tarjeta y reinicie el dispositivo", + + "Calibracion tactil", + "Toque el circulo:", + "Arriba izquierda", + "Arriba derecha", + "Abajo derecha", + "Abajo izquierda", + "Calibracion guardada!", + + "Buscando WiFi...", + "No se encontraron redes", + "Elegir WiFi:", + "Conectado!", + "Conexion fallida.", + "Volviendo a la lista...", + "Contrasena:", + "Espacio", + "Conectar", + "Volver a la lista", + "Ya hay 3 redes guardadas.", + "Elimine una primero.", + "Conectando...", + "WiFi: ", + + "Ajustes", + "Ajustes (2/2)", + "Calibrar tactil", + "Pantalla: invertida (toque)", + "Pantalla: normal (toque)", + "Gestionar redes WiFi", + "Ubicaciones guardadas", + "Apagado de pantalla: ", + "Estadisticas", + "Archivos del registro", + "Mas ajustes >", + "Alerta de emergencia: ", + "LED de proximidad: ", + "Latido LED: ", + "Registro de vuelos: ", + "Ocultar vehiculos terrestres: ", + "Filtro de aerolineas", + "Copia de seguridad", + "Restaurar ajustes", + "Copia guardada.", + "Fallo la copia de seguridad.", + "Restaurado. Reinicie el dispositivo!", + "Fallo la restauracion.", + "Idioma", + "Unidades", + + "Aerolineas ocultas", + "Los aviones de estas aerolineas", + "se ocultan del radar.", + "Anadir aerolinea", + "Anadir aerolinea (codigo OACI)", + + "Ubicaciones guardadas", + "Auto (GPS/IP)", + "Ubicacion", + "(vacio)", + "Anadir ubicacion", + "Latitud (ej. 48.6833)", + "Longitud (ej. 9.8333)", + + "Estadisticas", + "Aviones registrados hoy", + "Aviones registrados en total", + "Dias con avistamientos", + "Promedio por dia", + "Reiniciar registro", + "Toque de nuevo para confirmar!", + "Registro reiniciado.", + "Tiempo activo: ", + + "Archivos del registro", + "Aun no hay registros.", + "Mostrando los ultimos ", + " de ", + " dias", + " aviones", + + "Toque para ver detalles", + + "Modelo: ", + "Tipo: ", + "cargando...", + "desconocido", + "Alt.: ", + "Vel.: ", + "Dist.: ", + "Rumbo: ", + "Asientos (estimado): ", + "Asientos: desconocido", + "Toque otro lugar para cerrar", + "Subida: ", + "Descenso: ", + "Vuelo nivelado", + "Squawk: ", + + "Guardado: ", + "Fallo la captura de pantalla", + + "Unidades", + "Auto (segun ubicacion)", + "Metrico (km, m, km/h)", + "Imperial (ft, kt, nm)", + + "Idioma", +}; + +static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, + "I18N_ES table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/i18n_fr.h b/src/i18n_fr.h new file mode 100644 index 0000000..a32798f --- /dev/null +++ b/src/i18n_fr.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_FR[] = { + "OK", + "Annuler", + "Retour", + "< Retour", + "Chargement...", + "Ajouter", + + "ON", + "OFF", + "Jamais", + + "Carte SD: OK", + "Connexion WiFi...", + "WiFi OK: ", + "ECHEC WiFi", + "Localisation...", + "Pret !", + + "Pour cette appli une", + "carte SD est", + "requise", + "Inserez une carte et redemarrez l'appareil", + + "Etalonnage tactile", + "Touchez le cercle :", + "Haut gauche", + "Haut droite", + "Bas droite", + "Bas gauche", + "Etalonnage enregistre !", + + "Recherche WiFi...", + "Aucun reseau trouve", + "Choisir WiFi :", + "Connecte !", + "Echec de connexion.", + "Retour a la liste...", + "Mot de passe :", + "Espace", + "Connecter", + "Retour a la liste", + "Deja 3 reseaux enregistres.", + "Supprimez-en un d'abord.", + "Connexion...", + "WiFi : ", + + "Reglages", + "Reglages (2/2)", + "Etalonner le tactile", + "Ecran : inverse (touchez)", + "Ecran : normal (touchez)", + "Gerer les reseaux WiFi", + "Positions enregistrees", + "Extinction ecran : ", + "Statistiques", + "Fichiers du journal", + "Plus de reglages >", + "Alerte urgence : ", + "LED de proximite : ", + "Battement LED : ", + "Journal de vol : ", + "Masquer vehicules au sol : ", + "Filtre compagnies", + "Sauvegarder reglages", + "Restaurer reglages", + "Sauvegarde enregistree.", + "Echec de la sauvegarde.", + "Restaure. Redemarrez l'appareil !", + "Echec de la restauration.", + "Langue", + "Unites", + + "Compagnies masquees", + "Les avions de ces compagnies sont", + "masques du radar.", + "Ajouter une compagnie", + "Ajouter (code OACI)", + + "Positions enregistrees", + "Auto (GPS/IP)", + "Position", + "(vide)", + "Ajouter une position", + "Latitude (ex. 48.6833)", + "Longitude (ex. 9.8333)", + + "Statistiques", + "Avions enregistres aujourd'hui", + "Avions enregistres au total", + "Jours avec observations", + "Moyenne par jour", + "Reinitialiser le journal", + "Retouchez pour confirmer !", + "Journal reinitialise.", + "Duree de fonctionnement : ", + + "Fichiers du journal", + "Aucune entree pour le moment.", + "Affichage des ", + " derniers sur ", + " jours", + " avions", + + "Touchez pour les details", + + "Modele : ", + "Type : ", + "chargement...", + "inconnu", + "Alt. : ", + "Vitesse : ", + "Dist. : ", + "Cap : ", + "Sieges (estime) : ", + "Sieges : inconnu", + "Touchez ailleurs pour fermer", + "Montee : ", + "Descente : ", + "Vol en palier", + "Squawk : ", + + "Enregistre : ", + "Echec de la capture d'ecran", + + "Unites", + "Auto (selon la position)", + "Metrique (km, m, km/h)", + "Imperial (ft, kt, nm)", + + "Langue", +}; + +static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, + "I18N_FR table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/i18n_it.h b/src/i18n_it.h new file mode 100644 index 0000000..8aedace --- /dev/null +++ b/src/i18n_it.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_IT[] = { + "OK", + "Annulla", + "Indietro", + "< Indietro", + "Caricamento...", + "Aggiungi", + + "ON", + "OFF", + "Mai", + + "Scheda SD: OK", + "Connessione WiFi...", + "WiFi OK: ", + "WiFi FALLITO", + "Rilevamento posizione...", + "Pronto!", + + "Questa app richiede", + "una scheda SD", + "richiesta", + "Inserire una scheda e riavviare il dispositivo", + + "Calibrazione touch", + "Toccare il cerchio:", + "In alto a sinistra", + "In alto a destra", + "In basso a destra", + "In basso a sinistra", + "Calibrazione salvata!", + + "Ricerca WiFi...", + "Nessuna rete trovata", + "Seleziona WiFi:", + "Connesso!", + "Connessione fallita.", + "Ritorno alla lista...", + "Password:", + "Spazio", + "Connetti", + "Torna alla lista", + "Gia 3 reti salvate.", + "Rimuovine una prima.", + "Connessione...", + "WiFi: ", + + "Impostazioni", + "Impostazioni (2/2)", + "Calibra touch", + "Schermo: invertito (tocca)", + "Schermo: normale (tocca)", + "Gestisci reti WiFi", + "Posizioni salvate", + "Spegnimento schermo: ", + "Statistiche", + "File del registro", + "Altre impostazioni >", + "Allarme emergenza: ", + "LED di prossimita: ", + "Battito LED: ", + "Diario di volo: ", + "Nascondi veicoli a terra: ", + "Filtro compagnie", + "Backup impostazioni", + "Ripristina impostazioni", + "Backup salvato.", + "Backup fallito.", + "Ripristinato. Riavviare il dispositivo!", + "Ripristino fallito.", + "Lingua", + "Unita", + + "Compagnie nascoste", + "Gli aerei di queste compagnie", + "sono nascosti dal radar.", + "Aggiungi compagnia", + "Aggiungi compagnia (codice ICAO)", + + "Posizioni salvate", + "Auto (GPS/IP)", + "Posizione", + "(vuoto)", + "Aggiungi posizione", + "Latitudine (es. 48.6833)", + "Longitudine (es. 9.8333)", + + "Statistiche", + "Aerei registrati oggi", + "Aerei registrati totali", + "Giorni con avvistamenti", + "Media al giorno", + "Reimposta registro", + "Tocca di nuovo per confermare!", + "Registro reimpostato.", + "Tempo di attivita: ", + + "File del registro", + "Ancora nessun dato.", + "Mostra gli ultimi ", + " di ", + " giorni", + " aerei", + + "Tocca per i dettagli", + + "Modello: ", + "Tipo: ", + "caricamento...", + "sconosciuto", + "Alt.: ", + "Vel.: ", + "Dist.: ", + "Rotta: ", + "Posti (stimati): ", + "Posti: sconosciuto", + "Tocca altrove per chiudere", + "Salita: ", + "Discesa: ", + "Volo livellato", + "Squawk: ", + + "Salvato: ", + "Screenshot fallito", + + "Unita", + "Auto (in base alla posizione)", + "Metrico (km, m, km/h)", + "Imperiale (ft, kt, nm)", + + "Lingua", +}; + +static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, + "I18N_IT table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/i18n_tr.h b/src/i18n_tr.h new file mode 100644 index 0000000..69cdd25 --- /dev/null +++ b/src/i18n_tr.h @@ -0,0 +1,138 @@ +#pragma once +#include "i18n.h" + +static const char* const I18N_TR[] = { + "Tamam", + "Iptal", + "Geri", + "< Geri", + "Yukleniyor...", + "Ekle", + + "ACIK", + "KAPALI", + "Asla", + + "SD Kart: OK", + "WiFi'ye baglaniyor...", + "WiFi OK: ", + "WiFi BASARISIZ", + "Konum aliniyor...", + "Hazir!", + + "Bu uygulama icin bir", + "SD kart", + "gereklidir", + "Bir kart takin ve cihazi yeniden baslatin", + + "Dokunmatik Kalibrasyon", + "Lutfen daireye dokunun:", + "Sol ust", + "Sag ust", + "Sag alt", + "Sol alt", + "Kalibrasyon kaydedildi!", + + "WiFi taraniyor...", + "Ag bulunamadi", + "WiFi secin:", + "Baglandi!", + "Baglanti basarisiz.", + "Ag listesine donuluyor...", + "Sifre:", + "Bosluk", + "Baglan", + "Listeye don", + "Zaten 3 ag kayitli.", + "Once birini kaldirin.", + "Baglaniyor...", + "WiFi: ", + + "Ayarlar", + "Ayarlar (2/2)", + "Dokunmatigi kalibre et", + "Ekran: ters (dokun)", + "Ekran: normal (dokun)", + "WiFi aglarini yonet", + "Konum on ayarlari", + "Ekran zaman asimi: ", + "Istatistikler", + "Kayit defteri dosyalari", + "Diger ayarlar >", + "Acil durum alarmi: ", + "Yakinlik LED'i: ", + "LED kalp atisi: ", + "Ucus kayit defteri: ", + "Kara araclarini gizle: ", + "Havayolu filtresi", + "Ayarlari yedekle", + "Ayarlari geri yukle", + "Yedekleme kaydedildi.", + "Yedekleme basarisiz.", + "Geri yuklendi. Cihazi yeniden baslatin!", + "Geri yukleme basarisiz.", + "Dil", + "Birimler", + + "Gizli havayollari", + "Bu havayollarina ait ucaklar", + "radarda gizlenir.", + "Havayolu ekle", + "Havayolu ekle (ICAO kodu)", + + "Konum on ayarlari", + "Otomatik (GPS/IP)", + "On ayar", + "(bos)", + "On ayar ekle", + "Enlem (orn. 48.6833)", + "Boylam (orn. 9.8333)", + + "Istatistikler", + "Bugun kaydedilen ucaklar", + "Toplam kaydedilen ucaklar", + "Gozlem yapilan gunler", + "Gunluk ortalama", + "Kayit defterini sifirla", + "Onaylamak icin tekrar dokunun!", + "Kayit defteri sifirlandi.", + "Calisma suresi: ", + + "Kayit defteri dosyalari", + "Henuz kayit yok.", + "Son ", + " / ", + " gun gosteriliyor", + " ucak", + + "Detaylar icin dokunun", + + "Model: ", + "Tip: ", + "yukleniyor...", + "bilinmiyor", + "Yuk.: ", + "Hiz: ", + "Mesafe: ", + "Yon: ", + "Koltuk (tahmini): ", + "Koltuk: bilinmiyor", + "Kapatmak icin baska yere dokunun", + "Tirmanis: ", + "Alcalma: ", + "Duz ucus", + "Squawk: ", + + "Kaydedildi: ", + "Ekran goruntusu basarisiz", + + "Birimler", + "Otomatik (konuma gore)", + "Metrik (km, m, km/sa)", + "Imperyal (ft, kt, nm)", + + "Dil", +}; + +static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, + "I18N_TR table size must match StringId::COUNT"); \ No newline at end of file diff --git a/src/language_screen.cpp b/src/language_screen.cpp new file mode 100644 index 0000000..71d8807 --- /dev/null +++ b/src/language_screen.cpp @@ -0,0 +1,75 @@ +#include "language_screen.h" +#include "settings_store.h" +#include "touch_input.h" +#include "i18n.h" +#include "config.h" + +namespace LanguageScreen { + +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, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); + tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, bg); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + tft.setTextDatum(TL_DATUM); + } + + constexpr int16_t ROW_H = 36; + constexpr int16_t ROW_GAP = 6; + constexpr int16_t START_Y = 30; + + Rect rowRect(uint8_t index) { + return {10, (int16_t)(START_Y + index * (ROW_H + ROW_GAP)), + (int16_t)(Config::SCREEN_WIDTH - 20), ROW_H}; + } +} + +void run(TFT_eSPI& tft) { + bool done = false; + while (!done) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 6); + tft.println(I18n::t(StringId::LANGUAGE_TITLE)); + + uint8_t current = SettingsStore::language(); + Rect langRects[I18n::LANG_COUNT]; + + for (uint8_t i = 0; i < I18n::LANG_COUNT; i++) { + langRects[i] = rowRect(i); + drawButton(tft, langRects[i], I18n::languageName(i), + (i == current) ? TFT_DARKGREEN : TFT_NAVY); + } + + Rect backBtn = rowRect(I18n::LANG_COUNT); + drawButton(tft, backBtn, I18n::t(StringId::BACK)); + + TouchInput::Point tap; + while (true) { + if (TouchInput::wasTapped(tap)) break; + delay(20); + } + + bool handled = false; + for (uint8_t i = 0; i < I18n::LANG_COUNT && !handled; i++) { + if (langRects[i].contains(tap.x, tap.y)) { + SettingsStore::setLanguage(i); + handled = true; + } + } + if (!handled && backBtn.contains(tap.x, tap.y)) { + done = true; + } + } +} + +} \ No newline at end of file diff --git a/src/language_screen.h b/src/language_screen.h new file mode 100644 index 0000000..ae5ea9c --- /dev/null +++ b/src/language_screen.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +namespace LanguageScreen { + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/location_manager.cpp b/src/location_manager.cpp index 3dee717..0dea66b 100644 --- a/src/location_manager.cpp +++ b/src/location_manager.cpp @@ -1,5 +1,7 @@ #include "location_manager.h" #include "config.h" +#include "location_presets.h" +#include "settings_store.h" #include #include #include @@ -33,7 +35,7 @@ namespace { bool haveUtcOffset = false; int32_t utcOffsetSecs = 0; - bool metricUnits = true; // Standard: metrisch, bis die IP-Abfrage etwas anderes sagt + bool metricUnits = true; SemaphoreHandle_t mutex = nullptr; @@ -128,8 +130,6 @@ void requestIpLookupIfNeeded() { haveUtcOffset = true; const char* countryCode = doc["countryCode"] | ""; - // Nur die USA nutzen (fuer diesen Zweck) Fuss statt Meter. Bei Bedarf - // hier weitere Laender ergaenzen (z.B. "LR", "MM"). metricUnits = (strcmp(countryCode, "US") != 0); persistLocationAndSource(lat, lon, Source::IpGeolocation); @@ -137,6 +137,12 @@ void requestIpLookupIfNeeded() { } void getHomeLocation(double& lat, double& lon) { + int8_t presetIdx = LocationPresets::activeIndex(); + if (presetIdx >= 0) { + LocationPresets::getLatLon((uint8_t)presetIdx, lat, lon); + return; + } + if (gps.location.isValid()) { lat = gps.location.lat(); lon = gps.location.lng(); @@ -151,6 +157,8 @@ void getHomeLocation(double& lat, double& lon) { } Source currentSource() { + if (LocationPresets::activeIndex() >= 0) return Source::Manual; + xSemaphoreTake(mutex, portMAX_DELAY); Source s = source; xSemaphoreGive(mutex); @@ -187,6 +195,11 @@ bool hasGpsFix() { return gps.location.isValid(); } bool hasUtcOffset() { return haveUtcOffset; } int32_t utcOffsetSeconds() { return utcOffsetSecs; } -bool useMetricUnits() { return metricUnits; } +bool useMetricUnits() { + uint8_t mode = SettingsStore::unitsMode(); + if (mode == 1) return true; + if (mode == 2) return false; + return metricUnits; +} } \ No newline at end of file diff --git a/src/location_presets.cpp b/src/location_presets.cpp new file mode 100644 index 0000000..3882f9f --- /dev/null +++ b/src/location_presets.cpp @@ -0,0 +1,117 @@ +#include "location_presets.h" +#include "config.h" +#include "sd_mutex.h" +#include "sd_storage.h" +#include + +namespace LocationPresets { + +namespace { + constexpr const char* PRESETS_FILE = "/Flightradar_cyd/locations.txt"; + + struct Preset { + double lat = 0; + double lon = 0; + }; + + Preset presets[MAX_PRESETS]; + uint8_t presetCount = 0; + int8_t activeIdx = -1; + + void saveToSd() { + if (!SdStorage::isMounted()) return; + SdMutex::Guard guard; + + File f = SD.open(PRESETS_FILE, FILE_WRITE); + if (!f) return; + f.printf("active=%d\n", (int)activeIdx); + for (uint8_t i = 0; i < presetCount; i++) { + f.printf("%.6f,%.6f\n", presets[i].lat, presets[i].lon); + } + f.close(); + } + + void loadFromSd() { + presetCount = 0; + activeIdx = -1; + if (!SdStorage::isMounted()) return; + SdMutex::Guard guard; + + if (!SD.exists(PRESETS_FILE)) return; + File f = SD.open(PRESETS_FILE, FILE_READ); + if (!f) return; + + bool firstLine = true; + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) continue; + + if (firstLine) { + firstLine = false; + if (line.startsWith("active=")) { + activeIdx = (int8_t)line.substring(7).toInt(); + continue; + } + } + + int comma = line.indexOf(','); + if (comma < 0) continue; + if (presetCount >= MAX_PRESETS) break; + + presets[presetCount].lat = line.substring(0, comma).toDouble(); + presets[presetCount].lon = line.substring(comma + 1).toDouble(); + presetCount++; + } + f.close(); + + if (activeIdx >= (int8_t)presetCount) activeIdx = -1; + } +} + +void init() { + loadFromSd(); +} + +uint8_t count() { return presetCount; } + +void getLatLon(uint8_t index, double& lat, double& lon) { + if (index >= presetCount) return; + lat = presets[index].lat; + lon = presets[index].lon; +} + +bool addPreset(double lat, double lon) { + if (presetCount >= MAX_PRESETS) return false; + presets[presetCount].lat = lat; + presets[presetCount].lon = lon; + presetCount++; + saveToSd(); + return true; +} + +void removePreset(uint8_t index) { + if (index >= presetCount) return; + for (uint8_t i = index; i < presetCount - 1; i++) { + presets[i] = presets[i + 1]; + } + presetCount--; + presets[presetCount] = Preset{}; + + if (activeIdx == (int8_t)index) { + activeIdx = -1; + } else if (activeIdx > (int8_t)index) { + activeIdx--; + } + saveToSd(); +} + +int8_t activeIndex() { return activeIdx; } + +void setActiveIndex(int8_t index) { + if (index >= (int8_t)presetCount) return; + activeIdx = index; + saveToSd(); +} + +} \ No newline at end of file diff --git a/src/location_presets.h b/src/location_presets.h new file mode 100644 index 0000000..b5fe982 --- /dev/null +++ b/src/location_presets.h @@ -0,0 +1,17 @@ +#pragma once +#include + +namespace LocationPresets { + constexpr uint8_t MAX_PRESETS = 3; + + void init(); + + uint8_t count(); + void getLatLon(uint8_t index, double& lat, double& lon); + + bool addPreset(double lat, double lon); + void removePreset(uint8_t index); + + int8_t activeIndex(); + void setActiveIndex(int8_t index); +} \ No newline at end of file diff --git a/src/location_presets_screen.cpp b/src/location_presets_screen.cpp new file mode 100644 index 0000000..7fe2564 --- /dev/null +++ b/src/location_presets_screen.cpp @@ -0,0 +1,215 @@ +#include "location_presets_screen.h" +#include "location_presets.h" +#include "touch_input.h" +#include "config.h" +#include "i18n.h" + +namespace LocationPresetsScreen { + +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, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); + tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, bg); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + tft.setTextDatum(TL_DATUM); + } + + String runNumericKeypad(TFT_eSPI& tft, const String& title) { + char buf[16] = {0}; + uint8_t len = 0; + + constexpr int16_t KEY_W = 74; + constexpr int16_t KEY_H = 40; + constexpr int16_t KEY_GAP = 4; + constexpr int16_t GRID_LEFT = (Config::SCREEN_WIDTH - 3 * KEY_W - 2 * KEY_GAP) / 2; + constexpr int16_t GRID_TOP = 90; + + const char* keys[12] = {"1","2","3","4","5","6","7","8","9","-","0","."}; + Rect keyRects[12]; + for (uint8_t i = 0; i < 12; i++) { + int16_t col = i % 3; + int16_t row = i / 3; + keyRects[i] = {(int16_t)(GRID_LEFT + col * (KEY_W + KEY_GAP)), + (int16_t)(GRID_TOP + row * (KEY_H + KEY_GAP)), + KEY_W, KEY_H}; + } + + Rect backspaceBtn = {(int16_t)GRID_LEFT, (int16_t)(GRID_TOP + 4 * (KEY_H + KEY_GAP)), + (int16_t)(3 * KEY_W + 2 * KEY_GAP), 36}; + Rect cancelBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), 110, 40}; + Rect confirmBtn = {(int16_t)(Config::SCREEN_WIDTH - 120), (int16_t)(Config::SCREEN_HEIGHT - 50), 110, 40}; + + bool done = false; + bool confirmed = false; + + auto redraw = [&]() { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println(title); + + tft.fillRect(8, 40, Config::SCREEN_WIDTH - 16, 34, TFT_NAVY); + tft.drawRect(8, 40, Config::SCREEN_WIDTH - 16, 34, TFT_DARKGREY); + tft.setTextSize(2); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.setCursor(14, 49); + tft.print(buf); + tft.setTextSize(1); + + for (uint8_t i = 0; i < 12; i++) drawButton(tft, keyRects[i], keys[i]); + drawButton(tft, backspaceBtn, "<- Backspace"); + drawButton(tft, cancelBtn, I18n::t(StringId::CANCEL), TFT_MAROON); + drawButton(tft, confirmBtn, I18n::t(StringId::OK)); + }; + + redraw(); + + while (!done) { + TouchInput::Point tap; + if (!TouchInput::wasTapped(tap)) { delay(20); continue; } + + bool handled = false; + for (uint8_t i = 0; i < 12 && !handled; i++) { + if (keyRects[i].contains(tap.x, tap.y) && len < sizeof(buf) - 1) { + buf[len++] = keys[i][0]; + buf[len] = 0; + handled = true; + } + } + if (!handled && backspaceBtn.contains(tap.x, tap.y)) { + if (len > 0) { len--; buf[len] = 0; } + handled = true; + } + if (!handled && cancelBtn.contains(tap.x, tap.y)) { + done = true; + confirmed = false; + handled = true; + } + if (!handled && confirmBtn.contains(tap.x, tap.y) && len > 0) { + done = true; + confirmed = true; + handled = true; + } + + if (handled) redraw(); + } + + return confirmed ? String(buf) : String(); + } + + bool addPresetFlow(TFT_eSPI& tft) { + String latStr = runNumericKeypad(tft, I18n::t(StringId::LOCATION_LAT_PROMPT)); + if (latStr.length() == 0) return false; + + String lonStr = runNumericKeypad(tft, I18n::t(StringId::LOCATION_LON_PROMPT)); + if (lonStr.length() == 0) return false; + + double lat = latStr.toDouble(); + double lon = lonStr.toDouble(); + if (lat == 0.0 && lon == 0.0) return false; + + return LocationPresets::addPreset(lat, lon); + } +} + +void run(TFT_eSPI& tft) { + constexpr int16_t ROW_H = 32; + constexpr int16_t ROW_GAP = 6; + constexpr int16_t REMOVE_BTN_W = 60; + + bool done = false; + while (!done) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 8); + tft.println(I18n::t(StringId::LOCATION_TITLE)); + + int8_t active = LocationPresets::activeIndex(); + uint8_t count = LocationPresets::count(); + int16_t y = 36; + + Rect autoRect = {10, y, (int16_t)(Config::SCREEN_WIDTH - 20), ROW_H}; + String autoLabel = I18n::t(StringId::LOCATION_AUTO); + drawButton(tft, autoRect, active < 0 ? ("> " + autoLabel) : autoLabel, + active < 0 ? TFT_DARKGREEN : TFT_NAVY); + y += ROW_H + ROW_GAP; + + Rect rowRects[LocationPresets::MAX_PRESETS]; + Rect removeRects[LocationPresets::MAX_PRESETS]; + String presetWord = I18n::t(StringId::LOCATION_PRESET); + + for (uint8_t i = 0; i < LocationPresets::MAX_PRESETS; i++) { + Rect rowRect = {10, y, (int16_t)(Config::SCREEN_WIDTH - 20 - REMOVE_BTN_W - 6), ROW_H}; + Rect removeRect = {(int16_t)(Config::SCREEN_WIDTH - 10 - REMOVE_BTN_W), y, REMOVE_BTN_W, ROW_H}; + rowRects[i] = rowRect; + removeRects[i] = removeRect; + + if (i < count) { + double lat = 0, lon = 0; + LocationPresets::getLatLon(i, lat, lon); + char coords[24]; + snprintf(coords, sizeof(coords), "%d: %.3f, %.3f", i + 1, lat, lon); + String label = (active == (int8_t)i ? "> " : "") + presetWord + " " + coords; + drawButton(tft, rowRect, label, (active == (int8_t)i) ? TFT_DARKGREEN : TFT_NAVY); + drawButton(tft, removeRect, "X", TFT_MAROON); + } else { + tft.fillRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_BLACK); + tft.drawRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + String label = presetWord + " " + String(i + 1) + " " + I18n::t(StringId::LOCATION_PRESET_EMPTY); + tft.drawString(label, rowRect.x + rowRect.w / 2, rowRect.y + rowRect.h / 2); + tft.setTextDatum(TL_DATUM); + } + y += ROW_H + ROW_GAP; + } + + Rect addBtn = {10, y, (int16_t)(Config::SCREEN_WIDTH - 20), 36}; + bool canAdd = count < LocationPresets::MAX_PRESETS; + if (canAdd) { + drawButton(tft, addBtn, I18n::t(StringId::LOCATION_ADD)); + } + + 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; + delay(20); + } + + bool handled = false; + if (autoRect.contains(tap.x, tap.y)) { + LocationPresets::setActiveIndex(-1); + handled = true; + } + for (uint8_t i = 0; i < count && !handled; i++) { + if (removeRects[i].contains(tap.x, tap.y)) { + LocationPresets::removePreset(i); + handled = true; + } else if (rowRects[i].contains(tap.x, tap.y)) { + LocationPresets::setActiveIndex((int8_t)i); + handled = true; + } + } + if (!handled && canAdd && addBtn.contains(tap.x, tap.y)) { + addPresetFlow(tft); + handled = true; + } + if (!handled && backBtn.contains(tap.x, tap.y)) { + done = true; + } + } +} + +} \ No newline at end of file diff --git a/src/location_presets_screen.h b/src/location_presets_screen.h new file mode 100644 index 0000000..7e453d9 --- /dev/null +++ b/src/location_presets_screen.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +namespace LocationPresetsScreen { + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/logbook_files_screen.cpp b/src/logbook_files_screen.cpp index 7b33c83..573a2f2 100644 --- a/src/logbook_files_screen.cpp +++ b/src/logbook_files_screen.cpp @@ -2,6 +2,7 @@ #include "flight_logbook.h" #include "touch_input.h" #include "config.h" +#include "i18n.h" namespace LogbookFilesScreen { @@ -27,6 +28,14 @@ namespace { } void run(TFT_eSPI& tft) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 8); + tft.println(I18n::t(StringId::LOGFILES_TITLE)); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.setCursor(10, 30); + tft.print(I18n::t(StringId::LOADING)); + FlightLogbook::DayEntry days[MAX_DAYS_QUERIED]; uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED); @@ -35,12 +44,12 @@ void run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(10, 8); - tft.println("Logbook files"); + tft.println(I18n::t(StringId::LOGFILES_TITLE)); if (count == 0) { tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setCursor(10, 40); - tft.println("No logbook entries yet."); + tft.println(I18n::t(StringId::LOGFILES_EMPTY)); } else { uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0; int16_t y = 30; @@ -48,7 +57,8 @@ void run(TFT_eSPI& tft) { 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); + tft.print(String(I18n::t(StringId::LOGFILES_SHOWING_PREFIX)) + VISIBLE_ROWS + + I18n::t(StringId::LOGFILES_OF) + count + I18n::t(StringId::LOGFILES_DAYS_SUFFIX)); y += 16; } @@ -58,12 +68,12 @@ void run(TFT_eSPI& tft) { tft.print(days[i].date); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(110, y); - tft.printf("%lu aircraft", (unsigned long)days[i].count); + tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX)); y += 20; } } - drawButton(tft, backBtn, "Back"); + drawButton(tft, backBtn, I18n::t(StringId::BACK)); bool done = false; while (!done) { diff --git a/src/main.cpp b/src/main.cpp index 5b1246b..46c6d53 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,9 +10,11 @@ #include "radar_math.h" #include "aircraft_table.h" #include "airline_lookup.h" +#include "airline_filter.h" #include "sd_storage.h" #include "wifi_manager.h" #include "location_manager.h" +#include "location_presets.h" #include "adsb_client.h" #include "touch_input.h" #include "calibration_screen.h" @@ -25,12 +27,11 @@ #include "led_alert.h" #include "flight_logbook.h" #include "screenshot.h" +#include "i18n.h" +#include "first_run_language_screen.h" TFT_eSPI tft = TFT_eSPI(); -// Schlanker Header (Titel + Buttons) PLUS eine zweite, duenne Statuszeile -// (Uhrzeit + WLAN-Signalstaerke) darunter - der Radar-Kreis bekommt den Rest -// 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; @@ -45,6 +46,12 @@ bool forceRedraw = false; bool wasEmergency = false; bool bannerBlinkOn = false; +constexpr uint8_t BACKLIGHT_FULL = 255; +constexpr uint8_t BACKLIGHT_DIMMED = 12; +constexpr uint8_t BACKLIGHT_PWM_CHANNEL = 0; +uint32_t lastInteractionMs = 0; +bool screenDimmed = false; + struct Rect { int16_t x, y, w, h; bool contains(int16_t px, int16_t py) const { @@ -117,10 +124,10 @@ void takeScreenshotWithFeedback() { tft.setTextColor(TFT_WHITE, TFT_NAVY); tft.setCursor(6, 10); if (filename.length() > 0) { - tft.print("Saved: " + filename); + tft.print(String(I18n::t(StringId::SCREENSHOT_SAVED_PREFIX)) + filename); } else { tft.setTextColor(TFT_RED, TFT_NAVY); - tft.print("Screenshot failed"); + tft.print(I18n::t(StringId::SCREENSHOT_FAILED)); } delay(1200); drawHeader(); @@ -137,13 +144,13 @@ void haltWithSdRequiredScreen() { tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(2); - tft.drawString("For this app a", cx, cy - 40); - tft.drawString("SD card is", cx, cy - 10); - tft.drawString("required", cx, cy + 20); + tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE1), cx, cy - 40); + tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE2), cx, cy - 10); + tft.drawString(I18n::t(StringId::SD_REQUIRED_LINE3), cx, cy + 20); tft.setTextSize(1); tft.setTextColor(TFT_GREEN, TFT_BLACK); - tft.drawString("Insert a card and restart the device", cx, cy + 60); + tft.drawString(I18n::t(StringId::SD_REQUIRED_HINT), cx, cy + 60); tft.setTextDatum(TL_DATUM); while (true) { @@ -176,8 +183,13 @@ void setup() { tft.init(); tft.setRotation(0); + tft.invertDisplay(true); tft.fillScreen(TFT_BLACK); + ledcSetup(BACKLIGHT_PWM_CHANNEL, 5000, 8); + ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CHANNEL); + ledcWrite(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_FULL); + TouchInput::begin(); LedAlert::begin(); @@ -188,13 +200,21 @@ void setup() { } SdStorage::seedDefaultDataFiles(); + // "Allererster Start" erkennen: config.txt existiert noch nicht (wird + // erst beim ersten SettingsStore::save()-Aufruf angelegt). Muss VOR + // SettingsStore::load() geprueft werden, da load() die Datei nicht + // erzeugt, nur liest. + bool isFirstRun = !SD.exists(Config::SD_SETTINGS_FILE); + SettingsStore::load(); tft.invertDisplay(SettingsStore::displayInverted()); WifiMgr::init(); + LocationPresets::init(); + AirlineFilter::init(); SplashScreen::begin(tft); - SplashScreen::setStatusLine(tft, 0, "SD Card: OK", TFT_WHITE); + SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE); if (!TouchInput::loadCalibration()) { CalibrationScreen::run(tft); @@ -203,7 +223,7 @@ void setup() { if (WifiMgr::networkCount() == 0) { WifiSetupScreen::run(tft); } else { - SplashScreen::setStatusLine(tft, 1, "Connecting WiFi..."); + SplashScreen::setStatusLine(tft, 1, I18n::t(StringId::SPLASH_CONNECTING_WIFI)); WifiMgr::beginConnect(); uint32_t waitStart = millis(); @@ -213,15 +233,24 @@ void setup() { } if (WifiMgr::getState() == WifiMgr::State::Connected) { - SplashScreen::setStatusLine(tft, 1, String("WiFi OK: ") + WifiMgr::getIP()); + SplashScreen::setStatusLine(tft, 1, String(I18n::t(StringId::SPLASH_WIFI_OK_PREFIX)) + WifiMgr::getIP()); } else { - SplashScreen::setStatusLine(tft, 1, "WiFi FAILED", TFT_RED); + SplashScreen::setStatusLine(tft, 1, I18n::t(StringId::SPLASH_WIFI_FAILED), TFT_RED); } } + // --- Sprachauswahl: NUR beim allerersten Start, direkt nach Touch- + // Kalibrierung und WLAN-Ersteinrichtung, bevor irgendwelche weiteren + // Texte in (falscher) Standardsprache gezeigt werden. --- + if (isFirstRun) { + FirstRunLanguageScreen::run(tft); + SplashScreen::begin(tft); + SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE); + } + AdsbClient::primeTime(); - SplashScreen::setStatusLine(tft, 2, "Getting location..."); + SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_GETTING_LOCATION)); LocationManager::init(); uint32_t locStart = millis(); while (LocationManager::currentSource() == LocationManager::Source::None && @@ -229,7 +258,7 @@ void setup() { LocationManager::requestIpLookupIfNeeded(); delay(200); } - SplashScreen::setStatusLine(tft, 2, "Ready!"); + SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY)); if (LocationManager::hasUtcOffset()) { configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov"); @@ -246,11 +275,25 @@ void setup() { drawHeader(); updateStatusLine(); RadarScreen::render(tft, CONTENT_TOP); + + lastInteractionMs = millis(); } void loop() { TouchInput::Point tap; - if (TouchInput::wasTapped(tap)) { + bool tapped = TouchInput::wasTapped(tap); + + if (tapped) { + lastInteractionMs = millis(); + + if (screenDimmed) { + ledcWrite(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_FULL); + screenDimmed = false; + tapped = false; + } + } + + if (tapped) { if (menuBtn.contains(tap.x, tap.y)) { MenuScreen::run(tft); drawHeader(); @@ -291,4 +334,13 @@ void loop() { lastStatusLineMs = nowMs; updateStatusLine(); } + + uint8_t timeoutMin = SettingsStore::screenTimeoutMinutes(); + if (!screenDimmed && timeoutMin > 0) { + uint32_t timeoutMs = (uint32_t)timeoutMin * 60000UL; + if (nowMs - lastInteractionMs >= timeoutMs) { + ledcWrite(BACKLIGHT_PWM_CHANNEL, BACKLIGHT_DIMMED); + screenDimmed = true; + } + } } \ No newline at end of file diff --git a/src/menu_screen.cpp b/src/menu_screen.cpp index 6378ce7..26a6631 100644 --- a/src/menu_screen.cpp +++ b/src/menu_screen.cpp @@ -4,7 +4,13 @@ #include "wifi_manage_screen.h" #include "stats_screen.h" #include "logbook_files_screen.h" +#include "location_presets_screen.h" +#include "airline_filter_screen.h" +#include "language_screen.h" +#include "units_screen.h" +#include "settings_backup.h" #include "settings_store.h" +#include "i18n.h" #include "config.h" namespace MenuScreen { @@ -17,8 +23,8 @@ namespace { } }; - constexpr int16_t ROW_H = 24; - constexpr int16_t ROW_GAP = 2; + constexpr int16_t ROW_H = 26; + constexpr int16_t ROW_GAP = 3; constexpr int16_t ROW_START_Y = 20; Rect rowRect(uint8_t index) { @@ -26,83 +32,177 @@ namespace { (int16_t)(Config::SCREEN_WIDTH - 20), ROW_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); + void drawButton(TFT_eSPI& tft, const Rect& r, const String& label, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); tft.setTextDatum(MC_DATUM); - tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.setTextColor(TFT_WHITE, bg); tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); tft.setTextDatum(TL_DATUM); } - String onOff(bool on) { return on ? "ON" : "OFF"; } + String onOff(bool on) { return I18n::t(on ? StringId::ON : StringId::OFF); } + + String screenTimeoutLabel(uint8_t minutes) { + String prefix = I18n::t(StringId::MENU_SCREEN_TIMEOUT_PREFIX); + if (minutes == 0) return prefix + I18n::t(StringId::NEVER); + return prefix + String(minutes) + " min"; + } + + 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.setTextColor(color, TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.drawString(msg, Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT - 9); + tft.setTextDatum(TL_DATUM); + delay(1200); + } } void run(TFT_eSPI& tft) { - Rect calibBtn = rowRect(0); - Rect invertBtn = rowRect(1); - Rect wifiBtn = rowRect(2); - Rect emergencyBtn = rowRect(3); - Rect proximityBtn = rowRect(4); - Rect heartbeatBtn = rowRect(5); - Rect logbookBtn = rowRect(6); - Rect statsBtn = rowRect(7); - Rect logFilesBtn = rowRect(8); - Rect backBtn = rowRect(9); - + uint8_t page = 0; bool done = false; + while (!done) { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setCursor(10, 4); - tft.println("Settings"); + tft.setCursor(10, 2); + tft.println(page == 0 ? I18n::t(StringId::MENU_SETTINGS) : I18n::t(StringId::MENU_SETTINGS_PAGE2)); - drawButton(tft, calibBtn, "Calibrate touch"); + if (page == 0) { + Rect calibBtn = rowRect(0); + Rect invertBtn = rowRect(1); + Rect wifiBtn = rowRect(2); + Rect locationBtn = rowRect(3); + Rect timeoutBtn = rowRect(4); + Rect statsBtn = rowRect(5); + Rect logFilesBtn = rowRect(6); + Rect moreBtn = rowRect(7); + Rect backBtn = rowRect(8); - String invertLabel = SettingsStore::displayInverted() - ? "Display: inverted (tap)" - : "Display: normal (tap)"; - drawButton(tft, invertBtn, invertLabel); + drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE)); - drawButton(tft, wifiBtn, "Manage WiFi networks"); + String invertLabel = SettingsStore::displayInverted() + ? I18n::t(StringId::MENU_DISPLAY_INVERTED) + : I18n::t(StringId::MENU_DISPLAY_NORMAL); + drawButton(tft, invertBtn, invertLabel); - drawButton(tft, emergencyBtn, "Emergency alert: " + onOff(SettingsStore::emergencyAlertEnabled())); - drawButton(tft, proximityBtn, "Proximity LED: " + onOff(SettingsStore::proximityAlertEnabled())); - drawButton(tft, heartbeatBtn, "LED heartbeat: " + onOff(SettingsStore::ledHeartbeatEnabled())); - drawButton(tft, logbookBtn, "Flight logbook: " + onOff(SettingsStore::flightLogbookEnabled())); - drawButton(tft, statsBtn, "Statistics"); - drawButton(tft, logFilesBtn, "Logbook files"); + drawButton(tft, wifiBtn, I18n::t(StringId::MENU_MANAGE_WIFI)); + drawButton(tft, locationBtn, I18n::t(StringId::MENU_LOCATION_PRESETS)); + drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes())); + drawButton(tft, statsBtn, I18n::t(StringId::MENU_STATISTICS)); + drawButton(tft, logFilesBtn, I18n::t(StringId::MENU_LOGBOOK_FILES)); + drawButton(tft, moreBtn, I18n::t(StringId::MENU_MORE_SETTINGS)); + drawButton(tft, backBtn, I18n::t(StringId::BACK)); - drawButton(tft, backBtn, "Back"); + TouchInput::Point tap; + while (true) { + if (TouchInput::wasTapped(tap)) break; + delay(20); + } - TouchInput::Point tap; - while (true) { - if (TouchInput::wasTapped(tap)) break; - delay(20); - } + if (calibBtn.contains(tap.x, tap.y)) { + CalibrationScreen::run(tft); + } else if (invertBtn.contains(tap.x, tap.y)) { + bool newState = !SettingsStore::displayInverted(); + SettingsStore::setDisplayInverted(newState); + tft.invertDisplay(newState); + } else if (wifiBtn.contains(tap.x, tap.y)) { + WifiManageScreen::run(tft); + } else if (locationBtn.contains(tap.x, tap.y)) { + LocationPresetsScreen::run(tft); + } else if (timeoutBtn.contains(tap.x, tap.y)) { + uint8_t current = SettingsStore::screenTimeoutMinutes(); + uint8_t next = (current >= 10) ? 0 : (current + 1); + SettingsStore::setScreenTimeoutMinutes(next); + } else if (statsBtn.contains(tap.x, tap.y)) { + StatsScreen::run(tft); + } else if (logFilesBtn.contains(tap.x, tap.y)) { + LogbookFilesScreen::run(tft); + } else if (moreBtn.contains(tap.x, tap.y)) { + page = 1; + } else if (backBtn.contains(tap.x, tap.y)) { + done = true; + } + } else if (page == 1) { + Rect emergencyBtn = rowRect(0); + Rect proximityBtn = rowRect(1); + Rect heartbeatBtn = rowRect(2); + Rect logbookBtn = rowRect(3); + Rect groundBtn = rowRect(4); + Rect airlineBtn = rowRect(5); + Rect moreBtn = rowRect(6); + Rect backBtn = rowRect(7); - if (calibBtn.contains(tap.x, tap.y)) { - CalibrationScreen::run(tft); - } else if (invertBtn.contains(tap.x, tap.y)) { - bool newState = !SettingsStore::displayInverted(); - SettingsStore::setDisplayInverted(newState); - tft.invertDisplay(newState); - } else if (wifiBtn.contains(tap.x, tap.y)) { - WifiManageScreen::run(tft); - } else if (emergencyBtn.contains(tap.x, tap.y)) { - SettingsStore::setEmergencyAlertEnabled(!SettingsStore::emergencyAlertEnabled()); - } else if (proximityBtn.contains(tap.x, tap.y)) { - SettingsStore::setProximityAlertEnabled(!SettingsStore::proximityAlertEnabled()); - } else if (heartbeatBtn.contains(tap.x, tap.y)) { - SettingsStore::setLedHeartbeatEnabled(!SettingsStore::ledHeartbeatEnabled()); - } 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 (logFilesBtn.contains(tap.x, tap.y)) { - LogbookFilesScreen::run(tft); - } else if (backBtn.contains(tap.x, tap.y)) { - done = true; + drawButton(tft, emergencyBtn, I18n::t(StringId::MENU_EMERGENCY_ALERT) + onOff(SettingsStore::emergencyAlertEnabled())); + drawButton(tft, proximityBtn, I18n::t(StringId::MENU_PROXIMITY_LED) + onOff(SettingsStore::proximityAlertEnabled())); + drawButton(tft, heartbeatBtn, I18n::t(StringId::MENU_LED_HEARTBEAT) + onOff(SettingsStore::ledHeartbeatEnabled())); + drawButton(tft, logbookBtn, I18n::t(StringId::MENU_FLIGHT_LOGBOOK) + onOff(SettingsStore::flightLogbookEnabled())); + drawButton(tft, groundBtn, I18n::t(StringId::MENU_HIDE_GROUND) + onOff(SettingsStore::hideGroundVehicles())); + drawButton(tft, airlineBtn, I18n::t(StringId::MENU_AIRLINE_FILTER)); + drawButton(tft, moreBtn, I18n::t(StringId::MENU_MORE_SETTINGS)); + drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW)); + + TouchInput::Point tap; + while (true) { + if (TouchInput::wasTapped(tap)) break; + delay(20); + } + + if (emergencyBtn.contains(tap.x, tap.y)) { + SettingsStore::setEmergencyAlertEnabled(!SettingsStore::emergencyAlertEnabled()); + } else if (proximityBtn.contains(tap.x, tap.y)) { + SettingsStore::setProximityAlertEnabled(!SettingsStore::proximityAlertEnabled()); + } else if (heartbeatBtn.contains(tap.x, tap.y)) { + SettingsStore::setLedHeartbeatEnabled(!SettingsStore::ledHeartbeatEnabled()); + } else if (logbookBtn.contains(tap.x, tap.y)) { + SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled()); + } else if (groundBtn.contains(tap.x, tap.y)) { + SettingsStore::setHideGroundVehicles(!SettingsStore::hideGroundVehicles()); + } else if (airlineBtn.contains(tap.x, tap.y)) { + AirlineFilterScreen::run(tft); + } else if (moreBtn.contains(tap.x, tap.y)) { + page = 2; + } else if (backBtn.contains(tap.x, tap.y)) { + page = 0; + } + } else { + Rect languageBtn = rowRect(0); + Rect unitsBtn = rowRect(1); + Rect backupBtn = rowRect(2); + Rect restoreBtn = rowRect(3); + Rect backBtn = rowRect(4); + + drawButton(tft, languageBtn, String(I18n::t(StringId::MENU_LANGUAGE)) + ": " + I18n::languageName(SettingsStore::language())); + drawButton(tft, unitsBtn, I18n::t(StringId::MENU_UNITS)); + drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP)); + drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE), + SettingsBackup::hasBackup() ? TFT_NAVY : TFT_DARKGREY); + drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW)); + + TouchInput::Point tap; + while (true) { + if (TouchInput::wasTapped(tap)) break; + delay(20); + } + + if (languageBtn.contains(tap.x, tap.y)) { + LanguageScreen::run(tft); + } else if (unitsBtn.contains(tap.x, tap.y)) { + UnitsScreen::run(tft); + } else if (backupBtn.contains(tap.x, tap.y)) { + bool ok = SettingsBackup::backup(); + showBriefMessage(tft, I18n::t(ok ? StringId::MENU_BACKUP_SAVED : StringId::MENU_BACKUP_FAILED), + ok ? TFT_GREEN : TFT_RED); + } else if (restoreBtn.contains(tap.x, tap.y)) { + if (SettingsBackup::hasBackup()) { + bool ok = SettingsBackup::restore(); + showBriefMessage(tft, I18n::t(ok ? StringId::MENU_RESTORED : StringId::MENU_RESTORE_FAILED), + ok ? TFT_GREEN : TFT_RED); + } + } else if (backBtn.contains(tap.x, tap.y)) { + page = 1; + } } } } diff --git a/src/menu_screen.h b/src/menu_screen.h index 9ebc550..503ae81 100644 --- a/src/menu_screen.h +++ b/src/menu_screen.h @@ -3,7 +3,8 @@ #include namespace MenuScreen { - // Blockierend: einfaches Menue (aktuell nur "Touch kalibrieren"). + // Blockierend: einfaches Menue (Kalibrierung, Anzeige-Invertierung, + // WLAN-Verwaltung, Alarm-Toggles, Statistik, Logbuch-Dateien). // Kehrt zurueck, sobald "Zurueck" angetippt wird. void run(TFT_eSPI& tft); } \ No newline at end of file diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 471c16a..acc2dc7 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -10,6 +10,8 @@ #include "led_alert.h" #include "location_manager.h" #include "world_map.h" +#include "airline_filter.h" +#include "i18n.h" #include namespace RadarScreen { @@ -42,7 +44,7 @@ namespace { return L; } - constexpr int16_t DETAIL_PANEL_H = 150; + constexpr int16_t DETAIL_PANEL_H = 190; struct HitPoint { int16_t x, y; @@ -212,15 +214,8 @@ namespace { } } - // Dezente Weltkarten-Silhouette als Hintergrund-Layer, wie bei einem - // klassischen ATC-Radarschirm - rein dekorativ (nicht massstabsgetreu - // zu den Entfernungsringen). Wird auf das quadratische Kreis-Layout - // gestreckt (die Karte ist 2:1, der Kreis-Bereich 1:1 - das verzerrt - // die Kontinente etwas in der Hoehe, faellt aber bei dieser Groesse - // kaum auf und haelt die Zeichenlogik einfach). Wird NUR bei render() - // gezeichnet, NICHT bei jedem Sweep-Tick (zu teuer fuer 12x/Sekunde). void drawWorldMap(TFT_eSPI& gfx, const Layout& L) { - constexpr uint16_t dim = 0x0320; // sehr gedaempftes Gruen + constexpr uint16_t dim = 0x0320; float scaleX = (2.0f * L.radius) / WorldMap::GRID_W; float scaleY = (2.0f * L.radius) / WorldMap::GRID_H; int32_t radiusSq = (int32_t)(L.radius - 2) * (L.radius - 2); @@ -274,8 +269,8 @@ namespace { struct PanelState { bool valid = false; char hex[7] = {0}; - String callsignText, airlineText, modelText, - altText, speedText, distHeadingText, seatsText; + String callsignText, airlineText, modelText, typeText, + altText, speedText, climbText, distHeadingText, squawkText, seatsText; }; PanelState lastPanel; @@ -322,43 +317,59 @@ namespace { String(a.airlineName), forceFull); y += 15; - String modelLine; - if (details.loading) { - modelLine = "Model: loading..."; - } else if (details.model[0]) { - modelLine = String("Model: ") + details.model; - } else { - modelLine = String("Type: ") + (a.typeCode[0] ? a.typeCode : "unknown"); - } + String modelLine = details.loading + ? String(I18n::t(StringId::DETAIL_MODEL)) + I18n::t(StringId::DETAIL_LOADING_DOTS) + : String(I18n::t(StringId::DETAIL_MODEL)) + (details.model[0] ? details.model : I18n::t(StringId::DETAIL_UNKNOWN)); updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.modelText, modelLine, forceFull); y += 15; - char buf[40]; - snprintf(buf, sizeof(buf), "Alt: %.0fm / %.0fft", + String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN)); + updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.typeText, typeLine, forceFull); + y += 15; + + char buf[48]; + snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT), Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt); updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.altText, String(buf), forceFull); y += 15; - snprintf(buf, sizeof(buf), "Speed: %.0fkm/h / %.0fkt", + snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED), Units::ktToKmh(a.groundSpeedKt), a.groundSpeedKt); updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.speedText, String(buf), forceFull); y += 15; - snprintf(buf, sizeof(buf), "Dist: %.0fkm / %.0fnm Hdg: %.0f", - a.distanceKm, Units::kmToNm(a.distanceKm), a.headingDeg); + String climbLine; + if (a.vertRateFtMin > 100) { + snprintf(buf, sizeof(buf), "%s+%dft/min", I18n::t(StringId::DETAIL_CLIMB), a.vertRateFtMin); + climbLine = buf; + } else if (a.vertRateFtMin < -100) { + snprintf(buf, sizeof(buf), "%s%dft/min", I18n::t(StringId::DETAIL_DESCENT), a.vertRateFtMin); + climbLine = buf; + } else { + climbLine = I18n::t(StringId::DETAIL_LEVEL); + } + updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.climbText, climbLine, forceFull); + y += 15; + + snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm %s%.0f", I18n::t(StringId::DETAIL_DIST), + a.distanceKm, Units::kmToNm(a.distanceKm), I18n::t(StringId::DETAIL_HDG), a.headingDeg); updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.distHeadingText, String(buf), forceFull); y += 15; + String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN)); + updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.squawkText, squawkLine, forceFull); + y += 15; + String seatsLine = a.estSeats > 0 - ? String("Seats (estimated): ") + a.estSeats - : String("Seats: unknown"); + ? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats + : String(I18n::t(StringId::DETAIL_SEATS_UNKNOWN)); updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.seatsText, seatsLine, forceFull); y += 18; if (forceFull) { gfx.setTextColor(TFT_DARKGREY, TFT_NAVY); gfx.setCursor(8, y); - gfx.print("Tap elsewhere to close"); + gfx.print(I18n::t(StringId::DETAIL_TAP_CLOSE)); } lastPanel.valid = true; @@ -429,6 +440,10 @@ void render(TFT_eSPI& tft, int16_t top) { Aircraft& a = snapshot[i]; if (a.distanceKm > rangeKm * 1.05f) continue; + if (SettingsStore::hideGroundVehicles() && a.category[0] == 'C') continue; + + if (AirlineFilter::isHidden(a.callsign)) continue; + RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); @@ -482,7 +497,7 @@ void render(TFT_eSPI& tft, int16_t top) { tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(8, infoTop + 6); - tft.print("Tap for details"); + tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS)); char rangeLabel[8]; snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 8086130..8baff67 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -1,5 +1,6 @@ #include "screenshot.h" #include "config.h" +#include "sd_mutex.h" #include #include @@ -15,6 +16,8 @@ namespace { } String save(TFT_eSPI& tft) { + SdMutex::Guard guard; + if (!SD.exists(Config::SD_SCREENSHOT_DIR)) { SD.mkdir(Config::SD_SCREENSHOT_DIR); } diff --git a/src/sd_mutex.cpp b/src/sd_mutex.cpp new file mode 100644 index 0000000..602b903 --- /dev/null +++ b/src/sd_mutex.cpp @@ -0,0 +1,28 @@ +#include "sd_mutex.h" +#include +#include + +namespace SdMutex { + +namespace { + SemaphoreHandle_t mutex = nullptr; +} + +void init() { + if (mutex == nullptr) { + mutex = xSemaphoreCreateRecursiveMutex(); + } +} + +void lock() { + if (mutex == nullptr) init(); + xSemaphoreTakeRecursive(mutex, portMAX_DELAY); +} + +void unlock() { + if (mutex != nullptr) { + xSemaphoreGiveRecursive(mutex); + } +} + +} \ No newline at end of file diff --git a/src/sd_mutex.h b/src/sd_mutex.h new file mode 100644 index 0000000..f791cad --- /dev/null +++ b/src/sd_mutex.h @@ -0,0 +1,27 @@ +#pragma once +#include + +// Gemeinsames Lock fuer ALLE SD-Kartenzugriffe im gesamten Projekt. Die +// SD-Bibliothek ist nicht thread-sicher: ohne dieses Lock koennen +// gleichzeitige Zugriffe von NetTask (Core 0, z.B. Flugbuch-Eintrag +// schreiben alle 8s) und dem Haupt-Loop (Core 1, z.B. Einstellungen im +// Menue speichern) das Geraet einfrieren, wenn sie zeitlich zusammentreffen. +// Rekursiv, damit verschachtelte Aufrufe (z.B. FlightLogbook::update() ruft +// intern ensureCurrentDate() auf, das seinerseits auch sperrt) nicht +// blockieren. +namespace SdMutex { + void init(); + void lock(); + void unlock(); + + // RAII-Helfer: sperrt im Konstruktor, gibt im Destruktor frei - so kann + // man das Lock nicht versehentlich vergessen freizugeben (z.B. bei + // einem fruehen "return"). + class Guard { + public: + Guard() { lock(); } + ~Guard() { unlock(); } + Guard(const Guard&) = delete; + Guard& operator=(const Guard&) = delete; + }; +} \ No newline at end of file diff --git a/src/sd_storage.cpp b/src/sd_storage.cpp index 9294072..55bf28b 100644 --- a/src/sd_storage.cpp +++ b/src/sd_storage.cpp @@ -1,5 +1,6 @@ #include "sd_storage.h" #include "config.h" +#include "sd_mutex.h" #include #include #include @@ -64,6 +65,9 @@ namespace { } bool init() { + SdMutex::init(); + SdMutex::Guard guard; + sdSpi.begin(Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN, Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN); mounted = SD.begin(Config::SD_SPI_CS_PIN, sdSpi, 4000000); @@ -79,12 +83,14 @@ bool isMounted() { return mounted; } void seedDefaultDataFiles() { if (!mounted) return; + SdMutex::Guard guard; writeIfAbsent(Config::SD_AIRLINES_CSV, kDefaultAirlinesCsv); writeIfAbsent(Config::SD_AIRCRAFT_TYPES_CSV, kDefaultAircraftTypesCsv); } void logEvent(const char* csvLine) { if (!mounted) return; + SdMutex::Guard guard; time_t now = time(nullptr); struct tm tmNow; diff --git a/src/settings_backup.cpp b/src/settings_backup.cpp new file mode 100644 index 0000000..0d73851 --- /dev/null +++ b/src/settings_backup.cpp @@ -0,0 +1,60 @@ +#include "settings_backup.h" +#include "config.h" +#include "sd_mutex.h" +#include "sd_storage.h" +#include + +namespace SettingsBackup { + +namespace { + constexpr const char* WIFI_BACKUP_FILE = "/Flightradar_cyd/wifi_backup.txt"; + constexpr const char* SETTINGS_BACKUP_FILE = "/Flightradar_cyd/config_backup.txt"; + + bool copyFile(const char* srcPath, const char* dstPath) { + if (!SD.exists(srcPath)) return false; + + File src = SD.open(srcPath, FILE_READ); + if (!src) return false; + + File dst = SD.open(dstPath, FILE_WRITE); + if (!dst) { src.close(); return false; } + + constexpr size_t BUF_SIZE = 512; + static uint8_t buf[BUF_SIZE]; + while (src.available()) { + size_t n = src.read(buf, BUF_SIZE); + dst.write(buf, n); + yield(); + } + + src.close(); + dst.close(); + return true; + } +} + +bool backup() { + if (!SdStorage::isMounted()) return false; + SdMutex::Guard guard; + + bool okSettings = copyFile(Config::SD_SETTINGS_FILE, SETTINGS_BACKUP_FILE); + bool okWifi = copyFile(Config::SD_WIFI_CREDENTIALS_FILE, WIFI_BACKUP_FILE); + return okSettings || okWifi; +} + +bool restore() { + if (!SdStorage::isMounted()) return false; + SdMutex::Guard guard; + + bool okSettings = copyFile(SETTINGS_BACKUP_FILE, Config::SD_SETTINGS_FILE); + bool okWifi = copyFile(WIFI_BACKUP_FILE, Config::SD_WIFI_CREDENTIALS_FILE); + return okSettings || okWifi; +} + +bool hasBackup() { + if (!SdStorage::isMounted()) return false; + SdMutex::Guard guard; + return SD.exists(SETTINGS_BACKUP_FILE) || SD.exists(WIFI_BACKUP_FILE); +} + +} \ No newline at end of file diff --git a/src/settings_backup.h b/src/settings_backup.h new file mode 100644 index 0000000..bf23e32 --- /dev/null +++ b/src/settings_backup.h @@ -0,0 +1,8 @@ +#pragma once +#include + +namespace SettingsBackup { + bool backup(); + bool restore(); + bool hasBackup(); +} \ No newline at end of file diff --git a/src/settings_store.cpp b/src/settings_store.cpp index b778dbf..952315d 100644 --- a/src/settings_store.cpp +++ b/src/settings_store.cpp @@ -1,16 +1,21 @@ #include "settings_store.h" #include "config.h" +#include "sd_mutex.h" #include namespace SettingsStore { namespace { uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX; - bool inverted = false; + bool inverted = true; // Dieses Board braucht invertDisplay(true) fuer korrekte Farben (siehe main.cpp) bool emergencyAlertOn = true; bool proximityAlertOn = true; bool flightLogbookOn = true; bool ledHeartbeatOn = true; + uint8_t screenTimeoutMin = 0; + bool hideGroundVehiclesOn = true; + uint8_t languageIdx = 0; + uint8_t unitsModeVal = 0; void applyKeyValue(const String& key, const String& value) { if (key == "range_index") { @@ -28,11 +33,24 @@ namespace { flightLogbookOn = (value.toInt() != 0); } else if (key == "led_heartbeat") { ledHeartbeatOn = (value.toInt() != 0); + } else if (key == "screen_timeout_min") { + int v = value.toInt(); + if (v >= 0 && v <= 10) screenTimeoutMin = (uint8_t)v; + } else if (key == "hide_ground_vehicles") { + hideGroundVehiclesOn = (value.toInt() != 0); + } else if (key == "language") { + int v = value.toInt(); + if (v >= 0 && v <= 5) languageIdx = (uint8_t)v; + } else if (key == "units_mode") { + int v = value.toInt(); + if (v >= 0 && v <= 2) unitsModeVal = (uint8_t)v; } } } void load() { + SdMutex::Guard guard; + if (!SD.exists(Config::SD_SETTINGS_FILE)) return; File f = SD.open(Config::SD_SETTINGS_FILE, FILE_READ); @@ -56,6 +74,8 @@ void load() { } void save() { + SdMutex::Guard guard; + File f = SD.open(Config::SD_SETTINGS_FILE, FILE_WRITE); if (!f) return; f.printf("range_index=%d\n", rangeIdx); @@ -64,6 +84,10 @@ void save() { f.printf("proximity_alert=%d\n", proximityAlertOn ? 1 : 0); f.printf("flight_logbook=%d\n", flightLogbookOn ? 1 : 0); f.printf("led_heartbeat=%d\n", ledHeartbeatOn ? 1 : 0); + f.printf("screen_timeout_min=%d\n", screenTimeoutMin); + f.printf("hide_ground_vehicles=%d\n", hideGroundVehiclesOn ? 1 : 0); + f.printf("language=%d\n", languageIdx); + f.printf("units_mode=%d\n", unitsModeVal); f.close(); } @@ -111,4 +135,38 @@ void setLedHeartbeatEnabled(bool on) { save(); } +uint8_t screenTimeoutMinutes() { return screenTimeoutMin; } + +void setScreenTimeoutMinutes(uint8_t minutes) { + if (minutes <= 10) { + screenTimeoutMin = minutes; + save(); + } +} + +bool hideGroundVehicles() { return hideGroundVehiclesOn; } + +void setHideGroundVehicles(bool on) { + hideGroundVehiclesOn = on; + save(); +} + +uint8_t language() { return languageIdx; } + +void setLanguage(uint8_t lang) { + if (lang <= 5) { + languageIdx = lang; + save(); + } +} + +uint8_t unitsMode() { return unitsModeVal; } + +void setUnitsMode(uint8_t mode) { + if (mode <= 2) { + unitsModeVal = mode; + save(); + } +} + } \ No newline at end of file diff --git a/src/settings_store.h b/src/settings_store.h index cd21b35..1d5df3a 100644 --- a/src/settings_store.h +++ b/src/settings_store.h @@ -20,9 +20,21 @@ namespace SettingsStore { bool flightLogbookEnabled(); void setFlightLogbookEnabled(bool on); - // Kurzes gruenes Aufblitzen der Rueckseiten-LED synchron zu jeder - // ADS-B-Abfrage (alle 8s) - zeigt "Geraet ist aktiv", auch wenn gerade - // kein Naeherungs-/Notfall-Alarm laeuft. bool ledHeartbeatEnabled(); void setLedHeartbeatEnabled(bool on); + + uint8_t screenTimeoutMinutes(); + void setScreenTimeoutMinutes(uint8_t minutes); + + bool hideGroundVehicles(); + void setHideGroundVehicles(bool on); + + // Sprache der Benutzeroberflaeche: 0=EN,1=DE,2=FR,3=TR,4=ES,5=IT. + uint8_t language(); + void setLanguage(uint8_t lang); + + // Einheiten-Modus: 0=Auto (per IP-Standort geschaetzt), 1=Metrisch + // erzwingen, 2=Imperial (Fuss/Knoten/Meilen) erzwingen. + uint8_t unitsMode(); + void setUnitsMode(uint8_t mode); } \ No newline at end of file diff --git a/src/stats_screen.cpp b/src/stats_screen.cpp index 3a8aedb..9e9d1da 100644 --- a/src/stats_screen.cpp +++ b/src/stats_screen.cpp @@ -2,6 +2,7 @@ #include "flight_logbook.h" #include "touch_input.h" #include "config.h" +#include "i18n.h" namespace StatsScreen { @@ -13,11 +14,11 @@ namespace { } }; - void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) { - tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_NAVY); + void drawButton(TFT_eSPI& tft, const Rect& r, const String& label, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); tft.setTextDatum(MC_DATUM); - tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.setTextColor(TFT_WHITE, bg); tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); tft.setTextDatum(TL_DATUM); } @@ -32,39 +33,97 @@ namespace { tft.print(value); tft.setTextSize(1); } + + constexpr uint32_t CONFIRM_WINDOW_MS = 4000; } void run(TFT_eSPI& tft) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println(I18n::t(StringId::STATS_TITLE)); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.setCursor(10, 44); + tft.print(I18n::t(StringId::LOADING)); + 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}; + Rect resetBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 100), (int16_t)(Config::SCREEN_WIDTH - 20), 40}; + 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"); + bool confirmPending = false; + uint32_t confirmArmedAtMs = 0; + bool justReset = false; - 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)); + auto redraw = [&]() { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println(I18n::t(StringId::STATS_TITLE)); - 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)); - } + drawStatRow(tft, 44, I18n::t(StringId::STATS_TODAY), String(today)); + drawStatRow(tft, 90, I18n::t(StringId::STATS_ALLTIME), String(allTimeAircraft)); + drawStatRow(tft, 136, I18n::t(StringId::STATS_DAYS), String(allTimeDays)); - drawButton(tft, backBtn, "Back"); + if (justReset) { + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setCursor(10, 182); + tft.print(I18n::t(StringId::STATS_RESET_DONE)); + } else if (allTimeDays > 0) { + float avgPerDay = (float)allTimeAircraft / (float)allTimeDays; + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f", avgPerDay); + drawStatRow(tft, 182, I18n::t(StringId::STATS_AVG), String(buf)); + } + + uint32_t upSec = millis() / 1000; + uint32_t upH = upSec / 3600; + uint32_t upM = (upSec % 3600) / 60; + char upBuf[8]; + snprintf(upBuf, sizeof(upBuf), "%luh %lum", (unsigned long)upH, (unsigned long)upM); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.setCursor(10, 210); + tft.print(String(I18n::t(StringId::STATS_UPTIME_PREFIX)) + upBuf); + + if (confirmPending) { + drawButton(tft, resetBtn, I18n::t(StringId::STATS_RESET_CONFIRM), TFT_MAROON); + } else { + drawButton(tft, resetBtn, I18n::t(StringId::STATS_RESET_BTN)); + } + drawButton(tft, backBtn, I18n::t(StringId::BACK)); + }; + + redraw(); bool done = false; while (!done) { + if (confirmPending && millis() - confirmArmedAtMs > CONFIRM_WINDOW_MS) { + confirmPending = false; + redraw(); + } + TouchInput::Point tap; - if (TouchInput::wasTapped(tap) && backBtn.contains(tap.x, tap.y)) { - done = true; + if (TouchInput::wasTapped(tap)) { + if (resetBtn.contains(tap.x, tap.y)) { + if (confirmPending) { + FlightLogbook::resetAllData(); + today = 0; + allTimeAircraft = 0; + allTimeDays = 0; + confirmPending = false; + justReset = true; + redraw(); + } else { + confirmPending = true; + confirmArmedAtMs = millis(); + redraw(); + } + } else if (backBtn.contains(tap.x, tap.y)) { + done = true; + } } delay(20); } diff --git a/src/units_screen.cpp b/src/units_screen.cpp new file mode 100644 index 0000000..117c8f1 --- /dev/null +++ b/src/units_screen.cpp @@ -0,0 +1,78 @@ +#include "units_screen.h" +#include "settings_store.h" +#include "touch_input.h" +#include "i18n.h" +#include "config.h" + +namespace UnitsScreen { + +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, uint16_t bg = TFT_NAVY) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg); + tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, bg); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + tft.setTextDatum(TL_DATUM); + } + + constexpr int16_t ROW_H = 40; + constexpr int16_t ROW_GAP = 8; + constexpr int16_t START_Y = 40; + + Rect rowRect(uint8_t index) { + return {10, (int16_t)(START_Y + index * (ROW_H + ROW_GAP)), + (int16_t)(Config::SCREEN_WIDTH - 20), ROW_H}; + } +} + +void run(TFT_eSPI& tft) { + constexpr uint8_t MODE_COUNT = 3; + StringId labels[MODE_COUNT] = {StringId::UNITS_AUTO, StringId::UNITS_METRIC, StringId::UNITS_IMPERIAL}; + + bool done = false; + while (!done) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println(I18n::t(StringId::UNITS_TITLE)); + + uint8_t current = SettingsStore::unitsMode(); + Rect modeRects[MODE_COUNT]; + + for (uint8_t i = 0; i < MODE_COUNT; i++) { + modeRects[i] = rowRect(i); + drawButton(tft, modeRects[i], I18n::t(labels[i]), + (i == current) ? TFT_DARKGREEN : TFT_NAVY); + } + + Rect backBtn = rowRect(MODE_COUNT); + drawButton(tft, backBtn, I18n::t(StringId::BACK)); + + TouchInput::Point tap; + while (true) { + if (TouchInput::wasTapped(tap)) break; + delay(20); + } + + bool handled = false; + for (uint8_t i = 0; i < MODE_COUNT && !handled; i++) { + if (modeRects[i].contains(tap.x, tap.y)) { + SettingsStore::setUnitsMode(i); + handled = true; + } + } + if (!handled && backBtn.contains(tap.x, tap.y)) { + done = true; + } + } +} + +} \ No newline at end of file diff --git a/src/units_screen.h b/src/units_screen.h new file mode 100644 index 0000000..57ca760 --- /dev/null +++ b/src/units_screen.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +namespace UnitsScreen { + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp index 31db9a1..a30476e 100644 --- a/src/wifi_manager.cpp +++ b/src/wifi_manager.cpp @@ -1,5 +1,6 @@ #include "wifi_manager.h" #include "sd_storage.h" +#include "sd_mutex.h" #include #include #include @@ -25,12 +26,6 @@ namespace { uint32_t lastReconnectAttemptMs = 0; 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; @@ -44,6 +39,9 @@ namespace { void loadFromSd() { networkCountVal = 0; if (!SdStorage::isMounted()) return; + + SdMutex::Guard guard; + if (!SD.exists(Config::SD_WIFI_CREDENTIALS_FILE)) return; File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_READ); @@ -66,6 +64,8 @@ namespace { void saveToSd() { if (!SdStorage::isMounted()) return; + SdMutex::Guard guard; + File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_WRITE); if (!f) return; for (uint8_t i = 0; i < networkCountVal; i++) { @@ -75,11 +75,6 @@ namespace { 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); @@ -160,12 +155,6 @@ void beginConnect() { 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); int8_t chosen = -1; @@ -209,10 +198,6 @@ void update() { 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 (reconnectScanPending) { tryReconnectAsync(); diff --git a/src/wifi_setup_screen.cpp b/src/wifi_setup_screen.cpp index 0cb5a68..52f0e9d 100644 --- a/src/wifi_setup_screen.cpp +++ b/src/wifi_setup_screen.cpp @@ -2,6 +2,7 @@ #include "touch_input.h" #include "wifi_manager.h" #include "config.h" +#include "i18n.h" namespace WifiSetupScreen { @@ -185,7 +186,7 @@ bool run(TFT_eSPI& tft) { tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextSize(1); tft.setCursor(10, 10); - tft.println("Scanning WiFi..."); + tft.println(I18n::t(StringId::WIFI_SCANNING)); drawCancelButton(tft); while (!skipped) { @@ -206,7 +207,7 @@ bool run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setCursor(10, 10); tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.println(ssidCount == 0 ? "No networks found" : "Select WiFi:"); + tft.println(ssidCount == 0 ? I18n::t(StringId::WIFI_NO_NETWORKS) : I18n::t(StringId::WIFI_SELECT)); drawCancelButton(tft); } @@ -219,7 +220,7 @@ bool run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setCursor(10, 10); tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.println("Connected!"); + tft.println(I18n::t(StringId::WIFI_CONNECTED_BANG)); delay(900); return true; } else if (WifiMgr::getState() == WifiMgr::State::Failed) { @@ -227,10 +228,10 @@ bool run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setCursor(10, 10); tft.setTextColor(TFT_RED, TFT_BLACK); - tft.println("Connection failed."); + tft.println(I18n::t(StringId::WIFI_CONNECTION_FAILED)); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(10, 30); - tft.println("Back to network list..."); + tft.println(I18n::t(StringId::WIFI_BACK_TO_LIST_MSG)); delay(1400); stage = Stage::PickSsid; WifiMgr::beginScan(); @@ -292,10 +293,10 @@ bool run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setCursor(10, 10); tft.setTextColor(TFT_RED, TFT_BLACK); - tft.println("Already 3 networks saved."); + tft.println(I18n::t(StringId::WIFI_ALREADY_3)); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(10, 30); - tft.println("Remove one first."); + tft.println(I18n::t(StringId::WIFI_REMOVE_ONE_FIRST)); delay(1600); skipped = true; break; @@ -305,7 +306,7 @@ bool run(TFT_eSPI& tft) { tft.fillScreen(TFT_BLACK); tft.setCursor(10, 10); tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.println("Connecting..."); + tft.println(I18n::t(StringId::WIFI_CONNECTING)); } else if (backBtn.contains(tap.x, tap.y)) { stage = Stage::PickSsid; needsRedraw = true; @@ -339,10 +340,10 @@ bool run(TFT_eSPI& tft) { tft.fillRect(0, 0, Config::SCREEN_WIDTH, KB_TOP - 4, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(10, 6); - tft.printf("WiFi: %s", ssidList[selectedIndex].c_str()); + tft.printf("%s%s", I18n::t(StringId::WIFI_LABEL_PREFIX), ssidList[selectedIndex].c_str()); tft.setCursor(10, 22); tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.println("Password:"); + tft.println(I18n::t(StringId::WIFI_PASSWORD_LABEL)); tft.fillRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_NAVY); tft.drawRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_DARKGREY); tft.setCursor(12, 44); @@ -367,9 +368,9 @@ bool run(TFT_eSPI& tft) { Rect spaceBtn = {SIDE_MARGIN, rowYFn, 150, ROW_H}; Rect connectBtn = {SIDE_MARGIN + 154, rowYFn, Config::SCREEN_WIDTH - 2*SIDE_MARGIN - 154, ROW_H}; Rect backBtn = {SIDE_MARGIN, (int16_t)(rowYFn + ROW_H + ROW_GAP), (int16_t)(Config::SCREEN_WIDTH - 2*SIDE_MARGIN), 28}; - drawButton(tft, spaceBtn, "Space"); - drawButton(tft, connectBtn, "Connect"); - drawButton(tft, backBtn, "Back to list"); + drawButton(tft, spaceBtn, I18n::t(StringId::WIFI_SPACE)); + drawButton(tft, connectBtn, I18n::t(StringId::WIFI_CONNECT)); + drawButton(tft, backBtn, I18n::t(StringId::WIFI_BACK_TO_LIST_BTN)); } delay(20);