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:
14
CLAUDE.md
14
CLAUDE.md
@@ -190,6 +190,11 @@ in dieser Reihenfolge:
|
||||
Alex nachfragen statt zu raten. Erst NACH dem Eintragen der korrekten
|
||||
Nummer: einmal sauber `pio run` bauen, DANACH erst der Rest des
|
||||
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
|
||||
sind, die für Endnutzer sichtbar sind (neue Menüpunkte, geändertes
|
||||
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.
|
||||
"[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.
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "sd_mutex.h"
|
||||
#include "sd_storage.h"
|
||||
#include <SD.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
|
||||
@@ -14,6 +16,16 @@ namespace {
|
||||
char watched[MAX_WATCHED][9] = {{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
|
||||
// bricht bei einem Leerzeichen ab (ADS-B-Rufzeichen haben oft Padding),
|
||||
// alles in Grossbuchstaben.
|
||||
@@ -61,55 +73,83 @@ namespace {
|
||||
}
|
||||
|
||||
void init() {
|
||||
ensureMutex();
|
||||
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) {
|
||||
if (index >= watchedCount) return String();
|
||||
return String(watched[index]);
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
String out = (index >= watchedCount) ? String() : String(watched[index]);
|
||||
xSemaphoreGive(mutex);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool addWatched(const char* callsign) {
|
||||
if (watchedCount >= MAX_WATCHED) return false;
|
||||
if (!callsign || !callsign[0]) return false;
|
||||
|
||||
char normalized[9] = {0};
|
||||
normalize(callsign, normalized);
|
||||
if (!normalized[0]) return false;
|
||||
|
||||
for (uint8_t j = 0; j < watchedCount; j++) {
|
||||
if (strcmp(watched[j], normalized) == 0) return true;
|
||||
ensureMutex();
|
||||
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);
|
||||
watched[watchedCount][8] = 0;
|
||||
watchedCount++;
|
||||
saveToSd();
|
||||
return true;
|
||||
if (ok && !alreadyPresent) saveToSd();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void removeWatched(uint8_t index) {
|
||||
if (index >= watchedCount) return;
|
||||
for (uint8_t i = index; i < watchedCount - 1; i++) {
|
||||
strncpy(watched[i], watched[i + 1], 9);
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
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--;
|
||||
watched[watchedCount][0] = 0;
|
||||
saveToSd();
|
||||
xSemaphoreGive(mutex);
|
||||
if (changed) saveToSd();
|
||||
}
|
||||
|
||||
bool isWatched(const char* callsign) {
|
||||
if (watchedCount == 0) return false;
|
||||
ensureMutex();
|
||||
char normalized[9];
|
||||
normalize(callsign, normalized);
|
||||
if (!normalized[0]) return false;
|
||||
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
bool found = false;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "sd_mutex.h"
|
||||
#include "sd_storage.h"
|
||||
#include <SD.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
|
||||
@@ -14,6 +16,16 @@ namespace {
|
||||
char hidden[MAX_HIDDEN][4] = {{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) {
|
||||
int i = 0;
|
||||
for (; i < 3 && callsign[i] && isalpha((unsigned char)callsign[i]); i++) {
|
||||
@@ -56,18 +68,27 @@ namespace {
|
||||
}
|
||||
|
||||
void init() {
|
||||
ensureMutex();
|
||||
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) {
|
||||
if (index >= hiddenCount) return String();
|
||||
return String(hidden[index]);
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
String out = (index >= hiddenCount) ? String() : String(hidden[index]);
|
||||
xSemaphoreGive(mutex);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool addHidden(const char* icaoPrefix) {
|
||||
if (hiddenCount >= MAX_HIDDEN) return false;
|
||||
if (!icaoPrefix || !icaoPrefix[0]) return false;
|
||||
|
||||
char normalized[4] = {0};
|
||||
@@ -78,37 +99,56 @@ bool addHidden(const char* icaoPrefix) {
|
||||
normalized[i] = 0;
|
||||
if (i == 0) return false;
|
||||
|
||||
for (uint8_t j = 0; j < hiddenCount; j++) {
|
||||
if (strcmp(hidden[j], normalized) == 0) return true;
|
||||
ensureMutex();
|
||||
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);
|
||||
hidden[hiddenCount][3] = 0;
|
||||
hiddenCount++;
|
||||
saveToSd();
|
||||
return true;
|
||||
if (ok && !alreadyPresent) saveToSd();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void removeHidden(uint8_t index) {
|
||||
if (index >= hiddenCount) return;
|
||||
for (uint8_t i = index; i < hiddenCount - 1; i++) {
|
||||
strncpy(hidden[i], hidden[i + 1], 4);
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
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--;
|
||||
hidden[hiddenCount][0] = 0;
|
||||
saveToSd();
|
||||
xSemaphoreGive(mutex);
|
||||
if (changed) saveToSd();
|
||||
}
|
||||
|
||||
bool isHidden(const char* callsign) {
|
||||
if (hiddenCount == 0) return false;
|
||||
ensureMutex();
|
||||
char prefix[4];
|
||||
extractPrefix(callsign, prefix);
|
||||
if (!prefix[0]) return false;
|
||||
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
bool found = false;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ static const char* const I18N_DE[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Wetter",
|
||||
|
||||
@@ -199,7 +199,7 @@ static const char* const I18N_EN[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Weather",
|
||||
|
||||
@@ -198,7 +198,7 @@ static const char* const I18N_ES[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Clima",
|
||||
|
||||
@@ -198,7 +198,7 @@ static const char* const I18N_FR[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Météo",
|
||||
|
||||
@@ -198,7 +198,7 @@ static const char* const I18N_IT[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Meteo",
|
||||
|
||||
@@ -198,7 +198,7 @@ static const char* const I18N_TR[] = {
|
||||
|
||||
"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.",
|
||||
|
||||
"Hava Durumu",
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
#include "config.h"
|
||||
#include "flight_logbook.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 <ArduinoJson.h>
|
||||
#include <SD.h>
|
||||
#include <cstring>
|
||||
|
||||
@@ -23,6 +28,33 @@ namespace {
|
||||
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 html;
|
||||
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: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;}";
|
||||
// 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 += "<h1>" + title + "</h1>";
|
||||
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() {
|
||||
FlightLogbook::DayEntry 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 & Watchlist →</a></nav>";
|
||||
|
||||
html += "<h2>Flight Logbook</h2>";
|
||||
if (dayCount == 0) {
|
||||
@@ -145,6 +237,159 @@ namespace {
|
||||
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=\"/\">← 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() {
|
||||
server.send(404, "text/plain", "Not found");
|
||||
}
|
||||
@@ -152,9 +397,15 @@ namespace {
|
||||
|
||||
void begin() {
|
||||
server.on("/", handleRoot);
|
||||
server.on("/radar.json", handleRadarJson);
|
||||
server.on("/export.csv", handleExportCsv);
|
||||
server.on("/csv", HTTP_GET, handleCsvDownload);
|
||||
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.begin();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user