Pairs with node-26 feat/sdr-pins. NodeRecord gains sdr_pins
(service -> serial), sdr_devices and op25_sdr_serial, mirrored from the
node's checkin. PATCH /nodes/{id} validates pins (known services, one
dongle per service) and sends priority/pins as a 'set_sdr_config'
command, never a config re-push. The node restarts OP25 only when OP25's
own dongle changes. The node page's section becomes 'SDRs' with an OP25
SDR dropdown ('Automatic (first SDR)' + detected dongles) and a
per-service dongle dropdown ('Any spare SDR'), plus duplicate-serial and
double-pin warnings.
Verified: c2-core pytest 490 passed; frontend tsc --noEmit clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
"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<string, string>): 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 (
|
|
<select aria-label={label} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)} className={selectClass}>
|
|
<option value="">{autoLabel}</option>
|
|
{devices.map((d) => (
|
|
<option key={`${d.index}-${d.serial}`} value={d.serial ?? ""} disabled={!d.serial}>
|
|
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
|
|
</option>
|
|
))}
|
|
{value && !known && <option value={value}>serial {value} (not plugged in)</option>}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
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<Row[]>(() => rowsFrom(priority, pins));
|
|
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
|
|
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 serverKey = JSON.stringify([priority, pins]);
|
|
useEffect(() => {
|
|
if (dirty) return;
|
|
const [p, pn] = JSON.parse(serverKey) as [string[], Record<string, string>];
|
|
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<string, string | null> = { 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 (
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
|
<p className="text-xs text-gray-500 font-mono mb-3">
|
|
{reported && node.sdr_devices != null
|
|
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
|
: "This node hasn't reported its SDRs yet."}
|
|
</p>
|
|
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
|
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-gray-200">OP25 SDR</div>
|
|
<div className="text-xs text-gray-500">
|
|
{op25Moving
|
|
? "Saving restarts OP25 on the selected SDR."
|
|
: node.op25_sdr_serial
|
|
? `Using serial ${node.op25_sdr_serial}`
|
|
: "P25 / analog scanning"}
|
|
</div>
|
|
</div>
|
|
<DeviceSelect
|
|
value={op25Pin}
|
|
devices={devices}
|
|
autoLabel="Automatic (first SDR)"
|
|
label="OP25 SDR"
|
|
disabled={!canEdit}
|
|
onChange={(v) => { setOp25Pin(v); setDirty(true); setMessage(null); }}
|
|
/>
|
|
</div>
|
|
|
|
{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 (
|
|
<div key={row.mode} className="flex flex-wrap 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-[7rem]">
|
|
<div className="text-gray-200">{meta.name}</div>
|
|
<div className="text-xs text-gray-500">{meta.hint}</div>
|
|
</div>
|
|
<DeviceSelect
|
|
value={row.pin}
|
|
devices={devices}
|
|
autoLabel="Any spare SDR"
|
|
label={`${meta.name} SDR`}
|
|
disabled={!canEdit}
|
|
onChange={(v) => edit(rows.map((r, j) => (j === i ? { ...r, pin: v } : r)))}
|
|
/>
|
|
{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-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
|
{state}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 font-mono mt-2">
|
|
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".
|
|
</p>
|
|
{devices.some((d) => d.duplicate_serial) && (
|
|
<p className="text-xs text-red-400 font-mono mt-2">
|
|
Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s)
|
|
before pinning.
|
|
</p>
|
|
)}
|
|
{clash && <p className="text-xs text-red-400 font-mono mt-2">Two services are pinned to the same SDR.</p>}
|
|
|
|
{canEdit && (
|
|
<div className="flex items-center gap-3 mt-3">
|
|
<button
|
|
onClick={save}
|
|
disabled={!dirty || saving || clash}
|
|
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"}
|
|
</button>
|
|
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|