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
|
||||
|
||||
- name: Build firmware
|
||||
run: pio run
|
||||
|
||||
- name: Upload firmware.bin to release
|
||||
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."
|
||||
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,19 +14,24 @@ namespace AircraftDetails {
|
||||
namespace {
|
||||
SemaphoreHandle_t mutex = nullptr;
|
||||
|
||||
char pendingHex[7] = {0};
|
||||
char pendingCallsign[9] = {0};
|
||||
bool hasPending = false;
|
||||
|
||||
constexpr uint8_t CACHE_SIZE = 4;
|
||||
struct CacheEntry {
|
||||
constexpr uint8_t RING_SIZE = 8;
|
||||
struct RingEntry {
|
||||
char hex[7] = {0};
|
||||
Info info;
|
||||
};
|
||||
CacheEntry cache[CACHE_SIZE];
|
||||
uint8_t nextCacheSlot = 0;
|
||||
RingEntry ring[RING_SIZE];
|
||||
uint8_t ringNext = 0;
|
||||
|
||||
char pendingHex[7] = {0};
|
||||
char pendingCallsign[9] = {0};
|
||||
|
||||
enum class Phase { Idle, ModelSrc1, ModelSrc2, RouteSrc1, RouteSrc2, RouteSrc3, Done };
|
||||
Phase currentPhase = Phase::Idle;
|
||||
|
||||
char workHex[7] = {0};
|
||||
char workCallsign[9] = {0};
|
||||
Info workResult;
|
||||
|
||||
// Persistent client — avoids repeated TLS handshakes (~0.5-1s each).
|
||||
WiFiClientSecure persistentClient;
|
||||
bool clientConfigured = false;
|
||||
|
||||
@@ -31,6 +39,23 @@ namespace {
|
||||
if (mutex == nullptr) mutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
|
||||
int ringFind(const char* hex) {
|
||||
for (uint8_t i = 0; i < RING_SIZE; i++) {
|
||||
if (strcmp(ring[i].hex, hex) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ringStore(const char* hex, const Info& info) {
|
||||
int slot = ringFind(hex);
|
||||
if (slot < 0) {
|
||||
slot = ringNext;
|
||||
ringNext = (ringNext + 1) % RING_SIZE;
|
||||
}
|
||||
strncpy(ring[slot].hex, hex, sizeof(ring[slot].hex) - 1);
|
||||
ring[slot].info = info;
|
||||
}
|
||||
|
||||
bool httpGetString(const String& url, String& outBody, uint32_t timeoutMs) {
|
||||
if (!clientConfigured) {
|
||||
persistentClient.setInsecure();
|
||||
@@ -41,7 +66,6 @@ namespace {
|
||||
HTTPClient http;
|
||||
http.setTimeout(timeoutMs);
|
||||
if (!http.begin(persistentClient, url)) return false;
|
||||
http.setReuse(true);
|
||||
int code = http.GET();
|
||||
bool ok = (code == HTTP_CODE_OK);
|
||||
if (ok) outBody = http.getString();
|
||||
@@ -49,31 +73,149 @@ namespace {
|
||||
return ok;
|
||||
}
|
||||
|
||||
int findCacheSlot(const char* hex) {
|
||||
for (uint8_t i = 0; i < CACHE_SIZE; i++) {
|
||||
if (strcmp(cache[i].hex, hex) == 0) return i;
|
||||
bool loadFromSd(const char* hex, Info& out) {
|
||||
if (!SdStorage::isMounted()) return false;
|
||||
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
|
||||
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) {
|
||||
int slot = findCacheSlot(hex);
|
||||
if (slot < 0) {
|
||||
slot = nextCacheSlot;
|
||||
nextCacheSlot = (nextCacheSlot + 1) % CACHE_SIZE;
|
||||
void saveToSd(const char* hex, const Info& info) {
|
||||
if (!SdStorage::isMounted()) return;
|
||||
SD.mkdir(Config::SD_DETAIL_CACHE_DIR);
|
||||
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
|
||||
File f = SD.open(path, FILE_WRITE);
|
||||
if (!f) return;
|
||||
f.print("model="); f.println(info.model);
|
||||
f.print("route=");
|
||||
if (info.routeOrigin[0]) { f.print(info.routeOrigin); f.print("-"); f.println(info.routeDest); }
|
||||
else { f.println(); }
|
||||
f.close();
|
||||
}
|
||||
|
||||
void applyRouteCodes(const String& codes) {
|
||||
int dash = codes.indexOf('-');
|
||||
if (dash > 0 && dash < (int)codes.length() - 1) {
|
||||
strncpy(workResult.routeOrigin, codes.substring(0, dash).c_str(), sizeof(workResult.routeOrigin) - 1);
|
||||
strncpy(workResult.routeDest, codes.substring(dash + 1).c_str(), sizeof(workResult.routeDest) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
bool advance() {
|
||||
switch (currentPhase) {
|
||||
case Phase::ModelSrc1: {
|
||||
String body;
|
||||
if (httpGetString(String("https://hexdb.io/api/v1/aircraft/") + workHex, body, 2000)) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, body)) {
|
||||
const char* m = doc["Manufacturer"] | "";
|
||||
const char* t = doc["Type"] | "";
|
||||
if (m[0] && t[0]) snprintf(workResult.model, sizeof(workResult.model), "%s %s", m, t);
|
||||
else if (t[0]) strncpy(workResult.model, t, sizeof(workResult.model) - 1);
|
||||
}
|
||||
}
|
||||
currentPhase = workResult.model[0] ? Phase::RouteSrc1 : Phase::ModelSrc2;
|
||||
return true;
|
||||
}
|
||||
case Phase::ModelSrc2: {
|
||||
String body;
|
||||
if (httpGetString(String("https://api.adsbdb.com/v0/aircraft/") + workHex, body, 2000)) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, body)) {
|
||||
const char* m = doc["response"]["aircraft"]["manufacturer"] | "";
|
||||
const char* t = doc["response"]["aircraft"]["type"] | "";
|
||||
if (m[0] && t[0]) snprintf(workResult.model, sizeof(workResult.model), "%s %s", m, t);
|
||||
else if (t[0]) strncpy(workResult.model, t, sizeof(workResult.model) - 1);
|
||||
}
|
||||
}
|
||||
currentPhase = Phase::RouteSrc1;
|
||||
return true;
|
||||
}
|
||||
case Phase::RouteSrc1: {
|
||||
String cs = String(workCallsign);
|
||||
cs.trim(); cs.toUpperCase();
|
||||
if (cs.length() >= 2) {
|
||||
String body;
|
||||
String folder = cs.substring(0, 2);
|
||||
if (httpGetString(String("https://vrs-standing-data.adsb.lol/routes/") + folder + "/" + cs + ".json", body, 2000)) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, body)) {
|
||||
applyRouteCodes(String(doc["airport_codes"] | ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPhase = workResult.routeOrigin[0] ? Phase::Done : Phase::RouteSrc2;
|
||||
return true;
|
||||
}
|
||||
case Phase::RouteSrc2: {
|
||||
String cs = String(workCallsign);
|
||||
cs.trim(); cs.toUpperCase();
|
||||
if (cs.length() >= 2) {
|
||||
String body;
|
||||
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + cs, body, 2000)) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, body)) {
|
||||
applyRouteCodes(String(doc["route"] | ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPhase = workResult.routeOrigin[0] ? Phase::Done : Phase::RouteSrc3;
|
||||
return true;
|
||||
}
|
||||
case Phase::RouteSrc3: {
|
||||
String cs = String(workCallsign);
|
||||
cs.trim(); cs.toUpperCase();
|
||||
if (cs.length() > 0) {
|
||||
String body;
|
||||
if (httpGetString(String("https://api.adsbdb.com/v0/callsign/") + cs, body, 2000)) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, body)) {
|
||||
const char* o = doc["response"]["flightroute"]["origin"]["icao_code"] | "";
|
||||
const char* d = doc["response"]["flightroute"]["destination"]["icao_code"] | "";
|
||||
if (o[0] && d[0]) {
|
||||
strncpy(workResult.routeOrigin, o, sizeof(workResult.routeOrigin) - 1);
|
||||
strncpy(workResult.routeDest, d, sizeof(workResult.routeDest) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPhase = Phase::Done;
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
strncpy(cache[slot].hex, hex, sizeof(cache[slot].hex) - 1);
|
||||
cache[slot].info = info;
|
||||
}
|
||||
}
|
||||
|
||||
void request(const char* hex, const char* callsign) {
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
if (findCacheSlot(hex) < 0 && strcmp(pendingHex, hex) != 0) {
|
||||
if (currentPhase == Phase::Idle && ringFind(hex) < 0 && strcmp(pendingHex, hex) != 0) {
|
||||
strncpy(pendingHex, hex, sizeof(pendingHex) - 1);
|
||||
strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1);
|
||||
hasPending = true;
|
||||
}
|
||||
xSemaphoreGive(mutex);
|
||||
}
|
||||
@@ -82,11 +224,17 @@ Info get(const char* hex) {
|
||||
ensureMutex();
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
Info out;
|
||||
int slot = findCacheSlot(hex);
|
||||
int slot = ringFind(hex);
|
||||
if (slot >= 0) {
|
||||
out = cache[slot].info;
|
||||
} else if (strcmp(pendingHex, hex) == 0 && hasPending) {
|
||||
out = ring[slot].info;
|
||||
} else if (currentPhase != Phase::Idle && strcmp(workHex, hex) == 0) {
|
||||
out.loading = true;
|
||||
// Return partial results as they arrive
|
||||
if (workResult.model[0]) strncpy(out.model, workResult.model, sizeof(out.model) - 1);
|
||||
if (workResult.routeOrigin[0]) {
|
||||
strncpy(out.routeOrigin, workResult.routeOrigin, sizeof(out.routeOrigin) - 1);
|
||||
strncpy(out.routeDest, workResult.routeDest, sizeof(out.routeDest) - 1);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(mutex);
|
||||
return out;
|
||||
@@ -94,111 +242,42 @@ Info get(const char* hex) {
|
||||
|
||||
void update() {
|
||||
ensureMutex();
|
||||
|
||||
char hex[7] = {0};
|
||||
char callsign[9] = {0};
|
||||
bool doWork = false;
|
||||
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
if (hasPending) {
|
||||
strncpy(hex, pendingHex, sizeof(hex) - 1);
|
||||
strncpy(callsign, pendingCallsign, sizeof(callsign) - 1);
|
||||
doWork = true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Route lookup — via chain of three sources, 2s timeout each.
|
||||
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) {
|
||||
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 (currentPhase == Phase::Idle) {
|
||||
xSemaphoreGive(mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.routeOrigin[0] || !result.routeDest[0]) {
|
||||
String body4;
|
||||
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + trimmedCallsign, body4, 2000)) {
|
||||
JsonDocument doc4;
|
||||
if (!deserializeJson(doc4, body4)) {
|
||||
const char* route = doc4["route"] | "";
|
||||
applyRouteCodes(String(route));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do one HTTP request, then return
|
||||
bool working = advance();
|
||||
|
||||
if (!working || currentPhase == Phase::Done) {
|
||||
saveToSd(workHex, workResult);
|
||||
ringStore(workHex, workResult);
|
||||
currentPhase = Phase::Idle;
|
||||
}
|
||||
|
||||
if (trimmedCallsign.length() > 0 && (!result.routeOrigin[0] || !result.routeDest[0])) {
|
||||
String body5;
|
||||
if (httpGetString(String("https://api.adsbdb.com/v0/callsign/") + trimmedCallsign, body5, 2000)) {
|
||||
JsonDocument doc5;
|
||||
if (!deserializeJson(doc5, body5)) {
|
||||
const char* originIcao = doc5["response"]["flightroute"]["origin"]["icao_code"] | "";
|
||||
const char* destIcao = doc5["response"]["flightroute"]["destination"]["icao_code"] | "";
|
||||
if (originIcao[0] && destIcao[0]) {
|
||||
strncpy(result.routeOrigin, originIcao, sizeof(result.routeOrigin) - 1);
|
||||
strncpy(result.routeDest, destIcao, sizeof(result.routeDest) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreTake(mutex, portMAX_DELAY);
|
||||
storeInCache(hex, result);
|
||||
hasPending = false;
|
||||
xSemaphoreGive(mutex);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
21
src/config.h
21
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
|
||||
@@ -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;
|
||||
@@ -123,5 +132,5 @@ constexpr uint8_t MAX_WIFI_NETWORKS = 3;
|
||||
// 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!
|
||||
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) {
|
||||
|
||||
@@ -27,7 +27,11 @@ namespace {
|
||||
// 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;
|
||||
@@ -58,6 +62,7 @@ CheckInfo checkForUpdate() {
|
||||
http.setTimeout(8000);
|
||||
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;
|
||||
}
|
||||
addAuthHeader(http);
|
||||
@@ -65,10 +70,12 @@ CheckInfo checkForUpdate() {
|
||||
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;
|
||||
@@ -80,40 +87,33 @@ 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);
|
||||
|
||||
// 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* dlUrl = asset["browser_download_url"] | "";
|
||||
strncpy(info.downloadUrl, dlUrl, 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;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -21,6 +21,9 @@ 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 Gitea Releases API for the latest release, compares
|
||||
|
||||
@@ -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