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

103 строки
5.5 KiB
Python

"""Маршрутизация эфира: кому пересылать принятый кадр.
Сервер намеренно простой — несколько хотспотов во внутренней сети:
• групповой вызов на разрешённую группу → всем остальным подключённым хотспотам;
• приватный вызов → только тому хотспоту, где абонента слышали последним;
• вызов на группу, которой нет в списке → никуда (в панели будет видно отказ).
Здесь же переписывается peer_id кадра под каждого получателя — см. retarget().
"""
from __future__ import annotations
import time
from .analyzer import CALL_GROUP
from .log import get_logger
log = get_logger(__name__)
RADIO_LOC_TTL = 15 * 60 # сколько помним, через какой хотспот слышали абонента
def retarget(raw: bytes, peer_id: int) -> bytes:
"""Подставить в кадр номер ПОЛУЧАТЕЛЯ (байты 11-14).
Смысл поля — «ретранслятор, которого касается этот кадр на этом соединении»,
поэтому принимающий хотспот должен увидеть там свой номер. Так делают HBlink3
(`send_peer`) и BrandMeister. MMDVMHost поле на приёме не проверяет, но
DMRGateway на многих Pi-Star стоит между ним и сетью, так что делаем как эталон.
"""
if len(raw) < 15:
return raw
return raw[:11] + peer_id.to_bytes(4, "big") + raw[15:]
class Router:
def __init__(self, cfg: dict, state) -> None:
self.cfg = cfg
self.state = state
self._radio_loc: dict[int, tuple[int, float]] = {} # dmr_id -> (peer_id, когда)
# ── группы из конфига ───────────────────────────────────────────────────
def allowed(self, tg: int, slot: int) -> bool:
for g in self.cfg.get("groups", []):
if int(g["tg"]) == tg and int(g.get("slot", slot)) == slot:
return True
return False
def group_names(self) -> dict[int, str]:
return {int(g["tg"]): g.get("name", "") for g in self.cfg.get("groups", [])}
# ── где слышали абонента (для приватных вызовов) ────────────────────────
def set_radio_loc(self, dmr_id: int, peer_id: int) -> None:
self._radio_loc[dmr_id] = (peer_id, time.time())
def clear_radio_loc(self, dmr_id: int, peer_id: int) -> None:
cur = self._radio_loc.get(dmr_id)
if cur and cur[0] == peer_id:
del self._radio_loc[dmr_id]
def locate(self, dmr_id: int) -> int | None:
cur = self._radio_loc.get(dmr_id)
if cur is None:
return None
peer_id, when = cur
if time.time() - when > RADIO_LOC_TTL:
del self._radio_loc[dmr_id]
return None
return peer_id
def radio_locs(self) -> dict[int, int]:
return {rid: pid for rid, (pid, _) in self._radio_loc.items()}
def active_calls(self) -> list:
return [] # состояние вызовов ведёт State; здесь пусто для совместимости
# ── собственно маршрут ──────────────────────────────────────────────────
def route(self, peer_id: int, dmr, connected: list[int]) -> list[int]:
# запоминаем, через какой хотспот слышали передающего
if dmr.src:
self.set_radio_loc(dmr.src, peer_id)
parrot_tg = int(self.cfg.get("parrot", {}).get("tg", 9990))
if dmr.dst == parrot_tg:
return [] # попугай отвечает сам, напрямую вызывавшему
if dmr.call_type == CALL_GROUP:
if not self.allowed(dmr.dst, dmr.slot):
return []
# всем, кроме источника: возврат кадра отправителю вызывает петлю в эфире
return [pid for pid in connected if pid != peer_id]
# Приватный вызов — туда, где абонента слышали последним. Если не слышали
# никогда (рация ещё не выходила в эфир) — рассылаем всем, иначе вызов молча
# уходит в никуда. Так же поступает HBlink4 («user cache with broadcast
# fallback»). Лишний трафик тут копеечный: хотспотов единицы.
target = self.locate(dmr.dst)
if target is None:
log.debug("приват %s→%s: абонент не известен, рассылаю всем", dmr.src, dmr.dst)
return [pid for pid in connected if pid != peer_id]
if target == peer_id:
return [] # абонент на том же хотспоте — эфир и так его слышит
return [target] if target in connected else []