From aa114856e548e57ed4ad80291e0983bd7658eb47 Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Sat, 20 Jun 2026 13:54:28 +0100 Subject: [PATCH] Add optional airline label on aircraft tags Resolve each flight's airline from its callsign's ICAO prefix (e.g. BAW in BAW123) via a curated lookup table holding the ICAO code, IATA acronym, full name, and a friendly short name. A new Show: selector in the Wi-Fi setup portal chooses None / Full Airline Name / Airline Abbreviation, persisted to NVS. When set and the callsign resolves, the airline is rendered as an extra line on the aircraft tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/data/airlines.h | 23 +++++ include/services/adsb_client.h | 9 +- include/ui/radar_range.h | 10 +++ src/data/airlines_data.cpp | 158 +++++++++++++++++++++++++++++++++ src/services/adsb_client.cpp | 3 + src/services/wifi_setup.cpp | 30 +++++++ src/ui/radar_display.cpp | 75 +++++++++------- src/ui/radar_range.cpp | 34 +++++++ 8 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 include/data/airlines.h create mode 100644 src/data/airlines_data.cpp diff --git a/include/data/airlines.h b/include/data/airlines.h new file mode 100644 index 000000000..c31f96ba1 --- /dev/null +++ b/include/data/airlines.h @@ -0,0 +1,23 @@ +#pragma once + +// Airline lookup: maps a flight callsign's 3-letter ICAO airline designator +// (e.g. "BAW" in "BAW123") to the airline's IATA acronym and full name. +// Curated list of common/major airlines; unknown or non-airline (GA, military, +// registration) callsigns resolve to nullptr. +namespace data::airlines { + +struct Airline { + const char* icao; // 3-letter ICAO designator (callsign prefix) + const char* iata; // 2-letter IATA code / acronym (may be "") + const char* name; // full airline name, e.g. "British Airways" + const char* short_name; // friendly short name, e.g. "Virgin", "American" +}; + +/** + * Resolve a flight callsign (e.g. "BAW123") to an airline, or nullptr if the + * callsign does not look like an airline flight (needs 3 letters + a digit) or + * the ICAO code is not in the table. + */ +const Airline* forCallsign(const char* callsign); + +} // namespace data::airlines diff --git a/include/services/adsb_client.h b/include/services/adsb_client.h index 2b5dd787c..bca00cf32 100644 --- a/include/services/adsb_client.h +++ b/include/services/adsb_client.h @@ -2,6 +2,8 @@ #include +#include "data/airlines.h" + namespace services::adsb { struct Aircraft { @@ -10,9 +12,12 @@ struct Aircraft { float nose_deg; float track_deg; float gs_knots; - char callsign[9]; - char type[5]; + char callsign[9]; // flight/callsign, e.g. "BAW123" + char type[5]; // aircraft type code, e.g. "A320" char alt[12]; + // Resolved airline (nullptr if the callsign is not a known airline flight). + // Provides the IATA acronym, full name, and friendly short name. + const data::airlines::Airline* airline; }; constexpr size_t kMaxAircraft = 64; diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index a95c53275..a4d4ca78a 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -43,11 +43,21 @@ uint8_t rangeIndex(); /** ADSB fetch radius (km): scaled to screen edge so beyond-ring dots have data. */ float fetchRadiusKm(); +/** How to label aircraft with their airline (portal "Show:" selector). */ +enum class AirlineDisplay : uint8_t { + kNone = 0, // don't show airline + kFullName = 1, // e.g. "British Airways" + kAbbrev = 2, // friendly short name, e.g. "Virgin" +}; + bool useMiles(); bool showRunways(); +AirlineDisplay airlineDisplay(); /** WiFi portal checkbox: "T" = miles, otherwise km. */ void saveMilesFromPortal(const char* checkbox_value); void saveRunwaysFromPortal(const char* checkbox_value); +/** WiFi portal select: "0" none, "1" full name, "2" abbreviation. */ +void saveAirlineDisplayFromPortal(const char* select_value); void formatRing3Label(char* buf, size_t len, float ring3_km, bool use_miles); void formatCurrentRing3Label(char* buf, size_t len); /** Reset distance units to km (e.g. with WiFi credential wipe). */ diff --git a/src/data/airlines_data.cpp b/src/data/airlines_data.cpp new file mode 100644 index 000000000..0fc3cd64a --- /dev/null +++ b/src/data/airlines_data.cpp @@ -0,0 +1,158 @@ +#include "data/airlines.h" + +#include +#include + +namespace data::airlines { + +namespace { + +// Curated list of common/major airlines, keyed by ICAO designator (callsign +// prefix). Extend as needed. Order is not significant (linear lookup). +constexpr Airline kAirlines[] = { + // {ICAO, IATA, full name, friendly short name} + // North America + {"AAL", "AA", "American Airlines", "American"}, + {"UAL", "UA", "United Airlines", "United"}, + {"DAL", "DL", "Delta Air Lines", "Delta"}, + {"SWA", "WN", "Southwest Airlines", "Southwest"}, + {"JBU", "B6", "JetBlue Airways", "JetBlue"}, + {"ASA", "AS", "Alaska Airlines", "Alaska"}, + {"FFT", "F9", "Frontier Airlines", "Frontier"}, + {"NKS", "NK", "Spirit Airlines", "Spirit"}, + {"HAL", "HA", "Hawaiian Airlines", "Hawaiian"}, + {"ACA", "AC", "Air Canada", "Air Canada"}, + {"WJA", "WS", "WestJet", "WestJet"}, + {"AMX", "AM", "Aeromexico", "Aeromexico"}, + {"VOI", "Y4", "Volaris", "Volaris"}, + {"FDX", "FX", "FedEx Express", "FedEx"}, + {"UPS", "5X", "UPS Airlines", "UPS"}, + {"GTI", "5Y", "Atlas Air", "Atlas"}, + // United Kingdom & Ireland + {"BAW", "BA", "British Airways", "British Airways"}, + {"VIR", "VS", "Virgin Atlantic", "Virgin"}, + {"EZY", "U2", "easyJet", "easyJet"}, + {"EXS", "LS", "Jet2", "Jet2"}, + {"RYR", "FR", "Ryanair", "Ryanair"}, + {"TOM", "BY", "TUI Airways", "TUI"}, + // Western Europe + {"DLH", "LH", "Lufthansa", "Lufthansa"}, + {"AFR", "AF", "Air France", "Air France"}, + {"KLM", "KL", "KLM", "KLM"}, + {"IBE", "IB", "Iberia", "Iberia"}, + {"SWR", "LX", "Swiss", "Swiss"}, + {"AUA", "OS", "Austrian Airlines", "Austrian"}, + {"BEL", "SN", "Brussels Airlines", "Brussels"}, + {"TAP", "TP", "TAP Air Portugal", "TAP"}, + {"ITY", "AZ", "ITA Airways", "ITA"}, + {"VLG", "VY", "Vueling", "Vueling"}, + {"EWG", "EW", "Eurowings", "Eurowings"}, + {"CFG", "DE", "Condor", "Condor"}, + {"TVF", "TO", "Transavia France", "Transavia"}, + {"TRA", "HV", "Transavia", "Transavia"}, + // Nordics & Baltics + {"SAS", "SK", "Scandinavian Airlines", "SAS"}, + {"FIN", "AY", "Finnair", "Finnair"}, + {"NAX", "DY", "Norwegian", "Norwegian"}, + {"NSZ", "DY", "Norwegian", "Norwegian"}, + {"ICE", "FI", "Icelandair", "Icelandair"}, + {"BTI", "BT", "airBaltic", "airBaltic"}, + // Central & Eastern Europe + {"WZZ", "W6", "Wizz Air", "Wizz"}, + {"LOT", "LO", "LOT Polish Airlines", "LOT"}, + {"CSA", "OK", "Czech Airlines", "Czech"}, + {"AEE", "A3", "Aegean Airlines", "Aegean"}, + {"THY", "TK", "Turkish Airlines", "Turkish"}, + {"PGT", "PC", "Pegasus Airlines", "Pegasus"}, + {"AFL", "SU", "Aeroflot", "Aeroflot"}, + {"SBI", "S7", "S7 Airlines", "S7"}, + {"ROT", "RO", "Tarom", "Tarom"}, + // Middle East + {"UAE", "EK", "Emirates", "Emirates"}, + {"QTR", "QR", "Qatar Airways", "Qatar"}, + {"ETD", "EY", "Etihad Airways", "Etihad"}, + {"FDB", "FZ", "flydubai", "flydubai"}, + {"ABY", "G9", "Air Arabia", "Air Arabia"}, + {"SVA", "SV", "Saudia", "Saudia"}, + {"ELY", "LY", "El Al", "El Al"}, + {"MEA", "ME", "Middle East Airlines", "MEA"}, + {"RJA", "RJ", "Royal Jordanian", "Royal Jordanian"}, + {"GFA", "GF", "Gulf Air", "Gulf Air"}, + {"OMA", "WY", "Oman Air", "Oman Air"}, + {"KAC", "KU", "Kuwait Airways", "Kuwait"}, + // Africa + {"SAA", "SA", "South African Airways", "South African"}, + {"ETH", "ET", "Ethiopian Airlines", "Ethiopian"}, + {"MSR", "MS", "EgyptAir", "EgyptAir"}, + {"RAM", "AT", "Royal Air Maroc", "Royal Air Maroc"}, + {"KQA", "KQ", "Kenya Airways", "Kenya"}, + {"DAH", "AH", "Air Algerie", "Air Algerie"}, + {"TAR", "TU", "Tunisair", "Tunisair"}, + // Asia-Pacific + {"SIA", "SQ", "Singapore Airlines", "Singapore"}, + {"CPA", "CX", "Cathay Pacific", "Cathay"}, + {"ANA", "NH", "All Nippon Airways", "ANA"}, + {"JAL", "JL", "Japan Airlines", "Japan"}, + {"KAL", "KE", "Korean Air", "Korean"}, + {"AAR", "OZ", "Asiana Airlines", "Asiana"}, + {"CCA", "CA", "Air China", "Air China"}, + {"CES", "MU", "China Eastern Airlines", "China Eastern"}, + {"CSN", "CZ", "China Southern Airlines", "China Southern"}, + {"CHH", "HU", "Hainan Airlines", "Hainan"}, + {"CAL", "CI", "China Airlines", "China Airlines"}, + {"EVA", "BR", "EVA Air", "EVA"}, + {"THA", "TG", "Thai Airways", "Thai"}, + {"MAS", "MH", "Malaysia Airlines", "Malaysia"}, + {"AXM", "AK", "AirAsia", "AirAsia"}, + {"GIA", "GA", "Garuda Indonesia", "Garuda"}, + {"AIC", "AI", "Air India", "Air India"}, + {"IGO", "6E", "IndiGo", "IndiGo"}, + {"PAL", "PR", "Philippine Airlines", "Philippine"}, + {"CEB", "5J", "Cebu Pacific", "Cebu Pacific"}, + {"VJC", "VJ", "VietJet Air", "VietJet"}, + {"HVN", "VN", "Vietnam Airlines", "Vietnam"}, + {"QFA", "QF", "Qantas", "Qantas"}, + {"VOZ", "VA", "Virgin Australia", "Virgin Australia"}, + {"JST", "JQ", "Jetstar", "Jetstar"}, + {"ANZ", "NZ", "Air New Zealand", "Air New Zealand"}, + // Latin America + {"LAN", "LA", "LATAM Airlines", "LATAM"}, + {"TAM", "JJ", "LATAM Brasil", "LATAM"}, + {"GLO", "G3", "Gol", "Gol"}, + {"AZU", "AD", "Azul", "Azul"}, + {"ARG", "AR", "Aerolineas Argentinas", "Aerolineas"}, + {"AVA", "AV", "Avianca", "Avianca"}, + {"CMP", "CM", "Copa Airlines", "Copa"}, +}; + +constexpr size_t kAirlineCount = sizeof(kAirlines) / sizeof(kAirlines[0]); + +} // namespace + +const Airline* forCallsign(const char* callsign) { + if (callsign == nullptr) { + return nullptr; + } + // Airline callsign = 3-letter ICAO designator + flight number (starts digit). + if (!std::isalpha(static_cast(callsign[0])) || + !std::isalpha(static_cast(callsign[1])) || + !std::isalpha(static_cast(callsign[2])) || + !std::isdigit(static_cast(callsign[3]))) { + return nullptr; + } + + char code[4] = { + static_cast(std::toupper(static_cast(callsign[0]))), + static_cast(std::toupper(static_cast(callsign[1]))), + static_cast(std::toupper(static_cast(callsign[2]))), + '\0'}; + + for (size_t i = 0; i < kAirlineCount; ++i) { + if (std::strcmp(kAirlines[i].icao, code) == 0) { + return &kAirlines[i]; + } + } + return nullptr; +} + +} // namespace data::airlines diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index 84be21bb7..4c03e5c10 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -8,6 +8,7 @@ #include #include "config.h" +#include "data/airlines.h" namespace services::adsb { @@ -189,6 +190,8 @@ void formatAltitudeTag(const JsonObject& plane, char* out, size_t out_len) { void fillTagFields(Aircraft* ac, const JsonObject& plane) { copyJsonStringTrimmed(plane, "flight", ac->callsign, sizeof(ac->callsign)); + // Resolve the airline from the flight callsign before any hex fallback. + ac->airline = data::airlines::forCallsign(ac->callsign); if (ac->callsign[0] == '\0') { copyJsonStringTrimmed(plane, "hex", ac->callsign, sizeof(ac->callsign)); } diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index 2fa8b7233..b33f322ce 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -84,6 +84,30 @@ char s_runways_checkbox_attrs[32] = "type=\"checkbox\""; WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T", 2, s_runways_checkbox_attrs, WFM_LABEL_AFTER); +// Airline label selector. Custom-HTML-only param renders a "); + for (uint8_t i = 0; i < 3 && n > 0 && + n < static_cast(sizeof(s_airline_select_html)); + ++i) { + n += snprintf(s_airline_select_html + n, sizeof(s_airline_select_html) - n, + "", i, + i == cur ? " selected" : "", options[i]); + } + if (n > 0 && n < static_cast(sizeof(s_airline_select_html))) { + snprintf(s_airline_select_html + n, sizeof(s_airline_select_html) - n, + ""); + } +} + void refreshPortalParamDefaults() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -97,6 +121,7 @@ void refreshPortalParamDefaults() { snprintf(s_runways_checkbox_attrs, sizeof(s_runways_checkbox_attrs), "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); + buildAirlineSelectHtml(); } void onPortalParamsSaved() { @@ -106,6 +131,10 @@ void onPortalParamsSaved() { } ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); + if (s_wm.server) { + ui::radar::saveAirlineDisplayFromPortal( + s_wm.server->arg("airline_mode").c_str()); + } } void attachPortalParams(WiFiManager& wm) { @@ -114,6 +143,7 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_lon); wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); + wm.addParameter(&s_param_airline); wm.setSaveParamsCallback(onPortalParamsSaved); } diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index b0d333997..6da9c374b 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -378,37 +378,57 @@ void applyTagStyle() { } } -int measureTagBlockWidth(const services::adsb::Aircraft& plane) { - applyTagStyle(); - int max_w = 0; - if (plane.callsign[0] != '\0') { - const int w = s_draw->textWidth(plane.callsign); - if (w > max_w) { - max_w = w; +struct TagLine { + const char* text; + uint16_t color; +}; + +constexpr int kMaxTagLines = 4; + +// Builds the tag lines for an aircraft into out_lines. The airline line is shown +// (full name or friendly abbreviation) only when enabled and resolved. +int buildTagLines(const services::adsb::Aircraft& plane, TagLine* out_lines) { + int n = 0; + const ui::radar::AirlineDisplay mode = ui::radar::airlineDisplay(); + if (mode != ui::radar::AirlineDisplay::kNone && plane.airline != nullptr) { + const char* text = (mode == ui::radar::AirlineDisplay::kAbbrev) + ? plane.airline->short_name + : plane.airline->name; + if (text != nullptr && text[0] != '\0') { + out_lines[n++] = {text, radar::kColorLabel}; } } + if (plane.callsign[0] != '\0') { + out_lines[n++] = {plane.callsign, radar::kColorLabel}; + } if (plane.type[0] != '\0') { - const int w = s_draw->textWidth(plane.type); - if (w > max_w) { - max_w = w; - } + out_lines[n++] = {plane.type, radar::kColorTagType}; } if (plane.alt[0] != '\0') { - const int w = s_draw->textWidth(plane.alt); - if (w > max_w) { - max_w = w; - } + out_lines[n++] = {plane.alt, radar::kColorTagAltitude}; } - return max_w; + return n; } void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { initTagLabelMetrics(); applyTagStyle(); + TagLine lines[kMaxTagLines]; + const int n_lines = buildTagLines(plane, lines); + if (n_lines == 0) { + return; + } + const int line_h = s_draw->fontHeight(); - const int block_w = measureTagBlockWidth(plane); - const int block_h = line_h * 3; + int block_w = 0; + for (int i = 0; i < n_lines; ++i) { + const int w = s_draw->textWidth(lines[i].text); + if (w > block_w) { + block_w = w; + } + } + const int block_h = line_h * n_lines; int ly = y - block_h / 2; const int symbol_half = @@ -427,21 +447,10 @@ void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { } ly = std::max(1, std::min(ly, radar::kSize - block_h - 1)); - if (plane.callsign[0] != '\0') { - s_draw->setTextColor(radar::kColorLabel, radar::kColorBackground); - s_draw->drawString(plane.callsign, anchor_x, ly); - } - ly += line_h; - - if (plane.type[0] != '\0') { - s_draw->setTextColor(radar::kColorTagType, radar::kColorBackground); - s_draw->drawString(plane.type, anchor_x, ly); - } - ly += line_h; - - if (plane.alt[0] != '\0') { - s_draw->setTextColor(radar::kColorTagAltitude, radar::kColorBackground); - s_draw->drawString(plane.alt, anchor_x, ly); + for (int i = 0; i < n_lines; ++i) { + s_draw->setTextColor(lines[i].color, radar::kColorBackground); + s_draw->drawString(lines[i].text, anchor_x, ly); + ly += line_h; } } diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index d8bf002e2..7c1f9c3c4 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace ui::radar { @@ -15,6 +16,7 @@ constexpr char kPrefsNamespace[] = "planeradar"; constexpr char kPrefsRangeKey[] = "rangeIdx"; constexpr char kPrefsMilesKey[] = "useMiles"; constexpr char kPrefsRunwaysKey[] = "showRwys"; +constexpr char kPrefsAirlineKey[] = "airlnDisp"; constexpr uint8_t kDefaultRangeIndex = 1; // 10 km ring constexpr float kKmPerMile = 1.609344f; @@ -22,6 +24,7 @@ Preferences s_prefs; uint8_t s_range_index = kDefaultRangeIndex; bool s_use_miles = false; bool s_show_runways = true; +AirlineDisplay s_airline_display = AirlineDisplay::kNone; void saveRangeIndex() { if (!s_prefs.begin(kPrefsNamespace, false)) { @@ -47,6 +50,14 @@ void saveShowRunways() { s_prefs.end(); } +void saveAirlineDisplay() { + if (!s_prefs.begin(kPrefsNamespace, false)) { + return; + } + s_prefs.putUChar(kPrefsAirlineKey, static_cast(s_airline_display)); + s_prefs.end(); +} + bool portalCheckboxChecked(const char* value) { if (value == nullptr || value[0] == '\0') { return false; @@ -70,6 +81,11 @@ void rangeInit() { (saved < kRangePresetCount) ? saved : kDefaultRangeIndex; s_use_miles = s_prefs.getBool(kPrefsMilesKey, false); s_show_runways = s_prefs.getBool(kPrefsRunwaysKey, true); + const uint8_t airline_mode = s_prefs.getUChar( + kPrefsAirlineKey, static_cast(AirlineDisplay::kNone)); + s_airline_display = (airline_mode <= static_cast(AirlineDisplay::kAbbrev)) + ? static_cast(airline_mode) + : AirlineDisplay::kNone; s_prefs.end(); } @@ -93,6 +109,8 @@ bool useMiles() { return s_use_miles; } bool showRunways() { return s_show_runways; } +AirlineDisplay airlineDisplay() { return s_airline_display; } + void saveMilesFromPortal(const char* checkbox_value) { s_use_miles = portalCheckboxChecked(checkbox_value); saveUseMiles(); @@ -105,6 +123,20 @@ void saveRunwaysFromPortal(const char* checkbox_value) { Serial.printf("Runway overlay: %s\n", s_show_runways ? "on" : "off"); } +void saveAirlineDisplayFromPortal(const char* select_value) { + uint8_t mode = static_cast(AirlineDisplay::kNone); + if (select_value != nullptr && select_value[0] != '\0') { + const long v = strtol(select_value, nullptr, 10); + if (v >= 0 && v <= static_cast(AirlineDisplay::kAbbrev)) { + mode = static_cast(v); + } + } + s_airline_display = static_cast(mode); + saveAirlineDisplay(); + const char* labels[] = {"none", "full name", "abbreviation"}; + Serial.printf("Airline display: %s\n", labels[mode]); +} + void formatRing3Label(char* buf, size_t len, float ring3_km, bool use_miles) { if (use_miles) { const int mi = static_cast(lroundf(ring3_km / kKmPerMile)); @@ -122,9 +154,11 @@ void formatCurrentRing3Label(char* buf, size_t len) { void unitsReset() { s_use_miles = false; s_show_runways = true; + s_airline_display = AirlineDisplay::kNone; if (s_prefs.begin(kPrefsNamespace, false)) { s_prefs.remove(kPrefsMilesKey); s_prefs.remove(kPrefsRunwaysKey); + s_prefs.remove(kPrefsAirlineKey); s_prefs.end(); } }