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: + `