"""Сквозной тест: имитируем хотспот Pi-Star (MMDVMHost) против нашего мастера. Гоняем настоящие UDP-датаграммы через настоящий asyncio-мастер — так проверяется вся цепочка сразу: разбор кадров, sha256-рукопожатие, разбор RPTC, keepalive, маршрутизация группового вызова между двумя хотспотами и переписывание peer_id. Кадры собираются ровно так, как их шлёт MMDVMHost (DMRDirectNetwork.cpp): RPTL 8б · RPTK 40б · RPTC 302б · RPTPING 11б · DMRD 55б. Запуск: python3 tests/test_e2e.py """ from __future__ import annotations import asyncio import hashlib import os import socket import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) os.environ["DMRSRV_CONFIG"] = "/tmp/dmrsrv-test-config.json" from dmrsrv.router import Router, retarget # noqa: E402 from dmrsrv.server import HomebrewMaster, PeerState, run_server # noqa: E402 PASS = b"testpass" PEER_A, PEER_B = 2501001, 2501002 TG = 9 ok_n = fail_n = 0 def ok(msg: str) -> None: global ok_n ok_n += 1 print(f" \033[32mOK\033[0m {msg}") def bad(msg: str) -> None: global fail_n fail_n += 1 print(f" \033[31mFAIL\033[0m {msg}") def rptc_config(peer_id: int, callsign: str) -> bytes: """302 байта ровно как MMDVMHost: RPTC + id(4) + 294 байта ASCII фикс-ширины.""" body = ( f"{callsign:<8.8s}" # 8 позывной f"{438000000:09d}" # 9 RX Гц f"{430000000:09d}" # 9 TX Гц f"{1:02d}" # 2 мощность f"{1:02d}" # 2 colorcode f"{'51.66600':8.8s}" # 8 широта f"{'-1.170000':9.9s}" # 9 долгота f"{10:03d}" # 3 высота f"{'Test Location':<20.20s}" f"{'Test Repeater':<19.19s}" f"4" # 1 слоты: 4 = симплекс/хотспот f"{'https://example.org':<124.124s}" f"{'20250101_MMDVM':<40.40s}" f"{'MMDVM_MMDVM_HS_Hat':<40.40s}" ) assert len(body) == 294, f"конфиг {len(body)} байт вместо 294" return b"RPTC" + peer_id.to_bytes(4, "big") + body.encode("ascii") def dmrd(peer_id: int, src: int, dst: int, *, slot: int = 2, group: bool = True, seq: int = 0, stream: bytes = b"\x11\x22\x33\x44", frame_type: int = 0b10, dtype: int = 0x01) -> bytes: """55 байт: 53 ядро + BER + RSSI (как MMDVMHost).""" bits = (0x80 if slot == 2 else 0) | (0 if group else 0x40) | (frame_type << 4) | dtype return (b"DMRD" + bytes([seq]) + src.to_bytes(3, "big") + dst.to_bytes(3, "big") + peer_id.to_bytes(4, "big") + bytes([bits]) + stream + bytes(range(33)) + b"\x00\x00") class Hotspot: """Минимальный клиент Homebrew — ровно то, что делает MMDVMHost.""" def __init__(self, peer_id: int, callsign: str, port: int): self.peer_id, self.callsign, self.port = peer_id, callsign, port self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.s.bind(("127.0.0.1", 0)) self.s.settimeout(2.0) def send(self, data: bytes) -> None: self.s.sendto(data, ("127.0.0.1", self.port)) def recv(self) -> bytes: return self.s.recv(2048) def drain(self) -> int: """Выбросить всё, что уже пришло. Без этого следующая проверка вычитает кадр от предыдущей и делает ложный вывод.""" self.s.settimeout(0.05) n = 0 try: while True: self.s.recv(2048) n += 1 except (OSError, socket.timeout): pass return n def login(self) -> tuple[bool, str]: self.send(b"RPTL" + self.peer_id.to_bytes(4, "big")) r = self.recv() if r[:6] != b"RPTACK": return False, f"на RPTL пришло {r[:6]!r}, ждали RPTACK" salt = r[6:10] # sha256(СЫРЫЕ 4 байта соли + пароль) — не hex-строка (см. docs/PROTOCOL.md) self.send(b"RPTK" + self.peer_id.to_bytes(4, "big") + hashlib.sha256(salt + PASS).digest()) r = self.recv() if r[:6] != b"RPTACK": return False, f"на RPTK пришло {r[:6]!r} — пароль не принят" if r[6:10] != self.peer_id.to_bytes(4, "big"): return False, "в RPTACK после RPTK не наш peer_id" self.send(rptc_config(self.peer_id, self.callsign)) r = self.recv() if r[:6] != b"RPTACK": return False, f"на RPTC пришло {r[:6]!r}" return True, "ok" async def main() -> int: port = 62000 + (os.getpid() % 900) cfg = {"groups": [{"tg": TG, "name": "Тест", "slot": 2}], "parrot": {"enabled": False, "tg": 9990}} router = Router(cfg, state=None) master = HomebrewMaster( password_for=lambda pid: PASS, router=router, encode_for=lambda tgt, d: retarget(d.raw, tgt.peer_id), ) server = asyncio.create_task(run_server(master, "127.0.0.1", port)) await asyncio.sleep(0.3) a = Hotspot(PEER_A, "TEST1", port) b = Hotspot(PEER_B, "TEST2", port) print("==> рукопожатие (RPTL → RPTK → RPTC)") for hs in (a, b): good, why = await asyncio.get_running_loop().run_in_executor(None, hs.login) ok(f"хотспот {hs.peer_id} вошёл") if good else bad(f"{hs.peer_id}: {why}") p = master.peers.get(PEER_A) if p and p.state == PeerState.CONNECTED: ok("мастер считает хотспот подключённым") else: bad(f"состояние хотспота: {p.state if p else 'нет в реестре'}") if p and (p.config or {}).get("callsign") == "TEST1": ok("позывной из RPTC разобран верно") else: bad(f"позывной разобран как {(p.config or {}).get('callsign') if p else '—'!r}") if p and (p.config or {}).get("package_id") == "MMDVM_MMDVM_HS_Hat": ok("модель хотспота из RPTC разобрана (смещение 262)") else: bad(f"package_id: {(p.config or {}).get('package_id') if p else '—'!r}") print("==> неверный пароль отвергается") # ВАЖНО: recv() блокирующий — звать его напрямую из корутины нельзя, он застопорит # event loop, и мастер не успеет ответить. Только через run_in_executor. c = Hotspot(2501003, "BAD", port) c.send(b"RPTL" + (2501003).to_bytes(4, "big")) await asyncio.get_running_loop().run_in_executor(None, c.recv) c.send(b"RPTK" + (2501003).to_bytes(4, "big") + hashlib.sha256(b"wrong").digest()) r = await asyncio.get_running_loop().run_in_executor(None, c.recv) ok("неверный пароль → MSTNAK") if r[:6] == b"MSTNAK" else bad(f"пришло {r[:6]!r}, ждали MSTNAK") print("==> keepalive") a.send(b"RPTPING" + PEER_A.to_bytes(4, "big")) r = await asyncio.get_running_loop().run_in_executor(None, a.recv) if r[:7] == b"MSTPONG" and r[7:11] == PEER_A.to_bytes(4, "big"): ok("RPTPING → MSTPONG с нашим peer_id") else: bad(f"на RPTPING пришло {r[:7]!r}") print("==> групповой вызов A → B") b.s.settimeout(2.0) a.send(dmrd(PEER_A, src=2501101, dst=TG)) try: got = await asyncio.get_running_loop().run_in_executor(None, b.recv) except socket.timeout: got = b"" if got[:4] == b"DMRD": ok("кадр доставлен второму хотспоту") if len(got) == 55: ok("длина кадра 55 байт (DMRGateway отбрасывает 53)") else: bad(f"длина кадра {len(got)}, а не 55") if int.from_bytes(got[11:15], "big") == PEER_B: ok("peer_id переписан на получателя (байты 11-14)") else: bad(f"peer_id остался {int.from_bytes(got[11:15], 'big')}, ждали {PEER_B}") if got[16:20] == b"\x11\x22\x33\x44": ok("stream_id скопирован дословно") else: bad(f"stream_id изменён: {got[16:20].hex()}") if got[20:53] == bytes(range(33)): ok("бёрст не тронут") else: bad("бёрст изменён при пересылке") else: bad("второй хотспот кадр не получил") print("==> отправителю кадр не возвращается") a.drain() a.s.settimeout(0.6) a.send(dmrd(PEER_A, src=2501101, dst=TG, seq=1, stream=b"\xaa\xbb\xcc\xdd")) try: echo = await asyncio.get_running_loop().run_in_executor(None, a.recv) except socket.timeout: echo = b"" ok("эха отправителю нет (иначе петля в эфире)") if echo[:4] != b"DMRD" \ else bad("кадр вернулся отправителю — будет петля") print("==> вызов на неразрешённую группу") b.drain() b.s.settimeout(0.6) a.send(dmrd(PEER_A, src=2501101, dst=777, seq=2, stream=b"\x01\x02\x03\x04")) try: leak = await asyncio.get_running_loop().run_in_executor(None, b.recv) except socket.timeout: leak = b"" if leak[:4] != b"DMRD": ok("TG777 нет в списке → не маршрутизируется") else: bad(f"кадр на неразрешённую группу ушёл (dst={int.from_bytes(leak[8:11], 'big')})") print("==> приватный вызов идёт туда, где слышали абонента") b.drain() router.set_radio_loc(2501202, PEER_B) b.s.settimeout(2.0) a.send(dmrd(PEER_A, src=2501101, dst=2501202, group=False, seq=3, stream=b"\x09\x08\x07\x06")) try: priv = await asyncio.get_running_loop().run_in_executor(None, b.recv) except socket.timeout: priv = b"" ok("приват доставлен на нужный хотспот") if priv[:4] == b"DMRD" \ else bad("приватный вызов не доставлен") server.cancel() await asyncio.gather(server, return_exceptions=True) print(f"\n \033[1m{ok_n} OK, {fail_n} FAIL\033[0m\n") return 0 if fail_n == 0 else 1 if __name__ == "__main__": sys.exit(asyncio.run(main()))