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:
co-authored by
Claude Opus 5.5
parent
ae0ece9d2c
commit
23860eb31b
@@ -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 "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
||||
one SDR, within about 1.5 MHz of each other. Each channel'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't decoded by nodes yet — a node assigned this system won'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">
|
||||
|
||||
Reference in New Issue
Block a user