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>
72 lines
2.2 KiB
Python
72 lines
2.2 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)}
|