From 010a6b0be54eadce2ef557357481adef00c69dff Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sat, 7 Nov 2026 01:47:20 -0500 Subject: [PATCH] tr_config: cut a too-wide trunked control-channel list to the run one SDR covers instead of refusing (node-26#45) 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 --- drb-edge-node/app/internal/tr_config.py | 26 +++++++++++++++++++++- drb-edge-node/tests/test_trunk_recorder.py | 11 +++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/drb-edge-node/app/internal/tr_config.py b/drb-edge-node/app/internal/tr_config.py index 84f3fd2..9429ca4 100644 --- a/drb-edge-node/app/internal/tr_config.py +++ b/drb-edge-node/app/internal/tr_config.py @@ -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) diff --git a/drb-edge-node/tests/test_trunk_recorder.py b/drb-edge-node/tests/test_trunk_recorder.py index 6c750e3..4581f7f 100644 --- a/drb-edge-node/tests/test_trunk_recorder.py +++ b/drb-edge-node/tests/test_trunk_recorder.py @@ -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:]