From bff69a1d04039158ca1879bfa0f25931464031b4 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 13:12:36 -0400 Subject: [PATCH] ADS-B map: altitude-colored aircraft icons + click-to-show flight trail Icons were 16px accent-colored glyphs, indistinguishable from OSM's own airport symbols. Now a 30px outlined airliner silhouette filled on tar1090/ADS-B Exchange's altitude hue ramp, with a callsign/altitude hover tooltip; the selected aircraft grows and gets a white outline. Clicking an aircraft draws the path heard so far, segment-colored by altitude. c2-core writes one point per position change to aircraft/{icao}/positions (deduped in-process, writes now concurrent); points carry expire_at and a TTL fieldOverride deletes them after ~24h. Trail reads are gated on the parent aircraft doc's org via get(), so the query needs no org filter or composite index. The latest stretch without a 20-min gap counts as the current flight. Verified: c2-core pytest 479 passed; frontend tsc --noEmit clean (node:20 container on radio-box). Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/routers/telemetry.py | 50 +++++++++- drb-c2-core/tests/test_telemetry.py | 45 ++++++++- drb-frontend/components/MapView.tsx | 123 ++++++++++++++++++++----- drb-frontend/lib/types.ts | 8 ++ drb-frontend/lib/useAircraftTrail.ts | 43 +++++++++ infra/firestore/firestore.indexes.json | 10 +- infra/firestore/firestore.rules | 8 ++ 7 files changed, 257 insertions(+), 30 deletions(-) create mode 100644 drb-frontend/lib/useAircraftTrail.ts diff --git a/drb-c2-core/app/routers/telemetry.py b/drb-c2-core/app/routers/telemetry.py index 8b9bc20..e46ef76 100644 --- a/drb-c2-core/app/routers/telemetry.py +++ b/drb-c2-core/app/routers/telemetry.py @@ -1,5 +1,6 @@ -from datetime import datetime, timezone -from typing import List, Optional +import asyncio +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -10,6 +11,20 @@ from app.internal.logger import logger router = APIRouter(prefix="/telemetry", tags=["telemetry"]) +# Flight trail: every position change is also written to +# aircraft/{icao}/positions/{epoch_ms}, so clicking an aircraft on the map can +# draw the path heard so far. Points expire via a Firestore TTL policy on +# expire_at (infra/firestore/firestore.indexes.json fieldOverrides). +POSITIONS_SUBCOLLECTION = "positions" +POSITION_TTL = timedelta(hours=24) + +# Last position written per icao, so an aircraft reported unchanged across +# several 10s uploads (readsb holds a position until a new one decodes) +# doesn't get a duplicate point each time. Process-local and lossy by design: +# after a restart the worst case is one duplicate point per aircraft. +_last_position: Dict[str, Tuple[float, float]] = {} +_LAST_POSITION_MAX = 5000 + class AircraftReport(BaseModel): icao: str @@ -32,8 +47,8 @@ async def upload_adsb( ): """ Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts - its current aircraft snapshot here. One doc per icao, last-seen-wins — - this is a live-map overlay, not a flight history. + its current aircraft snapshot here. One doc per icao, last-seen-wins, + plus one trail point per position change (see POSITIONS_SUBCOLLECTION). """ node_id = decoded.get("node_id") if not node_id: @@ -43,7 +58,11 @@ async def upload_adsb( org_id = node.get("org_id") if node else None now = datetime.now(timezone.utc).isoformat() + expire_at = datetime.now(timezone.utc) + POSITION_TTL + epoch_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + writes = [] + trail = [] for ac in body.aircraft: if not ac.icao: continue @@ -62,12 +81,33 @@ async def upload_adsb( doc["org_id"] = org_id writes.append(("aircraft", ac.icao, doc)) - for collection, doc_id, doc in writes: + if ac.lat is None or ac.lon is None: + continue + pos = (ac.lat, ac.lon) + if _last_position.get(ac.icao) == pos: + continue + _last_position[ac.icao] = pos + point = { + "lat": ac.lat, + "lon": ac.lon, + "altitude_ft": ac.altitude_ft, + "t": now, + "expire_at": expire_at, + } + trail.append((f"aircraft/{ac.icao}/{POSITIONS_SUBCOLLECTION}", str(epoch_ms), point)) + + if len(_last_position) > _LAST_POSITION_MAX: + _last_position.clear() + + async def _write(collection: str, doc_id: str, doc: dict) -> None: try: await fstore.doc_set(collection, doc_id, doc, merge=True) except Exception as e: logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}") + # Concurrent: a busy sky is dozens of aircraft, two writes each, every 10s. + await asyncio.gather(*(_write(*w) for w in writes + trail)) + return {"ok": True, "count": len(writes)} diff --git a/drb-c2-core/tests/test_telemetry.py b/drb-c2-core/tests/test_telemetry.py index 3854d12..c3852a2 100644 --- a/drb-c2-core/tests/test_telemetry.py +++ b/drb-c2-core/tests/test_telemetry.py @@ -24,6 +24,11 @@ def _override(decoded: dict): def teardown_function(): app.dependency_overrides.pop(require_node_service_or_firebase_token, None) + telemetry._last_position.clear() + + +def _writes_to(mock_set, collection_prefix: str): + return [c for c in mock_set.await_args_list if c.args[0].startswith(collection_prefix)] def test_service_token_without_node_id_is_rejected(): @@ -41,8 +46,9 @@ def test_node_upload_upserts_and_stamps_org_id(): }) assert resp.status_code == 200 assert resp.json() == {"ok": True, "count": 1} - mock_set.assert_awaited_once() - (collection, doc_id, doc), kwargs = mock_set.await_args + snapshot = [c for c in mock_set.await_args_list if c.args[0] == "aircraft"] + assert len(snapshot) == 1 + (collection, doc_id, doc), kwargs = snapshot[0] assert collection == "aircraft" assert doc_id == "A1B2C3" assert doc["node_id"] == "node-1" @@ -92,3 +98,38 @@ def test_ais_node_upload_skips_entries_missing_mmsi(): assert resp.status_code == 200 assert resp.json() == {"ok": True, "count": 0} mock_set.assert_not_awaited() + + +def _post_adsb(aircraft): + with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \ + patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set: + resp = client.post("/telemetry/adsb", json={"aircraft": aircraft}) + assert resp.status_code == 200 + return mock_set + + +def test_position_writes_trail_point_with_ttl(): + _override({"node": True, "node_id": "node-1"}) + mock_set = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8, "altitude_ft": 3000}]) + trail = _writes_to(mock_set, "aircraft/A1B2C3/positions") + assert len(trail) == 1 + (_, doc_id, point), _ = trail[0] + assert doc_id.isdigit() + assert (point["lat"], point["lon"], point["altitude_ft"]) == (41.1, -73.8, 3000) + assert point["expire_at"] > telemetry.datetime.now(telemetry.timezone.utc) + + +def test_unchanged_position_is_not_rewritten_to_trail(): + _override({"node": True, "node_id": "node-1"}) + _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}]) + again = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}]) + moved = _post_adsb([{"icao": "A1B2C3", "lat": 41.2, "lon": -73.8}]) + assert _writes_to(again, "aircraft/A1B2C3/positions") == [] + assert len(_writes_to(moved, "aircraft/A1B2C3/positions")) == 1 + + +def test_aircraft_without_position_gets_no_trail_point(): + _override({"node": True, "node_id": "node-1"}) + mock_set = _post_adsb([{"icao": "A1B2C3", "callsign": "UAL123"}]) + assert _writes_to(mock_set, "aircraft/A1B2C3/positions") == [] + assert len(_writes_to(mock_set, "aircraft")) == 1 diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 34eee14..10682fd 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -9,13 +9,15 @@ import { Polyline, Popup, TileLayer, + Tooltip, useMap, } from "react-leaflet"; import L from "leaflet"; -import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; +import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAircraft } from "@/lib/useAircraft"; +import { useAircraftTrail } from "@/lib/useAircraftTrail"; import { useVessels } from "@/lib/useVessels"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── @@ -92,36 +94,113 @@ function nodeIcon(status: NodeStatus): L.DivIcon { }); } -// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ──────────────────────── -function aircraftIcon(trackDeg: number | null): L.DivIcon { - const size = 16; - const rotation = trackDeg ?? 0; +// ── Aircraft — node-26#9 second-SDR ADS-B overlay ───────────────────────────── +// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a +// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a +// glance and the icon stands out from OSM's own (purple) airport symbols. +const ALT_HUE_STOPS: [number, number][] = [ + [0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300], +]; + +function altitudeColor(altFt: number | null): string { + if (altFt == null) return "hsl(0, 0%, 55%)"; + if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground + let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1]; + for (let i = 1; i < ALT_HUE_STOPS.length; i++) { + const [a1, h1] = ALT_HUE_STOPS[i]; + if (altFt <= a1) { + const [a0, h0] = ALT_HUE_STOPS[i - 1]; + hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0); + break; + } + } + return `hsl(${hue.toFixed(0)}, 88%, 48%)`; +} + +const AIRLINER_PATH = + "M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " + + "L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z"; + +function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon { + const size = selected ? 36 : 30; + const outline = selected ? "#ffffff" : "#000000"; + const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))"; return L.divIcon({ className: "", - html: `
`, + html: + `
` + + `
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } -function AircraftLayer() { - const { aircraft } = useAircraft(); +function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) { + const trail = useAircraftTrail(icao); + // Extend to the live position so the path always meets the icon. + const points = [...trail]; + if (current.lat != null && current.lon != null) { + points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen }); + } + // One segment per leg, colored by altitude like tar1090's track. return ( <> - {aircraft - .filter((a) => a.lat != null && a.lon != null) - .map((a) => ( - - -
-
{a.callsign || a.icao}
-
ICAO {a.icao}
- {a.altitude_ft != null &&
Altitude: {Math.round(a.altitude_ft)} ft
} - {a.ground_speed_kt != null &&
Speed: {Math.round(a.ground_speed_kt)} kt
} -
-
-
- ))} + {points.slice(1).map((p, i) => ( + + ))} + + ); +} + +function AircraftLayer() { + const { aircraft } = useAircraft(); + const [selected, setSelected] = useState(null); + const positioned = aircraft.filter((a) => a.lat != null && a.lon != null); + const selectedTrack = positioned.find((a) => a.icao === selected); + + return ( + <> + {selectedTrack && } + {positioned.map((a) => ( + setSelected(a.icao), + popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)), + }} + > + + {a.callsign || a.icao} + {a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`} + + +
+
{a.callsign || a.icao}
+
ICAO {a.icao}
+ {a.altitude_ft != null && ( +
+ + Altitude: {Math.round(a.altitude_ft).toLocaleString()} ft +
+ )} + {a.ground_speed_kt != null &&
Speed: {Math.round(a.ground_speed_kt)} kt
} + {a.track_deg != null &&
Heading: {Math.round(a.track_deg)}°
} +
+
+
+ ))} ); } diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 98bb5bd..ab1f048 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -74,6 +74,14 @@ export interface AircraftTrack { last_seen: string; } +/** One point of an aircraft's flight path — aircraft/{icao}/positions. */ +export interface AircraftTrailPoint { + lat: number; + lon: number; + altitude_ft: number | null; + t: string; +} + export interface VesselTrack { mmsi: string; org_id?: string; diff --git a/drb-frontend/lib/useAircraftTrail.ts b/drb-frontend/lib/useAircraftTrail.ts new file mode 100644 index 0000000..df42a84 --- /dev/null +++ b/drb-frontend/lib/useAircraftTrail.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { collection, onSnapshot, orderBy, query, where, FirestoreError } from "firebase/firestore"; +import { db } from "@/lib/firebase"; +import type { AircraftTrailPoint } from "@/lib/types"; + +// Trail points live at aircraft/{icao}/positions (written by c2-core +// telemetry.py on every position change, TTL-deleted after ~24h). The same +// icao can fly several legs a day, so only the latest continuous stretch is +// "this flight": a gap longer than FLIGHT_GAP_MS starts a new one. +const LOOKBACK_MS = 6 * 60 * 60 * 1000; +const FLIGHT_GAP_MS = 20 * 60 * 1000; + +function currentFlight(points: AircraftTrailPoint[]): AircraftTrailPoint[] { + let start = 0; + for (let i = 1; i < points.length; i++) { + if (new Date(points[i].t).getTime() - new Date(points[i - 1].t).getTime() > FLIGHT_GAP_MS) start = i; + } + return points.slice(start); +} + +/** Live flight path for one aircraft; pass null to subscribe to nothing. */ +export function useAircraftTrail(icao: string | null) { + const [trail, setTrail] = useState([]); + + useEffect(() => { + setTrail([]); + if (!icao) return; + // `t` is Python's isoformat() in UTC ("...T17:06:48.755123+00:00"), so it + // sorts and range-filters correctly as a string against toISOString()'s + // "...T17:06:48.755Z" down to the second — no composite index needed. + const since = new Date(Date.now() - LOOKBACK_MS).toISOString(); + const q = query(collection(db, "aircraft", icao, "positions"), where("t", ">=", since), orderBy("t")); + return onSnapshot( + q, + (snap) => setTrail(currentFlight(snap.docs.map((d) => d.data() as AircraftTrailPoint))), + (err: FirestoreError) => console.error("useAircraftTrail:", err), + ); + }, [icao]); + + return trail; +} diff --git a/infra/firestore/firestore.indexes.json b/infra/firestore/firestore.indexes.json index b210727..51647c7 100644 --- a/infra/firestore/firestore.indexes.json +++ b/infra/firestore/firestore.indexes.json @@ -77,5 +77,13 @@ ] } ], - "fieldOverrides": [] + "fieldOverrides": [ + { + "//": "TTL: flight-trail points (aircraft/{icao}/positions, server-26 telemetry.py) are deleted ~24h after expire_at. indexes: [] because nothing queries on expire_at.", + "collectionGroup": "positions", + "fieldPath": "expire_at", + "ttl": true, + "indexes": [] + } + ] } diff --git a/infra/firestore/firestore.rules b/infra/firestore/firestore.rules index 64bcbb0..3d98748 100644 --- a/infra/firestore/firestore.rules +++ b/infra/firestore/firestore.rules @@ -98,6 +98,14 @@ service cloud.firestore { match /aircraft/{icao} { allow read: if docInMyOrg(); allow write: if false; + + // Flight trail points. Org is checked against the PARENT aircraft doc + // (one get() per query) so the map can query a trail by time alone, + // without an org_id filter and the composite index that would need. + match /positions/{pointId} { + allow read: if inOrg(get(/databases/$(database)/documents/aircraft/$(icao)).data.org_id); + allow write: if false; + } } match /vessels/{mmsi} {