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
Этот коммит содержится в:
@@ -0,0 +1,255 @@
|
||||
"""Сквозной тест: имитируем хотспот 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()))
|
||||
Ссылка в новой задаче
Block a user