diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index dd8c3ae..6db90c2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,9 +21,25 @@ jobs: run: pio run - name: Upload firmware.bin to release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ gitea.release.upload_url }} - asset_path: .pio/build/esp32dev/firmware.bin - asset_name: firmware.bin - asset_content_type: application/octet-stream \ No newline at end of file + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -e + API="http://192.168.0.37:3000/api/v1/repos/rasmus/flyradar" + TAG="${GITHUB_REF_NAME}" + echo "Fetching release for tag $TAG ..." + RELEASE_JSON=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ + "$API/releases/tags/$TAG") + RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id') + if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then + echo "Error: could not find release for tag $TAG" + echo "Response: $RELEASE_JSON" + exit 1 + fi + echo "Uploading to release ID $RELEASE_ID ..." + curl -s -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @.pio/build/esp32dev/firmware.bin \ + "$API/releases/$RELEASE_ID/assets?name=firmware.bin" + echo "" + echo "Upload complete." \ No newline at end of file diff --git a/src/aircraft_details.cpp b/src/aircraft_details.cpp index ae93ba5..c693c8d 100644 --- a/src/aircraft_details.cpp +++ b/src/aircraft_details.cpp @@ -15,29 +15,62 @@ namespace { char pendingCallsign[9] = {0}; bool hasPending = false; - char cachedHex[7] = {0}; - Info cached; + constexpr uint8_t CACHE_SIZE = 4; + struct CacheEntry { + char hex[7] = {0}; + Info info; + }; + CacheEntry cache[CACHE_SIZE]; + uint8_t nextCacheSlot = 0; + + // Persistent client — avoids repeated TLS handshakes (~0.5-1s each). + WiFiClientSecure persistentClient; + bool clientConfigured = false; void ensureMutex() { if (mutex == nullptr) mutex = xSemaphoreCreateMutex(); } - bool httpGetString(WiFiClientSecure& client, const String& url, String& outBody) { + bool httpGetString(const String& url, String& outBody, uint32_t timeoutMs) { + if (!clientConfigured) { + persistentClient.setInsecure(); + clientConfigured = true; + } + persistentClient.setTimeout(timeoutMs); + HTTPClient http; - http.setTimeout(5000); - if (!http.begin(client, url)) return false; + 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; } + + int findCacheSlot(const char* hex) { + for (uint8_t i = 0; i < CACHE_SIZE; i++) { + if (strcmp(cache[i].hex, hex) == 0) return i; + } + return -1; + } + + void storeInCache(const char* hex, const Info& info) { + int slot = findCacheSlot(hex); + if (slot < 0) { + slot = nextCacheSlot; + nextCacheSlot = (nextCacheSlot + 1) % CACHE_SIZE; + } + 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 (strcmp(cachedHex, hex) != 0 && strcmp(pendingHex, hex) != 0) { + if (findCacheSlot(hex) < 0 && strcmp(pendingHex, hex) != 0) { strncpy(pendingHex, hex, sizeof(pendingHex) - 1); strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1); hasPending = true; @@ -49,8 +82,9 @@ Info get(const char* hex) { ensureMutex(); xSemaphoreTake(mutex, portMAX_DELAY); Info out; - if (strcmp(cachedHex, hex) == 0) { - out = cached; + int slot = findCacheSlot(hex); + if (slot >= 0) { + out = cache[slot].info; } else if (strcmp(pendingHex, hex) == 0 && hasPending) { out.loading = true; } @@ -77,15 +111,10 @@ void update() { 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); + // 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(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) { + if (httpGetString(String("https://hexdb.io/api/v1/aircraft/") + hex, body, 2000)) { JsonDocument doc; DeserializationError err = deserializeJson(doc, body); if (!err) { @@ -99,13 +128,9 @@ void update() { } } -// 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)) { + if (httpGetString(String("https://api.adsbdb.com/v0/aircraft/") + hex, body2, 2000)) { JsonDocument doc2; DeserializationError err2 = deserializeJson(doc2, body2); if (!err2) { @@ -120,25 +145,7 @@ void update() { } } -// Flight route (origin/destination airport) - now via a chain of THREE - // independent free sources instead of just one, sorted descending by - // observed hit rate in testing. Previously adsbdb.com alone returned - // "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. + // Route lookup — via chain of three sources, 2s timeout each. String trimmedCallsign = String(callsign); trimmedCallsign.trim(); trimmedCallsign.toUpperCase(); @@ -152,11 +159,9 @@ void update() { }; 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)) { + 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"] | ""; @@ -164,11 +169,9 @@ void update() { } } - // 2. hexdb.io route endpoint, if source 1 did not deliver anything. if (!result.routeOrigin[0] || !result.routeDest[0]) { - client.setTimeout(3000); String body4; - if (httpGetString(client, String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, 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"] | ""; @@ -178,11 +181,9 @@ void update() { } } - // 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)) { + 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"] | ""; @@ -196,10 +197,9 @@ void update() { } xSemaphoreTake(mutex, portMAX_DELAY); - strncpy(cachedHex, hex, sizeof(cachedHex) - 1); - cached = result; + storeInCache(hex, result); hasPending = false; xSemaphoreGive(mutex); } -} +} \ No newline at end of file diff --git a/src/config.h b/src/config.h index fcd5609..e42adad 100644 --- a/src/config.h +++ b/src/config.h @@ -119,8 +119,9 @@ constexpr uint8_t MAX_WIFI_NETWORKS = 3; // Gitea OTA update (Menu > System > "Check for update") - checks // the latest release in a private Gitea repository and downloads -// firmware.bin from there. See ota_update.cpp. - constexpr const char* GITEA_API_BASE = "git.rasmusbendtsen.dk"; +// firmware.bin from there. Uses the external proxy URL since the +// 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! } \ No newline at end of file diff --git a/src/ota_update.cpp b/src/ota_update.cpp index 2f8a5a2..8dec2a7 100644 --- a/src/ota_update.cpp +++ b/src/ota_update.cpp @@ -12,12 +12,10 @@ namespace OtaUpdate { namespace { - constexpr const char* RELEASES_API_URL = - "https://git.rasmusbendtsen.dk/api/v1/repos/rasmus/flyradar/releases/latest"; - // Gitea API uses Authorization: token header instead of - // User-Agent. The token is set in Config::GITEA_API_TOKEN. - // Gitea serves downloads directly (no redirect), so - // setFollowRedirects is not needed. + // Gitea API URL constructed from config constants at runtime + String releasesApiUrl() { + return String("https://") + Config::GITEA_API_HOST + Config::GITEA_API_PATH; + } bool parseVersion(const char* s, int& major, int& minor, int& patch) { major = minor = patch = 0; @@ -26,10 +24,7 @@ namespace { 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. - // 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"). + // Numeric version comparison (not string-based, so e.g. "3.10.0" > "3.9.0"). int compareVersions(const char* a, const char* b) { int aMaj, aMin, aPat, bMaj, bMin, bPat; if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0; @@ -37,12 +32,22 @@ namespace { if (aMin != bMin) return aMin - bMin; 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 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()); WiFiClientSecure client; @@ -51,16 +56,11 @@ CheckInfo checkForUpdate() { HTTPClient http; http.setTimeout(8000); - if (!http.begin(client, RELEASES_API_URL)) { + if (!http.begin(client, url)) { Serial.println("[OTA] Check failed: http.begin() returned false."); return info; } - 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); - } + addAuthHeader(http); int code = http.GET(); if (code != HTTP_CODE_OK) { @@ -92,12 +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* url = asset["browser_download_url"] | ""; - strncpy(info.downloadUrl, url, sizeof(info.downloadUrl) - 1); + const char* dlUrl = asset["browser_download_url"] | ""; + strncpy(info.downloadUrl, dlUrl, sizeof(info.downloadUrl) - 1); break; } } @@ -105,54 +108,39 @@ CheckInfo checkForUpdate() { if (!info.downloadUrl[0]) { Serial.printf("[OTA] Check failed: Release v%s has no firmware.bin attachment.\n", info.latestVersion); - return info; // Release without firmware.bin attachment + return info; } int cmp = compareVersions(info.latestVersion, Config::APP_VERSION); info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate; -Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config::APP_VERSION, + Serial.printf("[OTA] Check successful: installed=v%s latest=v%s -> %s\n", Config::APP_VERSION, info.latestVersion, info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date"); return info; } 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, (unsigned)ESP.getFreeHeap(), WiFi.RSSI()); -WiFiClientSecure client; + WiFiClientSecure client; client.setInsecure(); client.setTimeout(15000); - // Gitea serves downloads directly (no redirect like GitHub's S3), - // but private repos still need the Authorization header. Use the - // HTTPClient overload of httpUpdate.update() so we can set it. + // Use HTTPClient overload so we can add the Authorization header + // (required for private repos even during download). HTTPClient http; if (!http.begin(client, url)) { Serial.println("[OTA] Failed: http.begin() returned false for download URL."); return false; } - if (Config::GITEA_API_TOKEN[0]) { - String auth = "token "; - auth += Config::GITEA_API_TOKEN; - http.addHeader("Authorization", auth); - } + addAuthHeader(http); - // We show a brief success message ourselves after successful installation, - // before the device reboots - see - // menu_screen.cpp::runOtaUpdateScreen(). httpUpdate.rebootOnUpdate(false); httpUpdate.onProgress([onProgress](int cur, int total) { if (onProgress && total > 0) { 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; if (total > 0) { int8_t pct = (int8_t)((cur * 100) / total); @@ -165,11 +153,10 @@ WiFiClientSecure client; }); t_httpUpdate_return result = httpUpdate.update(http); - http.end(); if (result != HTTP_UPDATE_OK) { -Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result, + Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result, httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str(), (unsigned)ESP.getFreeHeap()); } else { @@ -179,4 +166,4 @@ Serial.printf("[OTA] Failed: result=%d error=%d (%s) freeHeap=%u\n", (int)result return result == HTTP_UPDATE_OK; } -} +} \ No newline at end of file