4 Commits

Author SHA1 Message Date
ab9b81683b fixed sd read error
All checks were successful
Build Release / build (push) Successful in 1m21s
2026-08-16 20:17:39 +02:00
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
6 changed files with 276 additions and 214 deletions

View File

@@ -20,10 +20,9 @@ jobs:
- name: Build firmware - name: Build firmware
run: pio run run: pio run
- name: Upload firmware.bin to release - name: Create Release and Upload Firmware
uses: actions/upload-release-asset@v1 uses: softprops/action-gh-release@v2
with: with:
upload_url: ${{ gitea.release.upload_url }} files: .pio/build/esp32dev/firmware.bin
asset_path: .pio/build/esp32dev/firmware.bin env:
asset_name: firmware.bin GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
asset_content_type: application/octet-stream

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;
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";
if (!SD.exists(path)) return false;
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,145 +242,42 @@ 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); if (currentPhase == Phase::Idle) {
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1); if (pendingHex[0]) {
doWork = true; // Check SD cache before doing network
} Info cached;
if (loadFromSd(pendingHex, cached)) {
ringStore(pendingHex, cached);
memset(pendingHex, 0, sizeof(pendingHex));
memset(pendingCallsign, 0, sizeof(pendingCallsign));
xSemaphoreGive(mutex); xSemaphoreGive(mutex);
return;
if (!doWork) return;
Info result;
WiFiClientSecure client;
client.setInsecure();
// Try hexdb.io first (previous source) - but with a shorter
// timeout (3s instead of 5s) so a complete service outage does not
// block the ADS-B query unnecessarily long before the fallback kicks in.
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);
}
}
}
// Fallback: hexdb.io was unreachable/did not deliver a model -
// try adsbdb.com as a second, independent source (different API format,
// but equivalent in content: manufacturer + type via the hex code).
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);
}
} }
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;
} }
} }
// Flight route (origin/destination airport) - now via a chain of THREE if (currentPhase == Phase::Idle) {
// independent free sources instead of just one, sorted descending by xSemaphoreGive(mutex);
// observed hit rate in testing. Previously adsbdb.com alone returned return;
// "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) {
int dash = codes.indexOf('-');
if (dash > 0 && dash < (int)codes.length() - 1) {
strncpy(result.routeOrigin, codes.substring(0, dash).c_str(), sizeof(result.routeOrigin) - 1);
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. // Do one HTTP request, then return
if (!result.routeOrigin[0] || !result.routeDest[0]) { bool working = advance();
client.setTimeout(3000);
String body4; if (!working || currentPhase == Phase::Done) {
if (httpGetString(client, String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, body4)) { saveToSd(workHex, workResult);
JsonDocument doc4; ringStore(workHex, workResult);
if (!deserializeJson(doc4, body4)) { currentPhase = Phase::Idle;
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

@@ -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;
@@ -119,8 +120,9 @@ constexpr uint8_t MAX_WIFI_NETWORKS = 3;
// Gitea OTA update (Menu > System > "Check for update") - checks // Gitea OTA update (Menu > System > "Check for update") - checks
// the latest release in a private Gitea repository and downloads // the latest release in a private Gitea repository and downloads
// firmware.bin from there. See ota_update.cpp. // firmware.bin from there. Uses the external proxy URL since the
constexpr const char* GITEA_API_BASE = "git.rasmusbendtsen.dk"; // 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_PATH = "/api/v1/repos/rasmus/flyradar/releases/latest";
constexpr const char* GITEA_API_TOKEN = "YOUR_GITEA_TOKEN_HERE"; // set this! constexpr const char* GITEA_API_TOKEN = ""; // set this!
} }

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

@@ -12,12 +12,10 @@
namespace OtaUpdate { namespace OtaUpdate {
namespace { namespace {
constexpr const char* RELEASES_API_URL = // Gitea API URL constructed from config constants at runtime
"https://git.rasmusbendtsen.dk/api/v1/repos/rasmus/flyradar/releases/latest"; String releasesApiUrl() {
// Gitea API uses Authorization: token header instead of return String("https://") + Config::GITEA_API_HOST + Config::GITEA_API_PATH;
// User-Agent. The token is set in Config::GITEA_API_TOKEN. }
// Gitea serves downloads directly (no redirect), so
// setFollowRedirects is not needed.
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;
@@ -26,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;
@@ -37,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;
@@ -51,16 +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", "EiswolfsFlightradarCYD-OTA/1.0"); addAuthHeader(http);
if (Config::GITEA_API_TOKEN[0]) {
String auth = "token ";
auth += Config::GITEA_API_TOKEN;
http.addHeader("Authorization", auth);
}
int code = http.GET(); int code = http.GET();
if (code != HTTP_CODE_OK) { if (code != HTTP_CODE_OK) {
@@ -92,21 +92,15 @@ 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;
@@ -117,11 +111,6 @@ Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config
} }
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());
@@ -129,30 +118,20 @@ WiFiClientSecure client;
client.setInsecure(); client.setInsecure();
client.setTimeout(15000); client.setTimeout(15000);
// Gitea serves downloads directly (no redirect like GitHub's S3), // Use HTTPClient overload so we can add the Authorization header
// but private repos still need the Authorization header. Use the // (required for private repos even during download).
// HTTPClient overload of httpUpdate.update() so we can set it.
HTTPClient http; HTTPClient http;
if (!http.begin(client, url)) { if (!http.begin(client, url)) {
Serial.println("[OTA] Failed: http.begin() returned false for download URL."); Serial.println("[OTA] Failed: http.begin() returned false for download URL.");
return false; return false;
} }
if (Config::GITEA_API_TOKEN[0]) { addAuthHeader(http);
String auth = "token ";
auth += Config::GITEA_API_TOKEN;
http.addHeader("Authorization", auth);
}
// 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);
@@ -165,7 +144,6 @@ WiFiClientSecure client;
}); });
t_httpUpdate_return result = httpUpdate.update(http); t_httpUpdate_return result = httpUpdate.update(http);
http.end(); http.end();
if (result != HTTP_UPDATE_OK) { if (result != HTTP_UPDATE_OK) {