214 lines
8.5 KiB
Python
214 lines
8.5 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._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
|