Sun-based night dimming; separate marker for Heavy aircraft

Night dimming now uses the actual sunrise/sunset at the active
location (classic Almanac algorithm) instead of a fixed 22:00-06:00
window, with a fallback to that fixed window while location or time
isn't known yet. Applies to both the backlight (main.cpp) and the
radar's own color dimming (radar_screen.cpp).

Heavy-category aircraft (ADS-B emitter category "A5") now get a
distinct marker (larger circle with an outer ring, plus the usual
heading line) instead of sharing the orange "notable" ring, on both
the device display and the WebUI radar.

Note: the orange "notable" ring itself (military/government callsign
prefixes) remains unimplemented - Config::NOTABLE_CALLSIGN_PREFIXES
has never been defined anywhere in this project. The WebUI radar's
"notable" field is hardcoded false until that's actually specified;
on the device it was never wired up in the first place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-13 04:13:41 +02:00
parent 5e22025d7a
commit fe3eec29b8
11 changed files with 237 additions and 19 deletions

View File

@@ -56,7 +56,7 @@ static const char* const I18N_DE[] = {
"WLAN-Netzwerke verwalten",
"Standort-Presets",
"Bildschirm-Timeout: ",
"Nachtdimmung (22-6 Uhr): ",
"Nachtdimmung (Sonnenzeit): ",
"Statistik",
"Verlauf",
"Logbuch-Dateien",

View File

@@ -56,7 +56,7 @@ static const char* const I18N_EN[] = {
"Manage WiFi networks",
"Location presets",
"Screen timeout: ",
"Night dimming (10pm-6am): ",
"Night dimming (sun-based): ",
"Statistics",
"History chart",
"Logbook files",

View File

@@ -56,7 +56,7 @@ static const char* const I18N_ES[] = {
"Gestionar redes WiFi",
"Ubicaciones guardadas",
"Apagado de pantalla: ",
"Atenuación nocturna (22-6h): ",
"Atenuación nocturna (solar): ",
"Estadísticas",
"Historial",
"Archivos del registro",

View File

@@ -56,7 +56,7 @@ static const char* const I18N_FR[] = {
"Gérer les réseaux WiFi",
"Positions enregistrées",
"Extinction écran : ",
"Assombrissement nuit (22h-6h) : ",
"Assombrissement nuit (soleil) : ",
"Statistiques",
"Historique",
"Fichiers du journal",

View File

@@ -56,7 +56,7 @@ static const char* const I18N_IT[] = {
"Gestisci reti WiFi",
"Posizioni salvate",
"Spegnimento schermo: ",
"Attenuazione notte (22-6): ",
"Attenuazione notte (solare): ",
"Statistiche",
"Cronologia",
"File del registro",

View File

@@ -56,7 +56,7 @@ static const char* const I18N_TR[] = {
"WiFi ağlarını yönet",
"Konum ön ayarları",
"Ekran zaman aşımı: ",
"Gece kısma (22-06): ",
"Gece kısma (güneşe göre): ",
"İstatistikler",
"Geçmiş",
"Kayıt defteri dosyaları",

View File

@@ -16,6 +16,7 @@
#include "wifi_manager.h"
#include "location_manager.h"
#include "location_presets.h"
#include "sun_times.h"
#include "adsb_client.h"
#include "touch_input.h"
#include "calibration_screen.h"
@@ -408,23 +409,43 @@ void updateStatusLine() {
}
}
// Prueft, ob die aktuelle Lokalzeit im Nachtdimm-Fenster (22:00-06:00) liegt.
// Ueber Mitternacht hinweg gerechnet. Solange die Uhrzeit noch nicht per NTP
// synchronisiert ist, wird false zurueckgegeben statt zu raten (gleiche
// Pruefung wie in updateStatusLine()).
// Prueft, ob gerade Nacht ist (fuer die Nachtdimmung). Nutzt den echten
// Sonnenauf-/untergang am aktiven Standort (siehe sun_times.h), NICHT mehr
// ein festes 22:00-06:00-Fenster - im Sommer war das vorher oft noch hell
// draussen, wenn schon gedimmt wurde, und im Winter blieb es nach 6 Uhr noch
// lange dunkel, ohne dass gedimmt wurde. Solange Standort oder Uhrzeit noch
// nicht bekannt sind (z.B. kurz nach dem Start, bevor NTP/GPS/IP-Geolocation
// fertig sind), faellt die Funktion auf das alte feste Fenster zurueck,
// damit die Nachtdimmung nicht komplett ausfaellt.
bool isNightDimHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false;
struct tm tmNow;
localtime_r(&now, &tmNow);
double lat = 0, lon = 0;
LocationManager::getHomeLocation(lat, lon);
if (lat != 0.0 || lon != 0.0) {
SunTimes::Result sun = SunTimes::compute(lat, lon, tmNow.tm_year + 1900, tmNow.tm_mon + 1,
tmNow.tm_mday, LocationManager::utcOffsetSeconds());
if (sun.valid) {
if (sun.alwaysDay) return false;
if (sun.alwaysNight) return true;
float hourNow = tmNow.tm_hour + tmNow.tm_min / 60.0f;
return (hourNow < sun.sunriseHour) || (hourNow >= sun.sunsetHour);
}
}
// Fallback: Standort noch unbekannt.
int hour = tmNow.tm_hour;
return (hour >= 22 || hour < 6);
}
// Sanfte Nachtdimmung des Backlights zwischen 22:00 und 06:00 Uhr, sofern in
// den Einstellungen aktiviert. Der Inaktivitaets-Timeout (screenDimmed) hat
// Vorrang und wird hier nicht ueberschrieben.
// Sanfte Nachtdimmung des Backlights zwischen Sonnenuntergang und
// Sonnenaufgang (siehe isNightDimHours()), sofern in den Einstellungen
// aktiviert. Der Inaktivitaets-Timeout (screenDimmed) hat Vorrang und wird
// hier nicht ueberschrieben.
void updateNightDimming() {
if (screenDimmed) return;

View File

@@ -9,6 +9,7 @@
#include "settings_store.h"
#include "led_alert.h"
#include "location_manager.h"
#include "sun_times.h"
#include "world_map.h"
#include "airline_filter.h"
#include "aircraft_watchlist.h"
@@ -76,6 +77,7 @@ namespace {
bool isWatched;
bool isGroundVehicle;
bool isRotorcraft;
bool isHeavy;
};
constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT;
HitPoint hitPoints[MAX_HIT_POINTS];
@@ -203,15 +205,33 @@ namespace {
float prevSweepAngleDeg = -1.0f;
constexpr float SWEEP_DEGREES_PER_SEC = 45.0f;
// Gleiches Nachtdimm-Fenster wie main.cpp::isNightDimHours() (22:00-06:00,
// ueber Mitternacht hinweg gerechnet). Hier bewusst dupliziert statt
// geteilt, siehe CLAUDE.md-Konvention ("jeder Screen unabhaengig
// lauffaehig, kein gemeinsames Modul fuer solche Kleinigkeiten").
// Gleiche Logik wie main.cpp::isNightDimHours() - Nacht = zwischen
// Sonnenuntergang und Sonnenaufgang am aktiven Standort (siehe
// sun_times.h), mit Rueckfall auf ein festes 22:00-06:00-Fenster,
// solange Standort/Uhrzeit noch nicht bekannt sind. Hier bewusst
// dupliziert statt geteilt, siehe CLAUDE.md-Konvention ("jeder Screen
// unabhaengig lauffaehig, kein gemeinsames Modul fuer solche
// Kleinigkeiten").
bool isNightHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false; // Uhrzeit noch nicht per NTP synchronisiert
struct tm tmNow;
localtime_r(&now, &tmNow);
double lat = 0, lon = 0;
LocationManager::getHomeLocation(lat, lon);
if (lat != 0.0 || lon != 0.0) {
SunTimes::Result sun = SunTimes::compute(lat, lon, tmNow.tm_year + 1900, tmNow.tm_mon + 1,
tmNow.tm_mday, LocationManager::utcOffsetSeconds());
if (sun.valid) {
if (sun.alwaysDay) return false;
if (sun.alwaysNight) return true;
float hourNow = tmNow.tm_hour + tmNow.tm_min / 60.0f;
return (hourNow < sun.sunriseHour) || (hourNow >= sun.sunsetHour);
}
}
int hour = tmNow.tm_hour;
return (hour >= 22 || hour < 6);
}
@@ -352,6 +372,37 @@ namespace {
gfx.drawLine(x, (int16_t)(y - ROTOR_LEN), x, (int16_t)(y + ROTOR_LEN), color);
}
// Eigener Marker fuer "Heavy"-Flugzeuge (ADS-B-Emitter-Kategorie "A5" -
// Flugzeuge ueber 136t Starthoechstgewicht, z.B. A380/B747/B777/A330).
// Gleicher Aufbau wie drawAircraftMarker() (Kreis + Kurslinie mit
// Pfeilkopf), aber mit groesserem Kreis und einem zusaetzlichen duennen
// Aussenring - auf den ersten Blick als "groesser/schwerer" erkennbar,
// ohne eine komplett neue Formsprache einzufuehren. Die Farbe bleibt wie
// gewohnt die Hoehenfarbe (colorForAltitude()) - die Form transportiert
// "Heavy", kein Alarm-/Auffaelligkeitszustand.
void drawHeavyMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 6, color);
gfx.drawCircle(x, y, 8, color);
double rad = headingDeg * PI / 180.0;
int16_t dx = (int16_t)(sin(rad) * 10);
int16_t dy = (int16_t)(-cos(rad) * 10);
int16_t tipX = x + dx;
int16_t tipY = y + dy;
gfx.drawLine(x, y, tipX, tipY, color);
constexpr double WING_ANGLE_RAD = 150.0 * PI / 180.0;
constexpr int16_t WING_LEN = 4;
double wing1 = rad + WING_ANGLE_RAD;
double wing2 = rad - WING_ANGLE_RAD;
int16_t w1x = tipX + (int16_t)(sin(wing1) * WING_LEN);
int16_t w1y = tipY + (int16_t)(-cos(wing1) * WING_LEN);
int16_t w2x = tipX + (int16_t)(sin(wing2) * WING_LEN);
int16_t w2y = tipY + (int16_t)(-cos(wing2) * WING_LEN);
gfx.drawLine(tipX, tipY, w1x, w1y, color);
gfx.drawLine(tipX, tipY, w2x, w2y, color);
}
void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) {
String s = text;
if (gfx.textWidth(s) > maxWidth) {
@@ -391,7 +442,7 @@ namespace {
// Dieselbe colorForAltitude()-Funktion wie fuer die Flugzeug-Marker
// selbst verwenden (mit einer typischen Hoehe je Band) - sonst zeigt
// die Legende bei aktiver Nachtdimmung (22-6 Uhr) die HELLEN Farben,
// die Legende bei aktiver Nachtdimmung (Sonnenunter- bis -aufgang) die HELLEN Farben,
// waehrend die Marker/Beschriftungen auf dem Radar bereits gedaempft
// sind. Das fuehrte dazu, dass z.B. ein rotes Flugzeug nachts eher
// dunkelorange wirkte, obwohl die Legende noch reines Rot zeigte.
@@ -899,6 +950,9 @@ void render(TFT_eSPI& tft, int16_t top) {
// Darstellung, siehe drawGroundVehicleMarker()/colorForGroundVehicle().
bool isGroundVehicle = a.category[0] == 'C';
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
// ADS-B-Emitter-Kategorie "A5" = "Heavy" - eigene Markerform (siehe
// drawHeavyMarker()), unabhaengig von jeglicher Ring-Markierung.
bool isHeavy = a.category[0] == 'A' && a.category[1] == '5';
uint16_t color = isGroundVehicle ? colorForGroundVehicle(tft) : colorForAltitude(tft, a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk);
@@ -914,6 +968,8 @@ void render(TFT_eSPI& tft, int16_t top) {
drawGroundVehicleMarker(tft, pt.x, pt.y, color);
} else if (isRotorcraft) {
drawHelicopterMarker(tft, pt.x, pt.y, color);
} else if (isHeavy) {
drawHeavyMarker(tft, pt.x, pt.y, a.headingDeg, color);
} else {
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
}
@@ -940,6 +996,7 @@ void render(TFT_eSPI& tft, int16_t top) {
hitPoints[i].isWatched = isWatched;
hitPoints[i].isGroundVehicle = isGroundVehicle;
hitPoints[i].isRotorcraft = isRotorcraft;
hitPoints[i].isHeavy = isHeavy;
strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1);
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
}
@@ -1051,6 +1108,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK);
} else if (hp.isRotorcraft) {
drawHelicopterMarker(tft, hp.x, hp.y, TFT_BLACK);
} else if (hp.isHeavy) {
drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK);
}
@@ -1069,6 +1128,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color);
} else if (hp.isRotorcraft) {
drawHelicopterMarker(tft, hp.x, hp.y, hp.color);
} else if (hp.isHeavy) {
drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color);
}

102
src/sun_times.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include "sun_times.h"
#include <math.h>
namespace SunTimes {
namespace {
constexpr double DEG2RAD = 0.017453292519943295;
constexpr double RAD2DEG = 57.29577951308232;
int dayOfYear(int year, int month, int day) {
static const int cum[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
int n = cum[month - 1] + day;
bool leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
if (leap && month > 2) n += 1;
return n;
}
// Klassischer "Sunrise/Sunset Algorithm" (Almanac for Computers, 1990,
// weit verbreitete Formel). sunrise=true fuer Sonnenaufgang, false fuer
// Sonnenuntergang. Gibt false zurueck, wenn die Sonne an diesem Tag/
// Breitengrad ueberhaupt nicht auf-/untergeht (Polartag/-nacht) - dann
// ist outHour unveraendert.
bool calc(double lat, double lon, int N, int32_t utcOffsetSeconds, bool sunrise, float& outHour) {
double lngHour = lon / 15.0;
double t = sunrise ? (N + ((6.0 - lngHour) / 24.0)) : (N + ((18.0 - lngHour) / 24.0));
double M = (0.9856 * t) - 3.289;
double L = M + (1.916 * sin(M * DEG2RAD)) + (0.020 * sin(2 * M * DEG2RAD)) + 282.634;
L = fmod(L, 360.0);
if (L < 0) L += 360.0;
double RA = RAD2DEG * atan(0.91764 * tan(L * DEG2RAD));
RA = fmod(RA, 360.0);
if (RA < 0) RA += 360.0;
// RA muss im selben 90-Grad-Quadranten wie L liegen (atan() liefert
// nur Werte im Bereich -90..90 Grad, muss also entsprechend
// "zurueckgeklappt" werden).
double lQuadrant = floor(L / 90.0) * 90.0;
double raQuadrant = floor(RA / 90.0) * 90.0;
RA = RA + (lQuadrant - raQuadrant);
RA = RA / 15.0; // Grad -> Stunden
double sinDec = 0.39782 * sin(L * DEG2RAD);
double cosDec = cos(asin(sinDec));
constexpr double ZENITH = 90.833; // offizieller Zenit inkl. atm. Refraktion
double cosH = (cos(ZENITH * DEG2RAD) - (sinDec * sin(lat * DEG2RAD))) / (cosDec * cos(lat * DEG2RAD));
if (cosH > 1.0 || cosH < -1.0) return false; // Polartag/-nacht
double H = sunrise ? (360.0 - RAD2DEG * acos(cosH)) : (RAD2DEG * acos(cosH));
H = H / 15.0;
double T = H + RA - (0.06571 * t) - 6.622;
double UT = fmod(T - lngHour, 24.0);
if (UT < 0) UT += 24.0;
double local = fmod(UT + (utcOffsetSeconds / 3600.0), 24.0);
if (local < 0) local += 24.0;
outHour = (float)local;
return true;
}
}
Result compute(double lat, double lon, int year, int month, int day, int32_t utcOffsetSeconds) {
Result r;
if (lat == 0.0 && lon == 0.0) {
r.valid = false;
return r;
}
int N = dayOfYear(year, month, day);
float sunrise = 0, sunset = 0;
bool riseOk = calc(lat, lon, N, utcOffsetSeconds, true, sunrise);
bool setOk = calc(lat, lon, N, utcOffsetSeconds, false, sunset);
if (!riseOk || !setOk) {
// Polartag (Sonne geht nicht unter) vs. Polarnacht (Sonne geht nicht
// auf): grobe Naeherung ueber "hohe Sonne im Sommerhalbjahr der
// jeweiligen Hemisphaere = Polartag, sonst Polarnacht" - reicht fuer
// eine Nachtdimmung an den seltenen Orten/Tagen, an denen das
// ueberhaupt vorkommt, voellig aus.
bool northernSummerHalf = (N > 79 && N < 265); // grob Fruehlings-/Herbst-Tagundnachtgleiche
bool polarDay = (lat > 0) == northernSummerHalf;
r.valid = true;
r.alwaysDay = polarDay;
r.alwaysNight = !polarDay;
return r;
}
r.valid = true;
r.sunriseHour = sunrise;
r.sunsetHour = sunset;
return r;
}
}

24
src/sun_times.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <Arduino.h>
// Berechnet Sonnenauf-/untergang (lokale Zeit) fuer einen gegebenen Ort und
// Tag - Grundlage fuer die standortbasierte Nachtdimmung (siehe main.cpp::
// isNightDimHours()), die das vorherige feste 22:00-06:00-Fenster ersetzt.
namespace SunTimes {
struct Result {
bool valid = false; // false = Standort noch unbekannt (lat/lon == 0,0)
bool alwaysDay = false; // Polartag: Sonne geht heute an diesem Ort nicht unter
bool alwaysNight = false; // Polarnacht: Sonne geht heute an diesem Ort nicht auf
float sunriseHour = 0; // Lokale Dezimalstunde (0-24), nur gueltig wenn weder
float sunsetHour = 0; // alwaysDay noch alwaysNight gesetzt ist.
};
// year/month/day sind lokale Kalenderdatumswerte (z.B. aus localtime_r()),
// utcOffsetSeconds die bekannte Zeitzonenverschiebung (siehe
// LocationManager::utcOffsetSeconds()). Nutzt den klassischen "Almanac"-
// Sonnenauf-/untergangsalgorithmus (Sonnenzenit 90.833 Grad, beruecksichtigt
// atmosphaerische Refraktion) - Genauigkeit liegt typischerweise im Bereich
// weniger Minuten, fuer eine sanfte Backlight-Dimmung mehr als ausreichend.
Result compute(double lat, double lon, int year, int month, int day, int32_t utcOffsetSeconds);
}

View File

@@ -117,6 +117,8 @@ namespace {
html += "ctx.fillStyle=color;ctx.strokeStyle=color;";
html += "if(a.ground_vehicle){ctx.fillRect(x-3,y-3,6,6);}";
html += "else if(a.rotorcraft){ctx.beginPath();ctx.moveTo(x,y-5);ctx.lineTo(x+5,y);ctx.lineTo(x,y+5);ctx.lineTo(x-5,y);ctx.closePath();ctx.fill();}";
html += "else if(a.heavy){ctx.beginPath();ctx.arc(x,y,5,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.arc(x,y,7,0,Math.PI*2);ctx.stroke();";
html += "var hr2=a.track_deg*Math.PI/180;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+10*Math.sin(hr2),y-10*Math.cos(hr2));ctx.stroke();}";
html += "else{ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill();";
html += "var hr=a.track_deg*Math.PI/180;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+10*Math.sin(hr),y-10*Math.cos(hr));ctx.stroke();}";
html += "if(a.emergency){ctx.strokeStyle='#ff3b3b';ctx.beginPath();ctx.arc(x,y,9,0,Math.PI*2);ctx.stroke();}";
@@ -366,9 +368,16 @@ namespace {
if (AirlineFilter::isHidden(a.callsign)) continue;
bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7';
bool isHeavy = isHeavyCategoryWeb(a.category);
bool isEmergency = emergencyOn && isEmergencySquawkWeb(a.squawk);
bool isWatched = watchOn && AircraftWatchlist::isWatched(a.callsign);
bool isNotable = isHeavyCategoryWeb(a.category);
// "notable" (oranger Ring) ist fuer auffaellige Rufzeichen
// (Militaer/Regierung) reserviert - Heavy-Flugzeuge bekommen
// stattdessen die eigene Markerform (siehe "heavy" unten). Es
// gibt aktuell aber keine Militaer-/Regierungs-Praefixliste im
// Projekt (auch nicht am Geraete-Display, siehe radar_screen.cpp)
// - "notable" bleibt daher bis auf Weiteres immer false.
bool isNotable = false;
JsonObject o = arr.add<JsonObject>();
o["hex"] = a.hex;
@@ -379,6 +388,7 @@ namespace {
o["track_deg"] = a.headingDeg;
o["ground_vehicle"] = isGroundVehicle;
o["rotorcraft"] = isRotorcraft;
o["heavy"] = isHeavy;
o["emergency"] = isEmergency;
o["watched"] = isWatched;
o["notable"] = isNotable;