From 3cbb0828ae0e9fb7f3c060ba6e7ccc8a0c73b992 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 14:10:11 -0400 Subject: [PATCH 1/2] 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 --- drb-c2-core/app/internal/mqtt_handler.py | 7 +- drb-c2-core/app/models.py | 7 +- drb-c2-core/app/routers/nodes.py | 28 +++- .../tests/test_node_secondary_priority.py | 59 ++++++++ drb-frontend/app/nodes/[id]/page.tsx | 3 + .../components/SecondarySdrPriority.tsx | 139 ++++++++++++++++++ drb-frontend/lib/c2api.ts | 5 +- drb-frontend/lib/types.ts | 6 +- 8 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 drb-c2-core/tests/test_node_secondary_priority.py create mode 100644 drb-frontend/components/SecondarySdrPriority.tsx diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index ab5ece4..a9e9901 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -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"] diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 343eb5e..9ea0359 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -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 diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index 53d3378..dd22abd 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -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) diff --git a/drb-c2-core/tests/test_node_secondary_priority.py b/drb-c2-core/tests/test_node_secondary_priority.py new file mode 100644 index 0000000..1118505 --- /dev/null +++ b/drb-c2-core/tests/test_node_secondary_priority.py @@ -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 diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index 8476918..ba92b43 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems"; import { useCalls } from "@/lib/useCalls"; import { StatusBadge } from "@/components/StatusBadge"; import { NodeConfigModal } from "@/components/NodeConfigModal"; +import { SecondarySdrPriority } from "@/components/SecondarySdrPriority"; import { CallRow } from "@/components/CallRow"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAuth } from "@/components/AuthProvider"; @@ -336,6 +337,8 @@ export default function NodeDetailPage() { )} + + {/* Recent calls */}

Recent Calls

diff --git a/drb-frontend/components/SecondarySdrPriority.tsx b/drb-frontend/components/SecondarySdrPriority.tsx new file mode 100644 index 0000000..1c26863 --- /dev/null +++ b/drb-frontend/components/SecondarySdrPriority.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { c2api } from "@/lib/c2api"; +import type { NodeRecord } from "@/lib/types"; + +// node-26#9. OP25 always keeps its own SDR; every other SDR on the node runs +// the next enabled item here, top first — so a 3-SDR node runs both. +const MODES: { mode: string; name: string; hint: string }[] = [ + { mode: "adsb", name: "ADS-B", hint: "Aircraft · 1090 MHz" }, + { mode: "ais", name: "AIS", hint: "Vessels · 162 MHz" }, +]; + +type Row = { mode: string; enabled: boolean }; + +function rowsFrom(priority: string[]): Row[] { + return [ + ...priority.filter((m) => MODES.some((x) => x.mode === m)).map((mode) => ({ mode, enabled: true })), + ...MODES.filter((x) => !priority.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false })), + ]; +} + +export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) { + const priority = node.secondary_sdr_priority ?? []; + const running = node.secondary_sdr_running ?? []; + const [rows, setRows] = useState(() => rowsFrom(priority)); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + // Follow the node's live checkin unless there are unsaved edits. + const priorityKey = priority.join(","); + useEffect(() => { + if (!dirty) setRows(rowsFrom(priorityKey ? priorityKey.split(",") : [])); + }, [priorityKey, dirty]); + + function edit(next: Row[]) { + setRows(next); + setDirty(true); + setMessage(null); + } + + function move(i: number, delta: number) { + const next = [...rows]; + [next[i], next[i + delta]] = [next[i + delta], next[i]]; + edit(next); + } + + async function save() { + setSaving(true); + setMessage(null); + try { + await c2api.updateNode(node.node_id, { + secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode), + }); + setDirty(false); + setMessage("Sent to the node. Status updates when it checks in."); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Save failed."); + } finally { + setSaving(false); + } + } + + const sdrCount = node.sdr_count ?? 1; + const spare = Math.max(sdrCount - 1, 0); + let rank = 0; + + return ( +
+

Secondary SDRs

+

+ OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first. + {" "}This node reports {sdrCount} SDR{sdrCount === 1 ? "" : "s"} ({spare} spare). +

+
+ {rows.map((row, i) => { + const meta = MODES.find((x) => x.mode === row.mode)!; + const isRunning = running.includes(row.mode); + const state = !row.enabled ? "Off" : dirty ? "Unsaved" : isRunning ? "Running" : "Waiting for SDR"; + return ( +
+ {row.enabled ? ++rank : ""} + edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))} + className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" + /> +
+
{meta.name}
+
{meta.hint}
+
+ {canEdit && ( +
+ + +
+ )} + + {state} + +
+ ); + })} +
+ {canEdit && ( +
+ + {message && {message}} +
+ )} +
+ ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 8a90286..db3ed6a 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -35,7 +35,10 @@ export const c2api = { request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }), resetOverride: (nodeId: string) => request(`/nodes/${nodeId}/override/reset`, { method: "POST" }), - updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) => + updateNode: ( + id: string, + body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] }, + ) => request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), // Systems diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index ab1f048..527e192 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -53,7 +53,11 @@ export interface NodeRecord { hardware_preset?: string; ppm_override?: number | null; node_type?: string; - secondary_sdr_mode?: string; + secondary_sdr_mode?: string; // legacy; priority[0] on current nodes + /** Ordered decoders for the SDRs beyond OP25's, run top-down (node-26#9). */ + secondary_sdr_priority?: string[]; + /** What the node's last checkin reported actually running. */ + secondary_sdr_running?: string[] | null; sdr_count?: number; enforce_override_timeout?: boolean; is_overridden?: boolean; From 012cca402a295f07660021ff3368c1800514a10c Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 14:14:42 -0400 Subject: [PATCH 2/2] Secondary SDR panel: never present an unreported SDR count or run state as fact QA (drb-qa-review) blockers: sdr_count defaulted to 1 for nodes that never sent it, so the panel claimed 'reports 1 SDR (0 spare)'. It is now None until reported, and the count is only quoted alongside a real secondary_sdr_running report. Rows read 'Not reported' instead of 'Waiting for SDR' when the node hasn't said what's running (closes #187). Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/internal/mqtt_handler.py | 2 +- drb-c2-core/app/models.py | 2 +- .../components/SecondarySdrPriority.tsx | 23 +++++++++++++++---- drb-frontend/lib/types.ts | 2 +- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index a9e9901..de9bc65 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -114,7 +114,7 @@ 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_count": payload.get("sdr_count", 1), + "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, "override_system_id": None, diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 9ea0359..81bd047 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -68,7 +68,7 @@ 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 - sdr_count: int = 1 # self-reported by the node's checkin, best-effort + sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported enforce_override_timeout: bool = True is_overridden: bool = False override_system_id: Optional[str] = None diff --git a/drb-frontend/components/SecondarySdrPriority.tsx b/drb-frontend/components/SecondarySdrPriority.tsx index 1c26863..2fa6d4f 100644 --- a/drb-frontend/components/SecondarySdrPriority.tsx +++ b/drb-frontend/components/SecondarySdrPriority.tsx @@ -22,6 +22,9 @@ function rowsFrom(priority: string[]): Row[] { export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) { const priority = node.secondary_sdr_priority ?? []; + // null/absent = the node has never reported (older firmware, container down + // at checkin): unknown, not "nothing running" (server-26#187). + const reported = node.secondary_sdr_running != null; const running = node.secondary_sdr_running ?? []; const [rows, setRows] = useState(() => rowsFrom(priority)); const [dirty, setDirty] = useState(false); @@ -62,8 +65,9 @@ export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canE } } - const sdrCount = node.sdr_count ?? 1; - const spare = Math.max(sdrCount - 1, 0); + // Only quote a count the node actually sent alongside its running list; a + // bare sdr_count may be the Firestore default, not a report. + const sdrCount = reported ? node.sdr_count : undefined; let rank = 0; return ( @@ -71,13 +75,24 @@ export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canE

Secondary SDRs

OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first. - {" "}This node reports {sdrCount} SDR{sdrCount === 1 ? "" : "s"} ({spare} spare). + {" "} + {sdrCount != null + ? `This node reports ${sdrCount} SDR${sdrCount === 1 ? "" : "s"} (${Math.max(sdrCount - 1, 0)} spare).` + : "This node hasn't reported its SDRs yet."}

{rows.map((row, i) => { const meta = MODES.find((x) => x.mode === row.mode)!; const isRunning = running.includes(row.mode); - const state = !row.enabled ? "Off" : dirty ? "Unsaved" : isRunning ? "Running" : "Waiting for SDR"; + const state = !row.enabled + ? "Off" + : dirty + ? "Unsaved" + : !reported + ? "Not reported" + : isRunning + ? "Running" + : "Waiting for SDR"; return (
{row.enabled ? ++rank : ""} diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 527e192..2cf9546 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -58,7 +58,7 @@ export interface NodeRecord { secondary_sdr_priority?: string[]; /** What the node's last checkin reported actually running. */ secondary_sdr_running?: string[] | null; - sdr_count?: number; + sdr_count?: number | null; // null = never reported enforce_override_timeout?: boolean; is_overridden?: boolean; override_system_id?: string | null;