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
+4 -1
View File
@@ -148,8 +148,11 @@ class MQTTHandler:
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
# radio_channels (node-26#24): a conventional node's channels and
# whether each is encrypted — the one decode failure the owner has
# to hear about from C2, since nothing on the node can fix it.
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"):
if key in payload:
updates[key] = payload[key]
if "sdr_count" in payload:
+157
View File
@@ -0,0 +1,157 @@
"""
Radio-system shape checks for the `systems` collection (node-26#24).
A system's `config` blob is pushed to edge nodes verbatim, and what a node does
with it depends on the system's kind:
P25 trunked type "P25", config.mode absent or "trunked";
config.control_channels = [MHz, ...]
P25 conventional type "P25", config.mode "conventional";
config.channels = [{freq: MHz, name, nac?}, ...]
Analog type "NBFM"; config.channels = [{freq, name}] (one), or
the pre-#24 config.control_channels[0]
`mode` absent means trunked, so every system saved before this existed is
valid and unchanged — there is no migration. What this rejects is the mistake
that left the White Plains node silent: voice frequencies entered as a trunked
system's control channels can't be caught (they are just numbers), but a
conventional system can no longer carry control channels, and a conventional
channel list must be decodable by one node.
Synthetic talkgroup ids: the edge node labels a conventional call with the
channel's frequency in 100 Hz units (460.400 MHz -> 4604000) when its link
control carries no distinctive talkgroup (edge drb-edge-node
internal/conventional.py). channel_tgid() is the same function, so C2 can name
those calls after the channel.
"""
from typing import Any, Dict, List, Optional
TRUNKED, CONVENTIONAL = "trunked", "conventional"
P25_MODES = (TRUNKED, CONVENTIONAL)
# Mirrors the node: one audio pipeline carries two channels, one RTL-SDR's
# usable passband is ~1.6 MHz.
MAX_CONVENTIONAL_CHANNELS = 2
MAX_CONVENTIONAL_SPAN_MHZ = 1.5
MIN_MHZ, MAX_MHZ = 24.0, 1766.0
class SystemConfigError(ValueError):
pass
def channel_tgid(freq_mhz: Any) -> Optional[int]:
try:
f = float(freq_mhz)
except (TypeError, ValueError):
return None
if f <= 0:
return None
hz = f * 1_000_000 if f < 1_000_000 else f
return int(round(hz / 100))
def p25_mode(config: Dict[str, Any]) -> str:
return str((config or {}).get("mode") or TRUNKED).lower()
def _nac(value: Any, where: str) -> Optional[str]:
if value is None or (isinstance(value, str) and not value.strip()):
return None
text = str(value).strip().lower()
if isinstance(value, int) and not isinstance(value, bool):
n = value
else:
text = text[2:] if text.startswith("0x") else text
try:
n = int(text, 16)
except ValueError:
raise SystemConfigError(f"{where}: NAC {value!r} isn't hex (e.g. 293).")
if not 0 < n <= 0xFFF:
raise SystemConfigError(f"{where}: NAC {value!r} is outside 001-FFF.")
return f"{n:03X}"
def _channels(config: Dict[str, Any], *, allow_nac: bool) -> List[Dict[str, Any]]:
raw = config.get("channels")
if not isinstance(raw, list) or not raw:
raise SystemConfigError("Add at least one channel frequency.")
out = []
for i, ch in enumerate(raw, 1):
if not isinstance(ch, dict):
raise SystemConfigError(f"Channel {i} must be an object with a freq.")
try:
freq = float(ch.get("freq"))
except (TypeError, ValueError):
raise SystemConfigError(f"Channel {i} needs a frequency in MHz.")
if not MIN_MHZ <= freq <= MAX_MHZ:
raise SystemConfigError(
f"Channel {i}: {freq:g} isn't a frequency in MHz an RTL-SDR can tune ({MIN_MHZ:g}-{MAX_MHZ:g})."
)
clean = {**ch, "freq": freq, "name": str(ch.get("name") or "").strip()}
if allow_nac:
nac = _nac(ch.get("nac"), f"Channel {i}")
if nac is None:
clean.pop("nac", None)
else:
clean["nac"] = nac
out.append(clean)
return out
def normalize(system_type: str, config: Dict[str, Any]) -> Dict[str, Any]:
"""Validate and return the config to store. Raises SystemConfigError."""
config = dict(config or {})
t = str(system_type or "").upper()
if t == "P25":
mode = p25_mode(config)
if mode not in P25_MODES:
raise SystemConfigError(f"P25 mode must be 'trunked' or 'conventional', not {config.get('mode')!r}.")
if mode == TRUNKED:
return config # untouched: exactly what was accepted before #24
if config.get("control_channels"):
raise SystemConfigError(
"A conventional system has no control channel — list its voice frequencies as channels instead."
)
config.pop("control_channels", None)
config.pop("voice_channels", None)
config["mode"] = CONVENTIONAL
chans = _channels(config, allow_nac=True)
if len(chans) > MAX_CONVENTIONAL_CHANNELS:
raise SystemConfigError(
f"A node decodes at most {MAX_CONVENTIONAL_CHANNELS} conventional channels on one SDR."
)
freqs = [c["freq"] for c in chans]
if len(set(freqs)) != len(freqs):
raise SystemConfigError("The same channel frequency is listed twice.")
if max(freqs) - min(freqs) > MAX_CONVENTIONAL_SPAN_MHZ:
raise SystemConfigError(
f"These channels are {max(freqs) - min(freqs):.3f} MHz apart; one SDR covers about "
f"{MAX_CONVENTIONAL_SPAN_MHZ:g} MHz."
)
config["channels"] = chans
return config
if t == "NBFM" and ("channels" in config or not config.get("control_channels")):
chans = _channels(config, allow_nac=False)
if len(chans) != 1:
raise SystemConfigError("An analog system listens to exactly one frequency.")
config["channels"] = chans
# Nodes from before node-26#24 read the analog frequency from here.
config["control_channels"] = [chans[0]["freq"]]
return config
return config
def conventional_channel_name(system_doc: Dict[str, Any], talkgroup_id: Any) -> Optional[str]:
"""Name of the conventional/analog channel whose synthetic id this is."""
try:
tgid = int(talkgroup_id)
except (TypeError, ValueError):
return None
for ch in (system_doc.get("config") or {}).get("channels") or []:
if isinstance(ch, dict) and channel_tgid(ch.get("freq")) == tgid:
return ch.get("name") or None
return None
+4 -1
View File
@@ -44,7 +44,10 @@ async def name_from_system(system_id: Optional[str], talkgroup_id: Optional[int]
return tg.get("name") or None
except (TypeError, ValueError):
continue
return None
# A conventional/analog call labelled with its channel's synthetic id
# (frequency in 100 Hz units, node-26#24) is named after the channel.
from app.internal.system_config import conventional_channel_name
return conventional_channel_name(system_doc, tgid_int)
async def resolve(
+14 -1
View File
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
from app.models import AreaContextBody, SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal import area_context as area_ctx
from app.internal import system_config
from app.internal.auth import (
require_admin_token,
require_node_service_or_firebase_token,
@@ -38,6 +39,14 @@ class AiFlagsBody(BaseModel):
correlation_enabled: Optional[bool] = None
def _checked_config(system_type: str, config: dict) -> dict:
"""Trunked/conventional/analog shape check (node-26#24) — 422 with the reason."""
try:
return system_config.normalize(system_type, config)
except system_config.SystemConfigError as e:
raise HTTPException(422, str(e))
@router.get("")
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
@@ -64,7 +73,9 @@ async def create_system(
_: dict = Depends(require_admin_token),
):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
fields = body.model_dump()
fields["config"] = _checked_config(body.type, fields["config"])
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **fields)
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc
@@ -80,6 +91,8 @@ async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(re
# save — they are edited through PUT /{id}/ten-codes and were never in this
# payload. area_context (server-26#36) would have been the second casualty.
patch = body.model_dump(exclude_unset=True)
if "config" in patch:
patch["config"] = _checked_config(patch.get("type", existing.get("type")), patch["config"])
# The form sends config.talkgroups[] in full, which would erase the resolved
# anchor and the pending-term queue the backend put there. Same class of bug
# as ten_codes above; the backend merges its own fields back rather than
+119
View File
@@ -0,0 +1,119 @@
"""
node-26#24 — system kinds: P25 trunked / P25 conventional / analog.
Missing mode = trunked and passes through untouched (no migration). A
conventional system carries channels, never control channels, and the edge
node's synthetic per-channel talkgroup id resolves back to the channel name.
"""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.internal import system_config as sc
from app.internal import talkgroups
from app.internal.auth import require_admin_token, require_node_service_or_firebase_token
from app.routers import systems
client = TestClient(app)
WHITE_PLAINS = {
"mode": "conventional",
"channels": [
{"freq": 460.4, "name": "PD1 Dispatch"},
{"freq": "460.425", "name": " PD3 Tac ", "nac": "0x293"},
],
"talkgroups": [],
}
def setup_function():
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
app.dependency_overrides[require_node_service_or_firebase_token] = lambda: {"admin": True}
def teardown_function():
app.dependency_overrides.pop(require_admin_token, None)
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
def test_trunked_without_mode_is_untouched():
cfg = {"control_channels": [851.0125], "nac": "0x293", "talkgroups": [{"id": 1, "name": "x"}]}
assert sc.normalize("P25", cfg) == cfg
def test_conventional_is_normalized():
out = sc.normalize("P25", WHITE_PLAINS)
assert out["mode"] == "conventional"
assert out["channels"] == [
{"freq": 460.4, "name": "PD1 Dispatch"},
{"freq": 460.425, "name": "PD3 Tac", "nac": "293"},
]
assert "control_channels" not in out
@pytest.mark.parametrize("cfg, needle", [
({**WHITE_PLAINS, "control_channels": [460.4]}, "no control channel"),
({"mode": "conventional", "channels": []}, "at least one"),
({"mode": "conventional", "channels": [{"freq": 460.4}, {"freq": 460.425}, {"freq": 460.45}]}, "at most 2"),
({"mode": "conventional", "channels": [{"freq": 460.4}, {"freq": 462.6}]}, "apart"),
({"mode": "conventional", "channels": [{"freq": 460.4}, {"freq": 460.4}]}, "twice"),
({"mode": "conventional", "channels": [{"freq": 460400000}]}, "MHz"),
({"mode": "conventional", "channels": [{"freq": 460.4, "nac": "zz"}]}, "hex"),
({"mode": "conventional", "channels": [{"freq": 460.4, "nac": "1000"}]}, "outside"),
({"mode": "simulcast"}, "trunked"),
])
def test_conventional_rejections(cfg, needle):
with pytest.raises(sc.SystemConfigError, match=needle):
sc.normalize("P25", cfg)
def test_analog_channel_mirrors_legacy_control_channel():
out = sc.normalize("NBFM", {"channels": [{"freq": 154.43, "name": "Fire"}]})
assert out["control_channels"] == [154.43]
with pytest.raises(sc.SystemConfigError, match="exactly one"):
sc.normalize("NBFM", {"channels": [{"freq": 154.43}, {"freq": 154.1}]})
with pytest.raises(sc.SystemConfigError, match="at least one"):
sc.normalize("NBFM", {"channels": []})
with pytest.raises(sc.SystemConfigError, match="at least one"):
sc.normalize("NBFM", {})
# Legacy analog systems pass through untouched.
assert sc.normalize("NBFM", {"control_channels": [154.43]}) == {"control_channels": [154.43]}
def test_channel_tgid_matches_edge_node():
# drb-edge-node internal/conventional.channel_tgid: 100 Hz units.
assert sc.channel_tgid(460.4) == 4_604_000
assert sc.channel_tgid(460_425_000) == 4_604_250
@pytest.mark.asyncio
async def test_synthetic_talkgroup_resolves_to_channel_name():
doc = {"config": sc.normalize("P25", WHITE_PLAINS)}
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock(return_value=doc)):
assert await talkgroups.name_from_system("wp", 4_604_250) == "PD3 Tac"
assert await talkgroups.name_from_system("wp", 101) is None
def test_create_route_rejects_conventional_control_channels():
with patch.object(systems.fstore, "doc_set", AsyncMock()) as doc_set:
r = client.post("/systems", json={"name": "WP", "type": "P25",
"config": {**WHITE_PLAINS, "control_channels": [460.4]}})
assert r.status_code == 422
assert "control channel" in r.json()["detail"]
doc_set.assert_not_awaited()
def test_update_route_normalizes_with_existing_type():
existing = {"system_id": "wp", "name": "White Plains", "type": "P25",
"config": {"control_channels": [460.4, 460.425]}}
with patch.object(systems.fstore, "doc_get", AsyncMock(return_value=existing)), \
patch.object(systems.fstore, "doc_update", AsyncMock()) as doc_update, \
patch.object(systems.area_ctx, "schedule_refresh", lambda *_: None):
r = client.put("/systems/wp", json={"name": "White Plains", "type": "P25", "config": WHITE_PLAINS})
assert r.status_code == 200, r.text
(_, _, patch_), _ = doc_update.await_args
assert patch_["config"]["mode"] == "conventional"
assert "control_channels" not in patch_["config"]
assert [c["freq"] for c in patch_["config"]["channels"]] == [460.4, 460.425]
+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">
+59
View File
@@ -0,0 +1,59 @@
import type { RadioChannel } from "@/lib/types";
// node-26#24. A node on a conventional system reports each channel it
// decodes and whether the agency encrypts it — the one decode failure nothing
// on the node can fix, so it has to be said plainly here.
const ALGIDS: Record<number, string> = {
0x81: "DES-OFB", 0x83: "Triple DES", 0x84: "AES-256", 0x85: "AES-128",
0x9f: "DES-XL", 0xa0: "DVI-XL", 0xa1: "DVP-XL", 0xaa: "ADP (RC4)",
};
function mhz(hz: number | null): string {
return hz ? `${(hz / 1e6).toFixed(4)} MHz` : "";
}
function hex(n: number, width: number): string {
return n.toString(16).toUpperCase().padStart(width, "0");
}
export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }) {
if (!channels || channels.length === 0) return null;
const encrypted = channels.filter((c) => c.encrypted);
// "Reassign the node" only when nothing in the clear has ever been heard on
// any channel — `encrypted` alone means the most recent call was.
const all = encrypted.length === channels.length && channels.every((c) => c.clear_calls === 0);
const subject = all
? channels.length === 1 ? "This channel is" : "Every channel is"
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
return (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2>
{encrypted.length > 0 && (
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
{subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "}
{encrypted.length === 1 ? "it" : "them"} is recorded. Nothing to fix on the node
{all ? "; assign this node a different system." : "; clear calls are still recorded."}
</p>
)}
<ul className="space-y-1 font-mono text-xs">
{channels.map((c, i) => (
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300">
<span className="text-white">{c.name || mhz(c.freq)}</span>
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>}
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>}
{c.encrypted ? (
<span className="text-red-400">
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
</span>
) : (
<span className="text-gray-400">
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
</span>
)}
</li>
))}
</ul>
</section>
);
}
+16
View File
@@ -43,6 +43,20 @@ export interface AuditEntry {
timestamp: string;
}
/** One conventional channel as the node reports it (node-26#24). */
export interface RadioChannel {
name: string;
freq: number | null; // Hz
nac: number | null;
encrypted: boolean; // the channel's most recent call was encrypted
encrypted_now: boolean;
enc_calls: number;
clear_calls: number;
algid: number | null;
keyid: number | null;
voice: boolean;
}
export interface NodeRecord {
node_id: string;
name: string;
@@ -68,6 +82,8 @@ export interface NodeRecord {
/** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported
/** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
radio_channels?: RadioChannel[] | null;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
override_system_id?: string | null;