You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1034 lines
31 KiB
1034 lines
31 KiB
"""Black-box UDP integration harness for FreeDMR.
|
|
|
|
This module is test-only. It starts bridge_master.py with a generated loopback
|
|
configuration and interacts with it over UDP as emulated HBP repeaters.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from hashlib import blake2b, sha1
|
|
from hmac import new as hmac_new
|
|
from pathlib import Path
|
|
import os
|
|
import random
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import venv
|
|
|
|
from tests.harness.deterministic import (
|
|
HBPF_DATA_SYNC,
|
|
HBPF_SLT_VHEAD,
|
|
HBPF_SLT_VTERM,
|
|
HBPF_VOICE,
|
|
PacketSpec,
|
|
bytes_3,
|
|
bytes_4,
|
|
parse_dmr_fields,
|
|
)
|
|
|
|
|
|
RPTL = b"RPTL"
|
|
RPTACK = b"RPTACK"
|
|
RPTK = b"RPTK"
|
|
RPTC = b"RPTC"
|
|
RPTPING = b"RPTPING"
|
|
MSTPONG = b"MSTPONG"
|
|
DMRD = b"DMRD"
|
|
DMRE = b"DMRE"
|
|
BCKA = b"BCKA"
|
|
BCSQ = b"BCSQ"
|
|
BCST = b"BCST"
|
|
BCVE = b"BCVE"
|
|
FBP_VERSION = 5
|
|
FBP_PASSPHRASE = b"test-passphrase".ljust(20, b"\x00")[:20]
|
|
REQUIRED_RUNTIME_MODULES = ("bitarray", "twisted", "setproctitle")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LinkImpairment:
|
|
"""Deterministic test-only impairment for packets sent by fake UDP peers."""
|
|
|
|
drop_rate: float = 0.0
|
|
duplicate_rate: float = 0.0
|
|
delay_range: tuple[float, float] = (0.0, 0.0)
|
|
jitter_range: tuple[float, float] = (0.0, 0.0)
|
|
drop_indices: frozenset[int] = frozenset()
|
|
duplicate_indices: frozenset[int] = frozenset()
|
|
delay_by_index: dict[int, float] = field(default_factory=dict)
|
|
seed: int = 1
|
|
|
|
def schedule(
|
|
self,
|
|
packets: list[bytes],
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
) -> list[tuple[float, bytes]]:
|
|
rng = random.Random(self.seed)
|
|
scheduled = []
|
|
for index, packet in enumerate(packets):
|
|
if index in self.drop_indices or rng.random() < self.drop_rate:
|
|
continue
|
|
|
|
base_time = index * (cadence_seconds or 0.0)
|
|
jitter = rng.uniform(*self.jitter_range)
|
|
delay = self.delay_by_index.get(index, rng.uniform(*self.delay_range))
|
|
send_at = max(0.0, base_time + jitter + delay)
|
|
scheduled.append((send_at, packet))
|
|
|
|
if index in self.duplicate_indices or rng.random() < self.duplicate_rate:
|
|
scheduled.append((send_at + 0.001, packet))
|
|
|
|
return sorted(scheduled, key=lambda item: item[0])
|
|
|
|
|
|
class ImpairmentProfiles:
|
|
@staticmethod
|
|
def clean() -> LinkImpairment:
|
|
return LinkImpairment()
|
|
|
|
@staticmethod
|
|
def provider_vxlan_reorder(delayed_index: int = 1, delay: float = 0.08) -> LinkImpairment:
|
|
return LinkImpairment(delay_by_index={delayed_index: delay})
|
|
|
|
@staticmethod
|
|
def mobile_flutter(*drop_indices: int) -> LinkImpairment:
|
|
return LinkImpairment(drop_indices=frozenset(drop_indices))
|
|
|
|
@staticmethod
|
|
def duplicate_udp(*duplicate_indices: int) -> LinkImpairment:
|
|
return LinkImpairment(duplicate_indices=frozenset(duplicate_indices))
|
|
|
|
@staticmethod
|
|
def burst_loss(start: int, count: int) -> LinkImpairment:
|
|
return LinkImpairment(drop_indices=frozenset(range(start, start + count)))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StreamProfile:
|
|
packets: list[PacketSpec]
|
|
cadence_seconds: float = 0.03
|
|
|
|
@classmethod
|
|
def voice_over(
|
|
cls,
|
|
*,
|
|
peer_id: int | bytes,
|
|
rf_src: int | bytes = 3120001,
|
|
dst_id: int | bytes = 91,
|
|
slot: int = 2,
|
|
stream_id: int | bytes = 0x01020304,
|
|
start_seq: int = 0,
|
|
voice_bursts: int = 1,
|
|
include_header: bool = False,
|
|
include_terminator: bool = False,
|
|
) -> "StreamProfile":
|
|
packets = []
|
|
seq = start_seq
|
|
if include_header:
|
|
packets.append(
|
|
PacketSpec(
|
|
peer_id=peer_id,
|
|
rf_src=rf_src,
|
|
dst_id=dst_id,
|
|
slot=slot,
|
|
stream_id=stream_id,
|
|
seq=seq,
|
|
frame_type=HBPF_DATA_SYNC,
|
|
dtype_vseq=HBPF_SLT_VHEAD,
|
|
)
|
|
)
|
|
seq = (seq + 1) % 256
|
|
|
|
for index in range(voice_bursts):
|
|
packets.append(
|
|
PacketSpec(
|
|
peer_id=peer_id,
|
|
rf_src=rf_src,
|
|
dst_id=dst_id,
|
|
slot=slot,
|
|
stream_id=stream_id,
|
|
seq=seq,
|
|
frame_type=HBPF_VOICE,
|
|
dtype_vseq=index % 6,
|
|
)
|
|
)
|
|
seq = (seq + 1) % 256
|
|
|
|
if include_terminator:
|
|
packets.append(
|
|
PacketSpec(
|
|
peer_id=peer_id,
|
|
rf_src=rf_src,
|
|
dst_id=dst_id,
|
|
slot=slot,
|
|
stream_id=stream_id,
|
|
seq=seq,
|
|
frame_type=HBPF_DATA_SYNC,
|
|
dtype_vseq=HBPF_SLT_VTERM,
|
|
)
|
|
)
|
|
|
|
return cls(packets=packets)
|
|
|
|
|
|
def send_scheduled_packets(
|
|
send_packet,
|
|
packets: list[bytes],
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
) -> None:
|
|
if impairment is None:
|
|
for index, packet in enumerate(packets):
|
|
if index and cadence_seconds is not None:
|
|
time.sleep(cadence_seconds)
|
|
send_packet(packet)
|
|
return
|
|
|
|
started_at = time.monotonic()
|
|
for send_at, packet in impairment.schedule(packets, cadence_seconds=cadence_seconds):
|
|
wait = started_at + send_at - time.monotonic()
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
send_packet(packet)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecordedPacketFixture:
|
|
"""Small line-oriented recorded packet fixture.
|
|
|
|
Fixture files contain one hex-encoded UDP payload per non-empty line. Lines
|
|
starting with ``#`` are ignored. The format is intentionally transport-only:
|
|
replay preserves bytes and leaves protocol mutation to FreeDMR.
|
|
"""
|
|
|
|
packets: list[bytes]
|
|
|
|
@classmethod
|
|
def from_file(cls, path: Path) -> "RecordedPacketFixture":
|
|
packets = []
|
|
for line in path.read_text(encoding="ascii").splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
packets.append(bytes.fromhex(stripped))
|
|
return cls(packets)
|
|
|
|
def replay(
|
|
self,
|
|
send_packet,
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
) -> None:
|
|
send_scheduled_packets(
|
|
send_packet,
|
|
self.packets,
|
|
cadence_seconds=cadence_seconds,
|
|
impairment=impairment,
|
|
)
|
|
|
|
|
|
def require_udp_integration_enabled() -> None:
|
|
if os.environ.get("FREEDMR_RUN_UDP_TESTS") != "1":
|
|
raise unittest.SkipTest("set FREEDMR_RUN_UDP_TESTS=1 to run black-box UDP tests")
|
|
|
|
|
|
def _venv_python(venv_dir: Path) -> Path:
|
|
if os.name == "nt":
|
|
return venv_dir / "Scripts" / "python.exe"
|
|
return venv_dir / "bin" / "python"
|
|
|
|
|
|
def python_has_runtime_deps(python_executable: str | Path) -> bool:
|
|
return _runtime_import_check(python_executable).returncode == 0
|
|
|
|
|
|
def _runtime_import_check(python_executable: str | Path) -> subprocess.CompletedProcess:
|
|
imports = "; ".join(f"import {module}" for module in REQUIRED_RUNTIME_MODULES)
|
|
return subprocess.run(
|
|
[str(python_executable), "-c", imports],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
|
|
|
|
class DependencySandbox:
|
|
"""Resolve or create a Python runtime that can start bridge_master.py."""
|
|
|
|
def __init__(self, repo_root: Path) -> None:
|
|
self.repo_root = repo_root
|
|
self._tempdir: tempfile.TemporaryDirectory | None = None
|
|
|
|
def cleanup(self) -> None:
|
|
if self._tempdir is not None:
|
|
self._tempdir.cleanup()
|
|
self._tempdir = None
|
|
|
|
def resolve_python(self) -> str:
|
|
explicit_python = os.environ.get("FREEDMR_UDP_PYTHON")
|
|
if explicit_python:
|
|
if not python_has_runtime_deps(explicit_python):
|
|
raise unittest.SkipTest(
|
|
"FREEDMR_UDP_PYTHON does not have FreeDMR runtime dependencies"
|
|
)
|
|
return explicit_python
|
|
|
|
if python_has_runtime_deps(sys.executable):
|
|
return sys.executable
|
|
|
|
if os.environ.get("FREEDMR_UDP_BOOTSTRAP_VENV") != "1":
|
|
raise unittest.SkipTest(
|
|
"missing FreeDMR runtime dependencies; set "
|
|
"FREEDMR_UDP_BOOTSTRAP_VENV=1 to install them in a test venv"
|
|
)
|
|
|
|
return str(self._bootstrap_venv_python())
|
|
|
|
def _bootstrap_venv_python(self) -> Path:
|
|
venv_dir_env = os.environ.get("FREEDMR_UDP_VENV_DIR")
|
|
if venv_dir_env:
|
|
venv_dir = Path(venv_dir_env).expanduser().resolve()
|
|
else:
|
|
self._tempdir = tempfile.TemporaryDirectory(prefix="freedmr-udp-venv-")
|
|
venv_dir = Path(self._tempdir.name)
|
|
|
|
python_executable = _venv_python(venv_dir)
|
|
if not python_executable.exists():
|
|
venv.EnvBuilder(with_pip=True).create(venv_dir)
|
|
|
|
if not python_has_runtime_deps(python_executable):
|
|
requirements = self.repo_root / "requirements.txt"
|
|
subprocess.check_call(
|
|
[str(python_executable), "-m", "pip", "install", "-r", str(requirements)]
|
|
)
|
|
|
|
import_check = _runtime_import_check(python_executable)
|
|
if import_check.returncode != 0:
|
|
raise RuntimeError(
|
|
"test venv was created but FreeDMR dependencies still fail to import:\n"
|
|
+ import_check.stderr
|
|
)
|
|
|
|
return python_executable
|
|
|
|
|
|
def free_udp_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def parse_udp_dmr_fields(packet: bytes) -> dict[str, object]:
|
|
if packet[:4] != DMRE:
|
|
return parse_dmr_fields(packet)
|
|
|
|
fields = parse_dmr_fields(DMRD + packet[4:53])
|
|
fields.update(
|
|
{
|
|
"opcode": packet[:4],
|
|
"ber": packet[53:54],
|
|
"rssi": packet[54:55],
|
|
"fbp_version": packet[55] if len(packet) > 55 else None,
|
|
}
|
|
)
|
|
if len(packet) >= 89 and packet[55] > 4:
|
|
fields.update(
|
|
{
|
|
"timestamp": packet[56:64],
|
|
"source_server": packet[64:68],
|
|
"source_rptr": packet[68:72],
|
|
"hops": packet[72],
|
|
"hash": packet[73:89],
|
|
}
|
|
)
|
|
elif len(packet) >= 85:
|
|
fields.update(
|
|
{
|
|
"timestamp": packet[56:64],
|
|
"source_server": packet[64:68],
|
|
"source_rptr": b"\x00\x00\x00\x00",
|
|
"hops": packet[68],
|
|
"hash": packet[69:85],
|
|
}
|
|
)
|
|
return fields
|
|
|
|
|
|
def fbp_packet_from_spec(
|
|
packet: PacketSpec,
|
|
*,
|
|
network_id: int | bytes,
|
|
fbp_version: int = FBP_VERSION,
|
|
source_server: int | bytes = 9991,
|
|
source_rptr: int | bytes = 1001,
|
|
hops: int = 0,
|
|
timestamp_ns: int | None = None,
|
|
corrupt_hash: bool = False,
|
|
passphrase: bytes = FBP_PASSPHRASE,
|
|
) -> bytes:
|
|
dmr = packet.data()
|
|
body = b"".join(
|
|
[
|
|
DMRE,
|
|
dmr[4:11],
|
|
bytes_4(network_id),
|
|
dmr[15:53],
|
|
packet.ber,
|
|
packet.rssi,
|
|
bytes([fbp_version]),
|
|
(time.time_ns() if timestamp_ns is None else timestamp_ns).to_bytes(8, "big"),
|
|
bytes_4(source_server),
|
|
]
|
|
)
|
|
if fbp_version > 4:
|
|
body += bytes_4(source_rptr) + bytes([hops])
|
|
else:
|
|
body += bytes([hops])
|
|
digest = blake2b(body, key=passphrase, digest_size=16).digest()
|
|
if corrupt_hash:
|
|
digest = bytes([digest[0] ^ 0xFF]) + digest[1:]
|
|
return body + digest
|
|
|
|
|
|
def obp_v1_packet_from_spec(
|
|
packet: PacketSpec,
|
|
*,
|
|
network_id: int | bytes,
|
|
corrupt_hash: bool = False,
|
|
passphrase: bytes = FBP_PASSPHRASE,
|
|
) -> bytes:
|
|
dmr = packet.data()
|
|
body = b"".join([DMRD, dmr[4:11], bytes_4(network_id), dmr[15:53]])
|
|
digest = hmac_new(passphrase, body, sha1).digest()
|
|
if corrupt_hash:
|
|
digest = bytes([digest[0] ^ 0xFF]) + digest[1:]
|
|
return body + digest
|
|
|
|
|
|
def fbp_control_packet(
|
|
opcode: bytes,
|
|
payload: bytes = b"",
|
|
*,
|
|
corrupt_hash: bool = False,
|
|
passphrase: bytes = FBP_PASSPHRASE,
|
|
) -> bytes:
|
|
body = opcode + payload
|
|
if opcode == BCVE:
|
|
digest_payload = payload
|
|
else:
|
|
digest_payload = body
|
|
digest = hmac_new(passphrase, digest_payload, sha1).digest()
|
|
if corrupt_hash:
|
|
digest = bytes([digest[0] ^ 0xFF]) + digest[1:]
|
|
return body + digest
|
|
|
|
|
|
def repeater_config_packet(radio_id: bytes, callsign: str = "TST") -> bytes:
|
|
return b"".join(
|
|
[
|
|
radio_id,
|
|
callsign.encode("ascii", "ignore").ljust(8)[:8],
|
|
b"439000000",
|
|
b"430000000",
|
|
b"10",
|
|
b"01",
|
|
b"000.0000",
|
|
b"000.00000",
|
|
b"000",
|
|
b"FreeDMR Test".ljust(20),
|
|
b"UDP Harness".ljust(19),
|
|
b"2",
|
|
b"http://localhost".ljust(124),
|
|
b"freedmr-test".ljust(40),
|
|
b"freedmr-test".ljust(40),
|
|
]
|
|
)
|
|
|
|
|
|
def write_bridge_master_config(
|
|
path: Path,
|
|
system_ports: dict[str, int],
|
|
*,
|
|
fbp_system_ports: dict[str, int] | None = None,
|
|
fbp_target_ports: dict[str, int] | None = None,
|
|
fbp_network_ids: dict[str, int] | None = None,
|
|
fbp_proto_versions: dict[str, int] | None = None,
|
|
global_use_acl: bool = False,
|
|
global_sub_acl: str = "PERMIT:ALL",
|
|
global_tg1_acl: str = "PERMIT:ALL",
|
|
global_tg2_acl: str = "PERMIT:ALL",
|
|
ts1_static: str = "",
|
|
ts2_static: str = "91",
|
|
dial_a_tg: bool = True,
|
|
dynamic_tg_routing: bool = True,
|
|
) -> None:
|
|
global_use_acl_text = "True" if global_use_acl else "False"
|
|
dial_a_tg_text = "True" if dial_a_tg else "False"
|
|
dynamic_tg_routing_text = "True" if dynamic_tg_routing else "False"
|
|
systems = []
|
|
for name, port in system_ports.items():
|
|
systems.append(
|
|
f"""
|
|
[{name}]
|
|
MODE: MASTER
|
|
ENABLED: True
|
|
REPEAT: False
|
|
MAX_PEERS: 4
|
|
IP: 127.0.0.1
|
|
PORT: {port}
|
|
PASSPHRASE:
|
|
GROUP_HANGTIME: 0
|
|
USE_ACL: False
|
|
REG_ACL: PERMIT:ALL
|
|
SUB_ACL: PERMIT:ALL
|
|
TGID_TS1_ACL: PERMIT:ALL
|
|
TGID_TS2_ACL: PERMIT:ALL
|
|
DEFAULT_UA_TIMER: 1
|
|
SINGLE_MODE: True
|
|
VOICE_IDENT: False
|
|
DIAL_A_TG: {dial_a_tg_text}
|
|
DYNAMIC_TG_ROUTING: {dynamic_tg_routing_text}
|
|
TS1_STATIC: {ts1_static}
|
|
TS2_STATIC: {ts2_static}
|
|
DEFAULT_DIAL_TS1: 0
|
|
DEFAULT_DIAL_TS2: 0
|
|
DEFAULT_REFLECTOR: 0
|
|
ANNOUNCEMENT_LANGUAGE: en_GB
|
|
GENERATOR: 0
|
|
ALLOW_UNREG_ID: True
|
|
PROXY_CONTROL: False
|
|
OVERRIDE_IDENT_TG:
|
|
"""
|
|
)
|
|
|
|
for name, port in (fbp_system_ports or {}).items():
|
|
target_port = (fbp_target_ports or {})[name]
|
|
network_id = (fbp_network_ids or {})[name]
|
|
proto_version = (fbp_proto_versions or {}).get(name, FBP_VERSION)
|
|
systems.append(
|
|
f"""
|
|
[{name}]
|
|
MODE: OPENBRIDGE
|
|
ENABLED: True
|
|
NETWORK_ID: {network_id}
|
|
IP: 127.0.0.1
|
|
PORT: {port}
|
|
PASSPHRASE: test-passphrase
|
|
TARGET_IP: 127.0.0.1
|
|
TARGET_PORT: {target_port}
|
|
USE_ACL: False
|
|
SUB_ACL: PERMIT:ALL
|
|
TGID_ACL: PERMIT:ALL
|
|
RELAX_CHECKS: True
|
|
ENHANCED_OBP: True
|
|
PROTO_VER: {proto_version}
|
|
"""
|
|
)
|
|
|
|
path.write_text(
|
|
f"""[GLOBAL]
|
|
PATH: ./
|
|
PING_TIME: 1
|
|
MAX_MISSED: 3
|
|
USE_ACL: {global_use_acl_text}
|
|
REG_ACL: PERMIT:ALL
|
|
SUB_ACL: {global_sub_acl}
|
|
TGID_TS1_ACL: {global_tg1_acl}
|
|
TGID_TS2_ACL: {global_tg2_acl}
|
|
GEN_STAT_BRIDGES: False
|
|
ALLOW_NULL_PASSPHRASE: True
|
|
ANNOUNCEMENT_LANGUAGES: en_GB
|
|
SERVER_ID: 9990
|
|
DATA_GATEWAY: False
|
|
VALIDATE_SERVER_IDS: False
|
|
DEBUG_BRIDGES: False
|
|
ENABLE_API: False
|
|
|
|
[REPORTS]
|
|
REPORT: False
|
|
REPORT_INTERVAL: 60
|
|
REPORT_PORT: 0
|
|
REPORT_CLIENTS: 127.0.0.1
|
|
|
|
[LOGGER]
|
|
LOG_FILE: /dev/null
|
|
LOG_HANDLERS: console
|
|
LOG_LEVEL: INFO
|
|
LOG_NAME: FreeDMR-Test
|
|
|
|
[ALIASES]
|
|
TRY_DOWNLOAD: False
|
|
PATH: ./
|
|
PEER_FILE:
|
|
SUBSCRIBER_FILE:
|
|
TGID_FILE:
|
|
LOCAL_SUBSCRIBER_FILE:
|
|
STALE_DAYS: 1
|
|
SUB_MAP_FILE:
|
|
SERVER_ID_FILE:
|
|
CHECKSUM_FILE:
|
|
KEYS_FILE:
|
|
|
|
[ALLSTAR]
|
|
ENABLED: False
|
|
USER: test
|
|
PASS: test
|
|
SERVER: 127.0.0.1
|
|
PORT: 5038
|
|
NODE: 0
|
|
{''.join(systems)}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class UdpCapture:
|
|
packet: bytes
|
|
sockaddr: tuple[str, int]
|
|
received_at: float
|
|
fields: dict[str, object] = field(init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self.fields = parse_udp_dmr_fields(self.packet)
|
|
|
|
|
|
class HbpRepeater:
|
|
def __init__(self, master_port: int, radio_id: int, timeout: float = 2.0) -> None:
|
|
self.master = ("127.0.0.1", master_port)
|
|
self.radio_id = bytes_4(radio_id)
|
|
self.timeout = timeout
|
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.sock.bind(("127.0.0.1", 0))
|
|
self.sock.settimeout(timeout)
|
|
self.captures: list[UdpCapture] = []
|
|
|
|
@property
|
|
def sockaddr(self) -> tuple[str, int]:
|
|
return self.sock.getsockname()
|
|
|
|
def close(self) -> None:
|
|
self.sock.close()
|
|
|
|
def send(self, packet: bytes) -> None:
|
|
self.sock.sendto(packet, self.master)
|
|
|
|
def recv(self, timeout: float | None = None) -> UdpCapture:
|
|
old_timeout = self.sock.gettimeout()
|
|
if timeout is not None:
|
|
self.sock.settimeout(timeout)
|
|
try:
|
|
packet, sockaddr = self.sock.recvfrom(4096)
|
|
finally:
|
|
if timeout is not None:
|
|
self.sock.settimeout(old_timeout)
|
|
capture = UdpCapture(packet, sockaddr, time.monotonic())
|
|
self.captures.append(capture)
|
|
return capture
|
|
|
|
def drain(self, seconds: float = 0.2) -> list[UdpCapture]:
|
|
deadline = time.monotonic() + seconds
|
|
captures = []
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return captures
|
|
try:
|
|
captures.append(self.recv(timeout=remaining))
|
|
except TimeoutError:
|
|
return captures
|
|
except socket.timeout:
|
|
return captures
|
|
|
|
def login(self, startup_timeout: float = 8.0) -> None:
|
|
deadline = time.monotonic() + startup_timeout
|
|
while True:
|
|
self.send(RPTL + self.radio_id)
|
|
try:
|
|
challenge = self.recv(timeout=min(self.timeout, max(0.1, deadline - time.monotonic())))
|
|
break
|
|
except (TimeoutError, socket.timeout):
|
|
if time.monotonic() >= deadline:
|
|
raise
|
|
if not challenge.packet.startswith(RPTACK):
|
|
raise AssertionError(f"expected RPTACK challenge, got {challenge.packet!r}")
|
|
|
|
self.send(RPTK + self.radio_id)
|
|
auth = self.recv()
|
|
if not auth.packet.startswith(RPTACK):
|
|
raise AssertionError(f"expected RPTACK auth, got {auth.packet!r}")
|
|
|
|
self.send(RPTC + repeater_config_packet(self.radio_id))
|
|
configured = self.recv()
|
|
if not configured.packet.startswith(RPTACK):
|
|
raise AssertionError(f"expected RPTACK config, got {configured.packet!r}")
|
|
|
|
def ping(self) -> None:
|
|
self.send(RPTPING + b"\x00\x00\x00" + self.radio_id)
|
|
pong = self.recv()
|
|
if not pong.packet.startswith(MSTPONG):
|
|
raise AssertionError(f"expected MSTPONG, got {pong.packet!r}")
|
|
|
|
def send_dmr(self, packet: PacketSpec) -> None:
|
|
self.send(packet.data())
|
|
|
|
def replay_fixture(
|
|
self,
|
|
fixture: RecordedPacketFixture,
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
) -> None:
|
|
fixture.replay(
|
|
self.send,
|
|
cadence_seconds=cadence_seconds,
|
|
impairment=impairment,
|
|
)
|
|
|
|
def send_stream(
|
|
self,
|
|
packets: list[PacketSpec],
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
) -> None:
|
|
send_scheduled_packets(
|
|
self.send,
|
|
[packet.data() for packet in packets],
|
|
cadence_seconds=cadence_seconds,
|
|
impairment=impairment,
|
|
)
|
|
|
|
|
|
class FbpPeer:
|
|
def __init__(self, obp_port: int, network_id: int, timeout: float = 2.0) -> None:
|
|
self.master = ("127.0.0.1", obp_port)
|
|
self.network_id = network_id
|
|
self.timeout = timeout
|
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.sock.bind(("127.0.0.1", 0))
|
|
self.sock.settimeout(timeout)
|
|
self.captures: list[UdpCapture] = []
|
|
|
|
@property
|
|
def sockaddr(self) -> tuple[str, int]:
|
|
return self.sock.getsockname()
|
|
|
|
def close(self) -> None:
|
|
self.sock.close()
|
|
|
|
def send(self, packet: bytes) -> None:
|
|
self.sock.sendto(packet, self.master)
|
|
|
|
def recv(self, timeout: float | None = None) -> UdpCapture:
|
|
old_timeout = self.sock.gettimeout()
|
|
if timeout is not None:
|
|
self.sock.settimeout(timeout)
|
|
try:
|
|
packet, sockaddr = self.sock.recvfrom(4096)
|
|
finally:
|
|
if timeout is not None:
|
|
self.sock.settimeout(old_timeout)
|
|
capture = UdpCapture(packet, sockaddr, time.monotonic())
|
|
self.captures.append(capture)
|
|
return capture
|
|
|
|
def drain(self, seconds: float = 0.2) -> list[UdpCapture]:
|
|
deadline = time.monotonic() + seconds
|
|
captures = []
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return captures
|
|
try:
|
|
captures.append(self.recv(timeout=remaining))
|
|
except TimeoutError:
|
|
return captures
|
|
except socket.timeout:
|
|
return captures
|
|
|
|
def recv_dmre(self, timeout: float = 2.0) -> UdpCapture:
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise socket.timeout("timed out waiting for DMRE")
|
|
capture = self.recv(timeout=remaining)
|
|
if capture.packet[:4] == DMRE:
|
|
return capture
|
|
|
|
def recv_opcode(self, opcode: bytes, timeout: float = 2.0) -> UdpCapture:
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise socket.timeout(f"timed out waiting for {opcode!r}")
|
|
capture = self.recv(timeout=remaining)
|
|
if capture.packet[: len(opcode)] == opcode:
|
|
return capture
|
|
|
|
def send_bcka(self) -> None:
|
|
self.send(fbp_control_packet(BCKA))
|
|
|
|
def send_bcve(self, version: int = FBP_VERSION) -> None:
|
|
self.send(fbp_control_packet(BCVE, bytes([version])))
|
|
|
|
def send_invalid_bcve(self, version: int = FBP_VERSION) -> None:
|
|
self.send(fbp_control_packet(BCVE, bytes([version]), corrupt_hash=True))
|
|
|
|
def send_bcst(self) -> None:
|
|
self.send(fbp_control_packet(BCST))
|
|
|
|
def send_bcsq(self, tgid: int | bytes, stream_id: int | bytes) -> None:
|
|
self.send(fbp_control_packet(BCSQ, bytes_3(tgid) + bytes_4(stream_id)))
|
|
|
|
def send_invalid_bcsq(self, tgid: int | bytes, stream_id: int | bytes) -> None:
|
|
self.send(
|
|
fbp_control_packet(
|
|
BCSQ,
|
|
bytes_3(tgid) + bytes_4(stream_id),
|
|
corrupt_hash=True,
|
|
)
|
|
)
|
|
|
|
def send_fbp(
|
|
self,
|
|
packet: PacketSpec,
|
|
*,
|
|
network_id: int | bytes | None = None,
|
|
fbp_version: int = FBP_VERSION,
|
|
source_server: int | bytes = 9991,
|
|
source_rptr: int | bytes = 1001,
|
|
hops: int = 0,
|
|
timestamp_ns: int | None = None,
|
|
corrupt_hash: bool = False,
|
|
) -> None:
|
|
self.send(
|
|
fbp_packet_from_spec(
|
|
packet,
|
|
network_id=self.network_id if network_id is None else network_id,
|
|
fbp_version=fbp_version,
|
|
source_server=source_server,
|
|
source_rptr=source_rptr,
|
|
hops=hops,
|
|
timestamp_ns=timestamp_ns,
|
|
corrupt_hash=corrupt_hash,
|
|
)
|
|
)
|
|
|
|
def send_obp_v1(
|
|
self,
|
|
packet: PacketSpec,
|
|
*,
|
|
network_id: int | bytes | None = None,
|
|
corrupt_hash: bool = False,
|
|
) -> None:
|
|
self.send(
|
|
obp_v1_packet_from_spec(
|
|
packet,
|
|
network_id=self.network_id if network_id is None else network_id,
|
|
corrupt_hash=corrupt_hash,
|
|
)
|
|
)
|
|
|
|
def replay_fixture(
|
|
self,
|
|
fixture: RecordedPacketFixture,
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
) -> None:
|
|
fixture.replay(
|
|
self.send,
|
|
cadence_seconds=cadence_seconds,
|
|
impairment=impairment,
|
|
)
|
|
|
|
def send_fbp_stream(
|
|
self,
|
|
packets: list[PacketSpec],
|
|
*,
|
|
cadence_seconds: float | None = None,
|
|
impairment: LinkImpairment | None = None,
|
|
network_id: int | bytes | None = None,
|
|
fbp_version: int = FBP_VERSION,
|
|
source_server: int | bytes = 9991,
|
|
source_rptr: int | bytes = 1001,
|
|
hops: int = 0,
|
|
timestamp_ns: int | None = None,
|
|
corrupt_hash: bool = False,
|
|
) -> None:
|
|
raw_packets = [
|
|
fbp_packet_from_spec(
|
|
packet,
|
|
network_id=self.network_id if network_id is None else network_id,
|
|
fbp_version=fbp_version,
|
|
source_server=source_server,
|
|
source_rptr=source_rptr,
|
|
hops=hops,
|
|
timestamp_ns=timestamp_ns,
|
|
corrupt_hash=corrupt_hash,
|
|
)
|
|
for packet in packets
|
|
]
|
|
send_scheduled_packets(
|
|
self.send,
|
|
raw_packets,
|
|
cadence_seconds=cadence_seconds,
|
|
impairment=impairment,
|
|
)
|
|
|
|
|
|
class FreeDmrProcess:
|
|
def __init__(self, repo_root: Path, config_path: Path, python_executable: str) -> None:
|
|
self.repo_root = repo_root
|
|
self.config_path = config_path
|
|
self.python_executable = python_executable
|
|
self.proc: subprocess.Popen | None = None
|
|
self._output_lines: list[str] = []
|
|
self._output_lock = threading.Lock()
|
|
self._reader: threading.Thread | None = None
|
|
|
|
def __enter__(self):
|
|
self.proc = subprocess.Popen(
|
|
[self.python_executable, "bridge_master.py", "-c", str(self.config_path), "-l", "INFO"],
|
|
cwd=str(self.repo_root),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
self._reader = threading.Thread(target=self._read_output, daemon=True)
|
|
self._reader.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
if self.proc is None:
|
|
return
|
|
if self.proc.poll() is None:
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
self.proc.wait(timeout=5)
|
|
if self.proc.stdout is not None:
|
|
self.proc.stdout.close()
|
|
if self._reader is not None:
|
|
self._reader.join(timeout=1)
|
|
|
|
def _read_output(self) -> None:
|
|
if self.proc is None or self.proc.stdout is None:
|
|
return
|
|
for line in self.proc.stdout:
|
|
with self._output_lock:
|
|
self._output_lines.append(line)
|
|
|
|
def output(self) -> str:
|
|
with self._output_lock:
|
|
return "".join(self._output_lines)
|
|
|
|
def wait_for_log(self, text: str, timeout: float = 2.0) -> str:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
output = self.output()
|
|
if text in output:
|
|
return output
|
|
time.sleep(0.05)
|
|
raise AssertionError(f"log text {text!r} not found in output:\n{self.output()}")
|
|
|
|
def wait_for_start(self, timeout: float = 8.0) -> None:
|
|
assert self.proc is not None
|
|
deadline = time.monotonic() + timeout
|
|
ready_at = time.monotonic() + 0.5
|
|
while time.monotonic() < deadline:
|
|
if self.proc.poll() is not None:
|
|
raise RuntimeError("FreeDMR exited before startup:\n" + self.output())
|
|
# bridge_master does not provide a dedicated readiness signal. Give
|
|
# Twisted enough time to bind loopback UDP sockets, then let the
|
|
# first client login be the real readiness check.
|
|
if time.monotonic() >= ready_at:
|
|
return
|
|
time.sleep(0.05)
|
|
raise TimeoutError("FreeDMR did not reach startup wait window")
|
|
|
|
|
|
class UdpBlackBoxScenario:
|
|
def __init__(
|
|
self,
|
|
repo_root: Path | None = None,
|
|
*,
|
|
global_use_acl: bool = False,
|
|
global_sub_acl: str = "PERMIT:ALL",
|
|
global_tg1_acl: str = "PERMIT:ALL",
|
|
global_tg2_acl: str = "PERMIT:ALL",
|
|
ts1_static: str = "",
|
|
ts2_static: str = "91",
|
|
dial_a_tg: bool = True,
|
|
dynamic_tg_routing: bool = True,
|
|
fbp_systems: dict[str, int] | None = None,
|
|
fbp_proto_versions: dict[str, int] | None = None,
|
|
) -> None:
|
|
self.repo_root = repo_root or Path(__file__).resolve().parents[2]
|
|
self.tempdir = tempfile.TemporaryDirectory(prefix="freedmr-udp-test-")
|
|
self.config_path = Path(self.tempdir.name) / "freedmr-test.cfg"
|
|
self.system_ports = {"MASTER-A": free_udp_port(), "MASTER-B": free_udp_port()}
|
|
self.fbp_network_ids = fbp_systems or {}
|
|
self.fbp_proto_versions = fbp_proto_versions or {}
|
|
self.fbp_system_ports = {name: free_udp_port() for name in self.fbp_network_ids}
|
|
self.fbp_peers: dict[str, FbpPeer] = {}
|
|
self.process: FreeDmrProcess | None = None
|
|
self.deps = DependencySandbox(self.repo_root)
|
|
self.config_options = {
|
|
"global_use_acl": global_use_acl,
|
|
"global_sub_acl": global_sub_acl,
|
|
"global_tg1_acl": global_tg1_acl,
|
|
"global_tg2_acl": global_tg2_acl,
|
|
"ts1_static": ts1_static,
|
|
"ts2_static": ts2_static,
|
|
"dial_a_tg": dial_a_tg,
|
|
"dynamic_tg_routing": dynamic_tg_routing,
|
|
}
|
|
|
|
def __enter__(self):
|
|
python_executable = self.deps.resolve_python()
|
|
self.fbp_peers = {
|
|
name: FbpPeer(self.fbp_system_ports[name], network_id)
|
|
for name, network_id in self.fbp_network_ids.items()
|
|
}
|
|
write_bridge_master_config(
|
|
self.config_path,
|
|
self.system_ports,
|
|
fbp_system_ports=self.fbp_system_ports,
|
|
fbp_target_ports={
|
|
name: peer.sockaddr[1] for name, peer in self.fbp_peers.items()
|
|
},
|
|
fbp_network_ids=self.fbp_network_ids,
|
|
fbp_proto_versions=self.fbp_proto_versions,
|
|
**self.config_options,
|
|
)
|
|
self.process = FreeDmrProcess(self.repo_root, self.config_path, python_executable)
|
|
self.process.__enter__()
|
|
self.process.wait_for_start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
if self.process is not None:
|
|
self.process.__exit__(exc_type, exc, tb)
|
|
for peer in self.fbp_peers.values():
|
|
peer.close()
|
|
self.deps.cleanup()
|
|
self.tempdir.cleanup()
|
|
|
|
def repeater(self, system_name: str, radio_id: int) -> HbpRepeater:
|
|
return HbpRepeater(self.system_ports[system_name], radio_id)
|
|
|
|
def fbp_peer(self, system_name: str) -> FbpPeer:
|
|
return self.fbp_peers[system_name]
|