Ядро протокола форкнуто из 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
256 строки
11 KiB
Python
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()))
|