Files
dmr-server/dmrsrv/__main__.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

139 строки
5.4 KiB
Python

"""Точка входа: python3 -m dmrsrv
Связывает мастер, маршрутизатор, попугая, состояние и панель. Всё на stdlib —
на Debian 13 действует PEP 668, ставить что-либо через pip в систему нельзя,
а venv ради пары строк кода — лишний слой, который однажды сломается.
"""
from __future__ import annotations
import asyncio
import contextlib
import signal
import sys
from . import config as cfgmod
from . import log as logmod
from . import panel as panelmod
from .analyzer import TAG_MSTCL
from .parrot import Parrot
from .router import Router, retarget
from .server import HomebrewMaster, PeerState, run_server
from .state import Peer as StatePeer
from .state import State
def _wire_state(master: HomebrewMaster, state: State) -> None:
"""Зеркалим реестр пиров мастера в State для панели.
Мастер — форк из DMRhub, его не трогаем: снимаем состояние снаружи,
чтобы обновление форка не конфликтовало.
"""
def sync() -> None:
for pid, p in list(master.peers.items()):
sp = state.peer_get(pid)
cfgp = p.config or {}
if sp is None:
sp = StatePeer(peer_id=pid, addr=p.addr)
state.peer_add(sp)
sp.addr = p.addr
sp.stage = {PeerState.NEW: "вход", PeerState.CHALLENGE: "пароль",
PeerState.AUTHED: "конфигурация",
PeerState.CONNECTED: "в сети"}.get(p.state, p.state)
sp.callsign = cfgp.get("callsign", "")
sp.location = cfgp.get("location", "")
sp.description = cfgp.get("description", "")
sp.software = cfgp.get("package_id", "") or cfgp.get("software_id", "")
sp.colorcode = cfgp.get("colorcode", "")
sp.rx_freq = cfgp.get("rx_freq", "")
sp.tx_freq = cfgp.get("tx_freq", "")
for pid in list(state.peers):
if pid not in master.peers:
gone = state.peer_del(pid)
if gone:
state.event("peer", f"хотспот {pid} ({gone.callsign}) отключился")
master._state_sync = sync # зовём из фоновой задачи ниже
async def _sync_loop(master: HomebrewMaster, interval: float = 1.0) -> None:
while True:
await asyncio.sleep(interval)
with contextlib.suppress(Exception):
master._state_sync()
def main() -> int:
cfg = cfgmod.load()
state = State(log_keep=int(cfg.get("log_keep", 500)))
logmod.setup()
logmod.set_sink(state)
router = Router(cfg, state)
passphrase = cfg["passphrase"].encode("utf-8")
allow = {int(x) for x in cfg.get("peers_allowlist", [])}
def password_for(peer_id: int) -> bytes | None:
"""Пароль хотспота. Пустой список разрешённых = пускать всех, кто знает пароль."""
if allow and peer_id not in allow:
state.event("warn", f"хотспот {peer_id} не в списке разрешённых — отказ")
return None
return passphrase
master = HomebrewMaster(
password_for=password_for,
router=router,
# Подставляем номер ПОЛУЧАТЕЛЯ в байты 11-14. Так делают HBlink3/HBlink4
# и BrandMeister; DMRGateway на Pi-Star стоит между MMDVMHost и сетью.
encode_for=lambda tgt, dmr: retarget(dmr.raw, tgt.peer_id),
)
_wire_state(master, state)
parrot = None
if cfg.get("parrot", {}).get("enabled", True):
parrot = Parrot(master)
master.on_dmrd = lambda peer, dmr: parrot.feed(dmr)
state.event("info", f"попугай включён на TG{cfg['parrot'].get('tg', 9990)}")
def drop_peer(pid: int) -> bool:
p = master.peers.pop(pid, None)
if p is None:
return False
with contextlib.suppress(Exception):
if master._send:
master._send(TAG_MSTCL + pid.to_bytes(4, "big"), p.addr)
return True
panelmod.start(cfg, state, drop_peer)
state.event("info", f"мастер слушает UDP {cfg['bind']}:{cfg['port']}, "
f"групп в списке: {len(cfg.get('groups', []))}")
if not cfg.get("groups"):
state.event("warn", "групп нет — групповые вызовы никуда не пойдут, добавь в панели")
async def runner() -> None:
loop = asyncio.get_running_loop()
stop = loop.create_future()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, lambda: stop.done() or stop.set_result(None))
tasks = [
asyncio.create_task(run_server(master, cfg["bind"], int(cfg["port"]))),
asyncio.create_task(_sync_loop(master)),
]
await stop
state.event("info", "останавливаюсь")
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
try:
asyncio.run(runner())
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())