Add WebUI live radar; mutex-protect AirlineFilter/AircraftWatchlist

The WebUI home page now shows a live radar canvas above the flight
logbook, polling a new /radar.json endpoint every 3 seconds. It
mirrors the device display: distance rings, aircraft colored by
altitude, ground vehicles as squares, rotorcraft as diamonds, and
emergency/watchlist/heavy-category rings in that priority. Applies
the same range, hide-ground-vehicles, and airline filters as the
device radar.

AirlineFilter and AircraftWatchlist gain proper mutex protection
around their in-memory arrays (previously only guarded around SD
file I/O) since the new /lists WebUI pages reach them from Core 0
(NetTask) in addition to their existing Core 1 (menu screen)
callers.

Note: the "notable aircraft" military/government callsign-prefix
marker is not implemented - Config::NOTABLE_CALLSIGN_PREFIXES was
never defined anywhere in this project, on the device display or
here. The "notable" ring currently fires on heavy-category aircraft
only.

Also documents the OTA-testing exception to the auto-flash rule in
CLAUDE.md (build-only during Push & Release, so the test device stays
on the previous version for OTA testing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-13 03:50:20 +02:00
parent 220e670a88
commit 5e22025d7a
10 changed files with 393 additions and 48 deletions

View File

@@ -190,6 +190,11 @@ in dieser Reihenfolge:
Alex nachfragen statt zu raten. Erst NACH dem Eintragen der korrekten Alex nachfragen statt zu raten. Erst NACH dem Eintragen der korrekten
Nummer: einmal sauber `pio run` bauen, DANACH erst der Rest des Nummer: einmal sauber `pio run` bauen, DANACH erst der Rest des
bekannten Workflows (README, Tag, index.html, Bin-Dateien, Commit/Push). bekannten Workflows (README, Tag, index.html, Bin-Dateien, Commit/Push).
(Hier bewusst NUR bauen, NICHT flashen - Ausnahme von der sonst
geltenden Auto-Flash-Regel, siehe Abschnitt "Nach jedem erfolgreichen
Build automatisch flashen" weiter unten. Das Testgerät soll auf der
bisherigen Version bleiben, damit das neue Release per OTA getestet
werden kann.)
1. Prüfen, ob seit dem letzten Commit neue/geänderte Features hinzugekommen 1. Prüfen, ob seit dem letzten Commit neue/geänderte Features hinzugekommen
sind, die für Endnutzer sichtbar sind (neue Menüpunkte, geändertes sind, die für Endnutzer sichtbar sind (neue Menüpunkte, geändertes
Verhalten, neue Screens) - falls ja, **README.md entsprechend ergänzen** Verhalten, neue Screens) - falls ja, **README.md entsprechend ergänzen**
@@ -245,4 +250,13 @@ fragen - außer der Nutzer sagt ausdrücklich "nur bauen, nicht flashen" o.ä.
Kurz danach bestätigen, dass der Upload ebenfalls erfolgreich war (inkl. Kurz danach bestätigen, dass der Upload ebenfalls erfolgreich war (inkl.
"[SUCCESS]"-Zeile am Ende). "[SUCCESS]"-Zeile am Ende).
AUSNAHME: Innerhalb der Push & Release-Routine (siehe Abschnitt
"Standard-Workflow: Push & Release") NICHT automatisch flashen, selbst
nach erfolgreichem Build - dort wird bewusst nur gebaut. Grund: Alex
möchte das Testgerät auf der bisherigen Version belassen, um das neue
Release anschließend über die OTA-Update-Funktion zu testen, statt es
direkt per Kabel zu flashen. Für alle anderen Anlässe (normales
Entwickeln/Testen, einzelne Fixes) gilt die automatische Flash-Regel
unverändert weiter.
Bitte diese Regel jetzt in die CLAUDE.md-Datei einpflegen. Bitte diese Regel jetzt in die CLAUDE.md-Datei einpflegen.

View File

@@ -3,6 +3,8 @@
#include "sd_mutex.h" #include "sd_mutex.h"
#include "sd_storage.h" #include "sd_storage.h"
#include <SD.h> #include <SD.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring> #include <cstring>
#include <cctype> #include <cctype>
@@ -14,6 +16,16 @@ namespace {
char watched[MAX_WATCHED][9] = {{0}}; char watched[MAX_WATCHED][9] = {{0}};
uint8_t watchedCount = 0; uint8_t watchedCount = 0;
// Schuetzt watched[]/watchedCount - urspruenglich nur von Core 1 (Menue-
// Screens, Radar) verwendet, seit der WebUI-Listenverwaltung (siehe
// web_export_server.cpp) aber auch von Core 0 (NetTask) aus erreichbar.
// Gleiches Muster wie AircraftDetails::mutex.
SemaphoreHandle_t mutex = nullptr;
void ensureMutex() {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
}
// Ueberspringt fuehrende Leerzeichen, uebernimmt bis zu 8 Zeichen und // Ueberspringt fuehrende Leerzeichen, uebernimmt bis zu 8 Zeichen und
// bricht bei einem Leerzeichen ab (ADS-B-Rufzeichen haben oft Padding), // bricht bei einem Leerzeichen ab (ADS-B-Rufzeichen haben oft Padding),
// alles in Grossbuchstaben. // alles in Grossbuchstaben.
@@ -61,55 +73,83 @@ namespace {
} }
void init() { void init() {
ensureMutex();
loadFromSd(); loadFromSd();
} }
uint8_t count() { return watchedCount; } uint8_t count() {
ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY);
uint8_t c = watchedCount;
xSemaphoreGive(mutex);
return c;
}
String callsignAt(uint8_t index) { String callsignAt(uint8_t index) {
if (index >= watchedCount) return String(); ensureMutex();
return String(watched[index]); xSemaphoreTake(mutex, portMAX_DELAY);
String out = (index >= watchedCount) ? String() : String(watched[index]);
xSemaphoreGive(mutex);
return out;
} }
bool addWatched(const char* callsign) { bool addWatched(const char* callsign) {
if (watchedCount >= MAX_WATCHED) return false;
if (!callsign || !callsign[0]) return false; if (!callsign || !callsign[0]) return false;
char normalized[9] = {0}; char normalized[9] = {0};
normalize(callsign, normalized); normalize(callsign, normalized);
if (!normalized[0]) return false; if (!normalized[0]) return false;
for (uint8_t j = 0; j < watchedCount; j++) { ensureMutex();
if (strcmp(watched[j], normalized) == 0) return true; xSemaphoreTake(mutex, portMAX_DELAY);
bool ok = true;
bool alreadyPresent = false;
if (watchedCount >= MAX_WATCHED) {
ok = false;
} else {
for (uint8_t j = 0; j < watchedCount; j++) {
if (strcmp(watched[j], normalized) == 0) { alreadyPresent = true; break; }
}
if (!alreadyPresent) {
strncpy(watched[watchedCount], normalized, 8);
watched[watchedCount][8] = 0;
watchedCount++;
}
} }
xSemaphoreGive(mutex);
strncpy(watched[watchedCount], normalized, 8); if (ok && !alreadyPresent) saveToSd();
watched[watchedCount][8] = 0; return ok;
watchedCount++;
saveToSd();
return true;
} }
void removeWatched(uint8_t index) { void removeWatched(uint8_t index) {
if (index >= watchedCount) return; ensureMutex();
for (uint8_t i = index; i < watchedCount - 1; i++) { xSemaphoreTake(mutex, portMAX_DELAY);
strncpy(watched[i], watched[i + 1], 9); bool changed = index < watchedCount;
if (changed) {
for (uint8_t i = index; i < watchedCount - 1; i++) {
strncpy(watched[i], watched[i + 1], 9);
}
watchedCount--;
watched[watchedCount][0] = 0;
} }
watchedCount--; xSemaphoreGive(mutex);
watched[watchedCount][0] = 0; if (changed) saveToSd();
saveToSd();
} }
bool isWatched(const char* callsign) { bool isWatched(const char* callsign) {
if (watchedCount == 0) return false; ensureMutex();
char normalized[9]; char normalized[9];
normalize(callsign, normalized); normalize(callsign, normalized);
if (!normalized[0]) return false; if (!normalized[0]) return false;
xSemaphoreTake(mutex, portMAX_DELAY);
bool found = false;
for (uint8_t i = 0; i < watchedCount; i++) { for (uint8_t i = 0; i < watchedCount; i++) {
if (strcmp(watched[i], normalized) == 0) return true; if (strcmp(watched[i], normalized) == 0) { found = true; break; }
} }
return false; xSemaphoreGive(mutex);
return found;
} }
} }

View File

@@ -3,6 +3,8 @@
#include "sd_mutex.h" #include "sd_mutex.h"
#include "sd_storage.h" #include "sd_storage.h"
#include <SD.h> #include <SD.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring> #include <cstring>
#include <cctype> #include <cctype>
@@ -14,6 +16,16 @@ namespace {
char hidden[MAX_HIDDEN][4] = {{0}}; char hidden[MAX_HIDDEN][4] = {{0}};
uint8_t hiddenCount = 0; uint8_t hiddenCount = 0;
// Schuetzt hidden[]/hiddenCount - urspruenglich nur von Core 1 (Menue-
// Screens) verwendet, seit der WebUI-Listenverwaltung (siehe
// web_export_server.cpp) aber auch von Core 0 (NetTask) aus erreichbar.
// Gleiches Muster wie AircraftDetails::mutex.
SemaphoreHandle_t mutex = nullptr;
void ensureMutex() {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
}
void extractPrefix(const char* callsign, char* out) { void extractPrefix(const char* callsign, char* out) {
int i = 0; int i = 0;
for (; i < 3 && callsign[i] && isalpha((unsigned char)callsign[i]); i++) { for (; i < 3 && callsign[i] && isalpha((unsigned char)callsign[i]); i++) {
@@ -56,18 +68,27 @@ namespace {
} }
void init() { void init() {
ensureMutex();
loadFromSd(); loadFromSd();
} }
uint8_t count() { return hiddenCount; } uint8_t count() {
ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY);
uint8_t c = hiddenCount;
xSemaphoreGive(mutex);
return c;
}
String icaoAt(uint8_t index) { String icaoAt(uint8_t index) {
if (index >= hiddenCount) return String(); ensureMutex();
return String(hidden[index]); xSemaphoreTake(mutex, portMAX_DELAY);
String out = (index >= hiddenCount) ? String() : String(hidden[index]);
xSemaphoreGive(mutex);
return out;
} }
bool addHidden(const char* icaoPrefix) { bool addHidden(const char* icaoPrefix) {
if (hiddenCount >= MAX_HIDDEN) return false;
if (!icaoPrefix || !icaoPrefix[0]) return false; if (!icaoPrefix || !icaoPrefix[0]) return false;
char normalized[4] = {0}; char normalized[4] = {0};
@@ -78,37 +99,56 @@ bool addHidden(const char* icaoPrefix) {
normalized[i] = 0; normalized[i] = 0;
if (i == 0) return false; if (i == 0) return false;
for (uint8_t j = 0; j < hiddenCount; j++) { ensureMutex();
if (strcmp(hidden[j], normalized) == 0) return true; xSemaphoreTake(mutex, portMAX_DELAY);
bool ok = true;
bool alreadyPresent = false;
if (hiddenCount >= MAX_HIDDEN) {
ok = false;
} else {
for (uint8_t j = 0; j < hiddenCount; j++) {
if (strcmp(hidden[j], normalized) == 0) { alreadyPresent = true; break; }
}
if (!alreadyPresent) {
strncpy(hidden[hiddenCount], normalized, 3);
hidden[hiddenCount][3] = 0;
hiddenCount++;
}
} }
xSemaphoreGive(mutex);
strncpy(hidden[hiddenCount], normalized, 3); if (ok && !alreadyPresent) saveToSd();
hidden[hiddenCount][3] = 0; return ok;
hiddenCount++;
saveToSd();
return true;
} }
void removeHidden(uint8_t index) { void removeHidden(uint8_t index) {
if (index >= hiddenCount) return; ensureMutex();
for (uint8_t i = index; i < hiddenCount - 1; i++) { xSemaphoreTake(mutex, portMAX_DELAY);
strncpy(hidden[i], hidden[i + 1], 4); bool changed = index < hiddenCount;
if (changed) {
for (uint8_t i = index; i < hiddenCount - 1; i++) {
strncpy(hidden[i], hidden[i + 1], 4);
}
hiddenCount--;
hidden[hiddenCount][0] = 0;
} }
hiddenCount--; xSemaphoreGive(mutex);
hidden[hiddenCount][0] = 0; if (changed) saveToSd();
saveToSd();
} }
bool isHidden(const char* callsign) { bool isHidden(const char* callsign) {
if (hiddenCount == 0) return false; ensureMutex();
char prefix[4]; char prefix[4];
extractPrefix(callsign, prefix); extractPrefix(callsign, prefix);
if (!prefix[0]) return false; if (!prefix[0]) return false;
xSemaphoreTake(mutex, portMAX_DELAY);
bool found = false;
for (uint8_t i = 0; i < hiddenCount; i++) { for (uint8_t i = 0; i < hiddenCount; i++) {
if (strcmp(hidden[i], prefix) == 0) return true; if (strcmp(hidden[i], prefix) == 0) { found = true; break; }
} }
return false; xSemaphoreGive(mutex);
return found;
} }
} }

View File

@@ -198,7 +198,7 @@ static const char* const I18N_DE[] = {
"Logbuch / WebUI", "Logbuch / WebUI",
"Logbuch / WebUI", "Logbuch / WebUI",
"Über diese Webseite kannst du das Flugbuch direkt im Browser ansehen, herunterladen und einzeln löschen. Im selben WLAN einfach im Browser aufrufen:", "Diese Webseite zeigt oben ein Live-Radar mit allen aktuell sichtbaren Flugzeugen und darunter das Flugbuch zum Ansehen, Herunterladen und einzelnen Löschen direkt im Browser. Im selben WLAN einfach im Browser aufrufen:",
"Keine WLAN-Verbindung - die Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.", "Keine WLAN-Verbindung - die Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
"Wetter", "Wetter",

View File

@@ -199,7 +199,7 @@ static const char* const I18N_EN[] = {
"Logbook / WebUI", "Logbook / WebUI",
"Logbook / WebUI", "Logbook / WebUI",
"This web page lets you view, download and individually delete the flight logbook right from a browser. While connected to the same WiFi network, open a browser and go to:", "This web page shows a live radar with all currently visible aircraft at the top, and below it lets you view, download and individually delete the flight logbook right from a browser. While connected to the same WiFi network, open a browser and go to:",
"No WiFi connection - the page is only reachable while connected to a network.", "No WiFi connection - the page is only reachable while connected to a network.",
"Weather", "Weather",

View File

@@ -198,7 +198,7 @@ static const char* const I18N_ES[] = {
"Registro / WebUI", "Registro / WebUI",
"Registro / WebUI", "Registro / WebUI",
"Esta página web permite ver, descargar y eliminar individualmente el registro de vuelo directamente desde un navegador. Conectado a la misma red WiFi, simplemente abre un navegador y ve a:", "Esta página web muestra arriba un radar en vivo con todos los aviones actualmente visibles y, debajo, permite ver, descargar y eliminar individualmente el registro de vuelo directamente desde un navegador. Conectado a la misma red WiFi, simplemente abre un navegador y ve a:",
"Sin conexión WiFi - la página solo es accesible mientras haya una conexión de red.", "Sin conexión WiFi - la página solo es accesible mientras haya una conexión de red.",
"Clima", "Clima",

View File

@@ -198,7 +198,7 @@ static const char* const I18N_FR[] = {
"Journal / WebUI", "Journal / WebUI",
"Journal / WebUI", "Journal / WebUI",
"Cette page web permet de consulter, télécharger et supprimer individuellement le journal de vol directement depuis un navigateur. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez à :", "Cette page web affiche en haut un radar en direct avec tous les avions actuellement visibles, et en dessous permet de consulter, télécharger et supprimer individuellement le journal de vol directement depuis un navigateur. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez à :",
"Pas de connexion WiFi - la page n'est accessible que lorsqu'une connexion réseau est établie.", "Pas de connexion WiFi - la page n'est accessible que lorsqu'une connexion réseau est établie.",
"Météo", "Météo",

View File

@@ -198,7 +198,7 @@ static const char* const I18N_IT[] = {
"Registro / WebUI", "Registro / WebUI",
"Registro / WebUI", "Registro / WebUI",
"Questa pagina web permette di visualizzare, scaricare ed eliminare singolarmente il registro di volo direttamente dal browser. Connesso alla stessa rete WiFi, apri un browser e vai a:", "Questa pagina web mostra in alto un radar dal vivo con tutti gli aerei attualmente visibili e, sotto, permette di visualizzare, scaricare ed eliminare singolarmente il registro di volo direttamente dal browser. Connesso alla stessa rete WiFi, apri un browser e vai a:",
"Nessuna connessione WiFi - la pagina è raggiungibile solo mentre è attiva una connessione di rete.", "Nessuna connessione WiFi - la pagina è raggiungibile solo mentre è attiva una connessione di rete.",
"Meteo", "Meteo",

View File

@@ -198,7 +198,7 @@ static const char* const I18N_TR[] = {
"Kayıt Defteri / WebUI", "Kayıt Defteri / WebUI",
"Kayıt Defteri / WebUI", "Kayıt Defteri / WebUI",
"Bu web sayfası, uçuş kayıt defterini doğrudan tarayıcıdan görüntülemenizi, indirmenizi ve tek tek silmenizi sağlar. Aynı WiFi ağındayken tarayıcıyı açıp şu adrese gidin:", "Bu web sayfasının üst kısmında o an görünen tüm uçakları gösteren canlı bir radar, altında ise uçuş kayıt defterini doğrudan tarayıcıdan görüntüleme, indirme ve tek tek silme imkanı bulunur. Aynı WiFi ağındayken tarayıcıyı açıp şu adrese gidin:",
"WiFi bağlantısı yok - sayfaya yalnızca bir ağ bağlantısı varken erişilebilir.", "WiFi bağlantısı yok - sayfaya yalnızca bir ağ bağlantısı varken erişilebilir.",
"Hava Durumu", "Hava Durumu",

View File

@@ -2,7 +2,12 @@
#include "config.h" #include "config.h"
#include "flight_logbook.h" #include "flight_logbook.h"
#include "sd_mutex.h" #include "sd_mutex.h"
#include "airline_filter.h"
#include "aircraft_watchlist.h"
#include "aircraft_table.h"
#include "settings_store.h"
#include <WebServer.h> #include <WebServer.h>
#include <ArduinoJson.h>
#include <SD.h> #include <SD.h>
#include <cstring> #include <cstring>
@@ -23,6 +28,33 @@ namespace {
return true; return true;
} }
// Absichtliche Duplikate der gleichnamigen (lokalen/statischen) Funktionen
// aus radar_screen.cpp - dort nicht exportiert, und nach der im Projekt
// etablierten Konvention "jeder Screen/jedes Modul dupliziert seine
// eigenen kleinen Helfer statt eines gemeinsamen Moduls" (siehe z.B.
// timeout_screen.cpp) bewusst hier erneut definiert statt radar_screen.cpp
// umzubauen. Bei Aenderungen an der Logik in radar_screen.cpp bitte diese
// Kopie hier synchron halten, damit das WebUI-Radar dieselben Ringe/
// Markierungen zeigt wie das Geraete-Display.
bool isEmergencySquawkWeb(const char* squawk) {
if (!squawk[0]) return false;
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
if (strcmp(squawk, Config::EMERGENCY_SQUAWKS[i]) == 0) return true;
}
return false;
}
// "Auffaellig" beschraenkt sich aktuell auf die Heavy-Kategorie (ADS-B
// Emitter-Kategorie "A5") - der Teil ueber Militaer-/Regierungs-
// Rufzeichen-Praefixe (Config::NOTABLE_CALLSIGN_PREFIXES) ist NICHT
// umgesetzt, weil diese Konstante nirgends im Projekt existiert (auch
// nicht in radar_screen.cpp) - das zugehoerige Feature wurde bisher nie
// tatsaechlich spezifiziert/implementiert. Sobald es das gibt, hier den
// Praefix-Abgleich ergaenzen, analog zu isEmergencySquawkWeb() oben.
bool isHeavyCategoryWeb(const char* category) {
return category[0] == 'A' && category[1] == '5';
}
String htmlHeader(const String& title) { String htmlHeader(const String& title) {
String html; String html;
html += "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">"; html += "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">";
@@ -38,16 +70,76 @@ namespace {
html += "button{background:#0a0f0d;color:#ff3b3b;border:1px solid #ff3b3b;border-radius:4px;padding:3px 10px;font-family:inherit;cursor:pointer;}"; html += "button{background:#0a0f0d;color:#ff3b3b;border:1px solid #ff3b3b;border-radius:4px;padding:3px 10px;font-family:inherit;cursor:pointer;}";
html += "button:hover{background:#ff3b3b;color:#0a0f0d;}"; html += "button:hover{background:#ff3b3b;color:#0a0f0d;}";
html += ".dl{color:#39ff14;text-decoration:none;border:1px solid #39ff14;border-radius:4px;padding:3px 10px;margin-right:6px;display:inline-block;}"; html += ".dl{color:#39ff14;text-decoration:none;border:1px solid #39ff14;border-radius:4px;padding:3px 10px;margin-right:6px;display:inline-block;}";
// Gruen statt Rot fuer "Hinzufuegen"-Buttons - die roten button{}-Regeln
// oben bleiben fuer alle destruktiven "Entfernen/Loeschen"-Buttons
// unveraendert, .addbtn ist ausschliesslich fuer die neuen Listen-
// Formulare (siehe handleLists()) gedacht.
html += ".addbtn{background:#0a0f0d;color:#39ff14;border:1px solid #39ff14;border-radius:4px;padding:3px 10px;font-family:inherit;cursor:pointer;}";
html += ".addbtn:hover{background:#39ff14;color:#0a0f0d;}";
html += "input[type=text]{background:#0a0f0d;color:#39ff14;border:1px solid #39ff14;border-radius:4px;padding:5px 8px;font-family:inherit;}";
html += "nav{margin-bottom:10px;}nav a{color:#39ff14;margin-right:16px;}";
html += "#radarCanvas{width:100%;max-width:400px;height:auto;background:#05100a;border:1px solid #1f3a2b;border-radius:8px;display:block;}";
html += "#radarStatus{font-size:12px;color:#7a9a86;margin-top:4px;}";
html += "</style></head><body>"; html += "</style></head><body>";
html += "<h1>" + title + "</h1>"; html += "<h1>" + title + "</h1>";
return html; return html;
} }
// Live-Radar-Ansicht fuer die Startseite: ein <canvas>, das per JavaScript
// alle paar Sekunden /radar.json abruft und die Flugzeuge polar (Peilung/
// Distanz, genau wie auf dem Geraete-Display) zeichnet. Bewusst per
// fetch()-Polling statt WebSocket/SSE gehalten - deutlich weniger Code
// und Speicherbedarf auf dem ESP32, und fuer eine gelegentlich vom Handy
// aus aufgerufene Seite voellig ausreichend.
void appendRadarSection(String& html) {
html += "<h2>Live Radar</h2>";
html += "<canvas id=\"radarCanvas\" width=\"360\" height=\"360\"></canvas>";
html += "<p id=\"radarStatus\">Loading...</p>";
html += "<script>(function(){";
html += "var canvas=document.getElementById('radarCanvas');";
html += "var ctx=canvas.getContext('2d');";
html += "var status=document.getElementById('radarStatus');";
html += "var W=canvas.width,H=canvas.height,cx=W/2,cy=H/2,R=Math.min(W,H)/2-24;";
html += "function altColor(ft){if(ft<3000)return '#ff4d4d';if(ft<10000)return '#ffb84d';if(ft<25000)return '#ffe14d';if(ft<35000)return '#39ff14';return '#4dd2ff';}";
html += "function draw(data){";
html += "ctx.clearRect(0,0,W,H);";
html += "ctx.strokeStyle='#1f3a2b';ctx.fillStyle='#39ff14';ctx.font='10px monospace';ctx.textAlign='left';";
html += "for(var ring=1;ring<=3;ring++){var r=R*ring/3;ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.stroke();";
html += "ctx.fillText(Math.round(data.range_km*ring/3)+' km',cx+4,cy-r+10);}";
html += "ctx.strokeStyle='#12261a';ctx.beginPath();ctx.moveTo(cx-R,cy);ctx.lineTo(cx+R,cy);ctx.moveTo(cx,cy-R);ctx.lineTo(cx,cy+R);ctx.stroke();";
html += "ctx.fillStyle='#39ff14';ctx.textAlign='center';ctx.fillText('N',cx,cy-R-8);";
html += "ctx.fillStyle='#ffffff';ctx.beginPath();ctx.arc(cx,cy,3,0,Math.PI*2);ctx.fill();";
html += "(data.aircraft||[]).forEach(function(a){";
html += "var theta=a.bearing_deg*Math.PI/180;";
html += "var r=Math.min(a.dist_km/data.range_km,1)*R;";
html += "var x=cx+r*Math.sin(theta),y=cy-r*Math.cos(theta);";
html += "var color=a.ground_vehicle?'#aaaaaa':altColor(a.alt_ft);";
html += "ctx.fillStyle=color;ctx.strokeStyle=color;";
html += "if(a.ground_vehicle){ctx.fillRect(x-3,y-3,6,6);}";
html += "else if(a.rotorcraft){ctx.beginPath();ctx.moveTo(x,y-5);ctx.lineTo(x+5,y);ctx.lineTo(x,y+5);ctx.lineTo(x-5,y);ctx.closePath();ctx.fill();}";
html += "else{ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill();";
html += "var hr=a.track_deg*Math.PI/180;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+10*Math.sin(hr),y-10*Math.cos(hr));ctx.stroke();}";
html += "if(a.emergency){ctx.strokeStyle='#ff3b3b';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
html += "else if(a.watched){ctx.strokeStyle='#00e5ff';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
html += "else if(a.notable){ctx.strokeStyle='#ff9f1a';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
html += "ctx.fillStyle=color;ctx.textAlign='center';ctx.fillText(a.callsign,x,y-8);";
html += "});";
html += "status.textContent=(data.aircraft||[]).length+' aircraft \\u00b7 range '+data.range_km+' km';";
html += "}";
html += "function poll(){fetch('/radar.json').then(function(r){return r.json();}).then(draw).catch(function(){status.textContent='Connection lost - retrying...';});}";
html += "poll();setInterval(poll,3000);";
html += "})();</script>";
}
void handleRoot() { void handleRoot() {
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED]; FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t dayCount = FlightLogbook::listDays(days, MAX_DAYS_QUERIED); uint8_t dayCount = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
String html = htmlHeader("Eiswolfs Flightradar - Logbook Export"); String html = htmlHeader("Eiswolfs Flightradar");
appendRadarSection(html);
html += "<nav><a href=\"/lists\">Manage Airline Filter &amp; Watchlist &rarr;</a></nav>";
html += "<h2>Flight Logbook</h2>"; html += "<h2>Flight Logbook</h2>";
if (dayCount == 0) { if (dayCount == 0) {
@@ -145,6 +237,159 @@ namespace {
server.send(303); server.send(303);
} }
// Verwaltung von Airline-Filter und Beobachtungsliste per Browser -
// beide Backend-Module (AirlineFilter/AircraftWatchlist) sind seit
// dieser Erweiterung mutex-geschuetzt (siehe dort), da sie jetzt sowohl
// von Core 1 (Radar-/Menue-Screens) als auch von hier aus - Core 0,
// WebExportServer laeuft innerhalb von NetTask - aufgerufen werden.
// Praktisch vor allem fuer laengere Eingaben (Rufzeichen, ICAO-Codes),
// die sich per Handy-Tastatur deutlich bequemer eintippen lassen als
// ueber die kleine Bildschirmtastatur des Geraets.
void handleLists() {
String html = htmlHeader("Eiswolfs Flightradar - Lists");
html += "<nav><a href=\"/\">&larr; Logbook</a></nav>";
html += "<h2>Hidden Airlines</h2>";
html += "<p>Aircraft from these airlines (matched by callsign prefix, e.g. \"DLH\") are hidden from the radar.</p>";
uint8_t airlineCount = AirlineFilter::count();
if (airlineCount == 0) {
html += "<p>No hidden airlines yet.</p>";
} else {
html += "<table><tr><th>ICAO prefix</th><th></th></tr>";
for (uint8_t i = 0; i < airlineCount; i++) {
html += "<tr><td>" + AirlineFilter::icaoAt(i) + "</td><td>";
html += "<form method=\"POST\" action=\"/lists/airlines/delete\">";
html += "<input type=\"hidden\" name=\"index\" value=\"" + String(i) + "\">";
html += "<button type=\"submit\">Remove</button></form></td></tr>";
}
html += "</table>";
}
html += "<form method=\"POST\" action=\"/lists/airlines/add\">";
html += "<input type=\"text\" name=\"icao\" maxlength=\"3\" placeholder=\"e.g. DLH\"> ";
html += "<button class=\"addbtn\" type=\"submit\">Add</button></form>";
html += "<h2>Watched Aircraft</h2>";
html += "<p>The blue LED blinks when any of these exact callsigns appears on the radar (up to 5).</p>";
uint8_t watchCount = AircraftWatchlist::count();
if (watchCount == 0) {
html += "<p>No watched aircraft yet.</p>";
} else {
html += "<table><tr><th>Callsign</th><th></th></tr>";
for (uint8_t i = 0; i < watchCount; i++) {
html += "<tr><td>" + AircraftWatchlist::callsignAt(i) + "</td><td>";
html += "<form method=\"POST\" action=\"/lists/watchlist/delete\">";
html += "<input type=\"hidden\" name=\"index\" value=\"" + String(i) + "\">";
html += "<button type=\"submit\">Remove</button></form></td></tr>";
}
html += "</table>";
}
html += "<form method=\"POST\" action=\"/lists/watchlist/add\">";
html += "<input type=\"text\" name=\"callsign\" maxlength=\"8\" placeholder=\"e.g. DLH441\"> ";
html += "<button class=\"addbtn\" type=\"submit\">Add</button></form>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleAirlineAdd() {
if (server.hasArg("icao")) {
String icao = server.arg("icao");
icao.trim();
if (icao.length() > 0 && icao.length() <= 3) {
AirlineFilter::addHidden(icao.c_str());
}
}
server.sendHeader("Location", "/lists");
server.send(303);
}
void handleAirlineDelete() {
if (server.hasArg("index")) {
int idx = server.arg("index").toInt();
if (idx >= 0 && idx < 255) {
AirlineFilter::removeHidden((uint8_t)idx);
}
}
server.sendHeader("Location", "/lists");
server.send(303);
}
void handleWatchlistAdd() {
if (server.hasArg("callsign")) {
String callsign = server.arg("callsign");
callsign.trim();
if (callsign.length() > 0 && callsign.length() <= 8) {
AircraftWatchlist::addWatched(callsign.c_str());
}
}
server.sendHeader("Location", "/lists");
server.send(303);
}
void handleWatchlistDelete() {
if (server.hasArg("index")) {
int idx = server.arg("index").toInt();
if (idx >= 0 && idx < 255) {
AircraftWatchlist::removeWatched((uint8_t)idx);
}
}
server.sendHeader("Location", "/lists");
server.send(303);
}
// Datenquelle fuer das Live-Radar auf der Startseite (siehe
// appendRadarSection()). Wendet dieselben Filter/Prioritaeten an wie das
// Geraete-Display (render() in radar_screen.cpp): Reichweite, "Boden-
// fahrzeuge ausblenden", Airline-Filter, sowie Notfall/Beobachtungsliste/
// "auffaellig" als sich gegenseitig ausschliessende Ring-Markierungen in
// genau dieser Prioritaet.
void handleRadarJson() {
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
bool hideGround = SettingsStore::hideGroundVehicles();
bool emergencyOn = SettingsStore::emergencyAlertEnabled();
bool watchOn = SettingsStore::watchlistAlertEnabled();
JsonDocument doc;
doc["range_km"] = rangeKm;
JsonArray arr = doc["aircraft"].to<JsonArray>();
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
Aircraft& a = table[i];
if (!a.valid) continue;
if (a.distanceKm > rangeKm * 1.05f) continue;
bool isGroundVehicle = a.category[0] == 'C';
if (hideGround && isGroundVehicle) continue;
if (AirlineFilter::isHidden(a.callsign)) continue;
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
bool isEmergency = emergencyOn && isEmergencySquawkWeb(a.squawk);
bool isWatched = watchOn && AircraftWatchlist::isWatched(a.callsign);
bool isNotable = isHeavyCategoryWeb(a.category);
JsonObject o = arr.add<JsonObject>();
o["hex"] = a.hex;
o["callsign"] = a.callsign[0] ? a.callsign : a.hex;
o["dist_km"] = a.distanceKm;
o["bearing_deg"] = a.bearingDeg;
o["alt_ft"] = a.altBaroFt;
o["track_deg"] = a.headingDeg;
o["ground_vehicle"] = isGroundVehicle;
o["rotorcraft"] = isRotorcraft;
o["emergency"] = isEmergency;
o["watched"] = isWatched;
o["notable"] = isNotable;
}
AircraftTable::unlock();
String out;
serializeJson(doc, out);
server.send(200, "application/json", out);
}
void handleNotFound() { void handleNotFound() {
server.send(404, "text/plain", "Not found"); server.send(404, "text/plain", "Not found");
} }
@@ -152,9 +397,15 @@ namespace {
void begin() { void begin() {
server.on("/", handleRoot); server.on("/", handleRoot);
server.on("/radar.json", handleRadarJson);
server.on("/export.csv", handleExportCsv); server.on("/export.csv", handleExportCsv);
server.on("/csv", HTTP_GET, handleCsvDownload); server.on("/csv", HTTP_GET, handleCsvDownload);
server.on("/logbook/delete", HTTP_POST, handleLogbookDelete); server.on("/logbook/delete", HTTP_POST, handleLogbookDelete);
server.on("/lists", handleLists);
server.on("/lists/airlines/add", HTTP_POST, handleAirlineAdd);
server.on("/lists/airlines/delete", HTTP_POST, handleAirlineDelete);
server.on("/lists/watchlist/add", HTTP_POST, handleWatchlistAdd);
server.on("/lists/watchlist/delete", HTTP_POST, handleWatchlistDelete);
server.onNotFound(handleNotFound); server.onNotFound(handleNotFound);
server.begin(); server.begin();
} }