v1: Live ADS-B radar with detail panel, units, legend, proximity LED alert
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
37
platformio.ini
Normal 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
127
src/adsb_client.cpp
Normal 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
17
src/adsb_client.h
Normal 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
27
src/aircraft.h
Normal 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
102
src/aircraft_details.cpp
Normal 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
27
src/aircraft_details.h
Normal 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
67
src/aircraft_table.cpp
Normal 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
26
src/aircraft_table.h
Normal 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
109
src/airline_lookup.cpp
Normal 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
12
src/airline_lookup.h
Normal 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
102
src/calibration_screen.cpp
Normal 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
10
src/calibration_screen.h
Normal 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
70
src/config.h
Normal 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
43
src/led_alert.cpp
Normal 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
19
src/led_alert.h
Normal 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
183
src/location_manager.cpp
Normal 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
23
src/location_manager.h
Normal 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
172
src/main.cpp
Normal 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
65
src/menu_screen.cpp
Normal 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
9
src/menu_screen.h
Normal 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
85
src/net_task.cpp
Normal 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
10
src/net_task.h
Normal 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
49
src/radar_math.cpp
Normal 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
20
src/radar_math.h
Normal 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
446
src/radar_screen.cpp
Normal 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
30
src/radar_screen.h
Normal 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
114
src/sd_storage.cpp
Normal 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
11
src/sd_storage.h
Normal 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
72
src/settings_store.cpp
Normal 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
22
src/settings_store.h
Normal 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
70
src/splash_screen.cpp
Normal 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
20
src/splash_screen.h
Normal 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
125
src/touch_input.cpp
Normal 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
32
src/touch_input.h
Normal 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
16
src/units.h
Normal 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
140
src/wifi_manager.cpp
Normal 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
27
src/wifi_manager.h
Normal 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
377
src/wifi_setup_screen.cpp
Normal 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
11
src/wifi_setup_screen.h
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user