- 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>
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
"""
|
|
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
|