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

306 lines
11 KiB
Python

from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
# ---------------------------------------------------------------------------
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
# stripe_* fields are deliberately inert (None) here: no billing model has
# been decided yet (participation-based / reciprocal access, not per-seat
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
# is just the seam a future billing pass would write into, not a promise
# about what that pass looks like.
# ---------------------------------------------------------------------------
class OrganizationRecord(BaseModel):
org_id: str
name: str
created_at: datetime
created_by_uid: str
plan_id: Optional[str] = None
subscription_status: Optional[str] = None
stripe_customer_id: Optional[str] = None
stripe_subscription_id: Optional[str] = None
current_period_end: Optional[datetime] = None
seat_limit: Optional[int] = None
node_limit: Optional[int] = None
retention_days: Optional[int] = None
class OrgMember(BaseModel):
uid: str
org_id: str
org_role: str # "owner" | "member"
email: Optional[str] = None
added_at: datetime
class EnrollmentTokenRecord(BaseModel):
"""Firestore doc id is the SHA-256 hash of the raw token — see
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
org_id: str
label: str
created_at: datetime
created_by_uid: str
revoked: bool = False
uses: int = 0
# ---------------------------------------------------------------------------
# Nodes
# ---------------------------------------------------------------------------
class NodeRecord(BaseModel):
node_id: str
name: str
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
lat: float = 0.0
lon: float = 0.0
status: str = "offline" # online / offline / recording / unconfigured
configured: bool = False
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 CommandPayload(BaseModel):
action: str # discord_join / discord_leave / op25_restart
guild_id: Optional[str] = None
channel_id: Optional[str] = None
# ---------------------------------------------------------------------------
# Systems
# ---------------------------------------------------------------------------
class LocalKnowledgeEntry(BaseModel):
"""A local name and what it is. Both scopes, one shape (server-26#36)."""
term: str
meaning: Optional[str] = None
class AreaContextBody(BaseModel):
"""
Ground truth about the area a system or talkgroup covers.
Every field is nullable on purpose: which SCOPE an operator fills is their
declaration of how homogeneous the system is. A single-municipality system
is described once at system level and inherited by every talkgroup; a
statewide one is left null there and described per talkgroup. See
`internal/area_context.py` for the merge rules and the anchor.
`center`/`radius_km`/`resolved_from`/`resolved_at` are absent here by
design — the backend geocodes and writes those. A client that sends them is
ignored.
"""
municipality: Optional[str] = None
county: Optional[str] = None
state: Optional[str] = None
local_knowledge: List[LocalKnowledgeEntry] = []
class TalkgroupEntry(BaseModel):
"""
One entry in `config.talkgroups[]`.
Declared so the talkgroup copy of `area_context` stops being unvalidated
JSON riding inside the config blob — it is the same shape as the system's
and gets the same validator (server-26#36).
"""
model_config = {"extra": "allow"}
id: int
name: str = ""
tag: str = "other"
vocabulary: List[str] = []
area_context: Optional[AreaContextBody] = None
class SystemRecord(BaseModel):
system_id: str
org_id: Optional[str] = None
name: str
type: str # P25 / DMR / NBFM
config: Dict[str, Any] = {} # OP25-compatible config blob
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
# Ground truth about the area this system covers, fed to the transcript
# corrector and the place verifier (server-26#36 / #37). Shape is
# AreaContextBody plus the backend-owned anchor. Per-talkgroup overrides
# live inside config.talkgroups[] and rank ABOVE this, so a multi-county
# system narrows per channel rather than replacing this wholesale.
area_context: Dict[str, Any] = {}
class SystemCreate(BaseModel):
name: str
type: str
config: Dict[str, Any] = {}
ten_codes: Dict[str, str] = {}
area_context: Dict[str, Any] = {}
# ---------------------------------------------------------------------------
# Calls
# ---------------------------------------------------------------------------
class CallRecord(BaseModel):
call_id: str
node_id: str
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
system_id: Optional[str] = None
talkgroup_id: Optional[int] = None
talkgroup_name: Optional[str] = None
freq: Optional[float] = None
srcaddr: Optional[str] = None
started_at: datetime
ended_at: Optional[datetime] = None
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
duplicate_of: Optional[str] = None # another node recorded this same transmission first
transcript: Optional[str] = None # populated later by STT
incident_ids: List[str] = [] # one per scene detected in the recording
location: Optional[Dict[str, float]] = None # {lat, lng}
tags: List[str] = []
status: str = "active" # active / ended
# ---------------------------------------------------------------------------
# Incidents
# ---------------------------------------------------------------------------
class IncidentRecord(BaseModel):
incident_id: str
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
title: Optional[str] = None
type: Optional[str] = None # fire / police / ems / etc.
status: str = "active" # active / resolved
location: Optional[Dict[str, float]] = None
call_ids: List[str] = []
started_at: datetime
updated_at: datetime
summary: Optional[str] = None
tags: List[str] = []
class IncidentCreate(BaseModel):
title: str
type: str = "other"
status: str = "active"
location: Optional[Dict[str, float]] = None
call_ids: List[str] = []
summary: Optional[str] = None
tags: List[str] = []
class IncidentUpdate(BaseModel):
title: Optional[str] = None
type: Optional[str] = None
status: Optional[str] = None
location: Optional[Dict[str, float]] = None
summary: Optional[str] = None
tags: Optional[List[str]] = None
# ---------------------------------------------------------------------------
# Alerts
# ---------------------------------------------------------------------------
class AlertRule(BaseModel):
rule_id: Optional[str] = None
org_id: Optional[str] = None
name: str
keywords: List[str] = []
talkgroup_ids: List[int] = []
enabled: bool = True
discord_webhook: Optional[str] = None # POST here when rule fires
class AlertRuleUpdate(BaseModel):
name: Optional[str] = None
keywords: Optional[List[str]] = None
talkgroup_ids: Optional[List[int]] = None
enabled: Optional[bool] = None
discord_webhook: Optional[str] = None
class AlertEvent(BaseModel):
alert_id: Optional[str] = None
org_id: Optional[str] = None
rule_id: str
rule_name: str
call_id: str
node_id: str
talkgroup_id: Optional[int] = None
talkgroup_name: Optional[str] = None
matched_keywords: List[str] = []
transcript_snippet: Optional[str] = None
triggered_at: Optional[datetime] = None
acknowledged: bool = False
# ---------------------------------------------------------------------------
# Trips
# ---------------------------------------------------------------------------
class TripCreate(BaseModel):
name: str
location: str
maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel):
title: str
date: str # YYYY-MM-DD, must fall within parent trip range
start_time: Optional[str] = None # HH:MM (24h)
end_time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None
tags: List[str] = [] # tag labels applied to this event
class TripEventUpdate(BaseModel):
title: Optional[str] = None
date: Optional[str] = None
start_time: Optional[str] = None
end_time: Optional[str] = None
location: Optional[str] = None
maps_link: Optional[str] = None
place_id: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
class AttendeeAction(BaseModel):
discord_user_id: str
discord_username: Optional[str] = None