#include "aircraft_details.h" #include "sd_storage.h" #include "config.h" #include #include #include #include #include #include #include namespace AircraftDetails { namespace { 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 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; WiFiClientSecure persistentClient; bool clientConfigured = false; void ensureMutex() { 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(); clientConfigured = true; } persistentClient.setTimeout(timeoutMs); 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(); http.end(); 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) { ensureMutex(); xSemaphoreTake(mutex, portMAX_DELAY); if (currentPhase == Phase::Idle && ringFind(hex) < 0 && strcmp(pendingHex, hex) != 0) { strncpy(pendingHex, hex, sizeof(pendingHex) - 1); strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1); } xSemaphoreGive(mutex); } Info get(const char* hex) { ensureMutex(); xSemaphoreTake(mutex, portMAX_DELAY); Info out; int slot = ringFind(hex); if (slot >= 0) { 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; } void update() { ensureMutex(); xSemaphoreTake(mutex, portMAX_DELAY); 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 (currentPhase == Phase::Idle) { xSemaphoreGive(mutex); return; } // Do one HTTP request, then return bool working = advance(); if (!working || currentPhase == Phase::Done) { saveToSd(workHex, workResult); ringStore(workHex, workResult); currentPhase = Phase::Idle; } xSemaphoreGive(mutex); } }