test: add deterministic regression suite (142 tests)

pull/1/head
Rodrigo Pérez 4 months ago
parent ed5a4b31d4
commit 92c1e906bd

4
.gitignore vendored

@ -9,8 +9,8 @@ adn-voice.yaml
*.local.yaml
*.local.yml
# Local/helper scripts (keep scripts out of git)
scripts/
# Local/helper scripts (repo root only; not tests/scripts/)
/scripts/
# Env and secrets
.env

@ -43,6 +43,15 @@ Use the same `python3` you use for the project (e.g. pyenv’s `3.11.8`). Previe
Output: **`site/en/`** and **`site/es/`** under gitignored **`site/`**.
## Tests
```bash
python3 -m pip install -e ".[dev]"
python3 -m pytest tests/ -q
```
See [Testing](docs/en/server/development/testing.md) in the docs site. File index: [`tests/README.md`](tests/README.md).
## Run
```bash

@ -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`.

@ -65,6 +65,7 @@ nav:
- Development:
- Architecture: server/development/architecture.md
- Behaviour and timers: server/development/behaviour-and-timers.md
- Testing: server/development/testing.md
- Contributing:
- Translations: server/contributing/translations.md
- Monitor:

@ -21,6 +21,7 @@ dependencies = [
]
[project.optional-dependencies]
dev = ["pytest>=7.0"]
docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"]
[tool.setuptools.packages.find]

@ -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,138 @@
"""Parrot re-key and playback packet prep."""
from __future__ import annotations
from unittest.mock import patch
import pytest
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 PlaybackUseCases, _PACKET_INTERVAL_S
from adn_server.domain import bytes_3, bytes_4
def _long_voice_recording(
base: PacketSpec,
*,
burst_count: int,
) -> list[bytes]:
"""Simulate MMDVM seq byte wrapping (1..255, 0, 1..) over a long QSO."""
recorded = [DeterministicScenario.voice_head_spec(base).data()]
for i in range(1, burst_count + 1):
recorded.append(
DeterministicScenario.voice_burst_spec(
base,
seq=i & 0xFF,
dtype_vseq=((i - 1) % 4) + 1,
).data()
)
recorded.append(DeterministicScenario.voice_term_spec(base, seq=(burst_count + 1) & 0xFF).data())
return recorded
@pytest.mark.behavior
def test_prepare_playback_preserves_source_seq_past_255_packets() -> None:
"""Regression b0578fb: renumbering 1..255 duplicates seq ~15–30s; MMDVMHost drops audio."""
pb = PlaybackUseCases("ECHO")
base = PacketSpec(dst_id=9990, stream_id=0x55555555, slot=2)
recorded = _long_voice_recording(base, burst_count=500)
out = pb._prepare_playback_packets(recorded)
assert len(out) == len(recorded)
playback_sid = out[0][16:20]
for src, replay in zip(recorded, out):
assert replay[4] == src[4], (
f"playback must keep source seq byte 0x{src[4]:02x}, got 0x{replay[4]:02x}"
)
assert replay[16:20] == playback_sid
# After ~255 voice bursts (~15s), buggy renumber wraps to seq=1 while source has seq=0.
assert recorded[256][4] == 0
assert out[256][4] == 0
@pytest.mark.behavior
def test_start_playback_sends_preserved_seq_over_30s_recording() -> None:
"""End-to-end: ~500 bursts @ 60ms ≈ 30s; replay seq on wire must match recording."""
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x66666666, slot=2)
burst_count = 500
recorded = _long_voice_recording(base, burst_count=burst_count)
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,
burst_count * _PACKET_INTERVAL_S,
)
while scheduled:
_delay, fn, args = scheduled.pop(0)
fn(*args)
assert len(proto.sent) == len(recorded)
for src, sent in zip(recorded, proto.sent):
assert sent[4] == src[4]
def test_recording_rekey_does_not_store_second_vhead() -> None:
pb = PlaybackUseCases("ECHO", get_protocol=lambda: FakePlaybackProtocol())
base = PacketSpec(dst_id=9990, stream_id=0x11111111, slot=2)
rekey = PacketSpec(dst_id=9990, stream_id=0x22222222, 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") as mock_time:
mock_time.side_effect = [100.0, 100.1, 100.2, 100.3]
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base))
send_playback(
pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(rekey))
send_playback(
pb, "ECHO", DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2),
)
vheads = sum(
1
for pkt in pb.CALL_DATA
if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2
)
assert vheads == 1
assert pb._record_stream == rekey.data()[16:20]
def test_prepare_playback_skips_mid_call_vhead_and_preserves_seq() -> None:
pb = PlaybackUseCases("ECHO")
base = PacketSpec(dst_id=9990, stream_id=0x33333333, slot=2)
rekey = PacketSpec(dst_id=9990, stream_id=0x44444444, slot=2)
recorded = [
DeterministicScenario.voice_head_spec(base).data(),
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(),
DeterministicScenario.voice_head_spec(rekey).data(),
DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2).data(),
DeterministicScenario.voice_term_spec(rekey, seq=3).data(),
]
out = pb._prepare_playback_packets(recorded)
assert len(out) == 4
# Mid-call VHEAD (recorded[2]) dropped; source seq preserved (legacy playback.py).
assert out[0][4] == recorded[0][4]
assert out[1][4] == recorded[1][4]
assert out[2][4] == recorded[3][4]
assert out[3][4] == recorded[4][4]
mid_vheads = sum(
1
for pkt in out[1:]
if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2
)
assert mid_vheads == 0
assert out[0][16:20] == out[-1][16:20]

@ -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,133 @@
"""MMDVMHost DMRA wire layout and short TA passthrough."""
from __future__ import annotations
from bitarray import bitarray
from adn_server.application.talker_alias_use_cases import passthrough_complete, passthrough_packets_from_blocks
from adn_server.domain.talker_alias import (
decode_ta_from_blocks,
encode_utf8,
parse_dmra_packet,
required_ta_block_count,
store_ta_from_embed_lc,
talker_alias_block_id_from_lc,
talker_alias_lc_bytes,
try_buffer_ta_from_voice_fragments,
)
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc
_encode_emblc_correct = encode_emblc
def _voice_pkt_with_embed(frag: bitarray) -> bytes:
dmrbits = bitarray(264, endian="big")
dmrbits.setall(0)
dmrbits[116:148] = frag
return dmrbits.tobytes()[:33]
def mmdvm_wire_blocks(text: str) -> dict[int, bytes]:
"""Build wire payloads as MMDVMHost writeTalkerAlias / CDMRTA::add (m_buf[block*7:])."""
buf = encode_utf8(text)
count = required_ta_block_count(buf)
blocks: dict[int, bytes] = {}
for block_id in range(count):
start = block_id * 7
blocks[block_id] = buf[start : start + 7]
return blocks
def test_passthrough_complete_single_mmdvm_wire_block_ce5rpy() -> None:
blocks = mmdvm_wire_blocks("CE5RPY")
assert passthrough_complete(blocks) is True
assert decode_ta_from_blocks(blocks) == "CE5RPY"
def test_passthrough_complete_mmdvm_wire_two_blocks() -> None:
blocks = mmdvm_wire_blocks("CE5RPY Rodrigo")
assert passthrough_complete(blocks) is True
assert decode_ta_from_blocks(blocks) == "CE5RPY Rodrigo"
def test_peer_dmra_non_mmdvm_layout_rejected() -> None:
"""Pi-Star log with byte7=0x3D is not MMDVM writeTalkerAlias (type must be 0-3)."""
data = b"DMRA+\x83\x83=oe(\x00\x8cCE"
assert parse_dmra_packet(data) is None
def test_talker_alias_block_id_from_lc() -> None:
lc9 = talker_alias_lc_bytes(2, encode_utf8("CE5RPY")[0:7])
assert talker_alias_block_id_from_lc(lc9) == 2
assert talker_alias_block_id_from_lc(b"\x00\x00" + b"\x00" * 7) is None
def test_buffer_ta_from_voice_fragments_decodes_ce5rpy() -> None:
"""Voice bursts B–E carrying a correctly FEC-encoded TA decode losslessly to CE5RPY."""
text = "CE5RPY"
lc9 = talker_alias_lc_bytes(0, encode_utf8(text)[0:7])
frags = _encode_emblc_correct(lc9)
blocks: dict[int, bytes] = {}
acc: dict[int, object] = {}
for vseq in (1, 2, 3):
assert not try_buffer_ta_from_voice_fragments(acc, vseq, _voice_pkt_with_embed(frags[vseq]), blocks)
assert try_buffer_ta_from_voice_fragments(acc, 4, _voice_pkt_with_embed(frags[4]), blocks)
assert passthrough_complete(blocks)
assert decode_ta_from_blocks(blocks) == text
def test_injected_emblc_round_trips_losslessly() -> None:
"""Injected embedded LC must survive decode_emblc (regression: 'Rodrigo' -> 'Rodrigg').
The upstream dmr_utils3 encoder corrupted segment D (bit 25); our vendored encoder fixes it,
so every injected block round-trips through decode_emblc.
"""
from adn_server.domain.dmr import bptc
text = "CE5RPY Rodrigo"
emblcs, count = encode_talker_alias_emblc(text)
assert count >= 3
recovered: dict[int, bytes] = {}
for block_id, emblc in enumerate(emblcs):
lc = bptc.decode_emblc(emblc[1] + emblc[2] + emblc[3] + emblc[4])
assert lc == talker_alias_lc_bytes(block_id, encode_utf8(text)[block_id * 7 : block_id * 7 + 7])
recovered[block_id] = lc[2:9]
assert decode_ta_from_blocks(recovered) == text
def test_buffer_ta_from_voice_ignores_group_lc() -> None:
"""Group-call embedded LC (FLCO 0) is not stored as TA."""
group_lc = bytes([0x00, 0x00]) + b"\x00\x12\x34\x00\x00\x07\x53"
frags = _encode_emblc_correct(group_lc)
blocks: dict[int, bytes] = {}
acc: dict[int, object] = {}
for vseq in (1, 2, 3, 4):
try_buffer_ta_from_voice_fragments(acc, vseq, _voice_pkt_with_embed(frags[vseq]), blocks)
assert blocks == {}
def test_store_ta_from_embed_lc_matches_wire() -> None:
"""Wire payload == lc9[2:9] per DMRNetwork::writeTalkerAlias."""
text = "CE5RPY"
encoded = encode_utf8(text)
lc9 = talker_alias_lc_bytes(0, encoded[0:7])
blocks: dict[int, bytes] = {}
assert store_ta_from_embed_lc(blocks, 0, lc9)
parsed = parse_dmra_packet(b"DMRA" + b"\x00\x00\x01" + bytes([0]) + blocks[0])
assert parsed is not None
assert parsed[2] == blocks[0]
def test_mmdvm_three_byte_id_layout() -> None:
data = b"DMRA\x2b\x83\x83\x01" + b"\x00" * 7
parsed = parse_dmra_packet(data)
assert parsed == (bytes([0x2B, 0x83, 0x83]), 1, b"\x00" * 7)
def test_passthrough_packets_sends_only_required_blocks() -> None:
from tests.talker_alias.test_passthrough import _complete_blocks
rf = bytes([0x2B, 0x83, 0x83])
blocks = _complete_blocks("CE5RPY")
packets = passthrough_packets_from_blocks(rf, blocks)
assert len(packets) == 1
assert packets[0][7] == 0

@ -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…
Cancel
Save

Powered by TurnKey Linux.