Панель: • шесть страниц с маршрутизацией по хэшу: Обзор, 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
169 строки
6.7 KiB
Python
169 строки
6.7 KiB
Python
"""Общее состояние сервера: подключённые хотспоты, журнал событий, счётчики.
|
|
|
|
Читается из потока веб-панели, пишется из цикла asyncio, поэтому всё под
|
|
одним замком. Объём данных крошечный (десятки хотспотов, сотни строк лога),
|
|
так что простой Lock дешевле и надёжнее любых очередей.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class Peer:
|
|
"""Подключённый хотспот/ретранслятор."""
|
|
|
|
peer_id: int
|
|
addr: tuple[str, int]
|
|
# Всё, что хотспот присылает в RPTC. Имена — как у разборщика (analyzer),
|
|
# чтобы не переименовывать по дороге и не ловить пустые поля.
|
|
callsign: 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 = "" # Pi-Star кладёт сюда СТРАНУ
|
|
slots: int | None = None
|
|
url: 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
|
|
stage: str = "login"
|
|
salt: int = 0
|
|
rx_packets: int = 0
|
|
tx_packets: int = 0
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
now = time.time()
|
|
return {
|
|
"peer_id": self.peer_id,
|
|
"addr": f"{self.addr[0]}:{self.addr[1]}",
|
|
"callsign": self.callsign,
|
|
"location": self.location,
|
|
"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),
|
|
"rx_packets": self.rx_packets,
|
|
"tx_packets": self.tx_packets,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Call:
|
|
"""Идущая или завершившаяся передача."""
|
|
|
|
stream_id: int
|
|
src_id: int
|
|
dst_id: int
|
|
slot: int
|
|
group: bool
|
|
peer_id: int
|
|
started: float = field(default_factory=time.time)
|
|
ended: float = 0.0
|
|
frames: int = 0
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
end = self.ended or time.time()
|
|
return {
|
|
"stream_id": self.stream_id,
|
|
"src_id": self.src_id,
|
|
"dst_id": self.dst_id,
|
|
"slot": self.slot,
|
|
"type": "группа" if self.group else "приват",
|
|
"peer_id": self.peer_id,
|
|
"started": self.started,
|
|
"duration": round(end - self.started, 1),
|
|
"frames": self.frames,
|
|
"active": self.ended == 0.0,
|
|
}
|
|
|
|
|
|
class State:
|
|
def __init__(self, log_keep: int = 500) -> None:
|
|
self._lock = threading.Lock()
|
|
self.peers: dict[int, Peer] = {}
|
|
self.log: deque[dict[str, Any]] = deque(maxlen=log_keep)
|
|
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(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
|
|
|
|
# ── хотспоты ────────────────────────────────────────────────────────────
|
|
def peer_add(self, peer: Peer) -> None:
|
|
with self._lock:
|
|
self.peers[peer.peer_id] = peer
|
|
|
|
def peer_del(self, peer_id: int) -> Peer | None:
|
|
with self._lock:
|
|
return self.peers.pop(peer_id, None)
|
|
|
|
def peer_get(self, peer_id: int) -> Peer | None:
|
|
with self._lock:
|
|
return self.peers.get(peer_id)
|
|
|
|
def peers_ready(self) -> list[Peer]:
|
|
with self._lock:
|
|
return [p for p in self.peers.values() if p.stage == "ready"]
|
|
|
|
# ── снимок для панели ───────────────────────────────────────────────────
|
|
def snapshot(self, log_since: float = 0.0) -> dict[str, Any]:
|
|
with self._lock:
|
|
return {
|
|
"uptime": int(time.time() - self.started),
|
|
"peers": [p.as_dict() for p in self.peers.values()],
|
|
"calls": [c.as_dict() for c in reversed(self.calls)],
|
|
"log": [e for e in self.log if e["t"] > log_since],
|
|
"now": time.time(),
|
|
}
|