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:
@@ -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
|
||||
|
||||
16
src/i18n.h
16
src/i18n.h
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
src/main.cpp
37
src/main.cpp
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
143
src/webui_screen.cpp
Normal 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
11
src/webui_screen.h
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user