diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index de9bc65..8b34ac3 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -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: diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 81bd047..0712683 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -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 diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index dd22abd..c24e5d8 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 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 diff --git a/drb-c2-core/tests/test_node_secondary_priority.py b/drb-c2-core/tests/test_node_secondary_priority.py index 1118505..464c736 100644 --- a/drb-c2-core/tests/test_node_secondary_priority.py +++ b/drb-c2-core/tests/test_node_secondary_priority.py @@ -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 diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index ba92b43..a884a03 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -8,7 +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 { SdrSettings } from "@/components/SdrSettings"; import { CallRow } from "@/components/CallRow"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAuth } from "@/components/AuthProvider"; @@ -337,7 +337,7 @@ export default function NodeDetailPage() { )} - + {/* Recent calls */}
diff --git a/drb-frontend/components/SdrSettings.tsx b/drb-frontend/components/SdrSettings.tsx new file mode 100644 index 0000000..fcda4a9 --- /dev/null +++ b/drb-frontend/components/SdrSettings.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { c2api } from "@/lib/c2api"; +import type { NodeRecord, SdrDevice } from "@/lib/types"; + +// node-26#9 / #11. OP25 always has exactly one SDR (pinned by serial, or the +// first one found). Every other SDR runs the next enabled service, top first; +// a service can be pinned to the SDR that carries its antenna. +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; pin: string }; + +function rowsFrom(priority: string[], pins: Record): Row[] { + const known = priority.filter((m) => MODES.some((x) => x.mode === m)); + return [ + ...known.map((mode) => ({ mode, enabled: true, pin: pins[mode] ?? "" })), + ...MODES.filter((x) => !known.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false, pin: pins[x.mode] ?? "" })), + ]; +} + +const selectClass = + "bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]"; + +function DeviceSelect({ + value, devices, autoLabel, label, disabled, onChange, +}: { + value: string; + devices: SdrDevice[]; + autoLabel: string; + label: string; + disabled: boolean; + onChange: (serial: string) => void; +}) { + const known = devices.some((d) => d.serial === value); + return ( + + ); +} + +export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) { + const priority = node.secondary_sdr_priority ?? []; + const pins = node.sdr_pins ?? {}; + // 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 devices = node.sdr_devices ?? []; + + const [rows, setRows] = useState(() => rowsFrom(priority, pins)); + const [op25Pin, setOp25Pin] = useState(pins.op25 ?? ""); + 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 serverKey = JSON.stringify([priority, pins]); + useEffect(() => { + if (dirty) return; + const [p, pn] = JSON.parse(serverKey) as [string[], Record]; + setRows(rowsFrom(p, pn)); + setOp25Pin(pn.op25 ?? ""); + }, [serverKey, 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); + } + + const pinned = [op25Pin, ...rows.map((r) => r.pin)].filter(Boolean); + const clash = new Set(pinned).size !== pinned.length; + const op25Moving = op25Pin !== (pins.op25 ?? ""); + + async function save() { + setSaving(true); + setMessage(null); + try { + const nextPins: Record = { op25: op25Pin || null }; + rows.forEach((r) => { nextPins[r.mode] = r.pin || null; }); + await c2api.updateNode(node.node_id, { + secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode), + sdr_pins: nextPins, + }); + 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); + } + } + + let rank = 0; + + return ( +
+

SDRs

+

+ {reported && node.sdr_devices != null + ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` + : "This node hasn't reported its SDRs yet."} +

+ +
+
+
+
OP25 SDR
+
+ {op25Moving + ? "Saving restarts OP25 on the selected SDR." + : node.op25_sdr_serial + ? `Using serial ${node.op25_sdr_serial}` + : "P25 / analog scanning"} +
+
+ { setOp25Pin(v); setDirty(true); setMessage(null); }} + /> +
+ + {rows.map((row, i) => { + const meta = MODES.find((x) => x.mode === row.mode)!; + const pinMissing = !!row.pin && !devices.some((d) => d.serial === row.pin); + const state = !row.enabled + ? "Off" + : dirty + ? "Unsaved" + : !reported + ? "Not reported" + : running.includes(row.mode) + ? "Running" + : pinMissing + ? "Pinned SDR missing" + : "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}
+
+ edit(rows.map((r, j) => (j === i ? { ...r, pin: v } : r)))} + /> + {canEdit && ( +
+ + +
+ )} + + {state} + +
+ ); + })} +
+ +

+ Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its + antenna, or leave it on "Any spare SDR". +

+ {devices.some((d) => d.duplicate_serial) && ( +

+ Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s) + before pinning. +

+ )} + {clash &&

Two services are pinned to the same SDR.

} + + {canEdit && ( +
+ + {message && {message}} +
+ )} +
+ ); +} diff --git a/drb-frontend/components/SecondarySdrPriority.tsx b/drb-frontend/components/SecondarySdrPriority.tsx deleted file mode 100644 index 2fa6d4f..0000000 --- a/drb-frontend/components/SecondarySdrPriority.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"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 ?? []; - // 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); - 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); - } - } - - // 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 ( -
-

Secondary SDRs

-

- OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first. - {" "} - {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" - : !reported - ? "Not reported" - : 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 e7c96f1..52561ae 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -37,7 +37,12 @@ export const c2api = { request(`/nodes/${nodeId}/override/reset`, { method: "POST" }), updateNode: ( id: string, - body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] }, + body: { + node_type?: string; + enforce_override_timeout?: boolean; + secondary_sdr_priority?: string[]; + sdr_pins?: Record; + }, ) => request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 932620b..7e72ebd 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -58,6 +58,12 @@ export interface NodeRecord { secondary_sdr_priority?: string[]; /** What the node's last checkin reported actually running. */ secondary_sdr_running?: string[] | null; + /** node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic. */ + sdr_pins?: Record; + /** Dongles the node detected, from its checkin; null/absent = not reported. */ + sdr_devices?: SdrDevice[] | null; + /** The dongle OP25 is actually using, per the node. */ + op25_sdr_serial?: string | null; sdr_count?: number | null; // null = never reported enforce_override_timeout?: boolean; is_overridden?: boolean; @@ -65,6 +71,13 @@ export interface NodeRecord { override_timeout_at?: string | null; } +export interface SdrDevice { + index: number; + serial: string | null; + name: string; + duplicate_serial: boolean; +} + export interface AircraftTrack { icao: string; org_id?: string;