Systems: P25 trunked / P25 conventional / analog as one choice (node-26#24)

- system_config.normalize(): a P25 system with config.mode "conventional"
  carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
  can no longer carry control channels (422). A missing mode is trunked and
  passes through untouched, so there is no migration. An analog channel is
  mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
  id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
  DMR, plus a channel-row editor for conventional and a one-frequency form for
  analog. The node page shows conventional channels and an encryption notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 15:55:34 -04:00
co-authored by Claude Opus 5.5
parent ae0ece9d2c
commit 23860eb31b
9 changed files with 649 additions and 41 deletions
+3
View File
@@ -9,6 +9,7 @@ import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SdrSettings } from "@/components/SdrSettings";
import { RadioChannels } from "@/components/RadioChannels";
import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider";
@@ -370,6 +371,8 @@ export default function NodeDetailPage() {
)}
</div>
<RadioChannels channels={node.radio_channels} />
<SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */}
+273 -38
View File
@@ -103,12 +103,70 @@ function anchorLabel(a?: AreaContext): string {
return `${place} — not yet resolved`;
}
// ── System kinds (node-26#24) ─────────────────────────────────────────────────
// One choice in the form; stored as `type` plus, for P25, `config.mode`.
// A P25 system with no mode is trunked — every system saved before this.
type SystemKind = "p25_trunked" | "p25_conventional" | "NBFM" | "DMR";
const KIND_OPTIONS: { value: SystemKind; label: string }[] = [
{ value: "p25_trunked", label: "P25 trunked" },
{ value: "p25_conventional", label: "P25 conventional" },
{ value: "NBFM", label: "Analog (NBFM)" },
{ value: "DMR", label: "DMR" },
];
function kindOf(type: string | undefined, config?: Record<string, unknown>): SystemKind {
if (type === "NBFM" || type === "DMR") return type;
return config?.mode === "conventional" ? "p25_conventional" : "p25_trunked";
}
function typeOf(kind: SystemKind): string {
return kind === "p25_trunked" || kind === "p25_conventional" ? "P25" : kind;
}
function kindLabel(s: SystemRecord): string {
const k = kindOf(s.type, s.config);
return KIND_OPTIONS.find((o) => o.value === k)?.label ?? s.type;
}
/** One fixed voice channel: P25 conventional (up to 2) or analog (exactly 1). */
interface ChannelEntry {
freq: string; // MHz
name: string;
nac: string; // hex, P25 conventional only; blank = any
}
const MAX_CONVENTIONAL_CHANNELS = 2;
function recordToChannels(c: Record<string, unknown>): ChannelEntry[] {
if (Array.isArray(c.channels)) {
return (c.channels as Array<{ freq?: number | string; name?: string; nac?: string | number }>).map((ch) => ({
freq: ch.freq != null ? String(ch.freq) : "",
name: ch.name ?? "",
nac: ch.nac != null ? String(ch.nac) : "",
}));
}
return [];
}
function channelsToRecord(chs: ChannelEntry[], withNac: boolean) {
return chs
.filter((ch) => ch.freq.trim() && !isNaN(parseFloat(ch.freq)))
.map((ch) => ({
freq: parseFloat(ch.freq),
name: ch.name.trim(),
...(withNac && ch.nac.trim() ? { nac: ch.nac.trim() } : {}),
}));
}
interface P25Config {
nac: string;
system_id: string;
wacn: string;
control_channels: string;
voice_channels: string;
channels: ChannelEntry[]; // conventional only
talkgroups: TalkgroupEntry[];
}
@@ -118,6 +176,7 @@ const DEFAULT_P25: P25Config = {
wacn: "",
control_channels: "",
voice_channels: "",
channels: [{ freq: "", name: "", nac: "" }],
talkgroups: [],
};
@@ -134,6 +193,9 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
voice_channels: Array.isArray(c.voice_channels)
? (c.voice_channels as number[]).join(", ")
: "",
channels: Array.isArray(c.channels) && c.channels.length
? recordToChannels(c)
: [{ freq: "", name: "", nac: "" }],
talkgroups: Array.isArray(c.talkgroups)
? (c.talkgroups as Array<{
id: number; name: string; tag: string;
@@ -149,36 +211,66 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
};
}
function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
function p25ConfigToRecord(p: P25Config, conventional = false): Record<string, unknown> {
const parseFreqs = (s: string) =>
s
.split(",")
.map((f) => parseFloat(f.trim()))
.filter((f) => !isNaN(f));
const talkgroups = talkgroupsToRecord(p.talkgroups);
if (conventional) {
// No control/voice channel lists: the server rejects a conventional
// system that carries control channels (node-26#24).
return { mode: "conventional", channels: channelsToRecord(p.channels, true), talkgroups };
}
return {
nac: p.nac,
system_id: p.system_id ? parseInt(p.system_id, 10) : undefined,
wacn: p.wacn,
control_channels: parseFreqs(p.control_channels),
voice_channels: parseFreqs(p.voice_channels),
talkgroups: p.talkgroups
.filter((tg) => tg.id && tg.name)
.map((tg) => {
const area = cleanArea(tg.area_context);
const vocab = (tg.vocabulary ?? []).filter(Boolean);
return {
id: parseInt(tg.id, 10),
name: tg.name,
tag: tg.tag,
// Omitted rather than written empty: an absent key is what
// resolve_context() reads as "inherit from the system".
...(vocab.length ? { vocabulary: vocab } : {}),
...(area ? { area_context: area } : {}),
};
}),
talkgroups,
};
}
function talkgroupsToRecord(tgs: TalkgroupEntry[]) {
return tgs
.filter((tg) => tg.id && tg.name)
.map((tg) => {
const area = cleanArea(tg.area_context);
const vocab = (tg.vocabulary ?? []).filter(Boolean);
return {
id: parseInt(tg.id, 10),
name: tg.name,
tag: tg.tag,
// Omitted rather than written empty: an absent key is what
// resolve_context() reads as "inherit from the system".
...(vocab.length ? { vocabulary: vocab } : {}),
...(area ? { area_context: area } : {}),
};
});
}
interface AnalogConfig {
channel: ChannelEntry;
}
function recordToAnalog(c: Record<string, unknown>): AnalogConfig {
const chs = recordToChannels(c);
if (chs.length) return { channel: chs[0] };
// Saved before node-26#24: the one frequency lived in control_channels.
const legacy = Array.isArray(c.control_channels) ? (c.control_channels as number[])[0] : undefined;
return { channel: { freq: legacy != null ? String(legacy) : "", name: "", nac: "" } };
}
function analogToRecord(a: AnalogConfig): Record<string, unknown> {
if (isNaN(parseFloat(a.channel.freq))) {
throw new Error("Enter the analog frequency in MHz, e.g. 154.4300");
}
// The server mirrors the frequency into control_channels for older nodes.
return { channels: channelsToRecord([a.channel], false), talkgroups: [] };
}
// ── RadioReference parser types ───────────────────────────────────────────────
interface RRTalkgroup {
@@ -829,6 +921,114 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
);
}
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels;
const set = (i: number, field: keyof ChannelEntry, v: string) =>
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return (
<div className="space-y-4">
<p className="text-xs text-gray-500">
Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400">
<span>Frequency (MHz)</span>
<span>Channel name</span>
<span>NAC (hex, optional)</span>
<span className="w-16" />
</div>
{chs.map((ch, i) => (
<div key={i} className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 items-center">
<input
aria-label={`Channel ${i + 1} frequency in MHz`}
value={ch.freq}
onChange={(e) => set(i, "freq", e.target.value)}
className={INPUT}
placeholder="460.4000"
inputMode="decimal"
required={i === 0}
/>
<input
aria-label={`Channel ${i + 1} name`}
value={ch.name}
onChange={(e) => set(i, "name", e.target.value)}
className={INPUT}
placeholder="PD Dispatch"
/>
<input
aria-label={`Channel ${i + 1} NAC`}
value={ch.nac}
onChange={(e) => set(i, "nac", e.target.value)}
className={INPUT}
placeholder="any"
/>
<button
type="button"
onClick={() => onChange({ ...value, channels: chs.filter((_, j) => j !== i) })}
disabled={chs.length === 1}
className="w-16 text-xs text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
Remove
</button>
</div>
))}
{chs.length < MAX_CONVENTIONAL_CHANNELS && (
<button
type="button"
onClick={() => onChange({ ...value, channels: [...chs, { freq: "", name: "", nac: "" }] })}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add channel
</button>
)}
</div>
<TalkgroupEditor
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
<p className="text-xs text-gray-600 -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p>
</div>
);
}
function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: AnalogConfig) => void }) {
const ch = value.channel;
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label>
<input
id="analog-freq"
value={ch.freq}
onChange={(e) => onChange({ channel: { ...ch, freq: e.target.value } })}
className={INPUT}
placeholder="154.4300"
inputMode="decimal"
required
/>
</div>
<div>
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label>
<input
id="analog-name"
value={ch.name}
onChange={(e) => onChange({ channel: { ...ch, name: e.target.value } })}
className={INPUT}
placeholder="Fire Dispatch"
/>
</div>
</div>
);
}
// ── Main system form ──────────────────────────────────────────────────────────
function SystemForm({
@@ -843,34 +1043,47 @@ function SystemForm({
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState(initial?.type ?? "P25");
const [kind, setKind] = useState<SystemKind>(initial ? kindOf(initial.type, initial.config) : "p25_trunked");
const [p25, setP25] = useState<P25Config>(
initial?.type === "P25" && initial.config
? recordToP25Config(initial.config)
: DEFAULT_P25
);
const [analog, setAnalog] = useState<AnalogConfig>(
initial?.type === "NBFM" && initial.config
? recordToAnalog(initial.config)
: { channel: { freq: "", name: "", nac: "" } }
);
const [rawJson, setRawJson] = useState(
initial?.type !== "P25" && initial?.config
initial?.type === "DMR" && initial?.config
? JSON.stringify(initial.config, null, 2)
: "{}"
);
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
// DMR has no structured form (and no decoder on the node yet).
const [showRaw, setShowRaw] = useState(initial?.type === "DMR");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(t: string) {
setType(t);
setShowRaw(t !== "P25");
function structuredRecord(k: SystemKind): Record<string, unknown> {
if (k === "NBFM") return analogToRecord(analog);
return p25ConfigToRecord(p25, k === "p25_conventional");
}
function handleKindChange(k: SystemKind) {
setKind(k);
setShowRaw(k === "DMR");
}
function toggleRaw() {
if (!showRaw) {
setRawJson(JSON.stringify(p25ConfigToRecord(p25), null, 2));
setRawJson(JSON.stringify(structuredRecord(kind), null, 2));
} else {
try {
setP25(recordToP25Config(JSON.parse(rawJson)));
const parsed = JSON.parse(rawJson);
if (kind === "NBFM") setAnalog(recordToAnalog(parsed));
else setP25(recordToP25Config(parsed));
} catch {
// keep current p25 state if parse fails
// keep current structured state if parse fails
}
}
setShowRaw(!showRaw);
@@ -881,11 +1094,19 @@ function SystemForm({
setSaving(true);
setError(null);
try {
const type = typeOf(kind);
let config: Record<string, unknown>;
if (type === "P25" && !showRaw) {
config = p25ConfigToRecord(p25);
if (!showRaw) {
config = structuredRecord(kind);
} else {
config = JSON.parse(rawJson);
// The kind picker is the source of truth for trunked vs conventional.
if (kind === "p25_conventional") config = { ...config, mode: "conventional" };
if (kind === "p25_trunked" && config.mode === "conventional") {
const { mode: _drop, ...rest } = config;
void _drop;
config = rest;
}
}
if (initial && !createOnly) {
await c2api.updateSystem(initial.system_id, { name, type, config });
@@ -918,21 +1139,30 @@ function SystemForm({
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Type</label>
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label>
<select
value={type}
onChange={(e) => handleTypeChange(e.target.value)}
id="system-kind"
value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option>P25</option>
<option>DMR</option>
<option>NBFM</option>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{type === "P25" && !showRaw ? (
{kind === "DMR" && (
<p className="text-xs text-amber-400">DMR isn&apos;t decoded by nodes yet — a node assigned this system won&apos;t start.</p>
)}
{!showRaw && kind === "p25_trunked" ? (
<P25Form value={p25} onChange={setP25} />
) : !showRaw && kind === "p25_conventional" ? (
<ConventionalForm value={p25} onChange={setP25} />
) : !showRaw && kind === "NBFM" ? (
<AnalogForm value={analog} onChange={setAnalog} />
) : (
<div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
@@ -945,7 +1175,7 @@ function SystemForm({
</div>
)}
{type === "P25" && (
{kind !== "DMR" && (
<button
type="button"
onClick={toggleRaw}
@@ -1740,25 +1970,30 @@ export default function SystemsPage() {
const tgCount = Array.isArray(s.config.talkgroups)
? (s.config.talkgroups as unknown[]).length
: null;
const ccCount = Array.isArray(s.config.control_channels)
const conventional = kindOf(s.type, s.config) === "p25_conventional";
const ccCount = !conventional && s.type === "P25" && Array.isArray(s.config.control_channels)
? (s.config.control_channels as unknown[]).length
: null;
const chCount = (conventional || s.type === "NBFM") && Array.isArray(s.config.channels)
? (s.config.channels as unknown[]).length
: null;
return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p>
{(s.config.nac || ccCount !== null || tgCount !== null) && (
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>}
{tgCount !== null && <span>· {tgCount} TGs</span>}
</p>
)}
</div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
{s.type}
{kindLabel(s)}
</span>
</div>
<div className="mt-3 flex gap-3">