Панель: • шесть страниц с маршрутизацией по хэшу: Обзор, 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
150 строки
5.9 KiB
Python
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())
|