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
+15
View File
@@ -53,12 +53,27 @@ export interface NodeRecord {
hardware_preset?: string;
ppm_override?: number | null;
node_type?: string;
secondary_sdr_mode?: string;
sdr_count?: number;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
override_system_id?: string | null;
override_timeout_at?: string | null;
}
export interface AircraftTrack {
icao: string;
org_id?: string;
node_id: string;
callsign: string | null;
lat: number | null;
lon: number | null;
altitude_ft: number | null;
ground_speed_kt: number | null;
track_deg: number | null;
last_seen: string;
}
export interface VocabularyPendingTerm {
term: string;
source: "induction" | "correction";
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { AircraftTrack } from "@/lib/types";
// `aircraft` docs are a live snapshot (one per icao, overwritten on every
// sighting, node-26#9) — nothing prunes a doc when a plane leaves range, so
// staleness is filtered client-side rather than assuming the collection only
// ever holds current traffic.
const STALE_AFTER_MS = 2 * 60 * 1000;
export function useAircraft() {
const [aircraft, setAircraft] = useState<AircraftTrack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}