панель: многостраничная, 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 удалений
+47 -9
Просмотреть файл
@@ -6,6 +6,7 @@
"""
from __future__ import annotations
import queue
import threading
import time
from collections import deque
@@ -19,14 +20,22 @@ class Peer:
peer_id: int
addr: tuple[str, int]
# Всё, что хотспот присылает в RPTC. Имена — как у разборщика (analyzer),
# чтобы не переименовывать по дороге и не ловить пустые поля.
callsign: str = ""
rx_freq: str = ""
tx_freq: str = ""
colorcode: str = ""
rx_freq: int | None = None
tx_freq: int | None = None
tx_power: int | None = None
color_code: int | None = None
lat: float | None = None
lon: float | None = None
height: int | None = None
location: str = ""
description: str = ""
description: str = "" # Pi-Star кладёт сюда СТРАНУ
slots: int | None = None
url: str = ""
software: str = ""
software_id: str = ""
package_id: str = ""
connected_at: float = field(default_factory=time.time)
last_seen: float = field(default_factory=time.time)
# стадия рукопожатия: login -> auth -> config -> ready
@@ -42,11 +51,18 @@ class Peer:
"addr": f"{self.addr[0]}:{self.addr[1]}",
"callsign": self.callsign,
"location": self.location,
"description": self.description,
"software": self.software,
"colorcode": self.colorcode,
"country": self.description, # так это поле заполняет Pi-Star
"rx_freq": self.rx_freq,
"tx_freq": self.tx_freq,
"tx_power": self.tx_power,
"color_code": self.color_code,
"lat": self.lat,
"lon": self.lon,
"height": self.height,
"slots": self.slots,
"url": self.url,
"software_id": self.software_id,
"package_id": self.package_id,
"stage": self.stage,
"online_for": int(now - self.connected_at),
"idle": round(now - self.last_seen, 1),
@@ -93,12 +109,34 @@ class State:
self.calls: deque[Call] = deque(maxlen=50)
self.active: dict[tuple[int, int], Call] = {} # (slot, stream_id) -> Call
self.started = time.time()
# Подписчики SSE. Очередь ограничена: медленный или зависший браузер не
# должен тормозить приём эфира — при переполнении события просто теряются
# у этого одного клиента.
self._subs: list[queue.Queue] = []
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=200)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
if q in self._subs:
self._subs.remove(q)
# ── журнал ──────────────────────────────────────────────────────────────
def event(self, kind: str, text: str) -> None:
"""kind: info | call | peer | warn | error — панель красит по нему."""
rec = {"t": time.time(), "kind": kind, "text": text}
with self._lock:
self.log.append({"t": time.time(), "kind": kind, "text": text})
self.log.append(rec)
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(rec)
except queue.Full:
pass # этот клиент не успевает — его проблема, эфир важнее
print(f"[{kind}] {text}", flush=True) # заодно в journalctl
# ── хотспоты ────────────────────────────────────────────────────────────