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
+14 -12
View File
@@ -158,6 +158,9 @@ async def on_command(payload: dict):
)
elif action == "discord_leave":
await radio_bot.leave()
elif action == "set_secondary_priority":
from app.internal.secondary_priority import set_secondary_priority
await set_secondary_priority(payload.get("priority") or [])
elif action == "op25_restart":
from app.internal.op25_client import op25_client
await op25_client.stop()
@@ -224,7 +227,8 @@ async def on_config_push(payload: dict):
hardware_preset = payload.pop("hardware_preset", None)
ppm_override = payload.pop("ppm_override", None)
node_type = payload.pop("node_type", None)
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None)
secondary_sdr_priority = payload.pop("secondary_sdr_priority", None)
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) # legacy single-mode C2
enforce_override_timeout = payload.pop("enforce_override_timeout", None)
try:
config = SystemConfig(**payload)
@@ -244,8 +248,6 @@ async def on_config_push(payload: dict):
node_cfg.ppm_override = float(ppm_override)
if node_type is not None:
node_cfg.node_type = node_type
if secondary_sdr_mode is not None:
node_cfg.secondary_sdr_mode = secondary_sdr_mode
if enforce_override_timeout is not None:
node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
save_node_config(node_cfg)
@@ -260,12 +262,11 @@ async def on_config_push(payload: dict):
await op25_client.start()
logger.info(f"Config push applied: {config.name}")
if secondary_sdr_mode is not None:
from app.internal.secondary_sdr_client import secondary_sdr_client
if secondary_sdr_mode in ("adsb", "ais"):
await secondary_sdr_client.start(secondary_sdr_mode)
else:
await secondary_sdr_client.stop()
if secondary_sdr_priority is None and secondary_sdr_mode is not None:
secondary_sdr_priority = [secondary_sdr_mode]
if secondary_sdr_priority is not None:
from app.internal.secondary_priority import set_secondary_priority
await set_secondary_priority(secondary_sdr_priority)
# ---------------------------------------------------------------------------
@@ -322,10 +323,11 @@ async def lifespan(app: FastAPI):
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
await asyncio.sleep(3)
if node_cfg.secondary_sdr_mode in ("adsb", "ais"):
# After op25 has claimed its dongle, so the decoders only get the spares.
if node_cfg.secondary_sdr_priority:
from app.internal.secondary_sdr_client import secondary_sdr_client
logger.info(f"Resuming secondary SDR (mode={node_cfg.secondary_sdr_mode!r}) after restart.")
await secondary_sdr_client.start(node_cfg.secondary_sdr_mode)
logger.info(f"Resuming secondary SDRs (priority={node_cfg.secondary_sdr_priority!r}) after restart.")
await secondary_sdr_client.apply(node_cfg.secondary_sdr_priority)
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
from app.internal.telemetry_uplink import telemetry_uplink_loop