From 130f58d9ccf689876016c42e7c53adc85d99f760 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Fri, 20 Feb 2026 16:39:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(adsb):=20add=20IATA=E2=86=94ICAO=20airline?= =?UTF-8?q?=20code=20translation=20for=20ACARS=20cross-linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACARS messages use IATA codes (e.g. UA2412) while ADS-B uses ICAO callsigns (e.g. UAL2412). Add a translation layer so the two can match, enabling click-to-highlight and datalink message correlation. Co-Authored-By: Claude Opus 4.6 --- routes/adsb.py | 5 +- templates/adsb_dashboard.html | 97 ++++++++++++++++++-- utils/airline_codes.py | 161 ++++++++++++++++++++++++++++++++++ utils/flight_correlator.py | 21 ++++- 4 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 utils/airline_codes.py diff --git a/routes/adsb.py b/routes/adsb.py index 0cdfd6d..482456b 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -1186,9 +1186,12 @@ def get_aircraft_messages(icao: str): aircraft = app_module.adsb_aircraft.get(icao.upper()) callsign = aircraft.get('callsign') if aircraft else None + registration = aircraft.get('registration') if aircraft else None from utils.flight_correlator import get_flight_correlator - messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign) + messages = get_flight_correlator().get_messages_for_aircraft( + icao=icao.upper(), callsign=callsign, registration=registration + ) # Backfill translation on messages missing label_description try: diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 1c2a01d..4b84c9b 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -2809,6 +2809,7 @@ sudo make install renderAircraftList(); showAircraftDetails(icao); updateFlightLookupBtn(); + highlightSidebarMessages(icao); const ac = aircraft[icao]; if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { @@ -2816,6 +2817,24 @@ sudo make install } } + function highlightSidebarMessages(icao) { + // Highlight ACARS/VDL2 sidebar messages matching the selected aircraft + const containers = ['acarsMessages', 'vdl2Messages']; + containers.forEach(containerId => { + const container = document.getElementById(containerId); + if (!container) return; + for (const item of container.children) { + if (item.dataset.icao === icao) { + item.style.borderLeft = '3px solid var(--accent-cyan)'; + item.style.background = 'rgba(0, 212, 255, 0.08)'; + } else { + item.style.borderLeft = ''; + item.style.background = ''; + } + } + }); + } + function showAircraftDetails(icao) { const ac = aircraft[icao]; const container = document.getElementById('selectedInfo'); @@ -3757,14 +3776,56 @@ sudo make install // Track which aircraft have ACARS messages (by ICAO) const acarsAircraftIcaos = new Set(); + // IATA (2-letter) → ICAO (3-letter) airline code mapping + const IATA_TO_ICAO = { + 'AA':'AAL','DL':'DAL','UA':'UAL','WN':'SWA','B6':'JBU','AS':'ASA', + 'NK':'NKS','F9':'FFT','G4':'AAY','HA':'HAL','SY':'SCX','WS':'WJA', + 'AC':'ACA','WG':'WGN','TS':'TSC','PD':'POE','MX':'MXA','QX':'QXE', + 'OH':'COM','OO':'SKW','YX':'RPA','9E':'FLG','PT':'SWQ','MQ':'ENY', + 'YV':'ASH','AX':'LOF','ZW':'AWI','G7':'GJS','EV':'ASQ', + 'AM':'AMX','VB':'VIV','4O':'AIJ','Y4':'VOI', + '5X':'UPS','FX':'FDX', + 'BA':'BAW','LH':'DLH','AF':'AFR','KL':'KLM','IB':'IBE','AZ':'ITY', + 'SK':'SAS','AY':'FIN','OS':'AUA','LX':'SWR','SN':'BEL','TP':'TAP', + 'EI':'EIN','U2':'EZY','FR':'RYR','W6':'WZZ','VY':'VLG','PC':'PGT', + 'TK':'THY','LO':'LOT','BT':'BTI','DY':'NAX','VS':'VIR','EW':'EWG', + 'SQ':'SIA','CX':'CPA','QF':'QFA','JL':'JAL','NH':'ANA','KE':'KAL', + 'OZ':'AAR','CI':'CAL','BR':'EVA','CZ':'CSN','MU':'CES','CA':'CCA', + 'AI':'AIC','GA':'GIA','TG':'THA','MH':'MAS','PR':'PAL','VN':'HVN', + 'NZ':'ANZ','3K':'JSA','JQ':'JST','AK':'AXM','TR':'TGW','5J':'CEB', + 'EK':'UAE','QR':'QTR','EY':'ETD','GF':'GFA','SV':'SVA', + 'ET':'ETH','MS':'MSR','SA':'SAA','RJ':'RJA','WY':'OMA', + 'LA':'LAN','G3':'GLO','AD':'AZU','AV':'AVA','CM':'CMP','AR':'ARG' + }; + const ICAO_TO_IATA = Object.fromEntries(Object.entries(IATA_TO_ICAO).map(([k,v]) => [v,k])); + + function translateFlight(flight) { + if (!flight) return []; + const m = flight.match(/^([A-Z0-9]{2,3})(\d+[A-Z]?)$/); + if (!m) return []; + const [, prefix, num] = m; + const results = []; + if (IATA_TO_ICAO[prefix]) results.push(IATA_TO_ICAO[prefix] + num); + if (ICAO_TO_IATA[prefix]) results.push(ICAO_TO_IATA[prefix] + num); + return results; + } + function findAircraftIcaoByFlight(flight) { if (!flight || flight === 'UNKNOWN') return null; const upper = flight.trim().toUpperCase(); - for (const [icao, ac] of Object.entries(aircraft)) { - if ((ac.callsign || '').trim().toUpperCase() === upper) return icao; + // Build candidate list: original + translated variants + const candidates = [upper, ...translateFlight(upper)]; + for (const candidate of candidates) { + for (const [icao, ac] of Object.entries(aircraft)) { + const cs = (ac.callsign || '').trim().toUpperCase(); + if (cs === candidate) return icao; + // Also match by registration (tail number) + const reg = (ac.registration || '').trim().toUpperCase(); + if (reg && reg === candidate) return icao; + } + // Also check ICAO hex directly + if (aircraft[candidate]) return candidate; } - // Also check ICAO hex directly - if (aircraft[upper]) return upper; return null; } @@ -3790,10 +3851,13 @@ sudo make install const matchedIcao = findAircraftIcaoByFlight(flight) || findAircraftIcaoByFlight(data.tail) || findAircraftIcaoByFlight(data.reg) || - (data.icao ? data.icao.toUpperCase() : null); + (data.icao && aircraft[data.icao.toUpperCase()] ? data.icao.toUpperCase() : null); if (matchedIcao) acarsAircraftIcaos.add(matchedIcao); + // Tag message with matched ICAO for cross-highlighting + if (matchedIcao) msg.dataset.icao = matchedIcao; + // Make clickable if we have a matching aircraft msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' + (matchedIcao ? ' cursor: pointer;' : ''); @@ -4278,9 +4342,23 @@ sudo make install }); const label = flight || src || (avlc.frame_type || 'VDL2'); + // Try to find matching tracked aircraft (same logic as ACARS) + const matchedIcao = findAircraftIcaoByFlight(flight) || + findAircraftIcaoByFlight(acars.reg) || + (src && aircraft[src.toUpperCase()] ? src.toUpperCase() : null); + + if (matchedIcao) { + acarsAircraftIcaos.add(matchedIcao); + msg.dataset.icao = matchedIcao; + } + + msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' + + (matchedIcao ? ' cursor: pointer;' : ''); + const linkIcon = matchedIcao ? '' : ''; + msg.innerHTML = `
- ${escapeHtml(label)} + ${escapeHtml(label)}${linkIcon} ${time}
@@ -4288,7 +4366,12 @@ sudo make install
`; - msg.addEventListener('click', () => showVdl2Modal(data, time)); + if (matchedIcao) { + msg.addEventListener('click', (e) => { e.stopPropagation(); selectAircraft(matchedIcao); }); + msg.title = 'Click to locate ' + label + ' on map'; + } else { + msg.addEventListener('click', () => showVdl2Modal(data, time)); + } container.insertBefore(msg, container.firstChild); diff --git a/utils/airline_codes.py b/utils/airline_codes.py new file mode 100644 index 0000000..cd47e4e --- /dev/null +++ b/utils/airline_codes.py @@ -0,0 +1,161 @@ +"""IATA ↔ ICAO airline code mapping for flight number translation.""" + +from __future__ import annotations + +import re + +# IATA (2-letter) → ICAO (3-letter) mapping for common airlines +IATA_TO_ICAO: dict[str, str] = { + # North America — Major + "AA": "AAL", # American Airlines + "DL": "DAL", # Delta Air Lines + "UA": "UAL", # United Airlines + "WN": "SWA", # Southwest Airlines + "B6": "JBU", # JetBlue Airways + "AS": "ASA", # Alaska Airlines + "NK": "NKS", # Spirit Airlines + "F9": "FFT", # Frontier Airlines + "G4": "AAY", # Allegiant Air + "HA": "HAL", # Hawaiian Airlines + "SY": "SCX", # Sun Country Airlines + "WS": "WJA", # WestJet + "AC": "ACA", # Air Canada + "WG": "WGN", # Sunwing Airlines + "TS": "TSC", # Air Transat + "PD": "POE", # Porter Airlines + "MX": "MXA", # Breeze Airways + "QX": "QXE", # Horizon Air + "OH": "COM", # PSA Airlines (Compass) + "OO": "SKW", # SkyWest Airlines + "YX": "RPA", # Republic Airways + "9E": "FLG", # Endeavor Air (Pinnacle) + "CP": "CPZ", # Compass Airlines + "PT": "SWQ", # Piedmont Airlines + "MQ": "ENY", # Envoy Air + "YV": "ASH", # Mesa Airlines + "AX": "LOF", # Trans States / GoJet + "ZW": "AWI", # Air Wisconsin + "G7": "GJS", # GoJet Airlines + "EV": "ASQ", # ExpressJet / Atlantic Southeast + "AM": "AMX", # Aeromexico + "VB": "VIV", # VivaAerobus + "4O": "AIJ", # Interjet + "Y4": "VOI", # Volaris + # North America — Cargo + "5X": "UPS", # UPS Airlines + "FX": "FDX", # FedEx Express + # Europe — Major + "BA": "BAW", # British Airways + "LH": "DLH", # Lufthansa + "AF": "AFR", # Air France + "KL": "KLM", # KLM Royal Dutch + "IB": "IBE", # Iberia + "AZ": "ITY", # ITA Airways + "SK": "SAS", # SAS Scandinavian + "AY": "FIN", # Finnair + "OS": "AUA", # Austrian Airlines + "LX": "SWR", # Swiss International + "SN": "BEL", # Brussels Airlines + "TP": "TAP", # TAP Air Portugal + "EI": "EIN", # Aer Lingus + "U2": "EZY", # easyJet + "FR": "RYR", # Ryanair + "W6": "WZZ", # Wizz Air + "VY": "VLG", # Vueling + "PC": "PGT", # Pegasus Airlines + "TK": "THY", # Turkish Airlines + "LO": "LOT", # LOT Polish + "BT": "BTI", # airBaltic + "DY": "NAX", # Norwegian Air Shuttle + "VS": "VIR", # Virgin Atlantic + "EW": "EWG", # Eurowings + # Asia-Pacific — Major + "SQ": "SIA", # Singapore Airlines + "CX": "CPA", # Cathay Pacific + "QF": "QFA", # Qantas + "JL": "JAL", # Japan Airlines + "NH": "ANA", # All Nippon Airways + "KE": "KAL", # Korean Air + "OZ": "AAR", # Asiana Airlines + "CI": "CAL", # China Airlines + "BR": "EVA", # EVA Air + "CZ": "CSN", # China Southern + "MU": "CES", # China Eastern + "CA": "CCA", # Air China + "AI": "AIC", # Air India + "GA": "GIA", # Garuda Indonesia + "TG": "THA", # Thai Airways + "MH": "MAS", # Malaysia Airlines + "PR": "PAL", # Philippine Airlines + "VN": "HVN", # Vietnam Airlines + "NZ": "ANZ", # Air New Zealand + "3K": "JSA", # Jetstar Asia + "JQ": "JST", # Jetstar Airways + "AK": "AXM", # AirAsia + "TR": "TGW", # Scoot + "5J": "CEB", # Cebu Pacific + # Middle East / Africa + "EK": "UAE", # Emirates + "QR": "QTR", # Qatar Airways + "EY": "ETD", # Etihad Airways + "GF": "GFA", # Gulf Air + "SV": "SVA", # Saudia + "ET": "ETH", # Ethiopian Airlines + "MS": "MSR", # EgyptAir + "SA": "SAA", # South African Airways + "RJ": "RJA", # Royal Jordanian + "WY": "OMA", # Oman Air + # South America + "LA": "LAN", # LATAM Airlines + "G3": "GLO", # Gol Transportes Aéreos + "AD": "AZU", # Azul Brazilian Airlines + "AV": "AVA", # Avianca + "CM": "CMP", # Copa Airlines + "AR": "ARG", # Aerolíneas Argentinas + # ACARS-specific addressing codes + "MC": "MCO", # Possible: some ACARS systems use MC +} + +# Build reverse mapping (ICAO → IATA) +ICAO_TO_IATA: dict[str, str] = {v: k for k, v in IATA_TO_ICAO.items()} + +# Regex to split flight number into airline prefix and numeric part +_FLIGHT_RE = re.compile(r'^([A-Z]{2,3})(\d+[A-Z]?)$') + + +def translate_flight(flight: str) -> list[str]: + """Translate a flight number to all possible equivalent forms. + + Given "UA2412" (IATA), returns ["UAL2412"] (ICAO). + Given "UAL2412" (ICAO), returns ["UA2412"] (IATA). + Returns empty list if no translation found. + """ + if not flight: + return [] + + upper = flight.strip().upper() + m = _FLIGHT_RE.match(upper) + if not m: + return [] + + prefix, number = m.group(1), m.group(2) + results = [] + + # Try IATA → ICAO + if prefix in IATA_TO_ICAO: + results.append(IATA_TO_ICAO[prefix] + number) + + # Try ICAO → IATA + if prefix in ICAO_TO_IATA: + results.append(ICAO_TO_IATA[prefix] + number) + + return results + + +def expand_search_terms(terms: set[str]) -> set[str]: + """Expand a set of callsign/flight search terms with translated variants.""" + expanded = set(terms) + for term in list(terms): + for translated in translate_flight(term): + expanded.add(translated) + return expanded diff --git a/utils/flight_correlator.py b/utils/flight_correlator.py index af075a7..a95b4f4 100644 --- a/utils/flight_correlator.py +++ b/utils/flight_correlator.py @@ -5,6 +5,8 @@ from __future__ import annotations import time from collections import deque +from utils.airline_codes import expand_search_terms, translate_flight + class FlightCorrelator: """Correlate ACARS and VDL2 messages with ADS-B aircraft.""" @@ -26,7 +28,10 @@ class FlightCorrelator: }) def get_messages_for_aircraft( - self, icao: str | None = None, callsign: str | None = None + self, + icao: str | None = None, + callsign: str | None = None, + registration: str | None = None, ) -> dict[str, list[dict]]: """Match ACARS/VDL2 messages by callsign, flight, or registration fields.""" if not icao and not callsign: @@ -37,6 +42,11 @@ class FlightCorrelator: search_terms.add(callsign.strip().upper()) if icao: search_terms.add(icao.strip().upper()) + if registration: + search_terms.add(registration.strip().upper()) + + # Expand with IATA↔ICAO airline code translations + search_terms = expand_search_terms(search_terms) acars = [] for msg in self._acars_messages: @@ -55,8 +65,15 @@ class FlightCorrelator: """Check if any identifying field in msg matches the search terms.""" for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'): val = msg.get(field) - if val and str(val).strip().upper() in terms: + if not val: + continue + upper_val = str(val).strip().upper() + if upper_val in terms: return True + # Also try translating the message field value + for translated in translate_flight(upper_val): + if translated in terms: + return True return False @staticmethod