3 Commits

Author SHA1 Message Date
6a798f8a89 CI for OTA update
Some checks failed
Build Release / build (push) Failing after 1m25s
2026-08-16 20:27:14 +02:00
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
5 changed files with 228 additions and 148 deletions

View File

@@ -18,7 +18,10 @@ jobs:
run: pip install platformio
- name: Build firmware
run: pio run
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "Building version $VERSION"
PLATFORMIO_BUILD_FLAGS="-D CI_VERSION=\"$VERSION\"" pio run
- name: Create Release and Upload Firmware
uses: softprops/action-gh-release@v2

View File

@@ -1,4 +1,7 @@
#include "aircraft_details.h"
#include "sd_storage.h"
#include "config.h"
#include <SD.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
@@ -11,19 +14,24 @@ namespace AircraftDetails {
namespace {
SemaphoreHandle_t mutex = nullptr;
char pendingHex[7] = {0};
char pendingCallsign[9] = {0};
bool hasPending = false;
constexpr uint8_t CACHE_SIZE = 4;
struct CacheEntry {
constexpr uint8_t RING_SIZE = 8;
struct RingEntry {
char hex[7] = {0};
Info info;
};
CacheEntry cache[CACHE_SIZE];
uint8_t nextCacheSlot = 0;
RingEntry ring[RING_SIZE];
uint8_t ringNext = 0;
char pendingHex[7] = {0};
char pendingCallsign[9] = {0};
enum class Phase { Idle, ModelSrc1, ModelSrc2, RouteSrc1, RouteSrc2, RouteSrc3, Done };
Phase currentPhase = Phase::Idle;
char workHex[7] = {0};
char workCallsign[9] = {0};
Info workResult;
// Persistent client — avoids repeated TLS handshakes (~0.5-1s each).
WiFiClientSecure persistentClient;
bool clientConfigured = false;
@@ -31,6 +39,23 @@ namespace {
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
}
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();
@@ -41,7 +66,6 @@ namespace {
HTTPClient http;
http.setTimeout(timeoutMs);
if (!http.begin(persistentClient, url)) return false;
http.setReuse(true);
int code = http.GET();
bool ok = (code == HTTP_CODE_OK);
if (ok) outBody = http.getString();
@@ -49,31 +73,149 @@ namespace {
return ok;
}
int findCacheSlot(const char* hex) {
for (uint8_t i = 0; i < CACHE_SIZE; i++) {
if (strcmp(cache[i].hex, hex) == 0) return i;
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);
}
return -1;
}
}
f.close();
return out.model[0] || out.routeOrigin[0];
}
void storeInCache(const char* hex, const Info& info) {
int slot = findCacheSlot(hex);
if (slot < 0) {
slot = nextCacheSlot;
nextCacheSlot = (nextCacheSlot + 1) % CACHE_SIZE;
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;
}
strncpy(cache[slot].hex, hex, sizeof(cache[slot].hex) - 1);
cache[slot].info = info;
}
}
void request(const char* hex, const char* callsign) {
ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY);
if (findCacheSlot(hex) < 0 && strcmp(pendingHex, hex) != 0) {
if (currentPhase == Phase::Idle && ringFind(hex) < 0 && strcmp(pendingHex, hex) != 0) {
strncpy(pendingHex, hex, sizeof(pendingHex) - 1);
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
hasPending = true;
}
xSemaphoreGive(mutex);
}
@@ -82,11 +224,17 @@ Info get(const char* hex) {
ensureMutex();
xSemaphoreTake(mutex, portMAX_DELAY);
Info out;
int slot = findCacheSlot(hex);
int slot = ringFind(hex);
if (slot >= 0) {
out = cache[slot].info;
} else if (strcmp(pendingHex, hex) == 0 && hasPending) {
out = ring[slot].info;
} else if (currentPhase != Phase::Idle && strcmp(workHex, hex) == 0) {
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);
return out;
@@ -94,111 +242,42 @@ Info get(const char* hex) {
void update() {
ensureMutex();
char hex[7] = {0};
char callsign[9] = {0};
bool doWork = false;
xSemaphoreTake(mutex, portMAX_DELAY);
if (hasPending) {
strncpy(hex, pendingHex, sizeof(hex) - 1);
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1);
doWork = true;
}
if (currentPhase == Phase::Idle) {
if (pendingHex[0]) {
// 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);
if (!doWork) return;
Info result;
// Model lookup — try hexdb.io first, fallback to adsbdb.com.
// Timeout reduced to 2s per source (was 3-4s) since we chain fallbacks.
String body;
if (httpGetString(String("https://hexdb.io/api/v1/aircraft/") + hex, body, 2000)) {
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);
}
}
}
if (!result.model[0]) {
String body2;
if (httpGetString(String("https://api.adsbdb.com/v0/aircraft/") + hex, body2, 2000)) {
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);
}
return;
}
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;
}
}
// Route lookup — via chain of three sources, 2s timeout each.
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) {
String body3;
String folder = trimmedCallsign.substring(0, 2);
if (httpGetString(String("https://vrs-standing-data.adsb.lol/routes/") + folder + "/" + trimmedCallsign + ".json", body3, 2000)) {
JsonDocument doc3;
if (!deserializeJson(doc3, body3)) {
const char* codes = doc3["airport_codes"] | "";
applyRouteCodes(String(codes));
}
if (currentPhase == Phase::Idle) {
xSemaphoreGive(mutex);
return;
}
if (!result.routeOrigin[0] || !result.routeDest[0]) {
String body4;
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, body4, 2000)) {
JsonDocument doc4;
if (!deserializeJson(doc4, body4)) {
const char* route = doc4["route"] | "";
applyRouteCodes(String(route));
}
}
}
// Do one HTTP request, then return
bool working = advance();
if (!working || currentPhase == Phase::Done) {
saveToSd(workHex, workResult);
ringStore(workHex, workResult);
currentPhase = Phase::Idle;
}
if (trimmedCallsign.length() > 0 && (!result.routeOrigin[0] || !result.routeDest[0])) {
String body5;
if (httpGetString(String("https://api.adsbdb.com/v0/callsign/") + trimmedCallsign, body5, 2000)) {
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);
storeInCache(hex, result);
hasPending = false;
xSemaphoreGive(mutex);
}

View File

@@ -1,10 +1,13 @@
#pragma once
#include <Arduino.h>
// Additional aircraft details (model) that are NOT part of the ADS-B signal
// and get looked up via the free hexdb.io community database by hex code -
// only for the currently selected aircraft (not for all of them, to keep
// network load low).
// Additional aircraft details (model + route) that are NOT part of
// the ADS-B signal and get looked up via a chain of free community
// databases (hexdb.io, adsbdb.com, adsb.lol) by hex code and callsign.
// 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 {
struct Info {

View File

@@ -2,11 +2,14 @@
#include <Arduino.h>
namespace Config {
// Updated by Karl on every version release (see
// CLAUDE.md workflow "Standard Workflow: Push & Release") - shown
// in the info screen (Menu > System > Info) and must match the
// corresponding Git tag.
constexpr const char* APP_VERSION = "3.6.3";
// Updated by CI on every version release (see
// .gitea/workflows/release.yml) - shown in the info screen
// (Menu > System > Info). Locally built firmware shows "custom".
#ifdef CI_VERSION
constexpr const char* APP_VERSION = CI_VERSION;
#else
constexpr const char* APP_VERSION = "custom";
#endif
// Display brightness (Menu > System > Brightness), in percent.
// MIN intentionally not 0 - a completely dark display could otherwise
@@ -85,6 +88,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_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.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_MOSI_PIN = 23;
@@ -123,5 +127,5 @@ constexpr uint8_t MAX_WIFI_NETWORKS = 3;
// 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!
constexpr const char* GITEA_API_TOKEN = ""; // set this!
}

View File

@@ -92,24 +92,15 @@ CheckInfo checkForUpdate() {
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
sizeof(info.latestVersion) - 1);
// Use browser_download_url from the API response — this contains the
// external proxy URL (https://git.rasmusbendtsen.dk/...) which the
// ESP32 can reach through the proxy.
JsonArray assets = doc["assets"];
for (JsonObject asset : assets) {
const char* name = asset["name"] | "";
if (strcmp(name, "firmware.bin") == 0) {
const char* dlUrl = asset["browser_download_url"] | "";
strncpy(info.downloadUrl, dlUrl, sizeof(info.downloadUrl) - 1);
break;
}
}
// Construct download URL from the tag name using Gitea's standard
// release asset pattern. browser_download_url from the API contains
// the internal IP (http://192.168.0.37:3000/...) which the ESP32
// cannot use — it needs the external proxy URL.
String dlUrl = String("https://git.rasmusbendtsen.dk/rasmus/flyradar/releases/download/")
+ String(tag) + "/firmware.bin";
strncpy(info.downloadUrl, dlUrl.c_str(), sizeof(info.downloadUrl) - 1);
if (!info.downloadUrl[0]) {
Serial.printf("[OTA] Check failed: Release v%s has no firmware.bin attachment.\n",
info.latestVersion);
return info;
}
Serial.printf("[OTA] Download URL: %s\n", info.downloadUrl);
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate;