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
@@ -0,0 +1,59 @@
"""
node-26#9 — PATCH /nodes/{id} secondary_sdr_priority.
The priority must reach the node as its own MQTT command, never via a config
re-push: a config push restarts OP25, and reordering what the spare dongles do
must not interrupt P25 recording.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.routers import nodes
client = TestClient(app)
NODE = {"node_id": "n1", "assigned_system_id": "sys-1", "hardware_preset": "rtl-sdr-v3"}
def setup_function():
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
def teardown_function():
app.dependency_overrides.pop(require_admin_token, None)
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def _patch(body):
with patch.object(nodes.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: NODE if c == "nodes" else {"system_id": "sys-1"})), \
patch.object(nodes.fstore, "doc_update", AsyncMock()) as update, \
patch.object(nodes.mqtt_handler, "send_command", MagicMock(return_value=True)) as command, \
patch.object(nodes.mqtt_handler, "push_config", MagicMock()) as push:
resp = client.patch("/nodes/n1", json=body)
return resp, update, command, push
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"]})
push.assert_not_called()
(_, _, updates), _ = update.await_args
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
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": []})
(_, _, updates), _ = update.await_args
assert updates["secondary_sdr_mode"] == "none"
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