Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffff25cd2 | ||
|
|
3d2b722c64 | ||
|
|
5537b095df | ||
|
|
8892e824fc |
@@ -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
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
# 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,
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user