diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py index 8b34ac3..a2782df 100644 --- a/drb-c2-core/app/internal/mqtt_handler.py +++ b/drb-c2-core/app/internal/mqtt_handler.py @@ -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: diff --git a/drb-c2-core/app/internal/system_config.py b/drb-c2-core/app/internal/system_config.py new file mode 100644 index 0000000..1cfb405 --- /dev/null +++ b/drb-c2-core/app/internal/system_config.py @@ -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 diff --git a/drb-c2-core/app/internal/talkgroups.py b/drb-c2-core/app/internal/talkgroups.py index 662c0dd..c229967 100644 --- a/drb-c2-core/app/internal/talkgroups.py +++ b/drb-c2-core/app/internal/talkgroups.py @@ -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( diff --git a/drb-c2-core/app/routers/systems.py b/drb-c2-core/app/routers/systems.py index 848e7f2..91fae85 100644 --- a/drb-c2-core/app/routers/systems.py +++ b/drb-c2-core/app/routers/systems.py @@ -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 diff --git a/drb-c2-core/tests/test_system_config.py b/drb-c2-core/tests/test_system_config.py new file mode 100644 index 0000000..8772a77 --- /dev/null +++ b/drb-c2-core/tests/test_system_config.py @@ -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] diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index 9c6a785..f8cf95c 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -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() { )} + + {/* Recent calls */} diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index 5be6c59..c968d34 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -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): 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): 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): 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): P25Config { }; } -function p25ConfigToRecord(p: P25Config): Record { +function p25ConfigToRecord(p: P25Config, conventional = false): Record { 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): 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 { + 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 ( +
+

+ 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. +

+
+
+ Frequency (MHz) + Channel name + NAC (hex, optional) + +
+ {chs.map((ch, i) => ( +
+ set(i, "freq", e.target.value)} + className={INPUT} + placeholder="460.4000" + inputMode="decimal" + required={i === 0} + /> + set(i, "name", e.target.value)} + className={INPUT} + placeholder="PD Dispatch" + /> + set(i, "nac", e.target.value)} + className={INPUT} + placeholder="any" + /> + +
+ ))} + {chs.length < MAX_CONVENTIONAL_CHANNELS && ( + + )} +
+ onChange({ ...value, talkgroups: tgs })} + /> +

+ Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named. +

+
+ ); +} + +function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: AnalogConfig) => void }) { + const ch = value.channel; + return ( +
+
+ + onChange({ channel: { ...ch, freq: e.target.value } })} + className={INPUT} + placeholder="154.4300" + inputMode="decimal" + required + /> +
+
+ + onChange({ channel: { ...ch, name: e.target.value } })} + className={INPUT} + placeholder="Fire Dispatch" + /> +
+
+ ); +} + // ── 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(initial ? kindOf(initial.type, initial.config) : "p25_trunked"); const [p25, setP25] = useState( initial?.type === "P25" && initial.config ? recordToP25Config(initial.config) : DEFAULT_P25 ); + const [analog, setAnalog] = useState( + 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(null); - function handleTypeChange(t: string) { - setType(t); - setShowRaw(t !== "P25"); + function structuredRecord(k: SystemKind): Record { + 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; - 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({ />
- +
- {type === "P25" && !showRaw ? ( + {kind === "DMR" && ( +

DMR isn't decoded by nodes yet — a node assigned this system won't start.

+ )} + + {!showRaw && kind === "p25_trunked" ? ( + ) : !showRaw && kind === "p25_conventional" ? ( + + ) : !showRaw && kind === "NBFM" ? ( + ) : (
@@ -945,7 +1175,7 @@ function SystemForm({
)} - {type === "P25" && ( + {kind !== "DMR" && (