Rework radar system
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
"""Fan-out of pipeline result frames and status to connected web clients.
|
||||
|
||||
A single broadcaster task polls the :class:`WebController` off the event loop and
|
||||
A single broadcaster task reads cached :class:`WebController` snapshots and
|
||||
pushes the freshest frame (latest-wins) plus a slower status heartbeat to every
|
||||
registered client. Each client is a bounded ``asyncio.Queue`` with a drop-oldest
|
||||
policy, so a slow socket can never stall the producer or the loop — if a client
|
||||
falls behind it simply skips intermediate frames and always gets the newest.
|
||||
registered client. Each client keeps one latest message per type, so a slow
|
||||
socket skips intermediate frames without losing status or settings updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,24 +23,55 @@ _FRAME_INTERVAL_S = 0.05
|
||||
_STATUS_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
class ClientUpdates:
|
||||
"""At most one pending frame, status and settings update for one socket."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: dict[str, dict] = {}
|
||||
self._ready = asyncio.Event()
|
||||
|
||||
def put(self, message: dict) -> None:
|
||||
self._pending[message["type"]] = message
|
||||
self._ready.set()
|
||||
|
||||
async def get(self) -> dict:
|
||||
await self._ready.wait()
|
||||
message = self._pending.pop(next(iter(self._pending)))
|
||||
if not self._pending:
|
||||
self._ready.clear()
|
||||
return message
|
||||
|
||||
|
||||
class RingBroadcaster:
|
||||
"""Polls the controller and fans frames/status out to all WebSocket clients."""
|
||||
|
||||
def __init__(self, controller: WebController) -> None:
|
||||
self._controller = controller
|
||||
self._clients: set[asyncio.Queue[dict]] = set()
|
||||
self._clients: set[ClientUpdates] = set()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._last_frame_seq: int | None = None
|
||||
self._last_settings: dict | None = None
|
||||
self._last_settings: list | None = None
|
||||
|
||||
def register(self, queue: asyncio.Queue[dict]) -> None:
|
||||
"""Add a client queue to receive subsequent frames and status."""
|
||||
self._clients.add(queue)
|
||||
def register(self) -> ClientUpdates:
|
||||
"""Seed every connection with current snapshots, then stream updates."""
|
||||
client = ClientUpdates()
|
||||
frame = self._controller.peek_frame()
|
||||
if frame is not None:
|
||||
client.put(frame)
|
||||
client.put(self._status_message())
|
||||
client.put({"type": "settings", "schema": self._controller.current_live_settings()})
|
||||
if not self._clients:
|
||||
self._controller.set_frame_streaming(True)
|
||||
self._clients.add(client)
|
||||
logger.debug("Registered web client queue (clients=%d)", len(self._clients))
|
||||
return client
|
||||
|
||||
def unregister(self, queue: asyncio.Queue[dict]) -> None:
|
||||
def unregister(self, client: ClientUpdates) -> None:
|
||||
"""Remove a client queue; safe to call more than once."""
|
||||
self._clients.discard(queue)
|
||||
was_connected = bool(self._clients)
|
||||
self._clients.discard(client)
|
||||
if was_connected and not self._clients:
|
||||
self._controller.set_frame_streaming(False)
|
||||
logger.debug("Unregistered web client queue (clients=%d)", len(self._clients))
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -59,6 +89,8 @@ class RingBroadcaster:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the polling task and wait for it to unwind."""
|
||||
self._controller.set_frame_streaming(False)
|
||||
self._clients.clear()
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
@@ -68,13 +100,9 @@ class RingBroadcaster:
|
||||
logger.info("Ring broadcaster stopped")
|
||||
|
||||
def _publish(self, message: dict) -> None:
|
||||
"""Push a message to every client, dropping the oldest on a full queue."""
|
||||
for queue in self._clients:
|
||||
if queue.full():
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(message)
|
||||
"""Replace only older messages of the same type."""
|
||||
for client in self._clients:
|
||||
client.put(message)
|
||||
|
||||
def _status_message(self) -> dict:
|
||||
"""Build a status broadcast from the controller's current state."""
|
||||
@@ -86,22 +114,21 @@ class RingBroadcaster:
|
||||
next_status = loop.time()
|
||||
while True:
|
||||
try:
|
||||
# peek_frame may touch shared memory / NumPy, so keep it off the loop.
|
||||
frame = await loop.run_in_executor(None, self._controller.peek_frame)
|
||||
if frame is not None and frame["seq"] != self._last_frame_seq:
|
||||
self._last_frame_seq = frame["seq"]
|
||||
self._publish(frame)
|
||||
|
||||
now = loop.time()
|
||||
if now >= next_status:
|
||||
if self._clients and now >= next_status:
|
||||
self._publish(self._status_message())
|
||||
# Push live settings (Qt -> web) only when they change, so the
|
||||
# browser form mirrors desktop edits in real time without churn.
|
||||
settings = self._controller.current_live_settings()
|
||||
if settings != self._last_settings:
|
||||
self._last_settings = settings
|
||||
self._publish({"type": "settings", "schema": settings})
|
||||
self._last_settings = settings
|
||||
next_status = now + _STATUS_INTERVAL_S
|
||||
if self._clients:
|
||||
frame = self._controller.peek_frame()
|
||||
if frame is not None and frame["seq"] != self._last_frame_seq:
|
||||
self._publish(frame)
|
||||
self._last_frame_seq = frame["seq"]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - one bad frame must not stop streaming
|
||||
|
||||
Reference in New Issue
Block a user