|
|
|
|
@@ -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();
|
|
|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|