mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 15:33:32 -07:00
feat(acars): add message translator and ADS-B datalink integration
Add ACARS label translation, message classification, and field parsers so decoded messages show human-readable descriptions instead of raw label codes (H1, DF, _d, 5Z, etc.). Integrate translated ACARS messages into the ADS-B aircraft detail panel and add a live message feed to the standalone ACARS mode. - New utils/acars_translator.py with ~50 label codes, type classifier, and parsers for position reports, engine data, weather, and OOOI - Enrich messages at ingest in routes/acars.py with translation fields - Backfill translation in /adsb/aircraft/<icao>/messages endpoint - ADS-B dashboard: DATALINK MESSAGES section in aircraft detail panel with auto-refresh, color-coded type badges, and parsed field display - Standalone ACARS mode: scrollable live message feed (max 30 cards) - Fix default N. America ACARS frequencies to 131.550/130.025/129.125 - Unit tests covering all translator functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
"""ACARS message translator — label lookup, classification, and field parsers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Common ACARS label codes → human-readable descriptions
|
||||
# Sources: ARINC 618, ARINC 620, airline implementations
|
||||
ACARS_LABELS: dict[str, str] = {
|
||||
# Position & navigation
|
||||
'H1': 'Position report (HF data link)',
|
||||
'H2': 'Weather report',
|
||||
'5Z': 'OOOI (gate times)',
|
||||
'15': 'Departure report',
|
||||
'16': 'Arrival report',
|
||||
'20': 'Position report',
|
||||
'22': 'Fuel report',
|
||||
'2Z': 'Off-gate report',
|
||||
'30': 'Progress report',
|
||||
'44': 'Weather request',
|
||||
'80': 'Free text (3-char header)',
|
||||
'83': 'Free text',
|
||||
'8E': 'ATIS request',
|
||||
|
||||
# Engine & performance
|
||||
'DF': 'Engine data / DFDR',
|
||||
'D3': 'Engine exceedance',
|
||||
'D6': 'Engine trend data',
|
||||
|
||||
# ATS / air traffic services
|
||||
'B1': 'ATC request',
|
||||
'B2': 'ATC clearance',
|
||||
'B3': 'ATC comm test',
|
||||
'B6': 'ATC departure clearance',
|
||||
'B9': 'ATC message',
|
||||
'BA': 'ATC advisory',
|
||||
'BB': 'ATC response',
|
||||
|
||||
# CPDLC (Controller-Pilot Data Link Communications)
|
||||
'AA': 'CPDLC message',
|
||||
'AB': 'CPDLC response',
|
||||
'A0': 'CPDLC uplink',
|
||||
'A1': 'CPDLC downlink',
|
||||
'A2': 'CPDLC connection request',
|
||||
'A3': 'CPDLC logon/logoff',
|
||||
'A6': 'CPDLC message',
|
||||
'A7': 'CPDLC response',
|
||||
'AT': 'CPDLC transfer',
|
||||
|
||||
# Handshake & link management
|
||||
'_d': 'Demand mode (link test)',
|
||||
'Q0': 'Link test',
|
||||
'QA': 'Link test reply',
|
||||
'QB': 'Acknowledgement',
|
||||
'QC': 'Link request',
|
||||
'QD': 'Link accept',
|
||||
'QE': 'Link reject',
|
||||
'QF': 'Squitter / heartbeat',
|
||||
'QG': 'Abort',
|
||||
'QH': 'Version request',
|
||||
'QK': 'Mode change',
|
||||
'QM': 'Link verification',
|
||||
'QN': 'Media advisory',
|
||||
'QP': 'Polling',
|
||||
'QQ': 'Status',
|
||||
'QR': 'General response',
|
||||
'QS': 'System table request',
|
||||
'QT': 'System table',
|
||||
'QX': 'Frequency change',
|
||||
|
||||
# Squawk & surveillance
|
||||
'SQ': 'Squawk assignment',
|
||||
'SA': 'Surveillance data',
|
||||
'S1': 'ADS-C report',
|
||||
|
||||
# Airline operations
|
||||
'C1': 'Crew scheduling',
|
||||
'C2': 'Crew response',
|
||||
'C3': 'Crew message',
|
||||
'C4': 'Crew query',
|
||||
'10': 'Delay message',
|
||||
'12': 'Clearance request',
|
||||
'17': 'Cargo/load data',
|
||||
'4T': 'TWIP (terminal weather)',
|
||||
'4X': 'Connectivity test',
|
||||
'50': 'Weather observation',
|
||||
'51': 'METAR/TAF request',
|
||||
'52': 'METAR/TAF response',
|
||||
'54': 'SIGMET / AIRMET',
|
||||
'70': 'Maintenance report',
|
||||
'7A': 'Fault message',
|
||||
'7B': 'Fault clear',
|
||||
'F3': 'Flight plan',
|
||||
'F5': 'Flight plan amendment',
|
||||
'F6': 'Route request',
|
||||
'F7': 'Route clearance',
|
||||
'RA': 'ATIS report',
|
||||
'RB': 'ATIS request',
|
||||
}
|
||||
|
||||
# Message type classification for UI colour coding
|
||||
MESSAGE_TYPES = {
|
||||
'position', 'engine_data', 'weather', 'ats', 'handshake',
|
||||
'oooi', 'squawk', 'link_test', 'cpdlc', 'other',
|
||||
}
|
||||
|
||||
|
||||
def translate_label(label: str | None) -> str:
|
||||
"""Return human-readable description for an ACARS label code."""
|
||||
if not label:
|
||||
return 'Unknown label'
|
||||
label = label.strip()
|
||||
if label in ACARS_LABELS:
|
||||
return ACARS_LABELS[label]
|
||||
# Check for Q-prefix group
|
||||
if len(label) == 2 and label.startswith('Q'):
|
||||
return f'Link management ({label})'
|
||||
return f'Label {label}'
|
||||
|
||||
|
||||
def classify_message_type(label: str | None, text: str | None = None) -> str:
|
||||
"""Classify an ACARS message into a canonical type for UI display."""
|
||||
if not label:
|
||||
return 'other'
|
||||
label = label.strip()
|
||||
|
||||
# Position reports
|
||||
if label in ('H1', '20', '15', '16', '30', 'S1'):
|
||||
return 'position'
|
||||
if label == 'H1' or (text and '#M1BPOS' in text):
|
||||
return 'position'
|
||||
|
||||
# Engine / DFDR data
|
||||
if label in ('DF', 'D3', 'D6'):
|
||||
return 'engine_data'
|
||||
|
||||
# Weather
|
||||
if label in ('H2', '44', '50', '51', '52', '54', '4T'):
|
||||
return 'weather'
|
||||
|
||||
# ATS / ATC
|
||||
if label.startswith('B') and len(label) == 2:
|
||||
return 'ats'
|
||||
|
||||
# CPDLC
|
||||
if label in ('AA', 'AB', 'A0', 'A1', 'A2', 'A3', 'A6', 'A7', 'AT'):
|
||||
return 'cpdlc'
|
||||
|
||||
# OOOI (Out/Off/On/In gate times)
|
||||
if label in ('5Z', '2Z'):
|
||||
return 'oooi'
|
||||
|
||||
# Squawk
|
||||
if label in ('SQ', 'SA'):
|
||||
return 'squawk'
|
||||
|
||||
# Link test / handshake
|
||||
if label in ('_d', 'Q0', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG',
|
||||
'QH', 'QK', 'QM', 'QN', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QX',
|
||||
'4X'):
|
||||
return 'link_test'
|
||||
|
||||
# Handshake (_d is demand mode)
|
||||
if label == '_d':
|
||||
return 'handshake'
|
||||
|
||||
return 'other'
|
||||
|
||||
|
||||
def parse_position_report(text: str | None) -> dict | None:
|
||||
"""Parse H1 / #M1BPOS position report fields.
|
||||
|
||||
Example format:
|
||||
#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C
|
||||
Lat/Lon: N42411W086034 (N42.411 W086.034)
|
||||
Waypoint: CSG
|
||||
Time: 070852Z
|
||||
FL: 340
|
||||
Next waypoint coords, destination, ETA
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
result: dict = {}
|
||||
|
||||
# Look for BPOS block
|
||||
bpos_match = re.search(
|
||||
r'#M\d[A-Z]*POS'
|
||||
r'([NS])(\d{2,5})([EW])(\d{3,6})'
|
||||
r',([^,]*),(\d{4,6})'
|
||||
r',(\d{2,3})'
|
||||
r'(?:,([NS]\d{2,5}[EW]\d{3,6}))?'
|
||||
r'(?:,([A-Z]{3,4}))?',
|
||||
text
|
||||
)
|
||||
if bpos_match:
|
||||
lat_dir, lat_val, lon_dir, lon_val = bpos_match.group(1, 2, 3, 4)
|
||||
# Convert to decimal degrees
|
||||
if len(lat_val) >= 4:
|
||||
lat_deg = int(lat_val[:2])
|
||||
lat_min = int(lat_val[2:]) / (10 ** (len(lat_val) - 2)) * 60
|
||||
lat = lat_deg + lat_min / 60
|
||||
else:
|
||||
lat = float(lat_val)
|
||||
if lat_dir == 'S':
|
||||
lat = -lat
|
||||
|
||||
if len(lon_val) >= 5:
|
||||
lon_deg = int(lon_val[:3])
|
||||
lon_min = int(lon_val[3:]) / (10 ** (len(lon_val) - 3)) * 60
|
||||
lon = lon_deg + lon_min / 60
|
||||
else:
|
||||
lon = float(lon_val)
|
||||
if lon_dir == 'W':
|
||||
lon = -lon
|
||||
|
||||
result['lat'] = round(lat, 4)
|
||||
result['lon'] = round(lon, 4)
|
||||
result['waypoint'] = bpos_match.group(5).strip() if bpos_match.group(5) else None
|
||||
result['time'] = bpos_match.group(6)
|
||||
result['flight_level'] = f"FL{bpos_match.group(7)}"
|
||||
if bpos_match.group(9):
|
||||
result['destination'] = bpos_match.group(9)
|
||||
|
||||
# Look for temperature (e.g., /TS-045 or M045)
|
||||
temp_match = re.search(r'/TS([MP]?)(\d{2,3})', text)
|
||||
if temp_match:
|
||||
sign = '-' if temp_match.group(1) == 'M' else ''
|
||||
result['temperature'] = f"{sign}{temp_match.group(2)} C"
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def parse_engine_data(text: str | None) -> dict | None:
|
||||
"""Parse DF (engine/DFDR) messages.
|
||||
|
||||
Common format: #DFB followed by KEY/VALUE pairs.
|
||||
Keys: SM (source mode), AC0/AC1 (engine 1/2 N2), FL (flight level),
|
||||
FU (fuel used), ES (EGT spread), BA (bleed air), CO (config), AO (auto)
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
result: dict = {}
|
||||
engine_keys = {
|
||||
'SM': 'Source mode',
|
||||
'AC0': 'Eng 1 N2 (%)',
|
||||
'AC1': 'Eng 2 N2 (%)',
|
||||
'FL': 'Flight level',
|
||||
'FU': 'Fuel used (lbs)',
|
||||
'ES': 'EGT spread',
|
||||
'BA': 'Bleed air',
|
||||
'CO': 'Config',
|
||||
'AO': 'Auto',
|
||||
'EGT': 'Exhaust gas temp',
|
||||
'OIT': 'Oil temp',
|
||||
'OIP': 'Oil pressure',
|
||||
'N1': 'N1 (%)',
|
||||
'N2': 'N2 (%)',
|
||||
'FF': 'Fuel flow',
|
||||
'VIB': 'Vibration',
|
||||
}
|
||||
|
||||
# Match KEY/VALUE or KEY VALUE patterns
|
||||
for key, desc in engine_keys.items():
|
||||
pattern = rf'\b{re.escape(key)}[/: ]?\s*([+-]?\d+\.?\d*)'
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
result[key] = {'value': m.group(1), 'description': desc}
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def parse_weather_data(text: str | None) -> dict | None:
|
||||
"""Parse weather report fields (/WX blocks, METAR-like data)."""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
result: dict = {}
|
||||
|
||||
# Wind: direction/speed (e.g., 270/15 or WND270015)
|
||||
wind_match = re.search(r'(?:WND|WIND)\s*(\d{3})[/ ]?(\d{2,3})', text)
|
||||
if wind_match:
|
||||
result['wind_dir'] = f"{wind_match.group(1)} deg"
|
||||
result['wind_speed'] = f"{wind_match.group(2)} kts"
|
||||
|
||||
# Airport codes (3-4 letter ICAO)
|
||||
airports = re.findall(r'\b([A-Z]{3,4})\b', text)
|
||||
if airports:
|
||||
result['airports'] = list(dict.fromkeys(airports))[:4]
|
||||
|
||||
# Temperature (e.g., T24/D18, TMP24, TEMP -5)
|
||||
temp_match = re.search(r'(?:TMP|TEMP|T)\s*([MP+-]?\d{1,3})', text)
|
||||
if temp_match:
|
||||
val = temp_match.group(1).replace('M', '-').replace('P', '')
|
||||
result['temperature'] = f"{val} C"
|
||||
|
||||
# Visibility
|
||||
vis_match = re.search(r'VIS\s*(\d+(?:\.\d+)?)', text)
|
||||
if vis_match:
|
||||
result['visibility'] = f"{vis_match.group(1)} SM"
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def parse_oooi(text: str | None) -> dict | None:
|
||||
"""Parse 5Z OOOI (Out/Off/On/In) gate time messages.
|
||||
|
||||
Typical format: origin destination OUT OFF ON IN
|
||||
e.g., KJFK KLAX 1423 1435 1812 1824
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
result: dict = {}
|
||||
|
||||
# Try to find airport pair + 4 time blocks
|
||||
oooi_match = re.search(
|
||||
r'([A-Z]{3,4})\s+([A-Z]{3,4})\s+(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})',
|
||||
text
|
||||
)
|
||||
if oooi_match:
|
||||
result['origin'] = oooi_match.group(1)
|
||||
result['destination'] = oooi_match.group(2)
|
||||
result['out'] = oooi_match.group(3)
|
||||
result['off'] = oooi_match.group(4)
|
||||
result['on'] = oooi_match.group(5)
|
||||
result['in'] = oooi_match.group(6)
|
||||
return result
|
||||
|
||||
# Try partial (just origin/destination and some times)
|
||||
partial = re.search(r'([A-Z]{3,4})\s+([A-Z]{3,4})', text)
|
||||
if partial:
|
||||
result['origin'] = partial.group(1)
|
||||
result['destination'] = partial.group(2)
|
||||
|
||||
times = re.findall(r'\b(\d{4})\b', text)
|
||||
labels = ['out', 'off', 'on', 'in']
|
||||
for i, t in enumerate(times[:4]):
|
||||
result[labels[i]] = t
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def translate_message(msg: dict) -> dict:
|
||||
"""Translate an ACARS message dict, returning enrichment fields.
|
||||
|
||||
Args:
|
||||
msg: Raw ACARS message dict with 'label', 'text'/'msg' fields.
|
||||
|
||||
Returns:
|
||||
Dict with 'label_description', 'message_type', 'parsed'.
|
||||
"""
|
||||
label = msg.get('label')
|
||||
text = msg.get('text') or msg.get('msg') or ''
|
||||
|
||||
label_description = translate_label(label)
|
||||
message_type = classify_message_type(label, text)
|
||||
|
||||
parsed: dict | None = None
|
||||
if message_type == 'position' or (label == 'H1' and 'POS' in text.upper()):
|
||||
parsed = parse_position_report(text)
|
||||
elif message_type == 'engine_data':
|
||||
parsed = parse_engine_data(text)
|
||||
elif message_type == 'weather':
|
||||
parsed = parse_weather_data(text)
|
||||
elif message_type == 'oooi':
|
||||
parsed = parse_oooi(text)
|
||||
|
||||
return {
|
||||
'label_description': label_description,
|
||||
'message_type': message_type,
|
||||
'parsed': parsed,
|
||||
}
|
||||
Reference in New Issue
Block a user