Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a2adcbf3 | ||
|
|
c4379890df | ||
|
|
010a6b0be5 |
@@ -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,
|
||||||
|
|||||||
@@ -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
Regular → Executable
Reference in New Issue
Block a user