v2.7.0: Time-of-day greeting, bearing indicator, and empty-sky timer

Three small usability additions:
- Splash screen now shows a "Good morning/day/evening" greeting under
  the title, based on local time (computed after the timezone offset
  from IP geolocation is applied via configTime(), not before, so it
  reflects actual local time rather than UTC).
- Selecting an aircraft on the radar now also draws a dotted bearing
  line plus a degree label from the radar center to the compass edge,
  showing which direction to actually look to spot it in the sky
  (distinct from the aircraft's own heading arrow).
- The radar info bar now shows "Empty sky for <time>" instead of the
  "tap for details" hint whenever no aircraft are currently in range
  after all filters, counting up from the last sighting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-06 20:41:09 +02:00
parent e4aa03dbf2
commit 9b9b89eb85
12 changed files with 141 additions and 1 deletions

View File

@@ -30,6 +30,8 @@ You can flash the firmware directly from your browser to your CYD display withou
- **Real ADS-B data** via the free [adsb.fi](https://adsb.fi) API, refreshed every 8 seconds - **Real ADS-B data** via the free [adsb.fi](https://adsb.fi) API, refreshed every 8 seconds
- **Color-coded aircraft** by altitude (green `<10k ft`, yellow `10-30k ft`, red `>30k ft`) with an on-screen legend - **Color-coded aircraft** by altitude (green `<10k ft`, yellow `10-30k ft`, red `>30k ft`) with an on-screen legend
- **Tap an aircraft** to open a detail panel: callsign, airline, aircraft model (via [hexdb.io](https://hexdb.io)), altitude/speed/distance/heading in both metric and aviation units, estimated seat count - **Tap an aircraft** to open a detail panel: callsign, airline, aircraft model (via [hexdb.io](https://hexdb.io)), altitude/speed/distance/heading in both metric and aviation units, estimated seat count
- **Bearing indicator** – with an aircraft selected, a dotted line plus a heading-in-degrees label points from the radar center to the compass edge, showing exactly which direction to look to spot it in the sky (not to be confused with the aircraft's own heading arrow)
- **Empty-sky timer** – when no aircraft are currently in range, the info bar counts up how long the sky's been empty instead of showing the usual "tap for details" hint
- **Adjustable range** (10/25/50/100 km) via on-screen button - **Adjustable range** (10/25/50/100 km) via on-screen button
- **Touch-driven WiFi setup** and on-screen keyboard for first-time configuration, with up to 3 saved networks (see [WiFi Manager](#-wifi-manager-up-to-3-saved-networks) below) - **Touch-driven WiFi setup** and on-screen keyboard for first-time configuration, with up to 3 saved networks (see [WiFi Manager](#-wifi-manager-up-to-3-saved-networks) below)
- **Location presets** – save up to 3 fixed locations, or point the radar at any place in the world (see [Location Presets](#-location-presets) below) - **Location presets** – save up to 3 fixed locations, or point the radar at any place in the world (see [Location Presets](#-location-presets) below)
@@ -42,6 +44,7 @@ You can flash the firmware directly from your browser to your CYD display withou
- **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 - **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
- Data (airline names, aircraft-type seat estimates) loaded from CSV files on the SD card, auto-seeded on first boot - Data (airline names, aircraft-type seat estimates) loaded from CSV files on the SD card, auto-seeded on first boot
- **6 languages** (English, German, French, Turkish, Spanish, Italian), selectable on first boot or anytime from the menu - **6 languages** (English, German, French, Turkish, Spanish, Italian), selectable on first boot or anytime from the menu
- **Time-of-day greeting** – once WiFi and the timezone are set, the splash screen greets you with "Good morning" / "Good day" / "Good evening" based on your local time
## Feature Deep-Dive ## Feature Deep-Dive

View File

@@ -19,6 +19,9 @@ enum class StringId : uint8_t {
SPLASH_WIFI_FAILED, SPLASH_WIFI_FAILED,
SPLASH_GETTING_LOCATION, SPLASH_GETTING_LOCATION,
SPLASH_READY, SPLASH_READY,
SPLASH_GREETING_MORNING,
SPLASH_GREETING_DAY,
SPLASH_GREETING_EVENING,
SD_REQUIRED_LINE1, SD_REQUIRED_LINE1,
SD_REQUIRED_LINE2, SD_REQUIRED_LINE2,
@@ -134,6 +137,7 @@ enum class StringId : uint8_t {
LOGFILES_INFO_PARA2, LOGFILES_INFO_PARA2,
RADAR_TAP_FOR_DETAILS, RADAR_TAP_FOR_DETAILS,
RADAR_EMPTY_SKY_PREFIX,
DETAIL_MODEL, DETAIL_MODEL,
DETAIL_TYPE, DETAIL_TYPE,

View File

@@ -19,6 +19,9 @@ static const char* const I18N_DE[] = {
"WLAN FEHLGESCHLAGEN", "WLAN FEHLGESCHLAGEN",
"Ermittle Standort...", "Ermittle Standort...",
"Bereit!", "Bereit!",
"Guten Morgen",
"Guten Tag",
"Guten Abend",
"Für diese App wird", "Für diese App wird",
"eine SD-Karte", "eine SD-Karte",
@@ -134,6 +137,7 @@ static const char* const I18N_DE[] = {
"Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.", "Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
"Für Details antippen", "Für Details antippen",
"Leerer Himmel seit ",
"Modell: ", "Modell: ",
"Typ: ", "Typ: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_EN[] = {
"WiFi FAILED", "WiFi FAILED",
"Getting location...", "Getting location...",
"Ready!", "Ready!",
"Good morning",
"Good day",
"Good evening",
"For this app a", "For this app a",
"SD card is", "SD card is",
@@ -135,6 +138,7 @@ static const char* const I18N_EN[] = {
"No WiFi connection - the export page is only reachable while connected to a network.", "No WiFi connection - the export page is only reachable while connected to a network.",
"Tap for details", "Tap for details",
"Empty sky for ",
"Model: ", "Model: ",
"Type: ", "Type: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_ES[] = {
"WiFi FALLÓ", "WiFi FALLÓ",
"Obteniendo ubicación...", "Obteniendo ubicación...",
"¡Listo!", "¡Listo!",
"Buenos días",
"Buenas tardes",
"Buenas noches",
"Esta app necesita", "Esta app necesita",
"una tarjeta SD", "una tarjeta SD",
@@ -134,6 +137,7 @@ static const char* const I18N_ES[] = {
"Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.", "Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.",
"Toque para ver detalles", "Toque para ver detalles",
"Cielo vacío desde hace ",
"Modelo: ", "Modelo: ",
"Tipo: ", "Tipo: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_FR[] = {
"ÉCHEC WiFi", "ÉCHEC WiFi",
"Localisation...", "Localisation...",
"Prêt !", "Prêt !",
"Bonjour",
"Bonjour",
"Bonsoir",
"Pour cette appli une", "Pour cette appli une",
"carte SD est", "carte SD est",
@@ -134,6 +137,7 @@ static const char* const I18N_FR[] = {
"Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.", "Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.",
"Touchez pour les détails", "Touchez pour les détails",
"Ciel vide depuis ",
"Modèle : ", "Modèle : ",
"Type : ", "Type : ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_IT[] = {
"WiFi FALLITO", "WiFi FALLITO",
"Rilevamento posizione...", "Rilevamento posizione...",
"Pronto!", "Pronto!",
"Buongiorno",
"Buon pomeriggio",
"Buonasera",
"Questa app richiede", "Questa app richiede",
"una scheda SD", "una scheda SD",
@@ -134,6 +137,7 @@ static const char* const I18N_IT[] = {
"Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.", "Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.",
"Tocca per i dettagli", "Tocca per i dettagli",
"Cielo vuoto da ",
"Modello: ", "Modello: ",
"Tipo: ", "Tipo: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_TR[] = {
"WiFi BAŞARISIZ", "WiFi BAŞARISIZ",
"Konum alınıyor...", "Konum alınıyor...",
"Hazır!", "Hazır!",
"Günaydın",
"İyi günler",
"İyi akşamlar",
"Bu uygulama için bir", "Bu uygulama için bir",
"SD kart", "SD kart",
@@ -134,6 +137,7 @@ static const char* const I18N_TR[] = {
"WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.", "WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.",
"Detaylar için dokunun", "Detaylar için dokunun",
"Boş gökyüzü: ",
"Model: ", "Model: ",
"Tip: ", "Tip: ",

View File

@@ -318,10 +318,19 @@ void setup() {
} }
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY)); SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
// Sobald die IP-Geolocation einen UTC-Offset geliefert hat, die
// Zeitzone entsprechend setzen - Zeitstempel (Flugbuch, Log-Dateinamen)
// zeigen dann die ECHTE Ortszeit statt UTC, automatisch weltweit richtig.
if (LocationManager::hasUtcOffset()) { if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov"); configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
} }
// ERST JETZT die Begruessung zeichnen - vorher (siehe oben) war die
// Zeitzone noch nicht gesetzt, configTime() lief noch mit UTC statt
// Ortszeit, was in vielen Zeitzonen zu einer falschen Tageszeit-
// Einschaetzung gefuehrt haette (z.B. UTC-Nachmittag = Ortszeit-Abend).
SplashScreen::showGreeting(tft);
AircraftTable::init(); AircraftTable::init();
AirlineLookup::init(); AirlineLookup::init();
FlightLogbook::init(); FlightLogbook::init();

View File

@@ -90,6 +90,50 @@ namespace {
return TFT_RED; return TFT_RED;
} }
// Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading)
// zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom
// Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl
// direkt ausserhalb des Rings. Hilft dabei, das Flugzeug tatsaechlich am
// Himmel zu finden ("in diese Richtung schauen"). Nur fuer den Moment
// des Zeichnens relevant - beim naechsten Sweep-Tick/Redraw wird sie vom
// normalen Hintergrund-Redraw automatisch mit geloescht und neu gesetzt.
void drawBearingIndicator(TFT_eSPI& gfx, const Layout& L, float bearingDeg) {
double rad = bearingDeg * PI / 180.0;
double s = sin(rad), c = cos(rad);
// Gepunktete Linie: kurze Segmente statt einer durchgezogenen Linie,
// damit sie sich optisch von den Kompass-Kreuzlinien und Ringen
// unterscheidet und nicht wie ein staendiges UI-Element wirkt.
constexpr uint8_t DOT_COUNT = 10;
for (uint8_t i = 0; i < DOT_COUNT; i++) {
if (i % 2 != 0) continue; // nur jedes zweite Segment zeichnen
float t0 = (float)i / DOT_COUNT;
float t1 = (float)(i + 1) / DOT_COUNT;
int16_t x0 = L.cx + (int16_t)lround(t0 * L.radius * s);
int16_t y0 = L.cy - (int16_t)lround(t0 * L.radius * c);
int16_t x1 = L.cx + (int16_t)lround(t1 * L.radius * s);
int16_t y1 = L.cy - (int16_t)lround(t1 * L.radius * c);
gfx.drawLine(x0, y0, x1, y1, TFT_WHITE);
}
// Gradzahl knapp ausserhalb des Rings, an der Stelle wo die Peilung
// den Kreisrand durchstoesst. Der Radius-Kreis nutzt fast die volle
// Bildschirmbreite (siehe computeLayout(): nur 6px Rand) - ein Label
// ausserhalb des Rings wuerde bei Ost/West-Peilungen ueber den
// sichtbaren Bereich hinausragen. Deshalb: Label-Position an die
// Bildschirmgrenzen clampen statt stur dem Winkel zu folgen.
int16_t labelX = L.cx + (int16_t)lround((L.radius + 10) * s);
int16_t labelY = L.cy - (int16_t)lround((L.radius + 10) * c);
labelX = constrain(labelX, (int16_t)14, (int16_t)(Config::SCREEN_WIDTH - 14));
labelY = constrain(labelY, (int16_t)10, (int16_t)(L.cy + L.radius + 8));
char buf[6];
snprintf(buf, sizeof(buf), "%.0f", bearingDeg);
gfx.setTextDatum(MC_DATUM);
gfx.setTextColor(TFT_WHITE, TFT_BLACK);
gfx.drawString(buf, labelX, labelY);
gfx.setTextDatum(TL_DATUM);
}
// kurzer Kursstrich in Flugrichtung (headingDeg) MIT Pfeilspitze am Ende - // kurzer Kursstrich in Flugrichtung (headingDeg) MIT Pfeilspitze am Ende -
// eine reine Linie ohne Spitze war nicht eindeutig lesbar (nicht erkennbar, // eine reine Linie ohne Spitze war nicht eindeutig lesbar (nicht erkennbar,
// welches Ende "vorne" ist). Die Spitze besteht aus zwei kurzen Strichen, // welches Ende "vorne" ist). Die Spitze besteht aus zwei kurzen Strichen,
@@ -491,6 +535,7 @@ void render(TFT_eSPI& tft, int16_t top) {
bool selectionStillPresent = false; bool selectionStillPresent = false;
Aircraft selected{}; Aircraft selected{};
uint8_t visibleCount = 0; // nach allen Filtern (Reichweite, Bodenfahrzeuge, Airline-Filter)
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false; for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false;
@@ -504,6 +549,8 @@ void render(TFT_eSPI& tft, int16_t top) {
if (AirlineFilter::isHidden(a.callsign)) continue; if (AirlineFilter::isHidden(a.callsign)) continue;
visibleCount++;
RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg};
RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm);
@@ -522,6 +569,7 @@ void render(TFT_eSPI& tft, int16_t top) {
tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE);
selectionStillPresent = true; selectionStillPresent = true;
selected = a; selected = a;
drawBearingIndicator(tft, L, a.bearingDeg);
} }
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color); drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
@@ -549,6 +597,12 @@ void render(TFT_eSPI& tft, int16_t top) {
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
} }
// "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein
// Flugzeug sichtbar war (nach allen Filtern). Statisch, damit der Wert
// über mehrere render()-Aufrufe hinweg erhalten bleibt.
static uint32_t lastAircraftSeenMs = millis();
if (visibleCount > 0) lastAircraftSeenMs = millis();
if (selectedHex[0] && !selectionStillPresent) { if (selectedHex[0] && !selectionStillPresent) {
selectedHex[0] = 0; selectedHex[0] = 0;
} }
@@ -563,7 +617,21 @@ void render(TFT_eSPI& tft, int16_t top) {
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY); tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(8, infoTop + 20); tft.setCursor(8, infoTop + 20);
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS)); if (visibleCount == 0) {
// Kein Flugzeug in Reichweite - statt des "Fuer Details
// antippen"-Hinweises (der hier ohnehin ins Leere liefe) zeigen
// wir an, wie lange der Himmel schon leer ist.
uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000;
char buf[24];
if (emptySec < 60) {
snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec);
} else {
snprintf(buf, sizeof(buf), "%lum %02lus", (unsigned long)(emptySec / 60), (unsigned long)(emptySec % 60));
}
tft.print(String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf);
} else {
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS));
}
char rangeLabel[8]; char rangeLabel[8];
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);

View File

@@ -1,5 +1,6 @@
#include "splash_screen.h" #include "splash_screen.h"
#include "menu_stars.h" #include "menu_stars.h"
#include "i18n.h"
#include <math.h> #include <math.h>
namespace SplashScreen { namespace SplashScreen {
@@ -131,6 +132,30 @@ void begin(TFT_eSPI& tft) {
tft.setTextSize(1); tft.setTextSize(1);
} }
void showGreeting(TFT_eSPI& tft) {
// Ohne per NTP synchronisierte Uhrzeit koennen wir die Tageszeit nicht
// bestimmen - dann lieber nichts anzeigen als zu raten (gleiches Muster
// wie bei der Nachtdimmung in main.cpp::isNightDimHours()).
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return;
struct tm tmNow;
localtime_r(&now, &tmNow);
int h = tmNow.tm_hour;
StringId greetingId;
if (h < 12) greetingId = StringId::SPLASH_GREETING_MORNING;
else if (h < 18) greetingId = StringId::SPLASH_GREETING_DAY;
else greetingId = StringId::SPLASH_GREETING_EVENING;
int16_t cx = tft.width() / 2;
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setTextSize(1);
tft.drawString(I18n::t(greetingId), cx, 84);
tft.setTextDatum(TL_DATUM);
}
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) { void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) {
if (slot >= MAX_STATUS_LINES) return; if (slot >= MAX_STATUS_LINES) return;

View File

@@ -7,6 +7,13 @@ namespace SplashScreen {
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE); void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE);
// Zeigt eine tageszeit-abhaengige Begruessung (Morgen/Tag/Abend) unter dem
// "Flightradar"-Titel an. Braucht eine per NTP synchronisierte Uhrzeit -
// ist sie noch nicht verfuegbar, wird nichts gezeichnet (kein Raten).
// Am besten NACH der WLAN-Verbindung + Zeit-Synchronisierung aufrufen,
// NICHT direkt in begin() (die Uhrzeit steht zu dem Zeitpunkt noch nicht).
void showGreeting(TFT_eSPI& tft);
// Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind. // Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind.
// Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend // Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend
// des Wartens dieselbe Sterne-Animation wie die Menues. // des Wartens dieselbe Sterne-Animation wie die Menues.