v1.2: Settings toggles, flight logbook, local time, WiFi auto-reconnect, metric units

This commit is contained in:
Eiswolf-BG
2026-08-03 04:16:08 +02:00
parent 0de99ef1c5
commit b913ca2f44
22 changed files with 817 additions and 229 deletions

View File

@@ -68,6 +68,7 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm,
filterAc["baro_rate"]= true; filterAc["baro_rate"]= true;
filterAc["gs"] = true; filterAc["gs"] = true;
filterAc["track"] = true; filterAc["track"] = true;
filterAc["squawk"] = true;
JsonDocument doc; JsonDocument doc;
DeserializationError err = deserializeJson( DeserializationError err = deserializeJson(
@@ -113,6 +114,9 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm,
a.groundSpeedKt = ac["gs"] | 0.0f; a.groundSpeedKt = ac["gs"] | 0.0f;
a.headingDeg = ac["track"] | 0.0f; a.headingDeg = ac["track"] | 0.0f;
const char* squawk = ac["squawk"] | "";
strncpy(a.squawk, squawk, sizeof(a.squawk) - 1);
a.lastSeenMs = millis(); a.lastSeenMs = millis();
a.valid = (a.lat != 0.0f || a.lon != 0.0f); a.valid = (a.lat != 0.0f || a.lon != 0.0f);

View File

@@ -5,6 +5,7 @@ struct Aircraft {
char callsign[9] = {0}; char callsign[9] = {0};
char reg[9] = {0}; char reg[9] = {0};
char typeCode[5] = {0}; char typeCode[5] = {0};
char squawk[5] = {0}; // 4-stelliger Transponder-Code, z.B. "7700" (Notfall)
float lat = 0; float lat = 0;
float lon = 0; float lon = 0;

View File

@@ -3,10 +3,8 @@
namespace Config { namespace Config {
constexpr const char* IP_GEO_HOST = "ip-api.com"; constexpr const char* IP_GEO_HOST = "ip-api.com";
constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon"; constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon,offset,countryCode";
// Kein GPS-Modul am CYD angeschlossen - Platzhalter, falls spaeter eins
// per Erweiterungspins nachgeruestet wird.
struct GpsPinPair { uint8_t rx; uint8_t tx; const char* label; }; struct GpsPinPair { uint8_t rx; uint8_t tx; const char* label; };
constexpr GpsPinPair GPS_PIN_CANDIDATES[] = { constexpr GpsPinPair GPS_PIN_CANDIDATES[] = {
{22, 27, "G22/G27"} {22, 27, "G22/G27"}
@@ -25,16 +23,11 @@ namespace Config {
constexpr float DEFAULT_PROXIMITY_ALERT_KM = 8.0f; constexpr float DEFAULT_PROXIMITY_ALERT_KM = 8.0f;
// Radius, innerhalb dessen die rueckseitige RGB-LED (gruen) blinkt, um auf
// ein nahes Flugzeug hinzuweisen (Ersatz fuer den fehlenden Lautsprecher
// beim CYD).
constexpr float LED_ALERT_RADIUS_KM = 3.0f; constexpr float LED_ALERT_RADIUS_KM = 3.0f;
constexpr uint32_t ALERT_RETRIGGER_COOLDOWN_MS = 30000; constexpr uint32_t ALERT_RETRIGGER_COOLDOWN_MS = 30000;
constexpr uint8_t MAX_TRACKED_AIRCRAFT = 40; constexpr uint8_t MAX_TRACKED_AIRCRAFT = 40;
// Eigener Ordner, getrennt vom Cardputer-Projekt (das nutzt /adsb_radar) -
// so kann dieselbe SD-Karte in beiden Geraeten verwendet werden.
constexpr const char* SD_ROOT_DIR = "/Flightradar_cyd"; constexpr const char* SD_ROOT_DIR = "/Flightradar_cyd";
constexpr const char* SD_AIRLINES_CSV = "/Flightradar_cyd/airlines.csv"; constexpr const char* SD_AIRLINES_CSV = "/Flightradar_cyd/airlines.csv";
constexpr const char* SD_AIRCRAFT_TYPES_CSV = "/Flightradar_cyd/aircraft_types.csv"; constexpr const char* SD_AIRCRAFT_TYPES_CSV = "/Flightradar_cyd/aircraft_types.csv";
@@ -43,13 +36,11 @@ namespace Config {
constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt"; constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt";
constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt"; constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt";
// microSD-Slot beim CYD (ESP32-2432S028): eigener SPI-Bus, Standard-VSPI-Pins.
constexpr uint8_t SD_SPI_CS_PIN = 5; constexpr uint8_t SD_SPI_CS_PIN = 5;
constexpr uint8_t SD_SPI_MOSI_PIN = 23; constexpr uint8_t SD_SPI_MOSI_PIN = 23;
constexpr uint8_t SD_SPI_MISO_PIN = 19; constexpr uint8_t SD_SPI_MISO_PIN = 19;
constexpr uint8_t SD_SPI_CLK_PIN = 18; constexpr uint8_t SD_SPI_CLK_PIN = 18;
// Touch-Controller (XPT2046), eigener SPI-Bus, getrennt von Display und SD.
constexpr uint8_t TOUCH_CLK_PIN = 25; constexpr uint8_t TOUCH_CLK_PIN = 25;
constexpr uint8_t TOUCH_CS_PIN = 33; constexpr uint8_t TOUCH_CS_PIN = 33;
constexpr uint8_t TOUCH_MOSI_PIN = 32; constexpr uint8_t TOUCH_MOSI_PIN = 32;
@@ -59,7 +50,6 @@ namespace Config {
constexpr int16_t SCREEN_WIDTH = 240; constexpr int16_t SCREEN_WIDTH = 240;
constexpr int16_t SCREEN_HEIGHT = 320; constexpr int16_t SCREEN_HEIGHT = 320;
// Fuer spaetere Naeherungs-Alarme (Phase 4: Bildschirmrand blinkt statt LED)
constexpr float ZONE_BLUE_KM = 25.0f; constexpr float ZONE_BLUE_KM = 25.0f;
constexpr float ZONE_YELLOW_KM = 10.0f; constexpr float ZONE_YELLOW_KM = 10.0f;
constexpr float ZONE_AMBER_KM = 5.0f; constexpr float ZONE_AMBER_KM = 5.0f;
@@ -67,4 +57,11 @@ namespace Config {
constexpr uint16_t COLOR_LOW_ALT_THRESHOLD_FT = 10000; constexpr uint16_t COLOR_LOW_ALT_THRESHOLD_FT = 10000;
constexpr uint16_t COLOR_MID_ALT_THRESHOLD_FT = 30000; constexpr uint16_t COLOR_MID_ALT_THRESHOLD_FT = 30000;
// Notfall-Squawk-Codes: 7500 (Entfuehrung), 7600 (Funkausfall), 7700 (allg. Notfall)
constexpr const char* EMERGENCY_SQUAWKS[] = {"7500", "7600", "7700"};
constexpr uint8_t EMERGENCY_SQUAWK_COUNT = 3;
// Maximal gespeicherte WLAN-Netzwerke (z.B. Zuhause, Auto-Hotspot, Arbeit)
constexpr uint8_t MAX_WIFI_NETWORKS = 3;
} }

143
src/flight_logbook.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "flight_logbook.h"
#include "config.h"
#include "aircraft.h"
#include "aircraft_table.h"
#include "settings_store.h"
#include <SD.h>
#include <time.h>
#include <cstring>
namespace FlightLogbook {
namespace {
constexpr uint16_t MAX_SEEN = 400;
char seenHex[MAX_SEEN][7];
uint16_t seenCount = 0;
char currentDateStr[11] = {0}; // "YYYY-MM-DD"
bool computeDateStr(char* out, size_t outSize) {
time_t now = time(nullptr);
if (now < 8 * 3600 * 2) return false;
struct tm tmNow;
localtime_r(&now, &tmNow);
snprintf(out, outSize, "%04d-%02d-%02d", tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday);
return true;
}
void logFilename(char* out, size_t outSize) {
snprintf(out, outSize, "%s/%s.csv", Config::SD_LOG_DIR, currentDateStr);
}
bool alreadySeen(const char* hex) {
for (uint16_t i = 0; i < seenCount; i++) {
if (strcmp(seenHex[i], hex) == 0) return true;
}
return false;
}
void markSeen(const char* hex) {
if (seenCount >= MAX_SEEN) return;
strncpy(seenHex[seenCount], hex, sizeof(seenHex[seenCount]) - 1);
seenHex[seenCount][sizeof(seenHex[seenCount]) - 1] = 0;
seenCount++;
}
void loadSeenFromTodayFile() {
seenCount = 0;
char filename[64];
logFilename(filename, sizeof(filename));
if (!SD.exists(filename)) return;
File f = SD.open(filename, FILE_READ);
if (!f) return;
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; }
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());
}
f.close();
}
void ensureCurrentDate() {
char today[11];
if (!computeDateStr(today, sizeof(today))) return;
if (strcmp(today, currentDateStr) != 0) {
strncpy(currentDateStr, today, sizeof(currentDateStr) - 1);
loadSeenFromTodayFile();
}
}
void writeLogLine(const Aircraft& a) {
char filename[64];
logFilename(filename, sizeof(filename));
bool needsHeader = !SD.exists(filename);
File f = SD.open(filename, FILE_APPEND);
if (!f) return;
if (needsHeader) {
f.println("timestamp,hex,callsign,reg,type,distance_km,altitude_ft");
}
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
char timestamp[20];
snprintf(timestamp, sizeof(timestamp), "%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
f.printf("%s,%s,%s,%s,%s,%.1f,%d\n",
timestamp,
a.hex,
a.callsign[0] ? a.callsign : "",
a.reg[0] ? a.reg : "",
a.typeCode[0] ? a.typeCode : "",
a.distanceKm,
(int)a.altBaroFt);
f.close();
}
}
void init() {
ensureCurrentDate();
}
void update() {
if (!SettingsStore::flightLogbookEnabled()) return;
ensureCurrentDate();
if (currentDateStr[0] == 0) return;
static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT];
uint8_t count = 0;
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (table[i].valid) snapshot[count++] = table[i];
}
AircraftTable::unlock();
for (uint8_t i = 0; i < count; i++) {
if (!snapshot[i].hex[0]) continue;
if (alreadySeen(snapshot[i].hex)) continue;
markSeen(snapshot[i].hex);
writeLogLine(snapshot[i]);
}
}
}

22
src/flight_logbook.h Normal file
View File

@@ -0,0 +1,22 @@
#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 {
// Einmalig beim Boot aufrufen, NACHDEM die lokale Uhrzeit (UTC-Offset)
// bekannt ist - liest die heutige CSV-Datei (falls vorhanden), um bereits
// geloggte Flugzeuge nicht doppelt einzutragen.
void init();
// Periodisch aufrufen (z.B. nach jeder erfolgreichen ADS-B-Abfrage):
// prueft auf neue, bisher ungesehene Flugzeuge und schreibt sie ins
// heutige Logbuch. Kuemmert sich auch um den Tageswechsel (neue Datei,
// neue "gesehen"-Liste).
void update();
}

View File

@@ -6,37 +6,58 @@ namespace {
constexpr uint8_t PIN_RED = 4; constexpr uint8_t PIN_RED = 4;
constexpr uint8_t PIN_GREEN = 16; constexpr uint8_t PIN_GREEN = 16;
constexpr uint8_t PIN_BLUE = 17; constexpr uint8_t PIN_BLUE = 17;
constexpr uint32_t BLINK_INTERVAL_MS = 400;
constexpr uint32_t GREEN_BLINK_INTERVAL_MS = 400;
constexpr uint32_t RED_BLINK_INTERVAL_MS = 150;
bool initialized = false; bool initialized = false;
bool blinkState = false; bool blinkState = false;
uint32_t lastToggleMs = 0; uint32_t lastToggleMs = 0;
Mode lastMode = Mode::Off;
void setAllOff() {
digitalWrite(PIN_RED, HIGH);
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_BLUE, HIGH);
}
} }
void begin() { void begin() {
pinMode(PIN_RED, OUTPUT); pinMode(PIN_RED, OUTPUT);
pinMode(PIN_GREEN, OUTPUT); pinMode(PIN_GREEN, OUTPUT);
pinMode(PIN_BLUE, OUTPUT); pinMode(PIN_BLUE, OUTPUT);
digitalWrite(PIN_RED, HIGH); // aus (LED ist active-low) setAllOff();
digitalWrite(PIN_GREEN, HIGH); // aus
digitalWrite(PIN_BLUE, HIGH); // aus
initialized = true; initialized = true;
} }
bool update(bool active, uint32_t nowMs) { bool update(Mode mode, uint32_t nowMs) {
if (!initialized) begin(); if (!initialized) begin();
if (!active) { if (mode == Mode::Off) {
digitalWrite(PIN_GREEN, HIGH); // aus setAllOff();
blinkState = false; blinkState = false;
lastMode = mode;
return false; return false;
} }
if (nowMs - lastToggleMs >= BLINK_INTERVAL_MS) { if (mode != lastMode) {
lastToggleMs = nowMs;
blinkState = true;
lastMode = mode;
}
uint32_t interval = (mode == Mode::EmergencyRed) ? RED_BLINK_INTERVAL_MS : GREEN_BLINK_INTERVAL_MS;
uint8_t pin = (mode == Mode::EmergencyRed) ? PIN_RED : PIN_GREEN;
if (nowMs - lastToggleMs >= interval) {
lastToggleMs = nowMs; lastToggleMs = nowMs;
blinkState = !blinkState; blinkState = !blinkState;
digitalWrite(PIN_GREEN, blinkState ? LOW : HIGH); // LOW = an, volle Helligkeit (kein PWM noetig)
} }
digitalWrite(pin, blinkState ? LOW : HIGH);
digitalWrite(mode == Mode::EmergencyRed ? PIN_GREEN : PIN_RED, HIGH);
digitalWrite(PIN_BLUE, HIGH);
return blinkState; return blinkState;
} }

View File

@@ -2,18 +2,22 @@
#include <Arduino.h> #include <Arduino.h>
// Steuert die diskrete RGB-LED auf der Rueckseite des CYD (kein Lautsprecher // Steuert die diskrete RGB-LED auf der Rueckseite des CYD (kein Lautsprecher
// vorhanden, daher ersetzt die LED den akustischen Naeherungsalarm vom // vorhanden, daher ersetzt die LED den akustischen Alarm vom Cardputer-
// Cardputer-Projekt). Pins: Rot=GPIO4, Gruen=GPIO16, Blau=GPIO17, // Projekt). Pins: Rot=GPIO4, Gruen=GPIO16, Blau=GPIO17, active-low (LOW = an).
// active-low (LOW = an).
namespace LedAlert { namespace LedAlert {
enum class Mode {
Off, // keine LED
ProximityGreen, // Flugzeug innerhalb des Naeherungsradius -> gruen blinkt
EmergencyRed, // Notfall-Squawk (7500/7600/7700) -> rot blinkt schneller, hat Vorrang
};
// Einmalig in setup() aufrufen. // Einmalig in setup() aufrufen.
void begin(); void begin();
// Haeufig aufrufen (z.B. alle 80-100ms). 'active' = mindestens ein // Haeufig aufrufen (z.B. alle 80-100ms). Schaltet die LED passend zum
// Flugzeug ist innerhalb des Alarmradius. Schaltet die gruene LED // Modus an/aus (blinkend, volle Helligkeit) und gibt den aktuellen
// entsprechend an/aus (blinkend, volle Helligkeit) und gibt den // Blink-Zustand zurueck (true = LED gerade an), damit der Radar-
// aktuellen Blink-Zustand zurueck (true = LED gerade an), damit der // Bildschirm z.B. den betroffenen Punkt synchron mitblinken lassen kann.
// Radar-Bildschirm den betroffenen Punkt synchron mitblinken lassen kann. bool update(Mode mode, uint32_t nowMs);
bool update(bool active, uint32_t nowMs);
} }

View File

@@ -18,7 +18,6 @@ namespace {
TinyGPSPlus gps; TinyGPSPlus gps;
HardwareSerial gpsSerial(1); HardwareSerial gpsSerial(1);
bool gpsEnabled = false; bool gpsEnabled = false;
uint8_t gpsPinIndex = 0; uint8_t gpsPinIndex = 0;
bool gpsSerialStarted = false; bool gpsSerialStarted = false;
@@ -32,9 +31,10 @@ namespace {
Source source = Source::None; Source source = Source::None;
// Schuetzt lastLat/lastLon/havePersisted/source: der Netzwerk-Task (Core 0) bool haveUtcOffset = false;
// schreibt diese Werte, der Render-Loop (Core 1) liest sie ueber int32_t utcOffsetSecs = 0;
// getHomeLocation()/currentSource(). bool metricUnits = true; // Standard: metrisch, bis die IP-Abfrage etwas anderes sagt
SemaphoreHandle_t mutex = nullptr; SemaphoreHandle_t mutex = nullptr;
void startGpsSerialIfNeeded() { void startGpsSerialIfNeeded() {
@@ -44,9 +44,6 @@ namespace {
gpsSerialStarted = true; gpsSerialStarted = true;
} }
// Schreibt lastLat/lastLon/havePersisted UND setzt 'source' gleich mit,
// damit beide unter demselben Lock aktualisiert werden (kein Zwischenzustand
// sichtbar fuer den lesenden Core).
void persistLocationAndSource(double lat, double lon, Source newSource) { void persistLocationAndSource(double lat, double lon, Source newSource) {
prefs.putDouble("homeLat", lat); prefs.putDouble("homeLat", lat);
prefs.putDouble("homeLon", lon); prefs.putDouble("homeLon", lon);
@@ -127,6 +124,14 @@ void requestIpLookupIfNeeded() {
double lon = doc["lon"] | 0.0; double lon = doc["lon"] | 0.0;
if (lat == 0.0 && lon == 0.0) return; if (lat == 0.0 && lon == 0.0) return;
utcOffsetSecs = doc["offset"] | 0;
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); persistLocationAndSource(lat, lon, Source::IpGeolocation);
ipLookupDone = true; ipLookupDone = true;
} }
@@ -180,4 +185,8 @@ const char* currentGpsPinLabel() {
bool hasGpsFix() { return gps.location.isValid(); } bool hasGpsFix() { return gps.location.isValid(); }
bool hasUtcOffset() { return haveUtcOffset; }
int32_t utcOffsetSeconds() { return utcOffsetSecs; }
bool useMetricUnits() { return metricUnits; }
} }

View File

@@ -20,4 +20,14 @@ namespace LocationManager {
bool hasGpsFix(); bool hasGpsFix();
// UTC-Offset in Sekunden (inkl. evtl. Sommerzeit), ermittelt bei der
// IP-Geolocation-Abfrage. 0/false, falls noch nicht bekannt.
bool hasUtcOffset();
int32_t utcOffsetSeconds();
// Ob die Region (per IP-Geolocation-Laendercode) metrische Einheiten
// nutzt (Meter/km) statt Fuss/Meilen. Default true (metrisch), bis die
// IP-Abfrage etwas anderes ermittelt hat - nur die USA nutzen aktuell
// eine Ausnahme.
bool useMetricUnits();
} }

View File

@@ -21,16 +21,19 @@
#include "radar_screen.h" #include "radar_screen.h"
#include "splash_screen.h" #include "splash_screen.h"
#include "led_alert.h" #include "led_alert.h"
#include "flight_logbook.h"
TFT_eSPI tft = TFT_eSPI(); TFT_eSPI tft = TFT_eSPI();
constexpr int16_t CONTENT_TOP = 30; constexpr int16_t CONTENT_TOP = 30; // schlanker Header, Radar bekommt den Rest des Screens
constexpr uint32_t POLL_INTERVAL_MS = 300; constexpr uint32_t POLL_INTERVAL_MS = 300; // wie oft wir NACHSCHAUEN, ob sich Daten geaendert haben
constexpr uint32_t SWEEP_TICK_MS = 80; constexpr uint32_t SWEEP_TICK_MS = 80; // wie oft der Sweep-Strahl ein Stueck weiterdreht
uint32_t lastPollMs = 0; uint32_t lastPollMs = 0;
uint32_t lastSweepMs = 0; uint32_t lastSweepMs = 0;
uint32_t lastRenderedVersion = 0xFFFFFFFF; uint32_t lastRenderedVersion = 0xFFFFFFFF; // erzwingt einen ersten Render-Aufruf
bool forceRedraw = false; bool forceRedraw = false;
bool wasEmergency = false;
bool bannerBlinkOn = false;
struct Rect { struct Rect {
int16_t x, y, w, h; int16_t x, y, w, h;
@@ -39,18 +42,21 @@ struct Rect {
} }
}; };
Rect menuBtn = {Config::SCREEN_WIDTH - 38, 3, 32, 22}; Rect menuBtn = {Config::SCREEN_WIDTH - 60, 3, 54, 22};
void drawMenuButton() { void drawMenuButton() {
tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_NAVY); tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_NAVY);
tft.drawRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_DARKGREY); tft.drawRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_DARKGREY);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_NAVY); tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.drawString("...", menuBtn.x + menuBtn.w / 2, menuBtn.y + menuBtn.h / 2); tft.drawString("Menu", menuBtn.x + menuBtn.w / 2, menuBtn.y + menuBtn.h / 2);
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
} }
void drawHeader() { void drawHeader() {
// Bis CONTENT_TOP loeschen, damit kein Bildrest vom Menue zwischen Header
// und Radar-Bildschirm haengen bleibt. Schlanker Header, damit der Radar-
// Kreis darunter maximal viel Platz bekommt.
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK); tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1); tft.setTextSize(1);
@@ -59,41 +65,99 @@ void drawHeader() {
drawMenuButton(); drawMenuButton();
} }
// Zeigt eine grosse, unmissverstaendliche Meldung und haelt das Geraet an -
// KEIN weiterer Bildschirm erscheint, solange keine SD-Karte steckt. Die App
// braucht die Karte fuer Einstellungen, WLAN-Zugangsdaten und Nachschlage-
// tabellen, daher macht ein Weiterlaufen ohne sie keinen Sinn.
void haltWithSdRequiredScreen() {
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
int16_t cx = Config::SCREEN_WIDTH / 2;
int16_t cy = Config::SCREEN_HEIGHT / 2;
tft.setTextColor(TFT_RED, 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.setTextSize(1);
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
tft.drawString("Insert a card and restart the device", cx, cy + 60);
tft.setTextDatum(TL_DATUM);
while (true) {
delay(1000); // haengen bleiben - absichtlich kein weiterer Screen
}
}
// Zeigt/versteckt ein blinkendes Notfall-Banner im Header-Bereich, wenn ein
// Flugzeug einen Notfall-Squawk (7500/7600/7700) sendet. Ersetzt kurzzeitig
// den normalen Titel; sobald der Notfall vorbei ist, wird drawHeader() genau
// einmal wieder aufgerufen, um den Titel sauber wiederherzustellen.
void updateEmergencyBanner(uint32_t nowMs) {
RadarScreen::EmergencyInfo emergency = RadarScreen::checkEmergency();
if (emergency.active) {
bannerBlinkOn = !bannerBlinkOn;
uint16_t bg = bannerBlinkOn ? TFT_RED : TFT_BLACK;
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, bg);
tft.setTextColor(TFT_WHITE, bg);
tft.setTextSize(1);
tft.setCursor(4, 10);
tft.printf("EMERGENCY %s %s", emergency.squawk, emergency.callsign);
wasEmergency = true;
} else if (wasEmergency) {
wasEmergency = false;
drawHeader();
}
}
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(300); delay(300);
tft.init(); tft.init();
tft.setRotation(0); tft.setRotation(0);
// Sofort loeschen, bevor irgendetwas anderes passiert (SD-Init etc.
// braucht einen Moment) - sonst zeigt das Display kurz zufaelligen
// Bildspeicher-Muell an, bevor der erste echte Screen gezeichnet wird.
tft.fillScreen(TFT_BLACK);
TouchInput::begin(); TouchInput::begin();
LedAlert::begin(); LedAlert::begin();
// --- SD-Karte: PFLICHT. Ohne Karte kein weiterer Screen. ---
bool sdOk = SdStorage::init(); bool sdOk = SdStorage::init();
if (sdOk) { if (!sdOk) {
SdStorage::seedDefaultDataFiles(); haltWithSdRequiredScreen();
return; // unerreichbar (haltWithSdRequiredScreen() haengt fuer immer), nur zur Klarheit
} }
SdStorage::seedDefaultDataFiles();
// Einstellungen (u.a. Display-Invertierung) VOR dem Splash laden und
// anwenden, damit der Splash selbst schon in der richtigen Ausrichtung
// gezeichnet wird (kein Farbwechsel mitten in der Anzeige).
SettingsStore::load(); SettingsStore::load();
tft.invertDisplay(SettingsStore::displayInverted()); tft.invertDisplay(SettingsStore::displayInverted());
WifiMgr::init(); WifiMgr::init(); // laedt die gespeicherten Netzwerke (bis zu 3) von der SD-Karte
// --- Splash-Screen: schwarzer Hintergrund, gruenes Flugzeug, mind. 5s ---
SplashScreen::begin(tft); SplashScreen::begin(tft);
SplashScreen::setStatusLine(tft, 0, sdOk ? "SD Card: OK" : "SD Card: ERROR", SplashScreen::setStatusLine(tft, 0, "SD Card: OK", TFT_WHITE);
sdOk ? TFT_WHITE : TFT_RED);
// --- Touch-Kalibrierung (nur beim allerersten Start, oder wenn Datei fehlt) ---
if (!TouchInput::loadCalibration()) { if (!TouchInput::loadCalibration()) {
CalibrationScreen::run(tft); CalibrationScreen::run(tft);
} }
bool haveWifiFile = SD.exists(Config::SD_WIFI_CREDENTIALS_FILE); // --- WLAN: Ersteinrichtung (falls noch kein Netzwerk gespeichert ist)
if (!haveWifiFile) { // oder automatisches Verbinden mit dem ersten gerade sichtbaren
// gespeicherten Netzwerk (z.B. Zuhause ODER Auto-Hotspot). ---
if (WifiMgr::networkCount() == 0) {
WifiSetupScreen::run(tft); WifiSetupScreen::run(tft);
} else { } else {
if (!WifiMgr::hasStoredCredentials()) {
WifiMgr::loadCredentialsFromSd();
}
SplashScreen::setStatusLine(tft, 1, "Connecting WiFi..."); SplashScreen::setStatusLine(tft, 1, "Connecting WiFi...");
WifiMgr::beginConnect(); WifiMgr::beginConnect();
@@ -112,6 +176,7 @@ void setup() {
AdsbClient::primeTime(); AdsbClient::primeTime();
// --- Standort per IP-Geolocation (einmalig blockierend beim Start) ---
SplashScreen::setStatusLine(tft, 2, "Getting location..."); SplashScreen::setStatusLine(tft, 2, "Getting location...");
LocationManager::init(); LocationManager::init();
uint32_t locStart = millis(); uint32_t locStart = millis();
@@ -122,11 +187,22 @@ void setup() {
} }
SplashScreen::setStatusLine(tft, 2, "Ready!"); SplashScreen::setStatusLine(tft, 2, "Ready!");
// Sobald die IP-Geolocation einen UTC-Offset geliefert hat, die
// Zeitzone entsprechend setzen - Zeitstempel (Flugbuch, Log-Dateinamen)
// zeigen dann die ECHTE Ortszeit statt UTC, automatisch weltweit richtig.
if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
}
AircraftTable::init(); AircraftTable::init();
AirlineLookup::init(); AirlineLookup::init();
FlightLogbook::init(); // rekonstruiert die "heute schon geloggt"-Liste aus der SD-Karte
// --- Ab hier uebernimmt der Netzwerk-Task (Core 0) laufend WLAN-Status,
// Standort-Updates und ADS-B-Abfragen im Hintergrund. ---
NetTask::begin(); NetTask::begin();
// Splash bleibt mindestens 5 Sekunden sichtbar, egal wie schnell der Rest war.
SplashScreen::waitRemaining(); SplashScreen::waitRemaining();
drawHeader(); drawHeader();
@@ -139,14 +215,19 @@ void loop() {
if (menuBtn.contains(tap.x, tap.y)) { if (menuBtn.contains(tap.x, tap.y)) {
MenuScreen::run(tft); MenuScreen::run(tft);
drawHeader(); drawHeader();
forceRedraw = true; forceRedraw = true; // sofort neu zeichnen
} else if (tap.y >= CONTENT_TOP) { } else if (tap.y >= CONTENT_TOP) {
if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) { if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) {
forceRedraw = true; forceRedraw = true; // sofort neu zeichnen nach Interaktion
} }
} }
} }
// Nur alle POLL_INTERVAL_MS kurz nachschauen (billige Abfrage eines
// Zaehlers), statt staendig teuer neu zu zeichnen. Ein echtes Neuzeichnen
// (render()) passiert nur, wenn sich die Flugzeugdaten TATSAECHLICH
// geaendert haben (neue Abfrage im Hintergrund fertig) oder der Nutzer
// etwas angetippt hat - das vermeidet unnoetiges Flackern.
if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) { if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) {
lastPollMs = millis(); lastPollMs = millis();
uint32_t currentVersion = AircraftTable::version(); uint32_t currentVersion = AircraftTable::version();
@@ -154,19 +235,22 @@ void loop() {
lastRenderedVersion = currentVersion; lastRenderedVersion = currentVersion;
forceRedraw = false; forceRedraw = false;
RadarScreen::render(tft, CONTENT_TOP); RadarScreen::render(tft, CONTENT_TOP);
lastSweepMs = millis(); lastSweepMs = millis(); // Sweep-Delta nicht ueber den Vollbild-Redraw hinweg aufaddieren
} }
} }
// Sweep-Strahl dreht sich unabhaengig von den Flugzeugdaten weiter -
// billige Linien-Zeichnung ohne Vollbild-Clear, daher kein Flackern.
uint32_t nowMs = millis(); uint32_t nowMs = millis();
// Naeherungsalarm (LED) laeuft IMMER, auch wenn gerade das Detail-Fenster // Naeherungs-/Notfall-Alarm (LED) laeuft IMMER, auch wenn gerade das
// offen ist - unabhaengig vom Sweep/Radar-Redraw. // Detail-Fenster offen ist - unabhaengig vom Sweep/Radar-Redraw.
RadarScreen::updateProximityAlert(nowMs); RadarScreen::updateProximityAlert(nowMs);
if (nowMs - lastSweepMs >= SWEEP_TICK_MS) { if (nowMs - lastSweepMs >= SWEEP_TICK_MS) {
uint32_t deltaMs = nowMs - lastSweepMs; uint32_t deltaMs = nowMs - lastSweepMs;
lastSweepMs = nowMs; lastSweepMs = nowMs;
RadarScreen::tick(tft, CONTENT_TOP, deltaMs); RadarScreen::tick(tft, CONTENT_TOP, deltaMs);
updateEmergencyBanner(nowMs); // gleiche Taktung wie der Sweep-Tick
} }
} }

View File

@@ -1,6 +1,7 @@
#include "menu_screen.h" #include "menu_screen.h"
#include "touch_input.h" #include "touch_input.h"
#include "calibration_screen.h" #include "calibration_screen.h"
#include "wifi_manage_screen.h"
#include "settings_store.h" #include "settings_store.h"
#include "config.h" #include "config.h"
@@ -14,6 +15,15 @@ namespace {
} }
}; };
constexpr int16_t ROW_H = 30;
constexpr int16_t ROW_GAP = 4;
constexpr int16_t ROW_START_Y = 26;
Rect rowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (ROW_H + ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), ROW_H};
}
void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) { void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) {
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_NAVY); tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_NAVY);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY);
@@ -22,28 +32,41 @@ namespace {
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
} }
String onOff(bool on) { return on ? "ON" : "OFF"; }
} }
void run(TFT_eSPI& tft) { void run(TFT_eSPI& tft) {
Rect calibBtn = {10, 60, Config::SCREEN_WIDTH - 20, 40}; Rect calibBtn = rowRect(0);
Rect invertBtn = {10, 112, Config::SCREEN_WIDTH - 20, 40}; Rect invertBtn = rowRect(1);
Rect backBtn = {10, 260, Config::SCREEN_WIDTH - 20, 40}; Rect wifiBtn = rowRect(2);
Rect emergencyBtn = rowRect(3);
Rect proximityBtn = rowRect(4);
Rect logbookBtn = rowRect(5);
Rect backBtn = rowRect(6);
bool done = false; bool done = false;
while (!done) { while (!done) {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 10); tft.setCursor(10, 8);
tft.println("Einstellungen"); tft.println("Settings");
drawButton(tft, calibBtn, "Calibrate touch");
drawButton(tft, calibBtn, "Touch kalibrieren");
String invertLabel = SettingsStore::displayInverted() String invertLabel = SettingsStore::displayInverted()
? "Display: invertiert (antippen)" ? "Display: inverted (tap)"
: "Display: normal (antippen)"; : "Display: normal (tap)";
drawButton(tft, invertBtn, invertLabel); drawButton(tft, invertBtn, invertLabel);
drawButton(tft, backBtn, "Zurueck");
// Auf genau einen Tap warten drawButton(tft, wifiBtn, "Manage WiFi networks");
drawButton(tft, emergencyBtn, "Emergency alert: " + onOff(SettingsStore::emergencyAlertEnabled()));
drawButton(tft, proximityBtn, "Proximity LED: " + onOff(SettingsStore::proximityAlertEnabled()));
drawButton(tft, logbookBtn, "Flight logbook: " + onOff(SettingsStore::flightLogbookEnabled()));
drawButton(tft, backBtn, "Back");
TouchInput::Point tap; TouchInput::Point tap;
while (true) { while (true) {
if (TouchInput::wasTapped(tap)) break; if (TouchInput::wasTapped(tap)) break;
@@ -56,6 +79,14 @@ void run(TFT_eSPI& tft) {
bool newState = !SettingsStore::displayInverted(); bool newState = !SettingsStore::displayInverted();
SettingsStore::setDisplayInverted(newState); SettingsStore::setDisplayInverted(newState);
tft.invertDisplay(newState); tft.invertDisplay(newState);
} else if (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 (logbookBtn.contains(tap.x, tap.y)) {
SettingsStore::setFlightLogbookEnabled(!SettingsStore::flightLogbookEnabled());
} else if (backBtn.contains(tap.x, tap.y)) { } else if (backBtn.contains(tap.x, tap.y)) {
done = true; done = true;
} }

View File

@@ -7,6 +7,7 @@
#include "aircraft.h" #include "aircraft.h"
#include "settings_store.h" #include "settings_store.h"
#include "aircraft_details.h" #include "aircraft_details.h"
#include "flight_logbook.h"
#include <Arduino.h> #include <Arduino.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
@@ -18,11 +19,6 @@ namespace {
TaskHandle_t taskHandle = nullptr; TaskHandle_t taskHandle = nullptr;
uint32_t lastFetchMs = 0; uint32_t lastFetchMs = 0;
// Eigener Zwischenspeicher fuer die Netzwerk-Antwort. Die eigentliche
// AircraftTable wird NUR fuer den kurzen Kopiervorgang gesperrt, NICHT
// waehrend der (langsamen) Netzwerkabfrage selbst - sonst friert der
// Radar-Bildschirm fuer die Dauer der HTTPS-Anfrage ein, weil er auf
// denselben Lock wartet.
Aircraft tempTable[Config::MAX_TRACKED_AIRCRAFT]; Aircraft tempTable[Config::MAX_TRACKED_AIRCRAFT];
void taskFunc(void*) { void taskFunc(void*) {
@@ -30,9 +26,6 @@ namespace {
WifiMgr::update(); WifiMgr::update();
LocationManager::update(); LocationManager::update();
// Erledigt eine evtl. anstehende Detail-Abfrage (Modell/Route)
// fuer das aktuell vom Nutzer ausgewaehlte Flugzeug, falls es eine
// gibt. Guenstig, wenn nichts ansteht (nur ein Mutex-Check).
AircraftDetails::update(); AircraftDetails::update();
if (millis() - lastFetchMs >= Config::FETCH_INTERVAL_MS) { if (millis() - lastFetchMs >= Config::FETCH_INTERVAL_MS) {
@@ -46,10 +39,6 @@ namespace {
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
// Netzwerkabfrage OHNE Lock - schreibt nur in den lokalen
// Zwischenspeicher, den sonst niemand anfasst. Der
// Radar-Bildschirm kann waehrenddessen ganz normal mit den
// ALTEN Daten weiterzeichnen.
auto result = AdsbClient::fetch(lat, lon, rangeKm, auto result = AdsbClient::fetch(lat, lon, rangeKm,
tempTable, Config::MAX_TRACKED_AIRCRAFT); tempTable, Config::MAX_TRACKED_AIRCRAFT);
@@ -59,6 +48,8 @@ namespace {
sizeof(Aircraft) * Config::MAX_TRACKED_AIRCRAFT); sizeof(Aircraft) * Config::MAX_TRACKED_AIRCRAFT);
AircraftTable::postFetchUpdate(lat, lon); AircraftTable::postFetchUpdate(lat, lon);
AircraftTable::unlock(); AircraftTable::unlock();
FlightLogbook::update();
} else { } else {
Serial.printf("[NetTask] Abfrage fehlgeschlagen (HTTP %d)\n", result.httpCode); Serial.printf("[NetTask] Abfrage fehlgeschlagen (HTTP %d)\n", result.httpCode);
} }
@@ -74,11 +65,11 @@ void begin() {
xTaskCreatePinnedToCore( xTaskCreatePinnedToCore(
taskFunc, taskFunc,
"NetTask", "NetTask",
20480, // Stack: TLS-Handshake + JSON-Parsing braucht mehr als das Minimum 20480,
nullptr, nullptr,
1, // Prioritaet 1,
&taskHandle, &taskHandle,
0 // Core 0 (Core 1 bleibt frei fuer Rendering/Touch im main-Loop) 0
); );
} }

View File

@@ -8,6 +8,7 @@
#include "units.h" #include "units.h"
#include "settings_store.h" #include "settings_store.h"
#include "led_alert.h" #include "led_alert.h"
#include "location_manager.h"
#include <math.h> #include <math.h>
namespace RadarScreen { namespace RadarScreen {
@@ -50,6 +51,7 @@ namespace {
uint16_t color; uint16_t color;
float headingDeg; float headingDeg;
float distanceKm; float distanceKm;
bool isEmergency;
}; };
constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT; constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT;
HitPoint hitPoints[MAX_HIT_POINTS]; HitPoint hitPoints[MAX_HIT_POINTS];
@@ -58,6 +60,14 @@ namespace {
bool ledBlinkOn = true; bool ledBlinkOn = true;
bool isEmergencySquawk(const char* squawk) {
if (!squawk[0]) return false;
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
if (strcmp(squawk, Config::EMERGENCY_SQUAWKS[i]) == 0) return true;
}
return false;
}
float sweepAngleDeg = 0.0f; float sweepAngleDeg = 0.0f;
float prevSweepAngleDeg = -1.0f; float prevSweepAngleDeg = -1.0f;
constexpr float SWEEP_DEGREES_PER_SEC = 45.0f; constexpr float SWEEP_DEGREES_PER_SEC = 45.0f;
@@ -89,11 +99,29 @@ namespace {
gfx.setTextDatum(TL_DATUM); gfx.setTextDatum(TL_DATUM);
} }
// Kleine Farb-Legende (Hoehenband -> Farbe), damit auf den ersten Blick
// klar ist, was Gruen/Gelb/Rot bei den Flugzeug-Punkten bedeuten. Zeigt
// Meter statt Fuss, wenn die Region (per Geolocation) metrisch ist.
void drawLegend(TFT_eSPI& gfx, int16_t y) { void drawLegend(TFT_eSPI& gfx, int16_t y) {
bool metric = LocationManager::useMetricUnits();
char lowLabel[10], midLabel[10], highLabel[10];
if (metric) {
int lowM = (int)(Units::feetToMeters(Config::COLOR_LOW_ALT_THRESHOLD_FT) / 100) * 100;
int midM = (int)(Units::feetToMeters(Config::COLOR_MID_ALT_THRESHOLD_FT) / 100) * 100;
snprintf(lowLabel, sizeof(lowLabel), "<%dm", lowM);
snprintf(midLabel, sizeof(midLabel), "%d-%dm", lowM, midM);
snprintf(highLabel, sizeof(highLabel), ">%dm", midM);
} else {
snprintf(lowLabel, sizeof(lowLabel), "<10k ft");
snprintf(midLabel, sizeof(midLabel), "10-30k");
snprintf(highLabel, sizeof(highLabel), ">30k ft");
}
struct { uint16_t color; const char* label; } items[3] = { struct { uint16_t color; const char* label; } items[3] = {
{TFT_GREEN, "<10k ft"}, {TFT_GREEN, lowLabel},
{TFT_YELLOW, "10-30k"}, {TFT_YELLOW, midLabel},
{TFT_RED, ">30k ft"}, {TFT_RED, highLabel},
}; };
int16_t segW = Config::SCREEN_WIDTH / 3; int16_t segW = Config::SCREEN_WIDTH / 3;
gfx.setTextColor(TFT_WHITE, TFT_BLACK); gfx.setTextColor(TFT_WHITE, TFT_BLACK);
@@ -290,6 +318,7 @@ void render(TFT_eSPI& tft, int16_t top) {
uint16_t color = colorForAltitude(a.altBaroFt); uint16_t color = colorForAltitude(a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0; bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk);
if (isSelected) { if (isSelected) {
tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE);
@@ -298,6 +327,10 @@ void render(TFT_eSPI& tft, int16_t top) {
} }
tft.fillCircle(pt.x, pt.y, 5, color); tft.fillCircle(pt.x, pt.y, 5, color);
if (isEmergency) {
tft.drawCircle(pt.x, pt.y, 12, TFT_RED);
}
double rad = a.headingDeg * PI / 180.0; double rad = a.headingDeg * PI / 180.0;
int16_t dx = (int16_t)(sin(rad) * 10); int16_t dx = (int16_t)(sin(rad) * 10);
int16_t dy = (int16_t)(-cos(rad) * 10); int16_t dy = (int16_t)(-cos(rad) * 10);
@@ -315,6 +348,7 @@ void render(TFT_eSPI& tft, int16_t top) {
hitPoints[i].color = color; hitPoints[i].color = color;
hitPoints[i].headingDeg = a.headingDeg; hitPoints[i].headingDeg = a.headingDeg;
hitPoints[i].distanceKm = a.distanceKm; hitPoints[i].distanceKm = a.distanceKm;
hitPoints[i].isEmergency = isEmergency;
strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1);
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
} }
@@ -375,6 +409,10 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
tft.fillCircle(hp.x, hp.y, 5, hp.color); tft.fillCircle(hp.x, hp.y, 5, hp.color);
if (hp.isEmergency) {
tft.drawCircle(hp.x, hp.y, 12, TFT_RED);
}
double rad = hp.headingDeg * PI / 180.0; double rad = hp.headingDeg * PI / 180.0;
int16_t dx = (int16_t)(sin(rad) * 10); int16_t dx = (int16_t)(sin(rad) * 10);
int16_t dy = (int16_t)(-cos(rad) * 10); int16_t dy = (int16_t)(-cos(rad) * 10);
@@ -429,18 +467,47 @@ bool handleTap(int16_t x, int16_t y, int16_t top) {
void updateProximityAlert(uint32_t nowMs) { void updateProximityAlert(uint32_t nowMs) {
bool anyClose = false; bool anyClose = false;
bool anyEmergency = false;
bool proximityOn = SettingsStore::proximityAlertEnabled();
bool emergencyOn = SettingsStore::emergencyAlertEnabled();
if (proximityOn || emergencyOn) {
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (!table[i].valid) continue;
if (proximityOn && table[i].distanceKm <= Config::LED_ALERT_RADIUS_KM) anyClose = true;
if (emergencyOn && isEmergencySquawk(table[i].squawk)) anyEmergency = true;
}
AircraftTable::unlock();
}
LedAlert::Mode mode = anyEmergency ? LedAlert::Mode::EmergencyRed
: anyClose ? LedAlert::Mode::ProximityGreen
: LedAlert::Mode::Off;
ledBlinkOn = LedAlert::update(mode, nowMs);
}
EmergencyInfo checkEmergency() {
EmergencyInfo info;
if (!SettingsStore::emergencyAlertEnabled()) return info;
AircraftTable::lock(); AircraftTable::lock();
Aircraft* table = AircraftTable::raw(); Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (table[i].valid && table[i].distanceKm <= Config::LED_ALERT_RADIUS_KM) { if (table[i].valid && isEmergencySquawk(table[i].squawk)) {
anyClose = true; info.active = true;
strncpy(info.callsign, table[i].callsign[0] ? table[i].callsign : table[i].hex,
sizeof(info.callsign) - 1);
strncpy(info.squawk, table[i].squawk, sizeof(info.squawk) - 1);
break; break;
} }
} }
AircraftTable::unlock(); AircraftTable::unlock();
ledBlinkOn = LedAlert::update(anyClose, nowMs); return info;
} }
} }

View File

@@ -23,8 +23,19 @@ namespace RadarScreen {
bool handleTap(int16_t x, int16_t y, int16_t top); bool handleTap(int16_t x, int16_t y, int16_t top);
// Haeufig aufrufen (unabhaengig davon, ob ein Detail-Fenster offen ist): // Haeufig aufrufen (unabhaengig davon, ob ein Detail-Fenster offen ist):
// prueft, ob ein Flugzeug innerhalb des Alarmradius ist, steuert die // prueft, ob ein Flugzeug innerhalb des Alarmradius ist ODER ein
// gruene LED entsprechend und merkt sich den Blink-Zustand, damit tick() // Notfall-Squawk sendet, steuert die LED entsprechend (Notfall hat
// den betroffenen Punkt synchron mitblinken lassen kann. // Vorrang, blinkt rot statt gruen) und merkt sich den Blink-Zustand,
// damit tick() den betroffenen Punkt synchron mitblinken lassen kann.
void updateProximityAlert(uint32_t nowMs); void updateProximityAlert(uint32_t nowMs);
struct EmergencyInfo {
bool active = false;
char callsign[9] = {0};
char squawk[5] = {0};
};
// Reine Abfrage (kein Seiteneffekt): gibt das erste Flugzeug mit einem
// Notfall-Squawk zurueck, falls vorhanden. Fuer das Banner im Header.
EmergencyInfo checkEmergency();
} }

View File

@@ -7,6 +7,9 @@ namespace SettingsStore {
namespace { namespace {
uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX; uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX;
bool inverted = false; bool inverted = false;
bool emergencyAlertOn = true;
bool proximityAlertOn = true;
bool flightLogbookOn = true;
void applyKeyValue(const String& key, const String& value) { void applyKeyValue(const String& key, const String& value) {
if (key == "range_index") { if (key == "range_index") {
@@ -16,26 +19,26 @@ namespace {
} }
} else if (key == "invert") { } else if (key == "invert") {
inverted = (value.toInt() != 0); inverted = (value.toInt() != 0);
} else if (key == "emergency_alert") {
emergencyAlertOn = (value.toInt() != 0);
} else if (key == "proximity_alert") {
proximityAlertOn = (value.toInt() != 0);
} else if (key == "flight_logbook") {
flightLogbookOn = (value.toInt() != 0);
} }
// Weitere Einstellungen hier ergaenzen (unbekannte Keys werden
// einfach ignoriert, damit alte config.txt-Dateien kompatibel bleiben).
} }
} }
void load() { void load() {
if (!SD.exists(Config::SD_SETTINGS_FILE)) return; if (!SD.exists(Config::SD_SETTINGS_FILE)) return;
File f = SD.open(Config::SD_SETTINGS_FILE, FILE_READ); File f = SD.open(Config::SD_SETTINGS_FILE, FILE_READ);
if (!f) return; if (!f) return;
while (f.available()) { while (f.available()) {
String line = f.readStringUntil('\n'); String line = f.readStringUntil('\n');
line.trim(); line.trim();
if (line.length() == 0 || line.startsWith("#")) continue; if (line.length() == 0 || line.startsWith("#")) continue;
int eq = line.indexOf('='); int eq = line.indexOf('=');
if (eq < 0) continue; if (eq < 0) continue;
String key = line.substring(0, eq); String key = line.substring(0, eq);
String value = line.substring(eq + 1); String value = line.substring(eq + 1);
key.trim(); key.trim();
@@ -50,23 +53,27 @@ void save() {
if (!f) return; if (!f) return;
f.printf("range_index=%d\n", rangeIdx); f.printf("range_index=%d\n", rangeIdx);
f.printf("invert=%d\n", inverted ? 1 : 0); f.printf("invert=%d\n", inverted ? 1 : 0);
f.printf("emergency_alert=%d\n", emergencyAlertOn ? 1 : 0);
f.printf("proximity_alert=%d\n", proximityAlertOn ? 1 : 0);
f.printf("flight_logbook=%d\n", flightLogbookOn ? 1 : 0);
f.close(); f.close();
} }
uint8_t rangeIndex() { return rangeIdx; } uint8_t rangeIndex() { return rangeIdx; }
void setRangeIndex(uint8_t idx) { void setRangeIndex(uint8_t idx) {
if (idx < Config::RANGE_STEP_COUNT) { if (idx < Config::RANGE_STEP_COUNT) { rangeIdx = idx; save(); }
rangeIdx = idx;
save();
}
} }
bool displayInverted() { return inverted; } bool displayInverted() { return inverted; }
void setDisplayInverted(bool inv) { inverted = inv; save(); }
void setDisplayInverted(bool inv) { bool emergencyAlertEnabled() { return emergencyAlertOn; }
inverted = inv; void setEmergencyAlertEnabled(bool on) { emergencyAlertOn = on; save(); }
save();
} bool proximityAlertEnabled() { return proximityAlertOn; }
void setProximityAlertEnabled(bool on) { proximityAlertOn = on; save(); }
bool flightLogbookEnabled() { return flightLogbookOn; }
void setFlightLogbookEnabled(bool on) { flightLogbookOn = on; save(); }
} }

View File

@@ -1,17 +1,8 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
// Einfacher key=value Einstellungs-Speicher in /Flightradar_cyd/config.txt.
// Neue Einstellungen koennen spaeter einfach als weitere getX()/setX()-Paare
// dazu kommen, ohne das Dateiformat zu aendern (unbekannte Zeilen werden
// beim Laden ignoriert).
namespace SettingsStore { namespace SettingsStore {
// Liest config.txt von der SD-Karte (falls vorhanden). Werte, die nicht
// in der Datei stehen, behalten ihren Standardwert.
void load(); void load();
// Schreibt die aktuellen Werte zurueck auf die SD-Karte.
void save(); void save();
uint8_t rangeIndex(); uint8_t rangeIndex();
@@ -19,4 +10,13 @@ namespace SettingsStore {
bool displayInverted(); bool displayInverted();
void setDisplayInverted(bool inverted); void setDisplayInverted(bool inverted);
bool emergencyAlertEnabled();
void setEmergencyAlertEnabled(bool on);
bool proximityAlertEnabled();
void setProximityAlertEnabled(bool on);
bool flightLogbookEnabled();
void setFlightLogbookEnabled(bool on);
} }

View File

@@ -10,20 +10,33 @@ namespace {
constexpr int16_t STATUS_START_Y = 260; constexpr int16_t STATUS_START_Y = 260;
constexpr uint8_t MAX_STATUS_LINES = 3; constexpr uint8_t MAX_STATUS_LINES = 3;
// Einfache Vektor-Silhouette eines Flugzeugs von oben (keine Bilddatei // Dezente konzentrische Ringe + Fadenkreuz als "Zielfernrohr"-Hintergrund,
// eingebunden, daher per Dreiecken gezeichnet) - in Gruen, wie der // in gedaempftem Gruen (gleicher Ton wie die Sweep-Nachzieh-Linie im
// Radar-Sweep-Strahl, passend zum restlichen Dark-Theme. // Radar-Bildschirm), damit das Flugzeug wie ins Visier genommen wirkt.
void drawRadarReticle(TFT_eSPI& tft, int16_t cx, int16_t cy) {
uint16_t dim = 0x0320;
tft.drawCircle(cx, cy, 112, dim);
tft.drawCircle(cx, cy, 76, dim);
tft.drawCircle(cx, cy, 40, dim);
tft.drawFastHLine(cx - 112, cy, 224, dim);
tft.drawFastVLine(cx, cy - 112, 224, dim);
}
// Einfache Vektor-Silhouette eines schlanken Duesenjets von oben (keine
// Bilddatei eingebunden, daher per Dreiecken gezeichnet) - nur als Umriss
// (nicht ausgefuellt), in Gruen, passend zum restlichen Dark-Theme.
void drawAirplane(TFT_eSPI& tft, int16_t cx) { void drawAirplane(TFT_eSPI& tft, int16_t cx) {
uint16_t color = TFT_GREEN; uint16_t color = TFT_GREEN;
// Rumpf // Schlanker Rumpf (nur Umriss)
tft.fillTriangle(cx, 110, cx - 12, 240, cx + 12, 240, color); tft.drawTriangle(cx, 105, cx - 6, 255, cx + 6, 255, color);
// Haupttragflaechen // Deltafluegel, nach hinten gepfeilt (nur Umriss)
tft.fillTriangle(cx, 170, cx - 110, 230, cx + 110, 230, color); tft.drawTriangle(cx, 160, cx - 100, 235, cx, 200, color);
tft.drawTriangle(cx, 160, cx + 100, 235, cx, 200, color);
// Leitwerk (Heckfluegel) // Kleines Leitwerk (Hoehenruder) am Heck (nur Umriss)
tft.fillTriangle(cx, 232, cx - 35, 250, cx + 35, 250, color); tft.drawTriangle(cx, 250, cx - 22, 268, cx + 22, 268, color);
} }
} }
@@ -33,6 +46,7 @@ void begin(TFT_eSPI& tft) {
int16_t cx = tft.width() / 2; int16_t cx = tft.width() / 2;
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
drawRadarReticle(tft, cx, 185);
drawAirplane(tft, cx); drawAirplane(tft, cx);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);

View File

@@ -0,0 +1,98 @@
#include "wifi_manage_screen.h"
#include "wifi_manager.h"
#include "wifi_setup_screen.h"
#include "touch_input.h"
#include "config.h"
namespace WifiManageScreen {
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);
}
}
void run(TFT_eSPI& tft) {
constexpr int16_t ROW_H = 46;
constexpr int16_t ROW_GAP = 8;
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, 10);
tft.println("WiFi networks");
uint8_t count = WifiMgr::networkCount();
int16_t y = 40;
Rect rowRects[Config::MAX_WIFI_NETWORKS];
Rect removeRects[Config::MAX_WIFI_NETWORKS];
for (uint8_t i = 0; i < Config::MAX_WIFI_NETWORKS; 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) {
drawButton(tft, rowRect, WifiMgr::networkSsid(i));
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);
tft.drawString("(empty)", 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), 40};
bool canAdd = count < Config::MAX_WIFI_NETWORKS;
if (canAdd) {
drawButton(tft, addBtn, "Add network");
}
y += 48;
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
drawButton(tft, backBtn, "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)) {
WifiMgr::removeNetwork(i);
handled = true;
}
}
if (!handled && canAdd && addBtn.contains(tap.x, tap.y)) {
WifiSetupScreen::run(tft);
handled = true;
}
if (!handled && backBtn.contains(tap.x, tap.y)) {
done = true;
}
}
}
}

11
src/wifi_manage_screen.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace WifiManageScreen {
// Blockierend: zeigt die bis zu 3 gespeicherten WLAN-Netzwerke, erlaubt
// Loeschen einzelner Eintraege und (falls noch Platz ist) das Hinzufuegen
// eines neuen ueber den Scan+Passwort-Bildschirm. Kehrt zurueck, sobald
// "Back" angetippt wird.
void run(TFT_eSPI& tft);
}

View File

@@ -1,25 +1,30 @@
#include "wifi_manager.h" #include "wifi_manager.h"
#include "config.h"
#include "sd_storage.h" #include "sd_storage.h"
#include <WiFi.h> #include <WiFi.h>
#include <Preferences.h>
#include <SD.h> #include <SD.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <cstring>
namespace WifiMgr { namespace WifiMgr {
namespace { namespace {
Preferences prefs; struct NetworkEntry {
char ssid[33] = {0};
char pass[64] = {0};
};
NetworkEntry networks[Config::MAX_WIFI_NETWORKS];
uint8_t networkCountVal = 0;
State state = State::Idle; State state = State::Idle;
uint32_t connectStartMs = 0; uint32_t connectStartMs = 0;
constexpr uint32_t CONNECT_TIMEOUT_MS = 15000; // bounded, same fix as the Kinect sketch constexpr uint32_t CONNECT_TIMEOUT_MS = 15000;
char ipStr[16] = {0}; char ipStr[16] = {0};
// Schuetzt 'state': der Netzwerk-Task (Core 0) schreibt es in uint32_t lastReconnectAttemptMs = 0;
// beginConnect()/update(), der Render-Loop (Core 1) liest es ueber constexpr uint32_t RECONNECT_RETRY_MS = 10000;
// getState(). ipStr wird nur einmal beim Verbinden geschrieben und danach
// nur gelesen, daher hier ohne eigenen Lock.
SemaphoreHandle_t mutex = nullptr; SemaphoreHandle_t mutex = nullptr;
void setState(State s) { void setState(State s) {
@@ -27,51 +32,137 @@ namespace {
state = s; state = s;
xSemaphoreGive(mutex); xSemaphoreGive(mutex);
} }
void loadFromSd() {
networkCountVal = 0;
if (!SdStorage::isMounted()) return;
if (!SD.exists(Config::SD_WIFI_CREDENTIALS_FILE)) return;
File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_READ);
if (!f) return;
while (networkCountVal < Config::MAX_WIFI_NETWORKS && f.available()) {
String ssid = f.readStringUntil('\n');
String pass = f.readStringUntil('\n');
ssid.trim();
pass.trim();
if (ssid.length() == 0) break;
strncpy(networks[networkCountVal].ssid, ssid.c_str(), sizeof(networks[networkCountVal].ssid) - 1);
strncpy(networks[networkCountVal].pass, pass.c_str(), sizeof(networks[networkCountVal].pass) - 1);
networkCountVal++;
}
f.close();
}
void saveToSd() {
if (!SdStorage::isMounted()) return;
File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_WRITE);
if (!f) return;
for (uint8_t i = 0; i < networkCountVal; i++) {
f.println(networks[i].ssid);
f.println(networks[i].pass);
}
f.close();
}
} }
void init() { void init() {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
prefs.begin("adsb_radar", /*readOnly=*/false); loadFromSd();
setState(hasStoredCredentials() ? State::Idle : State::NoCredentials); setState(networkCountVal > 0 ? State::Idle : State::NoCredentials);
} }
bool hasStoredCredentials() { uint8_t networkCount() { return networkCountVal; }
String ssid = prefs.getString("ssid", "");
return ssid.length() > 0; String networkSsid(uint8_t index) {
if (index >= networkCountVal) return String();
return String(networks[index].ssid);
} }
void saveCredentials(const char* ssid, const char* password) { bool addNetwork(const char* ssid, const char* password) {
prefs.putString("ssid", ssid); if (networkCountVal >= Config::MAX_WIFI_NETWORKS) return false;
prefs.putString("pass", password);
strncpy(networks[networkCountVal].ssid, ssid, sizeof(networks[networkCountVal].ssid) - 1);
strncpy(networks[networkCountVal].pass, password, sizeof(networks[networkCountVal].pass) - 1);
networkCountVal++;
saveToSd();
return true;
} }
void beginConnect() { void removeNetwork(uint8_t index) {
if (!hasStoredCredentials()) { if (index >= networkCountVal) return;
setState(State::NoCredentials); for (uint8_t i = index; i < networkCountVal - 1; i++) {
return; networks[i] = networks[i + 1];
} }
String ssid = prefs.getString("ssid", ""); networkCountVal--;
String pass = prefs.getString("pass", ""); networks[networkCountVal] = NetworkEntry{};
saveToSd();
}
void connectTo(const char* ssid, const char* password) {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str()); WiFi.begin(ssid, password);
connectStartMs = millis(); connectStartMs = millis();
setState(State::Connecting); setState(State::Connecting);
} }
void update() { void beginConnect() {
if (getState() != State::Connecting) return; if (networkCountVal == 0) {
setState(State::NoCredentials);
return;
}
int visibleCount = WiFi.scanNetworks(/*async=*/false);
int8_t chosen = -1;
for (uint8_t i = 0; i < networkCountVal && chosen < 0; i++) {
for (int j = 0; j < visibleCount; j++) {
if (WiFi.SSID(j) == networks[i].ssid) {
chosen = (int8_t)i;
break;
}
}
}
if (chosen < 0) {
setState(State::Failed);
return;
}
connectTo(networks[chosen].ssid, networks[chosen].pass);
}
void update() {
State s = getState();
if (s == State::Connecting) {
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
strncpy(ipStr, WiFi.localIP().toString().c_str(), sizeof(ipStr) - 1); strncpy(ipStr, WiFi.localIP().toString().c_str(), sizeof(ipStr) - 1);
setState(State::Connected); setState(State::Connected);
return; return;
} }
if (millis() - connectStartMs > CONNECT_TIMEOUT_MS) { if (millis() - connectStartMs > CONNECT_TIMEOUT_MS) {
WiFi.disconnect(true); WiFi.disconnect(true);
setState(State::Failed); setState(State::Failed);
} }
return;
}
if (s == State::Connected && WiFi.status() != WL_CONNECTED) {
Serial.println("[WifiMgr] Verbindung verloren, versuche automatisch neu zu verbinden...");
setState(State::Idle);
lastReconnectAttemptMs = millis() - RECONNECT_RETRY_MS;
return;
}
if ((s == State::Idle || s == State::Failed) && networkCountVal > 0) {
if (millis() - lastReconnectAttemptMs >= RECONNECT_RETRY_MS) {
lastReconnectAttemptMs = millis();
beginConnect();
}
}
} }
State getState() { State getState() {
@@ -104,37 +195,4 @@ int32_t getScanResultRSSI(int index) {
const char* getIP() { return ipStr; } const char* getIP() { return ipStr; }
bool loadCredentialsFromSd() {
if (!SdStorage::isMounted()) return false;
if (!SD.exists(Config::SD_WIFI_CREDENTIALS_FILE)) return false;
File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_READ);
if (!f) return false;
String ssid = f.readStringUntil('\n');
String pass = f.readStringUntil('\n');
f.close();
ssid.trim();
pass.trim();
if (ssid.length() == 0) return false;
saveCredentials(ssid.c_str(), pass.c_str());
return true;
}
void saveCredentialsToSdIfMounted() {
if (!SdStorage::isMounted()) return;
String ssid = prefs.getString("ssid", "");
String pass = prefs.getString("pass", "");
if (ssid.length() == 0) return;
File f = SD.open(Config::SD_WIFI_CREDENTIALS_FILE, FILE_WRITE);
if (!f) return;
f.println(ssid);
f.println(pass);
f.close();
}
} }

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include "config.h"
namespace WifiMgr { namespace WifiMgr {
enum class State { enum class State {
@@ -8,20 +9,20 @@ namespace WifiMgr {
void init(); void init();
void beginConnect(); void beginConnect();
void connectTo(const char* ssid, const char* password);
void update(); void update();
State getState(); State getState();
const char* getIP();
void saveCredentials(const char* ssid, const char* password); uint8_t networkCount();
bool hasStoredCredentials(); String networkSsid(uint8_t index);
bool addNetwork(const char* ssid, const char* password);
bool loadCredentialsFromSd(); void removeNetwork(uint8_t index);
void saveCredentialsToSdIfMounted();
void beginScan(); void beginScan();
bool isScanComplete(); bool isScanComplete();
int getScanResultCount(); int getScanResultCount();
String getScanResultSSID(int index); String getScanResultSSID(int index);
int32_t getScanResultRSSI(int index); int32_t getScanResultRSSI(int index);
const char* getIP();
} }

View File

@@ -17,11 +17,10 @@ namespace {
struct KeyDef { struct KeyDef {
KeyType type; KeyType type;
char ch; // fuer KeyType::Char (Kleinbuchstabe/Grundzeichen) char ch;
const char* label; // Anzeige, falls kein einzelnes Zeichen (z.B. "<-") const char* label;
}; };
// --- Tastatur-Layout ---------------------------------------------------
constexpr KeyDef ROW_LETTERS_A[] = { constexpr KeyDef ROW_LETTERS_A[] = {
{KeyType::Char,'q',nullptr},{KeyType::Char,'w',nullptr},{KeyType::Char,'e',nullptr}, {KeyType::Char,'q',nullptr},{KeyType::Char,'w',nullptr},{KeyType::Char,'e',nullptr},
{KeyType::Char,'r',nullptr},{KeyType::Char,'t',nullptr},{KeyType::Char,'y',nullptr}, {KeyType::Char,'r',nullptr},{KeyType::Char,'t',nullptr},{KeyType::Char,'y',nullptr},
@@ -79,11 +78,11 @@ namespace {
uint8_t passwordLen = 0; uint8_t passwordLen = 0;
bool shiftOn = false; bool shiftOn = false;
uint8_t page = 0; // 0 = Buchstaben, 1 = Symbole uint8_t page = 0;
bool skipped = false; bool skipped = false;
bool connectSucceeded = false; bool connectSucceeded = false;
bool needsRedraw = true; // nur neu zeichnen, wenn sich wirklich was geaendert hat (verhindert Flackern) bool needsRedraw = true;
Rect cancelBtn = {Config::SCREEN_WIDTH - 34, 4, 30, 24}; Rect cancelBtn = {Config::SCREEN_WIDTH - 34, 4, 30, 24};
@@ -97,7 +96,6 @@ namespace {
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
} }
// Rechnet die Rects einer Tastenreihe aus (gleich verteilt ueber die Breite).
template <size_t N> template <size_t N>
void layoutRow(const KeyDef (&row)[N], int16_t y, Rect outRects[N]) { void layoutRow(const KeyDef (&row)[N], int16_t y, Rect outRects[N]) {
int16_t usableW = Config::SCREEN_WIDTH - 2 * SIDE_MARGIN; int16_t usableW = Config::SCREEN_WIDTH - 2 * SIDE_MARGIN;
@@ -184,10 +182,10 @@ bool run(TFT_eSPI& tft) {
WifiMgr::beginScan(); WifiMgr::beginScan();
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1); tft.setTextSize(1);
tft.setCursor(10, 10); tft.setCursor(10, 10);
tft.println("WLAN-Suche laeuft..."); tft.println("Scanning WiFi...");
drawCancelButton(tft); drawCancelButton(tft);
while (!skipped) { while (!skipped) {
@@ -199,7 +197,6 @@ bool run(TFT_eSPI& tft) {
break; break;
} }
// --- Uebergaenge, die nicht vom Touch kommen -----------------------
if (stage == Stage::Scanning && WifiMgr::isScanComplete()) { if (stage == Stage::Scanning && WifiMgr::isScanComplete()) {
ssidCount = (uint8_t)min((int)WifiMgr::getScanResultCount(), (int)MAX_LIST); ssidCount = (uint8_t)min((int)WifiMgr::getScanResultCount(), (int)MAX_LIST);
for (uint8_t i = 0; i < ssidCount; i++) ssidList[i] = WifiMgr::getScanResultSSID(i); for (uint8_t i = 0; i < ssidCount; i++) ssidList[i] = WifiMgr::getScanResultSSID(i);
@@ -208,8 +205,8 @@ bool run(TFT_eSPI& tft) {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setCursor(10, 10); tft.setCursor(10, 10);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println(ssidCount == 0 ? "Keine Netzwerke gefunden" : "WLAN waehlen:"); tft.println(ssidCount == 0 ? "No networks found" : "Select WiFi:");
drawCancelButton(tft); drawCancelButton(tft);
} }
@@ -217,12 +214,12 @@ bool run(TFT_eSPI& tft) {
WifiMgr::update(); WifiMgr::update();
if (WifiMgr::getState() == WifiMgr::State::Connected) { if (WifiMgr::getState() == WifiMgr::State::Connected) {
connectSucceeded = true; connectSucceeded = true;
WifiMgr::saveCredentialsToSdIfMounted(); WifiMgr::addNetwork(ssidList[selectedIndex].c_str(), passwordBuf);
stage = Stage::Done; stage = Stage::Done;
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setCursor(10, 10); tft.setCursor(10, 10);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("Verbunden!"); tft.println("Connected!");
delay(900); delay(900);
return true; return true;
} else if (WifiMgr::getState() == WifiMgr::State::Failed) { } else if (WifiMgr::getState() == WifiMgr::State::Failed) {
@@ -230,10 +227,10 @@ bool run(TFT_eSPI& tft) {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setCursor(10, 10); tft.setCursor(10, 10);
tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextColor(TFT_RED, TFT_BLACK);
tft.println("Verbindung fehlgeschlagen."); tft.println("Connection failed.");
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 30); tft.setCursor(10, 30);
tft.println("Zurueck zur Netzwerkliste..."); tft.println("Back to network list...");
delay(1400); delay(1400);
stage = Stage::PickSsid; stage = Stage::PickSsid;
WifiMgr::beginScan(); WifiMgr::beginScan();
@@ -241,10 +238,8 @@ bool run(TFT_eSPI& tft) {
} }
} }
// --- Touch-Eingaben je nach Stage -----------------------------------
if (tapped && stage == Stage::PickSsid) { if (tapped && stage == Stage::PickSsid) {
if (ssidCount > 0) { if (ssidCount > 0) {
// Liste
for (uint8_t row = 0; row < VISIBLE_ITEMS; row++) { for (uint8_t row = 0; row < VISIBLE_ITEMS; row++) {
uint8_t idx = scrollOffset + row; uint8_t idx = scrollOffset + row;
if (idx >= ssidCount) break; if (idx >= ssidCount) break;
@@ -256,7 +251,6 @@ bool run(TFT_eSPI& tft) {
needsRedraw = true; needsRedraw = true;
} }
} }
// Scroll-Pfeile
if (ssidCount > VISIBLE_ITEMS) { if (ssidCount > VISIBLE_ITEMS) {
Rect upBtn = {Config::SCREEN_WIDTH - 34, 34, 30, 26}; Rect upBtn = {Config::SCREEN_WIDTH - 34, 34, 30, 26};
Rect downBtn = {Config::SCREEN_WIDTH - 34, 64 + (VISIBLE_ITEMS - 1) * 34, 30, 26}; Rect downBtn = {Config::SCREEN_WIDTH - 34, 64 + (VISIBLE_ITEMS - 1) * 34, 30, 26};
@@ -294,22 +288,32 @@ bool run(TFT_eSPI& tft) {
passwordBuf[passwordLen] = 0; passwordBuf[passwordLen] = 0;
} }
} else if (connectBtn.contains(tap.x, tap.y)) { } else if (connectBtn.contains(tap.x, tap.y)) {
WifiMgr::saveCredentials(ssidList[selectedIndex].c_str(), passwordBuf); if (WifiMgr::networkCount() >= Config::MAX_WIFI_NETWORKS) {
WifiMgr::beginConnect(); tft.fillScreen(TFT_BLACK);
tft.setCursor(10, 10);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.println("Already 3 networks saved.");
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 30);
tft.println("Remove one first.");
delay(1600);
skipped = true;
break;
}
WifiMgr::connectTo(ssidList[selectedIndex].c_str(), passwordBuf);
stage = Stage::Connecting; stage = Stage::Connecting;
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setCursor(10, 10); tft.setCursor(10, 10);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("Verbinde..."); tft.println("Connecting...");
} else if (backBtn.contains(tap.x, tap.y)) { } else if (backBtn.contains(tap.x, tap.y)) {
stage = Stage::PickSsid; stage = Stage::PickSsid;
needsRedraw = true; needsRedraw = true;
} }
} }
needsRedraw = true; // Tastatureingabe (Zeichen/Shift/Seite/Backspace/Leerzeichen) aendert den Bildschirm needsRedraw = true;
} }
// --- Zeichnen (nur bei Aenderung, verhindert Flackern) ----------------
if (!needsRedraw) { if (!needsRedraw) {
delay(20); delay(20);
continue; continue;
@@ -333,16 +337,16 @@ bool run(TFT_eSPI& tft) {
drawCancelButton(tft); drawCancelButton(tft);
} else if (stage == Stage::EnterPassword) { } else if (stage == Stage::EnterPassword) {
tft.fillRect(0, 0, Config::SCREEN_WIDTH, KB_TOP - 4, TFT_BLACK); tft.fillRect(0, 0, Config::SCREEN_WIDTH, KB_TOP - 4, TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 6); tft.setCursor(10, 6);
tft.printf("WLAN: %s", ssidList[selectedIndex].c_str()); tft.printf("WiFi: %s", ssidList[selectedIndex].c_str());
tft.setCursor(10, 22); tft.setCursor(10, 22);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("Passwort:"); tft.println("Password:");
tft.fillRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_NAVY); tft.fillRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_NAVY);
tft.drawRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_DARKGREY); tft.drawRect(8, 38, Config::SCREEN_WIDTH - 16, 22, TFT_DARKGREY);
tft.setCursor(12, 44); tft.setCursor(12, 44);
tft.setTextColor(TFT_YELLOW, TFT_NAVY); tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.print(passwordBuf); tft.print(passwordBuf);
int16_t rowY0 = KB_TOP; int16_t rowY0 = KB_TOP;
@@ -363,15 +367,15 @@ bool run(TFT_eSPI& tft) {
Rect spaceBtn = {SIDE_MARGIN, rowYFn, 150, ROW_H}; Rect spaceBtn = {SIDE_MARGIN, rowYFn, 150, ROW_H};
Rect connectBtn = {SIDE_MARGIN + 154, rowYFn, Config::SCREEN_WIDTH - 2*SIDE_MARGIN - 154, 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}; Rect backBtn = {SIDE_MARGIN, (int16_t)(rowYFn + ROW_H + ROW_GAP), (int16_t)(Config::SCREEN_WIDTH - 2*SIDE_MARGIN), 28};
drawButton(tft, spaceBtn, "Leerzeichen"); drawButton(tft, spaceBtn, "Space");
drawButton(tft, connectBtn, "Verbinden"); drawButton(tft, connectBtn, "Connect");
drawButton(tft, backBtn, "Zurueck zur Liste"); drawButton(tft, backBtn, "Back to list");
} }
delay(20); delay(20);
} }
return false; // uebersprungen return false;
} }
} }