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:
co-authored by
Claude Opus 5.5
parent
fa207e494d
commit
3cbb0828ae
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user