Ядро протокола форкнуто из 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
103 строки
5.5 KiB
Python
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 []
|