Node SDRs: pin OP25 and each service to a dongle by serial (node-26#11)

Pairs with node-26 feat/sdr-pins. NodeRecord gains sdr_pins
(service -> serial), sdr_devices and op25_sdr_serial, mirrored from the
node's checkin. PATCH /nodes/{id} validates pins (known services, one
dongle per service) and sends priority/pins as a 'set_sdr_config'
command, never a config re-push. The node restarts OP25 only when OP25's
own dongle changes. The node page's section becomes 'SDRs' with an OP25
SDR dropdown ('Automatic (first SDR)' + detected dongles) and a
per-service dongle dropdown ('Any spare SDR'), plus duplicate-serial and
double-pin warnings.

Verified: c2-core pytest 490 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:36:49 -04:00
co-authored by Claude Opus 5.5
parent c95498e7fe
commit fb2d737d6f
9 changed files with 315 additions and 167 deletions
+5 -1
View File
@@ -114,6 +114,9 @@ class MQTTHandler:
"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_pins": payload.get("sdr_pins", {}),
"sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
@@ -145,7 +148,8 @@ class MQTTHandler:
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"):
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
if key in payload:
updates[key] = payload[key]
if "sdr_count" in payload:
+4
View File
@@ -68,6 +68,10 @@ class NodeRecord(BaseModel):
# 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
# node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic.
sdr_pins: Dict[str, str] = {}
sdr_devices: Optional[List[Dict[str, Any]]] = None # [{index, serial, name, duplicate_serial}] from checkin
op25_sdr_serial: Optional[str] = None # the dongle OP25 is actually using, per the node
sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
enforce_override_timeout: bool = True
is_overridden: bool = False
+29 -7
View File
@@ -1,5 +1,5 @@
import secrets
from typing import List, Optional
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload
@@ -193,6 +193,7 @@ async def assign_system(
SECONDARY_SDR_MODES = ("adsb", "ais")
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
class NodeUpdateBody(BaseModel):
@@ -201,6 +202,9 @@ class NodeUpdateBody(BaseModel):
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
# node-26#11: service -> dongle serial; null/"" = automatic. Moving OP25's
# dongle restarts OP25 on the node; the other pins never do.
sdr_pins: Optional[Dict[str, Optional[str]]] = None
@router.patch("/{node_id}")
@@ -224,14 +228,32 @@ async def update_node(
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"
if "sdr_pins" in updates:
raw = updates["sdr_pins"] or {}
unknown = [k for k in raw if k not in SDR_PIN_KEYS]
if unknown:
raise HTTPException(400, f"sdr_pins keys must be from {SDR_PIN_KEYS}.")
pins = {k: str(v).strip() for k, v in raw.items() if v and str(v).strip()}
if len(set(pins.values())) != len(pins):
raise HTTPException(400, "Two services can't be pinned to the same SDR.")
updates["sdr_pins"] = pins
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"}:
# SDR settings go as their own command: a config re-push restarts OP25, and
# changing what the spare dongles do must never interrupt P25 recording
# (only moving OP25's own dongle restarts it, on the node's side). The node
# applies it, then its checkin reports back what's really running.
sdr_keys = {"secondary_sdr_priority", "secondary_sdr_mode", "sdr_pins"}
if sdr_keys & set(updates):
command = {"action": "set_sdr_config"}
if priority is not None:
command["priority"] = priority
if "sdr_pins" in updates:
# Explicit nulls so a cleared pin reaches the node as "automatic".
command["pins"] = {k: updates["sdr_pins"].get(k) for k in SDR_PIN_KEYS}
mqtt_handler.send_command(node_id, command)
if set(updates) <= sdr_keys:
return {"ok": True}
# Re-push config to apply new node settings locally
@@ -40,7 +40,7 @@ def _patch(body):
def test_priority_only_sends_command_and_never_repushes_config():
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": ["ais", "adsb"]})
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": ["ais", "adsb"]})
push.assert_not_called()
(_, _, updates), _ = update.await_args
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
@@ -49,7 +49,7 @@ def test_priority_only_sends_command_and_never_repushes_config():
def test_empty_priority_turns_secondaries_off():
resp, update, command, push = _patch({"secondary_sdr_priority": []})
assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": []})
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": []})
(_, _, updates), _ = update.await_args
assert updates["secondary_sdr_mode"] == "none"
@@ -57,3 +57,20 @@ def test_empty_priority_turns_secondaries_off():
def test_unknown_or_duplicate_modes_are_rejected():
assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400
def test_pins_send_every_service_with_nulls_for_automatic():
resp, update, command, push = _patch({"sdr_pins": {"op25": "00000001", "adsb": "69420", "ais": ""}})
assert resp.status_code == 200
push.assert_not_called()
command.assert_called_once_with("n1", {
"action": "set_sdr_config",
"pins": {"op25": "00000001", "adsb": "69420", "ais": None},
})
(_, _, updates), _ = update.await_args
assert updates == {"sdr_pins": {"op25": "00000001", "adsb": "69420"}}
def test_two_services_on_one_dongle_or_unknown_service_rejected():
assert _patch({"sdr_pins": {"op25": "69420", "adsb": "69420"}})[0].status_code == 400
assert _patch({"sdr_pins": {"sonar": "1"}})[0].status_code == 400