7 Commits

Author SHA1 Message Date
a0418d3871 UI text fix
All checks were successful
Build Release / build (push) Successful in 1m21s
2026-08-16 22:15:58 +02:00
c962c04f42 OTA CI loop
All checks were successful
Build Release / build (push) Successful in 1m20s
2026-08-16 22:04:50 +02:00
065ae58b4b OTA memery allocation error msg 2026-08-16 21:42:15 +02:00
c41d419033 expand ground planes to include alt 0 m 2026-08-16 21:15:30 +02:00
63cd12b6f6 OTA CI loop
All checks were successful
Build Release / build (push) Successful in 1m20s
2026-08-16 20:35:38 +02:00
6a798f8a89 CI for OTA update
Some checks failed
Build Release / build (push) Failing after 1m25s
2026-08-16 20:27:14 +02:00
ab9b81683b fixed sd read error
All checks were successful
Build Release / build (push) Successful in 1m21s
2026-08-16 20:17:39 +02:00
14 changed files with 163 additions and 25 deletions

View File

@@ -18,7 +18,10 @@ jobs:
run: pip install platformio run: pip install platformio
- name: Build firmware - name: Build firmware
run: pio run 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 - name: Create Release and Upload Firmware
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

View File

@@ -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;

View File

@@ -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;

View File

@@ -66,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();
@@ -77,6 +76,7 @@ namespace {
bool loadFromSd(const char* hex, Info& out) { bool loadFromSd(const char* hex, Info& out) {
if (!SdStorage::isMounted()) return false; if (!SdStorage::isMounted()) return false;
String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt"; String path = String(Config::SD_DETAIL_CACHE_DIR) + "/" + hex + ".txt";
if (!SD.exists(path)) return false;
File f = SD.open(path); File f = SD.open(path);
if (!f) return false; if (!f) return false;
char line[64]; char line[64];

View File

@@ -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];
} }

View File

@@ -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
@@ -124,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!
} }

View File

@@ -147,7 +147,7 @@ static const char* const I18N_EN[] = {
"Hdg: ", "Hdg: ",
"Seats (estimated): ", "Seats (estimated): ",
"Seats: unknown", "Seats: unknown",
"Tap elsewhere to close", "Tap here to close",
"Climb: ", "Climb: ",
"Descent: ", "Descent: ",
"Level flight", "Level flight",
@@ -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.",

View File

@@ -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;
@@ -632,7 +636,25 @@ void setup() {
// regardless of when the device was booted. // regardless of when the device was booted.
configTime(0, 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); setenv("TZ", "CET-1CEST-2,M3.5.0/2,M10.5.0/3", 1);
tzset(); 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();
@@ -640,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();

View File

@@ -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) {

View File

@@ -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,12 +87,14 @@ 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;
} }
@@ -104,7 +113,7 @@ CheckInfo checkForUpdate() {
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;

View File

@@ -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

View File

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

View File

@@ -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++;

View File

@@ -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;