Files
node-26/drb-edge-node/app/routers/api.py
T
Logan CusanoandClaude Opus 5.5 9b6c64fbbf
CI / lint (push) Successful in 6s
CI / test (push) Successful in 39s
Secondary SDR priority: every spare SDR runs the next decoder in an ordered list
Replaces the single secondary_sdr_mode with secondary_sdr_priority, e.g.
["adsb", "ais"]. OP25 always keeps its own dongle; the secondary-sdr
container starts decoders top-down until it runs out of free SDRs, so a
3-SDR node runs ADS-B and AIS at once and a 2-SDR node runs the top pick.

- secondary-sdr: one decoder per mode, POST /secondary/apply(priority)
  (no-op when the right prefix is already running), orphaned decoders
  from a uvicorn reload are reaped on start, /status reports sdr_count
  via lsusb (op25's :stable image has none).
- edge-node: one apply path (set_secondary_priority) for the local
  dashboard, a new C2 'set_secondary_priority' MQTT command, and config
  pushes; it never restarts op25. Legacy mode migrates on load. Checkin
  reports priority, what's running, and sdr_count. Uplink forwards
  aircraft and vessels whenever either is present.
- Local dashboard: 'Secondary SDRs' card to enable/reorder/save.

Verified: edge-node pytest 190 passed, flake8 clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:08:07 -04:00

243 lines
8.9 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Body
from typing import List, Optional
import asyncio
import httpx
from app.config import settings
from app.models import SystemConfig, SECONDARY_SDR_MODES
from app.internal.op25_client import op25_client
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config
from app.internal.call_recorder import call_recorder
from app.internal.discord_radio import radio_bot
from app.internal.metadata_watcher import metadata_watcher
from app.internal import credentials
from app.internal.mqtt_manager import mqtt_manager
from app.internal import auth
# Every route in this router requires auth — a valid dashboard session cookie
# or HTTP Basic (see app/internal/auth.py). No exemption exists for any route
# here: there is no health/liveness endpoint in this file or anywhere else in
# the edge node (confirmed against source — no docker healthcheck references
# one either), so nothing needs to stay open for a container healthcheck.
router = APIRouter(prefix="/api", tags=["api"], dependencies=[Depends(auth.require_auth)])
@router.get("/status")
async def get_status():
node_cfg = load_node_config()
op25_status = await op25_client.status()
active_tgid = metadata_watcher.current_tgid
active_tgid_name = metadata_watcher.current_tgid_name
system_name = None
active_config = (
node_cfg.override_config
if (node_cfg.override_system_id and node_cfg.override_config)
else node_cfg.system_config
)
if active_config:
system_name = active_config.name
if active_tgid:
# Reverse engineer the TGID to talkgroup name based on active system object
tgs = active_config.config.get("talkgroups", [])
for tg in tgs:
if str(tg.get("id")) == str(active_tgid):
active_tgid_name = tg.get("name")
break
return {
"node_id": settings.node_id,
"node_name": settings.node_name,
"lat": settings.node_lat,
"lon": settings.node_lon,
"node_type": node_cfg.node_type,
"enforce_override_timeout": node_cfg.enforce_override_timeout,
"is_overridden": node_cfg.override_system_id is not None and node_cfg.node_type != "portable",
"override_system_id": node_cfg.override_system_id,
"configured": node_cfg.configured,
"assigned_system_id": node_cfg.assigned_system_id,
"system_name": system_name,
"is_recording": call_recorder.is_recording,
# Health of the PulseAudio capture that feeds every recording — the single
# most useful signal when recordings come back empty. Segment boundaries
# come from this stream, so audio_silence_seconds is also how far the
# node currently is from closing whatever it is recording.
"audio_capture": call_recorder.is_capturing,
"buffered_seconds": round(call_recorder.buffered_seconds, 1),
"audio_silence_seconds": round(call_recorder.audio_activity().silence_seconds, 1),
# Audio that was recorded but had no OP25 talkgroup anywhere near it, so
# it was discarded rather than uploaded. Non-zero means either the
# console is not decoding or something else is feeding drb_sink.
"unattributed_segments": metadata_watcher.unattributed_segments,
"active_tgid": active_tgid,
"active_tgid_name": active_tgid_name,
"active_call_id": metadata_watcher.active_call_id,
"discord_connected": radio_bot.is_connected,
"icecast_url": (
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}"
),
"op25": op25_status,
}
@router.post("/op25/start")
async def start_op25():
ok = await op25_client.start()
if not ok:
raise HTTPException(500, "Failed to start OP25")
return {"ok": True}
@router.post("/op25/stop")
async def stop_op25():
ok = await op25_client.stop()
if not ok:
raise HTTPException(500, "Failed to stop OP25")
return {"ok": True}
@router.get("/config")
async def get_config():
return load_node_config()
@router.post("/config/system")
async def set_system_config(config: SystemConfig):
"""
Apply a system config locally — called by the web UI or pushed by C2.
Writes the OP25 config and persists the node config.
"""
node_cfg = load_node_config()
node_cfg.assigned_system_id = config.system_id
node_cfg.system_config = config
node_cfg.configured = True
save_node_config(node_cfg)
apply_system_config(config)
return {"ok": True}
@router.get("/systems")
async def get_systems():
from app.internal.system_cacher import load_cached_systems, fetch_and_cache_systems
asyncio.create_task(fetch_and_cache_systems())
return load_cached_systems()
@router.post("/config/override")
async def set_override(
system_id: Optional[str] = Body(None),
system_config: Optional[dict] = Body(None)
):
node_cfg = load_node_config()
config = None
if system_id:
from app.internal.system_cacher import get_cached_system
cached = get_cached_system(system_id)
if not cached:
raise HTTPException(404, f"System '{system_id}' not found in cache.")
config = SystemConfig(**cached)
elif system_config:
if not all(k in system_config for k in ("system_id", "name", "type", "config")):
raise HTTPException(400, "Invalid manual system config schema.")
config = SystemConfig(**system_config)
else:
raise HTTPException(400, "Must specify system_id or system_config.")
node_cfg.override_system_id = config.system_id
node_cfg.override_config = config
save_node_config(node_cfg)
from app.main import _generate_op25_config
if not await _generate_op25_config(config):
raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}")
await op25_client.stop()
await asyncio.sleep(2)
await op25_client.start()
await mqtt_manager._publish_checkin()
return {"ok": True}
@router.post("/config/revert")
async def revert_config():
node_cfg = load_node_config()
if not node_cfg.override_system_id:
return {"ok": True, "message": "No override active."}
node_cfg.override_system_id = None
node_cfg.override_config = None
save_node_config(node_cfg)
if node_cfg.system_config:
from app.main import _generate_op25_config
if not await _generate_op25_config(node_cfg.system_config):
raise HTTPException(500, "Failed to regenerate original OP25 config.")
await op25_client.stop()
await asyncio.sleep(2)
await op25_client.start()
await mqtt_manager._publish_checkin()
return {"ok": True}
@router.post("/config/override/ack")
async def ack_override(timeout_minutes: int = Body(1440)):
if not settings.c2_url:
raise HTTPException(400, "C2_URL not configured.")
api_key = credentials.get_api_key()
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{settings.c2_url}/nodes/{settings.node_id}/override/ack",
json={"timeout_minutes": timeout_minutes},
headers=headers
)
r.raise_for_status()
return r.json()
except Exception as e:
raise HTTPException(500, f"Failed to contact C2: {e}")
@router.get("/secondary")
async def get_secondary():
"""Secondary SDR priority plus what's actually running (node-26#9)."""
from app.internal.secondary_sdr_client import secondary_sdr_client
status = await secondary_sdr_client.status()
return {
"priority": load_node_config().secondary_sdr_priority,
"modes": list(SECONDARY_SDR_MODES),
"running": [r["mode"] for r in status.get("running", [])] if status else None,
"sdr_count": status.get("sdr_count") if status else None,
}
@router.post("/secondary/priority")
async def set_secondary_priority(priority: List[str] = Body(..., embed=True)):
"""Set the ordered list the spare SDRs work through. Never restarts op25."""
from app.internal.secondary_priority import set_secondary_priority as apply_priority
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
if unknown:
raise HTTPException(400, f"Unknown secondary SDR mode(s): {unknown}")
running = await apply_priority(priority)
return {"ok": True, "priority": load_node_config().secondary_sdr_priority, "running": running}
@router.post("/discord/join")
async def discord_join(guild_id: int, channel_id: int):
ok = await radio_bot.join(guild_id, channel_id)
if not ok:
raise HTTPException(500, "Failed to join voice channel")
return {"ok": True}
@router.post("/discord/leave")
async def discord_leave():
await radio_bot.leave()
return {"ok": True}