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