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.
FreeDMR/tests/harness/deterministic.py

496 lines
14 KiB

"""In-process deterministic packet harness for bridge_master tests.
This module is test-only. It avoids UDP sockets and replaces production
network sends with capture functions while leaving production modules unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
import copy
import importlib
import unittest
DMRD = b"DMRD"
HBPF_VOICE = 0x0
HBPF_VOICE_SYNC = 0x1
HBPF_DATA_SYNC = 0x2
HBPF_SLT_VHEAD = 0x1
HBPF_SLT_VTERM = 0x2
ID_MAX = 16776415
PEER_MAX = 4294967295
def require_bridge_master():
"""Import bridge_master or skip tests when runtime deps are unavailable."""
try:
return importlib.import_module("bridge_master")
except ModuleNotFoundError as exc:
raise unittest.SkipTest(
f"bridge_master runtime dependency is not installed: {exc.name}"
) from exc
def bytes_3(value: int | bytes) -> bytes:
if isinstance(value, bytes):
if len(value) != 3:
raise ValueError("expected exactly 3 bytes")
return value
return int(value).to_bytes(3, "big")
def bytes_4(value: int | bytes) -> bytes:
if isinstance(value, bytes):
if len(value) != 4:
raise ValueError("expected exactly 4 bytes")
return value
return int(value).to_bytes(4, "big")
def int_id(value: int | bytes) -> int:
if isinstance(value, int):
return value
return int.from_bytes(value, "big")
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, object]:
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],
"ber": packet[53:54],
"rssi": packet[54:55],
}
@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"
delay: float = 0.0
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_args(self) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes]:
return (
bytes_4(self.peer_id),
bytes_3(self.rf_src),
bytes_3(self.dst_id),
self.seq & 0xFF,
self.slot,
self.call_type,
self.frame_type,
self.dtype_vseq,
bytes_4(self.stream_id),
self.data(),
)
def decoded_obp_args(
self,
packet_hash: bytes = b"",
hops: bytes = b"",
source_server: int | bytes = 9990,
source_rptr: int | bytes = 0,
) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes, bytes, bytes, bytes, bytes, bytes, bytes]:
return (
bytes_4(self.peer_id),
bytes_3(self.rf_src),
bytes_3(self.dst_id),
self.seq & 0xFF,
self.slot,
self.call_type,
self.frame_type,
self.dtype_vseq,
bytes_4(self.stream_id),
self.data(),
packet_hash,
hops,
bytes_4(source_server),
self.ber,
self.rssi,
bytes_4(source_rptr),
)
@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, object] = 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 [packet for packet in self.packets if packet.target_system == system]
class ReportCapture:
def __init__(self) -> None:
self.events: list[bytes] = []
def send_bridgeEvent(self, data: bytes) -> None:
self.events.append(data)
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 FakeReactor:
def __init__(self) -> None:
self.later: list[tuple[float, object, tuple, dict]] = []
self.thread_calls: list[tuple[object, tuple, dict]] = []
def callLater(self, delay, func, *args, **kwargs):
self.later.append((delay, func, args, kwargs))
return SimpleNamespace(cancel=lambda: None, active=lambda: True)
def callInThread(self, func, *args, **kwargs):
self.thread_calls.append((func, args, kwargs))
def callFromThread(self, func, *args, **kwargs):
return func(*args, **kwargs)
class FakeTransport:
def __init__(self) -> None:
self.writes: list[tuple[bytes, tuple[str, int] | None]] = []
def write(self, packet: bytes, sockaddr=None) -> None:
self.writes.append((packet, sockaddr))
def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> dict:
config = {
"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,
"DATA_GATEWAY": False,
"VALIDATE_SERVER_IDS": False,
},
"REPORTS": {"REPORT": False},
"ALIASES": {"PATH": "./", "SUB_MAP_FILE": ""},
"ALLSTAR": {"ENABLED": False},
"SYSTEMS": {},
"_SUB_IDS": {},
"_PEER_IDS": {},
"_LOCAL_SUBSCRIBER_IDS": {},
"_SERVER_IDS": {},
"CHECKSUMS": {},
}
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,
"DIAL_A_TG": True,
"DYNAMIC_TG_ROUTING": True,
"TS1_STATIC": "",
"TS2_STATIC": "",
"DEFAULT_DIAL_TS1": 0,
"DEFAULT_DIAL_TS2": 0,
"DEFAULT_REFLECTOR": 0,
"GENERATOR": 0,
"ANNOUNCEMENT_LANGUAGE": "en_GB",
"ALLOW_UNREG_ID": True,
"PROXY_CONTROL": False,
"OVERRIDE_IDENT_TG": False,
"PEERS": {},
}
return config
def add_openbridge_system(config: dict, name: str = "OBP-1", network_id: int = 1) -> dict:
config["SYSTEMS"][name] = {
"MODE": "OPENBRIDGE",
"ENABLED": True,
"NETWORK_ID": bytes_4(network_id),
"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": False,
"VER": 5,
}
return config
def active_bridge(
name: str,
tg_id: int,
entries: tuple[tuple[str, int], ...],
timeout_minutes: int = 1,
) -> dict[str, list[dict]]:
tg_bytes = bytes_3(tg_id)
return {
name: [
{
"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:
def __init__(self, config: dict | None = None, bridges: dict | None = None) -> None:
self.config = config or minimal_config()
self.bridges = bridges or {}
self.clock = FakeClock()
self.capture = PacketCapture()
self.reports: dict[str, ReportCapture] = {}
self.transports: dict[str, FakeTransport] = {}
self.reactor = FakeReactor()
self.bm = None
self._saved_attrs: dict[str, object] = {}
self._saved_systems: dict | None = None
def __enter__(self):
self.bm = require_bridge_master()
self._saved_systems = dict(self.bm.systems)
for attr in (
"CONFIG",
"BRIDGES",
"SUB_MAP",
"peer_ids",
"subscriber_ids",
"talkgroup_ids",
"local_subscriber_ids",
"server_ids",
"checksums",
"reactor",
"time",
"words",
):
if hasattr(self.bm, attr):
self._saved_attrs[attr] = getattr(self.bm, attr)
self.bm.CONFIG = self.config
self.bm.BRIDGES = copy.deepcopy(self.bridges)
self.bm.SUB_MAP = {}
self.bm.peer_ids = {}
self.bm.subscriber_ids = {}
self.bm.talkgroup_ids = {}
self.bm.local_subscriber_ids = {}
self.bm.server_ids = {}
self.bm.checksums = {}
self.bm.words = {"en_GB": {"silence": b"", "busy": b"", "notlinked": b"", "linkedto": b"", "to": b""}}
self.bm.reactor = self.reactor
self.bm.time = self.clock.time
self.bm.systems.clear()
for system_name, system_config in self.config["SYSTEMS"].items():
report = ReportCapture()
self.reports[system_name] = report
if system_config["MODE"] == "MASTER":
system = self.bm.routerHBP(system_name, self.config, report)
elif system_config["MODE"] == "OPENBRIDGE":
system = self.bm.routerOBP(system_name, self.config, report)
else:
continue
system.send_system = self.capture.recorder(system_name)
transport = FakeTransport()
system.transport = transport
self.transports[system_name] = transport
self.bm.systems[system_name] = system
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self.bm is None:
return
self.bm.systems.clear()
if self._saved_systems is not None:
self.bm.systems.update(self._saved_systems)
for attr in (
"CONFIG",
"BRIDGES",
"SUB_MAP",
"peer_ids",
"subscriber_ids",
"talkgroup_ids",
"local_subscriber_ids",
"server_ids",
"checksums",
"reactor",
"time",
"words",
):
if attr in self._saved_attrs:
setattr(self.bm, attr, self._saved_attrs[attr])
elif hasattr(self.bm, attr):
delattr(self.bm, attr)
@property
def systems(self):
return self.bm.systems
@property
def bridge_state(self):
return self.bm.BRIDGES
def inject_hbp(self, system_name: str, packet: PacketSpec) -> None:
self.systems[system_name].dmrd_received(*packet.decoded_args())
def inject_obp(self, system_name: str, packet: PacketSpec) -> None:
self.systems[system_name].dmrd_received(*packet.decoded_obp_args())
def inject_datagram(self, system_name: str, packet: bytes, sockaddr=("127.0.0.1", 50000)) -> None:
self.systems[system_name].datagramReceived(packet, sockaddr)
def register_peer(
self,
system_name: str,
peer_id: int | bytes = 1001,
sockaddr=("127.0.0.1", 50000),
callsign: bytes = b"TEST ",
) -> bytes:
peer = bytes_4(peer_id)
self.config["SYSTEMS"][system_name]["PEERS"][peer] = {
"CONNECTION": "YES",
"SOCKADDR": sockaddr,
"CALLSIGN": callsign,
"RADIO_ID": peer,
"LAST_PING": self.clock.time(),
}
return peer

Powered by TurnKey Linux.