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,130 @@
|
||||
"""Общее состояние сервера: подключённые хотспоты, журнал событий, счётчики.
|
||||
|
||||
Читается из потока веб-панели, пишется из цикла asyncio, поэтому всё под
|
||||
одним замком. Объём данных крошечный (десятки хотспотов, сотни строк лога),
|
||||
так что простой Lock дешевле и надёжнее любых очередей.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Peer:
|
||||
"""Подключённый хотспот/ретранслятор."""
|
||||
|
||||
peer_id: int
|
||||
addr: tuple[str, int]
|
||||
callsign: str = ""
|
||||
rx_freq: str = ""
|
||||
tx_freq: str = ""
|
||||
colorcode: str = ""
|
||||
location: str = ""
|
||||
description: str = ""
|
||||
url: str = ""
|
||||
software: str = ""
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
last_seen: float = field(default_factory=time.time)
|
||||
# стадия рукопожатия: login -> auth -> config -> ready
|
||||
stage: str = "login"
|
||||
salt: int = 0
|
||||
rx_packets: int = 0
|
||||
tx_packets: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
return {
|
||||
"peer_id": self.peer_id,
|
||||
"addr": f"{self.addr[0]}:{self.addr[1]}",
|
||||
"callsign": self.callsign,
|
||||
"location": self.location,
|
||||
"description": self.description,
|
||||
"software": self.software,
|
||||
"colorcode": self.colorcode,
|
||||
"rx_freq": self.rx_freq,
|
||||
"tx_freq": self.tx_freq,
|
||||
"stage": self.stage,
|
||||
"online_for": int(now - self.connected_at),
|
||||
"idle": round(now - self.last_seen, 1),
|
||||
"rx_packets": self.rx_packets,
|
||||
"tx_packets": self.tx_packets,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Call:
|
||||
"""Идущая или завершившаяся передача."""
|
||||
|
||||
stream_id: int
|
||||
src_id: int
|
||||
dst_id: int
|
||||
slot: int
|
||||
group: bool
|
||||
peer_id: int
|
||||
started: float = field(default_factory=time.time)
|
||||
ended: float = 0.0
|
||||
frames: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
end = self.ended or time.time()
|
||||
return {
|
||||
"stream_id": self.stream_id,
|
||||
"src_id": self.src_id,
|
||||
"dst_id": self.dst_id,
|
||||
"slot": self.slot,
|
||||
"type": "группа" if self.group else "приват",
|
||||
"peer_id": self.peer_id,
|
||||
"started": self.started,
|
||||
"duration": round(end - self.started, 1),
|
||||
"frames": self.frames,
|
||||
"active": self.ended == 0.0,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_keep: int = 500) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.peers: dict[int, Peer] = {}
|
||||
self.log: deque[dict[str, Any]] = deque(maxlen=log_keep)
|
||||
self.calls: deque[Call] = deque(maxlen=50)
|
||||
self.active: dict[tuple[int, int], Call] = {} # (slot, stream_id) -> Call
|
||||
self.started = time.time()
|
||||
|
||||
# ── журнал ──────────────────────────────────────────────────────────────
|
||||
def event(self, kind: str, text: str) -> None:
|
||||
"""kind: info | call | peer | warn | error — панель красит по нему."""
|
||||
with self._lock:
|
||||
self.log.append({"t": time.time(), "kind": kind, "text": text})
|
||||
print(f"[{kind}] {text}", flush=True) # заодно в journalctl
|
||||
|
||||
# ── хотспоты ────────────────────────────────────────────────────────────
|
||||
def peer_add(self, peer: Peer) -> None:
|
||||
with self._lock:
|
||||
self.peers[peer.peer_id] = peer
|
||||
|
||||
def peer_del(self, peer_id: int) -> Peer | None:
|
||||
with self._lock:
|
||||
return self.peers.pop(peer_id, None)
|
||||
|
||||
def peer_get(self, peer_id: int) -> Peer | None:
|
||||
with self._lock:
|
||||
return self.peers.get(peer_id)
|
||||
|
||||
def peers_ready(self) -> list[Peer]:
|
||||
with self._lock:
|
||||
return [p for p in self.peers.values() if p.stage == "ready"]
|
||||
|
||||
# ── снимок для панели ───────────────────────────────────────────────────
|
||||
def snapshot(self, log_since: float = 0.0) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"uptime": int(time.time() - self.started),
|
||||
"peers": [p.as_dict() for p in self.peers.values()],
|
||||
"calls": [c.as_dict() for c in reversed(self.calls)],
|
||||
"log": [e for e in self.log if e["t"] > log_since],
|
||||
"now": time.time(),
|
||||
}
|
||||
Ссылка в новой задаче
Block a user