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
from typing import Any, Dict, List, Optional, Tuple
from app.internal.logger import logger
from app.internal.conventional import channel_tgid
from app.internal.radio_config import (
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)
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:
"""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."""
@@ -208,7 +224,15 @@ def build_tr_config(
# 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
# 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)
elif mode == CONVENTIONAL:
chans = conventional_channels(config)
+9 -2
View File
@@ -136,7 +136,7 @@ def test_legacy_analog_and_unpinned_sdr():
@pytest.mark.parametrize("stype,cfg", [
("DMR", {"control_channels": [450.0]}),
("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": []}),
])
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()
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
# radio_backend reports the config error instead of pretending to start.
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():
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:]