Systems: P25 trunked / P25 conventional / analog as one choice (node-26#24)
- system_config.normalize(): a P25 system with config.mode "conventional"
carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
can no longer carry control channels (422). A missing mode is trunked and
passes through untouched, so there is no migration. An analog channel is
mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
DMR, plus a channel-row editor for conventional and a one-frequency form for
analog. The node page shows conventional channels and an encryption notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ae0ece9d2c
commit
23860eb31b
@@ -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:
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user