5 Commits

Author SHA1 Message Date
2949e8c110 OTA bypass gitea local ip
All checks were successful
Build Release / build (push) Successful in 1m20s
2026-08-16 19:46:29 +02:00
d80600863a timezone
All checks were successful
Build Release / build (push) Successful in 1m27s
2026-08-16 19:21:03 +02:00
fb884acc27 faster response times
Some checks failed
Build Release / build (push) Failing after 1m18s
2026-08-16 16:55:32 +02:00
3648538cbc eco mode menu toggle
Some checks failed
Build Release / build (push) Failing after 1m31s
2026-08-16 16:24:02 +02:00
a02e280f42 ota update 2026-08-16 14:52:23 +02:00
14 changed files with 320 additions and 211 deletions

View File

@@ -0,0 +1,28 @@
name: Build Release
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PlatformIO
run: pip install platformio
- name: Build firmware
run: pio run
- name: Create Release and Upload Firmware
uses: softprops/action-gh-release@v2
with:
files: .pio/build/esp32dev/firmware.bin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,7 @@
#include "aircraft_details.h" #include "aircraft_details.h"
#include "sd_storage.h"
#include "config.h"
#include <SD.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
@@ -11,36 +14,208 @@ namespace AircraftDetails {
namespace { namespace {
SemaphoreHandle_t mutex = nullptr; SemaphoreHandle_t mutex = nullptr;
constexpr uint8_t RING_SIZE = 8;
struct RingEntry {
char hex[7] = {0};
Info info;
};
RingEntry ring[RING_SIZE];
uint8_t ringNext = 0;
char pendingHex[7] = {0}; char pendingHex[7] = {0};
char pendingCallsign[9] = {0}; char pendingCallsign[9] = {0};
bool hasPending = false;
char cachedHex[7] = {0}; enum class Phase { Idle, ModelSrc1, ModelSrc2, RouteSrc1, RouteSrc2, RouteSrc3, Done };
Info cached; Phase currentPhase = Phase::Idle;
char workHex[7] = {0};
char workCallsign[9] = {0};
Info workResult;
WiFiClientSecure persistentClient;
bool clientConfigured = false;
void ensureMutex() { void ensureMutex() {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
} }
bool httpGetString(WiFiClientSecure& client, const String& url, String& outBody) { int ringFind(const char* hex) {
for (uint8_t i = 0; i < RING_SIZE; i++) {
if (strcmp(ring[i].hex, hex) == 0) return i;
}
return -1;
}
void ringStore(const char* hex, const Info& info) {
int slot = ringFind(hex);
if (slot < 0) {
slot = ringNext;
ringNext = (ringNext + 1) % RING_SIZE;
}
strncpy(ring[slot].hex, hex, sizeof(ring[slot].hex) - 1);
ring[slot].info = info;
}
bool httpGetString(const String& url, String& outBody, uint32_t timeoutMs) {
if (!clientConfigured) {
persistentClient.setInsecure();
clientConfigured = true;
}
persistentClient.setTimeout(timeoutMs);
HTTPClient http; HTTPClient http;
http.setTimeout(5000); http.setTimeout(timeoutMs);
if (!http.begin(client, url)) return false; if (!http.begin(persistentClient, url)) return false;
http.setReuse(true);
int code = http.GET(); int code = http.GET();
bool ok = (code == HTTP_CODE_OK); bool ok = (code == HTTP_CODE_OK);
if (ok) outBody = http.getString(); if (ok) outBody = http.getString();
http.end(); http.end();
return ok; return ok;
} }
bool loadFromSd(const char* hex, Info& out) {
if (!SdStorage::isMounted()) return false;
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
File f = SD.open(path);
if (!f) return false;
char line[64];
while (f.available()) {
int len = f.readBytesUntil('\n', line, sizeof(line) - 1);
line[len] = '\0';
char* eq = strchr(line, '=');
if (!eq) continue;
*eq = '\0';
const char* val = eq + 1;
if (strcmp(line, "model") == 0) {
strncpy(out.model, val, sizeof(out.model) - 1);
} else if (strcmp(line, "route") == 0) {
char* dash = strchr(val, '-');
if (dash) {
*dash = '\0';
strncpy(out.routeOrigin, val, sizeof(out.routeOrigin) - 1);
strncpy(out.routeDest, dash + 1, sizeof(out.routeDest) - 1);
}
}
}
f.close();
return out.model[0] || out.routeOrigin[0];
}
void saveToSd(const char* hex, const Info& info) {
if (!SdStorage::isMounted()) return;
SD.mkdir(Config::SD_DETAIL_CACHE_DIR);
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
File f = SD.open(path, FILE_WRITE);
if (!f) return;
f.print("model="); f.println(info.model);
f.print("route=");
if (info.routeOrigin[0]) { f.print(info.routeOrigin); f.print("-"); f.println(info.routeDest); }
else { f.println(); }
f.close();
}
void applyRouteCodes(const String& codes) {
int dash = codes.indexOf('-');
if (dash > 0 && dash < (int)codes.length() - 1) {
strncpy(workResult.routeOrigin, codes.substring(0, dash).c_str(), sizeof(workResult.routeOrigin) - 1);
strncpy(workResult.routeDest, codes.substring(dash + 1).c_str(), sizeof(workResult.routeDest) - 1);
}
}
bool advance() {
switch (currentPhase) {
case Phase::ModelSrc1: {
String body;
if (httpGetString(String("https://hexdb.io/api/v1/aircraft/") + workHex, body, 2000)) {
JsonDocument doc;
if (!deserializeJson(doc, body)) {
const char* m = doc["Manufacturer"] | "";
const char* t = doc["Type"] | "";
if (m[0] && t[0]) snprintf(workResult.model, sizeof(workResult.model), "%s %s", m, t);
else if (t[0]) strncpy(workResult.model, t, sizeof(workResult.model) - 1);
}
}
currentPhase = workResult.model[0] ? Phase::RouteSrc1 : Phase::ModelSrc2;
return true;
}
case Phase::ModelSrc2: {
String body;
if (httpGetString(String("https://api.adsbdb.com/v0/aircraft/") + workHex, body, 2000)) {
JsonDocument doc;
if (!deserializeJson(doc, body)) {
const char* m = doc["response"]["aircraft"]["manufacturer"] | "";
const char* t = doc["response"]["aircraft"]["type"] | "";
if (m[0] && t[0]) snprintf(workResult.model, sizeof(workResult.model), "%s %s", m, t);
else if (t[0]) strncpy(workResult.model, t, sizeof(workResult.model) - 1);
}
}
currentPhase = Phase::RouteSrc1;
return true;
}
case Phase::RouteSrc1: {
String cs = String(workCallsign);
cs.trim(); cs.toUpperCase();
if (cs.length() >= 2) {
String body;
String folder = cs.substring(0, 2);
if (httpGetString(String("https://vrs-standing-data.adsb.lol/routes/") + folder + "/" + cs + ".json", body, 2000)) {
JsonDocument doc;
if (!deserializeJson(doc, body)) {
applyRouteCodes(String(doc["airport_codes"] | ""));
}
}
}
currentPhase = workResult.routeOrigin[0] ? Phase::Done : Phase::RouteSrc2;
return true;
}
case Phase::RouteSrc2: {
String cs = String(workCallsign);
cs.trim(); cs.toUpperCase();
if (cs.length() >= 2) {
String body;
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + cs, body, 2000)) {
JsonDocument doc;
if (!deserializeJson(doc, body)) {
applyRouteCodes(String(doc["route"] | ""));
}
}
}
currentPhase = workResult.routeOrigin[0] ? Phase::Done : Phase::RouteSrc3;
return true;
}
case Phase::RouteSrc3: {
String cs = String(workCallsign);
cs.trim(); cs.toUpperCase();
if (cs.length() > 0) {
String body;
if (httpGetString(String("https://api.adsbdb.com/v0/callsign/") + cs, body, 2000)) {
JsonDocument doc;
if (!deserializeJson(doc, body)) {
const char* o = doc["response"]["flightroute"]["origin"]["icao_code"] | "";
const char* d = doc["response"]["flightroute"]["destination"]["icao_code"] | "";
if (o[0] && d[0]) {
strncpy(workResult.routeOrigin, o, sizeof(workResult.routeOrigin) - 1);
strncpy(workResult.routeDest, d, sizeof(workResult.routeDest) - 1);
}
}
}
}
currentPhase = Phase::Done;
return true;
}
default:
return false;
}
}
} }
void request(const char* hex, const char* callsign) { void request(const char* hex, const char* callsign) {
ensureMutex(); ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY); xSemaphoreTake(mutex, portMAX_DELAY);
if (strcmp(cachedHex, hex) != 0 && strcmp(pendingHex, hex) != 0) { if (currentPhase == Phase::Idle && ringFind(hex) < 0 && strcmp(pendingHex, hex) != 0) {
strncpy(pendingHex, hex, sizeof(pendingHex) - 1); strncpy(pendingHex, hex, sizeof(pendingHex) - 1);
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1); strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
hasPending = true;
} }
xSemaphoreGive(mutex); xSemaphoreGive(mutex);
} }
@@ -49,10 +224,17 @@ Info get(const char* hex) {
ensureMutex(); ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY); xSemaphoreTake(mutex, portMAX_DELAY);
Info out; Info out;
if (strcmp(cachedHex, hex) == 0) { int slot = ringFind(hex);
out = cached; if (slot >= 0) {
} else if (strcmp(pendingHex, hex) == 0 && hasPending) { out = ring[slot].info;
} else if (currentPhase != Phase::Idle && strcmp(workHex, hex) == 0) {
out.loading = true; out.loading = true;
// Return partial results as they arrive
if (workResult.model[0]) strncpy(out.model, workResult.model, sizeof(out.model) - 1);
if (workResult.routeOrigin[0]) {
strncpy(out.routeOrigin, workResult.routeOrigin, sizeof(out.routeOrigin) - 1);
strncpy(out.routeDest, workResult.routeDest, sizeof(out.routeDest) - 1);
}
} }
xSemaphoreGive(mutex); xSemaphoreGive(mutex);
return out; return out;
@@ -60,146 +242,43 @@ Info get(const char* hex) {
void update() { void update() {
ensureMutex(); ensureMutex();
char hex[7] = {0};
char callsign[9] = {0};
bool doWork = false;
xSemaphoreTake(mutex, portMAX_DELAY); xSemaphoreTake(mutex, portMAX_DELAY);
if (hasPending) {
strncpy(hex, pendingHex, sizeof(hex) - 1);
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1);
doWork = true;
}
xSemaphoreGive(mutex);
if (!doWork) return; if (currentPhase == Phase::Idle) {
if (pendingHex[0]) {
Info result; // Check SD cache before doing network
Info cached;
WiFiClientSecure client; if (loadFromSd(pendingHex, cached)) {
client.setInsecure(); ringStore(pendingHex, cached);
memset(pendingHex, 0, sizeof(pendingHex));
// Try hexdb.io first (previous source) - but with a shorter memset(pendingCallsign, 0, sizeof(pendingCallsign));
// timeout (3s instead of 5s) so a complete service outage does not xSemaphoreGive(mutex);
// block the ADS-B query unnecessarily long before the fallback kicks in. return;
client.setTimeout(3000);
String body;
if (httpGetString(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (!err) {
const char* manufacturer = doc["Manufacturer"] | "";
const char* type = doc["Type"] | "";
if (manufacturer[0] && type[0]) {
snprintf(result.model, sizeof(result.model), "%s %s", manufacturer, type);
} else if (type[0]) {
strncpy(result.model, type, sizeof(result.model) - 1);
} }
strncpy(workHex, pendingHex, sizeof(workHex) - 1);
strncpy(workCallsign, pendingCallsign, sizeof(workCallsign) - 1);
workResult = Info{};
memset(pendingHex, 0, sizeof(pendingHex));
memset(pendingCallsign, 0, sizeof(pendingCallsign));
currentPhase = Phase::ModelSrc1;
} }
} }
// Fallback: hexdb.io was unreachable/did not deliver a model - if (currentPhase == Phase::Idle) {
// try adsbdb.com as a second, independent source (different API format, xSemaphoreGive(mutex);
// but equivalent in content: manufacturer + type via the hex code). return;
if (!result.model[0]) {
client.setTimeout(4000);
String body2;
if (httpGetString(client, String("https://api.adsbdb.com/v0/aircraft/") + hex, body2)) {
JsonDocument doc2;
DeserializationError err2 = deserializeJson(doc2, body2);
if (!err2) {
const char* manufacturer = doc2["response"]["aircraft"]["manufacturer"] | "";
const char* type = doc2["response"]["aircraft"]["type"] | "";
if (manufacturer[0] && type[0]) {
snprintf(result.model, sizeof(result.model), "%s %s", manufacturer, type);
} else if (type[0]) {
strncpy(result.model, type, sizeof(result.model) - 1);
}
}
}
} }
// Flight route (origin/destination airport) - now via a chain of THREE // Do one HTTP request, then return
// independent free sources instead of just one, sorted descending by bool working = advance();
// observed hit rate in testing. Previously adsbdb.com alone returned
// "unknown" in about 80% of cases (see Alex' feedback) - the
// three sources draw from different, overlapping but
// not identical community databases, therefore much better
// overall coverage through chaining:
// 1. VRS-Standing-Data-Mirror (adsb.lol) - hourly updated
// mirror of the Virtual Radar Server project, by far the most
// reliable source in testing. Path = first 2 characters of the
// (UPPERCASE - the service is case-sensitive) callsign
// as folder, returns "airport_codes":"ORIG-DEST" (ICAO).
// 2. hexdb.io - own route endpoint (different URL from the
// aircraft endpoint above), returns "route":"ORIG-DEST".
// 3. adsbdb.com callsign endpoint - previous single source, remains
// as last fallback since it occasionally has data that the
// other two do not.
// Only try if a callsign is known at all -
// VFR aircraft without a callsign do not have an evaluable route anyway.
String trimmedCallsign = String(callsign);
trimmedCallsign.trim();
trimmedCallsign.toUpperCase();
auto applyRouteCodes = [&](const String& codes) { if (!working || currentPhase == Phase::Done) {
int dash = codes.indexOf('-'); saveToSd(workHex, workResult);
if (dash > 0 && dash < (int)codes.length() - 1) { ringStore(workHex, workResult);
strncpy(result.routeOrigin, codes.substring(0, dash).c_str(), sizeof(result.routeOrigin) - 1); currentPhase = Phase::Idle;
strncpy(result.routeDest, codes.substring(dash + 1).c_str(), sizeof(result.routeDest) - 1);
}
};
if (trimmedCallsign.length() >= 2) {
// 1. VRS-Standing-Data-Mirror.
client.setTimeout(4000);
String folder = trimmedCallsign.substring(0, 2);
String body3;
if (httpGetString(client, String("https://vrs-standing-data.adsb.lol/routes/") + folder + "/" + trimmedCallsign + ".json", body3)) {
JsonDocument doc3;
if (!deserializeJson(doc3, body3)) {
const char* codes = doc3["airport_codes"] | "";
applyRouteCodes(String(codes));
}
}
// 2. hexdb.io route endpoint, if source 1 did not deliver anything.
if (!result.routeOrigin[0] || !result.routeDest[0]) {
client.setTimeout(3000);
String body4;
if (httpGetString(client, String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, body4)) {
JsonDocument doc4;
if (!deserializeJson(doc4, body4)) {
const char* route = doc4["route"] | "";
applyRouteCodes(String(route));
}
}
}
} }
// 3. adsbdb.com callsign endpoint as last fallback.
if (trimmedCallsign.length() > 0 && (!result.routeOrigin[0] || !result.routeDest[0])) {
client.setTimeout(4000);
String body5;
if (httpGetString(client, String("https://api.adsbdb.com/v0/callsign/") + trimmedCallsign, body5)) {
JsonDocument doc5;
if (!deserializeJson(doc5, body5)) {
const char* originIcao = doc5["response"]["flightroute"]["origin"]["icao_code"] | "";
const char* destIcao = doc5["response"]["flightroute"]["destination"]["icao_code"] | "";
if (originIcao[0] && destIcao[0]) {
strncpy(result.routeOrigin, originIcao, sizeof(result.routeOrigin) - 1);
strncpy(result.routeDest, destIcao, sizeof(result.routeDest) - 1);
}
}
}
}
xSemaphoreTake(mutex, portMAX_DELAY);
strncpy(cachedHex, hex, sizeof(cachedHex) - 1);
cached = result;
hasPending = false;
xSemaphoreGive(mutex); xSemaphoreGive(mutex);
} }
} }

View File

@@ -1,10 +1,13 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
// Additional aircraft details (model) that are NOT part of the ADS-B signal // Additional aircraft details (model + route) that are NOT part of
// and get looked up via the free hexdb.io community database by hex code - // the ADS-B signal and get looked up via a chain of free community
// only for the currently selected aircraft (not for all of them, to keep // databases (hexdb.io, adsbdb.com, adsb.lol) by hex code and callsign.
// network load low). // Results are cached on the SD card (/Flightradar_cyd/cache/{hex}.txt)
// so each aircraft only needs to be fetched once, ever.
// Lookups are spread across multiple update() calls (state machine,
// one HTTP request per call) so Core 0 is never blocked for long.
namespace AircraftDetails { namespace AircraftDetails {
struct Info { struct Info {

View File

@@ -37,7 +37,7 @@ namespace Config {
// require a meaningful User-Agent instead of the HTTPClient // require a meaningful User-Agent instead of the HTTPClient
// default, see https://operations.osmfoundation.org/policies/nominatim/. // default, see https://operations.osmfoundation.org/policies/nominatim/.
constexpr const char* NOMINATIM_HOST = "nominatim.openstreetmap.org"; constexpr const char* NOMINATIM_HOST = "nominatim.openstreetmap.org";
constexpr const char* NOMINATIM_USER_AGENT = "EiswolfsFlightradarCYD (github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)"; constexpr const char* NOMINATIM_USER_AGENT = "EiswolfsFlightradarCYD (git.rasmusbendtsen.dk/rasmus/flyradar)";
struct GpsPinPair { uint8_t rx; uint8_t tx; const char* label; }; struct GpsPinPair { uint8_t rx; uint8_t tx; const char* label; };
constexpr GpsPinPair GPS_PIN_CANDIDATES[] = { constexpr GpsPinPair GPS_PIN_CANDIDATES[] = {
@@ -85,6 +85,7 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
constexpr const char* SD_SETTINGS_FILE = "/Flightradar_cyd/config.txt"; constexpr const char* SD_SETTINGS_FILE = "/Flightradar_cyd/config.txt";
constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt"; constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.txt";
constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt"; constexpr const char* SD_CALIBRATION_FILE = "/Flightradar_cyd/calibration.txt";
constexpr const char* SD_DETAIL_CACHE_DIR = "/Flightradar_cyd/cache";
constexpr uint8_t SD_SPI_CS_PIN = 5; constexpr uint8_t SD_SPI_CS_PIN = 5;
constexpr uint8_t SD_SPI_MOSI_PIN = 23; constexpr uint8_t SD_SPI_MOSI_PIN = 23;
@@ -115,5 +116,13 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
constexpr const char* EMERGENCY_SQUAWKS[] = {"7500", "7600", "7700"}; constexpr const char* EMERGENCY_SQUAWKS[] = {"7500", "7600", "7700"};
constexpr uint8_t EMERGENCY_SQUAWK_COUNT = 3; constexpr uint8_t EMERGENCY_SQUAWK_COUNT = 3;
constexpr uint8_t MAX_WIFI_NETWORKS = 3; constexpr uint8_t MAX_WIFI_NETWORKS = 3;
// Gitea OTA update (Menu > System > "Check for update") - checks
// the latest release in a private Gitea repository and downloads
// firmware.bin from there. Uses the external proxy URL since the
// ESP32 reaches Gitea via https://git.rasmusbendtsen.dk (Pangolin).
constexpr const char* GITEA_API_HOST = "git.rasmusbendtsen.dk";
constexpr const char* GITEA_API_PATH = "/api/v1/repos/rasmus/flyradar/releases/latest";
constexpr const char* GITEA_API_TOKEN = "YOUR_GITEA_TOKEN_HERE"; // set this!
} }

View File

@@ -255,7 +255,7 @@ static const char* const I18N_DE[] = {
"Das Update konnte nicht heruntergeladen oder installiert werden. Das Gerät läuft unverändert mit der bisherigen Firmware weiter. Bitte WLAN-Verbindung prüfen und es später erneut versuchen.", "Das Update konnte nicht heruntergeladen oder installiert werden. Das Gerät läuft unverändert mit der bisherigen Firmware weiter. Bitte WLAN-Verbindung prüfen und es später erneut versuchen.",
"Bildschirm-Timeout", "Bildschirm-Timeout",
"Nach dieser Zeit ohne Berührung schaltet sich das Display ab, um Strom zu sparen. Ist der Ruhebildschirm aktiviert, zeigt es stattdessen einen gedimmten Sternenhimmel mit Uhrzeit.", "Nach dieser Zeit ohne Berührung schaltet sich das Display ab, um Strom zu sparen. Ist der Ruhebildschirm aktiviert, zeigt es stattdessen einen gedimmten Sternenhimmel mit Uhrzeit.",
"Eco Mode", "Eco Mode ",
"Streckenfilter", "Streckenfilter",
"ICAO eingeben", "ICAO eingeben",
}; };

View File

@@ -256,7 +256,7 @@ static const char* const I18N_EN[] = {
"The update could not be downloaded or installed. The device is still running the previous firmware, unchanged. Please check your WiFi connection and try again later.", "The update could not be downloaded or installed. The device is still running the previous firmware, unchanged. Please check your WiFi connection and try again later.",
"Screen Timeout", "Screen Timeout",
"After this time without touch, the display turns off to save power. With the screensaver on, it dims to a starfield with a clock instead.", "After this time without touch, the display turns off to save power. With the screensaver on, it dims to a starfield with a clock instead.",
"Eco Mode", "Eco Mode ",
"Route Filter", "Route Filter",
"Enter ICAO", "Enter ICAO",
}; };

View File

@@ -255,7 +255,7 @@ static const char* const I18N_ES[] = {
"No se pudo descargar o instalar la actualización. El dispositivo sigue funcionando sin cambios con el firmware anterior. Comprueba tu conexión WiFi e inténtalo de nuevo más tarde.", "No se pudo descargar o instalar la actualización. El dispositivo sigue funcionando sin cambios con el firmware anterior. Comprueba tu conexión WiFi e inténtalo de nuevo más tarde.",
"Tiempo de espera de pantalla", "Tiempo de espera de pantalla",
"Tras este tiempo sin toques, la pantalla se apaga para ahorrar energía. Con el salvapantallas activado, muestra en su lugar un cielo estrellado atenuado con la hora.", "Tras este tiempo sin toques, la pantalla se apaga para ahorrar energía. Con el salvapantallas activado, muestra en su lugar un cielo estrellado atenuado con la hora.",
"Eco Mode", "Eco Mode ",
"Filtro de ruta", "Filtro de ruta",
"Ingresa OACI", "Ingresa OACI",
}; };

View File

@@ -255,7 +255,7 @@ static const char* const I18N_FR[] = {
"La mise à jour n'a pas pu être téléchargée ou installée. L'appareil continue de fonctionner normalement avec le micrologiciel précédent. Merci de vérifier votre connexion WiFi et de réessayer plus tard.", "La mise à jour n'a pas pu être téléchargée ou installée. L'appareil continue de fonctionner normalement avec le micrologiciel précédent. Merci de vérifier votre connexion WiFi et de réessayer plus tard.",
"Délai d'inactivité", "Délai d'inactivité",
"Après ce délai sans contact, l'écran s'éteint pour économiser l'énergie. Avec l'écran de veille activé, il affiche à la place un ciel étoilé tamisé avec l'heure.", "Après ce délai sans contact, l'écran s'éteint pour économiser l'énergie. Avec l'écran de veille activé, il affiche à la place un ciel étoilé tamisé avec l'heure.",
"Eco Mode", "Eco Mode ",
"Filtre d'itinéraire", "Filtre d'itinéraire",
"Entrer code OACI", "Entrer code OACI",
}; };

View File

@@ -255,7 +255,7 @@ static const char* const I18N_IT[] = {
"Non è stato possibile scaricare o installare l'aggiornamento. Il dispositivo continua a funzionare senza modifiche con il firmware precedente. Controlla la connessione WiFi e riprova più tardi.", "Non è stato possibile scaricare o installare l'aggiornamento. Il dispositivo continua a funzionare senza modifiche con il firmware precedente. Controlla la connessione WiFi e riprova più tardi.",
"Timeout schermo", "Timeout schermo",
"Dopo questo tempo senza tocchi, lo schermo si spegne per risparmiare energia. Con il salvaschermo attivo, mostra invece un cielo stellato attenuato con l'orario.", "Dopo questo tempo senza tocchi, lo schermo si spegne per risparmiare energia. Con il salvaschermo attivo, mostra invece un cielo stellato attenuato con l'orario.",
"Eco Mode", "Eco Mode ",
"Filtro rotta", "Filtro rotta",
"Inserisci ICAO", "Inserisci ICAO",
}; };

View File

@@ -255,7 +255,7 @@ static const char* const I18N_TR[] = {
"Güncelleme indirilemedi veya yüklenemedi. Cihaz değişmeden önceki donanım yazılımıyla çalışmaya devam ediyor. Lütfen WiFi bağlantınızı kontrol edip daha sonra tekrar deneyin.", "Güncelleme indirilemedi veya yüklenemedi. Cihaz değişmeden önceki donanım yazılımıyla çalışmaya devam ediyor. Lütfen WiFi bağlantınızı kontrol edip daha sonra tekrar deneyin.",
"Ekran Zaman Aşımı", "Ekran Zaman Aşımı",
"Bu süre boyunca dokunulmazsa ekran güç tasarrufu için kapanır. Ekran koruyucu açıksa, bunun yerine saatli, sönük bir yıldızlı gökyüzü gösterir.", "Bu süre boyunca dokunulmazsa ekran güç tasarrufu için kapanır. Ekran koruyucu açıksa, bunun yerine saatli, sönük bir yıldızlı gökyüzü gösterir.",
"Eco Mode", "Eco Mode ",
"Rota Filtresi", "Rota Filtresi",
"ICAO Girin", "ICAO Girin",
}; };

View File

@@ -626,12 +626,13 @@ void setup() {
} }
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY)); SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
// Once IP geolocation has provided a UTC offset, set the // Sync time via NTP (UTC), then set timezone to Central European
// timezone accordingly - timestamps (flight log, log file names) // Time (Copenhagen) with automatic DST handling (CET/CEST).
// then show the REAL local time instead of UTC, automatically correct worldwide. // This makes localtime_r() return the correct local time year-round
if (LocationManager::hasUtcOffset()) { // regardless of when the device was booted.
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov"); configTime(0, 0, "pool.ntp.org", "time.nist.gov");
} setenv("TZ", "CET-1CEST-2,M3.5.0/2,M10.5.0/3", 1);
tzset();
AircraftTable::init(); AircraftTable::init();
AirlineLookup::init(); AirlineLookup::init();

View File

@@ -687,6 +687,7 @@ void run(TFT_eSPI& tft) {
drawButton(tft, brightnessBtn, brightnessLabel(SettingsStore::brightnessPercent())); drawButton(tft, brightnessBtn, brightnessLabel(SettingsStore::brightnessPercent()));
drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes())); drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes()));
drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled())); drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
drawButton(tft, ecoModeBtn, I18n::t(StringId::MENU_ECO_MODE) + onOff(SettingsStore::ecoModeEnabled()));
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI)); drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET)); drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET));
drawButton(tft, checkUpdateBtn, I18n::t(StringId::MENU_CHECK_UPDATE)); drawButton(tft, checkUpdateBtn, I18n::t(StringId::MENU_CHECK_UPDATE));

View File

@@ -12,13 +12,10 @@
namespace OtaUpdate { namespace OtaUpdate {
namespace { namespace {
constexpr const char* RELEASES_API_URL = // Gitea API URL constructed from config constants at runtime
"https://api.github.com/repos/Eiswolf-BG/eiswolfs-flightradar-CYD/releases/latest"; String releasesApiUrl() {
// GitHub requires a meaningful User-Agent for API requests return String("https://") + Config::GITEA_API_HOST + Config::GITEA_API_PATH;
// (otherwise HTTP 403) - same principle as Config::NOMINATIM_USER_AGENT }
// for address search.
constexpr const char* USER_AGENT =
"EiswolfsFlightradarCYD-OTA/1.0 (+https://github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
bool parseVersion(const char* s, int& major, int& minor, int& patch) { bool parseVersion(const char* s, int& major, int& minor, int& patch) {
major = minor = patch = 0; major = minor = patch = 0;
@@ -27,10 +24,7 @@ namespace {
return sscanf(s, "%d.%d.%d", &major, &minor, &patch) == 3; return sscanf(s, "%d.%d.%d", &major, &minor, &patch) == 3;
} }
// > 0 if a is newer than b, 0 if equal, < 0 if a is older than b. // Numeric version comparison (not string-based, so e.g. "3.10.0" > "3.9.0").
// Deliberately a real numeric version comparison instead of a simple
// string comparison (which would e.g. wrongly rank "3.10.0" as "less"
// than "3.9.0").
int compareVersions(const char* a, const char* b) { int compareVersions(const char* a, const char* b) {
int aMaj, aMin, aPat, bMaj, bMin, bPat; int aMaj, aMin, aPat, bMaj, bMin, bPat;
if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0; if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0;
@@ -38,12 +32,22 @@ namespace {
if (aMin != bMin) return aMin - bMin; if (aMin != bMin) return aMin - bMin;
return aPat - bPat; return aPat - bPat;
} }
void addAuthHeader(HTTPClient& http) {
http.addHeader("User-Agent", "EiswolfsFlightradarCYD-OTA/1.0");
if (Config::GITEA_API_TOKEN[0]) {
String auth = "token ";
auth += Config::GITEA_API_TOKEN;
http.addHeader("Authorization", auth);
}
}
} }
CheckInfo checkForUpdate() { CheckInfo checkForUpdate() {
CheckInfo info; CheckInfo info;
String url = releasesApiUrl();
Serial.printf("[OTA] Checking for update: url=%s freeHeap=%u RSSI=%ddBm\n", RELEASES_API_URL, Serial.printf("[OTA] Checking for update: url=%s freeHeap=%u RSSI=%ddBm\n", url.c_str(),
(unsigned)ESP.getFreeHeap(), WiFi.RSSI()); (unsigned)ESP.getFreeHeap(), WiFi.RSSI());
WiFiClientSecure client; WiFiClientSecure client;
@@ -52,11 +56,11 @@ CheckInfo checkForUpdate() {
HTTPClient http; HTTPClient http;
http.setTimeout(8000); http.setTimeout(8000);
if (!http.begin(client, RELEASES_API_URL)) { if (!http.begin(client, url)) {
Serial.println("[OTA] Check failed: http.begin() returned false."); Serial.println("[OTA] Check failed: http.begin() returned false.");
return info; return info;
} }
http.addHeader("User-Agent", USER_AGENT); addAuthHeader(http);
int code = http.GET(); int code = http.GET();
if (code != HTTP_CODE_OK) { if (code != HTTP_CODE_OK) {
@@ -88,36 +92,25 @@ CheckInfo checkForUpdate() {
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag, strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
sizeof(info.latestVersion) - 1); sizeof(info.latestVersion) - 1);
JsonArray assets = doc["assets"]; // Construct download URL from the tag name using Gitea's standard
for (JsonObject asset : assets) { // release asset pattern. browser_download_url from the API contains
const char* name = asset["name"] | ""; // the internal IP (http://192.168.0.37:3000/...) which the ESP32
if (strcmp(name, "firmware.bin") == 0) { // cannot use — it needs the external proxy URL.
const char* url = asset["browser_download_url"] | ""; String dlUrl = String("https://git.rasmusbendtsen.dk/rasmus/flyradar/releases/download/")
strncpy(info.downloadUrl, url, sizeof(info.downloadUrl) - 1); + String(tag) + "/firmware.bin";
break; strncpy(info.downloadUrl, dlUrl.c_str(), sizeof(info.downloadUrl) - 1);
}
}
if (!info.downloadUrl[0]) { Serial.printf("[OTA] Download URL: %s\n", info.downloadUrl);
Serial.printf("[OTA] Check failed: Release v%s has no firmware.bin attachment.\n",
info.latestVersion);
return info; // Release without firmware.bin attachment
}
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION); int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate; info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate;
Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config::APP_VERSION, Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config::APP_VERSION,
info.latestVersion, info.latestVersion,
info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date"); info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date");
return info; return info;
} }
bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) { bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
// Diagnostic logging (only visible over USB serial, no impact on
// the UI) - previously a failed update only returned a simple "true/
// false" externally, without recording the actual reason (timeout,
// TLS error, HTTP status code...). This allows tracing a failed
// OTA attempt on the serial monitor instead of having to guess again.
Serial.printf("[OTA] Starting: url=%s freeHeap=%u RSSI=%ddBm\n", url, Serial.printf("[OTA] Starting: url=%s freeHeap=%u RSSI=%ddBm\n", url,
(unsigned)ESP.getFreeHeap(), WiFi.RSSI()); (unsigned)ESP.getFreeHeap(), WiFi.RSSI());
@@ -125,26 +118,20 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
client.setInsecure(); client.setInsecure();
client.setTimeout(15000); client.setTimeout(15000);
// IMPORTANT: GitHub's "browser_download_url" for release assets is NOT // Use HTTPClient overload so we can add the Authorization header
// a direct download link, but first returns an HTTP 301/302 redirect // (required for private repos even during download).
// to a signed objects.githubusercontent.com URL. HTTPUpdate does NOT HTTPClient http;
// follow redirects by default (HTTPC_DISABLE_FOLLOW_REDIRECTS is the if (!http.begin(client, url)) {
// default) - without this line the download aborts with HTTP_UPDATE_FAILED Serial.println("[OTA] Failed: http.begin() returned false for download URL.");
// because only the redirect response arrives instead of the .bin file. See return false;
// e.g. espressif/arduino-esp32#3020. HTTPC_STRICT_FOLLOW_REDIRECTS }
// suffices since we only use GET. addAuthHeader(http);
httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
// We show a brief success message ourselves after successful installation,
// before the device reboots - see
// menu_screen.cpp::runOtaUpdateScreen().
httpUpdate.rebootOnUpdate(false); httpUpdate.rebootOnUpdate(false);
httpUpdate.onProgress([onProgress](int cur, int total) { httpUpdate.onProgress([onProgress](int cur, int total) {
if (onProgress && total > 0) { if (onProgress && total > 0) {
onProgress((uint8_t)((cur * 100) / total)); onProgress((uint8_t)((cur * 100) / total));
} }
// Only log occasionally (every ~10%), otherwise the serial
// monitor overflows with hundreds of lines for large files.
static int8_t lastLoggedPercent = -1; static int8_t lastLoggedPercent = -1;
if (total > 0) { if (total > 0) {
int8_t pct = (int8_t)((cur * 100) / total); int8_t pct = (int8_t)((cur * 100) / total);
@@ -156,10 +143,11 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
} }
}); });
t_httpUpdate_return result = httpUpdate.update(client, url); t_httpUpdate_return result = httpUpdate.update(http);
http.end();
if (result != HTTP_UPDATE_OK) { if (result != HTTP_UPDATE_OK) {
Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result, Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result,
httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str(), httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str(),
(unsigned)ESP.getFreeHeap()); (unsigned)ESP.getFreeHeap());
} else { } else {
@@ -169,4 +157,4 @@ Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result
return result == HTTP_UPDATE_OK; return result == HTTP_UPDATE_OK;
} }
} }

View File

@@ -2,7 +2,7 @@
#include <Arduino.h> #include <Arduino.h>
// Firmware update over WiFi (Menu > System > "Check for update") - // Firmware update over WiFi (Menu > System > "Check for update") -
// checks against the latest version in the GitHub repository (Releases API) and // checks against the latest version in a private Gitea repository (Releases API) and
// installs it on the device directly upon confirmation, without cable/ // installs it on the device directly upon confirmation, without cable/
// web flasher. Blocking, synchronous HTTPS calls - only executed on // web flasher. Blocking, synchronous HTTPS calls - only executed on
// explicit button press (no background polling), analogous to // explicit button press (no background polling), analogous to
@@ -23,7 +23,7 @@ namespace OtaUpdate {
char downloadUrl[192] = {0}; char downloadUrl[192] = {0};
}; };
// Queries the GitHub Releases API for the latest release, compares // Queries the Gitea Releases API for the latest release, compares
// its version number (tag name, "v" prefix is ignored) against // its version number (tag name, "v" prefix is ignored) against
// Config::APP_VERSION and looks for the "firmware.bin" attachment in the release. // Config::APP_VERSION and looks for the "firmware.bin" attachment in the release.
// CheckResult::Error on any error along the way (no WiFi, timeout, // CheckResult::Error on any error along the way (no WiFi, timeout,