Secondary SDR priority: every spare SDR runs the next decoder in an ordered list
CI / lint (push) Successful in 6s
CI / test (push) Successful in 39s

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>
This commit is contained in:
Logan Cusano
2026-09-27 14:08:07 -04:00
co-authored by Claude Opus 5.5
parent 1c74edffea
commit 9b6c64fbbf
11 changed files with 472 additions and 117 deletions
+26 -2
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Optional
from typing import List, Optional
import asyncio
import httpx
from app.config import settings
from app.models import SystemConfig
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
@@ -204,6 +204,30 @@ async def ack_override(timeout_minutes: int = Body(1440)):
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)