панель: многостраничная, 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
co-authored by Claude Opus 4.8
родитель 47cb85327a
Коммит cb4e8a9bb1
9 изменённых файлов: 1045 добавлений и 125 удалений
+26 -21
Просмотреть файл
@@ -15,7 +15,9 @@ 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
@@ -41,13 +43,14 @@ def _wire_state(master: HomebrewMaster, state: State) -> None:
sp.stage = {PeerState.NEW: "вход", PeerState.CHALLENGE: "пароль",
PeerState.AUTHED: "конфигурация",
PeerState.CONNECTED: "в сети"}.get(p.state, p.state)
sp.callsign = cfgp.get("callsign", "")
sp.location = cfgp.get("location", "")
sp.description = cfgp.get("description", "")
sp.software = cfgp.get("package_id", "") or cfgp.get("software_id", "")
sp.colorcode = cfgp.get("colorcode", "")
sp.rx_freq = cfgp.get("rx_freq", "")
sp.tx_freq = cfgp.get("tx_freq", "")
# Имена полей — ровно как у 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().
# Без пересчёта панель показывала «простой» равным времени в сети,
# то есть живой хотспот выглядел мёртвым.
@@ -67,6 +70,8 @@ async def _sync_loop(master: HomebrewMaster, interval: float = 1.0) -> None:
await asyncio.sleep(interval)
with contextlib.suppress(Exception):
master._state_sync()
with contextlib.suppress(Exception):
master._lastheard_sweep()
def main() -> int:
@@ -76,31 +81,31 @@ def main() -> int:
logmod.set_sink(state)
router = Router(cfg, state)
passphrase = cfg["passphrase"].encode("utf-8")
allow = {int(x) for x in cfg.get("peers_allowlist", [])}
def password_for(peer_id: int) -> bytes | None:
"""Пароль хотспота. Пустой список разрешённых = пускать всех, кто знает пароль."""
if allow and peer_id not in allow:
state.event("warn", f"хотспот {peer_id} не в списке разрешённых — отказ")
return None
return passphrase
guard = Guard(cfg, state)
master = HomebrewMaster(
password_for=password_for,
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)
parrot = None
if cfg.get("parrot", {}).get("enabled", True):
parrot = Parrot(master)
master.on_dmrd = lambda peer, dmr: parrot.feed(dmr)
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: