diff --git a/backend/services/fetchers/acars_summarize.py b/backend/services/fetchers/acars_summarize.py new file mode 100644 index 0000000..8d45b0c --- /dev/null +++ b/backend/services/fetchers/acars_summarize.py @@ -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"] diff --git a/backend/services/fetchers/airframes.py b/backend/services/fetchers/airframes.py index 9d1f3cc..3d47b41 100644 --- a/backend/services/fetchers/airframes.py +++ b/backend/services/fetchers/airframes.py @@ -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, diff --git a/backend/tests/test_acars_summarize.py b/backend/tests/test_acars_summarize.py new file mode 100644 index 0000000..78ed5a4 --- /dev/null +++ b/backend/tests/test_acars_summarize.py @@ -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 diff --git a/frontend/src/components/DatalinkMessagesBlock.tsx b/frontend/src/components/DatalinkMessagesBlock.tsx index 07774d1..7bede4d 100644 --- a/frontend/src/components/DatalinkMessagesBlock.tsx +++ b/frontend/src/components/DatalinkMessagesBlock.tsx @@ -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 ( +
+ {hiddenCount} binary/fragment message{hiddenCount === 1 ? '' : 's'} hidden.{' '} + + {showHidden ? ( + + VDL splits long telemetry into many frames. Southwest also uses proprietary formats + that cannot be decoded without airline keys. + + ) : null} +
+ ) : null} ); }