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