tr_config: cut a too-wide trunked control-channel list to the run one SDR covers instead of refusing (node-26#45)
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 12s
CI / test (push) Successful in 44s

Found on node-002 (MTA Zone 2): five control channels span 4.15 MHz, so the
whole system was refused. TR needs one live control channel; use the largest
set that fits and log the rest. Conventional channels must still all fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-11-07 01:47:20 -05:00
co-authored by Claude Opus 5.5
parent e2a5b63067
commit 010a6b0be5
2 changed files with 34 additions and 3 deletions
+25 -1
View File
@@ -19,6 +19,7 @@ import csv
import io import io
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from app.internal.logger import logger
from app.internal.conventional import channel_tgid from app.internal.conventional import channel_tgid
from app.internal.radio_config import ( from app.internal.radio_config import (
ANALOG, CONVENTIONAL, DMR, TRUNKED, analog_frequency, conventional_channels, system_mode, to_hz, ANALOG, CONVENTIONAL, DMR, TRUNKED, analog_frequency, conventional_channels, system_mode, to_hz,
@@ -50,6 +51,21 @@ def _fits(center: int, freqs: List[int], half_band: float) -> bool:
return all(abs(f - center) <= half_band for f in freqs) return all(abs(f - center) <= half_band for f in freqs)
def widest_cover(freqs: List[int], rate: int) -> List[int]:
"""The largest run of frequencies one SDR at `rate` can hold at once
(ties: the lowest). A trunked system lists every control channel it might
use, but TR needs only one live at a time, so a list wider than the SDR is
cut down to what fits instead of refusing the whole system."""
half = rate * USABLE_BANDWIDTH_SHARE / 2
fs = sorted(set(freqs))
best: List[int] = []
for i, lo in enumerate(fs):
run = [f for f in fs[i:] if f - lo <= 2 * half]
if len(run) > len(best):
best = run
return best
def choose_center(freqs: List[int], rate: int, override: Optional[int] = None) -> int: def choose_center(freqs: List[int], rate: int, override: Optional[int] = None) -> int:
"""A centre frequency that puts every channel in the passband and none on """A centre frequency that puts every channel in the passband and none on
the DC spike. ValueError if they cannot all fit on one SDR.""" the DC spike. ValueError if they cannot all fit on one SDR."""
@@ -208,7 +224,15 @@ def build_tr_config(
# channels are known here, so the SDR is centred on those. A system # channels are known here, so the SDR is centred on those. A system
# whose voice channels sit outside that ~2 MHz loses those calls (TR # whose voice channels sit outside that ~2 MHz loses those calls (TR
# logs "no source covering" for each) — needs a second source. # logs "no source covering" for each) — needs a second source.
freqs = ccs covered = ccs if center_override else widest_cover(ccs, int(sample_rate))
if len(covered) < len(ccs):
system["control_channels"] = covered
logger.warning(
f"Control channels span {(max(ccs) - min(ccs)) / 1e6:.3f} MHz, wider than one SDR; "
f"using the {len(covered)} of {len(ccs)} that fit "
f"({min(covered) / 1e6:.5f}-{max(covered) / 1e6:.5f} MHz)."
)
freqs = covered
source["digitalRecorders"] = int(digital_recorders) source["digitalRecorders"] = int(digital_recorders)
elif mode == CONVENTIONAL: elif mode == CONVENTIONAL:
chans = conventional_channels(config) chans = conventional_channels(config)
+9 -2
View File
@@ -136,7 +136,7 @@ def test_legacy_analog_and_unpinned_sdr():
@pytest.mark.parametrize("stype,cfg", [ @pytest.mark.parametrize("stype,cfg", [
("DMR", {"control_channels": [450.0]}), ("DMR", {"control_channels": [450.0]}),
("P25", {"control_channels": []}), ("P25", {"control_channels": []}),
("P25", {"control_channels": [851.0, 855.0]}), # 4 MHz: wider than one RTL-SDR ("P25", {"mode": "conventional", "channels": [{"freq": 851.0}, {"freq": 855.0}]}), # 4 MHz: every channel must fit
("P25", {"mode": "conventional", "channels": []}), ("P25", {"mode": "conventional", "channels": []}),
]) ])
def test_unbuildable_systems_raise(stype, cfg): def test_unbuildable_systems_raise(stype, cfg):
@@ -374,7 +374,8 @@ async def test_apply_config_writes_files_and_restart(tr_env, monkeypatch, tmp_pa
assert cfg["sources"][0]["device"] == "rtl=00000101" and (d / "talkgroups.csv").exists() assert cfg["sources"][0]["device"] == "rtl=00000101" and (d / "talkgroups.csv").exists()
bad = await t.apply_config(SystemConfig(system_id="s2", name="Wide", type="P25", bad = await t.apply_config(SystemConfig(system_id="s2", name="Wide", type="P25",
config={"control_channels": [851.0, 856.0]})) config={"mode": "conventional",
"channels": [{"freq": 851.0}, {"freq": 856.0}]}))
assert not bad and "MHz" in t.config_error assert not bad and "MHz" in t.config_error
# radio_backend reports the config error instead of pretending to start. # radio_backend reports the config error instead of pretending to start.
monkeypatch.setattr(trmod, "trunk_recorder", t) monkeypatch.setattr(trmod, "trunk_recorder", t)
@@ -492,3 +493,9 @@ def test_health_tr_failures(tr, cid, state):
def test_health_tr_conventional_has_no_decode_rate(): def test_health_tr_conventional_has_no_decode_rate():
assert _facts(_tr(decode_rate=None), mode="conventional")["decode"]["state"] == "ok" assert _facts(_tr(decode_rate=None), mode="conventional")["decode"]["state"] == "ok"
def test_wide_control_channel_list_is_cut_to_what_one_sdr_covers():
# MTA Zone 2 (node-002): five control channels spanning 4.15 MHz.
ccs = [770_156_250, 770_431_250, 773_293_750, 773_843_750, 774_306_250]
assert tr_config.widest_cover(ccs, 2_400_000) == ccs[2:]