From 52c8009296ace98f21934a15e04ff1181ee42a45 Mon Sep 17 00:00:00 2001 From: Eiswolf-BG <37225519+Eiswolf-BG@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:28:42 +0200 Subject: [PATCH] v1: Live ADS-B radar with detail panel, units, legend, proximity LED alert --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 5 + .vscode/extensions.json | 10 + platformio.ini | 37 +++ src/adsb_client.cpp | 127 +++++++++++ src/adsb_client.h | 17 ++ src/aircraft.h | 27 +++ src/aircraft_details.cpp | 102 +++++++++ src/aircraft_details.h | 27 +++ src/aircraft_table.cpp | 67 ++++++ src/aircraft_table.h | 26 +++ src/airline_lookup.cpp | 109 +++++++++ src/airline_lookup.h | 12 + src/calibration_screen.cpp | 102 +++++++++ src/calibration_screen.h | 10 + src/config.h | 70 ++++++ src/led_alert.cpp | 43 ++++ src/led_alert.h | 19 ++ src/location_manager.cpp | 183 +++++++++++++++ src/location_manager.h | 23 ++ src/main.cpp | 172 ++++++++++++++ src/menu_screen.cpp | 65 ++++++ src/menu_screen.h | 9 + src/net_task.cpp | 85 +++++++ src/net_task.h | 10 + src/radar_math.cpp | 49 ++++ src/radar_math.h | 20 ++ src/radar_screen.cpp | 446 +++++++++++++++++++++++++++++++++++++ src/radar_screen.h | 30 +++ src/sd_storage.cpp | 114 ++++++++++ src/sd_storage.h | 11 + src/settings_store.cpp | 72 ++++++ src/settings_store.h | 22 ++ src/splash_screen.cpp | 70 ++++++ src/splash_screen.h | 20 ++ src/touch_input.cpp | 125 +++++++++++ src/touch_input.h | 32 +++ src/units.h | 16 ++ src/wifi_manager.cpp | 140 ++++++++++++ src/wifi_manager.h | 27 +++ src/wifi_setup_screen.cpp | 377 +++++++++++++++++++++++++++++++ src/wifi_setup_screen.h | 11 + 42 files changed, 2939 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 platformio.ini create mode 100644 src/adsb_client.cpp create mode 100644 src/adsb_client.h create mode 100644 src/aircraft.h create mode 100644 src/aircraft_details.cpp create mode 100644 src/aircraft_details.h create mode 100644 src/aircraft_table.cpp create mode 100644 src/aircraft_table.h create mode 100644 src/airline_lookup.cpp create mode 100644 src/airline_lookup.h create mode 100644 src/calibration_screen.cpp create mode 100644 src/calibration_screen.h create mode 100644 src/config.h create mode 100644 src/led_alert.cpp create mode 100644 src/led_alert.h create mode 100644 src/location_manager.cpp create mode 100644 src/location_manager.h create mode 100644 src/main.cpp create mode 100644 src/menu_screen.cpp create mode 100644 src/menu_screen.h create mode 100644 src/net_task.cpp create mode 100644 src/net_task.h create mode 100644 src/radar_math.cpp create mode 100644 src/radar_math.h create mode 100644 src/radar_screen.cpp create mode 100644 src/radar_screen.h create mode 100644 src/sd_storage.cpp create mode 100644 src/sd_storage.h create mode 100644 src/settings_store.cpp create mode 100644 src/settings_store.h create mode 100644 src/splash_screen.cpp create mode 100644 src/splash_screen.h create mode 100644 src/touch_input.cpp create mode 100644 src/touch_input.h create mode 100644 src/units.h create mode 100644 src/wifi_manager.cpp create mode 100644 src/wifi_manager.h create mode 100644 src/wifi_setup_screen.cpp create mode 100644 src/wifi_setup_screen.h diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..57c4496b2f83c9cdf2d0442b5fc947c33f051a41 GIT binary patch literal 6148 zcmeHKy-veG47U3tT7g7I#=HQCJ^`T$J5nWPM2aXtQe5pGQZd`SJZG~Yd+XAvvnvOvg7?x za3_uxeRc+%fhq${eeB8of3&{;uLk*(GvEyTD+aig43hyq$?n$L$H`qAVVq)!h+nR_ k3c(^=#qgD@cn2d0^pPxpv0<(V4aENl1R8vD27Z)*4@(zhGXMYp literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..02c442a --- /dev/null +++ b/platformio.ini @@ -0,0 +1,37 @@ +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 + +lib_deps = + bodmer/TFT_eSPI @ ^2.5.43 + https://github.com/PaulStoffregen/XPT2046_Touchscreen.git + bblanchon/ArduinoJson @ ^7.0.4 + mikalhart/TinyGPSPlus @ ^1.1.0 + +build_flags = + -D USER_SETUP_LOADED=1 + -D ILI9341_2_DRIVER=1 + -D TFT_WIDTH=240 + -D TFT_HEIGHT=320 + -D TFT_MISO=12 + -D TFT_MOSI=13 + -D TFT_SCLK=14 + -D TFT_CS=15 + -D TFT_DC=2 + -D TFT_RST=-1 + -D TFT_BL=21 + -D TFT_BACKLIGHT_ON=HIGH + -D SPI_FREQUENCY=55000000 + -D SPI_READ_FREQUENCY=20000000 + -D SPI_TOUCH_FREQUENCY=2500000 + -D LOAD_GLCD=1 + -D LOAD_FONT2=1 + -D LOAD_FONT4=1 + -D LOAD_FONT6=1 + -D LOAD_FONT7=1 + -D LOAD_FONT8=1 + -D LOAD_GFXFF=1 + -D SMOOTH_FONT=1 \ No newline at end of file diff --git a/src/adsb_client.cpp b/src/adsb_client.cpp new file mode 100644 index 0000000..fb7bf6a --- /dev/null +++ b/src/adsb_client.cpp @@ -0,0 +1,127 @@ +#include "adsb_client.h" +#include +#include +#include +#include + +namespace AdsbClient { + +namespace { + const char* kNoValidate = nullptr; + + WiFiClientSecure persistentClient; + bool clientConfigured = false; +} + +void primeTime() { + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + time_t now = time(nullptr); + uint32_t start = millis(); + while (now < 8 * 3600 * 2 && millis() - start < 5000) { + delay(100); + now = time(nullptr); + } +} + +FetchResult fetch(double homeLat, double homeLon, float radiusKm, + Aircraft* table, uint8_t tableCapacity) { + FetchResult result; + + if (WiFi.status() != WL_CONNECTED) { + return result; + } + + if (!clientConfigured) { + persistentClient.setInsecure(); + persistentClient.setTimeout(Config::HTTP_TIMEOUT_MS); + clientConfigured = true; + } + + HTTPClient http; + char url[160]; + snprintf(url, sizeof(url), + "https://%s/api/v3/lat/%.5f/lon/%.5f/dist/%.0f", + Config::ADSB_API_HOST, homeLat, homeLon, radiusKm); + + http.setTimeout(Config::HTTP_TIMEOUT_MS); + if (!http.begin(persistentClient, url)) { + return result; + } + http.setReuse(true); + + int code = http.GET(); + result.httpCode = code; + + if (code != HTTP_CODE_OK) { + http.end(); + return result; + } + JsonDocument filter; + JsonObject filterAc = filter["ac"].add(); + filterAc["hex"] = true; + filterAc["flight"] = true; + filterAc["r"] = true; + filterAc["t"] = true; + filterAc["lat"] = true; + filterAc["lon"] = true; + filterAc["alt_baro"] = true; + filterAc["baro_rate"]= true; + filterAc["gs"] = true; + filterAc["track"] = true; + + JsonDocument doc; + DeserializationError err = deserializeJson( + doc, http.getStream(), DeserializationOption::Filter(filter)); + + http.end(); + + if (err) { + result.ok = false; + return result; + } + + JsonArray acArray = doc["ac"].as(); + uint8_t idx = 0; + for (JsonObject ac : acArray) { + if (idx >= tableCapacity) break; + + Aircraft& a = table[idx]; + a = Aircraft{}; + + const char* hex = ac["hex"] | ""; + strncpy(a.hex, hex, sizeof(a.hex) - 1); + + const char* flight = ac["flight"] | ""; + strncpy(a.callsign, flight, sizeof(a.callsign) - 1); + + const char* reg = ac["r"] | ""; + strncpy(a.reg, reg, sizeof(a.reg) - 1); + + const char* type = ac["t"] | ""; + strncpy(a.typeCode, type, sizeof(a.typeCode) - 1); + + a.lat = ac["lat"] | 0.0f; + a.lon = ac["lon"] | 0.0f; + + if (ac["alt_baro"].is()) { + a.altBaroFt = 0; + } else { + a.altBaroFt = ac["alt_baro"] | 0; + } + + a.vertRateFtMin = ac["baro_rate"] | 0; + a.groundSpeedKt = ac["gs"] | 0.0f; + a.headingDeg = ac["track"] | 0.0f; + + a.lastSeenMs = millis(); + a.valid = (a.lat != 0.0f || a.lon != 0.0f); + + if (a.valid) idx++; + } + + result.ok = true; + result.aircraftCount = idx; + return result; +} + +} \ No newline at end of file diff --git a/src/adsb_client.h b/src/adsb_client.h new file mode 100644 index 0000000..97b6cc4 --- /dev/null +++ b/src/adsb_client.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include "aircraft.h" +#include "config.h" + +namespace AdsbClient { + + struct FetchResult { + bool ok = false; + uint16_t aircraftCount = 0; + int httpCode = 0; + }; + FetchResult fetch(double homeLat, double homeLon, float radiusKm, + Aircraft* table, uint8_t tableCapacity); + void primeTime(); + +} \ No newline at end of file diff --git a/src/aircraft.h b/src/aircraft.h new file mode 100644 index 0000000..0ad3ebe --- /dev/null +++ b/src/aircraft.h @@ -0,0 +1,27 @@ +#pragma once +#include +struct Aircraft { + char hex[7] = {0}; + char callsign[9] = {0}; + char reg[9] = {0}; + char typeCode[5] = {0}; + + float lat = 0; + float lon = 0; + int32_t altBaroFt = 0; // barometric altitude, ft + int16_t vertRateFtMin = 0; // vertical speed, ft/min (+climb / -descend) + float groundSpeedKt = 0; + float headingDeg = 0; // track/heading + + float distanceKm = 0; // computed relative to home, via Haversine + float bearingDeg = 0; // computed relative to home + + uint32_t lastSeenMs = 0; // millis() at last update, for stale-entry eviction + bool alerted = false;// whether proximity beep already fired this pass + 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 +}; \ No newline at end of file diff --git a/src/aircraft_details.cpp b/src/aircraft_details.cpp new file mode 100644 index 0000000..44dc68d --- /dev/null +++ b/src/aircraft_details.cpp @@ -0,0 +1,102 @@ +#include "aircraft_details.h" +#include +#include +#include +#include +#include +#include + +namespace AircraftDetails { + +namespace { + SemaphoreHandle_t mutex = nullptr; + + char pendingHex[7] = {0}; + bool hasPending = false; + + char cachedHex[7] = {0}; + Info cached; + + void ensureMutex() { + if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); + } + + bool httpGetString(WiFiClientSecure& client, const String& url, String& outBody) { + HTTPClient http; + http.setTimeout(5000); + if (!http.begin(client, url)) return false; + int code = http.GET(); + bool ok = (code == HTTP_CODE_OK); + if (ok) outBody = http.getString(); + http.end(); + return ok; + } +} + +void request(const char* hex) { + ensureMutex(); + xSemaphoreTake(mutex, portMAX_DELAY); + if (strcmp(cachedHex, hex) != 0 && strcmp(pendingHex, hex) != 0) { + strncpy(pendingHex, hex, sizeof(pendingHex) - 1); + hasPending = true; + } + xSemaphoreGive(mutex); +} + +Info get(const char* hex) { + ensureMutex(); + xSemaphoreTake(mutex, portMAX_DELAY); + Info out; + if (strcmp(cachedHex, hex) == 0) { + out = cached; + } else if (strcmp(pendingHex, hex) == 0 && hasPending) { + out.loading = true; + } + xSemaphoreGive(mutex); + return out; +} + +void update() { + ensureMutex(); + + char hex[7] = {0}; + bool doWork = false; + + xSemaphoreTake(mutex, portMAX_DELAY); + if (hasPending) { + strncpy(hex, pendingHex, sizeof(hex) - 1); + doWork = true; + } + xSemaphoreGive(mutex); + + if (!doWork) return; + + Info result; + + WiFiClientSecure client; + client.setInsecure(); + client.setTimeout(5000); + + String body; + if (httpGetString(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, body); + if (!err) { + const char* manufacturer = doc["Manufacturer"] | ""; + const char* type = doc["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; + hasPending = false; + xSemaphoreGive(mutex); +} + +} \ No newline at end of file diff --git a/src/aircraft_details.h b/src/aircraft_details.h new file mode 100644 index 0000000..0436cd7 --- /dev/null +++ b/src/aircraft_details.h @@ -0,0 +1,27 @@ +#pragma once +#include + +// Additional aircraft details (model) that are NOT part of the ADS-B signal +// and get looked up via the free hexdb.io community database by hex code - +// only for the currently selected aircraft (not for all of them, to keep +// network load low). +namespace AircraftDetails { + + struct Info { + bool loading = false; + char model[40] = {0}; // e.g. "Airbus A320 216", empty if unknown + }; + + // Called from Core 1 (touch selection): marks that details should be + // fetched for this aircraft (if not already done). + void request(const char* hex); + + // Called from Core 1 to get the current (possibly still incomplete) + // state for 'hex'. + Info get(const char* hex); + + // Called periodically from NetTask (Core 0): performs a pending request + // (blocking HTTPS call, but that's fine - runs in the background and + // only briefly delays the next ADS-B poll). + void update(); +} \ No newline at end of file diff --git a/src/aircraft_table.cpp b/src/aircraft_table.cpp new file mode 100644 index 0000000..b3eb258 --- /dev/null +++ b/src/aircraft_table.cpp @@ -0,0 +1,67 @@ +#include "aircraft_table.h" +#include "radar_math.h" +#include +#include +#include + +// WICHTIG: Diese Datei ruft absichtlich KEIN AirlineLookup::resolve() mehr auf! +// postFetchUpdate() wird vom NetTask auf Core 0 aufgerufen. AirlineLookup +// braucht SD-Kartenzugriff, und die SD-Karte wurde in setup() auf Core 1 +// initialisiert - Zugriff von Core 0 aus fuehrte zu einem Haenger (Task +// Watchdog auf IDLE0). Die Aufloesung der Airline-Namen passiert deshalb +// jetzt in main.cpp/renderAircraftList() auf Core 1 (demselben Core, der +// die SD-Karte urspruenglich initialisiert hat). + +namespace AircraftTable { + +namespace { + Aircraft table[Config::MAX_TRACKED_AIRCRAFT]; + constexpr uint32_t STALE_TIMEOUT_MS = Config::FETCH_INTERVAL_MS * 3; // ~24s + SemaphoreHandle_t mutex = nullptr; + uint32_t versionCounter = 0; +} + +void lock() { xSemaphoreTake(mutex, portMAX_DELAY); } +void unlock() { xSemaphoreGive(mutex); } + +uint32_t version() { return versionCounter; } + +void init() { + if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); + for (auto& a : table) a = Aircraft{}; +} + +Aircraft* raw() { return table; } +uint8_t capacity() { return Config::MAX_TRACKED_AIRCRAFT; } + +uint8_t validCount() { + uint8_t n = 0; + for (auto& a : table) if (a.valid) n++; + return n; +} + +void postFetchUpdate(double homeLat, double homeLon) { + uint32_t now = millis(); + + for (auto& a : table) { + if (!a.valid) continue; + + if (now - a.lastSeenMs > STALE_TIMEOUT_MS) { + a = Aircraft{}; // evict + continue; + } + + auto polar = RadarMath::toPolar(homeLat, homeLon, a.lat, a.lon); + a.distanceKm = polar.distanceKm; + a.bearingDeg = polar.bearingDeg; + } + std::sort(table, table + Config::MAX_TRACKED_AIRCRAFT, + [](const Aircraft& a, const Aircraft& b) { + if (a.valid != b.valid) return a.valid > b.valid; + if (!a.valid) return false; + return a.distanceKm < b.distanceKm; + }); + versionCounter++; +} + +} \ No newline at end of file diff --git a/src/aircraft_table.h b/src/aircraft_table.h new file mode 100644 index 0000000..04bccec --- /dev/null +++ b/src/aircraft_table.h @@ -0,0 +1,26 @@ +#pragma once +#include "aircraft.h" +#include "config.h" + +namespace AircraftTable { + + void init(); + + Aircraft* raw(); + uint8_t capacity(); + uint8_t validCount(); + void postFetchUpdate(double homeLat, double homeLon); + + // Wird bei jedem postFetchUpdate() erhoeht. Damit koennen andere Teile des + // Programms (z.B. der Render-Loop) erkennen, ob sich die Daten seit dem + // letzten Mal ueberhaupt geaendert haben, statt stumpf auf Zeit zu pollen - + // das vermeidet unnoetiges (und flackerndes) Neuzeichnen. + uint32_t version(); + + // Schuetzt den Zugriff auf raw()/validCount()/postFetchUpdate() zwischen + // dem Netzwerk-Task (Core 0, schreibt) und dem Render-Loop (Core 1, liest). + // Aufrufer muss lock() vor und unlock() nach jedem Zugriff aufrufen. + void lock(); + void unlock(); + +} \ No newline at end of file diff --git a/src/airline_lookup.cpp b/src/airline_lookup.cpp new file mode 100644 index 0000000..1122daf --- /dev/null +++ b/src/airline_lookup.cpp @@ -0,0 +1,109 @@ +#include "airline_lookup.h" +#include "sd_storage.h" +#include "config.h" +#include + +namespace AirlineLookup { + +namespace { + constexpr uint8_t CACHE_SIZE = 16; + + struct AirlineCacheEntry { + char icao[4] = {0}; + char name[24] = {0}; + bool used = false; + }; + struct TypeCacheEntry { + char type[5] = {0}; + uint16_t seats = 0; + bool used = false; + }; + + AirlineCacheEntry airlineCache[CACHE_SIZE]; + TypeCacheEntry typeCache[CACHE_SIZE]; + + uint8_t hash3(const char* s) { + uint32_t h = 2166136261u; + for (int i = 0; i < 3 && s[i]; i++) h = (h ^ s[i]) * 16777619u; + return h % CACHE_SIZE; + } + + bool lookupCsv(const char* path, const char* key, char* outValue, size_t outLen) { + if (!SdStorage::isMounted()) return false; + File f = SD.open(path); + if (!f) return false; + + bool found = false; + f.readStringUntil('\n'); + while (f.available()) { + String line = f.readStringUntil('\n'); + int comma = line.indexOf(','); + if (comma < 0) continue; + String k = line.substring(0, comma); + k.trim(); + if (k.equalsIgnoreCase(key)) { + String v = line.substring(comma + 1); + v.trim(); + strncpy(outValue, v.c_str(), outLen - 1); + found = true; + break; + } + } + f.close(); + return found; + } + void extractAirlinePrefix(const char* callsign, char* out /* size 4 */) { + int i = 0; + for (; i < 3 && callsign[i] && isalpha((unsigned char)callsign[i]); i++) { + out[i] = callsign[i]; + } + out[i] = '\0'; + } +} + +void init() { + clearCache(); +} + +void clearCache() { + memset(airlineCache, 0, sizeof(airlineCache)); + memset(typeCache, 0, sizeof(typeCache)); +} + +void resolve(Aircraft& a) { + char prefix[4]; + extractAirlinePrefix(a.callsign, prefix); + if (prefix[0]) { + uint8_t slot = hash3(prefix); + AirlineCacheEntry& c = airlineCache[slot]; + if (c.used && strncmp(c.icao, prefix, 3) == 0) { + strncpy(a.airlineName, c.name, sizeof(a.airlineName) - 1); + } else { + char name[24] = {0}; + if (lookupCsv(Config::SD_AIRLINES_CSV, prefix, name, sizeof(name))) { + strncpy(a.airlineName, name, sizeof(a.airlineName) - 1); + strncpy(c.icao, prefix, 3); + strncpy(c.name, name, sizeof(c.name) - 1); + c.used = true; + } + } + } + + if (a.typeCode[0]) { + uint8_t slot = hash3(a.typeCode); + TypeCacheEntry& c = typeCache[slot]; + if (c.used && strncmp(c.type, a.typeCode, 4) == 0) { + a.estSeats = c.seats; + } else { + char seatsStr[8] = {0}; + if (lookupCsv(Config::SD_AIRCRAFT_TYPES_CSV, a.typeCode, seatsStr, sizeof(seatsStr))) { + a.estSeats = atoi(seatsStr); + strncpy(c.type, a.typeCode, 4); + c.seats = a.estSeats; + c.used = true; + } + } + } +} + +} \ No newline at end of file diff --git a/src/airline_lookup.h b/src/airline_lookup.h new file mode 100644 index 0000000..16542d4 --- /dev/null +++ b/src/airline_lookup.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include "aircraft.h" + +namespace AirlineLookup { + + void init(); + void resolve(Aircraft& a); + + void clearCache(); + +} \ No newline at end of file diff --git a/src/calibration_screen.cpp b/src/calibration_screen.cpp new file mode 100644 index 0000000..bab4bd6 --- /dev/null +++ b/src/calibration_screen.cpp @@ -0,0 +1,102 @@ +#include "calibration_screen.h" +#include "touch_input.h" +#include "config.h" + +namespace CalibrationScreen { + +namespace { + struct RawAvg { + long sumX = 0, sumY = 0; + uint16_t n = 0; + void add(int16_t x, int16_t y) { sumX += x; sumY += y; n++; } + int16_t avgX() const { return n ? (int16_t)(sumX / n) : 0; } + int16_t avgY() const { return n ? (int16_t)(sumY / n) : 0; } + }; + + void drawTarget(TFT_eSPI& tft, int16_t x, int16_t y) { + tft.fillCircle(x, y, 10, TFT_RED); + tft.drawFastHLine(x - 16, y, 32, TFT_RED); + tft.drawFastVLine(x, y - 16, 32, TFT_RED); + 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; + + while (!TouchInput::rawPoint().touched) { + delay(10); + } + + uint32_t sampleStart = millis(); + while (millis() - sampleStart < 250) { + TouchInput::Point p = TouchInput::rawPoint(); + if (p.touched) avg.add(p.x, p.y); + delay(10); + } + + while (TouchInput::rawPoint().touched) { + delay(10); + } + delay(150); // kleine Pause, damit der naechste Tap nicht sofort durchrutscht + + return avg; + } + + void swapIfNeeded(int16_t& lo, int16_t& hi) { + if (lo > hi) { int16_t t = lo; lo = hi; hi = t; } + } +} + +void run(TFT_eSPI& tft) { + const int16_t M = 24; + 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" }, + }; + + RawAvg samples[4]; + + for (uint8_t i = 0; i < 4; i++) { + 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.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 + + swapIfNeeded(xmin, xmax); + swapIfNeeded(ymin, ymax); + + TouchInput::setCalibration(xmin, xmax, ymin, ymax); + TouchInput::saveCalibration(); + + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println("Kalibrierung gespeichert!"); + delay(800); +} + +} \ No newline at end of file diff --git a/src/calibration_screen.h b/src/calibration_screen.h new file mode 100644 index 0000000..e3e891b --- /dev/null +++ b/src/calibration_screen.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include + +namespace CalibrationScreen { + // Blockierend: zeigt 4 Kreise (Ecken), wartet auf Antippen jeweils in + // Reihenfolge, berechnet die Kalibrierung und speichert sie auf der + // SD-Karte (via TouchInput::saveCalibration()). + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..882b3fe --- /dev/null +++ b/src/config.h @@ -0,0 +1,70 @@ +#pragma once +#include + +namespace Config { + constexpr const char* IP_GEO_HOST = "ip-api.com"; + constexpr const char* IP_GEO_PATH = "/json/?fields=status,lat,lon"; + + // 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; }; + constexpr GpsPinPair GPS_PIN_CANDIDATES[] = { + {22, 27, "G22/G27"} + }; + constexpr uint8_t GPS_PIN_CANDIDATE_COUNT = 1; + constexpr uint32_t GPS_BAUD = 9600; + + constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f}; + constexpr uint8_t RANGE_STEP_COUNT = 4; + constexpr uint8_t DEFAULT_RANGE_INDEX = 1; + + constexpr const char* ADSB_API_HOST = "opendata.adsb.fi"; + constexpr uint16_t ADSB_API_PORT = 443; + constexpr uint32_t FETCH_INTERVAL_MS = 8000; + constexpr uint32_t HTTP_TIMEOUT_MS = 6000; + + 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 uint32_t ALERT_RETRIGGER_COOLDOWN_MS = 30000; + + 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_AIRLINES_CSV = "/Flightradar_cyd/airlines.csv"; + constexpr const char* SD_AIRCRAFT_TYPES_CSV = "/Flightradar_cyd/aircraft_types.csv"; + constexpr const char* SD_LOG_DIR = "/Flightradar_cyd/logs"; + constexpr const char* SD_SETTINGS_FILE = "/Flightradar_cyd/config.txt"; + constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt"; + constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt"; + + // 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_MOSI_PIN = 23; + constexpr uint8_t SD_SPI_MISO_PIN = 19; + 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_CS_PIN = 33; + constexpr uint8_t TOUCH_MOSI_PIN = 32; + constexpr uint8_t TOUCH_MISO_PIN = 39; + constexpr uint8_t TOUCH_IRQ_PIN = 36; + + constexpr int16_t SCREEN_WIDTH = 240; + 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_YELLOW_KM = 10.0f; + constexpr float ZONE_AMBER_KM = 5.0f; + constexpr float ZONE_VISUAL_KM = 2.0f; + + constexpr uint16_t COLOR_LOW_ALT_THRESHOLD_FT = 10000; + constexpr uint16_t COLOR_MID_ALT_THRESHOLD_FT = 30000; +} \ No newline at end of file diff --git a/src/led_alert.cpp b/src/led_alert.cpp new file mode 100644 index 0000000..3125b20 --- /dev/null +++ b/src/led_alert.cpp @@ -0,0 +1,43 @@ +#include "led_alert.h" + +namespace LedAlert { + +namespace { + constexpr uint8_t PIN_RED = 4; + constexpr uint8_t PIN_GREEN = 16; + constexpr uint8_t PIN_BLUE = 17; + constexpr uint32_t BLINK_INTERVAL_MS = 400; + + bool initialized = false; + bool blinkState = false; + uint32_t lastToggleMs = 0; +} + +void begin() { + pinMode(PIN_RED, OUTPUT); + pinMode(PIN_GREEN, OUTPUT); + pinMode(PIN_BLUE, OUTPUT); + digitalWrite(PIN_RED, HIGH); // aus (LED ist active-low) + digitalWrite(PIN_GREEN, HIGH); // aus + digitalWrite(PIN_BLUE, HIGH); // aus + initialized = true; +} + +bool update(bool active, uint32_t nowMs) { + if (!initialized) begin(); + + if (!active) { + digitalWrite(PIN_GREEN, HIGH); // aus + blinkState = false; + return false; + } + + if (nowMs - lastToggleMs >= BLINK_INTERVAL_MS) { + lastToggleMs = nowMs; + blinkState = !blinkState; + digitalWrite(PIN_GREEN, blinkState ? LOW : HIGH); // LOW = an, volle Helligkeit (kein PWM noetig) + } + return blinkState; +} + +} \ No newline at end of file diff --git a/src/led_alert.h b/src/led_alert.h new file mode 100644 index 0000000..07eabdd --- /dev/null +++ b/src/led_alert.h @@ -0,0 +1,19 @@ +#pragma once +#include + +// Steuert die diskrete RGB-LED auf der Rueckseite des CYD (kein Lautsprecher +// vorhanden, daher ersetzt die LED den akustischen Naeherungsalarm vom +// Cardputer-Projekt). Pins: Rot=GPIO4, Gruen=GPIO16, Blau=GPIO17, +// active-low (LOW = an). +namespace LedAlert { + + // Einmalig in setup() aufrufen. + void begin(); + + // Haeufig aufrufen (z.B. alle 80-100ms). 'active' = mindestens ein + // Flugzeug ist innerhalb des Alarmradius. Schaltet die gruene LED + // entsprechend an/aus (blinkend, volle Helligkeit) und gibt den + // aktuellen Blink-Zustand zurueck (true = LED gerade an), damit der + // Radar-Bildschirm den betroffenen Punkt synchron mitblinken lassen kann. + bool update(bool active, uint32_t nowMs); +} \ No newline at end of file diff --git a/src/location_manager.cpp b/src/location_manager.cpp new file mode 100644 index 0000000..a9f5e86 --- /dev/null +++ b/src/location_manager.cpp @@ -0,0 +1,183 @@ +#include "location_manager.h" +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LocationManager { + +namespace { + Preferences prefs; + + TinyGPSPlus gps; + HardwareSerial gpsSerial(1); + + + bool gpsEnabled = false; + uint8_t gpsPinIndex = 0; + bool gpsSerialStarted = false; + + double lastLat = 0, lastLon = 0; + bool havePersisted = false; + + bool ipLookupDone = false; + uint32_t lastIpLookupAttemptMs = 0; + constexpr uint32_t IP_LOOKUP_RETRY_MS = 15000; + + Source source = Source::None; + + // Schuetzt lastLat/lastLon/havePersisted/source: der Netzwerk-Task (Core 0) + // schreibt diese Werte, der Render-Loop (Core 1) liest sie ueber + // getHomeLocation()/currentSource(). + SemaphoreHandle_t mutex = nullptr; + + void startGpsSerialIfNeeded() { + if (!gpsEnabled || gpsSerialStarted) return; + const auto& pins = Config::GPS_PIN_CANDIDATES[gpsPinIndex]; + gpsSerial.begin(Config::GPS_BAUD, SERIAL_8N1, pins.rx, pins.tx); + 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) { + prefs.putDouble("homeLat", lat); + prefs.putDouble("homeLon", lon); + xSemaphoreTake(mutex, portMAX_DELAY); + lastLat = lat; + lastLon = lon; + havePersisted = true; + source = newSource; + xSemaphoreGive(mutex); + } +} + +void init() { + if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); + prefs.begin("adsb_radar", false); + gpsEnabled = prefs.getBool("gpsEn", false); + gpsPinIndex = prefs.getUChar("gpsPinIdx", 0); + if (gpsPinIndex >= Config::GPS_PIN_CANDIDATE_COUNT) gpsPinIndex = 0; + + double lat = prefs.getDouble("homeLat", 0.0); + double lon = prefs.getDouble("homeLon", 0.0); + if (lat != 0.0 || lon != 0.0) { + lastLat = lat; + lastLon = lon; + havePersisted = true; + source = Source::Persisted; + } + + startGpsSerialIfNeeded(); +} + +void update() { + if (!gpsEnabled) return; + startGpsSerialIfNeeded(); + + while (gpsSerial.available() > 0) { + gps.encode(gpsSerial.read()); + } + + if (gps.location.isValid() && gps.location.isUpdated()) { + persistLocationAndSource(gps.location.lat(), gps.location.lng(), Source::GpsFix); + } +} + +void requestIpLookupIfNeeded() { + if (ipLookupDone) return; + if (gps.location.isValid()) return; + if (WiFi.status() != WL_CONNECTED) return; + + uint32_t now = millis(); + + if (lastIpLookupAttemptMs != 0 && now - lastIpLookupAttemptMs < IP_LOOKUP_RETRY_MS) { + return; + } + lastIpLookupAttemptMs = now; + + WiFiClient client; + HTTPClient http; + char url[96]; + snprintf(url, sizeof(url), "http://%s%s", Config::IP_GEO_HOST, Config::IP_GEO_PATH); + + if (!http.begin(client, url)) return; + + http.setTimeout(5000); + + int code = http.GET(); + if (code != HTTP_CODE_OK) { http.end(); return; } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, http.getStream()); + http.end(); + if (err) return; + + const char* status = doc["status"] | ""; + if (strcmp(status, "success") != 0) return; + + double lat = doc["lat"] | 0.0; + double lon = doc["lon"] | 0.0; + if (lat == 0.0 && lon == 0.0) return; + + persistLocationAndSource(lat, lon, Source::IpGeolocation); + ipLookupDone = true; +} + +void getHomeLocation(double& lat, double& lon) { + if (gps.location.isValid()) { + lat = gps.location.lat(); + lon = gps.location.lng(); + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + if (havePersisted) { + lat = lastLat; + lon = lastLon; + } + xSemaphoreGive(mutex); +} + +Source currentSource() { + xSemaphoreTake(mutex, portMAX_DELAY); + Source s = source; + xSemaphoreGive(mutex); + return s; +} + +void setManualLocation(double lat, double lon) { + persistLocationAndSource(lat, lon, Source::Manual); +} + +void setGpsEnabled(bool enabled) { + gpsEnabled = enabled; + prefs.putBool("gpsEn", enabled); + if (enabled) { + gpsSerialStarted = false; + startGpsSerialIfNeeded(); + } +} + +bool isGpsEnabled() { return gpsEnabled; } + +void cycleGpsPinPair() { + gpsPinIndex = (gpsPinIndex + 1) % Config::GPS_PIN_CANDIDATE_COUNT; + prefs.putUChar("gpsPinIdx", gpsPinIndex); + gpsSerialStarted = false; + if (gpsEnabled) startGpsSerialIfNeeded(); +} + +const char* currentGpsPinLabel() { + return Config::GPS_PIN_CANDIDATES[gpsPinIndex].label; +} + +bool hasGpsFix() { return gps.location.isValid(); } + +} \ No newline at end of file diff --git a/src/location_manager.h b/src/location_manager.h new file mode 100644 index 0000000..ba86383 --- /dev/null +++ b/src/location_manager.h @@ -0,0 +1,23 @@ +#pragma once +#include + +namespace LocationManager { + + enum class Source { GpsFix, IpGeolocation, Manual, Persisted, None }; + + void init(); + void update(); + void requestIpLookupIfNeeded(); + void getHomeLocation(double& lat, double& lon); + + Source currentSource(); + void setManualLocation(double lat, double lon); + void setGpsEnabled(bool enabled); + bool isGpsEnabled(); + + void cycleGpsPinPair(); + const char* currentGpsPinLabel(); + + bool hasGpsFix(); + +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0997a7f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,172 @@ +#include +#include +#include +#include + +#include "config.h" +#include "aircraft.h" +#include "radar_math.h" +#include "aircraft_table.h" +#include "airline_lookup.h" +#include "sd_storage.h" +#include "wifi_manager.h" +#include "location_manager.h" +#include "adsb_client.h" +#include "touch_input.h" +#include "calibration_screen.h" +#include "wifi_setup_screen.h" +#include "menu_screen.h" +#include "settings_store.h" +#include "net_task.h" +#include "radar_screen.h" +#include "splash_screen.h" +#include "led_alert.h" + +TFT_eSPI tft = TFT_eSPI(); + +constexpr int16_t CONTENT_TOP = 30; +constexpr uint32_t POLL_INTERVAL_MS = 300; +constexpr uint32_t SWEEP_TICK_MS = 80; +uint32_t lastPollMs = 0; +uint32_t lastSweepMs = 0; +uint32_t lastRenderedVersion = 0xFFFFFFFF; +bool forceRedraw = false; + +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; + } +}; + +Rect menuBtn = {Config::SCREEN_WIDTH - 38, 3, 32, 22}; + +void drawMenuButton() { + 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.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.drawString("...", menuBtn.x + menuBtn.w / 2, menuBtn.y + menuBtn.h / 2); + tft.setTextDatum(TL_DATUM); +} + +void drawHeader() { + tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextSize(1); + tft.setCursor(6, 10); + tft.println("Eiswolfs Flightradar"); + drawMenuButton(); +} + +void setup() { + Serial.begin(115200); + delay(300); + + tft.init(); + tft.setRotation(0); + + TouchInput::begin(); + LedAlert::begin(); + + bool sdOk = SdStorage::init(); + if (sdOk) { + SdStorage::seedDefaultDataFiles(); + } + + SettingsStore::load(); + tft.invertDisplay(SettingsStore::displayInverted()); + + WifiMgr::init(); + + SplashScreen::begin(tft); + SplashScreen::setStatusLine(tft, 0, sdOk ? "SD Card: OK" : "SD Card: ERROR", + sdOk ? TFT_WHITE : TFT_RED); + + if (!TouchInput::loadCalibration()) { + CalibrationScreen::run(tft); + } + + bool haveWifiFile = SD.exists(Config::SD_WIFI_CREDENTIALS_FILE); + if (!haveWifiFile) { + WifiSetupScreen::run(tft); + } else { + if (!WifiMgr::hasStoredCredentials()) { + WifiMgr::loadCredentialsFromSd(); + } + SplashScreen::setStatusLine(tft, 1, "Connecting WiFi..."); + WifiMgr::beginConnect(); + + uint32_t waitStart = millis(); + while (WifiMgr::getState() == WifiMgr::State::Connecting && millis() - waitStart < 16000) { + WifiMgr::update(); + delay(100); + } + + if (WifiMgr::getState() == WifiMgr::State::Connected) { + SplashScreen::setStatusLine(tft, 1, String("WiFi OK: ") + WifiMgr::getIP()); + } else { + SplashScreen::setStatusLine(tft, 1, "WiFi FAILED", TFT_RED); + } + } + + AdsbClient::primeTime(); + + SplashScreen::setStatusLine(tft, 2, "Getting location..."); + LocationManager::init(); + uint32_t locStart = millis(); + while (LocationManager::currentSource() == LocationManager::Source::None && + millis() - locStart < 8000) { + LocationManager::requestIpLookupIfNeeded(); + delay(200); + } + SplashScreen::setStatusLine(tft, 2, "Ready!"); + + AircraftTable::init(); + AirlineLookup::init(); + + NetTask::begin(); + + SplashScreen::waitRemaining(); + + drawHeader(); + RadarScreen::render(tft, CONTENT_TOP); +} + +void loop() { + TouchInput::Point tap; + if (TouchInput::wasTapped(tap)) { + if (menuBtn.contains(tap.x, tap.y)) { + MenuScreen::run(tft); + drawHeader(); + forceRedraw = true; + } else if (tap.y >= CONTENT_TOP) { + if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) { + forceRedraw = true; + } + } + } + + if (forceRedraw || millis() - lastPollMs >= POLL_INTERVAL_MS) { + lastPollMs = millis(); + uint32_t currentVersion = AircraftTable::version(); + if (forceRedraw || currentVersion != lastRenderedVersion) { + lastRenderedVersion = currentVersion; + forceRedraw = false; + RadarScreen::render(tft, CONTENT_TOP); + lastSweepMs = millis(); + } + } + + uint32_t nowMs = millis(); + + // Naeherungsalarm (LED) laeuft IMMER, auch wenn gerade das Detail-Fenster + // offen ist - unabhaengig vom Sweep/Radar-Redraw. + RadarScreen::updateProximityAlert(nowMs); + + if (nowMs - lastSweepMs >= SWEEP_TICK_MS) { + uint32_t deltaMs = nowMs - lastSweepMs; + lastSweepMs = nowMs; + RadarScreen::tick(tft, CONTENT_TOP, deltaMs); + } +} \ No newline at end of file diff --git a/src/menu_screen.cpp b/src/menu_screen.cpp new file mode 100644 index 0000000..0fc461b --- /dev/null +++ b/src/menu_screen.cpp @@ -0,0 +1,65 @@ +#include "menu_screen.h" +#include "touch_input.h" +#include "calibration_screen.h" +#include "settings_store.h" +#include "config.h" + +namespace MenuScreen { + +namespace { + struct Rect { + int16_t x, y, w, h; + bool contains(int16_t px, int16_t py) const { + return px >= x && px < x + w && py >= y && py < y + h; + } + }; + + void drawButton(TFT_eSPI& tft, const Rect& r, const String& label) { + tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_NAVY); + tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + tft.setTextDatum(TL_DATUM); + } +} + +void run(TFT_eSPI& tft) { + Rect calibBtn = {10, 60, Config::SCREEN_WIDTH - 20, 40}; + Rect invertBtn = {10, 112, Config::SCREEN_WIDTH - 20, 40}; + Rect backBtn = {10, 260, Config::SCREEN_WIDTH - 20, 40}; + + bool done = false; + while (!done) { + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setCursor(10, 10); + tft.println("Einstellungen"); + + drawButton(tft, calibBtn, "Touch kalibrieren"); + String invertLabel = SettingsStore::displayInverted() + ? "Display: invertiert (antippen)" + : "Display: normal (antippen)"; + drawButton(tft, invertBtn, invertLabel); + drawButton(tft, backBtn, "Zurueck"); + + // Auf genau einen Tap warten + 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 (backBtn.contains(tap.x, tap.y)) { + done = true; + } + } +} + +} \ No newline at end of file diff --git a/src/menu_screen.h b/src/menu_screen.h new file mode 100644 index 0000000..9ebc550 --- /dev/null +++ b/src/menu_screen.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include + +namespace MenuScreen { + // Blockierend: einfaches Menue (aktuell nur "Touch kalibrieren"). + // Kehrt zurueck, sobald "Zurueck" angetippt wird. + void run(TFT_eSPI& tft); +} \ No newline at end of file diff --git a/src/net_task.cpp b/src/net_task.cpp new file mode 100644 index 0000000..efc8a49 --- /dev/null +++ b/src/net_task.cpp @@ -0,0 +1,85 @@ +#include "net_task.h" +#include "config.h" +#include "wifi_manager.h" +#include "location_manager.h" +#include "adsb_client.h" +#include "aircraft_table.h" +#include "aircraft.h" +#include "settings_store.h" +#include "aircraft_details.h" +#include +#include +#include +#include + +namespace NetTask { + +namespace { + TaskHandle_t taskHandle = nullptr; + 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]; + + void taskFunc(void*) { + for (;;) { + WifiMgr::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(); + + if (millis() - lastFetchMs >= Config::FETCH_INTERVAL_MS) { + lastFetchMs = millis(); + + if (WifiMgr::getState() == WifiMgr::State::Connected) { + LocationManager::requestIpLookupIfNeeded(); + + double lat = 0, lon = 0; + LocationManager::getHomeLocation(lat, lon); + + 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, + tempTable, Config::MAX_TRACKED_AIRCRAFT); + + if (result.ok) { + AircraftTable::lock(); + memcpy(AircraftTable::raw(), tempTable, + sizeof(Aircraft) * Config::MAX_TRACKED_AIRCRAFT); + AircraftTable::postFetchUpdate(lat, lon); + AircraftTable::unlock(); + } else { + Serial.printf("[NetTask] Abfrage fehlgeschlagen (HTTP %d)\n", result.httpCode); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(50)); + } + } +} + +void begin() { + xTaskCreatePinnedToCore( + taskFunc, + "NetTask", + 20480, // Stack: TLS-Handshake + JSON-Parsing braucht mehr als das Minimum + nullptr, + 1, // Prioritaet + &taskHandle, + 0 // Core 0 (Core 1 bleibt frei fuer Rendering/Touch im main-Loop) + ); +} + +} \ No newline at end of file diff --git a/src/net_task.h b/src/net_task.h new file mode 100644 index 0000000..009df87 --- /dev/null +++ b/src/net_task.h @@ -0,0 +1,10 @@ +#pragma once + +// Hintergrund-Task fuer WLAN-Status, Standortbestimmung und ADS-B-Abfragen. +// Laeuft auf Core 0, damit Core 1 (Rendering + Touch im main-Loop) nie durch +// Netzwerk-Wartezeiten (WLAN, HTTPS) blockiert wird. +namespace NetTask { + // Startet den Hintergrund-Task. Muss erst NACH WifiMgr::init() und + // LocationManager::init() aufgerufen werden. + void begin(); +} \ No newline at end of file diff --git a/src/radar_math.cpp b/src/radar_math.cpp new file mode 100644 index 0000000..e7faedd --- /dev/null +++ b/src/radar_math.cpp @@ -0,0 +1,49 @@ +#include "radar_math.h" +#include + +namespace RadarMath { + +namespace { + constexpr double EARTH_RADIUS_KM = 6371.0088; + constexpr double DEG2RAD = M_PI / 180.0; + constexpr double RAD2DEG = 180.0 / M_PI; +} + +PolarCoord toPolar(double lat0, double lon0, double lat1, double lon1) { + double phi1 = lat0 * DEG2RAD; + double phi2 = lat1 * DEG2RAD; + double dPhi = (lat1 - lat0) * DEG2RAD; + double dLambda = (lon1 - lon0) * DEG2RAD; + + // Haversine + double a = sin(dPhi / 2) * sin(dPhi / 2) + + cos(phi1) * cos(phi2) * sin(dLambda / 2) * sin(dLambda / 2); + double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + double distanceKm = EARTH_RADIUS_KM * c; + + // Initial bearing + double y = sin(dLambda) * cos(phi2); + double x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLambda); + double bearing = atan2(y, x) * RAD2DEG; + bearing = fmod(bearing + 360.0, 360.0); + + return PolarCoord{ static_cast(distanceKm), static_cast(bearing) }; +} + +ScreenPoint toScreen(const PolarCoord& polar, int16_t centerX, int16_t centerY, + int16_t radiusPx, float rangeKm) { + float clampedKm = polar.distanceKm > rangeKm ? rangeKm : polar.distanceKm; + float r = (clampedKm / rangeKm) * radiusPx; + + // bearing 0 = North = "up" on screen = negative Y direction. + double rad = polar.bearingDeg * DEG2RAD; + float dx = r * sin(rad); + float dy = -r * cos(rad); + + return ScreenPoint{ + static_cast(centerX + dx), + static_cast(centerY + dy) + }; +} + +} // namespace RadarMath \ No newline at end of file diff --git a/src/radar_math.h b/src/radar_math.h new file mode 100644 index 0000000..7a7ae8e --- /dev/null +++ b/src/radar_math.h @@ -0,0 +1,20 @@ +#pragma once +#include + +namespace RadarMath { + + struct PolarCoord { + float distanceKm; + float bearingDeg; // 0-360, 0 = North, clockwise + }; + + struct ScreenPoint { + int16_t x; + int16_t y; + }; + PolarCoord toPolar(double lat0, double lon0, double lat1, double lon1); + + ScreenPoint toScreen(const PolarCoord& polar, int16_t centerX, int16_t centerY, + int16_t radiusPx, float rangeKm); + +} \ No newline at end of file diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp new file mode 100644 index 0000000..706b80a --- /dev/null +++ b/src/radar_screen.cpp @@ -0,0 +1,446 @@ +#include "radar_screen.h" +#include "config.h" +#include "aircraft.h" +#include "aircraft_table.h" +#include "airline_lookup.h" +#include "aircraft_details.h" +#include "radar_math.h" +#include "units.h" +#include "settings_store.h" +#include "led_alert.h" +#include + +namespace RadarScreen { + +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; + } + }; + + struct Layout { + int16_t cx, cy, radius; + Rect rangeBtn; + int16_t infoTop; + }; + + constexpr int16_t INFO_BAR_H = 50; + + Layout computeLayout(int16_t top) { + Layout L; + L.infoTop = Config::SCREEN_HEIGHT - INFO_BAR_H; + int16_t maxRadiusByWidth = Config::SCREEN_WIDTH / 2 - 6; + int16_t maxRadiusByHeight = (L.infoTop - top) / 2 - 6; + L.radius = min(maxRadiusByWidth, maxRadiusByHeight); + L.cx = Config::SCREEN_WIDTH / 2; + L.cy = top + L.radius + 6; + L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 70), (int16_t)(L.infoTop + 4), 62, 22}; + return L; + } + + constexpr int16_t DETAIL_PANEL_H = 150; + + struct HitPoint { + int16_t x, y; + bool valid; + char hex[7]; + char callsign[9]; + uint16_t color; + float headingDeg; + float distanceKm; + }; + constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT; + HitPoint hitPoints[MAX_HIT_POINTS]; + + char selectedHex[7] = {0}; + + bool ledBlinkOn = true; + + float sweepAngleDeg = 0.0f; + float prevSweepAngleDeg = -1.0f; + constexpr float SWEEP_DEGREES_PER_SEC = 45.0f; + + uint16_t colorForAltitude(int32_t altFt) { + if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT) return TFT_GREEN; + if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT) return TFT_YELLOW; + return TFT_RED; + } + + void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) { + String s = text; + if (gfx.textWidth(s) > maxWidth) { + while (s.length() > 1 && gfx.textWidth(s + "...") > maxWidth) { + s.remove(s.length() - 1); + } + s += "..."; + } + gfx.setCursor(x, y); + gfx.print(s); + } + + void drawButton(TFT_eSPI& gfx, const Rect& r, const String& label) { + gfx.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_NAVY); + gfx.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + gfx.setTextDatum(MC_DATUM); + gfx.setTextColor(TFT_WHITE, TFT_NAVY); + gfx.drawString(label, r.x + r.w / 2, r.y + r.h / 2); + gfx.setTextDatum(TL_DATUM); + } + + void drawLegend(TFT_eSPI& gfx, int16_t y) { + struct { uint16_t color; const char* label; } items[3] = { + {TFT_GREEN, "<10k ft"}, + {TFT_YELLOW, "10-30k"}, + {TFT_RED, ">30k ft"}, + }; + int16_t segW = Config::SCREEN_WIDTH / 3; + gfx.setTextColor(TFT_WHITE, TFT_BLACK); + for (uint8_t i = 0; i < 3; i++) { + int16_t x0 = i * segW + 6; + gfx.fillCircle(x0, y + 4, 3, items[i].color); + gfx.setCursor(x0 + 7, y); + gfx.print(items[i].label); + } + } + + void drawSweepLine(TFT_eSPI& gfx, const Layout& L, float angleDeg, uint16_t color) { + double rad = angleDeg * DEG_TO_RAD; + int16_t x2 = L.cx + (int16_t)(L.radius * sin(rad)); + int16_t y2 = L.cy - (int16_t)(L.radius * cos(rad)); + gfx.drawLine(L.cx, L.cy, x2, y2, color); + } + + void drawStaticBackground(TFT_eSPI& gfx, const Layout& L, float rangeKm) { + gfx.drawCircle(L.cx, L.cy, L.radius, TFT_DARKGREY); + gfx.drawCircle(L.cx, L.cy, L.radius * 2 / 3, TFT_DARKGREY); + gfx.drawCircle(L.cx, L.cy, L.radius / 3, TFT_DARKGREY); + gfx.drawFastHLine(L.cx - L.radius, L.cy, L.radius * 2, TFT_DARKGREY); + gfx.drawFastVLine(L.cx, L.cy - L.radius, L.radius * 2, TFT_DARKGREY); + + gfx.setTextColor(TFT_DARKGREY, TFT_BLACK); + gfx.setTextDatum(MC_DATUM); + gfx.drawString("N", L.cx, L.cy - L.radius - 8); + char ringLabel[8]; + snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm / 3); + gfx.drawString(ringLabel, L.cx, L.cy - L.radius / 3); + snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm * 2 / 3); + gfx.drawString(ringLabel, L.cx, L.cy - L.radius * 2 / 3); + gfx.setTextDatum(TL_DATUM); + } + + struct PanelState { + bool valid = false; + char hex[7] = {0}; + String callsignText, airlineText, modelText, + altText, speedText, distHeadingText, seatsText; + }; + PanelState lastPanel; + + void updateLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth, + uint16_t fg, String& cached, const String& newText, bool forceFull) { + if (!forceFull && cached == newText) return; + gfx.fillRect(0, y - 2, Config::SCREEN_WIDTH, h, TFT_NAVY); + gfx.setTextColor(fg, TFT_NAVY); + printLineTruncated(gfx, 8, y, maxWidth, newText); + cached = newText; + } + + void drawDetailPanel(TFT_eSPI& gfx, Aircraft& a) { + AirlineLookup::resolve(a); + AircraftDetails::Info details = AircraftDetails::get(a.hex); + + int16_t panelTop = Config::SCREEN_HEIGHT - DETAIL_PANEL_H; + constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 16; + + bool forceFull = !lastPanel.valid || strcmp(lastPanel.hex, a.hex) != 0; + if (forceFull) { + gfx.fillRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_NAVY); + gfx.drawRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_DARKGREY); + lastPanel = PanelState{}; + strncpy(lastPanel.hex, a.hex, sizeof(lastPanel.hex) - 1); + } + + int16_t y = panelTop + 6; + + { + String txt = a.callsign[0] ? a.callsign : a.hex; + if (forceFull || lastPanel.callsignText != txt) { + gfx.fillRect(0, y - 2, Config::SCREEN_WIDTH, 20, TFT_NAVY); + gfx.setTextColor(TFT_WHITE, TFT_NAVY); + gfx.setTextSize(2); + printLineTruncated(gfx, 8, y, textMaxWidth, txt); + gfx.setTextSize(1); + lastPanel.callsignText = txt; + } + } + y += 22; + + updateLine(gfx, y, 15, textMaxWidth, TFT_GREEN, lastPanel.airlineText, + 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"); + } + updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.modelText, modelLine, forceFull); + y += 15; + + char buf[40]; + snprintf(buf, sizeof(buf), "Alt: %.0fm / %.0fft", + 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", + 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); + updateLine(gfx, y, 15, textMaxWidth, TFT_WHITE, lastPanel.distHeadingText, String(buf), forceFull); + y += 15; + + String seatsLine = a.estSeats > 0 + ? String("Seats (estimated): ") + a.estSeats + : String("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"); + } + + lastPanel.valid = true; + } +} + +void render(TFT_eSPI& tft, int16_t top) { + Layout L = computeLayout(top); + float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; + + bool panelAlreadyOpen = selectedHex[0] && lastPanel.valid && + strcmp(lastPanel.hex, selectedHex) == 0; + + if (panelAlreadyOpen) { + AircraftTable::lock(); + Aircraft* table = AircraftTable::raw(); + Aircraft selected{}; + bool found = false; + for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { + if (table[i].valid && strcmp(table[i].hex, selectedHex) == 0) { + selected = table[i]; + found = true; + break; + } + } + AircraftTable::unlock(); + + if (found && selected.distanceKm <= rangeKm * 1.05f) { + tft.startWrite(); + drawDetailPanel(tft, selected); + tft.endWrite(); + return; + } + selectedHex[0] = 0; + lastPanel.valid = false; + } + + tft.startWrite(); + + tft.fillRect(0, top, Config::SCREEN_WIDTH, Config::SCREEN_HEIGHT - top, TFT_BLACK); + + drawStaticBackground(tft, L, rangeKm); + + drawSweepLine(tft, L, sweepAngleDeg, TFT_GREEN); + prevSweepAngleDeg = sweepAngleDeg; + + tft.fillCircle(L.cx, L.cy, 3, TFT_WHITE); + + 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(); + + bool selectionStillPresent = false; + Aircraft selected{}; + + for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false; + + for (uint8_t i = 0; i < count && i < MAX_HIT_POINTS; i++) { + Aircraft& a = snapshot[i]; + if (a.distanceKm > rangeKm * 1.05f) continue; + + RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; + RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); + + uint16_t color = colorForAltitude(a.altBaroFt); + bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0; + + if (isSelected) { + tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); + selectionStillPresent = true; + selected = a; + } + tft.fillCircle(pt.x, pt.y, 5, color); + + double rad = a.headingDeg * PI / 180.0; + int16_t dx = (int16_t)(sin(rad) * 10); + int16_t dy = (int16_t)(-cos(rad) * 10); + tft.drawLine(pt.x, pt.y, pt.x + dx, pt.y + dy, color); + + tft.setTextColor(color, TFT_BLACK); + tft.setTextDatum(BC_DATUM); + const char* label = a.callsign[0] ? a.callsign : a.hex; + tft.drawString(label, pt.x, pt.y - 8); + tft.setTextDatum(TL_DATUM); + + hitPoints[i].x = pt.x; + hitPoints[i].y = pt.y; + hitPoints[i].valid = true; + hitPoints[i].color = color; + hitPoints[i].headingDeg = a.headingDeg; + hitPoints[i].distanceKm = a.distanceKm; + strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); + strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); + } + + if (selectedHex[0] && !selectionStillPresent) { + selectedHex[0] = 0; + } + + if (selectionStillPresent) { + tft.endWrite(); + drawDetailPanel(tft, selected); + tft.startWrite(); + } else { + lastPanel.valid = false; + int16_t infoTop = L.infoTop; + 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"); + + char rangeLabel[8]; + snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); + drawButton(tft, L.rangeBtn, rangeLabel); + + drawLegend(tft, infoTop + 30); + } + + tft.endWrite(); +} + +void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { + if (selectedHex[0]) return; + + Layout L = computeLayout(top); + float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; + + if (prevSweepAngleDeg >= 0.0f) { + drawSweepLine(tft, L, prevSweepAngleDeg, TFT_BLACK); + drawStaticBackground(tft, L, rangeKm); + tft.fillCircle(L.cx, L.cy, 3, TFT_WHITE); + } + + sweepAngleDeg += SWEEP_DEGREES_PER_SEC * (deltaMs / 1000.0f); + if (sweepAngleDeg >= 360.0f) sweepAngleDeg -= 360.0f; + + drawSweepLine(tft, L, sweepAngleDeg, TFT_GREEN); + prevSweepAngleDeg = sweepAngleDeg; + + for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { + if (!hitPoints[i].valid) continue; + const HitPoint& hp = hitPoints[i]; + + bool inAlertRange = hp.distanceKm <= Config::LED_ALERT_RADIUS_KM; + if (inAlertRange && !ledBlinkOn) { + tft.fillRect(hp.x - 20, hp.y - 18, 40, 30, TFT_BLACK); + continue; + } + + tft.fillCircle(hp.x, hp.y, 5, hp.color); + + double rad = hp.headingDeg * PI / 180.0; + int16_t dx = (int16_t)(sin(rad) * 10); + int16_t dy = (int16_t)(-cos(rad) * 10); + tft.drawLine(hp.x, hp.y, hp.x + dx, hp.y + dy, hp.color); + + tft.setTextColor(hp.color, TFT_BLACK); + tft.setTextDatum(BC_DATUM); + const char* label = hp.callsign[0] ? hp.callsign : hp.hex; + tft.drawString(label, hp.x, hp.y - 8); + tft.setTextDatum(TL_DATUM); + } +} + +bool handleTap(int16_t x, int16_t y, int16_t top) { + Layout L = computeLayout(top); + + if (selectedHex[0]) { + for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { + if (!hitPoints[i].valid) continue; + int16_t dx = x - hitPoints[i].x; + int16_t dy = y - hitPoints[i].y; + if (dx * dx + dy * dy <= 12 * 12) { + strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1); + AircraftDetails::request(hitPoints[i].hex); + return true; + } + } + selectedHex[0] = 0; + lastPanel.valid = false; + return true; + } + + if (L.rangeBtn.contains(x, y)) { + uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT; + SettingsStore::setRangeIndex(idx); + return true; + } + + for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { + if (!hitPoints[i].valid) continue; + int16_t dx = x - hitPoints[i].x; + int16_t dy = y - hitPoints[i].y; + if (dx * dx + dy * dy <= 12 * 12) { + strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1); + AircraftDetails::request(hitPoints[i].hex); + return true; + } + } + + return true; +} + +void updateProximityAlert(uint32_t nowMs) { + bool anyClose = false; + + AircraftTable::lock(); + Aircraft* table = AircraftTable::raw(); + for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { + if (table[i].valid && table[i].distanceKm <= Config::LED_ALERT_RADIUS_KM) { + anyClose = true; + break; + } + } + AircraftTable::unlock(); + + ledBlinkOn = LedAlert::update(anyClose, nowMs); +} + +} \ No newline at end of file diff --git a/src/radar_screen.h b/src/radar_screen.h new file mode 100644 index 0000000..d57c2c5 --- /dev/null +++ b/src/radar_screen.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include + +namespace RadarScreen { + // Zeichnet den kompletten Radar-Frame neu (Ringe, Flugzeuge als Punkte, + // Rufzeichen-Label, Sweep-Strahl, Auswahl-Infoleiste, Reichweiten-Button) + // im Bereich von y=top bis zum unteren Bildschirmrand. Wird nur bei + // tatsaechlichen Datenaenderungen oder nach Touch-Interaktion aufgerufen + // (siehe main.cpp), NICHT bei jedem Sweep-Tick - sonst wuerde es flackern. + void render(TFT_eSPI& tft, int16_t top); + + // Guenstiger, haeufiger Aufruf (z.B. alle 100ms): dreht NUR den + // Sweep-Strahl ein Stueck weiter und zeichnet ihn (plus dezente + // Nachzieh-Linien), OHNE den Bildschirm vorher zu loeschen. So entsteht + // eine fluessige Drehbewegung ohne Vollbild-Neuzeichnen (= kein Flackern). + // deltaMs = vergangene Zeit seit dem letzten tick()-Aufruf. + void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs); + + // Verarbeitet einen Touch-Tap (Reichweite umschalten, Flugzeug auswaehlen, + // leere Flaeche antippen = Auswahl aufheben). Gibt true zurueck, wenn der + // Tap im Radar-Bereich (y >= top) verarbeitet wurde. + bool handleTap(int16_t x, int16_t y, int16_t top); + + // Haeufig aufrufen (unabhaengig davon, ob ein Detail-Fenster offen ist): + // prueft, ob ein Flugzeug innerhalb des Alarmradius ist, steuert die + // gruene LED entsprechend und merkt sich den Blink-Zustand, damit tick() + // den betroffenen Punkt synchron mitblinken lassen kann. + void updateProximityAlert(uint32_t nowMs); +} \ No newline at end of file diff --git a/src/sd_storage.cpp b/src/sd_storage.cpp new file mode 100644 index 0000000..a27c39d --- /dev/null +++ b/src/sd_storage.cpp @@ -0,0 +1,114 @@ +#include "sd_storage.h" +#include "config.h" +#include +#include +#include + +namespace SdStorage { + +namespace { + bool mounted = false; + SPIClass sdSpi(HSPI); + const char* kDefaultAirlinesCsv = + "icao,name\n" + "BAW,British Airways\n" + "SAA,South African Airways\n" + "CAW,Comair\n" + "FLY,Safair (FlySafair)\n" + "KLM,KLM Royal Dutch Airlines\n" + "DLH,Lufthansa\n" + "UAE,Emirates\n" + "QTR,Qatar Airways\n" + "ETH,Ethiopian Airlines\n" + "AFR,Air France\n" + "SWR,Swiss International\n" + "BAW,British Airways\n" + "MSR,EgyptAir\n" + "KQA,Kenya Airways\n" + "UAL,United Airlines\n" + "DAL,Delta Air Lines\n" + "AAL,American Airlines\n"; + + const char* kDefaultAircraftTypesCsv = + "type,seats\n" + "A320,180\n" + "A321,220\n" + "A319,140\n" + "A332,278\n" + "A333,277\n" + "A359,314\n" + "A388,469\n" + "B738,189\n" + "B737,148\n" + "B739,180\n" + "B77W,365\n" + "B788,242\n" + "B789,296\n" + "E190,100\n" + "CRJ2,50\n" + "CRJ9,90\n" + "DH8D,78\n"; + + bool ensureDir(const char* path) { + if (SD.exists(path)) return true; + return SD.mkdir(path); + } + + void writeIfAbsent(const char* path, const char* contents) { + if (SD.exists(path)) return; + File f = SD.open(path, FILE_WRITE); + if (!f) return; + f.print(contents); + f.close(); + } +} + +bool init() { + Serial.printf("[SD] Pins: CLK=%d MISO=%d MOSI=%d CS=%d\n", + Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN, + Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN); + + Serial.println("[SD] vor sdSpi.begin()"); + sdSpi.begin(Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN, + Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN); + Serial.println("[SD] sdSpi.begin() fertig"); + + Serial.println("[SD] vor SD.begin()"); + mounted = SD.begin(Config::SD_SPI_CS_PIN, sdSpi, 4000000); + Serial.printf("[SD] SD.begin() fertig, mounted=%d\n", mounted); + + if (!mounted) return false; + + Serial.println("[SD] vor ensureDir(ROOT)"); + ensureDir(Config::SD_ROOT_DIR); + Serial.println("[SD] vor ensureDir(LOG)"); + ensureDir(Config::SD_LOG_DIR); + Serial.println("[SD] init() komplett fertig"); + return true; +} + +bool isMounted() { return mounted; } + +void seedDefaultDataFiles() { + if (!mounted) return; + writeIfAbsent(Config::SD_AIRLINES_CSV, kDefaultAirlinesCsv); + writeIfAbsent(Config::SD_AIRCRAFT_TYPES_CSV, kDefaultAircraftTypesCsv); +} + +void logEvent(const char* csvLine) { + if (!mounted) return; + + time_t now = time(nullptr); + struct tm tmNow; + localtime_r(&now, &tmNow); + char filename[64]; + snprintf(filename, sizeof(filename), "%s/%04d-%02d-%02d.csv", + Config::SD_LOG_DIR, tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday); + + File f = SD.open(filename, FILE_APPEND); + if (!f) return; + f.println(csvLine); + f.close(); +} + +} \ No newline at end of file diff --git a/src/sd_storage.h b/src/sd_storage.h new file mode 100644 index 0000000..5dad7fa --- /dev/null +++ b/src/sd_storage.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace SdStorage { + + bool init(); + bool isMounted(); + void seedDefaultDataFiles(); + void logEvent(const char* csvLine); + +} \ No newline at end of file diff --git a/src/settings_store.cpp b/src/settings_store.cpp new file mode 100644 index 0000000..b018a18 --- /dev/null +++ b/src/settings_store.cpp @@ -0,0 +1,72 @@ +#include "settings_store.h" +#include "config.h" +#include + +namespace SettingsStore { + +namespace { + uint8_t rangeIdx = Config::DEFAULT_RANGE_INDEX; + bool inverted = false; + + void applyKeyValue(const String& key, const String& value) { + if (key == "range_index") { + int v = value.toInt(); + if (v >= 0 && v < Config::RANGE_STEP_COUNT) { + rangeIdx = (uint8_t)v; + } + } else if (key == "invert") { + inverted = (value.toInt() != 0); + } + // Weitere Einstellungen hier ergaenzen (unbekannte Keys werden + // einfach ignoriert, damit alte config.txt-Dateien kompatibel bleiben). + } +} + +void load() { + if (!SD.exists(Config::SD_SETTINGS_FILE)) return; + + File f = SD.open(Config::SD_SETTINGS_FILE, FILE_READ); + if (!f) return; + + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0 || line.startsWith("#")) continue; + + int eq = line.indexOf('='); + if (eq < 0) continue; + + String key = line.substring(0, eq); + String value = line.substring(eq + 1); + key.trim(); + value.trim(); + applyKeyValue(key, value); + } + f.close(); +} + +void save() { + File f = SD.open(Config::SD_SETTINGS_FILE, FILE_WRITE); + if (!f) return; + f.printf("range_index=%d\n", rangeIdx); + f.printf("invert=%d\n", inverted ? 1 : 0); + f.close(); +} + +uint8_t rangeIndex() { return rangeIdx; } + +void setRangeIndex(uint8_t idx) { + if (idx < Config::RANGE_STEP_COUNT) { + rangeIdx = idx; + save(); + } +} + +bool displayInverted() { return inverted; } + +void setDisplayInverted(bool inv) { + inverted = inv; + save(); +} + +} \ No newline at end of file diff --git a/src/settings_store.h b/src/settings_store.h new file mode 100644 index 0000000..2388a47 --- /dev/null +++ b/src/settings_store.h @@ -0,0 +1,22 @@ +#pragma once +#include + +// 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 { + + // Liest config.txt von der SD-Karte (falls vorhanden). Werte, die nicht + // in der Datei stehen, behalten ihren Standardwert. + void load(); + + // Schreibt die aktuellen Werte zurueck auf die SD-Karte. + void save(); + + uint8_t rangeIndex(); + void setRangeIndex(uint8_t idx); + + bool displayInverted(); + void setDisplayInverted(bool inverted); +} \ No newline at end of file diff --git a/src/splash_screen.cpp b/src/splash_screen.cpp new file mode 100644 index 0000000..0e1cc32 --- /dev/null +++ b/src/splash_screen.cpp @@ -0,0 +1,70 @@ +#include "splash_screen.h" + +namespace SplashScreen { + +namespace { + constexpr uint32_t MIN_DISPLAY_MS = 5000; + uint32_t startMs = 0; + + constexpr int16_t STATUS_LINE_H = 18; + constexpr int16_t STATUS_START_Y = 260; + constexpr uint8_t MAX_STATUS_LINES = 3; + + // Einfache Vektor-Silhouette eines Flugzeugs von oben (keine Bilddatei + // eingebunden, daher per Dreiecken gezeichnet) - in Gruen, wie der + // Radar-Sweep-Strahl, passend zum restlichen Dark-Theme. + void drawAirplane(TFT_eSPI& tft, int16_t cx) { + uint16_t color = TFT_GREEN; + + // Rumpf + tft.fillTriangle(cx, 110, cx - 12, 240, cx + 12, 240, color); + + // Haupttragflaechen + tft.fillTriangle(cx, 170, cx - 110, 230, cx + 110, 230, color); + + // Leitwerk (Heckfluegel) + tft.fillTriangle(cx, 232, cx - 35, 250, cx + 35, 250, color); + } +} + +void begin(TFT_eSPI& tft) { + startMs = millis(); + + int16_t cx = tft.width() / 2; + + tft.fillScreen(TFT_BLACK); + drawAirplane(tft, cx); + + tft.setTextDatum(MC_DATUM); + + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextSize(3); + tft.drawString("Eiswolfs", cx, 40); + + tft.setTextSize(2); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.drawString("Flightradar", cx, 78); + + tft.setTextDatum(TL_DATUM); + tft.setTextSize(1); +} + +void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) { + if (slot >= MAX_STATUS_LINES) return; + + int16_t y = STATUS_START_Y + slot * STATUS_LINE_H; + tft.fillRect(0, y, tft.width(), STATUS_LINE_H, TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(color, TFT_BLACK); + tft.drawString(text, tft.width() / 2, y + STATUS_LINE_H / 2); + tft.setTextDatum(TL_DATUM); +} + +void waitRemaining() { + uint32_t elapsed = millis() - startMs; + if (elapsed < MIN_DISPLAY_MS) { + delay(MIN_DISPLAY_MS - elapsed); + } +} + +} \ No newline at end of file diff --git a/src/splash_screen.h b/src/splash_screen.h new file mode 100644 index 0000000..9c3f2fd --- /dev/null +++ b/src/splash_screen.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +namespace SplashScreen { + // Zeichnet den Splash-Hintergrund (Titel + gruene Flugzeug-Silhouette auf + // schwarzem Grund) und merkt sich den Startzeitpunkt. NICHT blockierend - + // der Aufrufer kann danach normal weiter booten und Status-Zeilen + // draufschreiben (siehe setStatusLine()). + void begin(TFT_eSPI& tft); + + // Schreibt/ueberschreibt eine der (begrenzt vielen) Status-Zeilen unten + // im Splash-Screen, z.B. "SD-Karte: OK" oder "WLAN verbunden". + // slot: 0 = erste Zeile, 1 = zweite Zeile, usw. + void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE); + + // Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind. + // Direkt vor dem Verlassen des Splash-Screens aufrufen. + void waitRemaining(); +} \ No newline at end of file diff --git a/src/touch_input.cpp b/src/touch_input.cpp new file mode 100644 index 0000000..1157a11 --- /dev/null +++ b/src/touch_input.cpp @@ -0,0 +1,125 @@ +#include "touch_input.h" +#include "config.h" +#include +#include +#include + +namespace TouchInput { + +namespace { + SPIClass touchSpi(VSPI); + XPT2046_Touchscreen touch(Config::TOUCH_CS_PIN, Config::TOUCH_IRQ_PIN); + + constexpr int16_t MARGIN_PX = 24; // Abstand der Kalibrierungspunkte vom Rand + + // Rohe Min/Max-Werte aus der Kalibrierung. Bis zur ersten Kalibrierung + // grosszuegige Standardwerte, damit die Kalibrierungs-Routine selbst schon + // grob nutzbare (wenn auch ungenaue) Koordinaten bekommt. + int16_t calXmin = 200; + int16_t calXmax = 3900; + int16_t calYmin = 200; + int16_t calYmax = 3900; + bool calibrationLoaded = false; + + bool lastTouched = false; + Point lastRaw; + + int16_t clampi(int16_t v, int16_t lo, int16_t hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; + } +} + +void begin() { + touchSpi.begin(Config::TOUCH_CLK_PIN, Config::TOUCH_MISO_PIN, + Config::TOUCH_MOSI_PIN, Config::TOUCH_CS_PIN); + touch.begin(touchSpi); + touch.setRotation(0); +} + +bool hasCalibration() { return calibrationLoaded; } + +void setCalibration(int16_t rawXmin, int16_t rawXmax, int16_t rawYmin, int16_t rawYmax) { + calXmin = rawXmin; + calXmax = rawXmax; + calYmin = rawYmin; + calYmax = rawYmax; + calibrationLoaded = true; +} + +bool loadCalibration() { + if (!SD.exists(Config::SD_CALIBRATION_FILE)) return false; + + File f = SD.open(Config::SD_CALIBRATION_FILE, FILE_READ); + if (!f) return false; + + String line = f.readStringUntil('\n'); + f.close(); + line.trim(); + + int p1 = line.indexOf(','); + int p2 = line.indexOf(',', p1 + 1); + int p3 = line.indexOf(',', p2 + 1); + if (p1 < 0 || p2 < 0 || p3 < 0) return false; + + int16_t xmin = line.substring(0, p1).toInt(); + int16_t xmax = line.substring(p1 + 1, p2).toInt(); + int16_t ymin = line.substring(p2 + 1, p3).toInt(); + int16_t ymax = line.substring(p3 + 1).toInt(); + + if (xmax <= xmin || ymax <= ymin) return false; + + setCalibration(xmin, xmax, ymin, ymax); + return true; +} + +void saveCalibration() { + File f = SD.open(Config::SD_CALIBRATION_FILE, FILE_WRITE); + if (!f) return; + f.printf("%d,%d,%d,%d\n", calXmin, calXmax, calYmin, calYmax); + f.close(); +} + +Point rawPoint() { + Point p; + p.touched = touch.touched(); + if (p.touched) { + TS_Point raw = touch.getPoint(); + p.x = raw.x; + p.y = raw.y; + } + return p; +} + +Point mappedPoint() { + Point raw = rawPoint(); + Point out; + out.touched = raw.touched; + if (!raw.touched) return out; + + long mx = map((long)raw.x, calXmin, calXmax, MARGIN_PX, Config::SCREEN_WIDTH - MARGIN_PX); + long my = map((long)raw.y, calYmin, calYmax, MARGIN_PX, Config::SCREEN_HEIGHT - MARGIN_PX); + + out.x = clampi((int16_t)mx, 0, Config::SCREEN_WIDTH - 1); + out.y = clampi((int16_t)my, 0, Config::SCREEN_HEIGHT - 1); + return out; +} + +bool wasTapped(Point& outPoint) { + Point cur = mappedPoint(); + + bool fired = false; + if (!cur.touched && lastTouched) { + // Loslassen erkannt -> Tap an der zuletzt bekannten Position melden. + outPoint = lastRaw; + fired = true; + } + + lastTouched = cur.touched; + if (cur.touched) lastRaw = cur; + + return fired; +} + +} \ No newline at end of file diff --git a/src/touch_input.h b/src/touch_input.h new file mode 100644 index 0000000..a599e96 --- /dev/null +++ b/src/touch_input.h @@ -0,0 +1,32 @@ +#pragma once +#include + +namespace TouchInput { + + struct Point { + int16_t x = 0; + int16_t y = 0; + bool touched = false; + }; + + void begin(); + + // Laedt calibration.txt von der SD-Karte. Gibt false zurueck, wenn keine + // vorhanden oder ungueltig ist. + bool loadCalibration(); + void setCalibration(int16_t rawXmin, int16_t rawXmax, int16_t rawYmin, int16_t rawYmax); + void saveCalibration(); + bool hasCalibration(); + + // Aktueller Touch-Zustand, roh (unkalibriert), z.B. fuer die + // Kalibrierungs-Routine selbst. + Point rawPoint(); + + // Aktueller Touch-Zustand, auf Bildschirmkoordinaten (0..SCREEN_WIDTH-1 / + // 0..SCREEN_HEIGHT-1) umgerechnet und geclampt. + Point mappedPoint(); + + // Liefert genau einmal pro physischer Beruehrung "true" (beim Loslassen), + // mitsamt der zuletzt bekannten Position. Fuer Buttons/Tastatur gedacht. + bool wasTapped(Point& outPoint); +} \ No newline at end of file diff --git a/src/units.h b/src/units.h new file mode 100644 index 0000000..47fa746 --- /dev/null +++ b/src/units.h @@ -0,0 +1,16 @@ +#pragma once + +// Simple unit conversions for display purposes. Instead of trying to +// auto-detect the user's region (extra network call, error-prone), we just +// show BOTH metric and aviation/imperial units side by side, e.g. +// "9144m / 30000ft" - simpler, more robust, and useful to everyone +// regardless of where the CYD is actually used. +namespace Units { + constexpr float FT_TO_M = 0.3048f; + constexpr float KT_TO_KMH = 1.852f; + constexpr float KM_TO_NM = 1.0f / 1.852f; + + inline float feetToMeters(float ft) { return ft * FT_TO_M; } + inline float ktToKmh(float kt) { return kt * KT_TO_KMH; } + inline float kmToNm(float km) { return km * KM_TO_NM; } +} \ No newline at end of file diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp new file mode 100644 index 0000000..0a5dd08 --- /dev/null +++ b/src/wifi_manager.cpp @@ -0,0 +1,140 @@ +#include "wifi_manager.h" +#include "config.h" +#include "sd_storage.h" +#include +#include +#include +#include +#include + +namespace WifiMgr { + +namespace { + Preferences prefs; + State state = State::Idle; + uint32_t connectStartMs = 0; + constexpr uint32_t CONNECT_TIMEOUT_MS = 15000; // bounded, same fix as the Kinect sketch + char ipStr[16] = {0}; + + // Schuetzt 'state': der Netzwerk-Task (Core 0) schreibt es in + // beginConnect()/update(), der Render-Loop (Core 1) liest es ueber + // getState(). ipStr wird nur einmal beim Verbinden geschrieben und danach + // nur gelesen, daher hier ohne eigenen Lock. + SemaphoreHandle_t mutex = nullptr; + + void setState(State s) { + xSemaphoreTake(mutex, portMAX_DELAY); + state = s; + xSemaphoreGive(mutex); + } +} + +void init() { + if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); + prefs.begin("adsb_radar", /*readOnly=*/false); + setState(hasStoredCredentials() ? State::Idle : State::NoCredentials); +} + +bool hasStoredCredentials() { + String ssid = prefs.getString("ssid", ""); + return ssid.length() > 0; +} + +void saveCredentials(const char* ssid, const char* password) { + prefs.putString("ssid", ssid); + prefs.putString("pass", password); +} + +void beginConnect() { + if (!hasStoredCredentials()) { + setState(State::NoCredentials); + return; + } + String ssid = prefs.getString("ssid", ""); + String pass = prefs.getString("pass", ""); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), pass.c_str()); + connectStartMs = millis(); + setState(State::Connecting); +} + +void update() { + if (getState() != State::Connecting) return; + + if (WiFi.status() == WL_CONNECTED) { + strncpy(ipStr, WiFi.localIP().toString().c_str(), sizeof(ipStr) - 1); + setState(State::Connected); + return; + } + + if (millis() - connectStartMs > CONNECT_TIMEOUT_MS) { + WiFi.disconnect(true); + setState(State::Failed); + } +} + +State getState() { + xSemaphoreTake(mutex, portMAX_DELAY); + State s = state; + xSemaphoreGive(mutex); + return s; +} + +void beginScan() { + WiFi.scanNetworks(/*async=*/true); +} + +bool isScanComplete() { + return WiFi.scanComplete() >= 0; +} + +int getScanResultCount() { + int n = WiFi.scanComplete(); + return n > 0 ? n : 0; +} + +String getScanResultSSID(int index) { + return WiFi.SSID(index); +} + +int32_t getScanResultRSSI(int index) { + return WiFi.RSSI(index); +} + +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(); +} + +} \ No newline at end of file diff --git a/src/wifi_manager.h b/src/wifi_manager.h new file mode 100644 index 0000000..a14da8d --- /dev/null +++ b/src/wifi_manager.h @@ -0,0 +1,27 @@ +#pragma once +#include + +namespace WifiMgr { + enum class State { + Idle, Connecting, Connected, Failed, NoCredentials + }; + + void init(); + void beginConnect(); + void update(); + State getState(); + + void saveCredentials(const char* ssid, const char* password); + bool hasStoredCredentials(); + + bool loadCredentialsFromSd(); + void saveCredentialsToSdIfMounted(); + + void beginScan(); + bool isScanComplete(); + int getScanResultCount(); + String getScanResultSSID(int index); + int32_t getScanResultRSSI(int index); + + const char* getIP(); +} \ No newline at end of file diff --git a/src/wifi_setup_screen.cpp b/src/wifi_setup_screen.cpp new file mode 100644 index 0000000..3ddec7f --- /dev/null +++ b/src/wifi_setup_screen.cpp @@ -0,0 +1,377 @@ +#include "wifi_setup_screen.h" +#include "touch_input.h" +#include "wifi_manager.h" +#include "config.h" + +namespace WifiSetupScreen { + +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; + } + }; + + enum class KeyType { Char, Shift, Backspace, TogglePage, Space, Connect }; + + struct KeyDef { + KeyType type; + char ch; // fuer KeyType::Char (Kleinbuchstabe/Grundzeichen) + const char* label; // Anzeige, falls kein einzelnes Zeichen (z.B. "<-") + }; + + // --- Tastatur-Layout --------------------------------------------------- + constexpr KeyDef ROW_LETTERS_A[] = { + {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,'u',nullptr},{KeyType::Char,'i',nullptr},{KeyType::Char,'o',nullptr}, + {KeyType::Char,'p',nullptr}, + }; + constexpr KeyDef ROW_LETTERS_B[] = { + {KeyType::Char,'a',nullptr},{KeyType::Char,'s',nullptr},{KeyType::Char,'d',nullptr}, + {KeyType::Char,'f',nullptr},{KeyType::Char,'g',nullptr},{KeyType::Char,'h',nullptr}, + {KeyType::Char,'j',nullptr},{KeyType::Char,'k',nullptr},{KeyType::Char,'l',nullptr}, + }; + constexpr KeyDef ROW_LETTERS_C[] = { + {KeyType::TogglePage,0,"123"},{KeyType::Shift,0,"^"}, + {KeyType::Char,'z',nullptr},{KeyType::Char,'x',nullptr},{KeyType::Char,'c',nullptr}, + {KeyType::Char,'v',nullptr},{KeyType::Char,'b',nullptr},{KeyType::Char,'n',nullptr}, + {KeyType::Char,'m',nullptr},{KeyType::Backspace,0,"<-"}, + }; + + constexpr KeyDef ROW_SYMBOLS_A[] = { + {KeyType::Char,'1',nullptr},{KeyType::Char,'2',nullptr},{KeyType::Char,'3',nullptr}, + {KeyType::Char,'4',nullptr},{KeyType::Char,'5',nullptr},{KeyType::Char,'6',nullptr}, + {KeyType::Char,'7',nullptr},{KeyType::Char,'8',nullptr},{KeyType::Char,'9',nullptr}, + {KeyType::Char,'0',nullptr}, + }; + constexpr KeyDef ROW_SYMBOLS_B[] = { + {KeyType::Char,'-',nullptr},{KeyType::Char,'_',nullptr},{KeyType::Char,'=',nullptr}, + {KeyType::Char,'+',nullptr},{KeyType::Char,':',nullptr},{KeyType::Char,';',nullptr}, + {KeyType::Char,'\'',nullptr},{KeyType::Char,'"',nullptr},{KeyType::Char,'?',nullptr}, + {KeyType::Char,'!',nullptr}, + }; + constexpr KeyDef ROW_SYMBOLS_C[] = { + {KeyType::TogglePage,0,"ABC"}, + {KeyType::Char,'@',nullptr},{KeyType::Char,'#',nullptr},{KeyType::Char,'$',nullptr}, + {KeyType::Char,'%',nullptr},{KeyType::Char,'&',nullptr},{KeyType::Char,'*',nullptr}, + {KeyType::Char,'(',nullptr},{KeyType::Char,')',nullptr},{KeyType::Backspace,0,"<-"}, + }; + + constexpr int16_t KB_TOP = 132; + constexpr int16_t ROW_H = 34; + constexpr int16_t ROW_GAP = 4; + constexpr int16_t KEY_GAP = 3; + constexpr int16_t SIDE_MARGIN = 4; + + enum class Stage { Scanning, PickSsid, EnterPassword, Connecting, Done }; + Stage stage = Stage::Scanning; + + constexpr uint8_t MAX_LIST = 16; + constexpr uint8_t VISIBLE_ITEMS = 7; + String ssidList[MAX_LIST]; + uint8_t ssidCount = 0; + int8_t selectedIndex = -1; + uint8_t scrollOffset = 0; + + char passwordBuf[64] = {0}; + uint8_t passwordLen = 0; + + bool shiftOn = false; + uint8_t page = 0; // 0 = Buchstaben, 1 = Symbole + + bool skipped = false; + bool connectSucceeded = false; + bool needsRedraw = true; // nur neu zeichnen, wenn sich wirklich was geaendert hat (verhindert Flackern) + + Rect cancelBtn = {Config::SCREEN_WIDTH - 34, 4, 30, 24}; + + void drawButton(TFT_eSPI& tft, const Rect& r, const String& label, bool highlighted = false) { + uint16_t bg = highlighted ? TFT_DARKGREEN : 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); + } + + // Rechnet die Rects einer Tastenreihe aus (gleich verteilt ueber die Breite). + template + void layoutRow(const KeyDef (&row)[N], int16_t y, Rect outRects[N]) { + int16_t usableW = Config::SCREEN_WIDTH - 2 * SIDE_MARGIN; + int16_t keyW = (usableW - (int16_t)(N - 1) * KEY_GAP) / (int16_t)N; + int16_t x = SIDE_MARGIN; + for (size_t i = 0; i < N; i++) { + outRects[i] = {x, y, keyW, ROW_H}; + x += keyW + KEY_GAP; + } + } + + String keyLabel(const KeyDef& k) { + if (k.label) return String(k.label); + char c = shiftOn ? (char)toupper(k.ch) : k.ch; + return String(c); + } + + template + bool handleRowTap(const KeyDef (&row)[N], int16_t y, int16_t tx, int16_t ty) { + Rect rects[N]; + layoutRow(row, y, rects); + for (size_t i = 0; i < N; i++) { + if (!rects[i].contains(tx, ty)) continue; + const KeyDef& k = row[i]; + switch (k.type) { + case KeyType::Char: + if (passwordLen < sizeof(passwordBuf) - 1) { + passwordBuf[passwordLen++] = shiftOn ? (char)toupper(k.ch) : k.ch; + passwordBuf[passwordLen] = 0; + } + break; + case KeyType::Shift: + shiftOn = !shiftOn; + break; + case KeyType::Backspace: + if (passwordLen > 0) { + passwordLen--; + passwordBuf[passwordLen] = 0; + } + break; + case KeyType::TogglePage: + page = page == 0 ? 1 : 0; + break; + default: + break; + } + return true; + } + return false; + } + + template + void renderRow(TFT_eSPI& tft, const KeyDef (&row)[N], int16_t y) { + Rect rects[N]; + layoutRow(row, y, rects); + for (size_t i = 0; i < N; i++) { + bool hl = (row[i].type == KeyType::Shift && shiftOn); + drawButton(tft, rects[i], keyLabel(row[i]), hl); + } + } + + void resetKeyboardState() { + passwordLen = 0; + passwordBuf[0] = 0; + shiftOn = false; + page = 0; + } + + void drawCancelButton(TFT_eSPI& tft) { + drawButton(tft, cancelBtn, "X"); + } +} + +bool run(TFT_eSPI& tft) { + stage = Stage::Scanning; + skipped = false; + connectSucceeded = false; + ssidCount = 0; + selectedIndex = -1; + scrollOffset = 0; + resetKeyboardState(); + needsRedraw = true; + + WifiMgr::beginScan(); + + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setTextSize(1); + tft.setCursor(10, 10); + tft.println("WLAN-Suche laeuft..."); + drawCancelButton(tft); + + while (!skipped) { + TouchInput::Point tap; + bool tapped = TouchInput::wasTapped(tap); + + if (tapped && cancelBtn.contains(tap.x, tap.y)) { + skipped = true; + break; + } + + // --- Uebergaenge, die nicht vom Touch kommen ----------------------- + if (stage == Stage::Scanning && WifiMgr::isScanComplete()) { + ssidCount = (uint8_t)min((int)WifiMgr::getScanResultCount(), (int)MAX_LIST); + for (uint8_t i = 0; i < ssidCount; i++) ssidList[i] = WifiMgr::getScanResultSSID(i); + stage = Stage::PickSsid; + needsRedraw = true; + + tft.fillScreen(TFT_BLACK); + tft.setCursor(10, 10); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.println(ssidCount == 0 ? "Keine Netzwerke gefunden" : "WLAN waehlen:"); + drawCancelButton(tft); + } + + if (stage == Stage::Connecting) { + WifiMgr::update(); + if (WifiMgr::getState() == WifiMgr::State::Connected) { + connectSucceeded = true; + WifiMgr::saveCredentialsToSdIfMounted(); + stage = Stage::Done; + tft.fillScreen(TFT_BLACK); + tft.setCursor(10, 10); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.println("Verbunden!"); + delay(900); + return true; + } else if (WifiMgr::getState() == WifiMgr::State::Failed) { + stage = Stage::Done; + tft.fillScreen(TFT_BLACK); + tft.setCursor(10, 10); + tft.setTextColor(TFT_RED, TFT_BLACK); + tft.println("Verbindung fehlgeschlagen."); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setCursor(10, 30); + tft.println("Zurueck zur Netzwerkliste..."); + delay(1400); + stage = Stage::PickSsid; + WifiMgr::beginScan(); + stage = Stage::Scanning; + } + } + + // --- Touch-Eingaben je nach Stage ----------------------------------- + if (tapped && stage == Stage::PickSsid) { + if (ssidCount > 0) { + // Liste + for (uint8_t row = 0; row < VISIBLE_ITEMS; row++) { + uint8_t idx = scrollOffset + row; + if (idx >= ssidCount) break; + Rect r = {10, (int16_t)(34 + row * 34), (int16_t)(Config::SCREEN_WIDTH - 20), 30}; + if (r.contains(tap.x, tap.y)) { + selectedIndex = idx; + resetKeyboardState(); + stage = Stage::EnterPassword; + needsRedraw = true; + } + } + // Scroll-Pfeile + if (ssidCount > VISIBLE_ITEMS) { + Rect upBtn = {Config::SCREEN_WIDTH - 34, 34, 30, 26}; + Rect downBtn = {Config::SCREEN_WIDTH - 34, 64 + (VISIBLE_ITEMS - 1) * 34, 30, 26}; + if (upBtn.contains(tap.x, tap.y) && scrollOffset > 0) { scrollOffset--; needsRedraw = true; } + if (downBtn.contains(tap.x, tap.y) && scrollOffset + VISIBLE_ITEMS < ssidCount) { scrollOffset++; needsRedraw = true; } + } + } + } + + if (tapped && stage == Stage::EnterPassword) { + int16_t rowY0 = KB_TOP; + int16_t rowY1 = KB_TOP + (ROW_H + ROW_GAP); + int16_t rowY2 = KB_TOP + 2 * (ROW_H + ROW_GAP); + int16_t rowYFn = KB_TOP + 3 * (ROW_H + ROW_GAP); + + bool handled = false; + if (page == 0) { + handled = handleRowTap(ROW_LETTERS_A, rowY0, tap.x, tap.y) || + handleRowTap(ROW_LETTERS_B, rowY1, tap.x, tap.y) || + handleRowTap(ROW_LETTERS_C, rowY2, tap.x, tap.y); + } else { + handled = handleRowTap(ROW_SYMBOLS_A, rowY0, tap.x, tap.y) || + handleRowTap(ROW_SYMBOLS_B, rowY1, tap.x, tap.y) || + handleRowTap(ROW_SYMBOLS_C, rowY2, tap.x, tap.y); + } + + if (!handled) { + 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}; + + if (spaceBtn.contains(tap.x, tap.y)) { + if (passwordLen < sizeof(passwordBuf) - 1) { + passwordBuf[passwordLen++] = ' '; + passwordBuf[passwordLen] = 0; + } + } else if (connectBtn.contains(tap.x, tap.y)) { + WifiMgr::saveCredentials(ssidList[selectedIndex].c_str(), passwordBuf); + WifiMgr::beginConnect(); + stage = Stage::Connecting; + tft.fillScreen(TFT_BLACK); + tft.setCursor(10, 10); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.println("Verbinde..."); + } else if (backBtn.contains(tap.x, tap.y)) { + stage = Stage::PickSsid; + needsRedraw = true; + } + } + needsRedraw = true; // Tastatureingabe (Zeichen/Shift/Seite/Backspace/Leerzeichen) aendert den Bildschirm + } + + // --- Zeichnen (nur bei Aenderung, verhindert Flackern) ---------------- + if (!needsRedraw) { + delay(20); + continue; + } + needsRedraw = false; + + if (stage == Stage::PickSsid) { + tft.fillRect(0, 30, Config::SCREEN_WIDTH, Config::SCREEN_HEIGHT - 30, TFT_BLACK); + for (uint8_t row = 0; row < VISIBLE_ITEMS; row++) { + uint8_t idx = scrollOffset + row; + if (idx >= ssidCount) break; + Rect r = {10, (int16_t)(34 + row * 34), (int16_t)(Config::SCREEN_WIDTH - 20), 30}; + drawButton(tft, r, ssidList[idx]); + } + if (ssidCount > VISIBLE_ITEMS) { + Rect upBtn = {Config::SCREEN_WIDTH - 34, 34, 30, 26}; + Rect downBtn = {Config::SCREEN_WIDTH - 34, 64 + (VISIBLE_ITEMS - 1) * 34, 30, 26}; + drawButton(tft, upBtn, "^"); + drawButton(tft, downBtn, "v"); + } + drawCancelButton(tft); + } else if (stage == Stage::EnterPassword) { + tft.fillRect(0, 0, Config::SCREEN_WIDTH, KB_TOP - 4, TFT_BLACK); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setCursor(10, 6); + tft.printf("WLAN: %s", ssidList[selectedIndex].c_str()); + tft.setCursor(10, 22); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.println("Passwort:"); + 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); + tft.setTextColor(TFT_YELLOW, TFT_NAVY); + tft.print(passwordBuf); + + int16_t rowY0 = KB_TOP; + int16_t rowY1 = KB_TOP + (ROW_H + ROW_GAP); + int16_t rowY2 = KB_TOP + 2 * (ROW_H + ROW_GAP); + int16_t rowYFn = KB_TOP + 3 * (ROW_H + ROW_GAP); + + if (page == 0) { + renderRow(tft, ROW_LETTERS_A, rowY0); + renderRow(tft, ROW_LETTERS_B, rowY1); + renderRow(tft, ROW_LETTERS_C, rowY2); + } else { + renderRow(tft, ROW_SYMBOLS_A, rowY0); + renderRow(tft, ROW_SYMBOLS_B, rowY1); + renderRow(tft, ROW_SYMBOLS_C, rowY2); + } + + 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, "Leerzeichen"); + drawButton(tft, connectBtn, "Verbinden"); + drawButton(tft, backBtn, "Zurueck zur Liste"); + } + + delay(20); + } + + return false; // uebersprungen +} + +} \ No newline at end of file diff --git a/src/wifi_setup_screen.h b/src/wifi_setup_screen.h new file mode 100644 index 0000000..1b52082 --- /dev/null +++ b/src/wifi_setup_screen.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +namespace WifiSetupScreen { + // Blockierend: WLAN-Netzwerke suchen, per Touch auswaehlen, Passwort + // ueber Bildschirmtastatur eingeben (Klartext, keine Sterne), verbinden. + // Speichert bei Erfolg die Zugangsdaten via WifiMgr auf der SD-Karte. + // Rueckgabe: true = verbunden, false = abgebrochen/uebersprungen. + bool run(TFT_eSPI& tft); +} \ No newline at end of file