v2.5.0: History chart, web export, and marquee fix for nearest airport
Adds a 7-day bar-chart view of the flight logbook (Menu -> Flight Options -> History chart), and a small built-in web server that serves a logbook overview page plus a merged CSV export of all logged days once the device is connected to WiFi (discoverable via the "?" button on the Logbook files screen, which now also shows the live device IP). Also fixes the "nearest airport" line on the Location Presets screen, which previously used tft.setViewport() and went completely invisible with our custom font - it now scrolls as a proper marquee without relying on the viewport clip. Also documents the standard release workflow in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
32
CLAUDE.md
32
CLAUDE.md
@@ -144,3 +144,35 @@ mit Foto vom echten Display), bevor der Code als fertig gilt.
|
||||
Keine akuten offenen Bugs bekannt (Stand v2.3.2). Mögliche Ideen für später,
|
||||
falls der Nutzer danach fragt: weitere Menüs mit Info-Buttons versehen,
|
||||
ggf. weitere Sprachen, ggf. Export/Teilen der Logbuch-Daten.
|
||||
## Standard-Workflow: Push & Release
|
||||
|
||||
Bei JEDEM Release-Push (egal ob großes Feature oder kleiner Bugfix) automatisch
|
||||
folgende Schritte in dieser Reihenfolge:
|
||||
|
||||
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**
|
||||
(gleicher Stil: Emoji-Überschriften, Ankerlinks zwischen "Features"-Liste
|
||||
und den Deep-Dive-Sektionen, kurze Beispiele wo sinnvoll). Reine interne
|
||||
Bugfixes/Refactorings ohne sichtbare Nutzerauswirkung brauchen keinen
|
||||
README-Eintrag.
|
||||
2. Code committen (aussagekräftige Commit-Message).
|
||||
3. Falls es sich um einen Versionssprung handelt: Git-Tag mit
|
||||
Versionsnummer + Beschreibung der Änderungen erstellen.
|
||||
4. Prüfen, ob `index.html` (Web-Flasher) noch die alte Versionsnummer zeigt -
|
||||
falls ja, aktualisieren.
|
||||
5. Prüfen, ob `bootloader.bin`, `firmware.bin`, `partitions.bin` im
|
||||
Hauptverzeichnis dem aktuellen Build in `.pio/build/esp32dev/`
|
||||
entsprechen - falls nicht, von dort kopieren.
|
||||
6. Alle diese Änderungen (Code + README + Web-Flasher-Dateien) zusammen
|
||||
committen und pushen (`git push`, plus `git push origin vX.Y.Z` falls ein
|
||||
Tag erstellt wurde).
|
||||
7. Kurze Zusammenfassung am Ende: was committet/getaggt/gepusht wurde, und
|
||||
ob die README aktualisiert wurde (und falls ja, welche Abschnitte).
|
||||
## Nach jedem erfolgreichen Build automatisch flashen
|
||||
|
||||
Sobald `pio run` (Build) erfolgreich ohne Fehler durchgelaufen ist, IMMER direkt
|
||||
im Anschluss auch flashen (`pio run --target upload`), ohne extra danach zu
|
||||
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).
|
||||
16
README.md
16
README.md
@@ -35,6 +35,8 @@ You can flash the firmware directly from your browser to your CYD display withou
|
||||
- **Location presets** – save up to 3 fixed locations, or point the radar at any place in the world (see [Location Presets](#-location-presets) below)
|
||||
- **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 to a softer (but still readable) level 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)
|
||||
- **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
|
||||
@@ -106,9 +108,23 @@ Shows at a glance:
|
||||
|
||||
The **"Reset logbook data"** button (tap twice to confirm) permanently deletes all logged data.
|
||||
|
||||
### 📈 History Chart
|
||||
Shows the last up to **7 days** from the flight logbook as a simple bar chart – a quick visual complement to the plain number list in Logbook files. Bar height is relative to the busiest day shown, with the exact count printed above each bar and the day-of-month printed below it.
|
||||
|
||||
**Good for:** spotting at a glance which days were busier than others, without having to compare numbers row by row.
|
||||
|
||||
A "?" info button on the History chart screen explains how it works directly on the device.
|
||||
|
||||
### 📁 Logbook files
|
||||
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.
|
||||
|
||||
### 🌐 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.
|
||||
|
||||
**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.
|
||||
|
||||
Don't know the device's current IP? The "?" info button on the **Logbook files** screen shows it live on the device.
|
||||
|
||||
### 📖 Flight logbook (ON/OFF)
|
||||
When enabled, the device logs **every newly sighted aircraft** (timestamp, hex code, callsign, registration, type, distance, altitude) into a daily CSV file on the SD card. An aircraft is only logged once per day, even if it crosses the radar multiple times. Survives a same-day restart without logging already-seen aircraft twice. Turning it off saves SD card write cycles if you don't care about the statistics/history.
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ enum class StringId : uint8_t {
|
||||
MENU_SCREEN_TIMEOUT_PREFIX,
|
||||
MENU_NIGHT_DIMMING,
|
||||
MENU_STATISTICS,
|
||||
MENU_STATS_HISTORY,
|
||||
MENU_LOGBOOK_FILES,
|
||||
MENU_MORE_SETTINGS,
|
||||
MENU_EMERGENCY_ALERT,
|
||||
@@ -116,6 +117,10 @@ enum class StringId : uint8_t {
|
||||
STATS_RESET_CONFIRM,
|
||||
STATS_RESET_DONE,
|
||||
STATS_UPTIME_PREFIX,
|
||||
STATS_HISTORY_TITLE,
|
||||
STATS_HISTORY_EMPTY,
|
||||
STATS_HISTORY_INFO_TITLE,
|
||||
STATS_HISTORY_INFO_PARA1,
|
||||
|
||||
LOGFILES_TITLE,
|
||||
LOGFILES_EMPTY,
|
||||
@@ -123,6 +128,9 @@ enum class StringId : uint8_t {
|
||||
LOGFILES_OF,
|
||||
LOGFILES_DAYS_SUFFIX,
|
||||
LOGFILES_AIRCRAFT_SUFFIX,
|
||||
LOGFILES_INFO_TITLE,
|
||||
LOGFILES_INFO_PARA1,
|
||||
LOGFILES_INFO_PARA2,
|
||||
|
||||
RADAR_TAP_FOR_DETAILS,
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_DE[] = {
|
||||
"Bildschirm-Timeout: ",
|
||||
"Nachtdimmung (22-6 Uhr): ",
|
||||
"Statistik",
|
||||
"Verlauf",
|
||||
"Logbuch-Dateien",
|
||||
"Weitere Einstellungen >",
|
||||
"Notfall-Alarm: ",
|
||||
@@ -116,6 +117,10 @@ static const char* const I18N_DE[] = {
|
||||
"Nochmal tippen zum Bestätigen!",
|
||||
"Flugbuch-Daten zurückgesetzt.",
|
||||
"Laufzeit: ",
|
||||
"Verlauf (letzte 7 Tage)",
|
||||
"Noch keine Flugbuch-Einträge.",
|
||||
"Wie der Verlauf funktioniert",
|
||||
"Zeigt die Anzahl neu geloggter Flugzeuge pro Tag, für bis zu die letzten 7 Tage mit Flugbuch-Daten. Die Balkenhöhe ist relativ zum verkehrsreichsten angezeigten Tag - ein höherer Balken bedeutet mehr geloggte Flugzeuge an diesem Tag. Die Zahl über jedem Balken ist der genaue Wert.",
|
||||
|
||||
"Logbuch-Dateien",
|
||||
"Noch keine Flugbuch-Einträge.",
|
||||
@@ -123,6 +128,9 @@ static const char* const I18N_DE[] = {
|
||||
" von ",
|
||||
" Tagen",
|
||||
" Flugzeuge",
|
||||
"Export per Webbrowser",
|
||||
"Das Gerät bietet auch eine kleine Webseite zum Exportieren des kompletten Flugbuchs an. Im selben WLAN einfach im Browser aufrufen:",
|
||||
"Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
|
||||
|
||||
"Für Details antippen",
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_EN[] = {
|
||||
"Screen timeout: ",
|
||||
"Night dimming (10pm-6am): ",
|
||||
"Statistics",
|
||||
"History chart",
|
||||
"Logbook files",
|
||||
"More settings >",
|
||||
"Emergency alert: ",
|
||||
@@ -117,6 +118,10 @@ static const char* const I18N_EN[] = {
|
||||
"Tap again to confirm!",
|
||||
"Logbook data reset.",
|
||||
"Uptime: ",
|
||||
"History (last 7 days)",
|
||||
"No logbook entries yet.",
|
||||
"How the history chart works",
|
||||
"Shows the number of newly logged aircraft per day, for up to the last 7 days with logbook data. Bar height is relative to the busiest day shown - a taller bar means more aircraft were logged that day. The number above each bar is the exact count.",
|
||||
|
||||
"Logbook files",
|
||||
"No logbook entries yet.",
|
||||
@@ -124,6 +129,9 @@ static const char* const I18N_EN[] = {
|
||||
" of ",
|
||||
" days",
|
||||
" aircraft",
|
||||
"Export via web browser",
|
||||
"The device also runs a small web page for exporting the full logbook. While connected to the same WiFi network, open a browser and go to:",
|
||||
"No WiFi connection - the export page is only reachable while connected to a network.",
|
||||
|
||||
"Tap for details",
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_ES[] = {
|
||||
"Apagado de pantalla: ",
|
||||
"Atenuación nocturna (22-6h): ",
|
||||
"Estadísticas",
|
||||
"Historial",
|
||||
"Archivos del registro",
|
||||
"Más ajustes >",
|
||||
"Alerta de emergencia: ",
|
||||
@@ -116,6 +117,10 @@ static const char* const I18N_ES[] = {
|
||||
"¡Toque de nuevo para confirmar!",
|
||||
"Registro reiniciado.",
|
||||
"Tiempo activo: ",
|
||||
"Historial (últimos 7 días)",
|
||||
"Aún no hay registros.",
|
||||
"Cómo funciona el historial",
|
||||
"Muestra el número de aviones recién registrados por día, para hasta los últimos 7 días con datos de registro. La altura de la barra es relativa al día más ocupado mostrado - una barra más alta significa que se registraron más aviones ese día. El número sobre cada barra es el valor exacto.",
|
||||
|
||||
"Archivos del registro",
|
||||
"Aún no hay registros.",
|
||||
@@ -123,6 +128,9 @@ static const char* const I18N_ES[] = {
|
||||
" de ",
|
||||
" días",
|
||||
" aviones",
|
||||
"Exportar por navegador web",
|
||||
"El dispositivo también ofrece una pequeña página web para exportar el registro completo. Conectado a la misma red WiFi, simplemente abra un navegador y vaya a:",
|
||||
"Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.",
|
||||
|
||||
"Toque para ver detalles",
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_FR[] = {
|
||||
"Extinction écran : ",
|
||||
"Assombrissement nuit (22h-6h) : ",
|
||||
"Statistiques",
|
||||
"Historique",
|
||||
"Fichiers du journal",
|
||||
"Plus de réglages >",
|
||||
"Alerte urgence : ",
|
||||
@@ -116,6 +117,10 @@ static const char* const I18N_FR[] = {
|
||||
"Retouchez pour confirmer !",
|
||||
"Journal réinitialisé.",
|
||||
"Durée de fonctionnement : ",
|
||||
"Historique (7 derniers jours)",
|
||||
"Aucune entrée pour le moment.",
|
||||
"Comment fonctionne l'historique",
|
||||
"Affiche le nombre d'avions nouvellement enregistrés par jour, pour les 7 derniers jours avec des données de journal. La hauteur de la barre est relative au jour le plus chargé affiché - une barre plus haute signifie plus d'avions enregistrés ce jour-là. Le nombre au-dessus de chaque barre est la valeur exacte.",
|
||||
|
||||
"Fichiers du journal",
|
||||
"Aucune entrée pour le moment.",
|
||||
@@ -123,6 +128,9 @@ static const char* const I18N_FR[] = {
|
||||
" derniers sur ",
|
||||
" jours",
|
||||
" avions",
|
||||
"Export via navigateur web",
|
||||
"L'appareil propose aussi une petite page web pour exporter le journal complet. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez sur :",
|
||||
"Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.",
|
||||
|
||||
"Touchez pour les détails",
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_IT[] = {
|
||||
"Spegnimento schermo: ",
|
||||
"Attenuazione notte (22-6): ",
|
||||
"Statistiche",
|
||||
"Cronologia",
|
||||
"File del registro",
|
||||
"Altre impostazioni >",
|
||||
"Allarme emergenza: ",
|
||||
@@ -116,6 +117,10 @@ static const char* const I18N_IT[] = {
|
||||
"Tocca di nuovo per confermare!",
|
||||
"Registro reimpostato.",
|
||||
"Tempo di attività: ",
|
||||
"Cronologia (ultimi 7 giorni)",
|
||||
"Ancora nessun dato.",
|
||||
"Come funziona la cronologia",
|
||||
"Mostra il numero di aerei registrati di recente per giorno, fino agli ultimi 7 giorni con dati di registro. L'altezza della barra è relativa al giorno più trafficato mostrato - una barra più alta significa che sono stati registrati più aerei quel giorno. Il numero sopra ogni barra è il valore esatto.",
|
||||
|
||||
"File del registro",
|
||||
"Ancora nessun dato.",
|
||||
@@ -123,6 +128,9 @@ static const char* const I18N_IT[] = {
|
||||
" di ",
|
||||
" giorni",
|
||||
" aerei",
|
||||
"Esportazione tramite browser web",
|
||||
"Il dispositivo offre anche una piccola pagina web per esportare l'intero registro. Connesso alla stessa rete WiFi, apri semplicemente un browser e vai su:",
|
||||
"Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.",
|
||||
|
||||
"Tocca per i dettagli",
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ static const char* const I18N_TR[] = {
|
||||
"Ekran zaman aşımı: ",
|
||||
"Gece kısma (22-06): ",
|
||||
"İstatistikler",
|
||||
"Geçmiş",
|
||||
"Kayıt defteri dosyaları",
|
||||
"Diğer ayarlar >",
|
||||
"Acil durum alarmı: ",
|
||||
@@ -116,6 +117,10 @@ static const char* const I18N_TR[] = {
|
||||
"Onaylamak için tekrar dokunun!",
|
||||
"Kayıt defteri sıfırlandı.",
|
||||
"Çalışma süresi: ",
|
||||
"Geçmiş (son 7 gün)",
|
||||
"Henüz kayıt yok.",
|
||||
"Geçmiş grafiği nasıl çalışır",
|
||||
"Kayıt defteri verisi bulunan son 7 güne kadar, günlük olarak yeni kaydedilen uçak sayısını gösterir. Çubuk yüksekliği gösterilen en yoğun güne göredir - daha yüksek bir çubuk o gün daha fazla uçak kaydedildiği anlamına gelir. Her çubuğun üzerindeki sayı tam değeri gösterir.",
|
||||
|
||||
"Kayıt defteri dosyaları",
|
||||
"Henüz kayıt yok.",
|
||||
@@ -123,6 +128,9 @@ static const char* const I18N_TR[] = {
|
||||
" / ",
|
||||
" gün gösteriliyor",
|
||||
" uçak",
|
||||
"Web tarayıcısı ile dışa aktarma",
|
||||
"Cihaz ayrıca tam kayıt defterini dışa aktarmak için küçük bir web sayfası da sunar. Aynı WiFi ağındayken tarayıcıda şu adresi açmanız yeterli:",
|
||||
"WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.",
|
||||
|
||||
"Detaylar için dokunun",
|
||||
|
||||
|
||||
@@ -167,6 +167,72 @@ namespace {
|
||||
return s + "...";
|
||||
}
|
||||
|
||||
// Laufschrift fuer den "Naechster Flughafen"-Text: statt ihn mit "..."
|
||||
// abzuschneiden, scrollt er horizontal durch, falls er nicht in die
|
||||
// verfuegbare Breite passt. BEWUSST OHNE tft.setViewport() (das hatte
|
||||
// im Zusammenspiel mit unserem eigenen Font dazu gefuehrt, dass der Text
|
||||
// komplett unsichtbar wurde) - stattdessen wird bei jedem Schritt einfach
|
||||
// der Text-Ausschnitt neu berechnet, der garantiert in die Breite passt.
|
||||
struct Marquee {
|
||||
String text;
|
||||
String ring; // text + Luecke, doppelt aneinandergehaengt
|
||||
bool needsScroll = false;
|
||||
int32_t charOffset = 0;
|
||||
uint32_t lastStepMs = 0;
|
||||
};
|
||||
Marquee airportMarquee;
|
||||
|
||||
constexpr uint32_t MARQUEE_STEP_MS = 200; // alle 200ms ein Zeichen weiter
|
||||
|
||||
// Einmal aufrufen, wenn sich der anzuzeigende Text aendert (z.B. weil ein
|
||||
// anderer Standort aktiviert wurde) - merkt sich Text + Ringpuffer und
|
||||
// setzt den Scroll-Fortschritt zurueck.
|
||||
void setupMarquee(TFT_eSPI& tft, const String& text, int16_t viewportW) {
|
||||
airportMarquee.text = text;
|
||||
airportMarquee.needsScroll = tft.textWidth(text) > viewportW;
|
||||
String withGap = text + " "; // 5 Leerzeichen Luecke vor der Wiederholung
|
||||
airportMarquee.ring = withGap + withGap;
|
||||
airportMarquee.charOffset = 0;
|
||||
airportMarquee.lastStepMs = millis();
|
||||
}
|
||||
|
||||
// Liefert den laengsten Ausschnitt ab startIdx, der noch in maxWidth
|
||||
// passt - OHNE "..." anzuhaengen (im Gegensatz zu truncateForWidth).
|
||||
String marqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) {
|
||||
String s = src.substring(startIdx);
|
||||
while (s.length() > 1 && tft.textWidth(s) > maxWidth) {
|
||||
s.remove(s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Bei jedem Aufruf (auch in der Warteschleife) neu zeichnen - wenn der
|
||||
// Text nicht scrollen muss, wird er einfach normal (fest) angezeigt.
|
||||
void drawMarquee(TFT_eSPI& tft, int16_t x, int16_t y, int16_t w, int16_t h) {
|
||||
if (airportMarquee.text.length() == 0) return;
|
||||
|
||||
tft.fillRect(x, y - h + 4, w, h, TFT_BLACK);
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(x, y);
|
||||
|
||||
if (!airportMarquee.needsScroll) {
|
||||
tft.print(airportMarquee.text);
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - airportMarquee.lastStepMs >= MARQUEE_STEP_MS) {
|
||||
airportMarquee.lastStepMs = now;
|
||||
airportMarquee.charOffset++;
|
||||
// Zurueck an den Anfang, sobald der erste (nicht doppelte)
|
||||
// Text+Luecke-Block durchgelaufen ist.
|
||||
int32_t singleLen = (int32_t)airportMarquee.text.length() + 5;
|
||||
if (airportMarquee.charOffset >= singleLen) airportMarquee.charOffset = 0;
|
||||
}
|
||||
|
||||
tft.print(marqueeWindow(tft, airportMarquee.ring, airportMarquee.charOffset, w));
|
||||
}
|
||||
|
||||
void runInfoScreen(TFT_eSPI& tft) {
|
||||
MenuStars::reset();
|
||||
|
||||
@@ -303,6 +369,9 @@ void run(TFT_eSPI& tft) {
|
||||
drawButton(tft, addBtn, I18n::t(StringId::LOCATION_ADD));
|
||||
}
|
||||
|
||||
constexpr int16_t AIRPORT_LINE_X = 10;
|
||||
constexpr int16_t AIRPORT_LINE_W = Config::SCREEN_WIDTH - 20;
|
||||
int16_t airportLineY = (int16_t)(Config::SCREEN_HEIGHT - 60);
|
||||
{
|
||||
double activeLat = 0, activeLon = 0;
|
||||
if (active < 0) {
|
||||
@@ -315,10 +384,10 @@ void run(TFT_eSPI& tft) {
|
||||
char buf[48];
|
||||
snprintf(buf, sizeof(buf), "%s %s (%.0f km)", nearest.icao, nearest.name, nearest.distanceKm);
|
||||
String line = String(I18n::t(StringId::LOCATION_NEAREST_AIRPORT_PREFIX)) + buf;
|
||||
line = truncateForWidth(tft, line, (int16_t)(Config::SCREEN_WIDTH - 20));
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, (int16_t)(Config::SCREEN_HEIGHT - 60));
|
||||
tft.print(line);
|
||||
setupMarquee(tft, line, AIRPORT_LINE_W);
|
||||
drawMarquee(tft, AIRPORT_LINE_X, airportLineY, AIRPORT_LINE_W, 20);
|
||||
} else {
|
||||
airportMarquee.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +397,7 @@ void run(TFT_eSPI& tft) {
|
||||
TouchInput::Point tap;
|
||||
while (true) {
|
||||
if (TouchInput::wasTapped(tap)) break;
|
||||
drawMarquee(tft, AIRPORT_LINE_X, airportLineY, AIRPORT_LINE_W, 20);
|
||||
MenuStars::update(tft);
|
||||
delay(20);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "menu_stars.h"
|
||||
#include "config.h"
|
||||
#include "i18n.h"
|
||||
#include <WiFi.h>
|
||||
|
||||
namespace LogbookFilesScreen {
|
||||
|
||||
@@ -26,6 +27,118 @@ namespace {
|
||||
|
||||
constexpr uint8_t MAX_DAYS_QUERIED = 31;
|
||||
constexpr uint8_t VISIBLE_ROWS = 10;
|
||||
|
||||
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 runInfoScreen(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();
|
||||
|
||||
int16_t totalH = VIEW_TOP;
|
||||
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_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::LOGFILES_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::LOGFILES_INFO_TITLE));
|
||||
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
int16_t y = VIEW_TOP;
|
||||
y = layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_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::LOGFILES_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run(TFT_eSPI& tft) {
|
||||
@@ -40,52 +153,60 @@ void run(TFT_eSPI& tft) {
|
||||
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
|
||||
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
|
||||
|
||||
Rect infoBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24};
|
||||
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
|
||||
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 14);
|
||||
tft.println(I18n::t(StringId::LOGFILES_TITLE));
|
||||
|
||||
if (count == 0) {
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 40);
|
||||
tft.println(I18n::t(StringId::LOGFILES_EMPTY));
|
||||
} else {
|
||||
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
|
||||
int16_t y = 30;
|
||||
|
||||
if (count > VISIBLE_ROWS) {
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, y);
|
||||
tft.print(String(I18n::t(StringId::LOGFILES_SHOWING_PREFIX)) + VISIBLE_ROWS +
|
||||
I18n::t(StringId::LOGFILES_OF) + count + I18n::t(StringId::LOGFILES_DAYS_SUFFIX));
|
||||
y += 16;
|
||||
}
|
||||
|
||||
for (uint8_t i = startIdx; i < count; i++) {
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, y);
|
||||
tft.print(days[i].date);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(110, y);
|
||||
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX));
|
||||
y += 20;
|
||||
}
|
||||
}
|
||||
|
||||
drawButton(tft, backBtn, I18n::t(StringId::BACK));
|
||||
MenuStars::reset();
|
||||
|
||||
bool done = false;
|
||||
MenuStars::reset();
|
||||
while (!done) {
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 14);
|
||||
tft.println(I18n::t(StringId::LOGFILES_TITLE));
|
||||
drawButton(tft, infoBtn, "?");
|
||||
|
||||
if (count == 0) {
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 40);
|
||||
tft.println(I18n::t(StringId::LOGFILES_EMPTY));
|
||||
} else {
|
||||
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
|
||||
int16_t y = 30;
|
||||
|
||||
if (count > VISIBLE_ROWS) {
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, y);
|
||||
tft.print(String(I18n::t(StringId::LOGFILES_SHOWING_PREFIX)) + VISIBLE_ROWS +
|
||||
I18n::t(StringId::LOGFILES_OF) + count + I18n::t(StringId::LOGFILES_DAYS_SUFFIX));
|
||||
y += 16;
|
||||
}
|
||||
|
||||
for (uint8_t i = startIdx; i < count; i++) {
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, y);
|
||||
tft.print(days[i].date);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(110, y);
|
||||
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX));
|
||||
y += 20;
|
||||
}
|
||||
}
|
||||
|
||||
drawButton(tft, backBtn, I18n::t(StringId::BACK));
|
||||
|
||||
TouchInput::Point tap;
|
||||
if (TouchInput::wasTapped(tap) && backBtn.contains(tap.x, tap.y)) {
|
||||
while (true) {
|
||||
if (TouchInput::wasTapped(tap)) break;
|
||||
MenuStars::update(tft);
|
||||
delay(20);
|
||||
}
|
||||
|
||||
if (infoBtn.contains(tap.x, tap.y)) {
|
||||
runInfoScreen(tft);
|
||||
} else if (backBtn.contains(tap.x, tap.y)) {
|
||||
done = true;
|
||||
}
|
||||
MenuStars::update(tft);
|
||||
delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "calibration_screen.h"
|
||||
#include "wifi_manage_screen.h"
|
||||
#include "stats_screen.h"
|
||||
#include "stats_history_screen.h"
|
||||
#include "logbook_files_screen.h"
|
||||
#include "location_presets_screen.h"
|
||||
#include "airline_filter_screen.h"
|
||||
@@ -213,19 +214,21 @@ void run(TFT_eSPI& tft) {
|
||||
tft.println(I18n::t(StringId::MENU_CATEGORY_FLIGHT));
|
||||
|
||||
Rect statsBtn = rowRect(0);
|
||||
Rect logFilesBtn = rowRect(1);
|
||||
Rect logbookBtn = rowRect(2);
|
||||
Rect heartbeatBtn = rowRect(3);
|
||||
Rect proximityBtn = rowRect(4);
|
||||
Rect emergencyBtn = rowRect(5);
|
||||
Rect locationBtn = rowRect(6);
|
||||
Rect airlineBtn = rowRect(7);
|
||||
Rect watchlistBtn = rowRect(8);
|
||||
Rect watchlistAlertBtn = rowRect(9);
|
||||
Rect groundBtn = rowRect(10);
|
||||
Rect backBtn = rowRect(11);
|
||||
Rect statsHistoryBtn = rowRect(1);
|
||||
Rect logFilesBtn = rowRect(2);
|
||||
Rect logbookBtn = rowRect(3);
|
||||
Rect heartbeatBtn = rowRect(4);
|
||||
Rect proximityBtn = rowRect(5);
|
||||
Rect emergencyBtn = rowRect(6);
|
||||
Rect locationBtn = rowRect(7);
|
||||
Rect airlineBtn = rowRect(8);
|
||||
Rect watchlistBtn = rowRect(9);
|
||||
Rect watchlistAlertBtn = rowRect(10);
|
||||
Rect groundBtn = rowRect(11);
|
||||
Rect backBtn = rowRect(12);
|
||||
|
||||
drawButton(tft, statsBtn, I18n::t(StringId::MENU_STATISTICS));
|
||||
drawButton(tft, statsHistoryBtn, I18n::t(StringId::MENU_STATS_HISTORY));
|
||||
drawButton(tft, logFilesBtn, I18n::t(StringId::MENU_LOGBOOK_FILES));
|
||||
drawButton(tft, logbookBtn, I18n::t(StringId::MENU_FLIGHT_LOGBOOK) + onOff(SettingsStore::flightLogbookEnabled()));
|
||||
drawButton(tft, heartbeatBtn, I18n::t(StringId::MENU_LED_HEARTBEAT) + onOff(SettingsStore::ledHeartbeatEnabled()));
|
||||
@@ -247,6 +250,8 @@ void run(TFT_eSPI& tft) {
|
||||
|
||||
if (statsBtn.contains(tap.x, tap.y)) {
|
||||
StatsScreen::run(tft);
|
||||
} else if (statsHistoryBtn.contains(tap.x, tap.y)) {
|
||||
StatsHistoryScreen::run(tft);
|
||||
} else if (logFilesBtn.contains(tap.x, tap.y)) {
|
||||
LogbookFilesScreen::run(tft);
|
||||
} else if (logbookBtn.contains(tap.x, tap.y)) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "aircraft_details.h"
|
||||
#include "flight_logbook.h"
|
||||
#include "led_alert.h"
|
||||
#include "web_export_server.h"
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
@@ -22,11 +23,21 @@ namespace {
|
||||
|
||||
Aircraft tempTable[Config::MAX_TRACKED_AIRCRAFT];
|
||||
|
||||
bool webServerStarted = false;
|
||||
|
||||
void taskFunc(void*) {
|
||||
for (;;) {
|
||||
WifiMgr::update();
|
||||
LocationManager::update();
|
||||
|
||||
if (!webServerStarted && WifiMgr::getState() == WifiMgr::State::Connected) {
|
||||
WebExportServer::begin();
|
||||
webServerStarted = true;
|
||||
}
|
||||
if (webServerStarted) {
|
||||
WebExportServer::update();
|
||||
}
|
||||
|
||||
AircraftDetails::update();
|
||||
|
||||
if (millis() - lastFetchMs >= Config::FETCH_INTERVAL_MS) {
|
||||
|
||||
@@ -122,13 +122,10 @@ void begin(TFT_eSPI& tft) {
|
||||
|
||||
tft.setTextDatum(MC_DATUM);
|
||||
|
||||
tft.setTextColor(TFT_WHITE, TFT_BLACK);
|
||||
tft.setTextSize(3);
|
||||
tft.drawString("Eiswolfs", cx, 40);
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.drawString("Flightradar", cx, 78);
|
||||
tft.setTextSize(2);
|
||||
tft.drawString("Eiswolfs", cx, 28);
|
||||
tft.drawString("Flightradar", cx, 60);
|
||||
|
||||
tft.setTextDatum(TL_DATUM);
|
||||
tft.setTextSize(1);
|
||||
|
||||
220
src/stats_history_screen.cpp
Normal file
220
src/stats_history_screen.cpp
Normal file
@@ -0,0 +1,220 @@
|
||||
#include "stats_history_screen.h"
|
||||
#include "flight_logbook.h"
|
||||
#include "touch_input.h"
|
||||
#include "menu_stars.h"
|
||||
#include "config.h"
|
||||
#include "i18n.h"
|
||||
|
||||
namespace StatsHistoryScreen {
|
||||
|
||||
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, bool danger = false) {
|
||||
uint16_t accent = danger ? TFT_RED : TFT_GREEN;
|
||||
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
|
||||
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, accent);
|
||||
tft.setTextDatum(MC_DATUM);
|
||||
tft.setTextColor(accent, TFT_BLACK);
|
||||
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
|
||||
tft.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
constexpr uint8_t MAX_DAYS_QUERIED = 31;
|
||||
constexpr uint8_t MAX_BARS = 7;
|
||||
|
||||
constexpr int16_t CHART_TOP = 60;
|
||||
constexpr int16_t CHART_BOTTOM = 210;
|
||||
constexpr int16_t LABEL_RESERVE_TOP = 16;
|
||||
constexpr int16_t USABLE_BAR_HEIGHT = (CHART_BOTTOM - CHART_TOP) - LABEL_RESERVE_TOP;
|
||||
constexpr int16_t DAY_LABEL_Y = CHART_BOTTOM + 14;
|
||||
|
||||
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 runInfoScreen(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;
|
||||
|
||||
int16_t totalH = VIEW_TOP;
|
||||
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::STATS_HISTORY_INFO_PARA1), 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::STATS_HISTORY_INFO_TITLE));
|
||||
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
int16_t y = VIEW_TOP;
|
||||
layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::STATS_HISTORY_INFO_PARA1), 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run(TFT_eSPI& tft) {
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 14);
|
||||
tft.println(I18n::t(StringId::STATS_HISTORY_TITLE));
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 30);
|
||||
tft.print(I18n::t(StringId::LOADING));
|
||||
|
||||
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
|
||||
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
|
||||
|
||||
uint8_t barCount = (count > MAX_BARS) ? MAX_BARS : count;
|
||||
uint8_t startIdx = count - barCount;
|
||||
|
||||
uint32_t maxCount = 0;
|
||||
for (uint8_t i = startIdx; i < count; i++) {
|
||||
if (days[i].count > maxCount) maxCount = days[i].count;
|
||||
}
|
||||
|
||||
Rect infoBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24};
|
||||
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
|
||||
|
||||
bool done = false;
|
||||
MenuStars::reset();
|
||||
while (!done) {
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 14);
|
||||
tft.println(I18n::t(StringId::STATS_HISTORY_TITLE));
|
||||
drawButton(tft, infoBtn, "?");
|
||||
|
||||
if (barCount == 0) {
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.setCursor(10, 40);
|
||||
tft.println(I18n::t(StringId::STATS_HISTORY_EMPTY));
|
||||
} else {
|
||||
tft.drawFastHLine(10, CHART_BOTTOM, (int16_t)(Config::SCREEN_WIDTH - 20), TFT_DARKGREEN);
|
||||
|
||||
int16_t barW = (int16_t)((Config::SCREEN_WIDTH - 20 - (barCount - 1) * 6) / barCount);
|
||||
int16_t x = 10;
|
||||
|
||||
for (uint8_t i = startIdx; i < count; i++) {
|
||||
uint32_t dayCount = days[i].count;
|
||||
|
||||
if (dayCount == 0) {
|
||||
tft.drawFastHLine(x, CHART_BOTTOM, barW, TFT_DARKGREEN);
|
||||
} else {
|
||||
int16_t barH = (maxCount > 0)
|
||||
? (int16_t)((uint32_t)USABLE_BAR_HEIGHT * dayCount / maxCount)
|
||||
: 0;
|
||||
if (barH < 2) barH = 2;
|
||||
int16_t barTop = CHART_BOTTOM - barH;
|
||||
|
||||
tft.fillRoundRect(x, barTop, barW, barH, 2, TFT_GREEN);
|
||||
|
||||
tft.setTextDatum(BC_DATUM);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.drawString(String(dayCount), x + barW / 2, barTop - 2);
|
||||
tft.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
const char* date = days[i].date;
|
||||
size_t dateLen = strlen(date);
|
||||
String dayLabel = dateLen >= 2 ? String(date + dateLen - 2) : String(date);
|
||||
tft.setTextDatum(TC_DATUM);
|
||||
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
|
||||
tft.drawString(dayLabel, x + barW / 2, DAY_LABEL_Y);
|
||||
tft.setTextDatum(TL_DATUM);
|
||||
|
||||
x += barW + 6;
|
||||
}
|
||||
}
|
||||
|
||||
drawButton(tft, backBtn, I18n::t(StringId::BACK));
|
||||
|
||||
TouchInput::Point tap;
|
||||
while (true) {
|
||||
if (TouchInput::wasTapped(tap)) break;
|
||||
MenuStars::update(tft);
|
||||
delay(20);
|
||||
}
|
||||
|
||||
if (infoBtn.contains(tap.x, tap.y)) {
|
||||
runInfoScreen(tft);
|
||||
} else if (backBtn.contains(tap.x, tap.y)) {
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
6
src/stats_history_screen.h
Normal file
6
src/stats_history_screen.h
Normal file
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <TFT_eSPI.h>
|
||||
|
||||
namespace StatsHistoryScreen {
|
||||
void run(TFT_eSPI& tft);
|
||||
}
|
||||
90
src/web_export_server.cpp
Normal file
90
src/web_export_server.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#include "web_export_server.h"
|
||||
#include "config.h"
|
||||
#include "flight_logbook.h"
|
||||
#include "sd_mutex.h"
|
||||
#include <WebServer.h>
|
||||
#include <SD.h>
|
||||
|
||||
namespace WebExportServer {
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t MAX_DAYS_QUERIED = 31;
|
||||
|
||||
WebServer server(80);
|
||||
|
||||
void handleRoot() {
|
||||
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
|
||||
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
|
||||
|
||||
String html;
|
||||
html += "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">";
|
||||
html += "<title>Eiswolfs Flightradar - Logbook Export</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 += "td,th{padding:4px 12px;text-align:left;border-bottom:1px solid #1f3a2b;}";
|
||||
html += "a{color:#39ff14;}";
|
||||
html += "</style></head><body>";
|
||||
html += "<h1>Eiswolfs Flightradar - Logbook Export</h1>";
|
||||
|
||||
if (count == 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 += "</table>";
|
||||
html += "<p><a href=\"/export.csv\">Download merged CSV (all days)</a></p>";
|
||||
}
|
||||
|
||||
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;
|
||||
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");
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void handleNotFound() {
|
||||
server.send(404, "text/plain", "Not found");
|
||||
}
|
||||
}
|
||||
|
||||
void begin() {
|
||||
server.on("/", handleRoot);
|
||||
server.on("/export.csv", handleExportCsv);
|
||||
server.onNotFound(handleNotFound);
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void update() {
|
||||
server.handleClient();
|
||||
}
|
||||
|
||||
}
|
||||
6
src/web_export_server.h
Normal file
6
src/web_export_server.h
Normal file
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace WebExportServer {
|
||||
void begin();
|
||||
void update();
|
||||
}
|
||||
Reference in New Issue
Block a user