панель: многостраничная, 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
Этот коммит содержится в:
+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:
|
||||
|
||||
Ссылка в новой задаче
Block a user