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",
|
"approval_status": "pending",
|
||||||
"node_type": payload.get("node_type", "fixed"),
|
"node_type": payload.get("node_type", "fixed"),
|
||||||
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
|
"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", 1),
|
||||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
"is_overridden": False,
|
"is_overridden": False,
|
||||||
@@ -143,8 +145,9 @@ class MQTTHandler:
|
|||||||
updates["node_type"] = node_type
|
updates["node_type"] = node_type
|
||||||
updates["enforce_override_timeout"] = enforce_timeout
|
updates["enforce_override_timeout"] = enforce_timeout
|
||||||
|
|
||||||
if "secondary_sdr_mode" in payload:
|
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"):
|
||||||
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
|
if key in payload:
|
||||||
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
updates["sdr_count"] = payload["sdr_count"]
|
updates["sdr_count"] = payload["sdr_count"]
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ class NodeRecord(BaseModel):
|
|||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
assigned_system_id: Optional[str] = None
|
assigned_system_id: Optional[str] = None
|
||||||
node_type: str = "fixed" # fixed or portable
|
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
|
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
|
||||||
enforce_override_timeout: bool = True
|
enforce_override_timeout: bool = True
|
||||||
is_overridden: bool = False
|
is_overridden: bool = False
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.models import CommandPayload
|
from app.models import CommandPayload
|
||||||
@@ -192,10 +192,15 @@ async def assign_system(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
SECONDARY_SDR_MODES = ("adsb", "ais")
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdateBody(BaseModel):
|
class NodeUpdateBody(BaseModel):
|
||||||
node_type: Optional[str] = None
|
node_type: Optional[str] = None
|
||||||
enforce_override_timeout: Optional[bool] = 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}")
|
@router.patch("/{node_id}")
|
||||||
@@ -212,8 +217,23 @@ async def update_node(
|
|||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
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)
|
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
|
# Re-push config to apply new node settings locally
|
||||||
updated_node = await fstore.doc_get("nodes", node_id)
|
updated_node = await fstore.doc_get("nodes", node_id)
|
||||||
assigned_system_id = updated_node.get("assigned_system_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:
|
if updated_node.get("ppm_override") is not None:
|
||||||
push_payload["ppm_override"] = updated_node["ppm_override"]
|
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"]
|
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
|
||||||
mqtt_handler.push_config(node_id, push_payload)
|
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
|
||||||
@@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems";
|
|||||||
import { useCalls } from "@/lib/useCalls";
|
import { useCalls } from "@/lib/useCalls";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority";
|
||||||
import { CallRow } from "@/components/CallRow";
|
import { CallRow } from "@/components/CallRow";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SecondarySdrPriority node={node} canEdit={isAdmin} />
|
||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
|
|||||||
@@ -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<Row[]>(() => rowsFrom(priority));
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(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 (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">Secondary SDRs</h2>
|
||||||
|
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||||
|
{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 (
|
||||||
|
<div key={row.mode} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.enabled}
|
||||||
|
disabled={!canEdit}
|
||||||
|
aria-label={`Enable ${meta.name}`}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-gray-200">{meta.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
disabled={i === 0}
|
||||||
|
aria-label={`Move ${meta.name} up`}
|
||||||
|
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
aria-label={`Move ${meta.name} down`}
|
||||||
|
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`w-28 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save priority"}
|
||||||
|
</button>
|
||||||
|
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ export const c2api = {
|
|||||||
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||||
resetOverride: (nodeId: string) =>
|
resetOverride: (nodeId: string) =>
|
||||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
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) }),
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ export interface NodeRecord {
|
|||||||
hardware_preset?: string;
|
hardware_preset?: string;
|
||||||
ppm_override?: number | null;
|
ppm_override?: number | null;
|
||||||
node_type?: string;
|
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;
|
sdr_count?: number;
|
||||||
enforce_override_timeout?: boolean;
|
enforce_override_timeout?: boolean;
|
||||||
is_overridden?: boolean;
|
is_overridden?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user