parent
ed5a4b31d4
commit
92c1e906bd
@ -0,0 +1,85 @@
|
||||
# Testing
|
||||
|
||||
Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`bridge/`, `hbp/`, `obp/`, …). See **[`tests/README.md`](../../../tests/README.md)** for the full file index.
|
||||
|
||||
They use an in-process **deterministic harness** (no Twisted reactor, no UDP sockets).
|
||||
|
||||
## Install dev dependencies
|
||||
|
||||
```bash
|
||||
python3 -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`.
|
||||
|
||||
## Run tests
|
||||
|
||||
Full suite:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
|
||||
**By domain** (while working on one area):
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/bridge/ -q
|
||||
python3 -m pytest tests/hbp/ -q
|
||||
python3 -m pytest tests/parrot/ -q
|
||||
```
|
||||
|
||||
**By file** (recommended for point checks):
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/bridge/test_unit_data_routing.py -q
|
||||
python3 -m pytest tests/parrot/test_rekey_playback.py -q
|
||||
```
|
||||
|
||||
**Single test**:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q
|
||||
```
|
||||
|
||||
Collect only:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ --collect-only -q
|
||||
```
|
||||
|
||||
## Markers
|
||||
|
||||
Registered in `pyproject.toml`:
|
||||
|
||||
| Marker | Use |
|
||||
|--------|-----|
|
||||
| `@pytest.mark.behavior` | Integration-style regression (P0/P1) |
|
||||
| `@pytest.mark.smoke` | Quick routing smoke checks |
|
||||
|
||||
## Harness overview
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| `DeterministicScenario` | Wires `BridgeUseCases` with fakes and packet capture |
|
||||
| `inject_hbp` / `inject_unit` / `inject_obp` | Public ingress paths into `dmrd_received` |
|
||||
| `PacketCapture` | Records outbound `send_to_system` + parsed DMR fields |
|
||||
| `FakeReportSender` + `ReportingUseCases` | Reporting events (via `scenario.report_factory.events`) |
|
||||
| `tests/harness/assertions.py` | Reusable asserts: `assert_forwarded`, `assert_report_event`, … |
|
||||
|
||||
Full audit (scores, red-test table, inventory): internal `docs-priv/en/test-audit.md` in maintainer checkouts.
|
||||
|
||||
## Writing a regression test
|
||||
|
||||
1. Docstring: **Regression:** if X breaks, this test fails because Y.
|
||||
2. Prefer a **dedicated file** (or the smallest existing file for the same topic).
|
||||
3. Enter through a public path (`inject_*`, use-case API, or `@pytest.mark.unit` for pure domain).
|
||||
4. Assert an **observable** outcome (capture, `master.sent`, report strings), not only private flags.
|
||||
5. Add a negative path where behaviour differs.
|
||||
6. **Red-test:** break the production condition → test must fail → revert.
|
||||
7. Mark `@pytest.mark.behavior` or `@pytest.mark.unit`.
|
||||
|
||||
Do not chase coverage percentage; chase regressions operators would notice on RF.
|
||||
|
||||
## v2 policy
|
||||
|
||||
Each v2 feature branch should include or extend regression tests in the same PR. Baseline task **V2-TST-001** tracks bringing `tests/` into the repo on `develop`.
|
||||
@ -0,0 +1,128 @@
|
||||
# Test layout
|
||||
|
||||
One **topic per file** — run only what you need while developing or validating a change.
|
||||
|
||||
```bash
|
||||
python3 -m pip install -e ".[dev]"
|
||||
python3 -m pytest tests/<path>/test_<name>.py -q # single file
|
||||
python3 -m pytest tests/<path>/test_<name>.py::test_foo -q # single test
|
||||
python3 -m pytest tests/bridge/ -q # whole domain
|
||||
python3 -m pytest tests/ -q # full suite (142)
|
||||
```
|
||||
|
||||
Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`.
|
||||
|
||||
## Directories
|
||||
|
||||
| Directory | What it covers |
|
||||
|-----------|----------------|
|
||||
| `bridge/` | Static TG, startup bridges, unit data, CRC dedup, echo, private voice, config reload |
|
||||
| `hbp/` | HBP ingress, loop control, rate limit, timeout/collision, master maintenance |
|
||||
| `obp/` | OpenBridge loop, rate limit, unit-data loop |
|
||||
| `voice/` | Announcements, TTS schedule, broadcast queue, disconnected voice, in-band signalling |
|
||||
| `talker_alias/` | Encode/decode, passthrough, MMDVM wire, bridge inject |
|
||||
| `parrot/` | Recording timers, playback loop, seq preservation, ingress path |
|
||||
| `smoke/` | Quick routing smoke + packet builder |
|
||||
| `infrastructure/` | Logging reload |
|
||||
| `scripts/` | Config conversion helpers |
|
||||
| `harness/` | Shared fakes (`DeterministicScenario`, assertions) — not run as tests |
|
||||
|
||||
## Files by domain
|
||||
|
||||
### bridge/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_config_reload.py` | 1 | Merge system config on reload |
|
||||
| `test_crc_dedup.py` | 3 | HBP/OBP CRC dedup, seq=0 |
|
||||
| `test_echo_bridgereset.py` | 5 | Echo leg after BRIDGERESET / OPTIONS |
|
||||
| `test_options_config_loop.py` | 2 | 26s OPTIONS static TG |
|
||||
| `test_private_voice.py` | 3 | Private call routing |
|
||||
| `test_startup_bridges.py` | 4 | Startup BRIDGES + voice E2E |
|
||||
| `test_static_tg_options.py` | 4 | Static TG from peer OPTIONS |
|
||||
| `test_unit_data_ingress.py` | 4 | Unit headers, CSBK, reports |
|
||||
| `test_unit_data_routing.py` | 5 | SUB_MAP, hotspot, gateway, OBP fanout |
|
||||
|
||||
### hbp/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_hbp_loop_control.py` | 2 | HBP loop winner/loser |
|
||||
| `test_hbp_rate_limit.py` | 2 | Ingress rate drop + stall |
|
||||
| `test_ingress.py` | 3 | RX start, rate drop, OBP loop loser |
|
||||
| `test_master_maintenance.py` | 2 | Peer timeout / shared PEERS dict |
|
||||
| `test_timeout_collision.py` | 3 | 180s timeout, collision, rekey |
|
||||
|
||||
### obp/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_loop_control.py` | 4 | OBP loop, VTERM, BCSQ |
|
||||
| `test_obp_rate_limit.py` | 1 | Rate limit epoch |
|
||||
| `test_unit_data_loop.py` | 2 | Unit data loop loser |
|
||||
|
||||
### voice/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_announcement_anticollision.py` | 3 | Busy slot skip / abort |
|
||||
| `test_broadcast_queue.py` | 2 | Same-TG broadcast queue |
|
||||
| `test_disconnected_voice.py` | 3 | Not-linked / reflector prompts |
|
||||
| `test_embed_ta_forward.py` | 2 | Embed TA state on bridge |
|
||||
| `test_in_band_signalling.py` | 5 | Reflector / single-mode VTERM |
|
||||
| `test_play_file_on_request.py` | 3 | On-demand file playback |
|
||||
| `test_scheduled_announcement.py` | 4 | File announcements (AMBE) |
|
||||
| `test_scheduled_tts.py` | 7 | TTS schedule + conversion |
|
||||
| `test_voice_config_reload.py` | 3 | Hot reload announcement/TTS loops |
|
||||
|
||||
### talker_alias/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_bridge_inject.py` | 5 | TA inject on bridge VHEAD |
|
||||
| `test_embed_ta.py` | 4 | Embedded LC modes |
|
||||
| `test_encode_decode.py` | 8 | Domain encode/decode |
|
||||
| `test_format.py` | 2 | Format from subscriber profile |
|
||||
| `test_mmdvm_wire.py` | 10 | MMDVM wire blocks |
|
||||
| `test_passthrough.py` | 9 | Passthrough / both modes |
|
||||
|
||||
### parrot/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `test_playback_ingress.py` | 2 | `ingress_pkt_time`, record→playback |
|
||||
| `test_playback_logging.py` | 1 | Duration log format |
|
||||
| `test_playback_send_loop.py` | 4 | Send loop, max duration, interval |
|
||||
| `test_recording_timers.py` | 2 | Idle timeout, VTERM commit |
|
||||
| `test_rekey_playback.py` | 4 | Seq preservation past 255 / 30s |
|
||||
|
||||
### smoke/ · infrastructure/ · scripts/
|
||||
|
||||
| File | Tests | Topic |
|
||||
|------|-------|-------|
|
||||
| `smoke/test_bridge_routing.py` | 1 | Static TG forward smoke |
|
||||
| `smoke/test_packet_builder.py` | 1 | PacketSpec builder |
|
||||
| `infrastructure/test_logging_reload.py` | 2 | Log level reload |
|
||||
| `scripts/test_freedmr_cfg_to_yaml.py` | 5 | Legacy cfg → YAML |
|
||||
|
||||
## Examples (copy-paste)
|
||||
|
||||
```bash
|
||||
# After changing bridge unit-data routing
|
||||
python3 -m pytest tests/bridge/test_unit_data_routing.py -q
|
||||
|
||||
# After parrot seq fix
|
||||
python3 -m pytest tests/parrot/test_rekey_playback.py -q
|
||||
|
||||
# HBP loop + rate (common RF regressions)
|
||||
python3 -m pytest tests/hbp/test_hbp_loop_control.py tests/hbp/test_hbp_rate_limit.py -q
|
||||
|
||||
# One test by name
|
||||
python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q
|
||||
```
|
||||
|
||||
## Policy
|
||||
|
||||
- **New tests:** add a new file (or extend the smallest existing file for the same topic). Avoid large multi-topic modules.
|
||||
- **Harness:** shared code lives in `harness/` only.
|
||||
- Full audit: `docs-priv/en/test-audit.md` (maintainer checkout).
|
||||
@ -0,0 +1,38 @@
|
||||
"""Config reload merge preserves runtime MASTER state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adn_server.infrastructure.config_reload import merge_system_config
|
||||
|
||||
|
||||
def test_merge_system_config_preserves_peers_and_static_tgs() -> None:
|
||||
old = {
|
||||
"MODE": "MASTER",
|
||||
"ENABLED": True,
|
||||
"IP": "127.0.0.1",
|
||||
"PORT": 62031,
|
||||
"PEERS": {"1001": {"IP": "10.0.0.1"}},
|
||||
"OPTIONS": "TS2=52090;TIMER=10",
|
||||
"TS1_STATIC": "91",
|
||||
"TS2_STATIC": "52090",
|
||||
"DEFAULT_UA_TIMER": 10,
|
||||
"_options_static_apply_fp": "52090|10",
|
||||
}
|
||||
new = {
|
||||
"MODE": "MASTER",
|
||||
"ENABLED": True,
|
||||
"IP": "127.0.0.1",
|
||||
"PORT": 62031,
|
||||
"GROUP_HANGTIME": 3,
|
||||
"TS1_STATIC": "",
|
||||
"TS2_STATIC": "",
|
||||
}
|
||||
|
||||
merged = merge_system_config(old, new)
|
||||
|
||||
assert merged["PEERS"] == old["PEERS"]
|
||||
assert merged["OPTIONS"] == old["OPTIONS"]
|
||||
assert merged["TS1_STATIC"] == old["TS1_STATIC"]
|
||||
assert merged["TS2_STATIC"] == old["TS2_STATIC"]
|
||||
assert merged["_options_static_apply_fp"] == old["_options_static_apply_fp"]
|
||||
assert merged["GROUP_HANGTIME"] == 3
|
||||
@ -0,0 +1,62 @@
|
||||
"""CRC payload dedup: only seq > 0 triggers drop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_forwarded, assert_inject_ok
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
patch_bridge_wall_time,
|
||||
)
|
||||
|
||||
|
||||
def test_hbp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0xABCD1234)
|
||||
burst = DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2)
|
||||
t0 = scenario.clock.time()
|
||||
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.06)
|
||||
ok = scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.12)
|
||||
assert not ok
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_hbp_crc_dedup_allows_seq_zero_repeat() -> None:
|
||||
"""Regression: duplicate VHEAD with seq 0 still forwards (not subject to crc dedup)."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0xDEADBEEF, seq=0)
|
||||
vhead = DeterministicScenario.voice_head_spec(base)
|
||||
t0 = scenario.clock.time()
|
||||
|
||||
ok1 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0)
|
||||
ok2 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0 + 0.06)
|
||||
assert_inject_ok(ok1)
|
||||
assert_inject_ok(ok2)
|
||||
assert_forwarded(scenario, "MASTER-B", count=2, dst_id=91)
|
||||
|
||||
|
||||
def test_obp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
|
||||
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
|
||||
config = DeterministicScenario().config
|
||||
add_openbridge_system(config, "OBP-CL")
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
base = PacketSpec(dst_id=52090, stream_id=0xCAFEBABE, slot=1)
|
||||
burst = DeterministicScenario.voice_burst_spec(base, seq=3, dtype_vseq=3)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_obp("OBP-CL", burst)
|
||||
ok = scenario.inject_obp("OBP-CL", burst)
|
||||
assert not ok
|
||||
@ -0,0 +1,141 @@
|
||||
"""ECHO / parrot bridge leg restore after BRIDGERESET (regression for production outage)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_forwarded
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
|
||||
|
||||
from adn_server.domain import ID_MAX, PEER_MAX
|
||||
from adn_server.infrastructure.config_loader import acl_build
|
||||
from adn_server.main import _make_echo_bridges
|
||||
|
||||
|
||||
def _echo_scenario_config() -> dict:
|
||||
config = minimal_config(("SYSTEM-82",))
|
||||
config["SYSTEMS"]["ECHO"] = {
|
||||
"MODE": "MASTER",
|
||||
"ENABLED": True,
|
||||
"REPEAT": True,
|
||||
"MAX_PEERS": 1,
|
||||
"IP": "127.0.0.1",
|
||||
"PORT": 54917,
|
||||
"PASSPHRASE": b"passw0rd",
|
||||
"GROUP_HANGTIME": 5,
|
||||
"USE_ACL": True,
|
||||
"REG_ACL": "DENY:1",
|
||||
"SUB_ACL": "DENY:1",
|
||||
"TGID_TS1_ACL": "DENY:ALL",
|
||||
"TGID_TS2_ACL": "PERMIT:9990",
|
||||
"DEFAULT_UA_TIMER": 1,
|
||||
"SINGLE_MODE": True,
|
||||
"VOICE_IDENT": False,
|
||||
"TS1_STATIC": "",
|
||||
"TS2_STATIC": "9990",
|
||||
"DEFAULT_REFLECTOR": 0,
|
||||
"GENERATOR": 0,
|
||||
"ALLOW_UNREG_ID": True,
|
||||
"PEERS": {},
|
||||
}
|
||||
config["SYSTEMS"]["SYSTEM-82"]["TS2_STATIC"] = ""
|
||||
echo = config["SYSTEMS"]["ECHO"]
|
||||
echo["REG_ACL"] = acl_build(str(echo["REG_ACL"]), PEER_MAX)
|
||||
echo["SUB_ACL"] = acl_build(str(echo["SUB_ACL"]), ID_MAX)
|
||||
echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX)
|
||||
echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX)
|
||||
return config
|
||||
|
||||
|
||||
def _bridges_after_bridgereset(config: dict) -> dict:
|
||||
bridges = _make_echo_bridges(config)
|
||||
for entry in bridges["9990"]:
|
||||
if entry["SYSTEM"] == "ECHO":
|
||||
entry["ACTIVE"] = False
|
||||
entry["TO_TYPE"] = "ON"
|
||||
for entry in bridges["9990"]:
|
||||
if entry["SYSTEM"] == "SYSTEM-82":
|
||||
entry["ACTIVE"] = True
|
||||
entry["TO_TYPE"] = "ON"
|
||||
return bridges
|
||||
|
||||
|
||||
def test_bridge_reset_restores_echo_leg_with_use_acl() -> None:
|
||||
"""BRIDGERESET must restore ECHO service leg when USE_ACL and TG2_ACL are processed."""
|
||||
config = _echo_scenario_config()
|
||||
config["SYSTEMS"]["ECHO"]["_reset"] = True
|
||||
bridges = _bridges_after_bridgereset(config)
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
scenario.bridge.bridge_reset_loop()
|
||||
|
||||
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
|
||||
assert echo_leg["ACTIVE"] is True
|
||||
assert echo_leg["TO_TYPE"] == "NONE"
|
||||
|
||||
|
||||
def test_options_config_restores_echo_after_bridgereset() -> None:
|
||||
"""Peer RPTO (options_config_for_system) re-enables ECHO leg after BRIDGERESET."""
|
||||
config = _echo_scenario_config()
|
||||
echo_cfg = config["SYSTEMS"]["ECHO"]
|
||||
echo_cfg["OPTIONS"] = "TS2=9990;"
|
||||
bridges = _bridges_after_bridgereset(config)
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
scenario.bridge.options_config_for_system("ECHO")
|
||||
|
||||
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
|
||||
assert echo_leg["ACTIVE"] is True
|
||||
assert echo_leg["TO_TYPE"] == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_echo_voice_forwards_after_bridgereset_and_options() -> None:
|
||||
"""Regression: voice on TG 9990 reaches ECHO master after reset + RPTO restore."""
|
||||
config = _echo_scenario_config()
|
||||
echo_cfg = config["SYSTEMS"]["ECHO"]
|
||||
echo_cfg["OPTIONS"] = "TS2=9990;"
|
||||
bridges = _bridges_after_bridgereset(config)
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
scenario.bridge.options_config_for_system("ECHO")
|
||||
|
||||
base = PacketSpec(dst_id=9990, stream_id=0xDEADBEEF, slot=2)
|
||||
scenario.inject_hbp("SYSTEM-82", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_hbp(
|
||||
"SYSTEM-82",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
|
||||
assert_forwarded(scenario, "ECHO", count=2, dst_id=9990)
|
||||
|
||||
|
||||
def test_bridge_reset_with_yaml_tgid_ts_acl_only() -> None:
|
||||
"""Restore reads TG2_ACL tuple (as after process_acls), not TGID_TS2_ACL YAML string."""
|
||||
config = _echo_scenario_config()
|
||||
echo = config["SYSTEMS"]["ECHO"]
|
||||
echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX)
|
||||
echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX)
|
||||
echo["_reset"] = True
|
||||
bridges = _bridges_after_bridgereset(config)
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
scenario.bridge.bridge_reset_loop()
|
||||
|
||||
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
|
||||
assert echo_leg["ACTIVE"] is True
|
||||
assert echo_leg["TO_TYPE"] == "NONE"
|
||||
|
||||
|
||||
def test_bridge_reset_acl_uses_processed_tg2_acl_not_yaml_string() -> None:
|
||||
"""_restore must not read raw TGID_TS2_ACL strings (TypeError in acl_check)."""
|
||||
config = _echo_scenario_config()
|
||||
echo_cfg = config["SYSTEMS"]["ECHO"]
|
||||
echo_cfg["TGID_TS2_ACL"] = "PERMIT:9990"
|
||||
echo_cfg["TG2_ACL"] = acl_build("PERMIT:9990", 16776415)
|
||||
echo_cfg["_reset"] = True
|
||||
bridges = _bridges_after_bridgereset(config)
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
scenario.bridge.bridge_reset_loop()
|
||||
|
||||
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
|
||||
assert echo_leg["ACTIVE"] is True
|
||||
@ -0,0 +1,32 @@
|
||||
"""OPTIONS timer loop (options_config_loop, 26s refresh)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario
|
||||
|
||||
|
||||
def test_options_config_loop_applies_static_tg_from_options() -> None:
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10"
|
||||
config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = ""
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
|
||||
scenario = DeterministicScenario(config=config)
|
||||
|
||||
scenario.bridge.options_config_loop()
|
||||
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
assert sys_cfg["TS2_STATIC"] == "52090"
|
||||
assert "52090" in scenario.bridge.get_bridges()
|
||||
|
||||
|
||||
def test_options_config_loop_skips_master_without_options() -> None:
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"].pop("OPTIONS", None)
|
||||
config["SYSTEMS"]["MASTER-B"]["OPTIONS"] = "TS2=91;TIMER=10"
|
||||
config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] = ""
|
||||
scenario = DeterministicScenario(config=config)
|
||||
|
||||
scenario.bridge.options_config_loop()
|
||||
|
||||
assert scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") == ""
|
||||
assert scenario.config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] == "91"
|
||||
@ -0,0 +1,79 @@
|
||||
"""Private (unit) voice routing via SUB_MAP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config, parse_dmr_fields
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
|
||||
|
||||
|
||||
def _private_call_scenario(dst_subscriber: int = 7123456) -> tuple[DeterministicScenario, int]:
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
config["SYSTEMS"]["MASTER-A"]["PEERS"] = {
|
||||
"1001": {"CALLSIGN": "SRC", "IP": "127.0.0.1", "PORT": 62032},
|
||||
}
|
||||
config["SYSTEMS"]["MASTER-B"]["PEERS"] = {
|
||||
"1002": {"CALLSIGN": "DST", "IP": "127.0.0.1", "PORT": 62033},
|
||||
}
|
||||
config["_SUB_MAP"] = {bytes_3(dst_subscriber): ("MASTER-B", 2, 1000.0)}
|
||||
return DeterministicScenario(config=config), dst_subscriber
|
||||
|
||||
|
||||
def test_private_call_forwards_to_subscriber_system() -> None:
|
||||
scenario, dst_sub = _private_call_scenario()
|
||||
base = PacketSpec(
|
||||
call_type="unit",
|
||||
dst_id=dst_sub,
|
||||
rf_src=3120001,
|
||||
stream_id=0xAABBCCDD,
|
||||
slot=2,
|
||||
)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
|
||||
scenario.inject_unit(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.unit_voice_burst_spec(base, seq=1),
|
||||
)
|
||||
|
||||
forwarded = scenario.capture.for_system("MASTER-B")
|
||||
assert len(forwarded) >= 2
|
||||
assert all(parse_dmr_fields(p.packet)["call_type"] == "unit" for p in forwarded)
|
||||
|
||||
|
||||
def test_private_call_same_system_does_not_forward() -> None:
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
dst_sub = 7123456
|
||||
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-A", 2, 1000.0)}
|
||||
scenario = DeterministicScenario(config=config)
|
||||
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x11223344, slot=2)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
|
||||
|
||||
assert scenario.capture.packets == []
|
||||
|
||||
|
||||
def test_private_call_collision_drops_new_stream() -> None:
|
||||
scenario, dst_sub = _private_call_scenario()
|
||||
t0 = scenario.clock.time()
|
||||
slot = scenario.protocols["MASTER-A"].STATUS[2]
|
||||
slot.update(
|
||||
{
|
||||
"RX_STREAM_ID": bytes_4(0x99998888),
|
||||
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||
"RX_RFS": bytes_3(1111111),
|
||||
"RX_TIME": t0,
|
||||
}
|
||||
)
|
||||
scenario.clock.advance(0.1)
|
||||
base = PacketSpec(
|
||||
call_type="unit",
|
||||
dst_id=dst_sub,
|
||||
rf_src=2222222,
|
||||
stream_id=0xDEADBEEF,
|
||||
slot=2,
|
||||
)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
|
||||
|
||||
assert scenario.capture.packets == []
|
||||
@ -0,0 +1,68 @@
|
||||
"""Startup bridge wiring (apply_startup_bridges)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_forwarded
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge, minimal_config
|
||||
|
||||
|
||||
def test_apply_startup_bridges_creates_static_ts2_tg() -> None:
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
|
||||
scenario = DeterministicScenario(config=config)
|
||||
|
||||
scenario.bridge.apply_startup_bridges()
|
||||
|
||||
assert "52090" in scenario.bridge.get_bridges()
|
||||
leg = next(e for e in scenario.bridge.get_bridges()["52090"] if e["SYSTEM"] == "MASTER-A")
|
||||
assert leg["TS"] == 2
|
||||
assert leg["ACTIVE"] is True
|
||||
|
||||
|
||||
def test_apply_startup_bridges_creates_default_reflector_bridge() -> None:
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 310
|
||||
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
|
||||
scenario = DeterministicScenario(config=config)
|
||||
|
||||
scenario.bridge.apply_startup_bridges()
|
||||
|
||||
assert "#310" in scenario.bridge.get_bridges()
|
||||
|
||||
|
||||
def test_options_config_for_system_matches_startup_static_tg() -> None:
|
||||
"""RPTO path (options_config_for_system) and startup both materialize the same TS2 leg."""
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10"
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
|
||||
scenario = DeterministicScenario(config=config)
|
||||
|
||||
scenario.bridge.options_config_for_system("MASTER-A")
|
||||
|
||||
assert scenario.config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] == "52090"
|
||||
assert "52090" in scenario.bridge.get_bridges()
|
||||
|
||||
scenario.bridge.apply_startup_bridges()
|
||||
assert "52090" in scenario.bridge.get_bridges()
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_startup_bridge_routes_voice_after_apply() -> None:
|
||||
"""Regression: static TG from apply_startup_bridges forwards HBP voice to peer MASTER."""
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
|
||||
bridges = active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
scenario.bridge.apply_startup_bridges()
|
||||
|
||||
base = PacketSpec(dst_id=52090, stream_id=0x80808080, slot=2)
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
|
||||
assert_forwarded(scenario, "MASTER-B", count=2, dst_id=52090)
|
||||
@ -0,0 +1,99 @@
|
||||
"""Static TG / OPTIONS handling (commits fdf45d3, 4e8a2d0 echo leg restore)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, active_bridge
|
||||
|
||||
from adn_server.domain import bytes_3
|
||||
|
||||
|
||||
def _master_with_options(options: str) -> DeterministicScenario:
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = options
|
||||
config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = ""
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
|
||||
return DeterministicScenario(config=config)
|
||||
|
||||
|
||||
def test_options_ignores_malformed_ts_tokens() -> None:
|
||||
scenario = _master_with_options("TS1=91,A92;TS2=52090;TIMER=10")
|
||||
before_ts2 = scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "")
|
||||
|
||||
scenario.bridge.options_config_for_system("MASTER-A")
|
||||
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
assert sys_cfg.get("TS1_STATIC", "") in ("", before_ts2)
|
||||
assert sys_cfg.get("TS2_STATIC", "") == before_ts2
|
||||
|
||||
|
||||
def test_options_duplicate_fingerprint_restores_prohibited_legs() -> None:
|
||||
"""Identical RPTO fingerprint still runs _restore_prohibited_static_bridge_legs."""
|
||||
config = DeterministicScenario().config
|
||||
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=9990;TIMER=10"
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "9990"
|
||||
config["SYSTEMS"]["MASTER-A"]["_options_static_apply_fp"] = "|9990|10"
|
||||
bridges = active_bridge(9990, (("MASTER-A", 2),))
|
||||
bridges["9990"] = [
|
||||
{
|
||||
"SYSTEM": "MASTER-A",
|
||||
"TS": 2,
|
||||
"TGID": bytes_3(9990),
|
||||
"ACTIVE": False,
|
||||
"TIMEOUT": 600,
|
||||
"TO_TYPE": "STAT",
|
||||
"ON": [],
|
||||
"OFF": [],
|
||||
"RESET": [],
|
||||
"TIMER": 0,
|
||||
}
|
||||
]
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
scenario.bridge.options_config_for_system("MASTER-A")
|
||||
|
||||
leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A")
|
||||
assert leg["ACTIVE"] is True
|
||||
assert leg["TO_TYPE"] == "NONE"
|
||||
|
||||
|
||||
def test_bridge_reset_restores_prohibited_static_legs() -> None:
|
||||
"""BRIDGERESET after peer loss re-enables YAML static TG 9990 on MASTER."""
|
||||
config = DeterministicScenario().config
|
||||
sys_cfg = config["SYSTEMS"]["MASTER-A"]
|
||||
sys_cfg["TS2_STATIC"] = "9990"
|
||||
sys_cfg["OPTIONS"] = "TS2=9990;TIMER=10"
|
||||
sys_cfg["_reset"] = True
|
||||
sys_cfg["_opt_key"] = "stale"
|
||||
sys_cfg["_options_static_apply_fp"] = "|9990|10"
|
||||
|
||||
bridges = active_bridge(9990, (("MASTER-A", 2),))
|
||||
for entry in bridges["9990"]:
|
||||
if entry["SYSTEM"] == "MASTER-A":
|
||||
entry["ACTIVE"] = False
|
||||
entry["TO_TYPE"] = "ON"
|
||||
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
scenario.bridge.bridge_reset_loop()
|
||||
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
assert sys_cfg["_reset"] is False
|
||||
assert "_opt_key" not in sys_cfg
|
||||
assert "_options_static_apply_fp" not in sys_cfg
|
||||
|
||||
leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A")
|
||||
assert leg["ACTIVE"] is True
|
||||
assert leg["TO_TYPE"] == "NONE"
|
||||
|
||||
|
||||
def test_options_applies_valid_static_tg_once() -> None:
|
||||
scenario = _master_with_options("TS2=52090;TIMER=10")
|
||||
scenario.bridge.options_config_for_system("MASTER-A")
|
||||
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
assert sys_cfg["TS2_STATIC"] == "52090"
|
||||
assert "52090" in scenario.bridge.get_bridges()
|
||||
fp = sys_cfg.get("_options_static_apply_fp")
|
||||
assert fp == "|52090|10"
|
||||
|
||||
scenario.bridge.options_config_for_system("MASTER-A")
|
||||
assert scenario.config["SYSTEMS"]["MASTER-A"].get("_options_static_apply_fp") == fp
|
||||
@ -0,0 +1,65 @@
|
||||
"""Unit data ingress: headers, CSBK, reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_inject_ok, assert_report_event
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
|
||||
|
||||
from adn_server.domain import bytes_4
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_unit_data_header_accepted_from_hbp() -> None:
|
||||
"""Regression: unit-data header (dtype 6) is accepted and reported."""
|
||||
scenario = DeterministicScenario(enable_reporting=True)
|
||||
base = PacketSpec(call_type="unit", dst_id=5001, stream_id=0x51515151, slot=2)
|
||||
|
||||
ok = scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_inject_ok(ok)
|
||||
assert_report_event(scenario, "UNIT DATA HEADER")
|
||||
slot = scenario.protocols["MASTER-A"].STATUS[2]
|
||||
assert slot.get("RX_STREAM_ID") == bytes_4(base.stream_id)
|
||||
|
||||
|
||||
def test_unit_data_reports_rx_event_when_reporting_enabled() -> None:
|
||||
config = minimal_config(("MASTER-A",))
|
||||
scenario = DeterministicScenario(config=config, enable_reporting=True)
|
||||
base = PacketSpec(call_type="unit", dst_id=5002, stream_id=0x56565656, slot=2)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base, dtype_vseq=7))
|
||||
|
||||
assert scenario.report_factory is not None
|
||||
assert any("UNIT VCSBK 1/2 DATA BLOCK" in ev for ev in scenario.report_factory.events)
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_unit_data_csbk_new_stream_is_handled() -> None:
|
||||
"""Regression: CSBK dtype 3 on new stream is accepted and reported."""
|
||||
scenario = DeterministicScenario(enable_reporting=True)
|
||||
base = PacketSpec(call_type="unit", dst_id=5003, stream_id=0x63636363, slot=2)
|
||||
|
||||
ok = scenario.inject_unit(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3),
|
||||
)
|
||||
|
||||
assert_inject_ok(ok)
|
||||
assert_report_event(scenario, "UNIT CSBK")
|
||||
assert scenario.protocols["MASTER-A"].STATUS[2].get("RX_STREAM_ID") == bytes_4(base.stream_id)
|
||||
|
||||
|
||||
def test_unit_data_csbk_ignored_when_stream_already_known() -> None:
|
||||
scenario = DeterministicScenario()
|
||||
base = PacketSpec(call_type="unit", dst_id=5004, stream_id=0x64646464, slot=2)
|
||||
stream_bytes = base.data()[16:20]
|
||||
scenario.protocols["MASTER-A"].STATUS[2]["RX_STREAM_ID"] = stream_bytes
|
||||
|
||||
ok = scenario.inject_unit(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3),
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert scenario.capture.packets == []
|
||||
@ -0,0 +1,93 @@
|
||||
"""Unit data routing: SUB_MAP, hotspot, gateway, OBP fanout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.bridge.unit_data_helpers import idle_hbp_slot
|
||||
from tests.harness.assertions import assert_forwarded, assert_not_forwarded
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
add_openbridge_system,
|
||||
minimal_config,
|
||||
parse_dmr_fields,
|
||||
patch_bridge_wall_time,
|
||||
)
|
||||
|
||||
from adn_server.domain import bytes_3
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_unit_data_sub_map_forwards_to_idle_hbp() -> None:
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
dst_sub = 7123456
|
||||
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)}
|
||||
scenario = DeterministicScenario(config=config)
|
||||
scenario.protocols["MASTER-B"].STATUS[2] = idle_hbp_slot()
|
||||
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x52525252, slot=2)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit")
|
||||
|
||||
|
||||
def test_unit_data_sub_map_skips_busy_hbp_target() -> None:
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
dst_sub = 7123456
|
||||
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)}
|
||||
scenario = DeterministicScenario(config=config)
|
||||
scenario.protocols["MASTER-B"].STATUS[2] = {
|
||||
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||
"TX_TYPE": HBPF_SLT_VTERM,
|
||||
"TX_TIME": scenario.clock.time(),
|
||||
}
|
||||
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x53535353, slot=2)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_not_forwarded(scenario, "MASTER-B")
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_unit_data_hotspot_peer_match_forwards_to_idle_hbp() -> None:
|
||||
"""Regression: hotspot peer ID match forwards unit data to idle MASTER slot."""
|
||||
config = minimal_config(("MASTER-A", "MASTER-B"))
|
||||
config["SYSTEMS"]["MASTER-B"]["PEERS"] = {
|
||||
1234567890: {"CALLSIGN": "HS1", "IP": "127.0.0.1", "PORT": 62040},
|
||||
}
|
||||
config["SYSTEMS"]["MASTER-B"]["GROUP_HANGTIME"] = 0
|
||||
scenario = DeterministicScenario(config=config)
|
||||
scenario.protocols["MASTER-B"].STATUS[2] = idle_hbp_slot()
|
||||
base = PacketSpec(call_type="unit", dst_id=123456, stream_id=0x54545454, slot=2)
|
||||
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit", dst_id=123456)
|
||||
|
||||
|
||||
def test_unit_data_forwards_to_data_gateway_when_enabled() -> None:
|
||||
config = minimal_config(("MASTER-A",))
|
||||
config["GLOBAL"]["DATA_GATEWAY"] = True
|
||||
add_openbridge_system(config, "DATA-GATEWAY")
|
||||
config["SYSTEMS"]["DATA-GATEWAY"]["ENABLED"] = True
|
||||
scenario = DeterministicScenario(config=config)
|
||||
base = PacketSpec(call_type="unit", dst_id=5005, stream_id=0x65656565, slot=2)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert len(scenario.capture.for_system("DATA-GATEWAY")) == 1
|
||||
|
||||
|
||||
def test_unit_data_fanout_to_other_obp_with_ver_gt_1() -> None:
|
||||
config = minimal_config(("MASTER-A",))
|
||||
add_openbridge_system(config, "OBP-FAN")
|
||||
scenario = DeterministicScenario(config=config)
|
||||
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=0x66666666, slot=2)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert len(scenario.capture.for_system("OBP-FAN")) == 1
|
||||
assert parse_dmr_fields(scenario.capture.for_system("OBP-FAN")[0].packet)["call_type"] == "unit"
|
||||
@ -0,0 +1,13 @@
|
||||
"""Shared helpers for bridge unit-data tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VTERM
|
||||
|
||||
|
||||
def idle_hbp_slot() -> dict:
|
||||
return {
|
||||
"RX_TYPE": HBPF_SLT_VTERM,
|
||||
"TX_TYPE": HBPF_SLT_VTERM,
|
||||
"TX_TIME": 0.0,
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
[GLOBAL]
|
||||
PATH: ./
|
||||
PING_TIME: 10
|
||||
MAX_MISSED: 3
|
||||
USE_ACL: True
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
GEN_STAT_BRIDGES: True
|
||||
ALLOW_NULL_PASSPHRASE: True
|
||||
ANNOUNCEMENT_LANGUAGES:
|
||||
SERVER_ID: 73010
|
||||
DATA_GATEWAY: False
|
||||
VALIDATE_SERVER_IDS: True
|
||||
URL_SECURITY: 143.47.40.69
|
||||
PORT_SECURITY: 7070
|
||||
PASS_SECURITY: secret
|
||||
USERS_PASS: user_passwords.json
|
||||
HASH_ENCRYPT: encryption_key.secret
|
||||
|
||||
[REPORTS]
|
||||
REPORT: True
|
||||
REPORT_INTERVAL: 60
|
||||
REPORT_PORT: 4321
|
||||
REPORT_CLIENTS: 127.0.0.1
|
||||
|
||||
[LOGGER]
|
||||
LOG_FILE: /var/log/FreeDMR/FreeDMR.log
|
||||
LOG_HANDLERS: file-timed
|
||||
LOG_LEVEL: INFO
|
||||
LOG_NAME: FreeDMR
|
||||
|
||||
[ALIASES]
|
||||
TRY_DOWNLOAD: True
|
||||
PATH: ./
|
||||
PEER_FILE: peer_ids.json
|
||||
SUBSCRIBER_FILE: subscriber_ids.json
|
||||
TGID_FILE: talkgroup_ids.json
|
||||
PEER_URL: https://adn.systems/files/peer_ids.json
|
||||
SUBSCRIBER_URL: https://adn.systems/files/subscriber_ids.json
|
||||
TGID_URL: https://adn.systems/files/talkgroup_ids.json
|
||||
LOCAL_SUBSCRIBER_FILE: local_subcriber_ids.json
|
||||
STALE_DAYS: 1
|
||||
SUB_MAP_FILE:
|
||||
SERVER_ID_URL: https://adn.systems/files/server_ids.tsv
|
||||
SERVER_ID_FILE: server_ids.tsv
|
||||
CHECKSUM_URL: https://adn.systems/files/file_checksums.json
|
||||
CHECKSUM_FILE: file_checksums.json
|
||||
|
||||
[ALLSTAR]
|
||||
ENABLED: False
|
||||
USER: llcgi
|
||||
PASS: mypass
|
||||
SERVER: my.asl.server
|
||||
PORT: 5038
|
||||
NODE: 0000
|
||||
|
||||
[SYSTEM]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_PEERS: 2
|
||||
EXPORT_AMBE: False
|
||||
IP: 127.0.0.1
|
||||
PORT: 56400
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: True
|
||||
REG_ACL: DENY:1
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
DEFAULT_UA_TIMER: 60
|
||||
SINGLE_MODE: False
|
||||
VOICE_IDENT: False
|
||||
TS1_STATIC:
|
||||
TS2_STATIC:
|
||||
DEFAULT_REFLECTOR: 0
|
||||
ANNOUNCEMENT_LANGUAGE: es_ES
|
||||
GENERATOR: 102
|
||||
ALLOW_UNREG_ID: False
|
||||
PROXY_CONTROL: False
|
||||
OVERRIDE_IDENT_TG:
|
||||
|
||||
[ECHO]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_PEERS: 1
|
||||
EXPORT_AMBE: False
|
||||
IP: 127.0.0.1
|
||||
PORT: 54917
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: True
|
||||
REG_ACL: DENY:1
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: DENY:ALL
|
||||
TGID_TS2_ACL: PERMIT:9990
|
||||
DEFAULT_UA_TIMER: 1
|
||||
SINGLE_MODE: True
|
||||
VOICE_IDENT: False
|
||||
TS1_STATIC:
|
||||
TS2_STATIC: 9990
|
||||
DEFAULT_REFLECTOR: 0
|
||||
ANNOUNCEMENT_LANGUAGE: en_GB
|
||||
GENERATOR: 0
|
||||
ALLOW_UNREG_ID: True
|
||||
PROXY_CONTROL: False
|
||||
OVERRIDE_IDENT_TG:
|
||||
|
||||
[OBP-ES]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: True
|
||||
IP: 44.31.61.66
|
||||
PORT: 62047
|
||||
NETWORK_ID: 21410
|
||||
PASSPHRASE: freedmr
|
||||
TARGET_IP: freedmr-spain.duckdns.org
|
||||
TARGET_PORT: 62047
|
||||
BOTH_SLOTS: True
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:0-1000000
|
||||
TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,730999
|
||||
TGID_TS1_ACL: DENY :0-89
|
||||
DEFAULT_UA_TIMER: 15
|
||||
RELAX_CHECKS: True
|
||||
ENHANCED_OBP: True
|
||||
PROTO_VER: 5
|
||||
|
||||
[OBP-UY]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: False
|
||||
IP: 44.31.61.66
|
||||
PORT: 62046
|
||||
NETWORK_ID: 74810
|
||||
PASSPHRASE: freedmr
|
||||
TARGET_IP: 179.27.97.125
|
||||
TARGET_PORT: 62046
|
||||
BOTH_SLOTS: True
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:0-1000000
|
||||
TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,730999
|
||||
TGID_TS1_ACL: DENY :0-89
|
||||
DEFAULT_UA_TIMER: 15
|
||||
RELAX_CHECKS: True
|
||||
ENHANCED_OBP: True
|
||||
PROTO_VER: 5
|
||||
@ -0,0 +1,23 @@
|
||||
"""Test-only packet harnesses for adn-server (in-process deterministic layer)."""
|
||||
|
||||
from tests.harness.assertions import (
|
||||
assert_all_dmr_fields,
|
||||
assert_capture_unchanged,
|
||||
assert_dmra_sent,
|
||||
assert_forwarded,
|
||||
assert_inject_ok,
|
||||
assert_not_forwarded,
|
||||
assert_report_event,
|
||||
packets_to,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"assert_all_dmr_fields",
|
||||
"assert_capture_unchanged",
|
||||
"assert_dmra_sent",
|
||||
"assert_forwarded",
|
||||
"assert_inject_ok",
|
||||
"assert_not_forwarded",
|
||||
"assert_report_event",
|
||||
"packets_to",
|
||||
]
|
||||
@ -0,0 +1,78 @@
|
||||
"""Reusable assertion helpers for deterministic harness tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from adn_server.domain import int_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tests.harness.deterministic import CapturedPacket, DeterministicScenario
|
||||
|
||||
|
||||
def packets_to(scenario: DeterministicScenario, system: str) -> list[CapturedPacket]:
|
||||
return scenario.capture.for_system(system)
|
||||
|
||||
|
||||
def assert_forwarded(
|
||||
scenario: DeterministicScenario,
|
||||
system: str,
|
||||
*,
|
||||
count: int | None = 1,
|
||||
call_type: str | None = None,
|
||||
dst_id: int | None = None,
|
||||
) -> list[CapturedPacket]:
|
||||
got = packets_to(scenario, system)
|
||||
if count is not None:
|
||||
assert len(got) == count, f"expected {count} packet(s) to {system!r}, got {len(got)}"
|
||||
for pkt in got:
|
||||
if call_type is not None:
|
||||
assert pkt.fields.get("call_type") == call_type, pkt.fields
|
||||
if dst_id is not None:
|
||||
assert int_id(pkt.fields["dst_id"]) == dst_id, pkt.fields
|
||||
return got
|
||||
|
||||
|
||||
def assert_not_forwarded(scenario: DeterministicScenario, system: str) -> None:
|
||||
got = packets_to(scenario, system)
|
||||
assert got == [], f"expected no packets to {system!r}, got {len(got)}"
|
||||
|
||||
|
||||
def assert_capture_unchanged(scenario: DeterministicScenario, system: str, before: int) -> None:
|
||||
after = len(packets_to(scenario, system))
|
||||
assert after == before, f"capture to {system!r} changed: {before} -> {after}"
|
||||
|
||||
|
||||
def assert_report_event(scenario: DeterministicScenario, *substrings: str) -> None:
|
||||
assert scenario.report_factory is not None, "reporting not enabled on scenario"
|
||||
events = scenario.report_factory.events
|
||||
for sub in substrings:
|
||||
assert any(sub in ev for ev in events), f"no report event containing {sub!r}: {events}"
|
||||
|
||||
|
||||
def assert_dmra_sent(
|
||||
scenario: DeterministicScenario,
|
||||
*,
|
||||
min_count: int = 1,
|
||||
payload_contains: bytes | None = None,
|
||||
) -> None:
|
||||
assert len(scenario.dmra_capture) >= min_count
|
||||
if payload_contains is not None:
|
||||
payloads = [p for cap in scenario.dmra_capture for p in cap.packets]
|
||||
assert any(payload_contains in p for p in payloads), payloads
|
||||
|
||||
|
||||
def assert_inject_ok(result: bool | None, *, expected: bool = True) -> None:
|
||||
if expected:
|
||||
assert result is not False, "inject returned False (dropped)"
|
||||
else:
|
||||
assert result is not True, f"inject should have dropped, got {result!r}"
|
||||
|
||||
|
||||
def assert_all_dmr_fields(packets: list[CapturedPacket], **expected: Any) -> None:
|
||||
for pkt in packets:
|
||||
for key, value in expected.items():
|
||||
actual = pkt.fields.get(key)
|
||||
if key == "dst_id" and isinstance(value, int):
|
||||
actual = int_id(actual)
|
||||
assert actual == value, f"field {key}: expected {value!r}, got {actual!r}"
|
||||
@ -0,0 +1,633 @@
|
||||
"""In-process deterministic harness for BridgeUseCases.
|
||||
|
||||
Inject at ``BridgeUseCases.dmrd_received()`` and capture outbound
|
||||
``send_to_system`` calls without UDP or Twisted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from adn_server.application.bridge_use_cases import BridgeUseCases
|
||||
from adn_server.application.reporting_use_cases import ReportingUseCases
|
||||
from adn_server.domain.dmr.bptc import encode_emblc
|
||||
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.hbp_protocol import (
|
||||
HBPF_DATA_SYNC,
|
||||
HBPF_SLT_VHEAD,
|
||||
HBPF_SLT_VTERM,
|
||||
HBPF_VOICE,
|
||||
)
|
||||
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
|
||||
from adn_server.infrastructure.hbp_constants import DMRD
|
||||
|
||||
ID_MAX = 16776415
|
||||
PEER_MAX = 4294967295
|
||||
|
||||
|
||||
def acl_permit_all(max_id: int = ID_MAX) -> tuple[bool, list[tuple[int, int]]]:
|
||||
return True, [(1, max_id)]
|
||||
|
||||
|
||||
def hbp_bits(slot: int, call_type: str, frame_type: int, dtype_vseq: int) -> int:
|
||||
bits = ((frame_type & 0x3) << 4) | (dtype_vseq & 0xF)
|
||||
if slot == 2:
|
||||
bits |= 0x80
|
||||
if call_type == "unit":
|
||||
bits |= 0x40
|
||||
return bits
|
||||
|
||||
|
||||
def parse_dmr_fields(packet: bytes) -> dict[str, Any]:
|
||||
if len(packet) < 20 or packet[:4] != DMRD:
|
||||
return {"raw": packet}
|
||||
|
||||
bits = packet[15]
|
||||
if bits & 0x40:
|
||||
call_type = "unit"
|
||||
elif (bits & 0x23) == 0x23:
|
||||
call_type = "vcsbk"
|
||||
else:
|
||||
call_type = "group"
|
||||
|
||||
return {
|
||||
"opcode": packet[:4],
|
||||
"seq": packet[4],
|
||||
"rf_src": packet[5:8],
|
||||
"dst_id": packet[8:11],
|
||||
"peer_id": packet[11:15],
|
||||
"bits": bits,
|
||||
"slot": 2 if bits & 0x80 else 1,
|
||||
"call_type": call_type,
|
||||
"frame_type": (bits & 0x30) >> 4,
|
||||
"dtype_vseq": bits & 0xF,
|
||||
"stream_id": packet[16:20],
|
||||
"dmr_payload": packet[20:53] if len(packet) >= 53 else b"",
|
||||
"ber": packet[53:54] if len(packet) >= 54 else b"",
|
||||
"rssi": packet[54:55] if len(packet) >= 55 else b"",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PacketSpec:
|
||||
peer_id: int | bytes = 1001
|
||||
rf_src: int | bytes = 3120001
|
||||
dst_id: int | bytes = 91
|
||||
slot: int = 2
|
||||
stream_id: int | bytes = 0x01020304
|
||||
seq: int = 0
|
||||
call_type: str = "group"
|
||||
frame_type: int = HBPF_VOICE
|
||||
dtype_vseq: int = 0
|
||||
payload: bytes = b"\x00" * 33
|
||||
ber: bytes = b"\x00"
|
||||
rssi: bytes = b"\x00"
|
||||
|
||||
def data(self) -> bytes:
|
||||
if len(self.payload) != 33:
|
||||
raise ValueError("DMR payload must be exactly 33 bytes")
|
||||
return b"".join(
|
||||
[
|
||||
DMRD,
|
||||
bytes([self.seq & 0xFF]),
|
||||
bytes_3(self.rf_src),
|
||||
bytes_3(self.dst_id),
|
||||
bytes_4(self.peer_id),
|
||||
bytes([hbp_bits(self.slot, self.call_type, self.frame_type, self.dtype_vseq)]),
|
||||
bytes_4(self.stream_id),
|
||||
self.payload,
|
||||
self.ber,
|
||||
self.rssi,
|
||||
]
|
||||
)
|
||||
|
||||
def decoded_hbp_args(self) -> dict[str, Any]:
|
||||
return {
|
||||
"peer_id": bytes_4(self.peer_id),
|
||||
"rf_src": bytes_3(self.rf_src),
|
||||
"dst_id": bytes_3(self.dst_id),
|
||||
"seq": self.seq & 0xFF,
|
||||
"slot": self.slot,
|
||||
"call_type": self.call_type,
|
||||
"frame_type": self.frame_type,
|
||||
"dtype_vseq": self.dtype_vseq,
|
||||
"stream_id": bytes_4(self.stream_id),
|
||||
"data": self.data(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapturedPacket:
|
||||
target_system: str
|
||||
packet: bytes
|
||||
hops: bytes | None = None
|
||||
ber: bytes = b"\x00"
|
||||
rssi: bytes = b"\x00"
|
||||
source_server: bytes = b"\x00\x00\x00\x00"
|
||||
source_rptr: bytes = b"\x00\x00\x00\x00"
|
||||
fields: dict[str, Any] = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.fields = parse_dmr_fields(self.packet)
|
||||
|
||||
|
||||
class PacketCapture:
|
||||
def __init__(self) -> None:
|
||||
self.packets: list[CapturedPacket] = []
|
||||
|
||||
def recorder(self, target_system: str):
|
||||
def record(
|
||||
packet: bytes,
|
||||
hops: bytes | None = b"",
|
||||
ber: bytes = b"\x00",
|
||||
rssi: bytes = b"\x00",
|
||||
source_server: bytes = b"\x00\x00\x00\x00",
|
||||
source_rptr: bytes = b"\x00\x00\x00\x00",
|
||||
) -> None:
|
||||
self.packets.append(
|
||||
CapturedPacket(
|
||||
target_system=target_system,
|
||||
packet=packet,
|
||||
hops=hops,
|
||||
ber=ber,
|
||||
rssi=rssi,
|
||||
source_server=source_server,
|
||||
source_rptr=source_rptr,
|
||||
)
|
||||
)
|
||||
|
||||
return record
|
||||
|
||||
def for_system(self, system: str) -> list[CapturedPacket]:
|
||||
return [p for p in self.packets if p.target_system == system]
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self, start: float = 1_700_000_000.0) -> None:
|
||||
self.now = float(start)
|
||||
|
||||
def time(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> float:
|
||||
self.now += seconds
|
||||
return self.now
|
||||
|
||||
|
||||
class FakeHbpProtocol:
|
||||
"""Minimal MASTER/PEER STATUS keyed by slot."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.STATUS: dict[int, dict[str, Any]] = {1: {}, 2: {}}
|
||||
|
||||
|
||||
class FakeReportSender:
|
||||
"""ReportSender port adapter for harness (events land in FakeReportFactory)."""
|
||||
|
||||
def __init__(self, factory: FakeReportFactory) -> None:
|
||||
self._factory = factory
|
||||
|
||||
def send_config(self, systems: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def send_bridge(self, bridges: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def send_bridge_event(self, event: str) -> None:
|
||||
self._factory.send_bridge_event(event)
|
||||
|
||||
|
||||
class FakeReportFactory:
|
||||
"""Minimal report sink so OBP VTERM sets ``_fin``."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[str] = []
|
||||
|
||||
def send_bridge_event(self, msg: str) -> None:
|
||||
self.events.append(msg)
|
||||
|
||||
|
||||
class FakeObpProtocol:
|
||||
"""Minimal OPENBRIDGE STATUS keyed by stream_id."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.STATUS: dict[bytes, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> dict[str, Any]:
|
||||
config: dict[str, Any] = {
|
||||
"GLOBAL": {
|
||||
"SERVER_ID": bytes_4(9990),
|
||||
"USE_ACL": False,
|
||||
"TG1_ACL": acl_permit_all(),
|
||||
"TG2_ACL": acl_permit_all(),
|
||||
"SUB_ACL": acl_permit_all(),
|
||||
"GEN_STAT_BRIDGES": False,
|
||||
"VALIDATE_SERVER_IDS": False,
|
||||
"TALKER_ALIAS": False,
|
||||
},
|
||||
"REPORTS": {"REPORT": False},
|
||||
"ALIASES": {"PATH": "./", "SUB_MAP_FILE": ""},
|
||||
"SYSTEMS": {},
|
||||
}
|
||||
for name in system_names:
|
||||
config["SYSTEMS"][name] = {
|
||||
"MODE": "MASTER",
|
||||
"ENABLED": True,
|
||||
"REPEAT": True,
|
||||
"MAX_PEERS": 1,
|
||||
"IP": "127.0.0.1",
|
||||
"PORT": 0,
|
||||
"PASSPHRASE": b"",
|
||||
"GROUP_HANGTIME": 0,
|
||||
"USE_ACL": False,
|
||||
"REG_ACL": acl_permit_all(PEER_MAX),
|
||||
"SUB_ACL": acl_permit_all(),
|
||||
"TG1_ACL": acl_permit_all(),
|
||||
"TG2_ACL": acl_permit_all(),
|
||||
"DEFAULT_UA_TIMER": 1,
|
||||
"SINGLE_MODE": True,
|
||||
"VOICE_IDENT": False,
|
||||
"TS1_STATIC": "",
|
||||
"TS2_STATIC": "",
|
||||
"DEFAULT_REFLECTOR": 0,
|
||||
"GENERATOR": 0,
|
||||
"ALLOW_UNREG_ID": True,
|
||||
"PEERS": {},
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def add_openbridge_system(
|
||||
config: dict[str, Any],
|
||||
name: str = "OBP-CL",
|
||||
*,
|
||||
enhanced: bool = False,
|
||||
) -> None:
|
||||
config["SYSTEMS"][name] = {
|
||||
"MODE": "OPENBRIDGE",
|
||||
"ENABLED": True,
|
||||
"NETWORK_ID": bytes_4(1),
|
||||
"IP": "127.0.0.1",
|
||||
"PORT": 0,
|
||||
"PASSPHRASE": b"test-passphrase\x00\x00\x00\x00\x00\x00",
|
||||
"TARGET_IP": "127.0.0.1",
|
||||
"TARGET_PORT": 0,
|
||||
"TARGET_SOCK": ("127.0.0.1", 0),
|
||||
"USE_ACL": False,
|
||||
"SUB_ACL": acl_permit_all(),
|
||||
"TG1_ACL": acl_permit_all(),
|
||||
"TG2_ACL": acl_permit_all(),
|
||||
"RELAX_CHECKS": True,
|
||||
"ENHANCED_OBP": enhanced,
|
||||
"VER": 5,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapturedDmra:
|
||||
target_system: str
|
||||
packets: list[bytes]
|
||||
exclude_peer: bytes | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapturedBcsq:
|
||||
system_name: str
|
||||
tgid: bytes
|
||||
stream_id: bytes
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_bridge_wall_time(clock: FakeClock):
|
||||
"""Patch ``bridge_use_cases.time.time`` to the harness clock (OBP path)."""
|
||||
import adn_server.application.bridge_use_cases as buc
|
||||
|
||||
original = buc.time.time
|
||||
buc.time.time = clock.time
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
buc.time.time = original
|
||||
|
||||
|
||||
def active_bridge(
|
||||
tg_id: int,
|
||||
entries: tuple[tuple[str, int], ...],
|
||||
timeout_minutes: int = 1,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
tg_bytes = bytes_3(tg_id)
|
||||
key = str(tg_id)
|
||||
return {
|
||||
key: [
|
||||
{
|
||||
"SYSTEM": system,
|
||||
"TS": slot,
|
||||
"TGID": tg_bytes,
|
||||
"ACTIVE": True,
|
||||
"TIMEOUT": timeout_minutes * 60,
|
||||
"TO_TYPE": "ON",
|
||||
"OFF": [],
|
||||
"ON": [tg_bytes],
|
||||
"RESET": [],
|
||||
"TIMER": 0,
|
||||
}
|
||||
for system, slot in entries
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class DeterministicScenario:
|
||||
"""Wires BridgeUseCases with fake protocols and outbound capture."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict[str, Any] | None = None,
|
||||
bridges: dict[str, list[dict[str, Any]]] | None = None,
|
||||
*,
|
||||
enable_reporting: bool = False,
|
||||
) -> None:
|
||||
self.config = copy.deepcopy(config or minimal_config())
|
||||
if enable_reporting:
|
||||
self.config.setdefault("REPORTS", {})["REPORT"] = True
|
||||
self.clock = FakeClock()
|
||||
self.capture = PacketCapture()
|
||||
self.dmra_capture: list[CapturedDmra] = []
|
||||
self.bcsq_capture: list[CapturedBcsq] = []
|
||||
self.report_factory = FakeReportFactory() if enable_reporting else None
|
||||
self.reporting = (
|
||||
ReportingUseCases(FakeReportSender(self.report_factory), self.config)
|
||||
if self.report_factory
|
||||
else None
|
||||
)
|
||||
self.router = InMemoryBridgeRouter()
|
||||
self.router.set_bridges(copy.deepcopy(bridges or {}))
|
||||
self.protocols: dict[str, FakeHbpProtocol | FakeObpProtocol] = {}
|
||||
self._wire_protocols_from_config()
|
||||
self.bridge = BridgeUseCases(
|
||||
self.router,
|
||||
self.config,
|
||||
send_to_system=self._send_capture,
|
||||
get_protocols=lambda: self.protocols,
|
||||
reporting=self.reporting,
|
||||
send_bcsq=self._send_bcsq_capture,
|
||||
send_dmra_to_system=self._send_dmra_capture,
|
||||
get_dmra_blocks=lambda _sys, _sid: None,
|
||||
encode_emblc=encode_emblc,
|
||||
ta_emblc_encoder=default_ta_emblc_encoder,
|
||||
)
|
||||
|
||||
def _wire_protocols_from_config(self) -> None:
|
||||
self.protocols.clear()
|
||||
for name, syscfg in self.config.get("SYSTEMS", {}).items():
|
||||
if syscfg.get("MODE") == "OPENBRIDGE":
|
||||
self.protocols[name] = FakeObpProtocol(name)
|
||||
else:
|
||||
self.protocols[name] = FakeHbpProtocol(name)
|
||||
|
||||
def _send_capture(self, target_system: str, packet: bytes, **kwargs: Any) -> None:
|
||||
self.capture.recorder(target_system)(
|
||||
packet,
|
||||
hops=kwargs.get("_hops", kwargs.get("hops", b"")),
|
||||
ber=kwargs.get("_ber", kwargs.get("ber", b"\x00")),
|
||||
rssi=kwargs.get("_rssi", kwargs.get("rssi", b"\x00")),
|
||||
source_server=kwargs.get("_source_server", kwargs.get("source_server", b"\x00\x00\x00\x00")),
|
||||
source_rptr=kwargs.get("_source_rptr", kwargs.get("source_rptr", b"\x00\x00\x00\x00")),
|
||||
)
|
||||
|
||||
def _send_bcsq_capture(self, system_name: str, tgid: bytes, stream_id: bytes) -> None:
|
||||
self.bcsq_capture.append(CapturedBcsq(system_name, tgid, stream_id))
|
||||
|
||||
def _send_dmra_capture(
|
||||
self,
|
||||
target_system: str,
|
||||
packets: list[bytes],
|
||||
exclude_peer: bytes | None = None,
|
||||
) -> int:
|
||||
self.dmra_capture.append(
|
||||
CapturedDmra(target_system, list(packets), exclude_peer=exclude_peer)
|
||||
)
|
||||
return 0 if exclude_peer else len(packets)
|
||||
|
||||
def seed_obp_stream(
|
||||
self,
|
||||
system_name: str,
|
||||
stream_id: int | bytes,
|
||||
*,
|
||||
tgid: int | bytes = 52090,
|
||||
rf_src: int | bytes = 3120001,
|
||||
peer_id: int | bytes = 1001,
|
||||
first_at: float | None = None,
|
||||
) -> None:
|
||||
"""Pre-populate OBP STATUS for loop-control scenarios."""
|
||||
proto = self.protocols[system_name]
|
||||
if not isinstance(proto, FakeObpProtocol):
|
||||
raise TypeError(f"{system_name} is not OPENBRIDGE")
|
||||
sid = bytes_4(stream_id)
|
||||
t0 = self.clock.time() if first_at is None else first_at
|
||||
proto.STATUS[sid] = {
|
||||
"START": t0,
|
||||
"CONTENTION": False,
|
||||
"RFS": bytes_3(rf_src),
|
||||
"TGID": bytes_3(tgid),
|
||||
"1ST": perf_counter() - 1.0,
|
||||
"lastSeq": False,
|
||||
"lastData": False,
|
||||
"RX_PEER": bytes_4(peer_id),
|
||||
"packets": 1,
|
||||
"loss": 0,
|
||||
"crcs": set(),
|
||||
"LC": b"\x00\x00\x00" + bytes_3(tgid) + bytes_3(rf_src),
|
||||
}
|
||||
|
||||
def seed_hbp_slot_stream(
|
||||
self,
|
||||
system_name: str,
|
||||
slot: int,
|
||||
stream_id: int | bytes,
|
||||
*,
|
||||
tgid: int | bytes = 91,
|
||||
rf_src: int | bytes = 3120001,
|
||||
peer_id: int | bytes = 1001,
|
||||
) -> None:
|
||||
"""Pre-populate competing HBP slot STATUS for loop-control scenarios."""
|
||||
proto = self.protocols[system_name]
|
||||
if not isinstance(proto, FakeHbpProtocol):
|
||||
raise TypeError(f"{system_name} is not HBP")
|
||||
proto.STATUS[slot] = {
|
||||
"RX_STREAM_ID": bytes_4(stream_id),
|
||||
"RX_PEER": bytes_4(peer_id),
|
||||
"RX_RFS": bytes_3(rf_src),
|
||||
"RX_TGID": bytes_3(tgid),
|
||||
"RX_TIME": self.clock.time(),
|
||||
"RX_START": self.clock.time(),
|
||||
"packets": 1,
|
||||
}
|
||||
|
||||
def _sync_hbp_slot(self, system_name: str, args: dict[str, Any]) -> None:
|
||||
"""Mirror udp_hbp STATUS[slot] updates after an accepted HBP packet."""
|
||||
proto = self.protocols.get(system_name)
|
||||
if not isinstance(proto, FakeHbpProtocol):
|
||||
return
|
||||
slot = args["slot"]
|
||||
st = proto.STATUS.setdefault(slot, {})
|
||||
st["RX_PEER"] = args["peer_id"]
|
||||
st["RX_SEQ"] = args["seq"]
|
||||
st["RX_RFS"] = args["rf_src"]
|
||||
st["RX_TYPE"] = args["dtype_vseq"]
|
||||
st["RX_TGID"] = args["dst_id"]
|
||||
st["RX_TIME"] = self.clock.time()
|
||||
st["RX_STREAM_ID"] = args["stream_id"]
|
||||
if args["frame_type"] == HBPF_DATA_SYNC and args["dtype_vseq"] == HBPF_SLT_VHEAD:
|
||||
st["RX_LC"] = bytes_3(0) + args["dst_id"] + args["rf_src"]
|
||||
|
||||
def inject_hbp(
|
||||
self,
|
||||
system_name: str,
|
||||
spec: PacketSpec,
|
||||
*,
|
||||
ingress_pkt_time: float | None = None,
|
||||
) -> bool | None:
|
||||
args = spec.decoded_hbp_args()
|
||||
pkt_time = self.clock.time() if ingress_pkt_time is None else ingress_pkt_time
|
||||
ok = self.bridge.dmrd_received(system_name, ingress_pkt_time=pkt_time, **args)
|
||||
if ok is not False:
|
||||
self._sync_hbp_slot(system_name, args)
|
||||
return ok
|
||||
|
||||
def inject_unit(
|
||||
self,
|
||||
system_name: str,
|
||||
spec: PacketSpec,
|
||||
) -> bool | None:
|
||||
"""Inject a unit (private) call packet; patches bridge wall time to harness clock."""
|
||||
args = spec.decoded_hbp_args()
|
||||
with patch_bridge_wall_time(self.clock):
|
||||
ok = self.bridge.dmrd_received(system_name, **args)
|
||||
if ok is not False:
|
||||
self._sync_hbp_slot(system_name, args)
|
||||
return ok
|
||||
|
||||
def inject_obp(
|
||||
self,
|
||||
system_name: str,
|
||||
spec: PacketSpec,
|
||||
*,
|
||||
obp_hops: bytes = b"\x00\x00\x00\x00",
|
||||
obp_source_server: bytes | None = None,
|
||||
) -> bool | None:
|
||||
args = spec.decoded_hbp_args()
|
||||
return self.bridge.dmrd_received(
|
||||
system_name,
|
||||
obp_use_parsed=True,
|
||||
obp_hops=obp_hops,
|
||||
obp_source_server=obp_source_server or bytes_4(9990),
|
||||
**args,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def voice_head_spec(base: PacketSpec) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=base.rf_src,
|
||||
dst_id=base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=0,
|
||||
call_type=base.call_type,
|
||||
frame_type=HBPF_DATA_SYNC,
|
||||
dtype_vseq=HBPF_SLT_VHEAD,
|
||||
payload=base.payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def voice_burst_spec(base: PacketSpec, seq: int, dtype_vseq: int = 0) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=base.rf_src,
|
||||
dst_id=base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=seq,
|
||||
call_type=base.call_type,
|
||||
frame_type=HBPF_VOICE,
|
||||
dtype_vseq=dtype_vseq,
|
||||
payload=base.payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def voice_term_spec(base: PacketSpec, seq: int = 99) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=base.rf_src,
|
||||
dst_id=base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=seq,
|
||||
call_type=base.call_type,
|
||||
frame_type=HBPF_DATA_SYNC,
|
||||
dtype_vseq=HBPF_SLT_VTERM,
|
||||
payload=base.payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unit_voice_head_spec(
|
||||
base: PacketSpec,
|
||||
*,
|
||||
dst_id: int | bytes | None = None,
|
||||
rf_src: int | bytes | None = None,
|
||||
) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=rf_src if rf_src is not None else base.rf_src,
|
||||
dst_id=dst_id if dst_id is not None else base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=0,
|
||||
call_type="unit",
|
||||
frame_type=HBPF_DATA_SYNC,
|
||||
dtype_vseq=HBPF_SLT_VHEAD,
|
||||
payload=base.payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unit_voice_burst_spec(base: PacketSpec, seq: int, dtype_vseq: int = 1) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=base.rf_src,
|
||||
dst_id=base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=seq,
|
||||
call_type="unit",
|
||||
frame_type=HBPF_VOICE,
|
||||
dtype_vseq=dtype_vseq,
|
||||
payload=base.payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unit_data_header_spec(
|
||||
base: PacketSpec,
|
||||
*,
|
||||
dst_id: int | bytes | None = None,
|
||||
dtype_vseq: int = 6,
|
||||
) -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=base.peer_id,
|
||||
rf_src=base.rf_src,
|
||||
dst_id=dst_id if dst_id is not None else base.dst_id,
|
||||
slot=base.slot,
|
||||
stream_id=base.stream_id,
|
||||
seq=0,
|
||||
call_type="unit",
|
||||
frame_type=HBPF_DATA_SYNC,
|
||||
dtype_vseq=dtype_vseq,
|
||||
payload=base.payload,
|
||||
)
|
||||
@ -0,0 +1,70 @@
|
||||
"""Helpers for PlaybackUseCases tests without a live reactor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.harness.deterministic import PacketSpec
|
||||
|
||||
|
||||
class FakePlaybackProtocol:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[bytes] = []
|
||||
self.STATUS: dict[int, dict[str, Any]] = {1: {}, 2: {}}
|
||||
|
||||
def send_system(self, packet: bytes) -> None:
|
||||
self.sent.append(packet)
|
||||
|
||||
|
||||
def packet_bytes(spec: PacketSpec) -> bytes:
|
||||
return spec.data()
|
||||
|
||||
|
||||
def install_reactor_capture() -> tuple[MagicMock, list[tuple[float, Any, tuple[Any, ...]]]]:
|
||||
"""Patch reactor.callLater; returns mock and scheduled (delay, fn, args) list."""
|
||||
scheduled: list[tuple[float, Any, tuple[Any, ...]]] = []
|
||||
mock_reactor = MagicMock()
|
||||
|
||||
def call_later(delay: float, fn: Any, *args: Any) -> MagicMock:
|
||||
scheduled.append((delay, fn, args))
|
||||
handle = MagicMock()
|
||||
handle.active.return_value = True
|
||||
handle.cancel = MagicMock()
|
||||
return handle
|
||||
|
||||
mock_reactor.callLater = call_later
|
||||
return mock_reactor, scheduled
|
||||
|
||||
|
||||
def run_scheduled(
|
||||
scheduled: list[tuple[float, Any, tuple[Any, ...]]],
|
||||
*,
|
||||
delay: float | None = None,
|
||||
) -> None:
|
||||
pending = list(scheduled)
|
||||
scheduled.clear()
|
||||
for item_delay, fn, args in pending:
|
||||
if delay is None or item_delay == delay:
|
||||
fn(*args)
|
||||
|
||||
|
||||
def send_playback(pb: Any, system: str, spec: PacketSpec, *, ingress_pkt_time: float | None = None) -> None:
|
||||
args = spec.decoded_hbp_args()
|
||||
kwargs: dict[str, Any] = {}
|
||||
if ingress_pkt_time is not None:
|
||||
kwargs["ingress_pkt_time"] = ingress_pkt_time
|
||||
pb.dmrd_received(
|
||||
system,
|
||||
args["peer_id"],
|
||||
args["rf_src"],
|
||||
args["dst_id"],
|
||||
args["seq"],
|
||||
args["slot"],
|
||||
args["call_type"],
|
||||
args["frame_type"],
|
||||
args["dtype_vseq"],
|
||||
args["stream_id"],
|
||||
spec.data(),
|
||||
**kwargs,
|
||||
)
|
||||
@ -0,0 +1,42 @@
|
||||
"""Shared deterministic scenario builders for domain tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from adn_server.application.talker_alias_use_cases import TalkerAliasUseCases
|
||||
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
|
||||
|
||||
from .deterministic import (
|
||||
DeterministicScenario,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
)
|
||||
|
||||
|
||||
def obp_bridge_scenario(*obp_names: str, tg: int = 52090) -> DeterministicScenario:
|
||||
entries = [(name, 1) for name in obp_names] + [("MASTER-A", 2)]
|
||||
bridges = active_bridge(tg, tuple(entries))
|
||||
config = DeterministicScenario().config
|
||||
for name in obp_names:
|
||||
add_openbridge_system(config, name)
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = str(tg)
|
||||
return DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
|
||||
def talker_alias_config() -> dict[str, Any]:
|
||||
config = DeterministicScenario().config
|
||||
config["GLOBAL"]["TALKER_ALIAS"] = True
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "inject"
|
||||
config["GLOBAL"]["TALKER_ALIAS_FORMAT"] = "{callsign} {fname}"
|
||||
rid = 3120001
|
||||
config["_SUB_PROFILES"] = {
|
||||
rid: {"callsign": "CE5RPY", "fname": "Rodrigo", "surname": "Perez"},
|
||||
}
|
||||
config["_SUB_IDS"] = {rid: "CE5RPY"}
|
||||
return config
|
||||
|
||||
|
||||
def make_talker_alias_use_cases(config: dict[str, Any]) -> TalkerAliasUseCases:
|
||||
"""Talker Alias use cases with production embedded-LC encoder (test harness)."""
|
||||
return TalkerAliasUseCases(config, ta_emblc_encoder=default_ta_emblc_encoder)
|
||||
@ -0,0 +1,156 @@
|
||||
"""Shared fakes for VoiceUseCases tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from tests.harness.deterministic import DeterministicScenario, FakeHbpProtocol, active_bridge
|
||||
|
||||
|
||||
class FakeVoiceProvider:
|
||||
def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]:
|
||||
return {languages: {"silence": b"\x00" * 7}}
|
||||
|
||||
def pkt_gen(
|
||||
self,
|
||||
rf_src: bytes,
|
||||
dst_id: bytes,
|
||||
peer: bytes,
|
||||
slot: int,
|
||||
phrase: list[Any],
|
||||
) -> Iterator[bytes]:
|
||||
del phrase
|
||||
ts_bit = 0x80 if slot else 0
|
||||
for seq in range(3):
|
||||
yield b"DMRD" + bytes([seq]) + rf_src[:3] + dst_id[:3] + peer[:4] + bytes([ts_bit | 0x10]) + bytes_4(0xA0A0A0A0 + seq) + b"\x00" * 33 + b"\x00\x00"
|
||||
|
||||
def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list:
|
||||
del audio_path, lang, file_number
|
||||
return [b"\x00" * 7]
|
||||
|
||||
def ensure_tts_ambe(self, config: dict, item: dict, audio_path: str) -> str | None:
|
||||
del config, item, audio_path
|
||||
return "/tmp/fake.ambe"
|
||||
|
||||
|
||||
class FakeMasterForVoice(FakeHbpProtocol):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name)
|
||||
self._system = name
|
||||
self.sent: list[bytes] = []
|
||||
|
||||
def send_system(self, packet: bytes) -> None:
|
||||
self.sent.append(packet)
|
||||
|
||||
|
||||
def voice_master_scenario(tg: int = 91) -> tuple[DeterministicScenario, FakeMasterForVoice]:
|
||||
config = DeterministicScenario().config
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
config["SYSTEMS"]["MASTER-A"]["PEERS"] = {
|
||||
"1001": {"CALLSIGN": "TEST", "IP": "127.0.0.1", "PORT": 62032},
|
||||
}
|
||||
bridges = active_bridge(tg, (("MASTER-A", 2),))
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
scenario.protocols["MASTER-A"] = master
|
||||
master.STATUS[2] = {
|
||||
"RX_TYPE": 2,
|
||||
"TX_TYPE": 2,
|
||||
"RX_STREAM_ID": b"\x00" * 4,
|
||||
}
|
||||
return scenario, master
|
||||
|
||||
|
||||
def reflector_bridge_entry(system: str = "MASTER-A", reflector: int = 310) -> dict[str, list[dict[str, Any]]]:
|
||||
return {
|
||||
"#{}".format(reflector): [
|
||||
{
|
||||
"SYSTEM": system,
|
||||
"TS": 2,
|
||||
"TGID": bytes_3(9),
|
||||
"ACTIVE": True,
|
||||
"TIMEOUT": 600,
|
||||
"TO_TYPE": "ON",
|
||||
"ON": [bytes_3(reflector)],
|
||||
"OFF": [],
|
||||
"RESET": [],
|
||||
"TIMER": 1000.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def make_voice_uc(
|
||||
scenario: DeterministicScenario,
|
||||
master: FakeMasterForVoice,
|
||||
*,
|
||||
audio_path: str = "/tmp/audio",
|
||||
) -> VoiceUseCases:
|
||||
scheduled: list[tuple[float, tuple]] = []
|
||||
|
||||
def call_later(delay, fn, *args):
|
||||
scheduled.append((delay, (fn, args)))
|
||||
return type("H", (), {"active": lambda self: True, "cancel": lambda self: None})()
|
||||
|
||||
uc = VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
get_bridges=scenario.bridge.get_bridges,
|
||||
call_later=call_later,
|
||||
audio_path=audio_path,
|
||||
)
|
||||
uc._scheduled = scheduled
|
||||
return uc
|
||||
|
||||
|
||||
def drain_call_later(uc: VoiceUseCases, max_rounds: int = 200) -> None:
|
||||
scheduled = getattr(uc, "_scheduled", None)
|
||||
if scheduled is None:
|
||||
return
|
||||
for _ in range(max_rounds):
|
||||
if not scheduled:
|
||||
break
|
||||
_, (fn, args) = scheduled.pop(0)
|
||||
fn(*args)
|
||||
|
||||
|
||||
def voice_announcement_config(
|
||||
scenario: DeterministicScenario,
|
||||
*,
|
||||
tg: int = 91,
|
||||
enabled: bool = True,
|
||||
file_number: str = "test-msg",
|
||||
) -> None:
|
||||
scenario.config["VOICE"] = {
|
||||
"ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": enabled,
|
||||
"TG": tg,
|
||||
"FILE": file_number,
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "interval",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def voice_tts_config(
|
||||
scenario: DeterministicScenario,
|
||||
*,
|
||||
tg: int = 91,
|
||||
enabled: bool = True,
|
||||
file_number: str = "tts-msg.ambe",
|
||||
) -> None:
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": enabled,
|
||||
"TG": tg,
|
||||
"FILE": file_number,
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "interval",
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
"""HBP vs HBP loop control (ingress packet control)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_capture_unchanged, assert_inject_ok
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
|
||||
|
||||
|
||||
def test_hbp_loop_loser_when_other_hbp_owns_stream() -> None:
|
||||
"""Second HBP source loses when another MASTER slot already has RX_STREAM_ID."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
stream_id = 0x41414141
|
||||
scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91)
|
||||
base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
|
||||
assert_inject_ok(ok, expected=False)
|
||||
assert scenario.protocols["MASTER-A"].STATUS[2].get("LOOPLOG") is True
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_hbp_loop_winner_forwards_until_loser_detected() -> None:
|
||||
"""Regression: VHEAD bridges; loop loser burst does not add forwards."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
stream_id = 0x42424242
|
||||
scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91)
|
||||
base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
forwarded_after_vhead = len(scenario.capture.for_system("MASTER-B"))
|
||||
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
assert_capture_unchanged(scenario, "MASTER-B", forwarded_after_vhead)
|
||||
assert_inject_ok(ok, expected=False)
|
||||
@ -0,0 +1,75 @@
|
||||
"""HBP ingress rate limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_capture_unchanged
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
|
||||
|
||||
import adn_server.application.bridge_use_cases as buc
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_hbp_rate_limit_drops_excessive_ingress_rate() -> None:
|
||||
"""Regression: excessive ingress rate triggers drop; post-drop forwards stall."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x55667788)
|
||||
t0 = scenario.clock.time()
|
||||
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
dropped = False
|
||||
dropped_at = 0
|
||||
for seq in range(1, 30):
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
ingress_pkt_time=t0 + seq * 0.01,
|
||||
)
|
||||
if not ok:
|
||||
dropped = True
|
||||
dropped_at = seq
|
||||
break
|
||||
assert dropped
|
||||
|
||||
forwarded_before = len(scenario.capture.for_system("MASTER-B"))
|
||||
for seq in range(dropped_at, dropped_at + 5):
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
ingress_pkt_time=t0 + seq * 0.01,
|
||||
)
|
||||
assert_capture_unchanged(scenario, "MASTER-B", forwarded_before)
|
||||
|
||||
|
||||
def test_hbp_rate_limit_uses_ingress_time_not_wall_clock() -> None:
|
||||
"""Rate control must use ingress_pkt_time even when wall clock is frozen."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x34343434)
|
||||
t0 = 1_700_000_200.0
|
||||
|
||||
original_time = buc.time.time
|
||||
buc.time.time = lambda: t0
|
||||
try:
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
accepted = 0
|
||||
for seq in range(1, 24):
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
ingress_pkt_time=t0 + seq * 0.06,
|
||||
)
|
||||
if ok is not False:
|
||||
accepted += 1
|
||||
assert accepted >= 20
|
||||
finally:
|
||||
buc.time.time = original_time
|
||||
@ -0,0 +1,86 @@
|
||||
"""HBP ingress timing and loop control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.assertions import assert_inject_ok
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
)
|
||||
|
||||
|
||||
def test_hbp_ingress_sets_rx_start_on_new_stream() -> None:
|
||||
"""New HBP group stream records RX_START from ingress_pkt_time."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x12121212)
|
||||
t0 = 1_700_000_100.0
|
||||
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
|
||||
slot_st = scenario.protocols["MASTER-A"].STATUS[2]
|
||||
assert slot_st.get("RX_START") == t0
|
||||
assert slot_st.get("RX_STREAM_ID") == base.data()[16:20]
|
||||
|
||||
|
||||
def test_hbp_rate_drop_prevents_bridge_forward() -> None:
|
||||
"""After ingress RATE DROP, no further packets are bridged."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x56565656)
|
||||
t0 = scenario.clock.time()
|
||||
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
dropped_at: int | None = None
|
||||
for seq in range(1, 30):
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
ingress_pkt_time=t0 + seq * 0.01,
|
||||
)
|
||||
if not ok:
|
||||
dropped_at = seq
|
||||
break
|
||||
assert dropped_at is not None
|
||||
|
||||
forwarded_before = len(scenario.capture.for_system("MASTER-B"))
|
||||
for seq in range(dropped_at, dropped_at + 10):
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
ingress_pkt_time=t0 + seq * 0.01,
|
||||
)
|
||||
assert len(scenario.capture.for_system("MASTER-B")) == forwarded_before
|
||||
|
||||
|
||||
def test_hbp_loop_loser_when_obp_already_has_stream() -> None:
|
||||
"""Regression: HBP loses loop when OBP already owns stream_id; no bridge forward."""
|
||||
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
|
||||
config = DeterministicScenario().config
|
||||
add_openbridge_system(config, "OBP-CL")
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
stream_id = 0x77778888
|
||||
scenario.seed_obp_stream("OBP-CL", stream_id, tgid=52090)
|
||||
|
||||
base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=2)
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
)
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
assert_inject_ok(ok, expected=False)
|
||||
@ -0,0 +1,59 @@
|
||||
"""HBP MASTER maintenance loop (peer timeout / PEERS dict parity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from adn_server.domain import bytes_4
|
||||
from adn_server.infrastructure.config_normalizer import ensure_system_runtime_config
|
||||
from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol
|
||||
|
||||
|
||||
def _echo_master_config() -> dict:
|
||||
config = {
|
||||
"GLOBAL": {"PING_TIME": 10, "MAX_MISSED": 3, "USE_ACL": False},
|
||||
"SYSTEMS": {
|
||||
"ECHO": {
|
||||
"MODE": "MASTER",
|
||||
"ENABLED": True,
|
||||
"MAX_PEERS": 1,
|
||||
"OPTIONS": "TS2=9990;",
|
||||
}
|
||||
},
|
||||
}
|
||||
ensure_system_runtime_config(config)
|
||||
return config
|
||||
|
||||
|
||||
def test_maintenance_timeout_removes_peer_and_sets_reset() -> None:
|
||||
"""Timed-out peer is removed from PEERS and triggers _reset when last peer leaves."""
|
||||
config = _echo_master_config()
|
||||
proto = HBPProtocol("ECHO", config)
|
||||
proto.transport = MagicMock()
|
||||
peer_id = bytes_4(9990)
|
||||
proto._peers[peer_id] = {
|
||||
"CONNECTION": "YES",
|
||||
"LAST_PING": 0,
|
||||
"SOCKADDR": ("127.0.0.1", 54915),
|
||||
"CALLSIGN": b"ECHO ",
|
||||
"RADIO_ID": "9990",
|
||||
}
|
||||
assert peer_id in config["SYSTEMS"]["ECHO"]["PEERS"]
|
||||
|
||||
proto._master_maintenance_loop()
|
||||
|
||||
assert peer_id not in proto._peers
|
||||
assert peer_id not in config["SYSTEMS"]["ECHO"]["PEERS"]
|
||||
assert config["SYSTEMS"]["ECHO"].get("_reset") is True
|
||||
proto.transport.write.assert_called_once()
|
||||
|
||||
|
||||
def test_peers_dict_is_shared_with_system_config() -> None:
|
||||
"""_peers must alias sys_cfg['PEERS'] so login state survives maintenance."""
|
||||
config = _echo_master_config()
|
||||
del config["SYSTEMS"]["ECHO"]["PEERS"]
|
||||
proto = HBPProtocol("ECHO", config)
|
||||
peer_id = bytes_4(9990)
|
||||
proto._peers[peer_id] = {"LAST_PING": time.time(), "SOCKADDR": ("127.0.0.1", 1)}
|
||||
assert peer_id in config["SYSTEMS"]["ECHO"]["PEERS"]
|
||||
@ -0,0 +1,88 @@
|
||||
"""HBP ingress 180s timeout and stream collision."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_forwarded, assert_inject_ok
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
|
||||
|
||||
|
||||
def test_hbp_source_timeout_drops_after_180_seconds() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x90909090)
|
||||
t0 = scenario.clock.time()
|
||||
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0,
|
||||
)
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
ingress_pkt_time=t0 + 181.0,
|
||||
)
|
||||
|
||||
assert not ok
|
||||
assert scenario.protocols["MASTER-A"].STATUS[2].get("LOOPLOG") is True
|
||||
|
||||
|
||||
def test_hbp_stream_collision_drops_conflicting_new_stream() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
t0 = scenario.clock.time()
|
||||
slot = scenario.protocols["MASTER-A"].STATUS[2]
|
||||
slot.update(
|
||||
{
|
||||
"RX_STREAM_ID": bytes_4(0x80808080),
|
||||
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||
"RX_RFS": bytes_3(1111111),
|
||||
"RX_TGID": bytes_3(91),
|
||||
"RX_TIME": t0,
|
||||
"RX_START": t0,
|
||||
}
|
||||
)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x70707070, rf_src=2222222)
|
||||
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0 + 0.1,
|
||||
)
|
||||
|
||||
assert not ok
|
||||
assert len(scenario.capture.for_system("MASTER-B")) == 0
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_hbp_collision_allows_same_subscriber_rekey() -> None:
|
||||
"""Regression: same RF source may start a new stream while prior call is still open."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
t0 = scenario.clock.time()
|
||||
rf_src = 3120001
|
||||
slot = scenario.protocols["MASTER-A"].STATUS[2]
|
||||
slot.update(
|
||||
{
|
||||
"RX_STREAM_ID": bytes_4(0x60606060),
|
||||
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||
"RX_RFS": bytes_3(rf_src),
|
||||
"RX_TGID": bytes_3(91),
|
||||
"RX_TIME": t0,
|
||||
"RX_START": t0,
|
||||
}
|
||||
)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x50505050, rf_src=rf_src)
|
||||
|
||||
ok = scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_head_spec(base),
|
||||
ingress_pkt_time=t0 + 0.1,
|
||||
)
|
||||
|
||||
assert_inject_ok(ok)
|
||||
assert_forwarded(scenario, "MASTER-B", count=1, dst_id=91)
|
||||
@ -0,0 +1,25 @@
|
||||
"""Tests for LOGGER hot-reload level application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from adn_server.infrastructure.logging_config import reapply_log_level
|
||||
|
||||
|
||||
def test_reapply_log_level_updates_root_and_named_logger() -> None:
|
||||
logging.basicConfig(level=logging.WARNING, force=True)
|
||||
app = logging.getLogger("adn-server")
|
||||
app.setLevel(logging.WARNING)
|
||||
|
||||
name = reapply_log_level({"LOG_LEVEL": "DEBUG", "LOG_NAME": "adn-server"})
|
||||
|
||||
assert name == "DEBUG"
|
||||
assert logging.getLogger().level == logging.DEBUG
|
||||
assert app.level == logging.DEBUG
|
||||
|
||||
|
||||
def test_reapply_log_level_disabled_logger_uses_critical() -> None:
|
||||
name = reapply_log_level({"ENABLED": False, "LOG_LEVEL": "DEBUG", "LOG_NAME": "adn-server"})
|
||||
assert name == "CRITICAL"
|
||||
assert logging.getLogger("adn-server").level == logging.CRITICAL
|
||||
@ -0,0 +1,120 @@
|
||||
"""OBP loop control and VTERM stream lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
FakeReportFactory,
|
||||
FakeReportSender,
|
||||
PacketSpec,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
patch_bridge_wall_time,
|
||||
)
|
||||
from tests.harness.scenarios import obp_bridge_scenario
|
||||
|
||||
from adn_server.application.reporting_use_cases import ReportingUseCases
|
||||
from adn_server.domain import bytes_4, int_id
|
||||
|
||||
|
||||
def test_obp_first_packet_bypasses_rate_control() -> None:
|
||||
"""First frame of a stream must not enter packets/START rate drop."""
|
||||
scenario = obp_bridge_scenario("OBP-CL")
|
||||
base = PacketSpec(dst_id=52090, stream_id=0xAABBCCDD, slot=1)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
ok = scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
|
||||
assert ok is not False
|
||||
obp_st = scenario.protocols["OBP-CL"].STATUS[bytes_4(base.stream_id)]
|
||||
assert obp_st.get("packets", -1) == 0
|
||||
|
||||
|
||||
def test_obp_loop_loser_drops_second_obp_source() -> None:
|
||||
"""Second OBP on the same stream_id loses loop control after its first burst."""
|
||||
scenario = obp_bridge_scenario("OBP-A", "OBP-B")
|
||||
stream_id = 0x01020304
|
||||
base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_obp(
|
||||
"OBP-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
scenario.inject_obp("OBP-B", DeterministicScenario.voice_head_spec(base))
|
||||
ok = scenario.inject_obp(
|
||||
"OBP-B",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_obp_loop_loser_sends_bcsq_once_when_enhanced() -> None:
|
||||
"""Loop loser sends BCSQ once (not on every subsequent packet)."""
|
||||
bridges = active_bridge(52090, (("OBP-A", 1), ("OBP-B", 1), ("MASTER-A", 2)))
|
||||
config = DeterministicScenario().config
|
||||
add_openbridge_system(config, "OBP-A")
|
||||
add_openbridge_system(config, "OBP-B", enhanced=True)
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
stream_id = 0x0D0E0F10
|
||||
base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_obp(
|
||||
"OBP-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
scenario.inject_obp("OBP-B", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_obp(
|
||||
"OBP-B",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
scenario.inject_obp(
|
||||
"OBP-B",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2),
|
||||
)
|
||||
|
||||
assert len(scenario.bcsq_capture) == 1
|
||||
bcsq = scenario.bcsq_capture[0]
|
||||
assert bcsq.system_name == "OBP-B"
|
||||
assert int_id(bcsq.tgid) == 52090
|
||||
assert int_id(bcsq.stream_id) == stream_id
|
||||
|
||||
|
||||
def test_obp_vterm_sets_fin_and_drops_late_packets() -> None:
|
||||
"""After VTERM, late packets from the same OBP stream are ignored (_fin)."""
|
||||
scenario = obp_bridge_scenario("OBP-CL")
|
||||
scenario.config.setdefault("REPORTS", {})["REPORT"] = True
|
||||
scenario.report_factory = FakeReportFactory()
|
||||
scenario.reporting = ReportingUseCases(FakeReportSender(scenario.report_factory), scenario.config)
|
||||
scenario.bridge._reporting = scenario.reporting
|
||||
|
||||
base = PacketSpec(dst_id=52090, stream_id=0x99887766, slot=1)
|
||||
sid = bytes_4(base.stream_id)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_obp(
|
||||
"OBP-CL",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
before = len(scenario.capture.for_system("MASTER-A"))
|
||||
scenario.inject_obp(
|
||||
"OBP-CL",
|
||||
DeterministicScenario.voice_term_spec(base, seq=99),
|
||||
)
|
||||
after_vterm = len(scenario.capture.for_system("MASTER-A"))
|
||||
assert after_vterm >= before
|
||||
|
||||
obp_st = scenario.protocols["OBP-CL"].STATUS[sid]
|
||||
assert obp_st.get("_fin") is True
|
||||
|
||||
ok = scenario.inject_obp(
|
||||
"OBP-CL",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=100, dtype_vseq=1),
|
||||
)
|
||||
assert not ok
|
||||
assert len(scenario.capture.for_system("MASTER-A")) == after_vterm
|
||||
@ -0,0 +1,33 @@
|
||||
"""OBP rate limit regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
patch_bridge_wall_time,
|
||||
)
|
||||
|
||||
|
||||
def test_obp_rate_limit_uses_start_epoch_not_elapsed() -> None:
|
||||
"""Regression: OBP must not RATE DROP at normal cadence (packets/START)."""
|
||||
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
|
||||
config = DeterministicScenario().config
|
||||
add_openbridge_system(config, "OBP-CL")
|
||||
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
base = PacketSpec(dst_id=52090, stream_id=0x11223344, slot=1)
|
||||
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
|
||||
accepted = 0
|
||||
for seq in range(1, 25):
|
||||
ok = scenario.inject_obp(
|
||||
"OBP-CL",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)),
|
||||
)
|
||||
if ok is not False:
|
||||
accepted += 1
|
||||
assert accepted >= 20
|
||||
@ -0,0 +1,42 @@
|
||||
"""OBP unit-data loop control (HBP/OBP earliest-wins)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_not_forwarded
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, patch_bridge_wall_time
|
||||
from tests.harness.scenarios import obp_bridge_scenario
|
||||
|
||||
from adn_server.domain import bytes_4
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_obp_unit_data_loop_loser_when_hbp_owns_stream() -> None:
|
||||
"""Regression: OBP unit-data loses when HBP slot already has RX_STREAM_ID."""
|
||||
scenario = obp_bridge_scenario("OBP-CL")
|
||||
stream_id = 0x71717171
|
||||
scenario.seed_hbp_slot_stream("MASTER-A", 2, stream_id, tgid=52090)
|
||||
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-CL", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_not_forwarded(scenario, "MASTER-B")
|
||||
obp_st = scenario.protocols["OBP-CL"].STATUS[bytes_4(stream_id)]
|
||||
assert obp_st.get("LOOPLOG") is True
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_obp_unit_data_loop_loser_for_second_obp_source() -> None:
|
||||
"""Regression: second OBP source loses unit-data loop to the first."""
|
||||
scenario = obp_bridge_scenario("OBP-A", "OBP-B")
|
||||
stream_id = 0x72727272
|
||||
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1)
|
||||
|
||||
with patch_bridge_wall_time(scenario.clock):
|
||||
scenario.inject_obp("OBP-A", DeterministicScenario.unit_data_header_spec(base))
|
||||
scenario.inject_obp("OBP-B", DeterministicScenario.unit_data_header_spec(base))
|
||||
|
||||
assert_not_forwarded(scenario, "MASTER-B")
|
||||
obp_b_st = scenario.protocols["OBP-B"].STATUS[bytes_4(stream_id)]
|
||||
assert obp_b_st.get("LOOPLOG") is True
|
||||
@ -0,0 +1,64 @@
|
||||
"""Parrot ingress path (PEER dmrd_received + record-to-playback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, send_playback
|
||||
|
||||
from adn_server.application.playback_use_cases import _PLAYBACK_DELAY_S, _RECORD_IDLE_S, PlaybackUseCases
|
||||
|
||||
|
||||
def test_dmrd_received_accepts_ingress_pkt_time_kwarg() -> None:
|
||||
"""Regression: PEER udp_hbp passes ingress_pkt_time (parrot must not TypeError)."""
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x12121212, slot=2)
|
||||
args = DeterministicScenario.voice_head_spec(base).decoded_hbp_args()
|
||||
|
||||
pb.dmrd_received(
|
||||
"PARROT",
|
||||
args["peer_id"],
|
||||
args["rf_src"],
|
||||
args["dst_id"],
|
||||
args["seq"],
|
||||
args["slot"],
|
||||
args["call_type"],
|
||||
args["frame_type"],
|
||||
args["dtype_vseq"],
|
||||
args["stream_id"],
|
||||
DeterministicScenario.voice_head_spec(base).data(),
|
||||
ingress_pkt_time=1_700_000_000.0,
|
||||
)
|
||||
|
||||
assert pb._recording_active is True
|
||||
assert len(pb.CALL_DATA) == 1
|
||||
|
||||
|
||||
def test_ingress_pkt_time_enables_record_to_playback(caplog) -> None:
|
||||
"""Regression: PEER path (ingress_pkt_time) records voice and schedules playback."""
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x34343434, slot=2)
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
t0 = 1_700_000_000.0
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
with patch("adn_server.application.playback_use_cases.time", return_value=t0):
|
||||
send_playback(pb, "PARROT", DeterministicScenario.voice_head_spec(base))
|
||||
send_playback(
|
||||
pb,
|
||||
"PARROT",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
ingress_pkt_time=t0 + 2.0,
|
||||
)
|
||||
with patch("adn_server.application.playback_use_cases.time", return_value=t0 + _RECORD_IDLE_S + 1):
|
||||
with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"):
|
||||
pb._on_record_idle(proto)
|
||||
|
||||
assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled)
|
||||
assert pb._playback_busy is True
|
||||
assert any("*END RECORDING*" in r.message for r in caplog.records)
|
||||
assert not any("unexpected keyword argument" in r.message for r in caplog.records)
|
||||
@ -0,0 +1,36 @@
|
||||
"""Parrot playback logging format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.harness.playback_helpers import FakePlaybackProtocol
|
||||
|
||||
from adn_server.application.playback_use_cases import PlaybackUseCases
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
|
||||
|
||||
def test_start_playback_logs_duration_with_two_decimals(caplog) -> None:
|
||||
"""PLAYBACK duration matches bridge-style %.2f (no float noise in logs)."""
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2)
|
||||
recorded = [DeterministicScenario.voice_head_spec(base).data()]
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"):
|
||||
pb._start_playback(
|
||||
proto,
|
||||
recorded,
|
||||
bytes_3(base.rf_src),
|
||||
bytes_4(base.peer_id),
|
||||
bytes_3(base.dst_id),
|
||||
2,
|
||||
10.985645771026611,
|
||||
)
|
||||
|
||||
playback_lines = [r.message for r in caplog.records if "*START PLAYBACK*" in r.message]
|
||||
assert len(playback_lines) == 1
|
||||
assert re.search(r"Duration: 10\.99\b", playback_lines[0])
|
||||
assert "10.985645771026611" not in playback_lines[0]
|
||||
@ -0,0 +1,108 @@
|
||||
"""Parrot playback send loop and max recording timer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, run_scheduled
|
||||
|
||||
from adn_server.application.playback_use_cases import (
|
||||
_PACKET_INTERVAL_S,
|
||||
_PLAYBACK_DELAY_S,
|
||||
_SOURCE_MAX_S,
|
||||
PlaybackUseCases,
|
||||
)
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
|
||||
|
||||
def test_send_next_packet_emits_all_packets_then_finishes() -> None:
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
|
||||
pb._playback_busy = True
|
||||
pb._playback_stream_id = bytes_4(0x77777777)
|
||||
pb._playback_packets = [bytes([i]) * 55 for i in range(1, 5)]
|
||||
pb._playback_index = 0
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
pb._send_next_packet(proto)
|
||||
while scheduled:
|
||||
run_scheduled(scheduled)
|
||||
|
||||
assert len(proto.sent) == 4
|
||||
assert pb._playback_busy is False
|
||||
assert pb._playback_packets == []
|
||||
assert pb._playback_index == 0
|
||||
|
||||
|
||||
def test_start_playback_sends_first_packet_and_schedules_rest() -> None:
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2)
|
||||
recorded = [
|
||||
DeterministicScenario.voice_head_spec(base).data(),
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(),
|
||||
]
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
pb._start_playback(
|
||||
proto,
|
||||
recorded,
|
||||
bytes_3(base.rf_src),
|
||||
bytes_4(base.peer_id),
|
||||
bytes_3(base.dst_id),
|
||||
2,
|
||||
1.5,
|
||||
)
|
||||
|
||||
assert len(proto.sent) == 1
|
||||
assert pb._playback_index == 1
|
||||
assert any(item[0] == _PACKET_INTERVAL_S for item in scheduled)
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
while scheduled:
|
||||
run_scheduled(scheduled)
|
||||
|
||||
assert len(proto.sent) == 2
|
||||
assert pb._playback_busy is False
|
||||
|
||||
|
||||
def test_max_duration_commits_recording_when_no_vterm() -> None:
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x99999999, slot=2)
|
||||
pb._recording_active = True
|
||||
pb.CALL_DATA = [DeterministicScenario.voice_head_spec(base).data()]
|
||||
pb._record_stream = base.data()[16:20]
|
||||
pb.STATUS["RX_START"] = 100.0
|
||||
pb._record_ctx = {
|
||||
"slot": 2,
|
||||
"rf_src": bytes_3(base.rf_src),
|
||||
"peer_id": bytes_4(base.peer_id),
|
||||
"dst_id": bytes_3(base.dst_id),
|
||||
}
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
with patch("adn_server.application.playback_use_cases.time", return_value=100.0 + _SOURCE_MAX_S):
|
||||
pb._on_record_max_duration(proto)
|
||||
|
||||
assert not pb._recording_active
|
||||
assert pb._playback_busy is True
|
||||
assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled)
|
||||
|
||||
|
||||
def test_packet_interval_matches_expected() -> None:
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
|
||||
pb._playback_busy = True
|
||||
pb._playback_stream_id = bytes_4(0xAAAAAAAA)
|
||||
pb._playback_packets = [b"\x01" * 55, b"\x02" * 55]
|
||||
pb._playback_index = 0
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
pb._send_next_packet(proto)
|
||||
|
||||
assert scheduled[0][0] == _PACKET_INTERVAL_S
|
||||
@ -0,0 +1,52 @@
|
||||
"""Parrot recording idle/max timers and synthetic VTERM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.harness.playback_helpers import (
|
||||
FakePlaybackProtocol,
|
||||
install_reactor_capture,
|
||||
run_scheduled,
|
||||
send_playback,
|
||||
)
|
||||
|
||||
from adn_server.application.playback_use_cases import _RECORD_IDLE_S, PlaybackUseCases
|
||||
|
||||
|
||||
def test_idle_timeout_appends_synthetic_vterm_and_schedules_playback() -> None:
|
||||
proto = FakePlaybackProtocol()
|
||||
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x55555555, slot=2)
|
||||
mock_reactor, scheduled = install_reactor_capture()
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
|
||||
with patch("adn_server.application.playback_use_cases.time", return_value=200.0):
|
||||
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base))
|
||||
send_playback(
|
||||
pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
|
||||
idle_calls = [item for item in scheduled if item[0] == _RECORD_IDLE_S]
|
||||
assert idle_calls
|
||||
|
||||
with patch("adn_server.application.playback_use_cases.time", return_value=200.0 + _RECORD_IDLE_S):
|
||||
run_scheduled(scheduled, delay=_RECORD_IDLE_S)
|
||||
|
||||
assert not pb._recording_active
|
||||
assert pb._playback_busy is True
|
||||
assert any(item[0] == 2.0 for item in scheduled)
|
||||
|
||||
|
||||
def test_vterm_commit_does_not_require_synthetic_vterm() -> None:
|
||||
pb = PlaybackUseCases("ECHO")
|
||||
base = PacketSpec(dst_id=9990, stream_id=0x66666666, slot=2)
|
||||
recorded = [
|
||||
DeterministicScenario.voice_head_spec(base).data(),
|
||||
DeterministicScenario.voice_term_spec(base, seq=2).data(),
|
||||
]
|
||||
|
||||
with_vterm = pb._ensure_vterm(recorded, slot=2)
|
||||
assert len(with_vterm) == len(recorded)
|
||||
assert pb._packet_is_vterm(with_vterm[-1])
|
||||
@ -0,0 +1,174 @@
|
||||
"""Tests for scripts/freedmr_cfg_to_yaml.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT / "scripts"))
|
||||
|
||||
from freedmr_cfg_to_yaml import dump_yaml, parse_freedmr_cfg # noqa: E402
|
||||
|
||||
|
||||
def _write_cfg(tmp_path: Path, body: str) -> Path:
|
||||
path = tmp_path / "test.cfg"
|
||||
path.write_text(textwrap.dedent(body).strip() + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_converts_global_echo_and_obp(tmp_path: Path) -> None:
|
||||
cfg = _write_cfg(
|
||||
tmp_path,
|
||||
"""
|
||||
[GLOBAL]
|
||||
SERVER_ID: 73010
|
||||
USE_ACL: True
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
[REPORTS]
|
||||
REPORT: True
|
||||
REPORT_CLIENTS: 127.0.0.1
|
||||
|
||||
[LOGGER]
|
||||
LOG_FILE: /var/log/FreeDMR/FreeDMR.log
|
||||
LOG_NAME: FreeDMR
|
||||
|
||||
[ALIASES]
|
||||
TRY_DOWNLOAD: True
|
||||
STALE_DAYS: 1
|
||||
|
||||
[ECHO]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
PORT: 54917
|
||||
TS2_STATIC: 9990
|
||||
TGID_TS2_ACL: PERMIT:9990
|
||||
GENERATOR: 0
|
||||
|
||||
[OBP-CR]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: False
|
||||
PORT: 62052
|
||||
NETWORK_ID: 71210
|
||||
PASSPHRASE: passw0rd
|
||||
TARGET_IP: freedmrcr.net
|
||||
TARGET_PORT: 62026
|
||||
TGID_ACL: DENY :0-82,9990-9999
|
||||
TGID_TS1_ACL: DENY :0-89
|
||||
PROTO_VER: 5
|
||||
""",
|
||||
)
|
||||
out = parse_freedmr_cfg(cfg)
|
||||
|
||||
assert out["GLOBAL"]["SERVER_ID"] == 73010
|
||||
assert out["GLOBAL"]["TALKER_ALIAS"] is False
|
||||
assert out["REPORTS"]["REPORT_CLIENTS"] == "127.0.0.1"
|
||||
assert out["LOGGER"]["LOG_FILE"] == "/var/log/adn-server/adn-server.log"
|
||||
assert out["LOGGER"]["LOG_NAME"] == "adn-server"
|
||||
assert out["ALIASES"]["KEYS_FILE"] == "keys.json"
|
||||
|
||||
echo = out["SYSTEMS"]["ECHO"]
|
||||
assert echo["MODE"] == "MASTER"
|
||||
assert echo["TS2_STATIC"] == "9990"
|
||||
assert echo["TGID_TS2_ACL"] == "PERMIT:9990"
|
||||
|
||||
obp = out["SYSTEMS"]["OBP-CR"]
|
||||
assert obp["ENABLED"] is False
|
||||
assert obp["TGID_ACL"] == "DENY:0-82,9990-9999"
|
||||
assert obp["TGID_TS1_ACL"] == "DENY:0-89"
|
||||
assert obp["PROTO_VER"] == 5
|
||||
|
||||
|
||||
def test_includes_disabled_systems(tmp_path: Path) -> None:
|
||||
cfg = _write_cfg(
|
||||
tmp_path,
|
||||
"""
|
||||
[OBP-OFF]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: False
|
||||
PORT: 62099
|
||||
NETWORK_ID: 1
|
||||
PASSPHRASE: x
|
||||
TARGET_IP: 1.2.3.4
|
||||
TARGET_PORT: 62099
|
||||
""",
|
||||
)
|
||||
out = parse_freedmr_cfg(cfg)
|
||||
assert "OBP-OFF" in out["SYSTEMS"]
|
||||
assert out["SYSTEMS"]["OBP-OFF"]["ENABLED"] is False
|
||||
|
||||
|
||||
def test_preserves_section_and_key_order(tmp_path: Path) -> None:
|
||||
cfg = _write_cfg(
|
||||
tmp_path,
|
||||
"""
|
||||
[GLOBAL]
|
||||
PATH: ./
|
||||
PING_TIME: 10
|
||||
SERVER_ID: 73010
|
||||
|
||||
[REPORTS]
|
||||
REPORT: True
|
||||
|
||||
[ALLSTAR]
|
||||
ENABLED: False
|
||||
|
||||
[ECHO]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
PORT: 54917
|
||||
TS2_STATIC: 9990
|
||||
|
||||
[OBP-CR]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: False
|
||||
PORT: 62052
|
||||
NETWORK_ID: 71210
|
||||
PASSPHRASE: passw0rd
|
||||
TARGET_IP: freedmrcr.net
|
||||
TARGET_PORT: 62026
|
||||
PROTO_VER: 5
|
||||
""",
|
||||
)
|
||||
out = parse_freedmr_cfg(cfg)
|
||||
|
||||
assert list(out.keys()) == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"]
|
||||
assert list(out["GLOBAL"].keys())[:3] == ["PATH", "PING_TIME", "SERVER_ID"]
|
||||
assert list(out["SYSTEMS"].keys()) == ["ECHO", "OBP-CR"]
|
||||
assert list(out["SYSTEMS"]["ECHO"].keys())[:4] == ["MODE", "ENABLED", "PORT", "TS2_STATIC"]
|
||||
|
||||
text = dump_yaml(out)
|
||||
yaml_keys = [line.rstrip(":") for line in text.splitlines() if line.endswith(":") and not line.startswith("#")]
|
||||
assert yaml_keys[:4] == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"]
|
||||
|
||||
|
||||
def test_fixture_matches_cfg_section_order() -> None:
|
||||
fixture = Path(__file__).resolve().parents[1] / "fixtures" / "sample_freedmr.cfg"
|
||||
out = parse_freedmr_cfg(fixture)
|
||||
expected_top = ["GLOBAL", "REPORTS", "LOGGER", "ALIASES", "ALLSTAR", "SYSTEMS"]
|
||||
assert list(out.keys()) == expected_top
|
||||
assert list(out["SYSTEMS"].keys()) == ["SYSTEM", "ECHO", "OBP-ES", "OBP-UY"]
|
||||
assert list(out["GLOBAL"].keys())[:5] == ["PATH", "PING_TIME", "MAX_MISSED", "USE_ACL", "REG_ACL"]
|
||||
|
||||
|
||||
def test_dump_yaml_is_parseable(tmp_path: Path) -> None:
|
||||
cfg = _write_cfg(
|
||||
tmp_path,
|
||||
"""
|
||||
[GLOBAL]
|
||||
SERVER_ID: 1
|
||||
[SYSTEM]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
PORT: 56400
|
||||
PASSPHRASE: secret
|
||||
GENERATOR: 1
|
||||
""",
|
||||
)
|
||||
text = dump_yaml(parse_freedmr_cfg(cfg))
|
||||
loaded = yaml.safe_load(text.split("\n", 2)[2])
|
||||
assert loaded["SYSTEMS"]["SYSTEM"]["PORT"] == 56400
|
||||
@ -0,0 +1,24 @@
|
||||
"""Smoke tests for basic HBP bridge forwarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from tests.harness.assertions import assert_forwarded
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_static_tg_routes_hbp_to_peer_master() -> None:
|
||||
"""Regression: active bridge forwards HBP group voice to peer MASTER."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, stream_id=0x0A0B0C0D)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
for seq in range(1, 4):
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=seq),
|
||||
)
|
||||
|
||||
assert_forwarded(scenario, "MASTER-B", count=4, call_type="group", dst_id=91)
|
||||
@ -0,0 +1,20 @@
|
||||
"""Smoke tests for PacketSpec and DMR header parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import PacketSpec, parse_dmr_fields
|
||||
|
||||
from adn_server.domain import int_id
|
||||
|
||||
|
||||
def test_packet_spec_builds_valid_dmr_header() -> None:
|
||||
spec = PacketSpec(dst_id=91, rf_src=3120001, peer_id=1001, seq=7)
|
||||
packet = spec.data()
|
||||
fields = parse_dmr_fields(packet)
|
||||
|
||||
assert fields["opcode"] == b"DMRD"
|
||||
assert fields["seq"] == 7
|
||||
assert int_id(fields["dst_id"]) == 91
|
||||
assert int_id(fields["rf_src"]) == 3120001
|
||||
assert fields["slot"] == 2
|
||||
assert fields["call_type"] == "group"
|
||||
@ -0,0 +1,109 @@
|
||||
"""Talker Alias bridge/repeat injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
active_bridge,
|
||||
add_openbridge_system,
|
||||
)
|
||||
from tests.harness.scenarios import talker_alias_config
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.talker_alias import DMRA_BLOCK_COUNT, DMRA_OPCODE
|
||||
|
||||
|
||||
def test_talker_alias_inject_dmra_on_bridge_vhead() -> None:
|
||||
"""Bridge forward sends DMRA blocks once per target stream on VHEAD."""
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x90909090)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
|
||||
assert len(scenario.dmra_capture) == 1
|
||||
dmra = scenario.dmra_capture[0]
|
||||
assert dmra.target_system == "MASTER-B"
|
||||
assert 1 <= len(dmra.packets) <= DMRA_BLOCK_COUNT
|
||||
assert all(p[:4] == DMRA_OPCODE for p in dmra.packets)
|
||||
payload = b"".join(p[8:15] for p in dmra.packets)
|
||||
assert b"CE5RPY" in payload
|
||||
|
||||
|
||||
def test_talker_alias_vhead_sent_once_per_target_stream() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x91919191)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
|
||||
assert len(scenario.dmra_capture) == 1
|
||||
|
||||
|
||||
def test_talker_alias_both_obp_source_injects_immediately() -> None:
|
||||
"""both + OBP source (can never carry TA): inject template at VHEAD without waiting."""
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
add_openbridge_system(config, "OBP-CL")
|
||||
bridges = active_bridge(52090, (("OBP-CL", 2), ("MASTER-A", 2)))
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
base = PacketSpec(dst_id=52090, rf_src=3120001, stream_id=0x93939393, slot=2)
|
||||
|
||||
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
|
||||
|
||||
assert len(scenario.dmra_capture) == 1
|
||||
dmra = scenario.dmra_capture[0]
|
||||
assert dmra.target_system == "MASTER-A"
|
||||
payload = b"".join(p[8:15] for p in dmra.packets)
|
||||
assert b"CE5RPY" in payload
|
||||
|
||||
|
||||
def test_both_mode_without_ta_still_rewrites_group_embedded_lc() -> None:
|
||||
"""Regression: with no injected TA (TX_TA_EMB None), bursts must still carry the
|
||||
destination group LC (legacy bridge.py parity).
|
||||
|
||||
The earlier 'preserve source embedded LC' shortcut left a stale LC on the wire, which
|
||||
the receiving MMDVM rejected on TG-rewriting legs -> 'watchdog expired, packet loss'.
|
||||
"""
|
||||
from bitarray import bitarray
|
||||
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=config, bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x95959595, slot=2)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1)
|
||||
)
|
||||
|
||||
ts_st = scenario.protocols["MASTER-B"].STATUS[2]
|
||||
assert ts_st.get("TX_TA_EMB") is None # no source TA buffered, no inject
|
||||
bursts = [p for p in scenario.capture.for_system("MASTER-B") if p.fields["dtype_vseq"] == 1]
|
||||
assert bursts, "voice burst B should be forwarded to MASTER-B"
|
||||
bits = bitarray(endian="big")
|
||||
bits.frombytes(bursts[-1].fields["dmr_payload"])
|
||||
emb = bits[116:148]
|
||||
assert emb == ts_st["TX_EMB_LC"][1] # rewritten to destination group LC...
|
||||
assert emb.any() # ...not the preserved all-zero source embedded LC
|
||||
|
||||
|
||||
def test_talker_alias_local_repeat_excludes_source_peer() -> None:
|
||||
scenario = DeterministicScenario(
|
||||
config=talker_alias_config(),
|
||||
bridges=active_bridge(91, (("MASTER-A", 2),)),
|
||||
)
|
||||
stream_id = bytes_4(0x92929292)
|
||||
peer = bytes_4(1001)
|
||||
rf_src = bytes_3(3120001)
|
||||
|
||||
scenario.bridge.send_talker_alias_local_repeat("MASTER-A", peer, rf_src, stream_id)
|
||||
|
||||
assert len(scenario.dmra_capture) == 1
|
||||
assert scenario.dmra_capture[0].exclude_peer == peer
|
||||
@ -0,0 +1,97 @@
|
||||
"""Talker Alias embedded LC (encode_emblc) paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
|
||||
from tests.talker_alias.test_passthrough import _complete_blocks, _passthrough_config
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
|
||||
|
||||
def test_embedded_emblc_inject_mode_returns_blocks() -> None:
|
||||
ta = make_talker_alias_use_cases(talker_alias_config())
|
||||
stream_id = bytes_4(0xD1D1D1D1)
|
||||
|
||||
result = ta.embedded_emblc_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
stream_id,
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
emblcs, count = result
|
||||
assert 1 <= count <= 4
|
||||
assert len(emblcs) == count
|
||||
|
||||
|
||||
def test_embedded_emblc_disabled_returns_none() -> None:
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS"] = False
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
|
||||
assert (
|
||||
ta.embedded_emblc_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xD2D2D2D2),
|
||||
lambda _s, _st: None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_embedded_emblc_passthrough_reencodes_source_ta() -> None:
|
||||
"""passthrough overlays the source TA (re-encoded) once its blocks are complete."""
|
||||
config = _passthrough_config()
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
blocks = _complete_blocks("CE5RPY embed")
|
||||
|
||||
# No source TA yet -> only the destination group LC is sent (None overlay).
|
||||
assert (
|
||||
ta.embedded_emblc_for_stream(
|
||||
"MASTER-A", bytes_3(3120001), bytes_4(0xD3D3D3D3), lambda _s, _st: None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# Source TA complete -> re-encode it as the overlay.
|
||||
result = ta.embedded_emblc_for_stream(
|
||||
"MASTER-A", bytes_3(3120001), bytes_4(0xD3D3D3D4), lambda _s, _st: blocks,
|
||||
)
|
||||
assert result is not None
|
||||
emblcs, count = result
|
||||
assert count >= 1
|
||||
assert len(emblcs) == count
|
||||
|
||||
|
||||
def test_embedded_emblc_both_source_ta_then_fallback() -> None:
|
||||
"""both overlays the source TA when present, and the template when none arrives."""
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
blocks = _complete_blocks("Radio TA")
|
||||
|
||||
# Source TA present -> overlay the source TA (re-encoded).
|
||||
source = ta.embedded_emblc_for_stream(
|
||||
"MASTER-A", bytes_3(9999999), bytes_4(0xD4D4D4D4), lambda _s, _st: blocks,
|
||||
)
|
||||
assert source is not None
|
||||
# No source TA yet, no fallback -> None (group LC only).
|
||||
assert (
|
||||
ta.embedded_emblc_for_stream(
|
||||
"MASTER-A", bytes_3(9999999), bytes_4(0xD4D4D4D5), lambda _s, _st: None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# No source TA, fallback -> inject the template embedded LC.
|
||||
fallback = ta.embedded_emblc_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(9999999),
|
||||
bytes_4(0xD5D5D5D5),
|
||||
lambda _s, _st: None,
|
||||
fallback_inject=True,
|
||||
)
|
||||
assert fallback is not None
|
||||
emblcs, count = fallback
|
||||
assert count >= 1
|
||||
assert len(emblcs) == count
|
||||
@ -0,0 +1,59 @@
|
||||
"""Talker Alias domain encode/decode roundtrips."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adn_server.domain.talker_alias import (
|
||||
buffer_from_blocks,
|
||||
decode_7bit,
|
||||
decode_ta,
|
||||
encode_7bit,
|
||||
encode_iso8,
|
||||
encode_utf8,
|
||||
parse_ta_text_formats,
|
||||
required_ta_block_count,
|
||||
truncate_talker_alias,
|
||||
)
|
||||
from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc
|
||||
|
||||
|
||||
def test_truncate_talker_alias_caps_at_29() -> None:
|
||||
long_text = "A" * 40
|
||||
assert len(truncate_talker_alias(long_text)) == 29
|
||||
|
||||
|
||||
def test_encode_utf8_roundtrip() -> None:
|
||||
text = "CE5RPY Rodrigo"
|
||||
assert decode_7bit(encode_utf8(text)) == text
|
||||
|
||||
|
||||
def test_encode_7bit_roundtrip() -> None:
|
||||
text = "CE5RPY"
|
||||
assert decode_7bit(encode_7bit(text)) == text
|
||||
|
||||
|
||||
def test_required_ta_block_count_skips_trailing_zeros() -> None:
|
||||
buf = encode_utf8("Hi")
|
||||
assert 1 <= required_ta_block_count(buf) <= 4
|
||||
|
||||
|
||||
def test_buffer_from_blocks_merges_payloads() -> None:
|
||||
blocks = {0: b"\x01" * 7, 1: b"\x02" * 7}
|
||||
buf = buffer_from_blocks(blocks)
|
||||
assert buf[:7] == b"\x01" * 7
|
||||
assert buf[7:14] == b"\x02" * 7
|
||||
|
||||
|
||||
def test_encode_iso8_roundtrip() -> None:
|
||||
text = "CE5RPY Niño"
|
||||
assert decode_ta(encode_iso8(text)) == text
|
||||
|
||||
|
||||
def test_parse_ta_text_formats_comma_list() -> None:
|
||||
assert parse_ta_text_formats("utf8, iso8") == ["utf8", "iso8"]
|
||||
|
||||
|
||||
def test_multi_format_emblc_concatenates() -> None:
|
||||
single, _ = encode_talker_alias_emblc("Hi", ("utf8",))
|
||||
dual, n = encode_talker_alias_emblc("Hi", ("utf8", "iso8"))
|
||||
assert n == len(dual)
|
||||
assert len(dual) > len(single)
|
||||
@ -0,0 +1,29 @@
|
||||
"""Talker Alias formatting and stream state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import PacketSpec
|
||||
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
|
||||
|
||||
from adn_server.application.talker_alias_use_cases import format_talker_alias_text
|
||||
from adn_server.domain import bytes_3
|
||||
|
||||
|
||||
def test_format_talker_alias_from_subscriber_profile() -> None:
|
||||
config = talker_alias_config()
|
||||
text = format_talker_alias_text(config, bytes_3(3120001))
|
||||
assert text == "CE5RPY Rodrigo"
|
||||
|
||||
|
||||
def test_talker_alias_clear_stream_allows_resend() -> None:
|
||||
ta = make_talker_alias_use_cases(talker_alias_config())
|
||||
stream_id = PacketSpec(stream_id=0x93939393).data()[16:20]
|
||||
key = ("MASTER-B", stream_id)
|
||||
|
||||
assert ta.should_send_on_vhead("MASTER-B", stream_id) is True
|
||||
ta.mark_dmra_sent("MASTER-B", stream_id)
|
||||
assert ta.should_send_on_vhead("MASTER-B", stream_id) is False
|
||||
|
||||
ta.clear_stream("MASTER-B", stream_id)
|
||||
assert key not in ta._sent_streams
|
||||
assert ta.should_send_on_vhead("MASTER-B", stream_id) is True
|
||||
@ -0,0 +1,181 @@
|
||||
"""Talker Alias passthrough and both modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
|
||||
|
||||
from adn_server.application.talker_alias_use_cases import (
|
||||
passthrough_complete,
|
||||
passthrough_packets_from_blocks,
|
||||
)
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.domain.talker_alias import (
|
||||
DMRA_BLOCK_COUNT,
|
||||
DMRA_OPCODE,
|
||||
blocks_from_buffer,
|
||||
buffer_from_blocks,
|
||||
decode_7bit,
|
||||
encode_utf8,
|
||||
)
|
||||
|
||||
|
||||
def _complete_blocks(text: str) -> dict[int, bytes]:
|
||||
payloads = blocks_from_buffer(encode_utf8(text))
|
||||
return {i: payloads[i] for i in range(DMRA_BLOCK_COUNT)}
|
||||
|
||||
|
||||
def _passthrough_config() -> dict:
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "passthrough"
|
||||
return config
|
||||
|
||||
|
||||
def test_passthrough_complete_full_adn_blocks() -> None:
|
||||
blocks = _complete_blocks("CE5RPY Test")
|
||||
assert passthrough_complete(blocks) is True
|
||||
|
||||
|
||||
def test_passthrough_complete_partial_adn_blocks_fails() -> None:
|
||||
blocks = _complete_blocks("CE5RPY Test")
|
||||
assert passthrough_complete({0: blocks[0]}) is False
|
||||
|
||||
|
||||
def test_passthrough_packets_rebuild_dmra_opcode() -> None:
|
||||
text = "CE5RPY Test"
|
||||
blocks = _complete_blocks(text)
|
||||
rf_src = bytes_3(3120001)
|
||||
packets = passthrough_packets_from_blocks(rf_src, blocks)
|
||||
|
||||
assert 1 <= len(packets) <= DMRA_BLOCK_COUNT
|
||||
assert all(p[:4] == DMRA_OPCODE for p in packets)
|
||||
assert all(p[4:7] == rf_src for p in packets)
|
||||
rebuilt = {i: packets[i][8:15] for i in range(len(packets))}
|
||||
assert decode_7bit(buffer_from_blocks(rebuilt)) == text
|
||||
|
||||
|
||||
def test_packets_for_stream_passthrough_mode() -> None:
|
||||
config = _passthrough_config()
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
stream_id = bytes_4(0xA1A1A1A1)
|
||||
blocks = _complete_blocks("CE5RPY Rodrigo")
|
||||
|
||||
def get_blocks(_system: str, _stream: bytes) -> dict[int, bytes]:
|
||||
return blocks
|
||||
|
||||
packets = ta.packets_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
stream_id,
|
||||
get_blocks,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
|
||||
assert packets is not None
|
||||
assert 1 <= len(packets) <= DMRA_BLOCK_COUNT
|
||||
assert all(p[:4] == DMRA_OPCODE for p in packets)
|
||||
|
||||
|
||||
def test_packets_for_stream_passthrough_without_blocks_returns_none() -> None:
|
||||
config = _passthrough_config()
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
|
||||
assert (
|
||||
ta.packets_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xB2B2B2B2),
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_packets_for_stream_both_prefers_passthrough() -> None:
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
passthrough_text = "From radio"
|
||||
blocks = _complete_blocks(passthrough_text)
|
||||
|
||||
packets = ta.packets_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(9999999),
|
||||
bytes_4(0xC3C3C3C3),
|
||||
lambda _s, _st: blocks,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
|
||||
assert packets is not None
|
||||
expected = passthrough_packets_from_blocks(bytes_3(9999999), blocks)
|
||||
assert packets == expected
|
||||
|
||||
|
||||
def test_packets_for_stream_both_without_blocks_preserves_source() -> None:
|
||||
"""both + no DMRA buffer (still waiting): do not inject; source embedded TA may arrive."""
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
|
||||
assert (
|
||||
ta.packets_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xD4D4D4D4),
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_packets_for_stream_both_fallback_inject_when_no_source_ta() -> None:
|
||||
"""both + window elapsed with no source TA: fall back to injecting the template."""
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
|
||||
packets = ta.packets_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xE5E5E5E5),
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
fallback_inject=True,
|
||||
)
|
||||
|
||||
assert packets is not None
|
||||
assert 1 <= len(packets) <= DMRA_BLOCK_COUNT
|
||||
assert all(p[:4] == DMRA_OPCODE for p in packets)
|
||||
assert all(p[4:7] == bytes_3(3120001) for p in packets)
|
||||
|
||||
|
||||
def test_embedded_emblc_both_fallback_inject_returns_template() -> None:
|
||||
"""both fallback inject also rewrites the embedded LC with the template."""
|
||||
config = talker_alias_config()
|
||||
config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both"
|
||||
ta = make_talker_alias_use_cases(config)
|
||||
|
||||
# Without fallback the embedded LC is preserved (None); with fallback it injects.
|
||||
assert (
|
||||
ta.embedded_emblc_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xE6E6E6E6),
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
)
|
||||
is None
|
||||
)
|
||||
emblcs = ta.embedded_emblc_for_stream(
|
||||
"MASTER-A",
|
||||
bytes_3(3120001),
|
||||
bytes_4(0xE7E7E7E7),
|
||||
lambda _s, _st: None,
|
||||
target_system="MASTER-B",
|
||||
fallback_inject=True,
|
||||
)
|
||||
assert emblcs is not None
|
||||
blocks, count = emblcs
|
||||
assert 1 <= count <= DMRA_BLOCK_COUNT
|
||||
assert len(blocks) == count
|
||||
@ -0,0 +1,50 @@
|
||||
"""Voice announcement anti-collision."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario
|
||||
|
||||
from adn_server.domain import HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3
|
||||
|
||||
|
||||
def test_build_targets_skips_busy_qso_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST")
|
||||
|
||||
assert targets == []
|
||||
assert busy == 1
|
||||
|
||||
|
||||
def test_build_targets_includes_idle_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST")
|
||||
|
||||
assert busy == 0
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["name"] == "MASTER-A"
|
||||
assert targets[0]["ts"] == 2
|
||||
|
||||
|
||||
def test_broadcast_aborts_when_qso_starts_mid_transmission() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(5000)
|
||||
dst = bytes_3(91)
|
||||
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc._announcement_send_broadcast(targets, pkts, 0, source, dst, 91, 0, "ANN-TEST", None)
|
||||
|
||||
assert uc._announcement_running[0] is False
|
||||
assert master.sent == []
|
||||
@ -0,0 +1,53 @@
|
||||
"""Voice broadcast queue (same-TG serialization)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario
|
||||
|
||||
from adn_server.domain import bytes_3
|
||||
|
||||
|
||||
def test_enqueue_broadcast_queues_second_same_tg() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(5000)
|
||||
dst = bytes_3(91)
|
||||
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
uc._enqueue_broadcast("ann", targets, pkts, source, dst, 91, 0, "ANN-2")
|
||||
|
||||
assert len(uc._broadcast_queue) == 1
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_broadcast_queue_drains_after_first_finishes() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(5000)
|
||||
dst = bytes_3(91)
|
||||
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
uc._broadcast_queue.append(
|
||||
{
|
||||
"type": "ann",
|
||||
"targets": targets,
|
||||
"pkts_by_ts": pkts,
|
||||
"source_id": source,
|
||||
"dst_id": dst,
|
||||
"tg": 91,
|
||||
"num": 1,
|
||||
"label": "ANN-2",
|
||||
}
|
||||
)
|
||||
uc._broadcast_finished(91)
|
||||
assert len(uc._scheduled) == 1
|
||||
uc._scheduled.pop(0)[1][0]()
|
||||
|
||||
assert "91" in uc._broadcast_active_tgs
|
||||
assert uc._broadcast_queue == []
|
||||
assert len(uc._scheduled) == 1
|
||||
assert uc._scheduled[0][0] == 0.5
|
||||
@ -0,0 +1,108 @@
|
||||
"""Disconnected / linked-to-reflector voice prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from tests.harness.voice_helpers import FakeMasterForVoice, FakeVoiceProvider, voice_master_scenario
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
|
||||
|
||||
class _VoiceMaster(FakeMasterForVoice):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name)
|
||||
self.voice_packets: list[bytes] = []
|
||||
|
||||
def send_voice_packet(self, packet: bytes, _source_id: bytes, _dst_id: bytes, _slot: dict) -> None:
|
||||
self.voice_packets.append(packet)
|
||||
|
||||
|
||||
class _WordsProvider(FakeVoiceProvider):
|
||||
def get_ambe_words(self, languages: str, audio_path: str) -> dict:
|
||||
del audio_path
|
||||
silence = b"\x00" * 7
|
||||
return {
|
||||
languages: {
|
||||
"silence": silence,
|
||||
"notlinked": silence,
|
||||
"linkedto": silence,
|
||||
"to": silence,
|
||||
**{str(d): silence for d in range(10)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _disconnected_uc(master: _VoiceMaster, scenario, provider: FakeVoiceProvider | None = None) -> VoiceUseCases:
|
||||
def call_from_reactor(fn, *args):
|
||||
fn(*args)
|
||||
|
||||
return VoiceUseCases(
|
||||
provider or _WordsProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
call_from_reactor=call_from_reactor,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_disconnected_voice_not_linked_prompt() -> None:
|
||||
"""Regression: DEFAULT_REFLECTOR=0 sends not-linked prompt packets."""
|
||||
scenario, _ = voice_master_scenario()
|
||||
master = _VoiceMaster("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2}
|
||||
scenario.config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 0
|
||||
uc = _disconnected_uc(master, scenario)
|
||||
|
||||
with patch("adn_server.application.voice_use_cases.time") as mock_time:
|
||||
mock_time.sleep = MagicMock()
|
||||
mock_time.time.side_effect = [2000.0] * 50
|
||||
uc.disconnected_voice("MASTER-A")
|
||||
|
||||
assert len(master.voice_packets) >= 1
|
||||
assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets)
|
||||
|
||||
|
||||
@pytest.mark.behavior
|
||||
def test_disconnected_voice_linked_to_reflector_includes_digits() -> None:
|
||||
"""Regression: linked reflector prompt builds longer speech sequence than not-linked."""
|
||||
scenario, _ = voice_master_scenario()
|
||||
master = _VoiceMaster("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2}
|
||||
scenario.config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 310
|
||||
uc = _disconnected_uc(master, scenario)
|
||||
linked_say: list[list] = []
|
||||
orig_pkt_gen = VoiceUseCases.pkt_gen
|
||||
|
||||
def capture_pkt_gen(self, *args, **kwargs):
|
||||
linked_say.append(list(args[4]))
|
||||
return orig_pkt_gen(self, *args, **kwargs)
|
||||
|
||||
with patch.object(VoiceUseCases, "pkt_gen", capture_pkt_gen):
|
||||
with patch("adn_server.application.voice_use_cases.time") as mock_time:
|
||||
mock_time.sleep = MagicMock()
|
||||
mock_time.time.side_effect = [3000.0] * 80
|
||||
uc.disconnected_voice("MASTER-A")
|
||||
|
||||
assert len(linked_say) == 1
|
||||
assert len(linked_say[0]) > 4
|
||||
assert len(master.voice_packets) >= 3
|
||||
assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets)
|
||||
|
||||
|
||||
def test_disconnected_voice_skips_when_language_missing() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
master = _VoiceMaster("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2}
|
||||
|
||||
class EmptyWordsProvider(FakeVoiceProvider):
|
||||
def get_ambe_words(self, languages: str, audio_path: str) -> dict:
|
||||
del languages, audio_path
|
||||
return {}
|
||||
|
||||
uc = _disconnected_uc(master, scenario, EmptyWordsProvider())
|
||||
uc.disconnected_voice("MASTER-A")
|
||||
|
||||
assert master.voice_packets == []
|
||||
@ -0,0 +1,39 @@
|
||||
"""Talker Alias embedded LC alternation on voice bursts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
|
||||
from tests.harness.scenarios import talker_alias_config
|
||||
|
||||
|
||||
def test_talker_alias_embed_state_prepared_on_bridge_vhead() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xABABABAB)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
|
||||
ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {})
|
||||
assert "TX_TA_EMB" in ts_st
|
||||
assert ts_st.get("TX_TA_ON") is False
|
||||
assert ts_st.get("TX_TA_PHASE") == 0
|
||||
|
||||
|
||||
def test_talker_alias_embed_cleared_on_vterm() -> None:
|
||||
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
|
||||
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
|
||||
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xCDCDCDCD)
|
||||
|
||||
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
|
||||
)
|
||||
scenario.inject_hbp(
|
||||
"MASTER-A",
|
||||
DeterministicScenario.voice_term_spec(base, seq=99),
|
||||
)
|
||||
|
||||
ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {})
|
||||
assert "TX_TA_EMB" not in ts_st
|
||||
assert "TX_TA_ON" not in ts_st
|
||||
@ -0,0 +1,70 @@
|
||||
"""In-band voice signalling on VTERM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, active_bridge
|
||||
from tests.harness.voice_helpers import reflector_bridge_entry
|
||||
|
||||
from adn_server.domain import bytes_3
|
||||
|
||||
|
||||
def _user_bridge(active: bool = True) -> dict:
|
||||
return active_bridge(52090, (("MASTER-A", 2),))
|
||||
|
||||
|
||||
def test_reflector_bridge_ignored_when_vterm_not_on_tg9() -> None:
|
||||
bridges = _user_bridge()
|
||||
bridges.update(reflector_bridge_entry())
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
leg = scenario.bridge.get_bridges()["#310"][0]
|
||||
timer_before = leg["TIMER"]
|
||||
|
||||
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(52090), pkt_time=2000.0)
|
||||
|
||||
assert scenario.bridge.get_bridges()["#310"][0]["TIMER"] == timer_before
|
||||
assert scenario.bridge.get_bridges()["#310"][0]["ACTIVE"] is True
|
||||
|
||||
|
||||
def test_reflector_bridge_processes_vterm_on_tg9() -> None:
|
||||
bridges = _user_bridge()
|
||||
bridges.update(reflector_bridge_entry())
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
|
||||
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(9), pkt_time=2000.0)
|
||||
|
||||
leg = scenario.bridge.get_bridges()["#310"][0]
|
||||
assert leg["ACTIVE"] is True
|
||||
assert leg["TIMER"] == 2000.0 + leg["TIMEOUT"]
|
||||
|
||||
|
||||
def test_single_mode_deactivates_on_non_source_tg() -> None:
|
||||
bridges = _user_bridge()
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = True
|
||||
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is True
|
||||
|
||||
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(91), pkt_time=3000.0)
|
||||
|
||||
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False
|
||||
|
||||
|
||||
def test_non_single_mode_keeps_bridge_on_arbitrary_vterm_tg() -> None:
|
||||
bridges = _user_bridge()
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = False
|
||||
|
||||
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(91), pkt_time=3000.0)
|
||||
|
||||
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is True
|
||||
|
||||
|
||||
def test_non_single_mode_deactivates_on_tg4000() -> None:
|
||||
bridges = copy.deepcopy(_user_bridge())
|
||||
scenario = DeterministicScenario(bridges=bridges)
|
||||
scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = False
|
||||
|
||||
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(4000), pkt_time=3000.0)
|
||||
|
||||
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False
|
||||
@ -0,0 +1,88 @@
|
||||
"""On-demand AMBE playback (TG 9991-9999, playFileOnRequest)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.harness.voice_helpers import FakeMasterForVoice, FakeVoiceProvider, voice_master_scenario
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
|
||||
|
||||
class _MasterWithVoice(FakeMasterForVoice):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name)
|
||||
self.voice_packets: list[bytes] = []
|
||||
|
||||
def send_voice_packet(self, packet: bytes, _source_id: bytes, _dst_id: bytes, _slot: dict) -> None:
|
||||
self.voice_packets.append(packet)
|
||||
|
||||
|
||||
def _play_file_uc(master: _MasterWithVoice, scenario) -> VoiceUseCases:
|
||||
def call_from_reactor(fn, *args):
|
||||
fn(*args)
|
||||
|
||||
return VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
call_from_reactor=call_from_reactor,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
|
||||
def test_play_file_on_request_sends_all_generated_packets() -> None:
|
||||
scenario, _master = voice_master_scenario()
|
||||
master = _MasterWithVoice("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2}
|
||||
uc = _play_file_uc(master, scenario)
|
||||
|
||||
with patch("adn_server.application.voice_use_cases.time") as mock_time:
|
||||
mock_time.sleep = MagicMock()
|
||||
mock_time.time.side_effect = [1000.0] * 20
|
||||
uc.play_file_on_request("9991", "MASTER-A")
|
||||
|
||||
assert len(master.voice_packets) == 3
|
||||
assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets)
|
||||
|
||||
|
||||
def test_play_file_on_request_skips_when_file_missing() -> None:
|
||||
scenario, _master = voice_master_scenario()
|
||||
master = _MasterWithVoice("MASTER-A")
|
||||
master.STATUS[2] = {}
|
||||
|
||||
class EmptyProvider(FakeVoiceProvider):
|
||||
def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list:
|
||||
del audio_path, lang, file_number
|
||||
return []
|
||||
|
||||
def call_from_reactor(fn, *args):
|
||||
fn(*args)
|
||||
|
||||
uc = VoiceUseCases(
|
||||
EmptyProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
call_from_reactor=call_from_reactor,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
uc.play_file_on_request("9992", "MASTER-A")
|
||||
|
||||
assert master.voice_packets == []
|
||||
|
||||
|
||||
def test_play_file_on_request_requires_reactor_callback() -> None:
|
||||
scenario, _master = voice_master_scenario()
|
||||
master = _MasterWithVoice("MASTER-A")
|
||||
master.STATUS[2] = {}
|
||||
uc = VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
uc.play_file_on_request("9993", "MASTER-A")
|
||||
|
||||
assert master.voice_packets == []
|
||||
@ -0,0 +1,70 @@
|
||||
"""Scheduled file announcements (AMBE on disk)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import (
|
||||
drain_call_later,
|
||||
make_voice_uc,
|
||||
voice_announcement_config,
|
||||
voice_master_scenario,
|
||||
)
|
||||
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||
|
||||
|
||||
def test_scheduled_announcement_starts_broadcast_on_idle_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running[0] is True
|
||||
assert "91" in uc._broadcast_active_tgs
|
||||
assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VHEAD
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, _scheduled = uc._scheduled[0]
|
||||
assert delay == 0.5
|
||||
drain_call_later(uc)
|
||||
assert len(master.sent) == 3
|
||||
|
||||
|
||||
def test_scheduled_announcement_retries_when_slot_busy() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running.get(0) is not True
|
||||
assert len(uc._scheduled) == 1
|
||||
assert master.sent == []
|
||||
delay, (_fn, args) = uc._scheduled[0]
|
||||
assert delay == 5.0
|
||||
assert args == (0, 1)
|
||||
|
||||
|
||||
def test_scheduled_announcement_skips_when_disabled() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario, enabled=False)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running.get(0) is not True
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_scheduled_announcement_sends_packets_to_master() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
drain_call_later(uc)
|
||||
|
||||
assert uc._announcement_running[0] is False
|
||||
assert "91" not in uc._broadcast_active_tgs
|
||||
assert len(master.sent) == 3
|
||||
assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VTERM
|
||||
@ -0,0 +1,137 @@
|
||||
"""Scheduled TTS announcements and conversion callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario, voice_tts_config
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
|
||||
|
||||
|
||||
def test_scheduled_tts_sync_path_enqueues_broadcast() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._broadcast_active_tgs == {"91"}
|
||||
assert len(uc._scheduled) == 1
|
||||
assert getattr(uc._scheduled[0][1][0], "__name__", "") == "_tts_send_broadcast"
|
||||
|
||||
|
||||
def test_tts_conversion_error_clears_running_flag() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_error(RuntimeError("TTS failed"), 0, "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
|
||||
|
||||
def test_tts_conversion_done_without_ambe_clears_running() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_done(None, 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_tts_conversion_done_retries_when_slot_busy() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_done("/tmp/fake.ambe", 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is True
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, (fn, args) = uc._scheduled[0]
|
||||
assert delay == 5.0
|
||||
assert getattr(fn, "__name__", "") == "_tts_conversion_done"
|
||||
assert args[-1] == 1
|
||||
|
||||
|
||||
def test_scheduled_tts_skips_outside_top_of_hour() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "hourly.ambe",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "hourly",
|
||||
}
|
||||
]
|
||||
}
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
with patch("adn_server.application.voice_use_cases.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = datetime(2026, 5, 24, 10, 30)
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running.get(0) is not True
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_scheduled_tts_defers_when_same_tg_already_broadcasting() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running.get(0) is not True
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, (fn, args) = uc._scheduled[0]
|
||||
assert delay == 3.0
|
||||
assert getattr(fn, "__name__", "") == "scheduled_tts_announcement"
|
||||
assert args == (0, 1)
|
||||
|
||||
|
||||
def test_scheduled_tts_sync_exception_clears_running() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
|
||||
class FailingProvider:
|
||||
def ensure_tts_ambe(self, config, item, audio_path):
|
||||
del config, item, audio_path
|
||||
raise OSError("disk full")
|
||||
|
||||
def read_single_file(self, *args):
|
||||
return [b"\x00" * 7]
|
||||
|
||||
def pkt_gen(self, *args, **kwargs):
|
||||
return iter([])
|
||||
|
||||
def get_ambe_words(self, *args):
|
||||
return {}
|
||||
|
||||
scheduled: list[tuple[float, tuple]] = []
|
||||
|
||||
def call_later(delay, fn, *args):
|
||||
scheduled.append((delay, (fn, args)))
|
||||
return MagicMock()
|
||||
|
||||
uc = VoiceUseCases(
|
||||
FailingProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
get_bridges=scenario.bridge.get_bridges,
|
||||
call_later=call_later,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
@ -0,0 +1,92 @@
|
||||
"""Voice config reload and announcement LoopingCall management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.harness.voice_helpers import FakeVoiceProvider, voice_master_scenario
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
|
||||
|
||||
def _reload_uc(scenario, *, start_looping_call) -> VoiceUseCases:
|
||||
return VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
start_looping_call=start_looping_call,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
|
||||
def test_check_voice_config_reload_starts_enabled_announcement_loop() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "welcome",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "interval",
|
||||
"INTERVAL": 120,
|
||||
}
|
||||
],
|
||||
}
|
||||
started: list[tuple[float, object]] = []
|
||||
|
||||
def start_looping_call(fn, interval, _now):
|
||||
started.append((interval, fn))
|
||||
handle = MagicMock(running=True)
|
||||
handle.stop = MagicMock()
|
||||
return handle
|
||||
|
||||
uc = _reload_uc(scenario, start_looping_call=start_looping_call)
|
||||
uc.check_voice_config_reload()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0][0] == 120.0
|
||||
assert 0 in uc._ann_tasks
|
||||
|
||||
|
||||
def test_check_voice_config_reload_stops_removed_announcement() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {"ANNOUNCEMENTS": [{"ENABLED": False, "TG": 91, "FILE": "x"}]}
|
||||
stop_mock = MagicMock()
|
||||
uc = VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
start_looping_call=lambda *_a: MagicMock(running=True, stop=stop_mock),
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
uc._ann_tasks[0] = MagicMock(running=True, stop=stop_mock)
|
||||
|
||||
uc.check_voice_config_reload()
|
||||
|
||||
assert 0 not in uc._ann_tasks
|
||||
stop_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_check_voice_config_reload_starts_tts_loop() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "hourly.ambe",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "hourly",
|
||||
}
|
||||
],
|
||||
}
|
||||
started: list[float] = []
|
||||
|
||||
def start_looping_call(_fn, interval, _now):
|
||||
started.append(interval)
|
||||
return MagicMock(running=True, stop=MagicMock())
|
||||
|
||||
uc = _reload_uc(scenario, start_looping_call=start_looping_call)
|
||||
uc.check_voice_config_reload()
|
||||
|
||||
assert started == [30.0]
|
||||
assert 0 in uc._tts_tasks
|
||||
Loading…
Reference in new issue