v2.7.5: Logbook Web UI menu entry, per-day CSV delete, remove screenshot feature

Adds a "Logbook / WebUI" entry directly in Menu > System, showing the
device's IP and usage instructions (previously only reachable via a
hidden "?" button in Logbook Files). The web export page now supports
downloading and deleting individual daily logbook CSV files, in
addition to the existing merged CSV download.

Fixes a task watchdog reset that could reboot the device mid-download
when exporting the logbook via the built-in web server: the CSV send
loop never yielded the CPU, starving the idle task on larger logbooks.
The export loop now yields periodically (delay(1) every 10 lines and
after each file).

Removes the screenshot feature (camera button, on-device BMP storage,
web-based screenshot browser). Screenshots always came out solid
black - diagnosis showed the SPI readback from the display returns all
zeros regardless of SPI_READ_FREQUENCY, indicating TFT_MISO isn't
wired to the display on this CYD board revision. This is a hardware
limitation, not fixable in software, so the feature has been removed
rather than shipped broken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-10 16:16:14 +02:00
parent cf9d096527
commit bb0f1d9e46
17 changed files with 300 additions and 170 deletions

View File

@@ -39,7 +39,7 @@ You can flash the firmware directly from your browser to your CYD display withou
- **Aircraft watchlist** – track up to 5 specific flights by callsign, with a cyan radar ring and blue LED alert when one appears (see [Watchlist](#-watchlist) below)
- **Night dimming** – automatically dims the backlight, plus the radar's aircraft markers and sweep line, to a softer (but still readable) look between 22:00 and 06:00 local time (see [Night Dimming](#-night-dimming) below)
- **History chart** – a simple 7-day bar chart of logged aircraft counts, right next to the plain logbook file list (see [History Chart](#-history-chart) below)
- **Web export** – a small built-in web page (reachable via the device's IP while on the same WiFi) to download the complete flight logbook as one merged CSV file (see [Web Export](#-web-export) below)
- **Web export** – a small built-in web page (reachable via the device's IP while on the same WiFi, with its own "Logbook / WebUI" entry in Menu → System) to view, download and individually delete the flight logbook, in addition to a merged CSV of all days (see [Web Export](#-web-export) below)
- **IP-based geolocation** (no GPS module needed) to center the radar on your location
- **Proximity LED alert** – the onboard RGB LED blinks green when an aircraft is within 3 km (no speaker on this board, so this replaces an audible alert)
- **Dual-core design** – all networking (WiFi, ADS-B polling, aircraft detail lookups) runs on Core 0, while the display and touch input run on Core 1, so the UI never freezes during a network request
@@ -132,11 +132,11 @@ A "?" info button on the History chart screen explains how it works directly on
Lists the last several days from the logbook individually, with date and the number of aircraft logged that day. Handy for tracing the history over multiple days instead of only seeing the grand total. Each file has its own red "X" to delete it individually.
### 🌐 Web Export
In addition to viewing the logbook on the device itself, a small built-in web page lets you export the **complete flight logbook** as a single merged CSV file – handy for opening in Excel, Numbers, or Google Sheets. It starts automatically in the background as soon as the device connects to WiFi. Deliberately **export-only, no live radar tracking over the web** – that would just burn resources for little benefit.
In addition to viewing the logbook on the device itself, a small built-in web page lets you export the flight logbook – handy for opening in Excel, Numbers, or Google Sheets. It starts automatically in the background as soon as the device connects to WiFi. Deliberately **export-only, no live radar tracking over the web** – that would just burn resources for little benefit.
**How to use it:** while your computer or phone is on the same WiFi network as the device, open a browser and go to the device's IP address (e.g. `http://192.168.1.42/`). The page lists every logged day with its aircraft count and a download link for the merged CSV.
**How to use it:** while your computer or phone is on the same WiFi network as the device, open a browser and go to the device's IP address (e.g. `http://192.168.1.42/`). The page lists every logged day with its aircraft count, a download link for the **merged CSV** (all days combined), and per-day **download/delete** links for each individual day's file.
Don't know the device's current IP? The "?" info button on the **Logbook files** screen shows it live on the device.
Don't know the device's current IP? **Menu > System > "Logbook / WebUI"** shows it live on the device, along with a short explanation of the page (the "?" info button on the **Logbook files** screen also still shows it).
### 📖 Flight logbook (ON/OFF)
When enabled, the device logs **every newly sighted aircraft** (timestamp, hex code, callsign, registration, type, distance, altitude) into a CSV file on the SD card. An aircraft is only logged once per activation, even if it crosses the radar multiple times.

View File

@@ -25,7 +25,7 @@ build_flags =
-D TFT_BL=21
-D TFT_BACKLIGHT_ON=HIGH
-D SPI_FREQUENCY=55000000
-D SPI_READ_FREQUENCY=20000000
-D SPI_READ_FREQUENCY=1000000
-D SPI_TOUCH_FREQUENCY=2500000
-D LOAD_GFXFF=1
-D SMOOTH_FONT=1

View File

@@ -6,7 +6,7 @@ namespace Config {
// CLAUDE.md-Workflow "Standard-Workflow: Push & Release") - erscheint
// im Info-Screen (Menue > System > Info) und muss zum jeweiligen
// Git-Tag passen.
constexpr const char* APP_VERSION = "2.7.4";
constexpr const char* APP_VERSION = "2.7.5";
// Display-Helligkeit (Menue > System > Helligkeit), in Prozent.
// MIN bewusst nicht 0 - ein komplett dunkles Display koennte sonst wie

View File

@@ -152,9 +152,6 @@ enum class StringId : uint8_t {
DETAIL_LEVEL,
DETAIL_SQUAWK,
SCREENSHOT_SAVED_PREFIX,
SCREENSHOT_FAILED,
UNITS_TITLE,
UNITS_AUTO,
UNITS_METRIC,
@@ -204,6 +201,19 @@ enum class StringId : uint8_t {
LOCATION_PRESETS_FULL,
LOCATION_INFO_PARA5,
// Neuer, direkt im System-Menue erreichbarer Punkt "Logbuch/WebUI"
// (statt nur ueber das versteckte "?" im Logbuch-Dateien-Screen) -
// zeigt IP + Erklaerung der Weboberflaeche (Flugbuch
// ansehen/herunterladen/loeschen). Die urspruenglich hier mit
// enthaltene Screenshot-Verwaltung wurde wieder entfernt, siehe
// Entfernung von SCREENSHOT_SAVED_PREFIX/SCREENSHOT_FAILED oben - das
// Bildschirm-Auslesen (SPI-Readback) funktioniert auf diesem
// CYD-Board hardwareseitig nicht (TFT_MISO nicht angebunden).
MENU_LOGBOOK_WEBUI,
WEBUI_TITLE,
WEBUI_INFO_PARA1,
WEBUI_INFO_PARA2,
COUNT
};

View File

@@ -152,9 +152,6 @@ static const char* const I18N_DE[] = {
"Horizontalflug",
"Squawk: ",
"Gespeichert: ",
"Screenshot fehlgeschlagen",
"Einheiten",
"Automatisch (nach Standort)",
"Metrisch (km, m, km/h)",
@@ -198,6 +195,11 @@ static const char* const I18N_DE[] = {
"Ohne Namen",
"Presets voll - zuerst einen entfernen",
"Beim Anlegen eines Presets kannst du ihm einen Namen geben (z.B. \"Zuhause\"), der dann in der Übersicht angezeigt wird. Tippe auf die Laufschrift mit dem nächsten Flughafen, um ihn direkt als neuen Preset mit seinem Namen zu übernehmen.",
"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:",
"Keine WLAN-Verbindung - die Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
};
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -153,9 +153,6 @@ static const char* const I18N_EN[] = {
"Level flight",
"Squawk: ",
"Saved: ",
"Screenshot failed",
"Units",
"Auto (by location)",
"Metric (km, m, km/h)",
@@ -199,6 +196,11 @@ static const char* const I18N_EN[] = {
"Skip name",
"Presets full - remove one first",
"When adding a preset you can give it a name (e.g. \"Home\"), which is then shown in the overview. Tap the nearest-airport ticker to add it directly as a new preset with its name.",
"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:",
"No WiFi connection - the page is only reachable while connected to a network.",
};
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -152,9 +152,6 @@ static const char* const I18N_ES[] = {
"Vuelo nivelado",
"Squawk: ",
"Guardado: ",
"Falló la captura de pantalla",
"Unidades",
"Auto (según ubicación)",
"Métrico (km, m, km/h)",
@@ -198,6 +195,11 @@ static const char* const I18N_ES[] = {
"Sin nombre",
"Ubicaciones llenas - elimine una primero",
"Al añadir una ubicación puede darle un nombre (ej. \"Casa\"), que luego se muestra en la lista. Toque el texto en movimiento del aeropuerto más cercano para añadirlo directamente como nueva ubicación con su nombre.",
"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:",
"Sin conexión WiFi - la página solo es accesible mientras haya una conexión de red.",
};
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -152,9 +152,6 @@ static const char* const I18N_FR[] = {
"Vol en palier",
"Squawk : ",
"Enregistré : ",
"Échec de la capture d'écran",
"Unités",
"Auto (selon la position)",
"Métrique (km, m, km/h)",
@@ -198,6 +195,11 @@ static const char* const I18N_FR[] = {
"Sans nom",
"Presets pleins - supprimez-en un d'abord",
"Lors de l'ajout d'une position, vous pouvez lui donner un nom (ex. \"Maison\"), affiché ensuite dans la liste. Touchez le texte défilant de l'aéroport le plus proche pour l'ajouter directement comme nouvelle position avec son nom.",
"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 à :",
"Pas de connexion WiFi - la page n'est accessible que lorsqu'une connexion réseau est établie.",
};
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -152,9 +152,6 @@ static const char* const I18N_IT[] = {
"Volo livellato",
"Squawk: ",
"Salvato: ",
"Screenshot fallito",
"Unità",
"Auto (in base alla posizione)",
"Metrico (km, m, km/h)",
@@ -198,6 +195,11 @@ static const char* const I18N_IT[] = {
"Senza nome",
"Posizioni piene - rimuovine una prima",
"Quando aggiungi una posizione puoi darle un nome (es. \"Casa\"), mostrato poi nell'elenco. Tocca il testo scorrevole dell'aeroporto più vicino per aggiungerlo direttamente come nuova posizione con il suo nome.",
"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:",
"Nessuna connessione WiFi - la pagina è raggiungibile solo mentre è attiva una connessione di rete.",
};
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -152,9 +152,6 @@ static const char* const I18N_TR[] = {
"Düz uçuş",
"Squawk: ",
"Kaydedildi: ",
"Ekran görüntüsü başarısız",
"Birimler",
"Otomatik (konuma göre)",
"Metrik (km, m, km/sa)",
@@ -198,6 +195,11 @@ static const char* const I18N_TR[] = {
"İsimsiz devam et",
"Ön ayarlar dolu - önce birini kaldırın",
"Bir ön ayar eklerken ona bir isim verebilirsiniz (örn. \"Ev\"), bu isim daha sonra listede gösterilir. En yakın havalimanı kayan yazısına dokunarak onu doğrudan ismiyle yeni bir ön ayar olarak ekleyebilirsiniz.",
"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:",
"WiFi bağlantısı yok - sayfaya yalnızca bir ağ bağlantısı varken erişilebilir.",
};
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -27,7 +27,6 @@
#include "splash_screen.h"
#include "led_alert.h"
#include "flight_logbook.h"
#include "screenshot.h"
#include "i18n.h"
#include "first_run_language_screen.h"
#include "menu_stars.h"
@@ -80,7 +79,6 @@ struct Rect {
};
Rect menuBtn = {Config::SCREEN_WIDTH - 90, 3, 54, 22};
Rect camBtn = {(int16_t)(menuBtn.x - 46), 3, 42, 22};
void drawMenuButton() {
tft.fillRoundRect(menuBtn.x, menuBtn.y, menuBtn.w, menuBtn.h, 4, TFT_BLACK);
@@ -91,15 +89,6 @@ void drawMenuButton() {
tft.setTextDatum(TL_DATUM);
}
void drawCamButton() {
tft.fillRoundRect(camBtn.x, camBtn.y, camBtn.w, camBtn.h, 4, TFT_BLACK);
tft.drawRoundRect(camBtn.x, camBtn.y, camBtn.w, camBtn.h, 4, TFT_GREEN);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString("Cam", camBtn.x + camBtn.w / 2, camBtn.y + camBtn.h / 2);
tft.setTextDatum(TL_DATUM);
}
void drawWifiIcon(int16_t rightX, int16_t rowTop, int16_t rowH, int8_t rssi) {
uint8_t level;
if (rssi >= -55) level = 4;
@@ -140,7 +129,6 @@ void drawHeader() {
tft.setTextSize(1);
tft.setCursor(6, 10);
tft.println("Eiswolfs FR");
drawCamButton();
drawMenuButton();
updateWifiIcon();
}
@@ -158,8 +146,8 @@ void updateStatusLine() {
// ueberlagerten sich mit den neuen (am sichtbarsten bei der letzten
// Minutenziffer, die sich am haeufigsten aendert). Deshalb hier gezielt
// NUR unter der Uhrzeit einen hoeheren Bereich loeschen - nicht die
// ganze Zeile, sonst wuerde das jede Sekunde in die Cam/Menu-Buttons
// hineinschneiden, die bis y=25 reichen.
// ganze Zeile, sonst wuerde das jede Sekunde in den Menu-Button
// hineinschneiden, der bis y=25 reicht.
constexpr int16_t CLOCK_CLEAR_W = 50;
constexpr int16_t CLOCK_CLEAR_TOP = HEADER_TITLE_H - 10;
tft.fillRect(0, CLOCK_CLEAR_TOP, CLOCK_CLEAR_W, CONTENT_TOP - CLOCK_CLEAR_TOP, TFT_BLACK);
@@ -206,25 +194,6 @@ void updateNightDimming() {
}
}
void takeScreenshotWithFeedback() {
LedAlert::flashWhite();
String filename = Screenshot::save(tft);
tft.fillRect(0, 0, Config::SCREEN_WIDTH, CONTENT_TOP, TFT_NAVY);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.setCursor(6, 10);
if (filename.length() > 0) {
tft.print(String(I18n::t(StringId::SCREENSHOT_SAVED_PREFIX)) + filename);
} else {
tft.setTextColor(TFT_RED, TFT_NAVY);
tft.print(I18n::t(StringId::SCREENSHOT_FAILED));
}
delay(1200);
drawHeader();
updateStatusLine();
}
void haltWithSdRequiredScreen() {
tft.invertDisplay(true);
@@ -412,8 +381,6 @@ void loop() {
drawHeader();
updateStatusLine();
forceRedraw = true;
} else if (camBtn.contains(tap.x, tap.y)) {
takeScreenshotWithFeedback();
} else if (tap.y >= CONTENT_TOP) {
if (RadarScreen::handleTap(tap.x, tap.y, CONTENT_TOP)) {
forceRedraw = true;

View File

@@ -5,6 +5,7 @@
#include "stats_screen.h"
#include "stats_history_screen.h"
#include "logbook_files_screen.h"
#include "webui_screen.h"
#include "location_presets_screen.h"
#include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h"
@@ -312,7 +313,8 @@ void run(TFT_eSPI& tft) {
Rect backupBtn = rowRect(5);
Rect restoreBtn = rowRect(6);
Rect aboutBtn = rowRect(7);
Rect backBtn = rowRect(8);
Rect webuiBtn = rowRect(8);
Rect backBtn = rowRect(9);
drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE));
@@ -327,6 +329,7 @@ void run(TFT_eSPI& tft) {
drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP));
drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE));
drawButton(tft, aboutBtn, I18n::t(StringId::MENU_ABOUT));
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap;
@@ -362,6 +365,8 @@ void run(TFT_eSPI& tft) {
}
} else if (aboutBtn.contains(tap.x, tap.y)) {
AboutScreen::run(tft);
} else if (webuiBtn.contains(tap.x, tap.y)) {
WebUiScreen::run(tft);
} else if (backBtn.contains(tap.x, tap.y)) {
page = Page::Main;
}

View File

@@ -1,81 +0,0 @@
#include "screenshot.h"
#include "config.h"
#include "sd_mutex.h"
#include <SD.h>
#include <time.h>
namespace Screenshot {
namespace {
void putU32(uint8_t* buf, uint32_t v) {
buf[0] = v & 0xFF;
buf[1] = (v >> 8) & 0xFF;
buf[2] = (v >> 16) & 0xFF;
buf[3] = (v >> 24) & 0xFF;
}
}
String save(TFT_eSPI& tft) {
SdMutex::Guard guard;
if (!SD.exists(Config::SD_SCREENSHOT_DIR)) {
SD.mkdir(Config::SD_SCREENSHOT_DIR);
}
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
char filename[32];
snprintf(filename, sizeof(filename), "%04d%02d%02d_%02d%02d%02d.bmp",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
char fullPath[64];
snprintf(fullPath, sizeof(fullPath), "%s/%s", Config::SD_SCREENSHOT_DIR, filename);
File f = SD.open(fullPath, FILE_WRITE);
if (!f) return String();
int32_t w = tft.width();
int32_t h = tft.height();
uint32_t rowSize = ((uint32_t)w * 3 + 3) & ~3u;
uint32_t dataSize = rowSize * (uint32_t)h;
uint32_t fileSize = 54 + dataSize;
uint8_t header[54] = {0};
header[0] = 'B'; header[1] = 'M';
putU32(&header[2], fileSize);
putU32(&header[10], 54);
putU32(&header[14], 40);
putU32(&header[18], (uint32_t)w);
putU32(&header[22], (uint32_t)h);
header[26] = 1;
header[28] = 24;
putU32(&header[34], dataSize);
f.write(header, 54);
uint16_t rowPixels[Config::SCREEN_WIDTH];
uint8_t rowBuf[Config::SCREEN_WIDTH * 3 + 3];
for (int32_t y = h - 1; y >= 0; y--) {
tft.readRect(0, y, w, 1, rowPixels);
for (int32_t x = 0; x < w; x++) {
uint16_t px = rowPixels[x];
uint8_t r5 = (px >> 11) & 0x1F;
uint8_t g6 = (px >> 5) & 0x3F;
uint8_t b5 = px & 0x1F;
rowBuf[x * 3 + 0] = (b5 * 255) / 31;
rowBuf[x * 3 + 1] = (g6 * 255) / 63;
rowBuf[x * 3 + 2] = (r5 * 255) / 31;
}
for (uint32_t p = (uint32_t)w * 3; p < rowSize; p++) rowBuf[p] = 0;
f.write(rowBuf, rowSize);
}
f.close();
return String(filename);
}
}

View File

@@ -1,13 +0,0 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
// Liest den aktuell angezeigten Bildschirminhalt per SPI-Readback zurueck
// (TFT_MISO ist am CYD verkabelt) und speichert ihn als 24-Bit-BMP-Datei auf
// der SD-Karte. Funktioniert mit allem, was gerade angezeigt wird (Radar,
// Detail-Fenster, Menue, ...).
namespace Screenshot {
// Blockierend (dauert ca. 1-2 Sekunden). Gibt bei Erfolg den Dateinamen
// (ohne Pfad) zurueck, sonst einen leeren String.
String save(TFT_eSPI& tft);
}

View File

@@ -4,6 +4,7 @@
#include "sd_mutex.h"
#include <WebServer.h>
#include <SD.h>
#include <cstring>
namespace WebExportServer {
@@ -12,35 +13,60 @@ namespace {
WebServer server(80);
void handleRoot() {
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
// Nur reine Dateinamen/Labels aus Formularfeldern akzeptieren - kein
// "/" und kein ".." - damit ueber die WebUI kein Ausbruch aus dem
// jeweiligen SD-Verzeichnis moeglich ist (Pfad-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;
}
String htmlHeader(const String& title) {
String html;
html += "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">";
html += "<title>Eiswolfs Flightradar - Logbook Export</title>";
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;}";
html += "table{border-collapse:collapse;margin-top:10px;}";
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;}";
html += "</style></head><body>";
html += "<h1>Eiswolfs Flightradar - Logbook Export</h1>";
html += "<h1>" + title + "</h1>";
return html;
}
if (count == 0) {
void handleRoot() {
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t dayCount = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
String html = htmlHeader("Eiswolfs Flightradar - Logbook Export");
html += "<h2>Flight Logbook</h2>";
if (dayCount == 0) {
html += "<p>No logbook entries yet.</p>";
} else {
html += "<table><tr><th>Date</th><th>Aircraft</th></tr>";
for (uint8_t i = 0; i < count; i++) {
html += "<tr><td>" + String(days[i].date) + "</td><td>" + String(days[i].count) + "</td></tr>";
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 += "<p><a href=\"/export.csv\">Download merged CSV (all days)</a></p>";
}
html += "</body></html>";
server.send(200, "text/html", html);
}
@@ -53,6 +79,7 @@ namespace {
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);
@@ -66,11 +93,58 @@ namespace {
line.trim();
if (line.length() == 0) continue;
server.sendContent(String(days[i].date) + "," + line + "\n");
// Wichtig: Ohne regelmaessiges Abgeben der CPU haengt dieser
// Task (Core 0, Prioritaet 1) die Idle-Task aus, die den
// Task-Watchdog fuettert. Bei groesseren Logbuechern fuehrt
// das nach ca. 5s ohne Yield zu einem Watchdog-Reset -
// genau der Reboot mitten im CSV-Download, den der Nutzer
// beobachtet hat. delay(1) erzwingt einen Kontextwechsel.
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);
}
void handleNotFound() {
server.send(404, "text/plain", "Not found");
}
@@ -79,6 +153,8 @@ namespace {
void begin() {
server.on("/", handleRoot);
server.on("/export.csv", handleExportCsv);
server.on("/csv", HTTP_GET, handleCsvDownload);
server.on("/logbook/delete", HTTP_POST, handleLogbookDelete);
server.onNotFound(handleNotFound);
server.begin();
}

143
src/webui_screen.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "webui_screen.h"
#include "touch_input.h"
#include "menu_stars.h"
#include "config.h"
#include "i18n.h"
#include <WiFi.h>
namespace WebUiScreen {
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_BLACK);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM);
}
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text, int16_t scrollY,
int16_t viewTop, int16_t viewBottom, bool draw) {
int16_t y = startY;
int32_t start = 0;
int32_t len = text.length();
while (start < len) {
while (start < len && text[start] == ' ') start++;
if (start >= len) break;
String line = text.substring(start, len);
while (tft.textWidth(line) > maxWidth) {
int32_t lastSpace = line.lastIndexOf(' ');
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
if (draw) {
int16_t screenY = y - scrollY;
if (screenY >= viewTop && screenY <= viewBottom) {
tft.setCursor(x, screenY);
tft.print(line);
}
}
y += lineHeight;
start += line.length();
}
return y;
}
}
void run(TFT_eSPI& tft) {
MenuStars::reset();
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t VIEW_TOP = 36;
constexpr int16_t VIEW_BOTTOM = Config::SCREEN_HEIGHT - 60;
bool wifiConnected = WiFi.status() == WL_CONNECTED;
String urlLine = wifiConnected ? ("http://" + WiFi.localIP().toString() + "/") : String();
// Gesamthoehe vorab berechnen (draw=false), um zu wissen, ob Scroll-
// Pfeile gebraucht werden - muss exakt zur Zeichenreihenfolge in
// redraw() unten passen.
int16_t totalH = VIEW_TOP;
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::WEBUI_INFO_PARA1), 0, 0, 0, false);
totalH += 8;
if (wifiConnected) {
totalH += LINE_H;
} else {
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::WEBUI_INFO_PARA2), 0, 0, 0, false);
}
int16_t maxScroll = totalH - VIEW_BOTTOM;
if (maxScroll < 0) maxScroll = 0;
bool scrollable = maxScroll > 0;
int16_t scrollY = 0;
Rect backBtn = scrollable
? Rect{10, (int16_t)(Config::SCREEN_HEIGHT - 50), 130, 40}
: Rect{10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
Rect upBtn = {146, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40};
Rect downBtn = {190, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40};
constexpr int16_t SCROLL_STEP = 48;
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::WEBUI_TITLE));
tft.setTextColor(TFT_GREEN, TFT_BLACK);
int16_t y = VIEW_TOP;
y = layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::WEBUI_INFO_PARA1), scrollY, VIEW_TOP, VIEW_BOTTOM, true);
y += 8;
if (wifiConnected) {
int16_t screenY = y - scrollY;
if (screenY >= VIEW_TOP && screenY <= VIEW_BOTTOM) {
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, screenY);
tft.print(urlLine);
}
} else {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::WEBUI_INFO_PARA2), scrollY, VIEW_TOP, VIEW_BOTTOM, true);
}
drawButton(tft, backBtn, I18n::t(StringId::BACK));
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (backBtn.contains(tap.x, tap.y)) return;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
}
}
}

11
src/webui_screen.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace WebUiScreen {
// Blockierend: zeigt die WLAN-IP und eine Erklaerung der kleinen
// eingebauten Webseite (Flugbuch ansehen/herunterladen/loeschen,
// Screenshots ansehen/herunterladen/loeschen). Kehrt zurueck, sobald
// "Zurueck" angetippt wurde.
void run(TFT_eSPI& tft);
}