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.
496 lines
14 KiB
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
|