Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a7a510bd6 | |||
| c962c04f42 | |||
| 065ae58b4b | |||
| c41d419033 | |||
| 63cd12b6f6 | |||
| 6a798f8a89 | |||
| ab9b81683b | |||
| 2949e8c110 | |||
| d80600863a | |||
| fb884acc27 | |||
| 3648538cbc | |||
| a02e280f42 |
31
.gitea/workflows/release.yml
Normal file
31
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Build Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: pip install platformio
|
||||
|
||||
- name: Build firmware
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "Building version $VERSION"
|
||||
PLATFORMIO_BUILD_FLAGS="-D CI_VERSION=$VERSION" pio run
|
||||
|
||||
- name: Create Release and Upload Firmware
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: .pio/build/esp32dev/firmware.bin
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,5 +1,3 @@
|
||||
# Eiswolfs Flightradar (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
|
||||
altitude/speed/model/route details and a proximity LED alert.
|
||||
|
||||
@@ -108,8 +108,10 @@ FetchResult fetch(double homeLat, double homeLon, float radiusKm,
|
||||
|
||||
if (ac["alt_baro"].is<const char*>()) {
|
||||
a.altBaroFt = 0;
|
||||
a.onGround = (strcmp(ac["alt_baro"], "ground") == 0);
|
||||
} else {
|
||||
a.altBaroFt = ac["alt_baro"] | 0;
|
||||
a.onGround = false;
|
||||
}
|
||||
|
||||
a.vertRateFtMin = ac["baro_rate"] | 0;
|
||||
|
||||
@@ -23,6 +23,7 @@ struct Aircraft {
|
||||
uint32_t alertedAtMs = 0;
|
||||
|
||||
bool valid = false;
|
||||
bool onGround = false;
|
||||
|
||||
char airlineName[24] = {0};
|
||||
uint16_t estSeats = 0;
|
||||
|
||||
@@ -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,36 +14,208 @@ 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};
|
||||
bool hasPending = false;
|
||||
|
||||
char cachedHex[7] = {0};
|
||||
Info cached;
|
||||
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();
|
||||
}
|
||||
|
||||
bool httpGetString(WiFiClientSecure& client, const String& url, String& outBody) {
|
||||
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(5000);
|
||||
if (!http.begin(client, url)) return false;
|
||||
http.setTimeout(timeoutMs);
|
||||
if (!http.begin(persistentClient, url)) return false;
|
||||
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";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (strcmp(cachedHex, 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);
|
||||
}
|
||||
@@ -49,10 +224,17 @@ Info get(const char* hex) {
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
Info out;
|
||||
if (strcmp(cachedHex, hex) == 0) {
|
||||
out = cached;
|
||||
} else if (strcmp(pendingHex, hex) == 0 && hasPending) {
|
||||
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;
|
||||
@@ -60,145 +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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!doWork) return;
|
||||
|
||||
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);
|
||||
String body;
|
||||
if (httpGetString(client, String("https://hexdb.io/api/v1/aircraft/") + hex, body)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
String trimmedCallsign = String(callsign);
|
||||
trimmedCallsign.trim();
|
||||
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) {
|
||||
// 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)) {
|
||||
JsonDocument doc3;
|
||||
if (!deserializeJson(doc3, body3)) {
|
||||
const char* codes = doc3["airport_codes"] | "";
|
||||
applyRouteCodes(String(codes));
|
||||
}
|
||||
if (currentPhase == Phase::Idle) {
|
||||
xSemaphoreGive(mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
JsonDocument doc4;
|
||||
if (!deserializeJson(doc4, body4)) {
|
||||
const char* route = doc4["route"] | "";
|
||||
applyRouteCodes(String(route));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do one HTTP request, then return
|
||||
bool working = advance();
|
||||
|
||||
if (!working || currentPhase == Phase::Done) {
|
||||
saveToSd(workHex, workResult);
|
||||
ringStore(workHex, workResult);
|
||||
currentPhase = Phase::Idle;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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);
|
||||
strncpy(cachedHex, hex, sizeof(cachedHex) - 1);
|
||||
cached = result;
|
||||
hasPending = false;
|
||||
xSemaphoreGive(mutex);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -90,7 +90,7 @@ bool run(TFT_eSPI& tft) {
|
||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||
if (!table[i].valid) 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;
|
||||
snapshot[count++] = table[i];
|
||||
}
|
||||
|
||||
29
src/config.h
29
src/config.h
@@ -2,11 +2,19 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace Config {
|
||||
// Updated by Karl on every version release (see
|
||||
// CLAUDE.md workflow "Standard Workflow: Push & Release") - shown
|
||||
// in the info screen (Menu > System > Info) and must match the
|
||||
// corresponding Git tag.
|
||||
constexpr const char* APP_VERSION = "3.6.3";
|
||||
// Updated by CI on every version release (see
|
||||
// .gitea/workflows/release.yml) - shown in the info screen
|
||||
// (Menu > System > Info). Locally built firmware shows "custom".
|
||||
// The STR() macro converts the raw -D define to a string literal
|
||||
// (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.
|
||||
// MIN intentionally not 0 - a completely dark display could otherwise
|
||||
@@ -37,7 +45,7 @@ namespace Config {
|
||||
// require a meaningful User-Agent instead of the HTTPClient
|
||||
// default, see https://operations.osmfoundation.org/policies/nominatim/.
|
||||
constexpr const char* NOMINATIM_HOST = "nominatim.openstreetmap.org";
|
||||
constexpr const char* NOMINATIM_USER_AGENT = "EiswolfsFlightradarCYD (github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
|
||||
constexpr const char* NOMINATIM_USER_AGENT = "EiswolfsFlightradarCYD (git.rasmusbendtsen.dk/rasmus/flyradar)";
|
||||
|
||||
struct GpsPinPair { uint8_t rx; uint8_t tx; const char* label; };
|
||||
constexpr GpsPinPair GPS_PIN_CANDIDATES[] = {
|
||||
@@ -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_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;
|
||||
@@ -116,4 +125,12 @@ constexpr float RANGE_STEPS_KM[] = {10.0f, 25.0f, 50.0f, 100.0f};
|
||||
constexpr uint8_t EMERGENCY_SQUAWK_COUNT = 3;
|
||||
|
||||
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. 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 = ""; // set this!
|
||||
}
|
||||
@@ -248,7 +248,7 @@ static const char* const I18N_EN[] = {
|
||||
"Already up to date: 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.",
|
||||
"Installing update ",
|
||||
"Install update ",
|
||||
"Update failed",
|
||||
"Update installed",
|
||||
"The new firmware was installed successfully. Please restart now to start using it.",
|
||||
|
||||
123
src/main.cpp
123
src/main.cpp
@@ -34,6 +34,7 @@
|
||||
#include "first_run_language_screen.h"
|
||||
#include "first_run_location_screen.h"
|
||||
#include "first_run_complete_screen.h"
|
||||
#include "ota_update.h"
|
||||
#include "menu_stars.h"
|
||||
#include "ui_font.h"
|
||||
#include <math.h>
|
||||
@@ -48,6 +49,9 @@ constexpr uint32_t SWEEP_TICK_MS = 80;
|
||||
constexpr uint32_t STATUS_LINE_UPDATE_MS = 1000;
|
||||
uint32_t lastPollMs = 0;
|
||||
uint32_t lastSweepMs = 0;
|
||||
bool gOtaUpdateAvailable = false;
|
||||
char gOtaLatestVersion[16] = {0};
|
||||
char gOtaDownloadUrl[192] = {0};
|
||||
uint32_t lastStatusLineMs = 0;
|
||||
uint32_t lastRenderedVersion = 0xFFFFFFFF;
|
||||
Weather::Condition lastRenderedWeather = Weather::Condition::Unknown;
|
||||
@@ -626,12 +630,31 @@ void setup() {
|
||||
}
|
||||
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
|
||||
|
||||
// Once IP geolocation has provided a UTC offset, set the
|
||||
// timezone accordingly - timestamps (flight log, log file names)
|
||||
// then show the REAL local time instead of UTC, automatically correct worldwide.
|
||||
if (LocationManager::hasUtcOffset()) {
|
||||
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
|
||||
// Sync time via NTP (UTC), then set timezone to Central European
|
||||
// Time (Copenhagen) with automatic DST handling (CET/CEST).
|
||||
// This makes localtime_r() return the correct local time year-round
|
||||
// regardless of when the device was booted.
|
||||
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();
|
||||
AirlineLookup::init();
|
||||
@@ -641,6 +664,96 @@ void setup() {
|
||||
|
||||
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();
|
||||
updateStatusLine();
|
||||
RadarScreen::render(tft, CONTENT_TOP);
|
||||
|
||||
@@ -540,7 +540,7 @@ namespace {
|
||||
NetTask::resume();
|
||||
|
||||
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;
|
||||
}
|
||||
if (info.result == OtaUpdate::CheckResult::UpToDate) {
|
||||
@@ -687,6 +687,7 @@ void run(TFT_eSPI& tft) {
|
||||
drawButton(tft, brightnessBtn, brightnessLabel(SettingsStore::brightnessPercent()));
|
||||
drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes()));
|
||||
drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
|
||||
drawButton(tft, ecoModeBtn, I18n::t(StringId::MENU_ECO_MODE) + onOff(SettingsStore::ecoModeEnabled()));
|
||||
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
|
||||
drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET));
|
||||
drawButton(tft, checkUpdateBtn, I18n::t(StringId::MENU_CHECK_UPDATE));
|
||||
|
||||
@@ -12,13 +12,10 @@
|
||||
namespace OtaUpdate {
|
||||
|
||||
namespace {
|
||||
constexpr const char* RELEASES_API_URL =
|
||||
"https://api.github.com/repos/Eiswolf-BG/eiswolfs-flightradar-CYD/releases/latest";
|
||||
// GitHub requires a meaningful User-Agent for API requests
|
||||
// (otherwise HTTP 403) - same principle as Config::NOMINATIM_USER_AGENT
|
||||
// for address search.
|
||||
constexpr const char* USER_AGENT =
|
||||
"EiswolfsFlightradarCYD-OTA/1.0 (+https://github.com/Eiswolf-BG/eiswolfs-flightradar-CYD)";
|
||||
// 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;
|
||||
@@ -27,23 +24,34 @@ 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;
|
||||
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 (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;
|
||||
@@ -52,19 +60,22 @@ 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.");
|
||||
strncpy(info.errorMsg, "Could not start HTTPS connection (low memory?)", sizeof(info.errorMsg) - 1);
|
||||
return info;
|
||||
}
|
||||
http.addHeader("User-Agent", USER_AGENT);
|
||||
addAuthHeader(http);
|
||||
|
||||
int code = http.GET();
|
||||
if (code != HTTP_CODE_OK) {
|
||||
if (code < 0) {
|
||||
Serial.printf("[OTA] Check failed: HTTP error=%d (%s)\n", code,
|
||||
HTTPClient::errorToString(code).c_str());
|
||||
String errStr = HTTPClient::errorToString(code);
|
||||
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 {
|
||||
Serial.printf("[OTA] Check failed: HTTP status=%d\n", code);
|
||||
snprintf(info.errorMsg, sizeof(info.errorMsg), "Server returned HTTP %d", code);
|
||||
}
|
||||
http.end();
|
||||
return info;
|
||||
@@ -76,48 +87,39 @@ CheckInfo checkForUpdate() {
|
||||
DeserializationError jsonErr = deserializeJson(doc, body);
|
||||
if (jsonErr) {
|
||||
Serial.printf("[OTA] Check failed: JSON error (%s)\n", jsonErr.c_str());
|
||||
snprintf(info.errorMsg, sizeof(info.errorMsg), "Invalid response from server");
|
||||
return info;
|
||||
}
|
||||
|
||||
const char* tag = doc["tag_name"] | "";
|
||||
if (!tag[0]) {
|
||||
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;
|
||||
}
|
||||
|
||||
strncpy(info.latestVersion, (tag[0] == 'v' || tag[0] == 'V') ? tag + 1 : tag,
|
||||
sizeof(info.latestVersion) - 1);
|
||||
|
||||
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);
|
||||
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; // Release without firmware.bin attachment
|
||||
}
|
||||
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;
|
||||
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.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());
|
||||
|
||||
@@ -125,26 +127,20 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
|
||||
client.setInsecure();
|
||||
client.setTimeout(15000);
|
||||
|
||||
// IMPORTANT: GitHub's "browser_download_url" for release assets is NOT
|
||||
// a direct download link, but first returns an HTTP 301/302 redirect
|
||||
// to a signed objects.githubusercontent.com URL. HTTPUpdate does NOT
|
||||
// follow redirects by default (HTTPC_DISABLE_FOLLOW_REDIRECTS is the
|
||||
// default) - without this line the download aborts with HTTP_UPDATE_FAILED
|
||||
// because only the redirect response arrives instead of the .bin file. See
|
||||
// e.g. espressif/arduino-esp32#3020. HTTPC_STRICT_FOLLOW_REDIRECTS
|
||||
// suffices since we only use GET.
|
||||
httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
// 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;
|
||||
}
|
||||
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);
|
||||
@@ -156,7 +152,8 @@ bool performUpdate(const char* url, void (*onProgress)(uint8_t percent)) {
|
||||
}
|
||||
});
|
||||
|
||||
t_httpUpdate_return result = httpUpdate.update(client, url);
|
||||
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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
// Firmware update over WiFi (Menu > System > "Check for update") -
|
||||
// checks against the latest version in the GitHub repository (Releases API) and
|
||||
// checks against the latest version in a private Gitea repository (Releases API) and
|
||||
// installs it on the device directly upon confirmation, without cable/
|
||||
// web flasher. Blocking, synchronous HTTPS calls - only executed on
|
||||
// explicit button press (no background polling), analogous to
|
||||
@@ -21,9 +21,12 @@ namespace OtaUpdate {
|
||||
// Direct download link to the firmware.bin of the release - only
|
||||
// set when result == UpdateAvailable.
|
||||
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 GitHub Releases API for the latest release, compares
|
||||
// Queries the Gitea Releases API for the latest release, compares
|
||||
// its version number (tag name, "v" prefix is ignored) against
|
||||
// Config::APP_VERSION and looks for the "firmware.bin" attachment in the release.
|
||||
// CheckResult::Error on any error along the way (no WiFi, timeout,
|
||||
|
||||
@@ -864,7 +864,7 @@ void render(TFT_eSPI& tft, int16_t top) {
|
||||
Aircraft& a = snapshot[i];
|
||||
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;
|
||||
|
||||
@@ -1188,7 +1188,7 @@ bool runTable(TFT_eSPI& tft, int16_t top) {
|
||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||
if (!table[i].valid) 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;
|
||||
|
||||
uint8_t altMode = SettingsStore::altFilterIndex();
|
||||
|
||||
@@ -185,7 +185,7 @@ bool run(TFT_eSPI& tft) {
|
||||
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
|
||||
if (!table[i].valid) 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;
|
||||
total++;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ bool run(TFT_eSPI& tft) {
|
||||
for (uint8_t i = 0; i < AircraftTable::capacity() && doneCount < total; i++) {
|
||||
if (!table2[i].valid) 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;
|
||||
doneCount++;
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ namespace {
|
||||
if (!a.valid) 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 (AirlineFilter::isHidden(a.callsign)) continue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user