Secondary SDR priority: C2 field, node command, dashboard section

Pairs with node-26 feat/secondary-sdr-priority. NodeRecord gains
secondary_sdr_priority (ordered; SDRs beyond OP25's run it top-down) and
secondary_sdr_running, both mirrored from the node's checkin.
PATCH /nodes/{id} accepts the priority, validates it, and sends it as a
'set_secondary_priority' MQTT command. A priority-only change never
re-pushes system config, because that restarts OP25. The node detail page
gets a 'Secondary SDRs' section (admin-editable) with enable, reorder,
save, and live Running / Waiting-for-SDR state from the checkin.

Verified: c2-core pytest 482 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 14:10:11 -04:00
co-authored by Claude Opus 5.5
parent fa207e494d
commit 3cbb0828ae
8 changed files with 246 additions and 8 deletions
+5 -2
View File
@@ -112,6 +112,8 @@ class MQTTHandler:
"approval_status": "pending",
"node_type": payload.get("node_type", "fixed"),
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
"secondary_sdr_running": payload.get("secondary_sdr_running"),
"sdr_count": payload.get("sdr_count", 1),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
@@ -143,8 +145,9 @@ class MQTTHandler:
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
if "secondary_sdr_mode" in payload:
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"):
if key in payload:
updates[key] = payload[key]
if "sdr_count" in payload:
updates["sdr_count"] = payload["sdr_count"]
+6 -1
View File
@@ -62,7 +62,12 @@ class NodeRecord(BaseModel):
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
secondary_sdr_mode: str = "none" # legacy single-mode field; priority[0] on current nodes
# Ordered decoders for the SDRs beyond op25's (node-26#9): the node runs
# them top-down until it runs out of dongles. Mirrored from the node's own
# checkin, which is the source of truth; set via PATCH /nodes/{id}.
secondary_sdr_priority: List[str] = []
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
enforce_override_timeout: bool = True
is_overridden: bool = False
+25 -3
View File
@@ -1,5 +1,5 @@
import secrets
from typing import Optional
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload
@@ -192,10 +192,15 @@ async def assign_system(
return {"ok": True}
SECONDARY_SDR_MODES = ("adsb", "ais")
class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
secondary_sdr_priority: Optional[List[str]] = None
@router.patch("/{node_id}")
@@ -212,8 +217,23 @@ async def update_node(
if not updates:
return {"ok": True}
priority = updates.get("secondary_sdr_priority")
if priority is not None:
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
if unknown or len(set(priority)) != len(priority):
raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.")
updates["secondary_sdr_mode"] = priority[0] if priority else "none"
await fstore.doc_update("nodes", node_id, updates)
# Priority goes as its own command: a config re-push restarts OP25, and
# changing what the spare dongles do must never interrupt P25 recording.
# The node applies it, then its checkin reports back what's really running.
if priority is not None:
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority})
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}:
return {"ok": True}
# Re-push config to apply new node settings locally
updated_node = await fstore.doc_get("nodes", node_id)
assigned_system_id = updated_node.get("assigned_system_id")
@@ -228,7 +248,9 @@ async def update_node(
}
if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"]
if updated_node.get("secondary_sdr_mode") is not None:
if updated_node.get("secondary_sdr_priority") is not None:
push_payload["secondary_sdr_priority"] = updated_node["secondary_sdr_priority"]
elif updated_node.get("secondary_sdr_mode") is not None:
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
mqtt_handler.push_config(node_id, push_payload)