diff --git a/src/aircraft_details.cpp b/src/aircraft_details.cpp index c693c8d..76b3585 100644 --- a/src/aircraft_details.cpp +++ b/src/aircraft_details.cpp @@ -1,4 +1,7 @@ #include "aircraft_details.h" +#include "sd_storage.h" +#include "config.h" +#include #include #include #include @@ -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(); @@ -49,31 +74,148 @@ 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"; + 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; - } - 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 (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); + 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; } } - 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); - } - } - } + if (currentPhase == Phase::Idle) { + xSemaphoreGive(mutex); + return; } - // Route lookup — via chain of three sources, 2s timeout each. - String trimmedCallsign = String(callsign); - trimmedCallsign.trim(); - trimmedCallsign.toUpperCase(); + // Do one HTTP request, then return + bool working = advance(); - 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 (!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)); - } - } - } + 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); } diff --git a/src/aircraft_details.h b/src/aircraft_details.h index 00655a6..01ccd8f 100644 --- a/src/aircraft_details.h +++ b/src/aircraft_details.h @@ -1,10 +1,13 @@ #pragma once #include -// 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 { diff --git a/src/config.h b/src/config.h index e42adad..3247c03 100644 --- a/src/config.h +++ b/src/config.h @@ -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_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; diff --git a/src/ota_update.cpp b/src/ota_update.cpp index 8dec2a7..98e7743 100644 --- a/src/ota_update.cpp +++ b/src/ota_update.cpp @@ -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;