Systems: P25 trunked / conventional / analog mode + channel editor (node-26#24) #205

Merged
logan merged 1 commits from feat/24-p25-conventional into main 2026-10-04 17:24:30 -04:00
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["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout 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", 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: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: 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 return tg.get("name") or None
except (TypeError, ValueError): except (TypeError, ValueError):
continue 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( 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.models import AreaContextBody, SystemCreate, SystemRecord
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import area_context as area_ctx from app.internal import area_context as area_ctx
from app.internal import system_config
from app.internal.auth import ( from app.internal.auth import (
require_admin_token, require_admin_token,
require_node_service_or_firebase_token, require_node_service_or_firebase_token,
@@ -38,6 +39,14 @@ class AiFlagsBody(BaseModel):
correlation_enabled: Optional[bool] = None 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("") @router.get("")
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)): async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded) org_id = await resolve_caller_org_id(decoded)
@@ -64,7 +73,9 @@ async def create_system(
_: dict = Depends(require_admin_token), _: dict = Depends(require_admin_token),
): ):
system_id = str(uuid.uuid4()) 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) await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc 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 # 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. # payload. area_context (server-26#36) would have been the second casualty.
patch = body.model_dump(exclude_unset=True) 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 # 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 # 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 # 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 { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SdrSettings } from "@/components/SdrSettings"; import { SdrSettings } from "@/components/SdrSettings";
import { RadioChannels } from "@/components/RadioChannels";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
@@ -370,6 +371,8 @@ export default function NodeDetailPage() {
)} )}
</div> </div>
<RadioChannels channels={node.radio_channels} />
<SdrSettings node={node} canEdit={isAdmin} /> <SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */} {/* Recent calls */}
+273 -38
View File
@@ -103,12 +103,70 @@ function anchorLabel(a?: AreaContext): string {
return `${place} — not yet resolved`; 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 { interface P25Config {
nac: string; nac: string;
system_id: string; system_id: string;
wacn: string; wacn: string;
control_channels: string; control_channels: string;
voice_channels: string; voice_channels: string;
channels: ChannelEntry[]; // conventional only
talkgroups: TalkgroupEntry[]; talkgroups: TalkgroupEntry[];
} }
@@ -118,6 +176,7 @@ const DEFAULT_P25: P25Config = {
wacn: "", wacn: "",
control_channels: "", control_channels: "",
voice_channels: "", voice_channels: "",
channels: [{ freq: "", name: "", nac: "" }],
talkgroups: [], talkgroups: [],
}; };
@@ -134,6 +193,9 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
voice_channels: Array.isArray(c.voice_channels) voice_channels: Array.isArray(c.voice_channels)
? (c.voice_channels as number[]).join(", ") ? (c.voice_channels as number[]).join(", ")
: "", : "",
channels: Array.isArray(c.channels) && c.channels.length
? recordToChannels(c)
: [{ freq: "", name: "", nac: "" }],
talkgroups: Array.isArray(c.talkgroups) talkgroups: Array.isArray(c.talkgroups)
? (c.talkgroups as Array<{ ? (c.talkgroups as Array<{
id: number; name: string; tag: string; 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) => const parseFreqs = (s: string) =>
s s
.split(",") .split(",")
.map((f) => parseFloat(f.trim())) .map((f) => parseFloat(f.trim()))
.filter((f) => !isNaN(f)); .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 { return {
nac: p.nac, nac: p.nac,
system_id: p.system_id ? parseInt(p.system_id, 10) : undefined, system_id: p.system_id ? parseInt(p.system_id, 10) : undefined,
wacn: p.wacn, wacn: p.wacn,
control_channels: parseFreqs(p.control_channels), control_channels: parseFreqs(p.control_channels),
voice_channels: parseFreqs(p.voice_channels), voice_channels: parseFreqs(p.voice_channels),
talkgroups: p.talkgroups 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 } : {}),
};
}),
}; };
} }
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 ─────────────────────────────────────────────── // ── RadioReference parser types ───────────────────────────────────────────────
interface RRTalkgroup { 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 ────────────────────────────────────────────────────────── // ── Main system form ──────────────────────────────────────────────────────────
function SystemForm({ function SystemForm({
@@ -843,34 +1043,47 @@ function SystemForm({
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ""); 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>( const [p25, setP25] = useState<P25Config>(
initial?.type === "P25" && initial.config initial?.type === "P25" && initial.config
? recordToP25Config(initial.config) ? recordToP25Config(initial.config)
: DEFAULT_P25 : DEFAULT_P25
); );
const [analog, setAnalog] = useState<AnalogConfig>(
initial?.type === "NBFM" && initial.config
? recordToAnalog(initial.config)
: { channel: { freq: "", name: "", nac: "" } }
);
const [rawJson, setRawJson] = useState( const [rawJson, setRawJson] = useState(
initial?.type !== "P25" && initial?.config initial?.type === "DMR" && initial?.config
? JSON.stringify(initial.config, null, 2) ? 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 [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleTypeChange(t: string) { function structuredRecord(k: SystemKind): Record<string, unknown> {
setType(t); if (k === "NBFM") return analogToRecord(analog);
setShowRaw(t !== "P25"); return p25ConfigToRecord(p25, k === "p25_conventional");
}
function handleKindChange(k: SystemKind) {
setKind(k);
setShowRaw(k === "DMR");
} }
function toggleRaw() { function toggleRaw() {
if (!showRaw) { if (!showRaw) {
setRawJson(JSON.stringify(p25ConfigToRecord(p25), null, 2)); setRawJson(JSON.stringify(structuredRecord(kind), null, 2));
} else { } else {
try { try {
setP25(recordToP25Config(JSON.parse(rawJson))); const parsed = JSON.parse(rawJson);
if (kind === "NBFM") setAnalog(recordToAnalog(parsed));
else setP25(recordToP25Config(parsed));
} catch { } catch {
// keep current p25 state if parse fails // keep current structured state if parse fails
} }
} }
setShowRaw(!showRaw); setShowRaw(!showRaw);
@@ -881,11 +1094,19 @@ function SystemForm({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
const type = typeOf(kind);
let config: Record<string, unknown>; let config: Record<string, unknown>;
if (type === "P25" && !showRaw) { if (!showRaw) {
config = p25ConfigToRecord(p25); config = structuredRecord(kind);
} else { } else {
config = JSON.parse(rawJson); 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) { if (initial && !createOnly) {
await c2api.updateSystem(initial.system_id, { name, type, config }); await c2api.updateSystem(initial.system_id, { name, type, config });
@@ -918,21 +1139,30 @@ function SystemForm({
/> />
</div> </div>
<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 <select
value={type} id="system-kind"
onChange={(e) => handleTypeChange(e.target.value)} 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" 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> {KIND_OPTIONS.map((o) => (
<option>DMR</option> <option key={o.value} value={o.value}>{o.label}</option>
<option>NBFM</option> ))}
</select> </select>
</div> </div>
</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} /> <P25Form value={p25} onChange={setP25} />
) : !showRaw && kind === "p25_conventional" ? (
<ConventionalForm value={p25} onChange={setP25} />
) : !showRaw && kind === "NBFM" ? (
<AnalogForm value={analog} onChange={setAnalog} />
) : ( ) : (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label> <label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
@@ -945,7 +1175,7 @@ function SystemForm({
</div> </div>
)} )}
{type === "P25" && ( {kind !== "DMR" && (
<button <button
type="button" type="button"
onClick={toggleRaw} onClick={toggleRaw}
@@ -1740,25 +1970,30 @@ export default function SystemsPage() {
const tgCount = Array.isArray(s.config.talkgroups) const tgCount = Array.isArray(s.config.talkgroups)
? (s.config.talkgroups as unknown[]).length ? (s.config.talkgroups as unknown[]).length
: null; : 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 ? (s.config.control_channels as unknown[]).length
: null; : null;
const chCount = (conventional || s.type === "NBFM") && Array.isArray(s.config.channels)
? (s.config.channels as unknown[]).length
: null;
return ( return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono"> <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 className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{s.name}</p> <p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</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"> <p className="text-xs text-gray-600 mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>} {!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>} {ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>}
{tgCount !== null && <span>· {tgCount} TGs</span>} {tgCount !== null && <span>· {tgCount} TGs</span>}
</p> </p>
)} )}
</div> </div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded"> <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> </span>
</div> </div>
<div className="mt-3 flex gap-3"> <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; 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 { export interface NodeRecord {
node_id: string; node_id: string;
name: string; name: string;
@@ -68,6 +82,8 @@ export interface NodeRecord {
/** The dongle OP25 is actually using, per the node. */ /** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null; op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported 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; enforce_override_timeout?: boolean;
is_overridden?: boolean; is_overridden?: boolean;
override_system_id?: string | null; override_system_id?: string | null;