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,525 @@
|
||||
"""DMRhub v2 · M2 — Homebrew-мастер: UDP-петля, рукопожатие, реестр пиров.
|
||||
|
||||
Реализует серверную сторону протокола Homebrew (HBP) для хотспотов MMDVMHost:
|
||||
RPTL(login) → RPTACK+salt → RPTK(auth sha256(salt+pass)) → RPTACK → RPTC(config)
|
||||
→ RPTACK → RPTPING↔MSTPONG (keepalive) + DMRD (эфир). RPTCL/таймаут → отключение.
|
||||
|
||||
Ядро `HomebrewMaster.handle(data, addr)` — чистое (без сокета): принимает датаграмму,
|
||||
возвращает список ответов-датаграмм. Это позволяет юнит-тестировать без сети.
|
||||
Аутентификация — через колбэк `password_for(peer_id) → bytes|None` (позже: Postgres,
|
||||
поиск хотспота по dmr_id + secret, [[dmrhub-id-base-essid]]). DMRD-маршрут — M3 (хук).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from .analyzer import analyze, HBP, DMR, Kind, auth_hash, TAG_ACK, TAG_NAK, TAG_PONG
|
||||
from .log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# человекочитаемые причины отклонения гвардом (для лога панели)
|
||||
_GUARD_REASON_RU = {
|
||||
"peer_spoof": "подмена peer_id (кадр не от владельца этого ID)",
|
||||
"own_id": "src совпал с peer_id (эхо/петля собственного ID)",
|
||||
"src_out_of_range": "src вне допустимого диапазона DMR ID",
|
||||
"unregistered_src": "незарегистрированный абонент (закрытая сеть)",
|
||||
}
|
||||
|
||||
|
||||
class PeerState:
|
||||
NEW = "new"
|
||||
CHALLENGE = "challenge" # выслан salt, ждём RPTK
|
||||
AUTHED = "authed" # пароль принят, ждём RPTC
|
||||
CONNECTED = "connected" # сконфигурирован, в сети
|
||||
|
||||
|
||||
@dataclass
|
||||
class Peer:
|
||||
peer_id: int
|
||||
addr: tuple
|
||||
state: str = PeerState.NEW
|
||||
salt: bytes = b""
|
||||
password: bytes = b""
|
||||
config: dict | None = None
|
||||
last_seen: float = 0.0
|
||||
logins: int = 0
|
||||
transport: str = "homebrew" # homebrew | hytera
|
||||
extra: dict = field(default_factory=dict) # адреса портов Hytera и пр.
|
||||
|
||||
|
||||
def _ack(peer_id: int, extra: bytes = b"") -> bytes:
|
||||
"""RPTACK + 4-байт peer_id (или salt/иное в extra)."""
|
||||
return TAG_ACK + (extra if extra else peer_id.to_bytes(4, "big"))
|
||||
|
||||
|
||||
def _nak(peer_id: int) -> bytes:
|
||||
return TAG_NAK + peer_id.to_bytes(4, "big")
|
||||
|
||||
|
||||
def _pong(peer_id: int) -> bytes:
|
||||
return TAG_PONG + peer_id.to_bytes(4, "big")
|
||||
|
||||
|
||||
class HomebrewMaster:
|
||||
"""Ядро мастера. `password_for(peer_id)` → пароль (bytes) или None (неизвестен).
|
||||
`on_dmrd(peer, dmr)` — хук маршрутизации эфира (M3). `now`/`rand` — для тестов."""
|
||||
|
||||
def __init__(self, password_for: Callable[[int], bytes | None],
|
||||
router=None, guard=None, data_hub=None, ta_injector=None,
|
||||
on_dmrd: Callable[[Peer, DMR], None] | None = None,
|
||||
send: Callable[[bytes, tuple], None] | None = None,
|
||||
encode_for: Callable[[Peer, DMR], bytes] | None = None,
|
||||
timeout: float = 90.0,
|
||||
on_unknown: Callable[[int], None] | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
rand: Callable[[int], bytes] = os.urandom):
|
||||
self.password_for = password_for
|
||||
# Незнакомый пир → просим перечитать реестр (как было в v1). Регистрация создаёт
|
||||
# вирт. хотспот приложения, а Store перечитывается раз в 30с — без этого новичок
|
||||
# до полуминуты получает отказы и может сдаться.
|
||||
self.on_unknown = on_unknown
|
||||
self._unknown_at: float = 0.0
|
||||
self.router = router
|
||||
self.guard = guard
|
||||
self.data_hub = data_hub # M5: сборка SMS/GPS/TA из эфира
|
||||
self.ta_injector = ta_injector # подмешивание TA (позывной из БД) в эфир без TA
|
||||
self.bridges: dict[int, object] = {} # peer_id → Bridge (внешние сети)
|
||||
self.on_dmrd = on_dmrd
|
||||
self.encode_for = encode_for # (target_peer, dmr) → байты в формате транспорта цели
|
||||
self._send = send # доставка кадров чужим пирам (asyncio: transport.sendto)
|
||||
self._send_hytera = None # доставка IPSC Hytera-пирам (сокет 50001); ставит hytera_run
|
||||
self.timeout = timeout
|
||||
self._now = now
|
||||
self._rand = rand
|
||||
self.peers: dict[int, Peer] = {}
|
||||
self._last_guard_drop: dict[int, int] = {} # peer_id → последний дропнутый stream (лог по потоку)
|
||||
self._last_call_log: dict[int, int] = {} # peer_id → последний залогированный stream (CALL start)
|
||||
self._last_spoof_at: float = 0.0 # троттл лога анти-спуфа DMRD (по времени, без роста dict)
|
||||
# Кандидатские логины: RPTL по живой сессии, но с ДРУГОГО адреса (смена IP/NAT либо
|
||||
# спуф). Живую сессию не трогаем — держим челлендж отдельно и переносим пира на новый
|
||||
# адрес ТОЛЬКО после верного RPTK. Ограничены по TTL и размеру (без роста под флудом).
|
||||
self._pending: dict[int, dict] = {}
|
||||
self._pending_ttl: float = 30.0
|
||||
self._pending_max: int = 64
|
||||
self._call_stats: dict[tuple, dict] = {} # (peer_id,slot) → статистика текущего потока (длит./BER/loss/тип)
|
||||
|
||||
# ── обработка датаграммы: вернуть список ответов ──
|
||||
def handle(self, data: bytes, addr: tuple) -> list[bytes]:
|
||||
pkt = analyze(data)
|
||||
if isinstance(pkt, DMR):
|
||||
return self._on_dmrd(pkt, addr)
|
||||
if not isinstance(pkt, HBP):
|
||||
return []
|
||||
pid = pkt.peer_id
|
||||
if pkt.kind == Kind.LOGIN:
|
||||
return self._on_login(pid, addr)
|
||||
if pkt.kind == Kind.AUTH:
|
||||
return self._on_auth(pid, addr, pkt.auth or b"")
|
||||
if pkt.kind == Kind.CONFIG:
|
||||
return self._on_config(pid, addr, pkt.config)
|
||||
if pkt.kind == Kind.PING:
|
||||
return self._on_ping(pid, addr)
|
||||
if pkt.kind in (Kind.CLOSE,):
|
||||
# АНТИ-СПУФ управляющих (аудит 2026-07-25): RPTCL принимался БЕЗ проверки
|
||||
# адреса — знающий peer_id (он выводится из публичного DMR ID) выбивал чужой
|
||||
# хотспот из сети одним UDP-пакетом, а повторяя — держал офлайн. Для app-пиров
|
||||
# это дополнительно стирало last-heard, и приваты абоненту переставали доходить.
|
||||
# Логика та же, что у DMRD: закрыть сессию может только тот адрес, с которого
|
||||
# пир прошёл рукопожатие. У app/bridge-пиров addr фиктивный (("app", pid) и
|
||||
# т.п.) и по UDP они не закрываются вовсе — их снимают свои обработчики.
|
||||
p = self.peers.get(pid)
|
||||
if p is None:
|
||||
return []
|
||||
if p.transport in ("app", "bridge"):
|
||||
log.info("RPTCL peer=%s addr=%s — игнор: пир транспорта %s не закрывается по UDP",
|
||||
pid, addr, p.transport)
|
||||
return []
|
||||
if p.addr != addr:
|
||||
log.warning("RPTCL peer=%s с ЧУЖОГО адреса %s (пир на %s) — игнор",
|
||||
pid, addr, p.addr)
|
||||
return []
|
||||
self.peers.pop(pid, None)
|
||||
if pid % 100 == 99 and self.router is not None:
|
||||
self.router.clear_radio_loc(pid // 100, pid)
|
||||
return []
|
||||
return []
|
||||
|
||||
def _on_login(self, pid: int, addr: tuple) -> list[bytes]:
|
||||
pw = self.password_for(pid)
|
||||
if pw is None:
|
||||
# v1-поведение: refresh + NAK. Пир ретрайнет через несколько секунд и пройдёт,
|
||||
# не дожидаясь планового перечитывания Store.
|
||||
# ТРОТТЛИНГ ОБЯЗАТЕЛЕН: без него спам чужими peer_id = шторм запросов в PG.
|
||||
if self.on_unknown is not None:
|
||||
t = self._now()
|
||||
if t - self._unknown_at > 10.0:
|
||||
self._unknown_at = t
|
||||
try:
|
||||
self.on_unknown(pid)
|
||||
except Exception:
|
||||
log.exception("on_unknown")
|
||||
log.warning("LOGIN отказ: незарегистрированный peer=%s addr=%s (реестр перечитан)", pid, addr)
|
||||
return [_nak(pid)] # незарегистрированный ID — отказ
|
||||
salt = self._rand(4)
|
||||
p = self.peers.get(pid)
|
||||
# ЗАЩИТА ЖИВОЙ СЕССИИ (аудит 2026-07-25): раньше RPTL с ЛЮБОГО адреса сразу
|
||||
# перетирал p.addr/p.state работающего пира — его DMRD после этого отбрасывались
|
||||
# анти-спуфом, эфир хотспота пропадал (для этого даже пароль не требовался).
|
||||
# Теперь логин с чужого адреса по живой сессии = кандидат: сессию не трогаем,
|
||||
# переносим пира на новый адрес только после верного RPTK (см. _on_auth).
|
||||
if (p is not None and p.state == PeerState.CONNECTED and p.addr != addr
|
||||
and p.transport not in ("app", "bridge")):
|
||||
now = self._now()
|
||||
for k in [k for k, v in self._pending.items() if now - v["ts"] > self._pending_ttl]:
|
||||
self._pending.pop(k, None)
|
||||
if len(self._pending) < self._pending_max:
|
||||
self._pending[pid] = {"addr": addr, "salt": salt, "password": pw, "ts": now}
|
||||
log.info("LOGIN peer=%s с НОВОГО адреса %s (живая сессия на %s) → челлендж-кандидат",
|
||||
pid, addr, p.addr)
|
||||
return [_ack(pid, salt)]
|
||||
p = p or Peer(pid, addr)
|
||||
p.addr = addr
|
||||
p.salt = salt
|
||||
p.password = pw
|
||||
p.state = PeerState.CHALLENGE
|
||||
p.last_seen = self._now()
|
||||
p.logins += 1
|
||||
self.peers[pid] = p
|
||||
log.info("LOGIN peer=%s addr=%s → выслан salt (челлендж)", pid, addr)
|
||||
return [_ack(pid, salt)] # RPTACK + salt
|
||||
|
||||
def _on_auth(self, pid: int, addr: tuple, sha_recv: bytes) -> list[bytes]:
|
||||
# Кандидатский логин (RPTL с нового адреса по живой сессии): проверяем пароль,
|
||||
# НЕ трогая работающего пира. Верный → переносим сессию на новый адрес (легитимная
|
||||
# смена IP/NAT). Неверный → выбрасываем только кандидата, эфир пира не страдает.
|
||||
pend = self._pending.get(pid)
|
||||
if pend is not None and pend["addr"] == addr:
|
||||
self._pending.pop(pid, None)
|
||||
if sha_recv[:32] != auth_hash(pend["salt"], pend["password"]):
|
||||
log.warning("AUTH отказ (кандидат): неверный пароль peer=%s addr=%s — "
|
||||
"живая сессия сохранена", pid, addr)
|
||||
return [_nak(pid)]
|
||||
p = self.peers.get(pid) or Peer(pid, addr)
|
||||
old = p.addr
|
||||
p.addr = addr
|
||||
p.salt = pend["salt"]
|
||||
p.password = pend["password"]
|
||||
p.state = PeerState.AUTHED
|
||||
p.last_seen = self._now()
|
||||
p.logins += 1
|
||||
self.peers[pid] = p
|
||||
log.info("AUTH ok (кандидат): peer=%s переехал %s → %s", pid, old, addr)
|
||||
return [_ack(pid)]
|
||||
p = self.peers.get(pid)
|
||||
if p is None or p.state != PeerState.CHALLENGE:
|
||||
return [_nak(pid)]
|
||||
expected = auth_hash(p.salt, p.password)
|
||||
if sha_recv[:32] != expected:
|
||||
self.peers.pop(pid, None) # неверный пароль — сброс
|
||||
log.warning("AUTH отказ: неверный пароль peer=%s addr=%s", pid, addr)
|
||||
return [_nak(pid)]
|
||||
p.state = PeerState.AUTHED
|
||||
p.last_seen = self._now()
|
||||
log.info("AUTH ok: хотспот peer=%s addr=%s подтвердил пароль → жду конфиг (RPTC)", pid, addr)
|
||||
return [_ack(pid)]
|
||||
|
||||
def _on_config(self, pid: int, addr: tuple, config: dict | None) -> list[bytes]:
|
||||
p = self.peers.get(pid)
|
||||
if p is None or p.state not in (PeerState.AUTHED, PeerState.CONNECTED):
|
||||
return [_nak(pid)]
|
||||
p.config = config
|
||||
p.state = PeerState.CONNECTED
|
||||
p.last_seen = self._now()
|
||||
cs = (config or {}).get("callsign", "?") if config else "?"
|
||||
log.info("CONFIG peer=%s callsign=%s → CONNECTED (пиров: %d)",
|
||||
pid, cs, len(self.connected_peers()))
|
||||
# Онлайн-приложение (essid 99 = виртуальная рация) регистрирует свою локацию
|
||||
# СРАЗУ на коннекте, чтобы приватный вызов доходил, даже если оно ещё не
|
||||
# передавало (route() ставит локацию лишь при передаче). База = pid//100.
|
||||
if pid % 100 == 99 and self.router is not None:
|
||||
self.router.set_radio_loc(pid // 100, pid)
|
||||
return [_ack(pid)]
|
||||
|
||||
def _on_ping(self, pid: int, addr: tuple) -> list[bytes]:
|
||||
p = self.peers.get(pid)
|
||||
if p is None or p.state != PeerState.CONNECTED:
|
||||
return [_nak(pid)]
|
||||
# Адрес пинга должен совпадать с адресом сессии (аудит 2026-07-25). Раньше пинг с
|
||||
# ЛЮБОГО адреса продлевал сессию — при смене порта NAT хотспот получал PONG и считал
|
||||
# себя в сети, а его DMRD резались анти-спуфом: пир «онлайн», но глухонемой навсегда.
|
||||
# NAK заставляет его перелогиниться (и переехать на новый адрес по паролю, см.
|
||||
# кандидатский путь в _on_login/_on_auth). Спуферу NAK не даёт ничего: чужая сессия
|
||||
# не продлевается и не рвётся.
|
||||
if p.addr != addr and p.transport not in ("app", "bridge"):
|
||||
log.info("PING peer=%s с чужого/нового адреса %s (сессия на %s) → NAK, пусть перелогинится",
|
||||
pid, addr, p.addr)
|
||||
return [_nak(pid)]
|
||||
p.last_seen = self._now()
|
||||
return [_pong(pid)]
|
||||
|
||||
def _on_dmrd(self, dmr: DMR, addr: tuple) -> list[bytes]:
|
||||
# АНТИ-СПУФ (C1): DMRD принимаем ТОЛЬКО с адреса, с которого пир прошёл рукопожатие
|
||||
# RPTL/RPTK (пароль). Сами DMRD-кадры не подписаны — единственная привязка к
|
||||
# аутентификации это исходный addr. Без этой проверки любой хост интернета, зная
|
||||
# peer_id хотспота (7 цифр, светятся в lastheard) и любой зарегистрированный src,
|
||||
# инжектит голос/данные в любой TG и ПЕРЕХВАТЫВАЕТ приваты (router на каждом кадре
|
||||
# пишет _radio_loc[src]=peer). peers keyed by peer_id → addr==p.addr гарантирует,
|
||||
# что кадр пришёл именно от владельца этого peer_id (и peer_id не подставлен).
|
||||
p = self.peers.get(dmr.peer_id)
|
||||
if p is None or p.state != PeerState.CONNECTED or p.addr != addr:
|
||||
t = self._now()
|
||||
if t - self._last_spoof_at > 5.0: # троттл по времени (без роста dict под флудом)
|
||||
self._last_spoof_at = t
|
||||
log.warning("DMRD отброшен (спуф/чужой addr): peer=%s src=%s→%s от %s (ожидался %s)",
|
||||
dmr.peer_id, dmr.src, dmr.dst, addr, p.addr if p else None)
|
||||
return []
|
||||
self._route_dmr(dmr) # Homebrew DMRD-путь = общий маршрут
|
||||
return []
|
||||
|
||||
# общий вход для эфира из ЛЮБОГО транспорта (Homebrew DMRD или мост Hytera→DMR)
|
||||
def ingest_dmr(self, dmr: DMR) -> None:
|
||||
"""Прогнать готовый DMR-кадр через гвард+маршрут (для Hytera-транспорта)."""
|
||||
self._route_dmr(dmr)
|
||||
|
||||
def _route_dmr(self, dmr: DMR) -> None:
|
||||
p = self.peers.get(dmr.peer_id)
|
||||
if p is None or p.state != PeerState.CONNECTED:
|
||||
return
|
||||
p.last_seen = self._now()
|
||||
# гвард НЕ трогает мосты: их трафик фильтрует сам мост (only_registered), а не мастер
|
||||
# ([[dmrhub-closed-network-src]]). Иначе SMK/ADN-юзеры без нашей регистрации режутся.
|
||||
if self.guard is not None and p.transport != "bridge":
|
||||
allow, reason = self.guard.check(dmr.peer_id, dmr)
|
||||
if not allow:
|
||||
if self._last_guard_drop.get(dmr.peer_id) != dmr.stream_id: # лог по потоку, не по кадру
|
||||
self._last_guard_drop[dmr.peer_id] = dmr.stream_id
|
||||
tgt = ("TG %s" % dmr.dst) if dmr.call_type == "group" else ("приват %s" % dmr.dst)
|
||||
log.warning("ГВАРД отклонил кадр: %s — src=%s → %s через хотспот peer=%s "
|
||||
"(код=%s, поток %08x)", _GUARD_REASON_RU.get(reason, reason),
|
||||
dmr.src, tgt, dmr.peer_id, reason, dmr.stream_id)
|
||||
return
|
||||
if dmr.is_voice_start and self._last_call_log.get(dmr.peer_id) != dmr.stream_id:
|
||||
self._last_call_log[dmr.peer_id] = dmr.stream_id # лог РАЗ В ПОТОК (не на каждый повторный заголовок late-entry)
|
||||
tgt = ("группа TG %s" % dmr.dst) if dmr.call_type == "group" else ("приват на %s" % dmr.dst)
|
||||
log.info("ЭФИР старт: src=%s → %s · слот TS%s · хотспот peer=%s (поток %08x)",
|
||||
dmr.src, tgt, dmr.slot, dmr.peer_id, dmr.stream_id)
|
||||
ctype = "voice" if dmr.is_voice_start else "data"
|
||||
if self.data_hub is not None and getattr(self.data_hub, "on_call", None):
|
||||
try:
|
||||
self.data_hub.on_call({"src": dmr.src, "dst": dmr.dst,
|
||||
"is_group": dmr.call_type == "group", "slot": dmr.slot,
|
||||
"ctype": ctype})
|
||||
except Exception:
|
||||
log.debug("on_call emit не выполнен", exc_info=True)
|
||||
self._call_stats[(dmr.peer_id, dmr.slot)] = {
|
||||
"stream_id": dmr.stream_id, "src": dmr.src, "dst": dmr.dst,
|
||||
"is_group": dmr.call_type == "group", "slot": dmr.slot, "ctype": ctype,
|
||||
"start": self._now(), "last": self._now(), "frames": 0, "ber_sum": 0.0, "ber_n": 0}
|
||||
if self.data_hub is not None: # M5: собрать SMS/GPS/TA
|
||||
self.data_hub.feed(dmr)
|
||||
try: # статистика потока (длит./BER/loss) + финал по терминатору
|
||||
self._accumulate_call(dmr)
|
||||
except Exception:
|
||||
log.debug("accumulate_call сбой", exc_info=True)
|
||||
# TA-инжект: рация без своего TA → подмешиваем позывной из БД в embedded LC.
|
||||
# ПОСЛЕ data_hub.feed (разбор видит оригинал источника) и ДО рассылки/tap —
|
||||
# чтобы и пиры, и voice_bridge, и Hytera-encode_for получили кадр с TA.
|
||||
if self.ta_injector is not None:
|
||||
try:
|
||||
self.ta_injector.process(dmr)
|
||||
except Exception:
|
||||
log.debug("ta_inject сбой", exc_info=True)
|
||||
if self.on_dmrd:
|
||||
self.on_dmrd(p, dmr)
|
||||
if self.router is not None:
|
||||
connected = [pid for pid, pp in self.peers.items() if pp.state == PeerState.CONNECTED]
|
||||
targets = self.router.route(dmr.peer_id, dmr, connected)
|
||||
for tid in targets:
|
||||
if tid in self.bridges: # мост во внешнюю сеть
|
||||
try:
|
||||
self.bridges[tid].feed_from_master(dmr)
|
||||
except Exception:
|
||||
log.exception("мост %s: ошибка отправки", tid)
|
||||
continue
|
||||
tgt = self.peers.get(tid)
|
||||
if tgt and tgt.transport == "app":
|
||||
continue # виртуальный app-пир: доставку делает voice_bridge (tap)
|
||||
if tgt and self._send:
|
||||
frame = self.encode_for(tgt, dmr) if self.encode_for else dmr.raw
|
||||
if getattr(tgt, "transport", None) == "hytera" and self._send_hytera:
|
||||
self._send_hytera(frame, tgt.addr) # IPSC через Hytera-сокет 50001
|
||||
else:
|
||||
self._send(frame, tgt.addr)
|
||||
if targets and (dmr.is_voice_start or dmr.data_type == 6):
|
||||
log.debug("route peer=%s src=%s→%s → %d получателям %s",
|
||||
dmr.peer_id, dmr.src, dmr.dst, len(targets), targets)
|
||||
|
||||
def _accumulate_call(self, dmr) -> None:
|
||||
key = (dmr.peer_id, dmr.slot)
|
||||
st = self._call_stats.get(key)
|
||||
if st is None or st["stream_id"] != dmr.stream_id:
|
||||
return
|
||||
st["frames"] += 1
|
||||
st["last"] = self._now()
|
||||
if dmr.ber is not None:
|
||||
st["ber_sum"] += dmr.ber; st["ber_n"] += 1
|
||||
if dmr.is_terminator:
|
||||
self._finalize_call(key)
|
||||
|
||||
def _finalize_call(self, key) -> None:
|
||||
st = self._call_stats.pop(key, None)
|
||||
if not st or st["frames"] <= 0:
|
||||
return
|
||||
dur_ms = int(max(0.0, st["last"] - st["start"]) * 1000)
|
||||
ber = round(st["ber_sum"] / st["ber_n"], 1) if st["ber_n"] else None
|
||||
loss = None
|
||||
if st["ctype"] == "voice" and dur_ms > 0:
|
||||
expected = int(round(dur_ms / 60.0)) + 1 # голос ~60 мс/кадр
|
||||
if expected > 0:
|
||||
loss = max(0, min(100, round((expected - st["frames"]) / expected * 100)))
|
||||
if self.data_hub is not None and getattr(self.data_hub, "on_call_end", None):
|
||||
try:
|
||||
self.data_hub.on_call_end({"src": st["src"], "dst": st["dst"],
|
||||
"is_group": st["is_group"], "slot": st["slot"],
|
||||
"ctype": st["ctype"], "duration_ms": dur_ms,
|
||||
"ber": ber, "loss": loss})
|
||||
except Exception:
|
||||
log.debug("on_call_end emit не выполнен", exc_info=True)
|
||||
|
||||
# ── обслуживание: отключить протухшие пиры ──
|
||||
def reap(self) -> list[int]:
|
||||
"""Удалить пиры без активности дольше timeout. Вернуть их id.
|
||||
Мосты (transport=bridge) НЕ реапим: они исходящие клиенты и не пингуют наш мастер,
|
||||
их last_seen не обновляется — иначе мост-пир пропадает из маршрута (эфир us→сеть рвётся)."""
|
||||
now_ts = self._now() # финализировать потоки без терминатора (потеря UDP)
|
||||
for k in list(self._call_stats):
|
||||
if now_ts - self._call_stats[k]["last"] > 1.5:
|
||||
self._finalize_call(k)
|
||||
cutoff = self._now() - self.timeout
|
||||
dead = [pid for pid, p in self.peers.items()
|
||||
if p.last_seen < cutoff and p.transport not in ("bridge", "app")]
|
||||
for pid in dead:
|
||||
self.peers.pop(pid, None)
|
||||
if pid % 100 == 99 and self.router is not None:
|
||||
self.router.clear_radio_loc(pid // 100, pid) # app ушёл — снять локацию
|
||||
# Динамическую подписку НЕ снимаем: для отключённого пира она безвредна (subscribers()
|
||||
# фильтрует по connected) и переживает реконнект → приложению/хотспоту не нужен повторный
|
||||
# subscribe-берст (кёрчанк), который слышат все на TG. Подписка также в БД (dynamic_tg).
|
||||
return dead
|
||||
|
||||
def connected_peers(self) -> list[Peer]:
|
||||
return [p for p in self.peers.values() if p.state == PeerState.CONNECTED]
|
||||
|
||||
def hotspots_by_ip(self, ip: str) -> list[int]:
|
||||
"""peer_id подключённых homebrew-хотспотов с данным IP — для привязки P25/NXDN/YSF к
|
||||
аутентифицированному DMR-хотспоту (эти протоколы своей аутентификации не имеют).
|
||||
Пусто = IP неизвестен (не наш хотспот) → трафик не принимаем. Несколько = NAT
|
||||
(неоднозначно) → атрибутируем на общий шлюз, но принимаем (IP известен)."""
|
||||
return [pid for pid, p in self.peers.items()
|
||||
if p.state == PeerState.CONNECTED and p.transport == "homebrew"
|
||||
and p.addr and p.addr[0] == ip]
|
||||
|
||||
# ── виртуальный «хотспот» онлайн-приложения (id пользователя×100+essid) ──
|
||||
# Даёт системе единый вид: приложение онлайн как рация+хотспот; приватный поток
|
||||
# роутится к нему (router.locate). Доставку кадров делает voice_bridge (tap), _send пропускается.
|
||||
def add_app_peer(self, peer_id: int, callsign: str | None = None) -> "Peer | None":
|
||||
p = self.peers.get(peer_id)
|
||||
if p is not None and p.transport != "app":
|
||||
return None # на этом essid реальный хотспот — не трогаем
|
||||
if p is None:
|
||||
p = Peer(peer_id, ("app", peer_id))
|
||||
self.peers[peer_id] = p
|
||||
p.transport = "app"
|
||||
p.state = PeerState.CONNECTED
|
||||
p.last_seen = self._now()
|
||||
p.config = {"callsign": callsign, "location": "Приложение"}
|
||||
return p
|
||||
|
||||
def remove_app_peer(self, peer_id: int) -> None:
|
||||
p = self.peers.get(peer_id)
|
||||
if p is not None and p.transport == "app":
|
||||
self.peers.pop(peer_id, None)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Срез состояния для статус-панели (пиры, счётчики, гвард)."""
|
||||
peers = []
|
||||
for p in self.peers.values():
|
||||
cfg = p.config or {}
|
||||
peers.append({
|
||||
"peer_id": p.peer_id, "state": p.state, "transport": p.transport,
|
||||
"addr": f"{p.addr[0]}:{p.addr[1]}" if p.addr else None,
|
||||
"callsign": cfg.get("callsign"), "location": cfg.get("location"),
|
||||
"last_seen": round(self._now() - p.last_seen, 1),
|
||||
})
|
||||
peers.sort(key=lambda x: (x["state"] != "connected", x["peer_id"]))
|
||||
bridges = []
|
||||
for nid, br in self.bridges.items():
|
||||
cfg = getattr(br, "cfg", None)
|
||||
cl = getattr(br, "client", None)
|
||||
bridges.append({
|
||||
"network_id": nid,
|
||||
"name": getattr(cfg, "name", "?"),
|
||||
"type": getattr(cfg, "type", "?"),
|
||||
"host": f"{getattr(cfg,'host','?')}:{getattr(cfg,'port','?')}",
|
||||
"state": getattr(cl, "state", "idle") if cl else "idle",
|
||||
"maps": [{"our": m.our_tg, "remote": m.remote_tg, "slot": m.our_slot}
|
||||
for m in getattr(cfg, "maps", [])],
|
||||
})
|
||||
return {
|
||||
"peers_total": len(self.peers),
|
||||
"peers_connected": len([p for p in self.connected_peers() if p.transport not in ("bridge", "app")]),
|
||||
"apps_online": len([p for p in self.peers.values() if p.transport == "app"]),
|
||||
"peers": [p for p in peers if p["transport"] not in ("bridge", "app")],
|
||||
"bridges": bridges,
|
||||
"guard_dropped": dict(self.guard.dropped) if self.guard else {},
|
||||
"active_calls": self.router.active_calls() if self.router else [],
|
||||
# рация → peer_id последнего приёма (для карты: кого «слышит» каждый хотспот)
|
||||
"radio_locs": ({str(k): v for k, v in self.router.radio_locs().items()}
|
||||
if self.router else {}),
|
||||
}
|
||||
|
||||
|
||||
# ── asyncio UDP-обвязка (сетевой слой поверх чистого ядра) ──
|
||||
import asyncio
|
||||
|
||||
|
||||
class _MasterProtocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, master: HomebrewMaster):
|
||||
self.master = master
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
self.master._send = transport.sendto # пересылка кадров чужим пирам
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple):
|
||||
try:
|
||||
for reply in self.master.handle(data, addr):
|
||||
self.transport.sendto(reply, addr)
|
||||
except Exception: # один битый пакет не роняет мастер
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
pass
|
||||
|
||||
def connection_lost(self, exc):
|
||||
pass
|
||||
|
||||
|
||||
async def run_server(master: HomebrewMaster, host: str = "0.0.0.0",
|
||||
port: int = 62031, reap_interval: float = 30.0):
|
||||
"""Запустить UDP-мастер: слушать host:port, периодически чистить протухшие пиры."""
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: _MasterProtocol(master), local_addr=(host, port))
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(reap_interval)
|
||||
master.reap()
|
||||
finally:
|
||||
transport.close()
|
||||
Ссылка в новой задаче
Block a user