Files
radar_system/python_app/gui/controllers/app_window_web_mixin.py
T
2026-06-06 00:52:52 +03:00

215 lines
8.6 KiB
Python

"""Embed the browser UI inside the AppWindow without duplicating any rendering.
The desktop already owns the hardware and draws every plot with pyqtgraph, so the
web view simply streams a snapshot of the *current Qt plot widget* — the browser
shows exactly what the desktop shows, for every processing mode, with zero
re-implemented rendering. Controls cross back to the Qt main thread through queued
signals (the GPIO-button pattern) and invoke the AppWindow's existing buttons.
Two roles, kept separate from the Qt-free :mod:`python_app.webui` package:
* :class:`AppWindowWebController` — the Qt bridge implementing the web contract.
* :class:`AppWindowWebMixin` — wiring that grabs the plot, refreshes snapshots,
and starts/stops the server.
"""
from __future__ import annotations
import base64
import contextlib
import dataclasses
import os
import time
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
_DEFAULT_PORT = 8080
# Headless has no shown window, so give the offscreen window a usable size for the
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
_HEADLESS_PLOT_SIZE = (1600, 900)
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
_GRAB_INTERVAL_S = 0.2
_LIVE_FIELD_NAMES = frozenset(field.name for field in dataclasses.fields(ProcessingLiveConfig))
class AppWindowWebController(QObject):
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
Mutating calls (made on the web thread) emit queued signals the AppWindow
connects to its existing slots. Read calls return immutable snapshots the
AppWindow refreshes on its poll tick — replaced atomically, never mutated in
place, so the web thread reads a consistent value without locking.
"""
start_requested = pyqtSignal()
stop_requested = pyqtSignal()
single_capture_requested = pyqtSignal()
capture_requested = pyqtSignal()
apply_settings_requested = pyqtSignal(dict)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._status: dict = {}
self._live_settings: list = []
self._frame: dict | None = None
# -- Snapshot refresh (Qt main thread) -----------------------------------
def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None:
"""Replace the served snapshots; ``frame`` only when a new one was grabbed."""
self._status = status
self._live_settings = live_settings
if frame is not None:
self._frame = frame
# -- WebController reads (web thread) ------------------------------------
def status(self) -> dict:
return dict(self._status)
def current_live_settings(self) -> list:
return list(self._live_settings)
def peek_frame(self) -> dict | None:
"""Return the most recent rendered-plot frame (or None before the first)."""
return self._frame
# -- WebController controls (web thread -> Qt main thread) ---------------
def start(self) -> None:
self.start_requested.emit()
def stop(self) -> None:
self.stop_requested.emit()
def single_capture(self) -> None:
self.single_capture_requested.emit()
def capture_tmp_reference(self) -> None:
self.capture_requested.emit()
def apply_live_settings(self, fields: dict) -> dict:
unknown = set(fields) - _LIVE_FIELD_NAMES
if unknown:
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
self.apply_settings_requested.emit(dict(fields))
return self.current_live_settings()
class AppWindowWebMixin:
"""Start/stop the embedded web server and feed it the live plot + settings."""
def _init_web_ui(self) -> None:
"""Start the web server (default-on, both modes), wired to existing buttons."""
self._web_controller: AppWindowWebController | None = None
self._web_server = None
self._web_frame_seq = 0
self._web_last_png: str | None = None
self._web_last_grab_s = 0.0
try:
from python_app.webui.server import WebUiServer
# Headless never shows the window; size it so the grabbed plot is usable.
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
self.resize(*_HEADLESS_PLOT_SIZE)
controller = AppWindowWebController(parent=self)
controller.start_requested.connect(self._start_run)
controller.stop_requested.connect(self._stop_run)
controller.single_capture_requested.connect(self._start_single_capture)
controller.capture_requested.connect(self._capture_tmp_reference)
controller.apply_settings_requested.connect(self._apply_web_live_settings)
self._web_controller = controller
self._web_update_snapshot() # seed snapshots before the first request
port = self._web_ui_port()
self._web_server = WebUiServer(controller, port=port)
self._web_server.start()
self._log(f"Web UI started on http://0.0.0.0:{port}")
except Exception as exc: # noqa: BLE001 - a missing dependency must not abort startup
self._log_exception("Failed to start web UI", exc, level="WARN")
self._web_controller = None
self._web_server = None
def _web_update_snapshot(self) -> None:
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
Must NEVER raise: it runs inside the periodic render tick, where an escaping
exception would abort the Qt slot (qFatal) and kill the app.
"""
controller = getattr(self, "_web_controller", None)
if controller is None:
return
try:
controller.update_snapshot(
status={
"running": self._supervisor.is_running(),
"processor_running": self._supervisor.is_processor_running(),
"ring_name": self._defaults_config.rings.results.name,
},
live_settings=self._web_settings_schema(),
frame=self._web_grab_frame_if_due(),
)
except Exception as exc: # noqa: BLE001 - the render loop must survive this
self._web_snapshot_errors = getattr(self, "_web_snapshot_errors", 0) + 1
if self._web_snapshot_errors % 200 == 1:
self._log_exception("Web UI snapshot refresh failed", exc, level="WARN")
def _web_grab_frame_if_due(self) -> dict | None:
"""Grab the current plot as a PNG frame, throttled and change-gated."""
now = time.monotonic()
if now - self._web_last_grab_s < _GRAB_INTERVAL_S:
return None
self._web_last_grab_s = now
png_b64 = self._grab_plot_png_b64()
if png_b64 is None or png_b64 == self._web_last_png:
return None # nothing rendered yet, or the plot is unchanged
self._web_last_png = png_b64
self._web_frame_seq += 1
return {
"type": "frame",
"seq": self._web_frame_seq,
"mode": self._processing_mode.currentText(),
"png_b64": png_b64,
}
def _grab_plot_png_b64(self) -> str | None:
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
widget = self._plot_stack.currentWidget()
if widget is None or widget.width() <= 0 or widget.height() <= 0:
return None
pixmap = widget.grab()
if pixmap.isNull():
return None
buffer = QBuffer()
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
pixmap.save(buffer, "PNG")
return base64.b64encode(bytes(buffer.data())).decode()
def _shutdown_web_ui(self) -> None:
"""Stop the server (and its broadcaster) during teardown."""
server = getattr(self, "_web_server", None)
if server is not None:
with contextlib.suppress(Exception):
server.stop()
self._web_server = None
self._log("Web UI stopped.")
self._web_controller = None
@staticmethod
def _web_ui_port() -> int:
"""Resolve the web UI port from the environment, defaulting to 8080."""
raw = os.environ.get(_WEBUI_PORT_ENV, "").strip()
if not raw:
return _DEFAULT_PORT
try:
port = int(raw)
except ValueError:
return _DEFAULT_PORT
return port if 1 <= port <= 65535 else _DEFAULT_PORT