Files
server-26/drb-c2-core/app/routers/telemetry.py
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

126 lines
3.7 KiB
Python

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)}