4 Commits
Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 1ffff25cd2 upload: let short transcripts (<=5 words) attempt correlation instead of never linking at all
Build & Deploy / Build & push images (push) Successful in 4m8s
Build & Deploy / Deploy to VM (push) Failing after 2m21s
Build & Deploy / Report a failed deploy (push) Successful in 1s
intelligence.py skips GPT extraction for transcripts <=5 words ("10-8", "show
me clear", a unit check-in) -- real cost/hallucination guard, kept as-is. But
upload.py's no-scenes correlation fallback (the path that thin-links a call
by talkgroup even with zero extracted content) excluded ANY skip_reason,
including transcript_too_short -- so this exact population, brief but real
follow-up and clearance traffic, never even attempted to attach to anything.
Found live: "Live, Ossining." sitting an orphan 6 seconds before a real
incident's founding call, on the same talkgroup.

Now only garbage_transcript (Whisper hallucination, no real content) stays
excluded; transcript_too_short reaches the same thin/fast-path fallback
already trusted for no-transcript calls, gated the same way -- same-talkgroup,
recently-active incident required before anything attaches. No GPT re-invoked,
no new cost.

Verified: 410 pass, 0 fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:39:46 -04:00
Logan CusanoandClaude Sonnet 5 3d2b722c64 Wire AIS end to end: telemetry ingestion + live map overlay
node-26#9, same shape as the ADS-B commit. Adds POST /telemetry/ais
(same node-key auth, same org_id-stamped upsert-by-key pattern, this time
by mmsi into a new `vessels` collection) and its docInMyOrg() firestore
rule. Frontend gets useVessels() (mirrors useAircraft(), longer staleness
window since AIS position reports are minutes apart, not seconds) and an
opt-in "Vessels" map overlay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:12:56 -04:00
Logan CusanoandClaude Sonnet 5 5537b095df 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>
2026-09-20 16:08:29 -04:00
Logan CusanoandClaude Sonnet 5 8892e824fc Add second-SDR fields: secondary_sdr_mode + sdr_count on NodeRecord
Server-side half of node-26#9. NodeRecord gains secondary_sdr_mode
(none|adsb|ais|op25_2) and sdr_count; checkin ingestion stores both,
and PATCH /nodes/{id} accepts and re-pushes secondary_sdr_mode the same
way hardware_preset/ppm_override already work, so it isn't wiped by a
system reassignment (see server-26#111 for the pre-existing bug that
pattern avoids repeating).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 15:56:33 -04:00
13 changed files with 561 additions and 4 deletions
+7
View File
@@ -111,6 +111,8 @@ class MQTTHandler:
"assigned_system_id": None, "assigned_system_id": None,
"approval_status": "pending", "approval_status": "pending",
"node_type": payload.get("node_type", "fixed"), "node_type": payload.get("node_type", "fixed"),
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"sdr_count": payload.get("sdr_count", 1),
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
@@ -141,6 +143,11 @@ class MQTTHandler:
updates["node_type"] = node_type updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout updates["enforce_override_timeout"] = enforce_timeout
if "secondary_sdr_mode" in payload:
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
if "sdr_count" in payload:
updates["sdr_count"] = payload["sdr_count"]
if node_type == "portable": if node_type == "portable":
updates["is_overridden"] = False updates["is_overridden"] = False
updates["override_system_id"] = None updates["override_system_id"] = None
+2 -1
View File
@@ -17,7 +17,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token, 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 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 dynsec
from app.internal import firestore as fstore 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 # write routes inside carry their own require_admin_token, so nodes get read
# access only. # access only.
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)]) 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(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.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)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
+31
View File
@@ -62,12 +62,43 @@ class NodeRecord(BaseModel):
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable node_type: str = "fixed" # fixed or portable
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
enforce_override_timeout: bool = True enforce_override_timeout: bool = True
is_overridden: bool = False is_overridden: bool = False
override_system_id: Optional[str] = None override_system_id: Optional[str] = None
override_timeout_at: Optional[datetime] = None 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 VesselTrack(BaseModel):
"""Live AIS position, one doc per mmsi. Same live-snapshot shape as
AircraftTrack — overwritten on every sighting (see node-26#9)."""
mmsi: str
org_id: Optional[str] = None
node_id: str
name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
speed_kt: Optional[float] = None
heading_deg: Optional[float] = None
last_seen: datetime
class CommandPayload(BaseModel): class CommandPayload(BaseModel):
action: str # discord_join / discord_leave / op25_restart action: str # discord_join / discord_leave / op25_restart
guild_id: Optional[str] = None guild_id: Optional[str] = None
+3
View File
@@ -195,6 +195,7 @@ async def assign_system(
class NodeUpdateBody(BaseModel): class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None enforce_override_timeout: Optional[bool] = None
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
@router.patch("/{node_id}") @router.patch("/{node_id}")
@@ -227,6 +228,8 @@ async def update_node(
} }
if updated_node.get("ppm_override") is not None: if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"] push_payload["ppm_override"] = updated_node["ppm_override"]
if updated_node.get("secondary_sdr_mode") is not None:
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
mqtt_handler.push_config(node_id, push_payload) mqtt_handler.push_config(node_id, push_payload)
return {"ok": True} return {"ok": True}
+125
View File
@@ -0,0 +1,125 @@
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)}
class VesselReport(BaseModel):
mmsi: str
name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
speed_kt: Optional[float] = None
heading_deg: Optional[float] = None
class AisUploadBody(BaseModel):
vessels: List[VesselReport]
@router.post("/ais")
async def upload_ais(
body: AisUploadBody,
decoded: dict = Depends(require_node_service_or_firebase_token),
):
"""Same shape as /telemetry/adsb, one doc per mmsi in `vessels`."""
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 v in body.vessels:
if not v.mmsi:
continue
doc = {
"mmsi": v.mmsi,
"node_id": node_id,
"name": v.name,
"lat": v.lat,
"lon": v.lon,
"speed_kt": v.speed_kt,
"heading_deg": v.heading_deg,
"last_seen": now,
}
if org_id:
doc["org_id"] = org_id
writes.append(("vessels", v.mmsi, 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)}
+10 -3
View File
@@ -522,11 +522,18 @@ async def _run_intelligence_pipeline(
# Correlator also runs for calls with no scenes (unclassified) to attempt # Correlator also runs for calls with no scenes (unclassified) to attempt
# talkgroup-based linking even when no transcript could be produced. # talkgroup-based linking even when no transcript could be produced.
# Skip when extraction flagged the call — garbage or too-short transcripts # transcript_too_short (<=5 words: "10-8", "show me clear", a unit
# carry no signal and would only attach spuriously via the thin path. # check-in) still carries a real transcript and talkgroup — exactly the
# brief follow-up/clearance traffic an incident needs, and the thin-path
# merge below already requires a same-talkgroup, recently-active
# incident before attaching anything, same guard already trusted for
# no-transcript calls. Previously excluded here, so these calls never
# attached to anything at all. garbage_transcript (Whisper
# hallucination) has no real content behind it and stays excluded.
if not scenes: if not scenes:
_call_doc = await fstore.doc_get("calls", call_id) _call_doc = await fstore.doc_get("calls", call_id)
if not (_call_doc or {}).get("skip_reason"): skip_reason = (_call_doc or {}).get("skip_reason")
if not skip_reason or skip_reason == "transcript_too_short":
incident_id = await _correlate_with_consensus( incident_id = await _correlate_with_consensus(
call_id=call_id, call_id=call_id,
node_id=node_id, node_id=node_id,
+94
View File
@@ -0,0 +1,94 @@
"""
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()
def test_ais_service_token_without_node_id_is_rejected():
_override({"service": True})
resp = client.post("/telemetry/ais", json={"vessels": []})
assert resp.status_code == 400
def test_ais_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/ais", json={
"vessels": [{"mmsi": "123456789", "name": "MV TEST", "lat": 41.0, "lon": -73.9}],
})
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 == "vessels"
assert doc_id == "123456789"
assert doc["node_id"] == "node-1"
assert doc["org_id"] == "org-A"
assert kwargs.get("merge") is True
def test_ais_node_upload_skips_entries_missing_mmsi():
_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/ais", json={"vessels": [{"mmsi": ""}]})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 0}
mock_set.assert_not_awaited()
@@ -0,0 +1,62 @@
"""
server-26#<pending> — a transcript_too_short call (<=5 words: "10-8", "show me
clear", a unit check-in) never reached correlation at all. upload.py's
no-scenes fallback (the path that lets a no-transcript call still thin-link
by talkgroup) explicitly excluded ANY skip_reason, so short-but-real follow-up
and clearance traffic was permanently unlinkable — not just unextracted by
GPT, but never even attempted against the fast/thin path that already exists
for exactly this kind of content-free signal. garbage_transcript (Whisper
hallucination) has no real content behind it and should stay excluded.
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.routers import upload
ALL_ON = {
"stt_enabled": True,
"correlation_enabled": True,
"summaries_enabled": True,
"vocabulary_learning_enabled": True,
"transcript_correction_enabled": True,
}
async def _run_ingest(skip_reason):
with patch("app.internal.feature_flags.get_flags",
AsyncMock(return_value=ALL_ON)), \
patch("app.internal.firestore.doc_get_cached",
AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \
patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
patch("app.internal.transcription.transcribe_call",
AsyncMock(return_value=("10-8", []))), \
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \
patch("app.internal.alerter.check_and_dispatch", AsyncMock()):
fs.doc_get = AsyncMock(return_value={"skip_reason": skip_reason} if skip_reason else {})
fs.doc_set = AsyncMock()
await upload._run_intelligence_pipeline(
call_id="call-1", node_id="node-1", system_id="sys-1",
talkgroup_id=101, talkgroup_name="PD Dispatch",
gcs_uri="gs://bucket/call-1.mp3",
)
return corr
@pytest.mark.asyncio
async def test_transcript_too_short_now_attempts_correlation():
corr = await _run_ingest("transcript_too_short")
corr.assert_awaited_once()
@pytest.mark.asyncio
async def test_garbage_transcript_still_skips_correlation():
corr = await _run_ingest("garbage_transcript")
corr.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_skip_reason_still_attempts_correlation():
corr = await _run_ingest(None)
corr.assert_awaited_once()
+83
View File
@@ -15,6 +15,8 @@ import L from "leaflet";
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -90,6 +92,73 @@ 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>
))}
</>
);
}
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
function vesselIcon(headingDeg: number | null): L.DivIcon {
const size = 14;
const rotation = headingDeg ?? 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 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function VesselLayer() {
const { vessels } = useVessels();
return (
<>
{vessels
.filter((v) => v.lat != null && v.lon != null)
.map((v) => (
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{v.name || v.mmsi}</div>
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
))}
</>
);
}
function nodeFanIcon(members: NodeRecord[]): L.DivIcon { function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
const n = members.length; const n = members.length;
const CARD = 13; const CARD = 13;
@@ -577,6 +646,20 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </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: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
<LayersControl.Overlay name="Vessels">
<FeatureGroup>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
<TileLayer <TileLayer
+27
View File
@@ -53,12 +53,39 @@ export interface NodeRecord {
hardware_preset?: string; hardware_preset?: string;
ppm_override?: number | null; ppm_override?: number | null;
node_type?: string; node_type?: string;
secondary_sdr_mode?: string;
sdr_count?: number;
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;
is_overridden?: boolean; is_overridden?: boolean;
override_system_id?: string | null; override_system_id?: string | null;
override_timeout_at?: 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 VesselTrack {
mmsi: string;
org_id?: string;
node_id: string;
name: string | null;
lat: number | null;
lon: number | null;
speed_kt: number | null;
heading_deg: number | null;
last_seen: string;
}
export interface VocabularyPendingTerm { export interface VocabularyPendingTerm {
term: string; term: string;
source: "induction" | "correction"; 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 };
}
+53
View File
@@ -0,0 +1,53 @@
"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 { VesselTrack } from "@/lib/types";
// Same shape as useAircraft — `vessels` is a live snapshot (one per mmsi,
// overwritten on every sighting, node-26#9), nothing prunes a doc when a
// vessel goes out of range, so staleness is filtered client-side. AIS
// position reports are much less frequent than ADS-B (minutes, not
// seconds), so this window is longer than useAircraft's.
const STALE_AFTER_MS = 10 * 60 * 1000;
export function useVessels() {
const [vessels, setVessels] = useState<VesselTrack[]>([]);
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) {
setVessels([]);
setLoading(false);
return;
}
const q = query(collection(db, "vessels"), where("org_id", "==", orgId));
unsubFirestore = onSnapshot(q, (snap) => {
const now = Date.now();
const fresh = snap.docs
.map((d) => d.data() as VesselTrack)
.filter((v) => now - new Date(v.last_seen).getTime() < STALE_AFTER_MS);
setVessels(fresh);
setLoading(false);
}, (err: FirestoreError) => { console.error("useVessels:", err); setError(err.message); setLoading(false); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [orgId]);
return { vessels, loading, error };
}
+12
View File
@@ -95,6 +95,18 @@ service cloud.firestore {
allow write: if false; 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 /vessels/{mmsi} {
allow read: if docInMyOrg();
allow write: if false;
}
match /alert_events/{alertId} { match /alert_events/{alertId} {
allow read: if docInMyOrg(); allow read: if docInMyOrg();
allow write: if false; allow write: if false;