556 lines
27 KiB
C++
556 lines
27 KiB
C++
#include "web_export_server.h"
|
|
#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>
|
|
#include <cmath>
|
|
|
|
namespace WebExportServer {
|
|
|
|
namespace {
|
|
constexpr uint8_t MAX_DAYS_QUERIED = 31;
|
|
|
|
WebServer server(80);
|
|
|
|
// Only accept plain filenames/labels from form fields - no
|
|
// "/" and no ".." - so that no escape from the respective
|
|
// SD directory is possible via the WebUI (path traversal).
|
|
bool isSafeName(const String& name) {
|
|
if (name.length() == 0 || name.length() > 40) return false;
|
|
if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) return false;
|
|
if (name.indexOf("..") >= 0) return false;
|
|
return true;
|
|
}
|
|
|
|
// Intentional duplicates of the same-named (local/static) functions
|
|
// from radar_screen.cpp - not exported there, and following the project's
|
|
// established convention "each screen/module duplicates its own small
|
|
// helpers instead of a shared module" (see e.g. timeout_screen.cpp),
|
|
// deliberately redefined here instead of refactoring radar_screen.cpp.
|
|
// If the logic in radar_screen.cpp changes, please keep this copy
|
|
// in sync so that the WebUI radar shows the same rings/markers as the
|
|
// device 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;
|
|
}
|
|
|
|
// "Notable" (orange ring) is currently limited to nothing at all -
|
|
// the part about military/government callsign prefixes
|
|
// (Config::NOTABLE_CALLSIGN_PREFIXES) is NOT implemented, because this
|
|
// constant does not exist anywhere in the project (not even in
|
|
// radar_screen.cpp) - the associated feature has never actually been
|
|
// specified/implemented. "notable" is therefore hardcoded to false
|
|
// below in handleRadarJson(). As soon as there is a real prefix list,
|
|
// add an isNotableCallsignWeb() function analogous to
|
|
// isEmergencySquawkWeb() here.
|
|
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\">";
|
|
html += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">";
|
|
html += "<title>" + title + "</title>";
|
|
html += "<style>";
|
|
html += "body{background-color:#0a0f0d;color:#39ff14;font-family:'Courier New',Courier,monospace;padding:20px;}";
|
|
html += "h1{font-size:20px;}h2{font-size:16px;margin-top:24px;border-top:1px solid #1f3a2b;padding-top:12px;}";
|
|
html += "table{border-collapse:collapse;margin-top:10px;width:100%;}";
|
|
html += "td,th{padding:4px 12px;text-align:left;border-bottom:1px solid #1f3a2b;}";
|
|
html += "a{color:#39ff14;}";
|
|
html += "form{display:inline;}";
|
|
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;}";
|
|
// Green instead of red for "Add" buttons - the red button{} rules
|
|
// above stay unchanged for all destructive "Remove/Delete" buttons.
|
|
// .addbtn is exclusively for the new list forms (see handleLists()).
|
|
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;cursor:pointer;}";
|
|
html += "#radarStatus{font-size:12px;color:#7a9a86;margin-top:4px;}";
|
|
html += "#radarControls{font-size:12px;margin-bottom:8px;}";
|
|
html += "#radarControls select{background:#0a0f0d;color:#39ff14;border:1px solid #39ff14;border-radius:4px;padding:2px 6px;font-family:inherit;}";
|
|
html += "#acInfo{display:none;max-width:400px;margin-top:8px;padding:8px 10px;border:1px solid #39ff14;border-radius:6px;font-size:13px;line-height:1.7;}";
|
|
html += "#acInfo a{color:#ff3b3b;text-decoration:none;border:1px solid #ff3b3b;border-radius:4px;padding:2px 8px;display:inline-block;margin-top:4px;}";
|
|
html += "</style></head><body>";
|
|
html += "<h1>" + title + "</h1>";
|
|
return html;
|
|
}
|
|
|
|
// Live radar view for the start page: a <canvas> that uses JavaScript
|
|
// to fetch /radar.json every few seconds and draw the aircraft polar
|
|
// (bearing/distance, exactly like on the device display). Intentionally
|
|
// kept with fetch()-polling instead of WebSocket/SSE - significantly less
|
|
// code and memory usage on the ESP32, and perfectly adequate for a page
|
|
// occasionally opened from a phone.
|
|
//
|
|
// The range selector (<select>) is purely client-side/per page view -
|
|
// it does NOT change the device setting (SettingsStore::
|
|
// rangeIndex()), but is sent as a "range_km" query parameter to
|
|
// /radar.json (see handleRadarJson()) and allows independent zooming
|
|
// in/out on the phone without affecting the device display. Default is
|
|
// the current device range.
|
|
void appendRadarSection(String& html) {
|
|
float deviceRangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
|
|
|
|
html += "<h2>Live Radar</h2>";
|
|
html += "<div id=\"radarControls\">Range: <select id=\"radarRange\">";
|
|
for (uint8_t i = 0; i < Config::RANGE_STEP_COUNT; i++) {
|
|
bool isDefault = fabsf(Config::RANGE_STEPS_KM[i] - deviceRangeKm) < 0.5f;
|
|
html += "<option value=\"" + String(Config::RANGE_STEPS_KM[i], 0) + "\"";
|
|
if (isDefault) html += " selected";
|
|
html += ">" + String(Config::RANGE_STEPS_KM[i], 0) + " km</option>";
|
|
}
|
|
html += "</select></div>";
|
|
html += "<canvas id=\"radarCanvas\" width=\"360\" height=\"360\"></canvas>";
|
|
html += "<p id=\"radarStatus\">Loading...</p>";
|
|
html += "<div id=\"acInfo\"></div>";
|
|
html += "<script>(function(){";
|
|
html += "var canvas=document.getElementById('radarCanvas');";
|
|
html += "var ctx=canvas.getContext('2d');";
|
|
html += "var status=document.getElementById('radarStatus');";
|
|
html += "var infoBox=document.getElementById('acInfo');";
|
|
html += "var rangeSel=document.getElementById('radarRange');";
|
|
html += "var W=canvas.width,H=canvas.height,cx=W/2,cy=H/2,R=Math.min(W,H)/2-24;";
|
|
html += "var lastData={range_km:" + String(deviceRangeKm, 0) + ",aircraft:[]};";
|
|
html += "var markers=[];";
|
|
html += "var selectedHex=null;";
|
|
|
|
// Background stars OUTSIDE the radar circle - same principle
|
|
// as updateBgStars()/initBgStarsIfNeeded() in radar_screen.cpp
|
|
// (rejection sampling, so no star lands inside the circle),
|
|
// replicated here via Canvas/JS instead of TFT_eSPI.
|
|
html += "var stars=[];";
|
|
html += "(function(){var minDistSq=(R+6)*(R+6);for(var i=0;i<24;i++){var x,y,tries=0;";
|
|
html += "do{x=4+Math.random()*(W-8);y=4+Math.random()*(H-8);tries++;}";
|
|
html += "while(((x-cx)*(x-cx)+(y-cy)*(y-cy))<minDistSq&&tries<25);";
|
|
html += "stars.push({x:x,y:y,phase:Math.random()*255,speed:1+Math.random()*2});}})();";
|
|
html += "function drawStars(){for(var i=0;i<stars.length;i++){var s=stars[i];";
|
|
html += "s.phase=(s.phase+s.speed)%256;";
|
|
html += "var bright=Math.round(s.phase<128?s.phase*2:(255-s.phase)*2);";
|
|
html += "ctx.fillStyle='rgb(0,'+bright+',0)';ctx.fillRect(s.x,s.y,1,1);}}";
|
|
|
|
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 += "lastData=data;";
|
|
html += "ctx.clearRect(0,0,W,H);";
|
|
html += "drawStars();";
|
|
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();";
|
|
// All four cardinal directions (N/E/S/W), exactly like
|
|
// drawStaticBackground() in radar_screen.cpp - previously only
|
|
// "N" was shown here, which was expanded upon request.
|
|
html += "ctx.fillStyle='#39ff14';ctx.textAlign='center';ctx.fillText('N',cx,cy-R-8);";
|
|
html += "ctx.fillText('S',cx,cy+R+16);";
|
|
html += "ctx.textAlign='left';ctx.fillText('E',cx+R+4,cy+3);";
|
|
html += "ctx.textAlign='right';ctx.fillText('W',cx-R-4,cy+3);";
|
|
html += "ctx.textAlign='center';";
|
|
html += "ctx.fillStyle='#ffffff';ctx.beginPath();ctx.arc(cx,cy,3,0,Math.PI*2);ctx.fill();";
|
|
html += "markers=[];";
|
|
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 if(a.heavy){ctx.beginPath();ctx.arc(x,y,5,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.arc(x,y,7,0,Math.PI*2);ctx.stroke();";
|
|
html += "var hr2=a.track_deg*Math.PI/180;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+10*Math.sin(hr2),y-10*Math.cos(hr2));ctx.stroke();}";
|
|
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();}";
|
|
// Selected aircraft (by click/tap, see below) gets a
|
|
// white selection ring, same principle as isSelected on the
|
|
// device display (radar_screen.cpp render()).
|
|
html += "if(a.hex===selectedHex){ctx.strokeStyle='#ffffff';ctx.beginPath();ctx.arc(x,y,11,0,Math.PI*2);ctx.stroke();}";
|
|
html += "ctx.fillStyle=color;ctx.textAlign='center';ctx.fillText(a.callsign,x,y-8);";
|
|
html += "markers.push({x:x,y:y,a:a});";
|
|
html += "});";
|
|
html += "status.textContent=(data.aircraft||[]).length+' aircraft \\u00b7 range '+data.range_km+' km';";
|
|
// If the selected aircraft no longer appears in this update
|
|
// (e.g. flew out of range), close the info box instead of
|
|
// showing stale data.
|
|
html += "if(selectedHex){var found=markers.filter(function(m){return m.a.hex===selectedHex;})[0];";
|
|
html += "if(found){showInfo(found.a);}else{selectedHex=null;hideInfo();}}";
|
|
html += "}";
|
|
|
|
// Info panel for a tapped aircraft - intentionally a dedicated,
|
|
// persistent box (not a tooltip/popup that disappears on the next
|
|
// redraw), with an explicit close link, same basic principle as
|
|
// infoScreen() on the device: the user should actively decide when
|
|
// the info disappears.
|
|
html += "function fmtNum(n,d){return (typeof n==='number')?n.toFixed(d):'?';}";
|
|
html += "function showInfo(a){";
|
|
html += "var lines=[];";
|
|
html += "lines.push('<b>'+(a.callsign||a.hex)+'</b> ('+a.hex+')');";
|
|
html += "lines.push('Altitude: '+Math.round(a.alt_ft)+' ft');";
|
|
html += "if(a.speed_kt){lines.push('Speed: '+Math.round(a.speed_kt)+' kt');}";
|
|
html += "lines.push('Distance: '+fmtNum(a.dist_km,1)+' km, bearing '+Math.round(a.bearing_deg)+'\\u00b0');";
|
|
html += "lines.push('Heading: '+Math.round(a.track_deg)+'\\u00b0');";
|
|
html += "if(a.squawk){lines.push('Squawk: '+a.squawk);}";
|
|
html += "infoBox.innerHTML=lines.join('<br>')+'<br><a href=\"#\" id=\"acInfoClose\">Close</a>';";
|
|
html += "infoBox.style.display='block';";
|
|
html += "document.getElementById('acInfoClose').onclick=function(e){e.preventDefault();selectedHex=null;hideInfo();draw(lastData);};";
|
|
html += "}";
|
|
html += "function hideInfo(){infoBox.style.display='none';}";
|
|
|
|
html += "canvas.addEventListener('click',function(ev){";
|
|
html += "var rect=canvas.getBoundingClientRect();";
|
|
html += "var scaleX=canvas.width/rect.width,scaleY=canvas.height/rect.height;";
|
|
html += "var px=(ev.clientX-rect.left)*scaleX,py=(ev.clientY-rect.top)*scaleY;";
|
|
html += "var best=null,bestD=14*14;";
|
|
html += "markers.forEach(function(m){var dx=m.x-px,dy=m.y-py,d=dx*dx+dy*dy;if(d<bestD){bestD=d;best=m;}});";
|
|
html += "if(best){selectedHex=best.a.hex;showInfo(best.a);}else{selectedHex=null;hideInfo();}";
|
|
html += "draw(lastData);";
|
|
html += "});";
|
|
|
|
html += "if(rangeSel){rangeSel.addEventListener('change',poll);}";
|
|
html += "function poll(){";
|
|
html += "var url='/radar.json';";
|
|
html += "if(rangeSel&&rangeSel.value){url+='?range_km='+rangeSel.value;}";
|
|
html += "fetch(url).then(function(r){return r.json();}).then(draw).catch(function(){status.textContent='Connection lost - retrying...';});";
|
|
html += "}";
|
|
html += "poll();";
|
|
html += "setInterval(poll,3000);";
|
|
// Faster, purely local redraw interval (every 150ms, without network
|
|
// request) only for star twinkling + the selection marker -
|
|
// same basic principle as tick() vs. render() on the
|
|
// device display: aircraft positions still update
|
|
// only every 3s via poll(), but stars twinkle smoothly in between.
|
|
html += "setInterval(function(){draw(lastData);},150);";
|
|
html += "})();</script>";
|
|
}
|
|
|
|
void handleRoot() {
|
|
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
|
|
uint8_t dayCount = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
|
|
|
|
String html = htmlHeader("Leos Flightradar");
|
|
|
|
appendRadarSection(html);
|
|
|
|
html += "<nav><a href=\"/lists\">Manage Airline Filter & Watchlist →</a></nav>";
|
|
|
|
html += "<h2>Flight Logbook</h2>";
|
|
if (dayCount == 0) {
|
|
html += "<p>No logbook entries yet.</p>";
|
|
} else {
|
|
html += "<p><a class=\"dl\" href=\"/export.csv\">Download merged CSV (all days)</a></p>";
|
|
html += "<table><tr><th>Date</th><th>Aircraft</th><th></th><th></th></tr>";
|
|
for (uint8_t i = 0; i < dayCount; i++) {
|
|
String date = String(days[i].date);
|
|
html += "<tr><td>" + date + "</td><td>" + String(days[i].count) + "</td>";
|
|
html += "<td><a class=\"dl\" href=\"/csv?date=" + date + "\">Download</a></td>";
|
|
html += "<td><form method=\"POST\" action=\"/logbook/delete\">";
|
|
html += "<input type=\"hidden\" name=\"date\" value=\"" + date + "\">";
|
|
html += "<button type=\"submit\">Delete</button></form></td></tr>";
|
|
}
|
|
html += "</table>";
|
|
}
|
|
|
|
html += "</body></html>";
|
|
server.send(200, "text/html", html);
|
|
}
|
|
|
|
void handleExportCsv() {
|
|
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
|
|
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
|
|
|
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
|
server.send(200, "text/csv", "");
|
|
server.sendContent("date,timestamp,hex,callsign,reg,type,distance_km,altitude_ft\n");
|
|
|
|
SdMutex::Guard guard;
|
|
uint16_t linesSent = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
char path[48];
|
|
snprintf(path, sizeof(path), "%s/%s.csv", Config::SD_LOG_DIR, days[i].date);
|
|
|
|
File f = SD.open(path, FILE_READ);
|
|
if (!f) continue;
|
|
|
|
f.readStringUntil('\n');
|
|
while (f.available()) {
|
|
String line = f.readStringUntil('\n');
|
|
line.trim();
|
|
if (line.length() == 0) continue;
|
|
server.sendContent(String(days[i].date) + "," + line + "\n");
|
|
|
|
// Important: Without regularly yielding the CPU, this
|
|
// task (Core 0, priority 1) starves the idle task that feeds
|
|
// the task watchdog. With larger logbooks, this causes a
|
|
// watchdog reset after approx. 5s without yield -
|
|
// exactly the reboot during CSV download that the user
|
|
// observed. delay(1) forces a context switch.
|
|
if (++linesSent % 10 == 0) {
|
|
delay(1);
|
|
}
|
|
}
|
|
f.close();
|
|
delay(1);
|
|
}
|
|
}
|
|
|
|
void handleCsvDownload() {
|
|
if (!server.hasArg("date")) {
|
|
server.send(400, "text/plain", "Bad request");
|
|
return;
|
|
}
|
|
String date = server.arg("date");
|
|
if (!isSafeName(date)) {
|
|
server.send(400, "text/plain", "Bad request");
|
|
return;
|
|
}
|
|
|
|
char path[64];
|
|
snprintf(path, sizeof(path), "%s/%s.csv", Config::SD_LOG_DIR, date.c_str());
|
|
|
|
SdMutex::Guard guard;
|
|
File f = SD.open(path, FILE_READ);
|
|
if (!f) {
|
|
server.send(404, "text/plain", "Not found");
|
|
return;
|
|
}
|
|
server.sendHeader("Content-Disposition", "attachment; filename=\"" + date + ".csv\"");
|
|
server.streamFile(f, "text/csv");
|
|
f.close();
|
|
}
|
|
|
|
void handleLogbookDelete() {
|
|
if (server.hasArg("date")) {
|
|
String date = server.arg("date");
|
|
if (isSafeName(date)) {
|
|
FlightLogbook::deleteFile(date.c_str());
|
|
}
|
|
}
|
|
server.sendHeader("Location", "/");
|
|
server.send(303);
|
|
}
|
|
|
|
// Management of airline filter and watchlist via browser -
|
|
// both backend modules (AirlineFilter/AircraftWatchlist) are now
|
|
// mutex-protected (see there), since they are called both
|
|
// from Core 1 (radar/menu screens) and from here - Core 0,
|
|
// WebExportServer runs within NetTask.
|
|
// Practically most useful for longer inputs (callsigns, ICAO codes)
|
|
// that are much easier to type on a phone keyboard than
|
|
// via the device's small on-screen keyboard.
|
|
void handleLists() {
|
|
String html = htmlHeader("Leos 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);
|
|
}
|
|
|
|
// Data source for the live radar on the start page (see
|
|
// appendRadarSection()). Applies the same filters/priorities as the
|
|
// device display (render() in radar_screen.cpp): range, "hide ground
|
|
// vehicles", airline filter, and emergency/watchlist/"notable" as
|
|
// mutually exclusive ring markers in exactly this priority.
|
|
void handleRadarJson() {
|
|
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
|
|
// Allows the web view its own independent zooming (see
|
|
// range selector in appendRadarSection()), WITHOUT changing the
|
|
// device setting. Only accept one of the known
|
|
// Config::RANGE_STEPS_KM values - a missing or
|
|
// unknown parameter falls back to the device range.
|
|
if (server.hasArg("range_km")) {
|
|
float requested = server.arg("range_km").toFloat();
|
|
for (uint8_t i = 0; i < Config::RANGE_STEP_COUNT; i++) {
|
|
if (fabsf(Config::RANGE_STEPS_KM[i] - requested) < 0.5f) {
|
|
rangeKm = Config::RANGE_STEPS_KM[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
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' || a.onGround;
|
|
if (hideGround && isGroundVehicle) continue;
|
|
if (AirlineFilter::isHidden(a.callsign)) continue;
|
|
|
|
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
|
|
bool isHeavy = isHeavyCategoryWeb(a.category);
|
|
bool isEmergency = emergencyOn && isEmergencySquawkWeb(a.squawk);
|
|
bool isWatched = watchOn && AircraftWatchlist::isWatched(a.callsign);
|
|
// "notable" (orange ring) is reserved for conspicuous callsigns
|
|
// (military/government) - heavy aircraft instead get their
|
|
// own marker shape (see "heavy" above). However, there is
|
|
// currently no military/government prefix list in the
|
|
// project (not even on the device display, see
|
|
// radar_screen.cpp) - "notable" therefore remains
|
|
// always false for now.
|
|
bool isNotable = false;
|
|
|
|
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["heavy"] = isHeavy;
|
|
o["emergency"] = isEmergency;
|
|
o["watched"] = isWatched;
|
|
o["notable"] = isNotable;
|
|
// Additional fields only for the info panel on click/tap on
|
|
// an aircraft (see showInfo() in appendRadarSection()) - both
|
|
// are already losslessly in the Aircraft snapshot, no
|
|
// additional network/SD access needed.
|
|
o["speed_kt"] = a.groundSpeedKt;
|
|
o["squawk"] = a.squawk;
|
|
}
|
|
AircraftTable::unlock();
|
|
|
|
String out;
|
|
serializeJson(doc, out);
|
|
server.send(200, "application/json", out);
|
|
}
|
|
|
|
void handleNotFound() {
|
|
server.send(404, "text/plain", "Not found");
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
void update() {
|
|
server.handleClient();
|
|
}
|
|
|
|
}
|