Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffff25cd2 | ||
|
|
3d2b722c64 | ||
|
|
5537b095df | ||
|
|
8892e824fc |
@@ -111,6 +111,8 @@ class MQTTHandler:
|
||||
"assigned_system_id": None,
|
||||
"approval_status": "pending",
|
||||
"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),
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
@@ -141,6 +143,11 @@ class MQTTHandler:
|
||||
updates["node_type"] = node_type
|
||||
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":
|
||||
updates["is_overridden"] = False
|
||||
updates["override_system_id"] = None
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -62,12 +62,43 @@ class NodeRecord(BaseModel):
|
||||
last_seen: Optional[datetime] = None
|
||||
assigned_system_id: Optional[str] = None
|
||||
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
|
||||
is_overridden: bool = False
|
||||
override_system_id: Optional[str] = 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):
|
||||
action: str # discord_join / discord_leave / op25_restart
|
||||
guild_id: Optional[str] = None
|
||||
|
||||
@@ -195,6 +195,7 @@ async def assign_system(
|
||||
class NodeUpdateBody(BaseModel):
|
||||
node_type: Optional[str] = None
|
||||
enforce_override_timeout: Optional[bool] = None
|
||||
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
|
||||
|
||||
|
||||
@router.patch("/{node_id}")
|
||||
@@ -227,6 +228,8 @@ async def update_node(
|
||||
}
|
||||
if updated_node.get("ppm_override") is not None:
|
||||
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)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -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)}
|
||||
@@ -522,11 +522,18 @@ async def _run_intelligence_pipeline(
|
||||
|
||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||
# talkgroup-based linking even when no transcript could be produced.
|
||||
# Skip when extraction flagged the call — garbage or too-short transcripts
|
||||
# carry no signal and would only attach spuriously via the thin path.
|
||||
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
||||
# 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:
|
||||
_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(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
|
||||
@@ -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()
|
||||
@@ -15,6 +15,8 @@ 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";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
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 {
|
||||
const n = members.length;
|
||||
const CARD = 13;
|
||||
@@ -577,6 +646,20 @@ 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: 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 */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
|
||||
@@ -53,12 +53,39 @@ 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 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 {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -95,6 +95,18 @@ 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 /vessels/{mmsi} {
|
||||
allow read: if docInMyOrg();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
match /alert_events/{alertId} {
|
||||
allow read: if docInMyOrg();
|
||||
allow write: if false;
|
||||
|
||||
Reference in New Issue
Block a user