Wire ADS-B end to end: telemetry ingestion + live map overlay

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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-20 16:08:29 -04:00
co-authored by Claude Sonnet 5
parent 8892e824fc
commit 5537b095df
8 changed files with 264 additions and 1 deletions
+42
View File
@@ -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<string, unknown>)._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: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L15 11 L22 15 L15 15.5 L14 21 L17 22.5 L12 21.5 L7 22.5 L10 21 L9 15.5 L2 15 L9 11 Z"/></svg></div>`,
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) => (
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{a.callsign || a.icao}</div>
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
{a.altitude_ft != null && <div className="text-xs">Altitude: {Math.round(a.altitude_ft)} ft</div>}
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
))}
</>
);
}
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 = []
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
<LayersControl.Overlay name="Aircraft">
<FeatureGroup>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar">
<TileLayer