v2.0: Full localization (6 languages), first-run language screen, aircraft detail overhaul, aircraft-model API fallback, and multiple bugfixes

This commit is contained in:
Eiswolf-BG
2026-08-04 01:32:44 +02:00
parent 4e8bfcd30c
commit 665130e8bb
45 changed files with 2705 additions and 240 deletions

View File

@@ -1,11 +1,10 @@
#include <WiFi.h>
#include "adsb_client.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
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);

View File

@@ -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;
};

View File

@@ -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;

114
src/airline_filter.cpp Normal file
View File

@@ -0,0 +1,114 @@
#include "airline_filter.h"
#include "config.h"
#include "sd_mutex.h"
#include "sd_storage.h"
#include <SD.h>
#include <cstring>
#include <cctype>
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;
}
}

16
src/airline_filter.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <Arduino.h>
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);
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace AirlineFilterScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace FirstRunLanguageScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -3,6 +3,7 @@
#include "aircraft.h"
#include "aircraft_table.h"
#include "settings_store.h"
#include "sd_mutex.h"
#include <SD.h>
#include <time.h>
#include <cstring>
@@ -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();
}
}

View File

@@ -1,12 +1,6 @@
#pragma once
#include <Arduino.h>
// 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();
}

37
src/i18n.cpp Normal file
View File

@@ -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];
}
}

155
src/i18n.h Normal file
View File

@@ -0,0 +1,155 @@
#pragma once
#include <Arduino.h>
// 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);
}

138
src/i18n_de.h Normal file
View File

@@ -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");

138
src/i18n_en.h Normal file
View File

@@ -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");

138
src/i18n_es.h Normal file
View File

@@ -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");

138
src/i18n_fr.h Normal file
View File

@@ -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");

138
src/i18n_it.h Normal file
View File

@@ -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");

138
src/i18n_tr.h Normal file
View File

@@ -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");

75
src/language_screen.cpp Normal file
View File

@@ -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;
}
}
}
}

7
src/language_screen.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace LanguageScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -1,5 +1,7 @@
#include "location_manager.h"
#include "config.h"
#include "location_presets.h"
#include "settings_store.h"
#include <TinyGPSPlus.h>
#include <HardwareSerial.h>
#include <WiFi.h>
@@ -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;
}
}

117
src/location_presets.cpp Normal file
View File

@@ -0,0 +1,117 @@
#include "location_presets.h"
#include "config.h"
#include "sd_mutex.h"
#include "sd_storage.h"
#include <SD.h>
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();
}
}

17
src/location_presets.h Normal file
View File

@@ -0,0 +1,17 @@
#pragma once
#include <Arduino.h>
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);
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace LocationPresetsScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -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) {

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -3,7 +3,8 @@
#include <TFT_eSPI.h>
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);
}

View File

@@ -10,6 +10,8 @@
#include "led_alert.h"
#include "location_manager.h"
#include "world_map.h"
#include "airline_filter.h"
#include "i18n.h"
#include <math.h>
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);

View File

@@ -1,5 +1,6 @@
#include "screenshot.h"
#include "config.h"
#include "sd_mutex.h"
#include <SD.h>
#include <time.h>
@@ -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);
}

28
src/sd_mutex.cpp Normal file
View File

@@ -0,0 +1,28 @@
#include "sd_mutex.h"
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
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);
}
}
}

27
src/sd_mutex.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
// 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;
};
}

View File

@@ -1,5 +1,6 @@
#include "sd_storage.h"
#include "config.h"
#include "sd_mutex.h"
#include <SD.h>
#include <SPI.h>
#include <time.h>
@@ -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;

60
src/settings_backup.cpp Normal file
View File

@@ -0,0 +1,60 @@
#include "settings_backup.h"
#include "config.h"
#include "sd_mutex.h"
#include "sd_storage.h"
#include <SD.h>
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);
}
}

8
src/settings_backup.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
#include <Arduino.h>
namespace SettingsBackup {
bool backup();
bool restore();
bool hasBackup();
}

View File

@@ -1,16 +1,21 @@
#include "settings_store.h"
#include "config.h"
#include "sd_mutex.h"
#include <SD.h>
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();
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

78
src/units_screen.cpp Normal file
View File

@@ -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;
}
}
}
}

7
src/units_screen.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace UnitsScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -1,5 +1,6 @@
#include "wifi_manager.h"
#include "sd_storage.h"
#include "sd_mutex.h"
#include <WiFi.h>
#include <SD.h>
#include <freertos/FreeRTOS.h>
@@ -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();

View File

@@ -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);