From 5537b095dfe5879e3e0f7dfccfcdf59ae9ccd9fb Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 20 Sep 2026 16:08:29 -0400 Subject: [PATCH] Wire ADS-B end to end: telemetry ingestion + live map overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-26#9. Adds POST /telemetry/adsb (node-key authed via require_node_service_or_firebase_token) that upserts one Firestore doc per icao into a new `aircraft` collection, org_id stamped from the reporting node the same way upload.py defensively stamps `calls`. firestore.rules gets a matching docInMyOrg()-gated read rule. Frontend: useAircraft() mirrors useNodes()'s onSnapshot pattern, filtering docs older than 2 minutes client-side since nothing prunes a stale aircraft doc server-side yet. MapView gets an opt-in "Aircraft" overlay (unchecked by default, like the weather radar layer) rendering a rotated plane glyph per sighting. Unverified via typecheck — no Node.js/npm on this authoring box yet (see CLAUDE.md Testing reality). Server side is pytest-covered (test_telemetry.py). Co-Authored-By: Claude Sonnet 5 --- drb-c2-core/app/main.py | 3 +- drb-c2-core/app/models.py | 15 ++++++ drb-c2-core/app/routers/telemetry.py | 71 ++++++++++++++++++++++++++++ drb-c2-core/tests/test_telemetry.py | 60 +++++++++++++++++++++++ drb-frontend/components/MapView.tsx | 42 ++++++++++++++++ drb-frontend/lib/types.ts | 15 ++++++ drb-frontend/lib/useAircraft.ts | 52 ++++++++++++++++++++ infra/firestore/firestore.rules | 7 +++ 8 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 drb-c2-core/app/routers/telemetry.py create mode 100644 drb-c2-core/tests/test_telemetry.py create mode 100644 drb-frontend/lib/useAircraft.ts diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index eca5a4f..0d76464 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -17,7 +17,7 @@ from app.internal.auth import ( require_node_service_or_firebase_token, ) from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users -from app.routers import enrollment, media, org, waitlist +from app.routers import enrollment, media, org, waitlist, telemetry from app.internal import dynsec from app.internal import firestore as fstore @@ -120,6 +120,7 @@ app.include_router(nodes.router, dependencies=[Depends(require_service_or_fi # write routes inside carry their own require_admin_token, so nodes get read # access only. app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)]) +app.include_router(telemetry.router, dependencies=[Depends(require_node_service_or_firebase_token)]) app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)]) diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 4ddd692..db0db2f 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -70,6 +70,21 @@ class NodeRecord(BaseModel): override_timeout_at: Optional[datetime] = None +class AircraftTrack(BaseModel): + """Live ADS-B position, one doc per icao. Overwritten on every sighting — + this is a live-map snapshot, not a history (see node-26#9).""" + icao: str + org_id: Optional[str] = None + node_id: str + callsign: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + altitude_ft: Optional[float] = None + ground_speed_kt: Optional[float] = None + track_deg: Optional[float] = None + last_seen: datetime + + class CommandPayload(BaseModel): action: str # discord_join / discord_leave / op25_restart guild_id: Optional[str] = None diff --git a/drb-c2-core/app/routers/telemetry.py b/drb-c2-core/app/routers/telemetry.py new file mode 100644 index 0000000..faf80d0 --- /dev/null +++ b/drb-c2-core/app/routers/telemetry.py @@ -0,0 +1,71 @@ +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.internal import firestore as fstore +from app.internal.auth import require_node_service_or_firebase_token +from app.internal.logger import logger + +router = APIRouter(prefix="/telemetry", tags=["telemetry"]) + + +class AircraftReport(BaseModel): + icao: str + callsign: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + altitude_ft: Optional[float] = None + ground_speed_kt: Optional[float] = None + track_deg: Optional[float] = None + + +class AdsbUploadBody(BaseModel): + aircraft: List[AircraftReport] + + +@router.post("/adsb") +async def upload_adsb( + body: AdsbUploadBody, + decoded: dict = Depends(require_node_service_or_firebase_token), +): + """ + 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. + """ + node_id = decoded.get("node_id") + if not node_id: + raise HTTPException(400, "This endpoint requires node identity, not a service/admin token.") + + node = await fstore.doc_get_cached("nodes", node_id) + org_id = node.get("org_id") if node else None + now = datetime.now(timezone.utc).isoformat() + + writes = [] + for ac in body.aircraft: + if not ac.icao: + continue + doc = { + "icao": ac.icao, + "node_id": node_id, + "callsign": ac.callsign, + "lat": ac.lat, + "lon": ac.lon, + "altitude_ft": ac.altitude_ft, + "ground_speed_kt": ac.ground_speed_kt, + "track_deg": ac.track_deg, + "last_seen": now, + } + if org_id: + doc["org_id"] = org_id + writes.append(("aircraft", ac.icao, doc)) + + for collection, doc_id, doc in writes: + 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}") + + return {"ok": True, "count": len(writes)} diff --git a/drb-c2-core/tests/test_telemetry.py b/drb-c2-core/tests/test_telemetry.py new file mode 100644 index 0000000..50b253c --- /dev/null +++ b/drb-c2-core/tests/test_telemetry.py @@ -0,0 +1,60 @@ +""" +node-26#9 — second-SDR ADS-B telemetry ingestion. + +Two things matter here: the endpoint requires node identity (a service/admin +token has no node_id to attribute the sighting to, so it must 400 rather than +silently write an orphan doc), and org_id gets stamped from the node's own +Firestore doc so firestore.rules' docInMyOrg() can gate the frontend's read — +the same defensive-stamp pattern upload.py already uses for `calls`. +""" +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from app.main import app +from app.internal.auth import require_node_service_or_firebase_token +from app.routers import telemetry + +client = TestClient(app) + + +def _override(decoded: dict): + app.dependency_overrides[require_node_service_or_firebase_token] = lambda: decoded + + +def teardown_function(): + app.dependency_overrides.pop(require_node_service_or_firebase_token, None) + + +def test_service_token_without_node_id_is_rejected(): + _override({"service": True}) + resp = client.post("/telemetry/adsb", json={"aircraft": []}) + assert resp.status_code == 400 + + +def test_node_upload_upserts_and_stamps_org_id(): + _override({"node": True, "node_id": "node-1"}) + 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": [{"icao": "A1B2C3", "callsign": "UAL123", "lat": 41.1, "lon": -73.8}], + }) + 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 + assert collection == "aircraft" + assert doc_id == "A1B2C3" + assert doc["node_id"] == "node-1" + assert doc["org_id"] == "org-A" + assert kwargs.get("merge") is True + + +def test_node_upload_skips_entries_missing_icao(): + _override({"node": True, "node_id": "node-1"}) + with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value=None)), \ + patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set: + resp = client.post("/telemetry/adsb", json={"aircraft": [{"icao": ""}]}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "count": 0} + mock_set.assert_not_awaited() diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index ece572f..ae14c3b 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -15,6 +15,7 @@ import L from "leaflet"; import type { 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"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; @@ -90,6 +91,40 @@ 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; + return L.divIcon({ + className: "", + html: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + +function AircraftLayer() { + const { aircraft } = useAircraft(); + 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
} +
+
+
+ ))} + + ); +} + function nodeFanIcon(members: NodeRecord[]): L.DivIcon { const n = members.length; const CARD = 13; @@ -577,6 +612,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] + {/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */} + + + + + + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} ([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { orgId } = useAuth(); + + useEffect(() => { + let unsubFirestore: (() => void) | undefined; + + const unsubAuth = onAuthStateChanged(auth, (user) => { + if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } + + if (!user || !orgId) { + setAircraft([]); + setLoading(false); + return; + } + + const q = query(collection(db, "aircraft"), where("org_id", "==", orgId)); + unsubFirestore = onSnapshot(q, (snap) => { + const now = Date.now(); + const fresh = snap.docs + .map((d) => d.data() as AircraftTrack) + .filter((a) => now - new Date(a.last_seen).getTime() < STALE_AFTER_MS); + setAircraft(fresh); + setLoading(false); + }, (err: FirestoreError) => { console.error("useAircraft:", err); setError(err.message); setLoading(false); }); + }); + + return () => { + unsubAuth(); + if (unsubFirestore) unsubFirestore(); + }; + }, [orgId]); + + return { aircraft, loading, error }; +} diff --git a/infra/firestore/firestore.rules b/infra/firestore/firestore.rules index 9510c9a..7655348 100644 --- a/infra/firestore/firestore.rules +++ b/infra/firestore/firestore.rules @@ -95,6 +95,13 @@ service cloud.firestore { allow write: if false; } + // Live map overlays fed by a node's second SDR (node-26#9). Snapshot + // docs, one per icao/mmsi, last-seen-wins — not a history collection. + match /aircraft/{icao} { + allow read: if docInMyOrg(); + allow write: if false; + } + match /alert_events/{alertId} { allow read: if docInMyOrg(); allow write: if false;