Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a7a510bd6 | |||
| c962c04f42 | |||
| 065ae58b4b | |||
| c41d419033 | |||
| 63cd12b6f6 | |||
| 6a798f8a89 | |||
| ab9b81683b | |||
| 2949e8c110 | |||
| d80600863a |
@@ -18,28 +18,14 @@ jobs:
|
|||||||
run: pip install platformio
|
run: pip install platformio
|
||||||
|
|
||||||
- name: Build firmware
|
- name: Build firmware
|
||||||
run: pio run
|
|
||||||
|
|
||||||
- name: Upload firmware.bin to release
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
API="http://192.168.0.37:3000/api/v1/repos/rasmus/flyradar"
|
echo "Building version $VERSION"
|
||||||
TAG="${GITHUB_REF_NAME}"
|
PLATFORMIO_BUILD_FLAGS="-D CI_VERSION=$VERSION" pio run
|
||||||
echo "Fetching release for tag $TAG ..."
|
|
||||||
RELEASE_JSON=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
- name: Create Release and Upload Firmware
|
||||||
"$API/releases/tags/$TAG")
|
uses: softprops/action-gh-release@v2
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
with:
|
||||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
files: .pio/build/esp32dev/firmware.bin
|
||||||
echo "Error: could not find release for tag $TAG"
|
env:
|
||||||
echo "Response: $RELEASE_JSON"
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
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."
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# Eiswolfs Flightradar (CYD)
|
|
||||||
|
|
||||||
A live ADS-B flight radar running on the ESP32 "Cheap Yellow Display" (CYD,
|
A live ADS-B flight radar running on the ESP32 "Cheap Yellow Display" (CYD,
|
||||||
ESP32-2432S028), showing nearby aircraft on a rotating radar screen with
|
ESP32-2432S028), showing nearby aircraft on a rotating radar screen with
|
||||||
altitude/speed/model/route details and a proximity LED alert.
|
altitude/speed/model/route details and a proximity LED alert.
|
||||||
|
|||||||
@@ -106,10 +106,12 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm,
|
|||||||
a.lat = ac["lat"] | 0.0f;
|
a.lat = ac["lat"] | 0.0f;
|
||||||
a.lon = ac["lon"] | 0.0f;
|
a.lon = ac["lon"] | 0.0f;
|
||||||
|
|
||||||
if (ac["alt_baro"].is<const char*>()) {
|
if (ac["alt_baro"].is<const char*>()) {
|
||||||
a.altBaroFt = 0;
|
a.altBaroFt = 0;
|
||||||
|
a.onGround = (strcmp(ac["alt_baro"], "ground") == 0);
|
||||||
} else {
|
} else {
|
||||||
a.altBaroFt = ac["alt_baro"] | 0;
|
a.altBaroFt = ac["alt_baro"] | 0;
|
||||||
|
a.onGround = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.vertRateFtMin = ac["baro_rate"] | 0;
|
a.vertRateFtMin = ac["baro_rate"] | 0;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct Aircraft {
|
|||||||
uint32_t alertedAtMs = 0;
|
uint32_t alertedAtMs = 0;
|
||||||
|
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
|
bool onGround = false;
|
||||||
|
|
||||||
char airlineName[24] = {0};
|
char airlineName[24] = {0};
|
||||||
uint16_t estSeats = 0;
|
uint16_t estSeats = 0;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#include "aircraft_details.h"
|
#include "aircraft_details.h"
|
||||||
|
#include "sd_storage.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <SD.h>
|
||||||
#include <WiFiClientSecure.h>
|
#include <WiFiClientSecure.h>
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
@@ -11,19 +14,24 @@ namespace AircraftDetails {
|
|||||||
namespace {
|
namespace {
|
||||||
SemaphoreHandle_t mutex = nullptr;
|
SemaphoreHandle_t mutex = nullptr;
|
||||||
|
|
||||||
char pendingHex[7] = {0};
|
constexpr uint8_t RING_SIZE = 8;
|
||||||
char pendingCallsign[9] = {0};
|
struct RingEntry {
|
||||||
bool hasPending = false;
|
|
||||||
|
|
||||||
constexpr uint8_t CACHE_SIZE = 4;
|
|
||||||
struct CacheEntry {
|
|
||||||
char hex[7] = {0};
|
char hex[7] = {0};
|
||||||
Info info;
|
Info info;
|
||||||
};
|
};
|
||||||
CacheEntry cache[CACHE_SIZE];
|
RingEntry ring[RING_SIZE];
|
||||||
uint8_t nextCacheSlot = 0;
|
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;
|
WiFiClientSecure persistentClient;
|
||||||
bool clientConfigured = false;
|
bool clientConfigured = false;
|
||||||
|
|
||||||
@@ -31,6 +39,23 @@ namespace {
|
|||||||
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
|
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) {
|
bool httpGetString(const String& url, String& outBody, uint32_t timeoutMs) {
|
||||||
if (!clientConfigured) {
|
if (!clientConfigured) {
|
||||||
persistentClient.setInsecure();
|
persistentClient.setInsecure();
|
||||||
@@ -41,7 +66,6 @@ namespace {
|
|||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
http.setTimeout(timeoutMs);
|
http.setTimeout(timeoutMs);
|
||||||
if (!http.begin(persistentClient, url)) return false;
|
if (!http.begin(persistentClient, url)) return false;
|
||||||
http.setReuse(true);
|
|
||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
bool ok = (code == HTTP_CODE_OK);
|
bool ok = (code == HTTP_CODE_OK);
|
||||||
if (ok) outBody = http.getString();
|
if (ok) outBody = http.getString();
|
||||||
@@ -49,31 +73,149 @@ namespace {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
int findCacheSlot(const char* hex) {
|
bool loadFromSd(const char* hex, Info& out) {
|
||||||
for (uint8_t i = 0; i < CACHE_SIZE; i++) {
|
if (!SdStorage::isMounted()) return false;
|
||||||
if (strcmp(cache[i].hex, hex) == 0) return i;
|
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) {
|
void saveToSd(const char* hex, const Info& info) {
|
||||||
int slot = findCacheSlot(hex);
|
if (!SdStorage::isMounted()) return;
|
||||||
if (slot < 0) {
|
SD.mkdir(Config::SD_DETAIL_CACHE_DIR);
|
||||||
slot = nextCacheSlot;
|
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
|
||||||
nextCacheSlot = (nextCacheSlot + 1) % CACHE_SIZE;
|
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) {
|
void request(const char* hex, const char* callsign) {
|
||||||
ensureMutex();
|
ensureMutex();
|
||||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
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(pendingHex, hex, sizeof(pendingHex) - 1);
|
||||||
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
|
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
|
||||||
hasPending = true;
|
|
||||||
}
|
}
|
||||||
xSemaphoreGive(mutex);
|
xSemaphoreGive(mutex);
|
||||||
}
|
}
|
||||||
@@ -82,11 +224,17 @@ Info get(const char* hex) {
|
|||||||
ensureMutex();
|
ensureMutex();
|
||||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||||
Info out;
|
Info out;
|
||||||
int slot = findCacheSlot(hex);
|
int slot = ringFind(hex);
|
||||||
if (slot >= 0) {
|
if (slot >= 0) {
|
||||||
out = cache[slot].info;
|
out = ring[slot].info;
|
||||||
} else if (strcmp(pendingHex, hex) == 0 && hasPending) {
|
} else if (currentPhase != Phase::Idle && strcmp(workHex, hex) == 0) {
|
||||||
out.loading = true;
|
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);
|
xSemaphoreGive(mutex);
|
||||||
return out;
|
return out;
|
||||||
@@ -94,111 +242,42 @@ Info get(const char* hex) {
|
|||||||
|
|
||||||
void update() {
|
void update() {
|
||||||
ensureMutex();
|
ensureMutex();
|
||||||
|
|
||||||
char hex[7] = {0};
|
|
||||||
char callsign[9] = {0};
|
|
||||||
bool doWork = false;
|
|
||||||
|
|
||||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||||
if (hasPending) {
|
|
||||||
strncpy(hex, pendingHex, sizeof(hex) - 1);
|
if (currentPhase == Phase::Idle) {
|
||||||
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1);
|
if (pendingHex[0]) {
|
||||||
doWork = true;
|
// 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);
|
xSemaphoreGive(mutex);
|
||||||
|
return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
if (currentPhase == Phase::Idle) {
|
||||||
String trimmedCallsign = String(callsign);
|
xSemaphoreGive(mutex);
|
||||||
trimmedCallsign.trim();
|
return;
|
||||||
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 (!result.routeOrigin[0] || !result.routeDest[0]) {
|
// Do one HTTP request, then return
|
||||||
String body4;
|
bool working = advance();
|
||||||
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, body4, 2000)) {
|
|
||||||
JsonDocument doc4;
|
if (!working || currentPhase == Phase::Done) {
|
||||||
if (!deserializeJson(doc4, body4)) {
|
saveToSd(workHex, workResult);
|
||||||
const char* route = doc4["route"] | "";
|
ringStore(workHex, workResult);
|
||||||
applyRouteCodes(String(route));
|
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);
|
xSemaphoreGive(mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
// Additional aircraft details (model) that are NOT part of the ADS-B signal
|
// Additional aircraft details (model + route) that are NOT part of
|
||||||
// and get looked up via the free hexdb.io community database by hex code -
|
// the ADS-B signal and get looked up via a chain of free community
|
||||||
// only for the currently selected aircraft (not for all of them, to keep
|
// databases (hexdb.io, adsbdb.com, adsb.lol) by hex code and callsign.
|
||||||
// network load low).
|
// 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 {
|
namespace AircraftDetails {
|
||||||
|
|
||||||
struct Info {
|
struct Info {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ bool run(TFT_eSPI& tft) {
|
|||||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||||
if (!table[i].valid) continue;
|
if (!table[i].valid) continue;
|
||||||
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
||||||
if (SettingsStore::hideGroundVehicles() && table[i].category[0] == 'C') continue;
|
if (SettingsStore::hideGroundVehicles() && (table[i].category[0] == 'C' || table[i].onGround)) continue;
|
||||||
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
||||||
snapshot[count++] = table[i];
|
snapshot[count++] = table[i];
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/config.h
21
src/config.h
@@ -2,11 +2,19 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
namespace Config {
|
namespace Config {
|
||||||
// Updated by Karl on every version release (see
|
// Updated by CI on every version release (see
|
||||||
// CLAUDE.md workflow "Standard Workflow: Push & Release") - shown
|
// .gitea/workflows/release.yml) - shown in the info screen
|
||||||
// in the info screen (Menu > System > Info) and must match the
|
// (Menu > System > Info). Locally built firmware shows "custom".
|
||||||
// corresponding Git tag.
|
// The STR() macro converts the raw -D define to a string literal
|
||||||
constexpr const char* APP_VERSION = "3.6.3";
|
// (needed because PlatformIO strips quotes from build flags).
|
||||||
|
#define STR_HELPER(x) #x
|
||||||
|
#define STR(x) STR_HELPER(x)
|
||||||
|
|
||||||
|
#ifdef CI_VERSION
|
||||||
|
constexpr const char* APP_VERSION = STR(CI_VERSION);
|
||||||
|
#else
|
||||||
|
constexpr const char* APP_VERSION = "custom";
|
||||||
|
#endif
|
||||||
|
|
||||||
// Display brightness (Menu > System > Brightness), in percent.
|
// Display brightness (Menu > System > Brightness), in percent.
|
||||||
// MIN intentionally not 0 - a completely dark display could otherwise
|
// MIN intentionally not 0 - a completely dark display could otherwise
|
||||||
@@ -85,6 +93,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_SETTINGS_FILE = "/Flightradar_cyd/config.txt";
|
||||||
constexpr const char* SD_WIFI_CREDENTIALS_FILE = "/Flightradar_cyd/wifi.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_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_CS_PIN = 5;
|
||||||
constexpr uint8_t SD_SPI_MOSI_PIN = 23;
|
constexpr uint8_t SD_SPI_MOSI_PIN = 23;
|
||||||
@@ -123,5 +132,5 @@ constexpr uint8_t MAX_WIFI_NETWORKS = 3;
|
|||||||
// ESP32 reaches Gitea via https://git.rasmusbendtsen.dk (Pangolin).
|
// ESP32 reaches Gitea via https://git.rasmusbendtsen.dk (Pangolin).
|
||||||
constexpr const char* GITEA_API_HOST = "git.rasmusbendtsen.dk";
|
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_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!
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ static const char* const I18N_EN[] = {
|
|||||||
"Already up to date: v",
|
"Already up to date: v",
|
||||||
"Update available: v",
|
"Update available: v",
|
||||||
"A new firmware update will be downloaded and installed. Please don't interrupt the WiFi connection or power supply during this process. You'll be asked to restart once installation is complete. If the WebUI page (logbook/radar) is open in a browser, please close it first - it regularly polls the device for data and can interfere with the download otherwise.",
|
"A new firmware update will be downloaded and installed. Please don't interrupt the WiFi connection or power supply during this process. You'll be asked to restart once installation is complete. If the WebUI page (logbook/radar) is open in a browser, please close it first - it regularly polls the device for data and can interfere with the download otherwise.",
|
||||||
"Installing update ",
|
"Install update ",
|
||||||
"Update failed",
|
"Update failed",
|
||||||
"Update installed",
|
"Update installed",
|
||||||
"The new firmware was installed successfully. Please restart now to start using it.",
|
"The new firmware was installed successfully. Please restart now to start using it.",
|
||||||
|
|||||||
125
src/main.cpp
125
src/main.cpp
@@ -34,6 +34,7 @@
|
|||||||
#include "first_run_language_screen.h"
|
#include "first_run_language_screen.h"
|
||||||
#include "first_run_location_screen.h"
|
#include "first_run_location_screen.h"
|
||||||
#include "first_run_complete_screen.h"
|
#include "first_run_complete_screen.h"
|
||||||
|
#include "ota_update.h"
|
||||||
#include "menu_stars.h"
|
#include "menu_stars.h"
|
||||||
#include "ui_font.h"
|
#include "ui_font.h"
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
@@ -48,6 +49,9 @@ constexpr uint32_t SWEEP_TICK_MS = 80;
|
|||||||
constexpr uint32_t STATUS_LINE_UPDATE_MS = 1000;
|
constexpr uint32_t STATUS_LINE_UPDATE_MS = 1000;
|
||||||
uint32_t lastPollMs = 0;
|
uint32_t lastPollMs = 0;
|
||||||
uint32_t lastSweepMs = 0;
|
uint32_t lastSweepMs = 0;
|
||||||
|
bool gOtaUpdateAvailable = false;
|
||||||
|
char gOtaLatestVersion[16] = {0};
|
||||||
|
char gOtaDownloadUrl[192] = {0};
|
||||||
uint32_t lastStatusLineMs = 0;
|
uint32_t lastStatusLineMs = 0;
|
||||||
uint32_t lastRenderedVersion = 0xFFFFFFFF;
|
uint32_t lastRenderedVersion = 0xFFFFFFFF;
|
||||||
Weather::Condition lastRenderedWeather = Weather::Condition::Unknown;
|
Weather::Condition lastRenderedWeather = Weather::Condition::Unknown;
|
||||||
@@ -626,12 +630,31 @@ void setup() {
|
|||||||
}
|
}
|
||||||
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
|
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
|
||||||
|
|
||||||
// Once IP geolocation has provided a UTC offset, set the
|
// Sync time via NTP (UTC), then set timezone to Central European
|
||||||
// timezone accordingly - timestamps (flight log, log file names)
|
// Time (Copenhagen) with automatic DST handling (CET/CEST).
|
||||||
// then show the REAL local time instead of UTC, automatically correct worldwide.
|
// This makes localtime_r() return the correct local time year-round
|
||||||
if (LocationManager::hasUtcOffset()) {
|
// regardless of when the device was booted.
|
||||||
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
|
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||||
|
setenv("TZ", "CET-1CEST-2,M3.5.0/2,M10.5.0/3", 1);
|
||||||
|
tzset();
|
||||||
|
|
||||||
|
// Quick OTA version check during splash screen
|
||||||
|
SplashScreen::setStatusLine(tft, 1, I18n::t(StringId::OTA_CHECKING));
|
||||||
|
uint32_t otaStart = millis();
|
||||||
|
while (WifiMgr::getState() != WifiMgr::State::Connected &&
|
||||||
|
millis() - otaStart < 4000) {
|
||||||
|
MenuStars::update(tft);
|
||||||
|
delay(100);
|
||||||
}
|
}
|
||||||
|
if (WifiMgr::getState() == WifiMgr::State::Connected) {
|
||||||
|
auto otaInfo = OtaUpdate::checkForUpdate();
|
||||||
|
if (otaInfo.result == OtaUpdate::CheckResult::UpdateAvailable) {
|
||||||
|
gOtaUpdateAvailable = true;
|
||||||
|
strncpy(gOtaLatestVersion, otaInfo.latestVersion, sizeof(gOtaLatestVersion) - 1);
|
||||||
|
strncpy(gOtaDownloadUrl, otaInfo.downloadUrl, sizeof(gOtaDownloadUrl) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SplashScreen::setStatusLine(tft, 1, "");
|
||||||
|
|
||||||
AircraftTable::init();
|
AircraftTable::init();
|
||||||
AirlineLookup::init();
|
AirlineLookup::init();
|
||||||
@@ -639,7 +662,97 @@ void setup() {
|
|||||||
|
|
||||||
NetTask::begin();
|
NetTask::begin();
|
||||||
|
|
||||||
SplashScreen::waitRemaining(tft);
|
SplashScreen::waitRemaining(tft);
|
||||||
|
|
||||||
|
if (gOtaUpdateAvailable) {
|
||||||
|
struct Rect { int16_t x, y, w, h;
|
||||||
|
bool contains(int16_t px, int16_t py) const {
|
||||||
|
return px >= x && px < x + w && py >= y && py < y + h;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto drawBtn = [](Rect r, const String& label, uint16_t color) {
|
||||||
|
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
|
||||||
|
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, color);
|
||||||
|
tft.setTextDatum(MC_DATUM);
|
||||||
|
tft.setTextColor(color, TFT_BLACK);
|
||||||
|
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
|
||||||
|
tft.setTextDatum(TL_DATUM);
|
||||||
|
};
|
||||||
|
|
||||||
|
tft.fillScreen(TFT_BLACK);
|
||||||
|
tft.setTextDatum(MC_DATUM);
|
||||||
|
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
|
||||||
|
tft.setTextSize(1);
|
||||||
|
tft.drawString(String(I18n::t(StringId::OTA_UPDATE_AVAILABLE_PREFIX)) + gOtaLatestVersion,
|
||||||
|
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2 - 30);
|
||||||
|
tft.setTextSize(1);
|
||||||
|
|
||||||
|
int16_t btnW = 100, btnH = 40, btnY = Config::SCREEN_HEIGHT / 2 + 10;
|
||||||
|
int16_t gap = 16;
|
||||||
|
int16_t totalW = btnW * 2 + gap;
|
||||||
|
int16_t startX = (Config::SCREEN_WIDTH - totalW) / 2;
|
||||||
|
Rect installBtn = {startX, btnY, btnW, btnH};
|
||||||
|
Rect dismissBtn = {startX + btnW + gap, btnY, btnW, btnH};
|
||||||
|
drawBtn(installBtn, I18n::t(StringId::OTA_INSTALLING_PREFIX), TFT_GREEN);
|
||||||
|
drawBtn(dismissBtn, "Dismiss", TFT_RED);
|
||||||
|
|
||||||
|
tft.setTextDatum(TL_DATUM);
|
||||||
|
MenuStars::reset();
|
||||||
|
TouchInput::Point tap;
|
||||||
|
bool installChosen = false;
|
||||||
|
while (true) {
|
||||||
|
if (TouchInput::wasTapped(tap)) {
|
||||||
|
if (installBtn.contains(tap.x, tap.y)) { installChosen = true; break; }
|
||||||
|
if (dismissBtn.contains(tap.x, tap.y)) break;
|
||||||
|
}
|
||||||
|
MenuStars::update(tft);
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installChosen) {
|
||||||
|
tft.fillScreen(TFT_BLACK);
|
||||||
|
tft.setTextDatum(MC_DATUM);
|
||||||
|
tft.setTextSize(1);
|
||||||
|
constexpr int16_t PROG_Y = Config::SCREEN_HEIGHT / 2 + 10;
|
||||||
|
auto progressFn = [](uint8_t pct) {
|
||||||
|
tft.fillRect(0, PROG_Y, Config::SCREEN_WIDTH, 20, TFT_BLACK);
|
||||||
|
char buf[8]; snprintf(buf, sizeof(buf), "%d%%", pct);
|
||||||
|
tft.setTextDatum(MC_DATUM);
|
||||||
|
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||||
|
tft.drawString(buf, Config::SCREEN_WIDTH / 2, PROG_Y + 2);
|
||||||
|
tft.setTextDatum(TL_DATUM);
|
||||||
|
};
|
||||||
|
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||||
|
tft.drawString(String(I18n::t(StringId::OTA_INSTALLING_PREFIX)) + gOtaLatestVersion + "...",
|
||||||
|
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2 - 20);
|
||||||
|
bool ok = OtaUpdate::performUpdate(gOtaDownloadUrl, progressFn);
|
||||||
|
tft.fillScreen(TFT_BLACK);
|
||||||
|
tft.setTextDatum(MC_DATUM);
|
||||||
|
if (ok) {
|
||||||
|
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||||
|
tft.drawString(I18n::t(StringId::OTA_UPDATE_SUCCESS),
|
||||||
|
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2 - 20);
|
||||||
|
Rect restartBtn = {startX, btnY, btnW, btnH};
|
||||||
|
drawBtn(restartBtn, I18n::t(StringId::OTA_RESTART_BUTTON), TFT_GREEN);
|
||||||
|
while (true) {
|
||||||
|
if (TouchInput::wasTapped(tap) && restartBtn.contains(tap.x, tap.y)) {
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tft.setTextColor(TFT_RED, TFT_BLACK);
|
||||||
|
tft.drawString(I18n::t(StringId::OTA_UPDATE_FAILED),
|
||||||
|
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2 - 20);
|
||||||
|
tft.drawString("Dismiss",
|
||||||
|
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2 + 10);
|
||||||
|
while (true) {
|
||||||
|
if (TouchInput::wasTapped(tap)) break;
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
drawHeader();
|
drawHeader();
|
||||||
updateStatusLine();
|
updateStatusLine();
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ namespace {
|
|||||||
NetTask::resume();
|
NetTask::resume();
|
||||||
|
|
||||||
if (info.result == OtaUpdate::CheckResult::Error) {
|
if (info.result == OtaUpdate::CheckResult::Error) {
|
||||||
infoScreen(tft, I18n::t(StringId::OTA_CHECK_FAILED), "", TFT_RED, I18n::t(StringId::OK));
|
infoScreen(tft, I18n::t(StringId::OTA_CHECK_FAILED), info.errorMsg, TFT_RED, I18n::t(StringId::OK));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (info.result == OtaUpdate::CheckResult::UpToDate) {
|
if (info.result == OtaUpdate::CheckResult::UpToDate) {
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ namespace {
|
|||||||
// Numeric version comparison (not string-based, so e.g. "3.10.0" > "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 compareVersions(const char* a, const char* b) {
|
||||||
int aMaj, aMin, aPat, bMaj, bMin, bPat;
|
int aMaj, aMin, aPat, bMaj, bMin, bPat;
|
||||||
if (!parseVersion(a, aMaj, aMin, aPat) || !parseVersion(b, bMaj, bMin, bPat)) return 0;
|
bool aOk = parseVersion(a, aMaj, aMin, aPat);
|
||||||
|
bool bOk = parseVersion(b, bMaj, bMin, bPat);
|
||||||
|
if (!aOk && !bOk) return 0;
|
||||||
|
if (!aOk) return -1; // non-numeric version (e.g. "custom") = older than any real version
|
||||||
|
if (!bOk) return 1;
|
||||||
if (aMaj != bMaj) return aMaj - bMaj;
|
if (aMaj != bMaj) return aMaj - bMaj;
|
||||||
if (aMin != bMin) return aMin - bMin;
|
if (aMin != bMin) return aMin - bMin;
|
||||||
return aPat - bPat;
|
return aPat - bPat;
|
||||||
@@ -58,6 +62,7 @@ CheckInfo checkForUpdate() {
|
|||||||
http.setTimeout(8000);
|
http.setTimeout(8000);
|
||||||
if (!http.begin(client, url)) {
|
if (!http.begin(client, url)) {
|
||||||
Serial.println("[OTA] Check failed: http.begin() returned false.");
|
Serial.println("[OTA] Check failed: http.begin() returned false.");
|
||||||
|
strncpy(info.errorMsg, "Could not start HTTPS connection (low memory?)", sizeof(info.errorMsg) - 1);
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
addAuthHeader(http);
|
addAuthHeader(http);
|
||||||
@@ -65,10 +70,12 @@ CheckInfo checkForUpdate() {
|
|||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
if (code != HTTP_CODE_OK) {
|
if (code != HTTP_CODE_OK) {
|
||||||
if (code < 0) {
|
if (code < 0) {
|
||||||
Serial.printf("[OTA] Check failed: HTTP error=%d (%s)\n", code,
|
String errStr = HTTPClient::errorToString(code);
|
||||||
HTTPClient::errorToString(code).c_str());
|
Serial.printf("[OTA] Check failed: HTTP error=%d (%s)\n", code, errStr.c_str());
|
||||||
|
snprintf(info.errorMsg, sizeof(info.errorMsg), "HTTPS connection error: %s", errStr.c_str());
|
||||||
} else {
|
} else {
|
||||||
Serial.printf("[OTA] Check failed: HTTP status=%d\n", code);
|
Serial.printf("[OTA] Check failed: HTTP status=%d\n", code);
|
||||||
|
snprintf(info.errorMsg, sizeof(info.errorMsg), "Server returned HTTP %d", code);
|
||||||
}
|
}
|
||||||
http.end();
|
http.end();
|
||||||
return info;
|
return info;
|
||||||
@@ -80,40 +87,33 @@ CheckInfo checkForUpdate() {
|
|||||||
DeserializationError jsonErr = deserializeJson(doc, body);
|
DeserializationError jsonErr = deserializeJson(doc, body);
|
||||||
if (jsonErr) {
|
if (jsonErr) {
|
||||||
Serial.printf("[OTA] Check failed: JSON error (%s)\n", jsonErr.c_str());
|
Serial.printf("[OTA] Check failed: JSON error (%s)\n", jsonErr.c_str());
|
||||||
|
snprintf(info.errorMsg, sizeof(info.errorMsg), "Invalid response from server");
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* tag = doc["tag_name"] | "";
|
const char* tag = doc["tag_name"] | "";
|
||||||
if (!tag[0]) {
|
if (!tag[0]) {
|
||||||
Serial.println("[OTA] Check failed: no tag_name in release JSON.");
|
Serial.println("[OTA] Check failed: no tag_name in release JSON.");
|
||||||
|
snprintf(info.errorMsg, sizeof(info.errorMsg), "No release found on git.rasmusbendtsen.dk");
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
|
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
|
||||||
sizeof(info.latestVersion) - 1);
|
sizeof(info.latestVersion) - 1);
|
||||||
|
|
||||||
// Use browser_download_url from the API response — this contains the
|
// Construct download URL from the tag name using Gitea's standard
|
||||||
// external proxy URL (https://git.rasmusbendtsen.dk/...) which the
|
// release asset pattern. browser_download_url from the API contains
|
||||||
// ESP32 can reach through the proxy.
|
// the internal IP (http://192.168.0.37:3000/...) which the ESP32
|
||||||
JsonArray assets = doc["assets"];
|
// cannot use — it needs the external proxy URL.
|
||||||
for (JsonObject asset : assets) {
|
String dlUrl = String("https://git.rasmusbendtsen.dk/rasmus/flyradar/releases/download/")
|
||||||
const char* name = asset["name"] | "";
|
+ String(tag) + "/firmware.bin";
|
||||||
if (strcmp(name, "firmware.bin") == 0) {
|
strncpy(info.downloadUrl, dlUrl.c_str(), sizeof(info.downloadUrl) - 1);
|
||||||
const char* dlUrl = asset["browser_download_url"] | "";
|
|
||||||
strncpy(info.downloadUrl, dlUrl, sizeof(info.downloadUrl) - 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!info.downloadUrl[0]) {
|
Serial.printf("[OTA] Download URL: %s\n", info.downloadUrl);
|
||||||
Serial.printf("[OTA] Check failed: Release v%s has no firmware.bin attachment.\n",
|
|
||||||
info.latestVersion);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
|
int cmp = compareVersions(info.latestVersion, Config::APP_VERSION);
|
||||||
info.result = (cmp > 0) ? CheckResult::UpdateAvailable : CheckResult::UpToDate;
|
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=%s latest=%s -> %s\n", Config::APP_VERSION,
|
||||||
info.latestVersion,
|
info.latestVersion,
|
||||||
info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date");
|
info.result == CheckResult::UpdateAvailable ? "Update available" : "already up-to-date");
|
||||||
return info;
|
return info;
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ namespace OtaUpdate {
|
|||||||
char latestVersion[16] = {0};
|
char latestVersion[16] = {0};
|
||||||
// Direct download link to the firmware.bin of the release - only
|
// Direct download link to the firmware.bin of the release - only
|
||||||
// set when result == UpdateAvailable.
|
// set when result == UpdateAvailable.
|
||||||
char downloadUrl[192] = {0};
|
char downloadUrl[192] = {0};
|
||||||
|
// Human-readable error explanation (set when result == Error).
|
||||||
|
// e.g. "TLS handshake failed (low memory)" or "HTTP 401".
|
||||||
|
char errorMsg[64] = {0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Queries the Gitea Releases API for the latest release, compares
|
// Queries the Gitea Releases API for the latest release, compares
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ void render(TFT_eSPI& tft, int16_t top) {
|
|||||||
Aircraft& a = snapshot[i];
|
Aircraft& a = snapshot[i];
|
||||||
if (a.distanceKm > rangeKm * 1.05f) continue;
|
if (a.distanceKm > rangeKm * 1.05f) continue;
|
||||||
|
|
||||||
if (SettingsStore::hideGroundVehicles() && a.category[0] == 'C') continue;
|
if (SettingsStore::hideGroundVehicles() && (a.category[0] == 'C' || a.onGround)) continue;
|
||||||
|
|
||||||
if (AirlineFilter::isHidden(a.callsign)) continue;
|
if (AirlineFilter::isHidden(a.callsign)) continue;
|
||||||
|
|
||||||
@@ -1188,7 +1188,7 @@ bool runTable(TFT_eSPI& tft, int16_t top) {
|
|||||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||||
if (!table[i].valid) continue;
|
if (!table[i].valid) continue;
|
||||||
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
||||||
if (SettingsStore::hideGroundVehicles() && table[i].category[0] == 'C') continue;
|
if (SettingsStore::hideGroundVehicles() && (table[i].category[0] == 'C' || table[i].onGround)) continue;
|
||||||
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
||||||
|
|
||||||
uint8_t altMode = SettingsStore::altFilterIndex();
|
uint8_t altMode = SettingsStore::altFilterIndex();
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ bool run(TFT_eSPI& tft) {
|
|||||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||||
if (!table[i].valid) continue;
|
if (!table[i].valid) continue;
|
||||||
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
if (table[i].distanceKm > rangeKm * 1.05f) continue;
|
||||||
if (SettingsStore::hideGroundVehicles() && table[i].category[0] == 'C') continue;
|
if (SettingsStore::hideGroundVehicles() && (table[i].category[0] == 'C' || table[i].onGround)) continue;
|
||||||
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
if (AirlineFilter::isHidden(table[i].callsign)) continue;
|
||||||
total++;
|
total++;
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ bool run(TFT_eSPI& tft) {
|
|||||||
for (uint8_t i = 0; i < AircraftTable::capacity() && doneCount < total; i++) {
|
for (uint8_t i = 0; i < AircraftTable::capacity() && doneCount < total; i++) {
|
||||||
if (!table2[i].valid) continue;
|
if (!table2[i].valid) continue;
|
||||||
if (table2[i].distanceKm > rangeKm * 1.05f) continue;
|
if (table2[i].distanceKm > rangeKm * 1.05f) continue;
|
||||||
if (SettingsStore::hideGroundVehicles() && table2[i].category[0] == 'C') continue;
|
if (SettingsStore::hideGroundVehicles() && (table2[i].category[0] == 'C' || table2[i].onGround)) continue;
|
||||||
if (AirlineFilter::isHidden(table2[i].callsign)) continue;
|
if (AirlineFilter::isHidden(table2[i].callsign)) continue;
|
||||||
doneCount++;
|
doneCount++;
|
||||||
|
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ namespace {
|
|||||||
if (!a.valid) continue;
|
if (!a.valid) continue;
|
||||||
if (a.distanceKm > rangeKm * 1.05f) continue;
|
if (a.distanceKm > rangeKm * 1.05f) continue;
|
||||||
|
|
||||||
bool isGroundVehicle = a.category[0] == 'C';
|
bool isGroundVehicle = a.category[0] == 'C' || a.onGround;
|
||||||
if (hideGround && isGroundVehicle) continue;
|
if (hideGround && isGroundVehicle) continue;
|
||||||
if (AirlineFilter::isHidden(a.callsign)) continue;
|
if (AirlineFilter::isHidden(a.callsign)) continue;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user