Compare commits

...
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 a7a2adcbf3 trunk-recorder: mark drb-upload.sh and run.sh executable (node-26#45)
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 13s
CI / test (push) Successful in 45s
TR execvp()s the uploadScript; without +x every call failed with status 127
on node-002 and nothing was uploaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:07:16 -05:00
Logan CusanoandClaude Opus 5.5 c4379890df tr_config: logColor is a string in TR 5.x; TR_CENTER_HZ keeps the control channels it covers (node-26#45)
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 13s
CI / test (push) Successful in 44s
Both found on node-002. A boolean logColor made TR 5.2.1 refuse the whole
config (json type_error.302). MTA Zone 2's live control channel is 770.43 MHz,
outside the widest-cover window, so a centre override must be able to pick
which control channels to keep rather than demand all of them fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:52:02 -05:00
Logan CusanoandClaude Opus 5.5 010a6b0be5 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>
2026-11-07 01:47:20 -05:00
4 changed files with 48 additions and 4 deletions
+32 -2
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,21 @@ 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 if center_override:
half = int(sample_rate) * USABLE_BANDWIDTH_SHARE / 2
covered = [c for c in ccs if abs(c - center_override) <= half]
if not covered:
raise ValueError(f"TR_CENTER_HZ {center_override} covers none of {name!r}'s control channels.")
else:
covered = 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)
@@ -243,7 +273,7 @@ def build_tr_config(
# reads the control-channel decode rate from it for the health check. # reads the control-channel decode rate from it for the health check.
"logFile": True, "logFile": True,
"logDir": f"{TR_APP_DIR}/logs", "logDir": f"{TR_APP_DIR}/logs",
"logColor": False, "logColor": "none", # TR 5.x: a string ("console"|"none"), not a bool
# Live audio for Discord: simplestream sends every call's PCM to the # Live audio for Discord: simplestream sends every call's PCM to the
# edge-node, which follows one talkgroup at a time into PulseAudio. # edge-node, which follows one talkgroup at a time into PulseAudio.
"audioStreaming": True, "audioStreaming": True,
+16 -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,16 @@ 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:]
def test_center_override_keeps_only_the_control_channels_it_covers():
tr, _ = tr_config.build_tr_config(
"P25", "MTA", {"control_channels": [770.15625, 770.43125, 773.29375]},
**{**TR_KW, "center_override": 770_900_000})
assert tr["systems"][0]["control_channels"] == [770_156_250, 770_431_250]
Regular → Executable
View File
Regular → Executable
View File