v1: Live ADS-B radar with detail panel, units, legend, proximity LED alert

This commit is contained in:
Eiswolf-BG
2026-08-02 23:28:42 +02:00
commit 52c8009296
42 changed files with 2939 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

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

37
platformio.ini Normal file
View File

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

127
src/adsb_client.cpp Normal file
View File

@@ -0,0 +1,127 @@
#include "adsb_client.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
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<JsonObject>();
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<JsonArray>();
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<const char*>()) {
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;
}
}

17
src/adsb_client.h Normal file
View File

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

27
src/aircraft.h Normal file
View File

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

102
src/aircraft_details.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include "aircraft_details.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
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);
}
}

27
src/aircraft_details.h Normal file
View File

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

67
src/aircraft_table.cpp Normal file
View File

@@ -0,0 +1,67 @@
#include "aircraft_table.h"
#include "radar_math.h"
#include <algorithm>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// 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++;
}
}

26
src/aircraft_table.h Normal file
View File

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

109
src/airline_lookup.cpp Normal file
View File

@@ -0,0 +1,109 @@
#include "airline_lookup.h"
#include "sd_storage.h"
#include "config.h"
#include <SD.h>
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;
}
}
}
}
}

12
src/airline_lookup.h Normal file
View File

@@ -0,0 +1,12 @@
#pragma once
#include <Arduino.h>
#include "aircraft.h"
namespace AirlineLookup {
void init();
void resolve(Aircraft& a);
void clearCache();
}

102
src/calibration_screen.cpp Normal file
View File

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

10
src/calibration_screen.h Normal file
View File

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

70
src/config.h Normal file
View File

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

43
src/led_alert.cpp Normal file
View File

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

19
src/led_alert.h Normal file
View File

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

183
src/location_manager.cpp Normal file
View File

@@ -0,0 +1,183 @@
#include "location_manager.h"
#include "config.h"
#include <TinyGPSPlus.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
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(); }
}

23
src/location_manager.h Normal file
View File

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

172
src/main.cpp Normal file
View File

@@ -0,0 +1,172 @@
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <SD.h>
#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);
}
}

65
src/menu_screen.cpp Normal file
View File

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

9
src/menu_screen.h Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace MenuScreen {
// Blockierend: einfaches Menue (aktuell nur "Touch kalibrieren").
// Kehrt zurueck, sobald "Zurueck" angetippt wird.
void run(TFT_eSPI& tft);
}

85
src/net_task.cpp Normal file
View File

@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <cstring>
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)
);
}
}

10
src/net_task.h Normal file
View File

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

49
src/radar_math.cpp Normal file
View File

@@ -0,0 +1,49 @@
#include "radar_math.h"
#include <math.h>
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<float>(distanceKm), static_cast<float>(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<int16_t>(centerX + dx),
static_cast<int16_t>(centerY + dy)
};
}
} // namespace RadarMath

20
src/radar_math.h Normal file
View File

@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
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);
}

446
src/radar_screen.cpp Normal file
View File

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

30
src/radar_screen.h Normal file
View File

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

114
src/sd_storage.cpp Normal file
View File

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

11
src/sd_storage.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <Arduino.h>
namespace SdStorage {
bool init();
bool isMounted();
void seedDefaultDataFiles();
void logEvent(const char* csvLine);
}

72
src/settings_store.cpp Normal file
View File

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

22
src/settings_store.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <Arduino.h>
// Einfacher key=value Einstellungs-Speicher in /Flightradar_cyd/config.txt.
// Neue Einstellungen koennen spaeter einfach als weitere getX()/setX()-Paare
// dazu kommen, ohne das Dateiformat zu aendern (unbekannte Zeilen werden
// beim Laden ignoriert).
namespace SettingsStore {
// 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);
}

70
src/splash_screen.cpp Normal file
View File

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

20
src/splash_screen.h Normal file
View File

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

125
src/touch_input.cpp Normal file
View File

@@ -0,0 +1,125 @@
#include "touch_input.h"
#include "config.h"
#include <SPI.h>
#include <XPT2046_Touchscreen.h>
#include <SD.h>
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;
}
}

32
src/touch_input.h Normal file
View File

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

16
src/units.h Normal file
View File

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

140
src/wifi_manager.cpp Normal file
View File

@@ -0,0 +1,140 @@
#include "wifi_manager.h"
#include "config.h"
#include "sd_storage.h"
#include <WiFi.h>
#include <Preferences.h>
#include <SD.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
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();
}
}

27
src/wifi_manager.h Normal file
View File

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

377
src/wifi_setup_screen.cpp Normal file
View File

@@ -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 <size_t N>
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 <size_t N>
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 <size_t N>
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
}
}

11
src/wifi_setup_screen.h Normal file
View File

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