mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -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:
|
||||
|
||||
@@ -2809,6 +2809,7 @@ sudo make install</code>
|
||||
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</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) {
|
||||
const ac = aircraft[icao];
|
||||
const container = document.getElementById('selectedInfo');
|
||||
@@ -3757,14 +3776,56 @@ sudo make install</code>
|
||||
// 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</code>
|
||||
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</code>
|
||||
});
|
||||
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">✈</span>' : '';
|
||||
|
||||
msg.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
|
||||
161
utils/airline_codes.py
Normal file
161
utils/airline_codes.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user