feat(adsb): add IATA↔ICAO airline code translation for ACARS cross-linking

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 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-20 16:39:00 -05:00
parent 15d5cb2272
commit 130f58d9cc
4 changed files with 274 additions and 10 deletions

View File

@@ -1186,9 +1186,12 @@ def get_aircraft_messages(icao: str):
aircraft = app_module.adsb_aircraft.get(icao.upper()) aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None callsign = aircraft.get('callsign') if aircraft else None
registration = aircraft.get('registration') if aircraft else None
from utils.flight_correlator import get_flight_correlator 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 # Backfill translation on messages missing label_description
try: try:

View File

@@ -2809,6 +2809,7 @@ sudo make install</code>
renderAircraftList(); renderAircraftList();
showAircraftDetails(icao); showAircraftDetails(icao);
updateFlightLookupBtn(); updateFlightLookupBtn();
highlightSidebarMessages(icao);
const ac = aircraft[icao]; const ac = aircraft[icao];
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
@@ -2816,6 +2817,24 @@ sudo make install</code>
} }
} }
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) { function showAircraftDetails(icao) {
const ac = aircraft[icao]; const ac = aircraft[icao];
const container = document.getElementById('selectedInfo'); const container = document.getElementById('selectedInfo');
@@ -3757,14 +3776,56 @@ sudo make install</code>
// Track which aircraft have ACARS messages (by ICAO) // Track which aircraft have ACARS messages (by ICAO)
const acarsAircraftIcaos = new Set(); 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) { function findAircraftIcaoByFlight(flight) {
if (!flight || flight === 'UNKNOWN') return null; if (!flight || flight === 'UNKNOWN') return null;
const upper = flight.trim().toUpperCase(); const upper = flight.trim().toUpperCase();
for (const [icao, ac] of Object.entries(aircraft)) { // Build candidate list: original + translated variants
if ((ac.callsign || '').trim().toUpperCase() === upper) return icao; 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; return null;
} }
@@ -3790,10 +3851,13 @@ sudo make install</code>
const matchedIcao = findAircraftIcaoByFlight(flight) || const matchedIcao = findAircraftIcaoByFlight(flight) ||
findAircraftIcaoByFlight(data.tail) || findAircraftIcaoByFlight(data.tail) ||
findAircraftIcaoByFlight(data.reg) || findAircraftIcaoByFlight(data.reg) ||
(data.icao ? data.icao.toUpperCase() : null); (data.icao && aircraft[data.icao.toUpperCase()] ? data.icao.toUpperCase() : null);
if (matchedIcao) acarsAircraftIcaos.add(matchedIcao); 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 // Make clickable if we have a matching aircraft
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' + msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' +
(matchedIcao ? ' cursor: pointer;' : ''); (matchedIcao ? ' cursor: pointer;' : '');
@@ -4278,9 +4342,23 @@ sudo make install</code>
}); });
const label = flight || src || (avlc.frame_type || 'VDL2'); 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 ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
msg.innerHTML = ` msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;"> <div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}${linkIcon}</span>
<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--text-muted);">${time}</span>
</div> </div>
<div style="color: var(--text-dim); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <div style="color: var(--text-dim); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@@ -4288,7 +4366,12 @@ sudo make install</code>
</div> </div>
`; `;
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); container.insertBefore(msg, container.firstChild);

161
utils/airline_codes.py Normal file
View File

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

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import time import time
from collections import deque from collections import deque
from utils.airline_codes import expand_search_terms, translate_flight
class FlightCorrelator: class FlightCorrelator:
"""Correlate ACARS and VDL2 messages with ADS-B aircraft.""" """Correlate ACARS and VDL2 messages with ADS-B aircraft."""
@@ -26,7 +28,10 @@ class FlightCorrelator:
}) })
def get_messages_for_aircraft( 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]]: ) -> dict[str, list[dict]]:
"""Match ACARS/VDL2 messages by callsign, flight, or registration fields.""" """Match ACARS/VDL2 messages by callsign, flight, or registration fields."""
if not icao and not callsign: if not icao and not callsign:
@@ -37,6 +42,11 @@ class FlightCorrelator:
search_terms.add(callsign.strip().upper()) search_terms.add(callsign.strip().upper())
if icao: if icao:
search_terms.add(icao.strip().upper()) 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 = [] acars = []
for msg in self._acars_messages: for msg in self._acars_messages:
@@ -55,8 +65,15 @@ class FlightCorrelator:
"""Check if any identifying field in msg matches the search terms.""" """Check if any identifying field in msg matches the search terms."""
for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'): for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'):
val = msg.get(field) 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 return True
# Also try translating the message field value
for translated in translate_flight(upper_val):
if translated in terms:
return True
return False return False
@staticmethod @staticmethod