"""Общее состояние сервера: подключённые хотспоты, журнал событий, счётчики. Читается из потока веб-панели, пишется из цикла 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(), }