Files
dmr-server/tests/test_e2e.py
T
viktorиClaude Opus 4.8 8e3b9144c3 DMR-сервер: мастер Homebrew на stdlib для Pi-Star
Ядро протокола форкнуто из dmrhubv2 (~1300 строк из 31700): analyzer, server,
parrot, dmr_voice, bptc. Отрезаны портал, БД, мосты, алерты, динамические
подписки. Своё: конфиг, состояние, маршрутизатор, панель, установщик.

Ноль зависимостей — на Debian 13 действует PEP 668, pip в систему запрещён.

Две правки под Pi-Star, которых в исходном коде не было:
 • peer_id переписывается на получателя (байты 11-14) через хук encode_for —
   так делают HBlink3/HBlink4 и BrandMeister, а DMRGateway на Pi-Star стоит
   между MMDVMHost и сетью;
 • приватный вызов при неизвестном абоненте уходит всем, а не в никуда
   (взято из HBlink4: user cache with broadcast fallback).

docs/PROTOCOL.md фиксирует, что тест-вектор auth-хеша в спецификации 2015 года
ОШИБОЧЕН (посчитан от ASCII-строки соли вместо четырёх байт). Правка кода по
нему сломала бы вход всем хотспотам.

tests/test_e2e.py: 15 проверок настоящими UDP-кадрами MMDVMHost. Все проходят.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
2026-07-28 23:32:04 +09:00

256 строки
11 KiB
Python

"""Сквозной тест: имитируем хотспот 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()))