Files
viktorиClaude Opus 4.8 cb4e8a9bb1 панель: многостраничная, Last Heard, SSE, контроль доступа
Панель:
 • шесть страниц с маршрутизацией по хэшу: Обзор, Last Heard, Хотспоты,
   Группы, Доступ, Настройки;
 • журнал приходит живым потоком (SSE) вместо опроса раз в две секунды;
 • Last Heard: кто, куда, когда, сколько, через какой хотспот;
 • в таблице хотспотов — ВСЁ, что устройство сообщает в RPTC.

Контроль доступа (два независимых уровня, их легко перепутать):
 • хотспот пускается по паролю — у каждого может быть свой, плюс режим
   «только из списка» и отключение конкретного устройства;
 • абонент (поле src, владелец рации) допускается белым/чёрным списком с
   поддержкой диапазонов. Встроено в штатный хук guard ядра.

Два бага по дороге:
 • поля RPTC читались как colorcode/latitude/longitude, а разборщик отдаёт
   color_code/lat/lon — половина таблицы молча пустовала;
 • столбец «описание» на деле содержит СТРАНУ: именно туда Pi-Star кладёт
   country. Переименован.

tests/test_access.py 20/20, tests/test_panel.py 31/31 (включая наполнение
Last Heard настоящим рукопожатием и голосовым вызовом по UDP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
2026-07-29 01:32:46 +09:00

150 строки
5.9 KiB
Python

"""Точка входа: python3 -m dmrsrv
Связывает мастер, маршрутизатор, попугая, состояние и панель. Всё на stdlib —
на Debian 13 действует PEP 668, ставить что-либо через pip в систему нельзя,
а venv ради пары строк кода — лишний слой, который однажды сломается.
"""
from __future__ import annotations
import asyncio
import contextlib
import signal
import sys
import time
from . import config as cfgmod
from . import log as logmod
from . import panel as panelmod
from .access import Guard, make_password_for
from .analyzer import TAG_MSTCL
from .lastheard import LastHeard
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)
# Имена полей — ровно как у analyzer.parse_rptc_config. Раньше читались
# colorcode/latitude/longitude, которых там нет, и поля молча пустовали.
for fld in ("callsign", "location", "description", "url",
"software_id", "package_id", "rx_freq", "tx_freq",
"tx_power", "color_code", "lat", "lon", "height", "slots"):
val = cfgp.get(fld)
if val is not None:
setattr(sp, fld, val)
# Мастер считает время по monotonic, панель — по time.time().
# Без пересчёта панель показывала «простой» равным времени в сети,
# то есть живой хотспот выглядел мёртвым.
if p.last_seen:
sp.last_seen = time.time() - max(0.0, master._now() - p.last_seen)
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()
with contextlib.suppress(Exception):
master._lastheard_sweep()
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)
guard = Guard(cfg, state)
master = HomebrewMaster(
password_for=make_password_for(cfg, state),
router=router,
guard=guard,
# Подставляем номер ПОЛУЧАТЕЛЯ в байты 11-14. Так делают HBlink3/HBlink4
# и BrandMeister; DMRGateway на Pi-Star стоит между MMDVMHost и сетью.
encode_for=lambda tgt, dmr: retarget(dmr.raw, tgt.peer_id),
)
_wire_state(master, state)
lastheard = LastHeard(state)
master._lastheard_sweep = lastheard.sweep
parrot = Parrot(master) if cfg.get("parrot", {}).get("enabled", True) else None
if parrot is not None:
state.event("info", f"попугай включён на TG{cfg['parrot'].get('tg', 9990)}")
def on_dmrd(peer, dmr):
lastheard.feed(peer, dmr)
if parrot is not None:
parrot.feed(dmr)
master.on_dmrd = on_dmrd
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())