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