web UI added and refactoring done
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user