mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-28 16:59:55 +02:00
Add multi-airline ACARS summarizer for readable datalink dossiers.
Parse common position, OOOI, performance, and ops formats across major carriers while hiding VDL binary fragments and duplicate frames from the dossier feed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
"""Heuristics to summarize ACARS/VDL payloads across airlines for dossier display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# --- shared patterns ---
|
||||
|
||||
_ICAO_AIRPORT = re.compile(r"\b([A-Z]{4})\b")
|
||||
_TAIL = re.compile(r"\b(?:N[0-9A-Z]{3,6}|G-[A-Z]{4,5}|[A-Z]-[A-Z]{4,5}|[A-Z]{2}-[A-Z]{3,4})\b")
|
||||
# Major carriers — explicit list avoids matching FL280, GS450, etc.
|
||||
_FLIGHT = re.compile(
|
||||
r"\b(?:"
|
||||
r"WN|SWA|UA|UAL|AA|AAL|DL|DAL|AS|ASA|B6|JBU|NK|NKS|F9|FFT|G4|HA|HAL|SY|MX|"
|
||||
r"FDX|UPS|GTI|ABX|ATN|RCH|CNV|EVAC|SAM|REACH|"
|
||||
r"BA|BAW|AF|AFR|LH|DLH|KL|KLM|QF|QFA|EK|UAE|QR|QTR|TK|THY|AC|ACA|WS|WJA|"
|
||||
r"FR|RYR|U2|EZY|VS|VIR|NH|ANA|JL|JAL|CX|CPA|SQ|SIA|NZ|ANZ|"
|
||||
r"UA|CO|NW|US|HP|TW|VX|AS|QX|OO|YX|MQ|OH|9E|"
|
||||
r"JT|JSA|VA|VOZ|NZ|QF|EK|ET|MS|SU|LO|SK|AY|IB|UX|TP|TAP"
|
||||
r")\d{1,5}\b",
|
||||
re.I,
|
||||
)
|
||||
# IATA flight numbers on FI lines and standalone (e.g. UO614, CX889).
|
||||
_FI_FLIGHT = re.compile(r"\b([A-Z]{2,3}\d{1,4})\b")
|
||||
_NON_FLIGHT_TOKENS = frozenset(
|
||||
{"FL", "FT", "GS", "KT", "RW", "NM", "TD", "TO", "ON", "IN", "OF", "AT", "DA", "AA", "AD"}
|
||||
)
|
||||
_FI_BLOCK = re.compile(
|
||||
r"FI\s+([A-Z0-9]{2,5}\d{1,5})"
|
||||
r"(?:/AN\s+([A-Z0-9\-]+))?"
|
||||
r"(?:/DA\s+([A-Z]{4}))?"
|
||||
r"(?:/(?:AA|AD|DS)\s+([A-Z]{4}))?",
|
||||
re.I,
|
||||
)
|
||||
_AC_TYPE = re.compile(
|
||||
r"\b(?:B\d{3,4}(?:-\d{3}|MAX|ER|LR|F)?|A\d{3,4}(?:-\d{3}|NEO|LR)?|"
|
||||
r"E\d{3}|MD-\d{2}|DC-\d{2}|B77[0-9LWR]?|B78[79]|A35[09]|A33[0-9]|CRJ\d{2,3}|E\d{3})\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# --- message family patterns ---
|
||||
|
||||
_TRACK_HEADER = re.compile(
|
||||
r"^\+\+86501,([^,]+),([^,]+),(\d{6}),([^,]+),([A-Z]{4}),([A-Z]{4})",
|
||||
re.I,
|
||||
)
|
||||
_POS_HEADER = re.compile(r"^POS(N?\d{4,5}[NS]?\d{4,5}[EW]?)", re.I)
|
||||
_POS_COORDS = re.compile(r"^N?(\d{4,5})([NS])(\d{4,6})([EW])", re.I)
|
||||
_WAYPOINT = re.compile(
|
||||
r"^(?:N)?(\d{1,2}\d{2}\.\d),W(\d{1,3}\d{2}\.\d),(\d{6}),(\d+),",
|
||||
re.I,
|
||||
)
|
||||
_PERF_HEADER = re.compile(
|
||||
r"^[\w]+,(\d+),([^,]+),(\d{6}),([^,]+),([A-Z]{4}),([A-Z]{4})",
|
||||
re.I,
|
||||
)
|
||||
_PHASE_SNAPSHOT = re.compile(
|
||||
r"^(\d{2}\.\d{2}\.\d{2}),(CL|CR|DE|TO|LD|ER|GND),[^,]*,[^,]*,[^,]*,[^,]*,[^,]*,[^,]*,"
|
||||
r"N(\d+)\.(\d+),W(\d+)\.(\d+)",
|
||||
re.I,
|
||||
)
|
||||
_TRAJECTORY_HEADER = re.compile(r"^76401\s*$", re.I)
|
||||
_TRAJECTORY_ROUTE = re.compile(r"^02E24([A-Z]{4})([A-Z]{4})\s*$", re.I)
|
||||
_COMPRESSED_WP = re.compile(r"^N(\d{5})W(\d{5})", re.I)
|
||||
_FPN = re.compile(r"^FPN/?", re.I)
|
||||
_OOOI_TIMES = re.compile(r"\b(OUT|OFF|ON|IN)\s*(\d{4,6})\b", re.I)
|
||||
_OOOI_STATUS = re.compile(r"\b(OUT|OFF|ON|IN)\s*,\s*(LO|CL|ON|OF|CLOS)\b", re.I)
|
||||
_ETA = re.compile(r"\bETA\s+(\d{3,4}Z?)\b", re.I)
|
||||
_DEP_ARR = re.compile(r"^(DEP|ARR|DLA|ALR)\b", re.I)
|
||||
_WX = re.compile(r"^(?:WXR?\d*|WX\s|MET\b|/WX\b)", re.I)
|
||||
_REQ = re.compile(r"^(?:REQ|REQUEST)\b", re.I)
|
||||
_LDR = re.compile(r"^LDR\d+", re.I)
|
||||
_PIREP = re.compile(r"^#(?:CFB|DFB)", re.I)
|
||||
_ATN = re.compile(r"^USADCXA\.AT1\.", re.I)
|
||||
_CPDLC = re.compile(r"^(?:DM-|UM-|AT1\.|ATC\s)", re.I)
|
||||
_ENG = re.compile(r"^(?:ENG\d|/ENG|OILTEMP|EGT\b)", re.I)
|
||||
_DOOR = re.compile(r"^(?:DOOR|CABIN|SMOKE)\b", re.I)
|
||||
_VDL_FRAME = re.compile(r"^[0-9A-F]{6,8}[A-Z]?\s*$", re.I)
|
||||
_FRAGMENT = re.compile(r"^[,0\s]+(?:,\d{5,8},\d{5,8},\d{5,8})*$", re.I)
|
||||
_GARBLED_VDL = re.compile(r"[)Z][A-Z0-9,\-:]{20,}")
|
||||
_MOSTLY_OPAQUE = re.compile(r"^[0-9A-Fa-f\s.\-+/,]{40,}$")
|
||||
_FREE_TEXT_POS = re.compile(
|
||||
r"^POS\s+N?(\d{1,2}\.\d+)\s+([NS])\s+W?(\d{1,3}\.\d+)\s+([EW])\s+FL(\d{3})",
|
||||
re.I,
|
||||
)
|
||||
_CLIMB_REQ = re.compile(r"\b(?:CLIMB|DESCEND|REQUEST)\s+(?:FL)?(\d{2,3})\b", re.I)
|
||||
|
||||
_LABEL_HINTS: dict[str, str] = {
|
||||
"00": "out (gate)",
|
||||
"01": "off (takeoff)",
|
||||
"02": "on (landing)",
|
||||
"03": "in (gate)",
|
||||
"10": "position",
|
||||
"15": "waypoint",
|
||||
"20": "position",
|
||||
"40": "ops / clearance",
|
||||
"44": "OOOI + position",
|
||||
"80": "weather",
|
||||
"81": "wind",
|
||||
"B1": "engine 1",
|
||||
"B2": "engine 2",
|
||||
"B3": "engine 3",
|
||||
"B4": "engine 4",
|
||||
"M1": "maintenance",
|
||||
"M2": "maintenance",
|
||||
"M3": "maintenance",
|
||||
"M4": "maintenance",
|
||||
"Q0": "position / OOOI",
|
||||
"H1": "terminal",
|
||||
"D0": "ATC clearance",
|
||||
"S1": "system status",
|
||||
"SA": "system status",
|
||||
"SB": "system status",
|
||||
"4T": "met report",
|
||||
"5Z": "free text",
|
||||
}
|
||||
|
||||
|
||||
def _result(
|
||||
summary: str,
|
||||
*,
|
||||
kind: str,
|
||||
readable: bool = True,
|
||||
hidden: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"summary": summary,
|
||||
"kind": kind,
|
||||
"readable": readable,
|
||||
"hidden": hidden,
|
||||
}
|
||||
|
||||
|
||||
def _phase_name(code: str) -> str:
|
||||
return {
|
||||
"CL": "climb",
|
||||
"CR": "cruise",
|
||||
"DE": "descent",
|
||||
"ER": "en route",
|
||||
"TO": "takeoff",
|
||||
"LD": "landed",
|
||||
"ON": "on ground",
|
||||
"OF": "off block",
|
||||
"GND": "on ground",
|
||||
"LO": "level",
|
||||
}.get(code.upper(), code.upper() or "unknown")
|
||||
|
||||
|
||||
def _fmt_coords(lat_deg: str, lat_frac: str, lon_deg: str, lon_frac: str) -> str:
|
||||
return f"{int(lat_deg)}°{lat_frac}'N {int(lon_deg)}°{lon_frac}'W"
|
||||
|
||||
|
||||
def _parse_pos_coords(token: str) -> str | None:
|
||||
token = token.upper().lstrip("POS")
|
||||
match = _POS_COORDS.match(token)
|
||||
if not match:
|
||||
return None
|
||||
lat, lat_dir, lon, lon_dir = match.groups()
|
||||
lat_v = f"{int(lat[:2])}°{lat[2:]}.{lat[4:] if len(lat) > 4 else '0'}'{lat_dir}"
|
||||
lon_v = f"{int(lon[:3])}°{lon[3:]}.{lon[5:] if len(lon) > 5 else '0'}'{lon_dir}"
|
||||
return f"{lat_v} {lon_v}"
|
||||
|
||||
|
||||
def _extract_route(raw: str) -> str:
|
||||
fi = _FI_BLOCK.search(raw)
|
||||
if fi:
|
||||
flight, _tail, dep, dest = fi.groups()
|
||||
parts = [flight.upper()]
|
||||
if dep and dest:
|
||||
parts.append(f"{dep}→{dest}")
|
||||
elif dep:
|
||||
parts.append(f"from {dep}")
|
||||
elif dest:
|
||||
parts.append(f"to {dest}")
|
||||
return " · ".join(parts)
|
||||
|
||||
airports = _ICAO_AIRPORT.findall(raw)
|
||||
# Filter duplicates while preserving order
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for apt in airports:
|
||||
if apt in seen:
|
||||
continue
|
||||
seen.add(apt)
|
||||
ordered.append(apt)
|
||||
if len(ordered) >= 2:
|
||||
return f"{ordered[0]}→{ordered[-1]}"
|
||||
if ordered:
|
||||
return ordered[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_flight(raw: str) -> str:
|
||||
fi = _FI_BLOCK.search(raw)
|
||||
if fi and fi.group(1):
|
||||
return fi.group(1).upper()
|
||||
for match in _FLIGHT.finditer(raw):
|
||||
return match.group(0).upper()
|
||||
for match in _FI_FLIGHT.finditer(raw):
|
||||
token = match.group(1).upper()
|
||||
prefix = re.match(r"^([A-Z]+)", token)
|
||||
if prefix and prefix.group(1) not in _NON_FLIGHT_TOKENS:
|
||||
return token
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_phase_snapshot(raw: str) -> str | None:
|
||||
for line in raw.splitlines():
|
||||
match = _PHASE_SNAPSHOT.match(line.strip())
|
||||
if not match:
|
||||
continue
|
||||
time_s, phase, lat_d, lat_f, lon_d, lon_f = match.groups()
|
||||
coords = _fmt_coords(lat_d, lat_f, lon_d, lon_f)
|
||||
return f"{_phase_name(phase)} · {coords} · {time_s}Z"
|
||||
return None
|
||||
|
||||
|
||||
def _has_aircraft_context(raw: str) -> bool:
|
||||
head = raw[:160].upper()
|
||||
if _AC_TYPE.search(head):
|
||||
return True
|
||||
if _FLIGHT.search(head):
|
||||
return True
|
||||
if _FI_BLOCK.search(head):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_fragment(raw: str) -> bool:
|
||||
first = raw.splitlines()[0].strip()
|
||||
if _FRAGMENT.match(first):
|
||||
return True
|
||||
if re.match(r"^[,0]{1,12}$", first):
|
||||
return True
|
||||
if first.startswith("000000") or first.startswith(",000000"):
|
||||
return True
|
||||
if re.match(r"^\d{2}\.\d{2}\.\d{2},", first) and not _has_aircraft_context(raw):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _summarize_oooi(raw: str, label: str) -> dict[str, Any] | None:
|
||||
times = _OOOI_TIMES.findall(raw)
|
||||
statuses = _OOOI_STATUS.findall(raw)
|
||||
if not times and not statuses and label not in {"00", "01", "02", "03", "44", "Q0"}:
|
||||
return None
|
||||
|
||||
events: list[str] = []
|
||||
for event, value in times:
|
||||
events.append(f"{event.upper()} {value}")
|
||||
for event, status in statuses:
|
||||
events.append(f"{event.upper()} ({_phase_name(status)})")
|
||||
|
||||
if label in {"00", "01", "02", "03"} and not events:
|
||||
events.append(_LABEL_HINTS[label])
|
||||
|
||||
if not events and "ON ,LO" not in raw and "OFF,OFF" not in raw:
|
||||
return None
|
||||
|
||||
route = _extract_route(raw)
|
||||
flight = _extract_flight(raw)
|
||||
prefix = "OOOI"
|
||||
if label in _LABEL_HINTS:
|
||||
prefix = f"OOOI ({_LABEL_HINTS[label]})"
|
||||
bits = [prefix]
|
||||
if flight:
|
||||
bits.append(flight)
|
||||
if route:
|
||||
bits.append(route)
|
||||
if events:
|
||||
bits.append(", ".join(events[:4]))
|
||||
return _result(" · ".join(bits), kind="oooi")
|
||||
|
||||
|
||||
def _summarize_position(raw: str, first_line: str) -> dict[str, Any] | None:
|
||||
upper = first_line.upper()
|
||||
pos_token = re.search(r"POSN?\d", raw, re.I)
|
||||
if not (upper.startswith("POS") or _POS_HEADER.match(first_line) or pos_token):
|
||||
return None
|
||||
|
||||
coord_line = first_line
|
||||
if pos_token and not upper.startswith("POS"):
|
||||
coord_line = raw[pos_token.start() :].split(",")[0]
|
||||
|
||||
coords = _parse_pos_coords(coord_line)
|
||||
free = _FREE_TEXT_POS.match(raw)
|
||||
fl = ""
|
||||
if free:
|
||||
lat, lat_dir, lon, lon_dir, fl = free.groups()
|
||||
coords = f"{lat}°{lat_dir} {lon}°{lon_dir}"
|
||||
fl = f"FL{fl}"
|
||||
|
||||
route = _extract_route(raw)
|
||||
flight = _extract_flight(raw)
|
||||
parts = ["Position report"]
|
||||
if flight:
|
||||
parts.append(flight)
|
||||
if route:
|
||||
parts.append(route)
|
||||
if coords:
|
||||
parts.append(coords)
|
||||
if fl:
|
||||
parts.append(fl)
|
||||
elif re.search(r"\bFL?\d{3}\b", raw):
|
||||
fl_match = re.search(r"\bFL?(\d{2,3})\b", raw)
|
||||
if fl_match:
|
||||
parts.append(f"FL{fl_match.group(1)}")
|
||||
return _result(" · ".join(parts), kind="position")
|
||||
|
||||
|
||||
def _summarize_performance(raw: str, first_line: str) -> dict[str, Any] | None:
|
||||
match = _PERF_HEADER.match(first_line)
|
||||
if not match or not _AC_TYPE.search(first_line):
|
||||
return None
|
||||
|
||||
_serial, ac_type, _date, flight, dep, dest = match.groups()
|
||||
phase_bits = _extract_phase_snapshot(raw) or ""
|
||||
extra = f" · {phase_bits}" if phase_bits else ""
|
||||
|
||||
if "FHP" in raw or "SIN," in raw or "SOU," in raw:
|
||||
title, kind = "Engine health (FHP)", "engine_health"
|
||||
elif "OATTO" in raw or "LPACKCL" in raw or "RPACKCL" in raw:
|
||||
title, kind = "Pack temperature", "pack_temp"
|
||||
elif "FLAPS" in raw.upper():
|
||||
title, kind = "Climb performance", "climb_perf"
|
||||
elif "FRE," in raw or "FEX," in raw:
|
||||
title, kind = "Fuel/performance snapshot", "fuel_perf"
|
||||
else:
|
||||
title, kind = "Flight performance", "performance"
|
||||
|
||||
return _result(
|
||||
f"{title} · {flight} · {ac_type} · {dep}→{dest}{extra}",
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def _summarize_by_label(label: str, raw: str, first_line: str) -> dict[str, Any] | None:
|
||||
label_u = label.upper()
|
||||
hint = _LABEL_HINTS.get(label_u, "")
|
||||
|
||||
if label_u in {"B1", "B2", "B3", "B4"} or _ENG.match(first_line):
|
||||
eng = label_u if label_u.startswith("B") else "Engine"
|
||||
return _result(f"{eng} data report", kind="engine", readable=bool(hint))
|
||||
|
||||
if label_u.startswith("M") and label_u[1:2].isdigit():
|
||||
return _result(f"Maintenance ({hint or 'system report'})", kind="maintenance")
|
||||
|
||||
if label_u in {"80", "81", "4T"} or _WX.match(first_line):
|
||||
apt = _ICAO_AIRPORT.search(raw)
|
||||
apt_s = f" · {apt.group(1)}" if apt else ""
|
||||
return _result(f"Weather report{apt_s}", kind="weather")
|
||||
|
||||
if label_u == "D0" or _REQ.match(first_line) or _CLIMB_REQ.search(raw):
|
||||
climb = _CLIMB_REQ.search(raw)
|
||||
if climb:
|
||||
return _result(f"Altitude request · FL{climb.group(1)}", kind="request")
|
||||
return _result("ATC / ops request", kind="request")
|
||||
|
||||
if label_u in {"40", "5Z"} and len(raw) < 200:
|
||||
text = raw.replace("\n", " · ")[:140]
|
||||
return _result(f"Ops message · {text}", kind="ops")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def summarize_datalink_message(
|
||||
*,
|
||||
label: str = "",
|
||||
text: str = "",
|
||||
source_type: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return {summary, kind, readable, hidden} for a cached datalink message."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return _result("", kind="empty", readable=False, hidden=True)
|
||||
|
||||
first_line = raw.splitlines()[0].strip()
|
||||
upper = first_line.upper()
|
||||
label_u = label.upper()
|
||||
|
||||
if _is_fragment(raw):
|
||||
return _result(
|
||||
"Split telemetry fragment (part of a longer VDL message)",
|
||||
kind="fragment",
|
||||
readable=False,
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
if _ATN.match(first_line) or _CPDLC.match(first_line):
|
||||
tail = _TAIL.search(raw)
|
||||
return _result(
|
||||
"Datalink protocol / CPDLC header" + (f" · {tail.group(0)}" if tail else ""),
|
||||
kind="protocol",
|
||||
readable=False,
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
if label_u == "37" or (_VDL_FRAME.match(first_line) and len(raw) < 160):
|
||||
if _GARBLED_VDL.search(raw) or len(raw) < 160:
|
||||
return _result("VDL binary frame (undecoded)", kind="vdl_binary", readable=False, hidden=True)
|
||||
|
||||
# --- structured families (order matters) ---
|
||||
|
||||
if _DEP_ARR.match(first_line):
|
||||
kind_word = first_line.split()[0].upper()
|
||||
route = _extract_route(raw)
|
||||
flight = _extract_flight(raw)
|
||||
title = {"DEP": "Departure", "ARR": "Arrival", "DLA": "Delay", "ALR": "Alert"}.get(
|
||||
kind_word, kind_word
|
||||
)
|
||||
bits = [title]
|
||||
if flight:
|
||||
bits.append(flight)
|
||||
if route:
|
||||
bits.append(route)
|
||||
return _result(" · ".join(bits), kind=kind_word.lower())
|
||||
|
||||
oooi = _summarize_oooi(raw, label_u)
|
||||
if oooi:
|
||||
return oooi
|
||||
|
||||
match = _TRACK_HEADER.match(first_line)
|
||||
if match:
|
||||
tail, ac_type, _date, flight, dep, dest = match.groups()
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
waypoint_lines = [line for line in lines if _WAYPOINT.match(line.lstrip("N"))]
|
||||
phase = ""
|
||||
if waypoint_lines:
|
||||
parts = waypoint_lines[-1].rstrip(",").split(",")
|
||||
if len(parts) >= 8:
|
||||
phase = _phase_name(parts[7])
|
||||
wp_count = len(waypoint_lines) or max(0, len(lines) - 2)
|
||||
summary = (
|
||||
f"Track report · {flight} · {tail} ({ac_type}) · {dep}→{dest}"
|
||||
+ (f" · {wp_count} waypoint(s)" + (f" · {phase}" if phase else ""))
|
||||
)
|
||||
return _result(summary, kind="track")
|
||||
|
||||
pos = _summarize_position(raw, first_line)
|
||||
if pos:
|
||||
return pos
|
||||
|
||||
if _FPN.match(first_line):
|
||||
route = _extract_route(raw)
|
||||
flight = _extract_flight(raw)
|
||||
bits = ["Flight plan"]
|
||||
if flight:
|
||||
bits.append(flight)
|
||||
if route:
|
||||
bits.append(route)
|
||||
return _result(" · ".join(bits), kind="flight_plan")
|
||||
|
||||
if _PIREP.match(first_line):
|
||||
return _result("Pilot report (PIREP)", kind="pirep")
|
||||
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
if _TRAJECTORY_HEADER.match(first_line) or (len(lines) >= 2 and _TRAJECTORY_ROUTE.match(lines[1])):
|
||||
route = ""
|
||||
route_match = next((m for line in lines if (m := _TRAJECTORY_ROUTE.match(line))), None)
|
||||
if route_match:
|
||||
route = f" · {route_match.group(1)}→{route_match.group(2)}"
|
||||
wp_count = sum(1 for line in lines if _COMPRESSED_WP.match(line))
|
||||
return _result(
|
||||
f"Trajectory / ADS report{route}" + (f" · {wp_count} point(s)" if wp_count else ""),
|
||||
kind="trajectory",
|
||||
)
|
||||
|
||||
perf = _summarize_performance(raw, first_line)
|
||||
if perf:
|
||||
return perf
|
||||
|
||||
if _LDR.match(first_line):
|
||||
route = _extract_route(raw)
|
||||
return _result(f"Load report · {route}" if route else "Load report", kind="load")
|
||||
|
||||
if _WX.match(first_line):
|
||||
route = _extract_route(raw)
|
||||
return _result(f"Weather request · {route or 'en route'}", kind="weather")
|
||||
|
||||
if _DOOR.match(first_line):
|
||||
return _result("Cabin / door advisory", kind="cabin")
|
||||
|
||||
if _WAYPOINT.match(first_line.lstrip("N")):
|
||||
parts = first_line.lstrip("N").rstrip(",").split(",")
|
||||
if len(parts) >= 4:
|
||||
lat, lon, _t, alt = parts[0], parts[1], parts[2], parts[3]
|
||||
phase = _phase_name(parts[7]) if len(parts) >= 8 else ""
|
||||
summary = f"Waypoint · {lat},{lon} · alt {alt} ft" + (f" · {phase}" if phase else "")
|
||||
return _result(summary, kind="waypoint")
|
||||
|
||||
label_summary = _summarize_by_label(label_u, raw, first_line)
|
||||
if label_summary:
|
||||
return label_summary
|
||||
|
||||
flight = _extract_flight(raw)
|
||||
route = _extract_route(raw)
|
||||
if flight and route:
|
||||
return _result(f"Datalink · {flight} · {route}", kind="flight")
|
||||
|
||||
eta = _ETA.search(raw)
|
||||
if eta and flight:
|
||||
return _result(f"ETA update · {flight} · {eta.group(1)}", kind="eta")
|
||||
|
||||
if len(raw) < 100 and not _MOSTLY_OPAQUE.match(raw) and not _GARBLED_VDL.search(raw):
|
||||
clean = raw.replace("\n", " · ")
|
||||
if label_u in _LABEL_HINTS:
|
||||
return _result(f"{_LABEL_HINTS[label_u].title()} · {clean}", kind="short")
|
||||
return _result(clean, kind="short")
|
||||
|
||||
digit_ratio = sum(ch.isdigit() for ch in raw) / max(len(raw), 1)
|
||||
if digit_ratio > 0.55 or _MOSTLY_OPAQUE.match(raw.replace(" ", "")) or _GARBLED_VDL.search(raw):
|
||||
return _result(
|
||||
"Binary / proprietary telemetry (undecoded)",
|
||||
kind="vdl_binary",
|
||||
readable=False,
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
if label_u in _LABEL_HINTS:
|
||||
return _result(
|
||||
f"{_LABEL_HINTS[label_u].title()} message",
|
||||
kind=label_u.lower(),
|
||||
readable=False,
|
||||
hidden=False,
|
||||
)
|
||||
|
||||
return _result(
|
||||
first_line[:100] + ("…" if len(first_line) > 100 else ""),
|
||||
kind="raw",
|
||||
readable=False,
|
||||
hidden=False,
|
||||
)
|
||||
|
||||
|
||||
def prepare_datalink_display(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Attach summaries and filter noise for dossier display."""
|
||||
enriched: list[dict[str, Any]] = []
|
||||
hidden_count = 0
|
||||
seen_summaries: set[str] = set()
|
||||
|
||||
for message in messages:
|
||||
meta = summarize_datalink_message(
|
||||
label=str(message.get("label") or ""),
|
||||
text=str(message.get("text") or ""),
|
||||
source_type=str(message.get("source_type") or ""),
|
||||
)
|
||||
item = {**message, **meta}
|
||||
if item.get("hidden"):
|
||||
hidden_count += 1
|
||||
continue
|
||||
|
||||
# Drop back-to-back duplicate summaries (common with multi-part VDL)
|
||||
sig = f"{item.get('kind')}|{item.get('summary')}"
|
||||
if sig in seen_summaries and item.get("kind") not in {"short", "ops", "request"}:
|
||||
hidden_count += 1
|
||||
continue
|
||||
seen_summaries.add(sig)
|
||||
|
||||
enriched.append(item)
|
||||
|
||||
return {
|
||||
"messages": enriched,
|
||||
"hidden_count": hidden_count,
|
||||
"total_count": len(messages),
|
||||
}
|
||||
|
||||
|
||||
def attach_summaries(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return prepare_datalink_display(messages)["messages"]
|
||||
@@ -593,9 +593,15 @@ def lookup_datalink_messages(
|
||||
if queued_refresh:
|
||||
_ensure_stagger_worker()
|
||||
|
||||
from services.fetchers.acars_summarize import prepare_datalink_display
|
||||
|
||||
display = prepare_datalink_display(messages)
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"messages": messages,
|
||||
"messages": display["messages"],
|
||||
"hidden_count": display["hidden_count"],
|
||||
"total_count": display["total_count"],
|
||||
"last_success_at": last_success_at,
|
||||
"queued_refresh": queued_refresh,
|
||||
"priority_scan": queued_refresh,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
from services.fetchers.acars_summarize import prepare_datalink_display, summarize_datalink_message
|
||||
|
||||
# --- Southwest (existing) ---
|
||||
|
||||
|
||||
def test_summarize_track_report():
|
||||
text = """++86501,N8997Q,B7378MAX,260620,WN3743,KMSP,KMDW,0496,SMX34-2502-F320
|
||||
6
|
||||
N4432.0,W09305.6,201041,15193,-08.3,310,044,CL,00000,0,"""
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "track"
|
||||
assert "WN3743" in meta["summary"]
|
||||
assert "KMSP→KMDW" in meta["summary"]
|
||||
|
||||
|
||||
def test_summarize_sw_performance_cruise():
|
||||
text = """72740,7852,B737-700,260624,WN0120,KABQ,KDEN,1986,SW2501
|
||||
18.45.14,CR,1575,28981,280.0,.729,-32.3,-06.5,N3601.3,W10655.7,131240
|
||||
0.48,FHP,AIR
|
||||
SIN,-1.42 0.30 0.29"""
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "engine_health"
|
||||
assert "WN0120" in meta["summary"]
|
||||
assert "cruise" in meta["summary"]
|
||||
|
||||
|
||||
def test_summarize_sw_climb_performance():
|
||||
text = """05201,7852,B737-700,260624,WN0120,KABQ,KDEN,1986,SW2501
|
||||
18.38.08,CL,1149,15631,257.0,.520,000.0,014.5,N3515.4,W10649.4,132800
|
||||
001.40,001,4100,FLAPS-UP"""
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "climb_perf"
|
||||
assert "climb" in meta["summary"]
|
||||
|
||||
|
||||
def test_summarize_trajectory():
|
||||
text = """76401
|
||||
02E24KABQKDEN
|
||||
N35112W10679318361096P014343008G000022::I0:9W
|
||||
N35195W10681118371370P006337009G000022::Q0OXW"""
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "trajectory"
|
||||
assert "KABQ→KDEN" in meta["summary"]
|
||||
|
||||
|
||||
def test_fragment_hidden():
|
||||
text = "0000000,00000000,00000000\n18.38.23,16395,250.2,.510,01.07,01.04,00,00000000"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "fragment"
|
||||
assert meta["hidden"] is True
|
||||
|
||||
|
||||
def test_vdl_binary_hidden():
|
||||
text = "014F63N\n)AJQZ)LC0Z0IP-M7O,ZHN3-M,73ZO,UU-ZOS1Z7PPZMSN1ZN"
|
||||
meta = summarize_datalink_message(label="37", text=text, source_type="vdl")
|
||||
assert meta["hidden"] is True
|
||||
|
||||
|
||||
# --- United / Delta / American ---
|
||||
|
||||
|
||||
def test_united_free_text_position():
|
||||
text = "POS N40.123 W074.456 FL350 GS450 1425Z"
|
||||
meta = summarize_datalink_message(label="Q0", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "position"
|
||||
assert "FL350" in meta["summary"]
|
||||
|
||||
|
||||
def test_delta_oooi_out():
|
||||
text = "OUT 1425 12JAN KATL"
|
||||
meta = summarize_datalink_message(label="00", text=text, source_type="acars")
|
||||
assert meta["kind"] == "oooi"
|
||||
assert "OUT 1425" in meta["summary"]
|
||||
|
||||
|
||||
def test_american_fi_block():
|
||||
text = "FI AA100/AN N100AA/DA KDFW/AA KLAX OUT 1832 OFF 1845"
|
||||
meta = summarize_datalink_message(label="44", text=text, source_type="acars")
|
||||
assert "AA100" in meta["summary"]
|
||||
assert "KDFW→KLAX" in meta["summary"]
|
||||
|
||||
|
||||
def test_united_performance_a320():
|
||||
text = """88401,4521,A320-200,260624,UA1234,KORD,KDEN,1200,UA2501
|
||||
19.10.22,CR,2200,35000,450.0,.820,-45.0,-02.0,N3950.1,W10440.2,125000"""
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "performance"
|
||||
assert "UA1234" in meta["summary"]
|
||||
assert "KORD→KDEN" in meta["summary"]
|
||||
|
||||
|
||||
# --- International ---
|
||||
|
||||
|
||||
def test_british_airways_engine():
|
||||
text = "ENG1 N1 92.5 N2 95.1 EGT 512 FF 2850"
|
||||
meta = summarize_datalink_message(label="B1", text=text, source_type="satcom")
|
||||
assert meta["kind"] == "engine"
|
||||
|
||||
|
||||
def test_qantas_fi_position():
|
||||
text = "FI QF9/AN VH-OQA/DA YSSY/AD EGLL POSN32249E045047,,082806,380,DEBNI"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="acars")
|
||||
assert meta["kind"] == "position"
|
||||
assert "QF9" in meta["summary"]
|
||||
assert "YSSY→EGLL" in meta["summary"]
|
||||
|
||||
|
||||
def test_lufthansa_weather():
|
||||
text = "WX 250/045 SAT -42 TB MOD EDDF"
|
||||
meta = summarize_datalink_message(label="80", text=text, source_type="acars")
|
||||
assert meta["kind"] == "weather"
|
||||
assert "EDDF" in meta["summary"]
|
||||
|
||||
|
||||
def test_air_france_request():
|
||||
text = "REQUEST FL370 DUE TURB"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "request"
|
||||
assert "FL370" in meta["summary"]
|
||||
|
||||
|
||||
# --- Cargo / military ---
|
||||
|
||||
|
||||
def test_fedex_flight():
|
||||
text = "++86501,N123FE,B763,260624,FDX1544,KMEM,KORD,0498,SMX34"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "track"
|
||||
assert "FDX1544" in meta["summary"]
|
||||
|
||||
|
||||
def test_military_rch():
|
||||
text = "POSN3840.5 W07720.1 FL280 RCH123 KADW KDMA"
|
||||
meta = summarize_datalink_message(label="Q0", text=text, source_type="acars")
|
||||
assert "RCH123" in meta["summary"]
|
||||
|
||||
|
||||
# --- Ops / misc ---
|
||||
|
||||
|
||||
def test_flight_plan():
|
||||
text = "FPN/RI:DA:KJFK:AA:EGLL..MERIT:D:MERIT"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="vdl")
|
||||
assert meta["kind"] == "flight_plan"
|
||||
assert "KJFK→EGLL" in meta["summary"]
|
||||
|
||||
|
||||
def test_departure_report():
|
||||
text = "DEP FI DL456/DA KATL/AD KLAX OUT 1205"
|
||||
meta = summarize_datalink_message(label="40", text=text, source_type="acars")
|
||||
assert meta["kind"] == "dep"
|
||||
assert "DL456" in meta["summary"]
|
||||
|
||||
|
||||
def test_pirep():
|
||||
text = "#CFB/PIREP MOD TURB FL280 N3845 W09030"
|
||||
meta = summarize_datalink_message(label="H1", text=text, source_type="acars")
|
||||
assert meta["kind"] == "pirep"
|
||||
|
||||
|
||||
def test_prepare_filters_hidden_and_dedupes():
|
||||
messages = [
|
||||
{"id": 1, "label": "H1", "text": "POSN35259W106517,KABQ,KDEN", "source_type": "vdl"},
|
||||
{"id": 2, "label": "H1", "text": "0000000,00000000,00000000", "source_type": "vdl"},
|
||||
{"id": 3, "label": "37", "text": "014F63N\n)AJQZ)LC0Z", "source_type": "vdl"},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "H1",
|
||||
"text": "72740,7852,B737-700,260624,WN0120,KABQ,KDEN\n18.45.14,CR,1575,28981",
|
||||
"source_type": "vdl",
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "H1",
|
||||
"text": "72740,7852,B737-700,260624,WN0120,KABQ,KDEN\n18.45.14,CR,1575,28981",
|
||||
"source_type": "vdl",
|
||||
},
|
||||
]
|
||||
display = prepare_datalink_display(messages)
|
||||
assert display["hidden_count"] == 3
|
||||
assert len(display["messages"]) == 2
|
||||
@@ -9,11 +9,49 @@ type DatalinkMessage = {
|
||||
label?: string;
|
||||
text?: string;
|
||||
source_type?: string;
|
||||
summary?: string;
|
||||
kind?: string;
|
||||
readable?: boolean;
|
||||
};
|
||||
|
||||
const PRIORITY_POLL_MS = 3_000;
|
||||
const PRIORITY_POLL_MAX_MS = 45_000;
|
||||
|
||||
function DatalinkMessageRow({ message }: { message: DatalinkMessage }) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const summary = message.summary?.trim();
|
||||
const raw = message.text?.trim() || '';
|
||||
const hasSummary = Boolean(summary);
|
||||
const showRawBlock = showRaw || (!hasSummary && raw);
|
||||
|
||||
return (
|
||||
<div className="text-[10px] font-mono leading-snug border border-[var(--border-primary)]/60 bg-black/20 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-0.5">
|
||||
<span>{formatDatalinkTime(message.timestamp)}</span>
|
||||
{message.label ? <span className="text-orange-400/90">{message.label}</span> : null}
|
||||
{message.source_type ? <span className="truncate">{message.source_type}</span> : null}
|
||||
</div>
|
||||
{hasSummary ? (
|
||||
<div className="text-[var(--text-primary)] break-words">{summary}</div>
|
||||
) : null}
|
||||
{hasSummary && raw ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw((value) => !value)}
|
||||
className="mt-0.5 text-[var(--text-muted)] hover:text-cyan-400/90 underline underline-offset-2"
|
||||
>
|
||||
{showRaw ? 'hide raw' : 'show raw'}
|
||||
</button>
|
||||
) : null}
|
||||
{showRawBlock && raw ? (
|
||||
<div className="mt-0.5 text-[var(--text-muted)] whitespace-pre-wrap break-words text-[9px] leading-relaxed max-h-24 overflow-y-auto">
|
||||
{raw}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDatalinkTime(value?: string): string {
|
||||
if (!value) return '--:--';
|
||||
try {
|
||||
@@ -40,6 +78,8 @@ export default function DatalinkMessagesBlock({
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [priorityScanning, setPriorityScanning] = useState(false);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const pollUntilRef = useRef(0);
|
||||
|
||||
const buildParams = useCallback(() => {
|
||||
@@ -71,6 +111,7 @@ export default function DatalinkMessagesBlock({
|
||||
const json = await res.json();
|
||||
setConfigured(Boolean(json.configured));
|
||||
setMessages(Array.isArray(json.messages) ? json.messages : []);
|
||||
setHiddenCount(typeof json.hidden_count === 'number' ? json.hidden_count : 0);
|
||||
setHint(typeof json.hint === 'string' ? json.hint : null);
|
||||
setLoadError(null);
|
||||
if (json.priority_scan || json.queued_refresh) {
|
||||
@@ -163,16 +204,27 @@ export default function DatalinkMessagesBlock({
|
||||
) : null}
|
||||
<div className="max-h-36 overflow-y-auto space-y-1.5 pr-1">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="text-[10px] font-mono leading-snug border border-[var(--border-primary)]/60 bg-black/20 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-0.5">
|
||||
<span>{formatDatalinkTime(message.timestamp)}</span>
|
||||
{message.label ? <span className="text-orange-400/90">{message.label}</span> : null}
|
||||
{message.source_type ? <span className="truncate">{message.source_type}</span> : null}
|
||||
</div>
|
||||
<div className="text-[var(--text-primary)] whitespace-pre-wrap break-words">{message.text}</div>
|
||||
</div>
|
||||
<DatalinkMessageRow key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
{hiddenCount > 0 ? (
|
||||
<p className="text-[9px] font-mono text-[var(--text-muted)] mt-1">
|
||||
{hiddenCount} binary/fragment message{hiddenCount === 1 ? '' : 's'} hidden.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((value) => !value)}
|
||||
className="underline underline-offset-2 hover:text-cyan-400/90"
|
||||
>
|
||||
{showHidden ? 'Hide note' : 'Why?'}
|
||||
</button>
|
||||
{showHidden ? (
|
||||
<span className="block mt-0.5 text-[var(--text-muted)]/80">
|
||||
VDL splits long telemetry into many frames. Southwest also uses proprietary formats
|
||||
that cannot be decoded without airline keys.
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user