Merge pull request 'Systems: P25 trunked / conventional / analog mode + channel editor (node-26#24)' (#205) from feat/24-p25-conventional into main
Reviewed-on: #205
This commit was merged in pull request #205.
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
||||||
|
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400">
|
||||||
|
<span>Frequency (MHz)</span>
|
||||||
|
<span>Channel name</span>
|
||||||
|
<span>NAC (hex, optional)</span>
|
||||||
|
<span className="w-16" />
|
||||||
|
</div>
|
||||||
|
{chs.map((ch, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
aria-label={`Channel ${i + 1} frequency in MHz`}
|
||||||
|
value={ch.freq}
|
||||||
|
onChange={(e) => set(i, "freq", e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="460.4000"
|
||||||
|
inputMode="decimal"
|
||||||
|
required={i === 0}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={`Channel ${i + 1} name`}
|
||||||
|
value={ch.name}
|
||||||
|
onChange={(e) => set(i, "name", e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="PD Dispatch"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={`Channel ${i + 1} NAC`}
|
||||||
|
value={ch.nac}
|
||||||
|
onChange={(e) => set(i, "nac", e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="any"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, channels: chs.filter((_, j) => j !== i) })}
|
||||||
|
disabled={chs.length === 1}
|
||||||
|
className="w-16 text-xs text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{chs.length < MAX_CONVENTIONAL_CHANNELS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, channels: [...chs, { freq: "", name: "", nac: "" }] })}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add channel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TalkgroupEditor
|
||||||
|
talkgroups={value.talkgroups}
|
||||||
|
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-600 -mt-2">
|
||||||
|
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: AnalogConfig) => void }) {
|
||||||
|
const ch = value.channel;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label>
|
||||||
|
<input
|
||||||
|
id="analog-freq"
|
||||||
|
value={ch.freq}
|
||||||
|
onChange={(e) => onChange({ channel: { ...ch, freq: e.target.value } })}
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="154.4300"
|
||||||
|
inputMode="decimal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label>
|
||||||
|
<input
|
||||||
|
id="analog-name"
|
||||||
|
value={ch.name}
|
||||||
|
onChange={(e) => onChange({ channel: { ...ch, name: e.target.value } })}
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="Fire Dispatch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main system form ──────────────────────────────────────────────────────────
|
// ── 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't decoded by nodes yet — a node assigned this system won'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">
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user