301 lines
13 KiB
Python
301 lines
13 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 os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
|
|
|
from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names
|
|
|
|
_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
|
|
# Field names a web client may apply: the form's live/display/stable fields plus the
|
|
# history command. Derived from the single schema so it can never drift from the form.
|
|
_LIVE_FIELD_NAMES = web_apply_field_names()
|
|
|
|
|
|
def _safe_run_config_path(run_configs_dir: Path, name: str) -> Path | None:
|
|
"""Resolve a client-supplied config name to a file strictly inside ``run_configs_dir``.
|
|
|
|
The web sends only a bare file name; this is the trust boundary. The name must be a
|
|
plain ``.json`` file living directly in the directory — ``name != Path(name).name``
|
|
rejects any separator or ``..`` traversal. Returns the path, or ``None`` if invalid.
|
|
"""
|
|
if not name or name != Path(name).name or not name.lower().endswith(".json"):
|
|
return None
|
|
candidate = run_configs_dir / name
|
|
return candidate if candidate.is_file() else None
|
|
|
|
|
|
def _list_run_config_names(run_configs_dir: Path) -> list[str]:
|
|
"""Return the sorted ``.json`` file names directly inside ``run_configs_dir``."""
|
|
if not run_configs_dir.is_dir():
|
|
return []
|
|
return sorted(entry.name for entry in run_configs_dir.glob("*.json") if entry.is_file())
|
|
|
|
|
|
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)
|
|
load_config_requested = pyqtSignal(str)
|
|
save_dataset_requested = pyqtSignal(str, str)
|
|
|
|
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._run_configs_dir = run_configs_dir
|
|
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 list_configs(self) -> list[str]:
|
|
"""Return the run-config file names available to load (a plain directory scan)."""
|
|
return _list_run_config_names(self._run_configs_dir)
|
|
|
|
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()
|
|
|
|
def load_config(self, name: str) -> None:
|
|
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
|
|
|
|
Validates the name against the directory here — on the web thread — so an invalid
|
|
or unsafe name fails the HTTP request immediately instead of silently doing nothing
|
|
on the Qt side; the actual load runs through the queued signal.
|
|
"""
|
|
if _safe_run_config_path(self._run_configs_dir, name) is None:
|
|
raise ValueError(f"Unknown run config: {name}")
|
|
self.load_config_requested.emit(name)
|
|
|
|
def save_dataset(self, path: str, name: str) -> None:
|
|
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
|
|
self.save_dataset_requested.emit(path, name)
|
|
|
|
|
|
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)
|
|
|
|
# The web picker browses this directory; ensure it exists on fresh deploys.
|
|
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
controller = AppWindowWebController(self._run_configs_dir, 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)
|
|
controller.load_config_requested.connect(self._load_web_config)
|
|
controller.save_dataset_requested.connect(self._save_web_dataset)
|
|
|
|
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 _load_web_config(self, name: str) -> None:
|
|
"""Load a run config chosen in the browser through the shared desktop load path.
|
|
|
|
Runs on the Qt main thread (queued from the web thread). The name is re-resolved
|
|
against the directory here as the authoritative trust boundary before any file
|
|
access, then handed to the same loader the desktop "Load Config" button uses.
|
|
"""
|
|
config_path = _safe_run_config_path(self._run_configs_dir, name)
|
|
if config_path is None:
|
|
self._log_warning(f"Ignored web request to load unknown run config: {name}")
|
|
return
|
|
self._load_config_from_path(config_path)
|
|
|
|
def _save_web_dataset(self, path: str, name: str) -> None:
|
|
"""Save the dataset from the browser via the same handler as the desktop button.
|
|
|
|
The web fields are remote editors of the shared save-path/name widgets: the name
|
|
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
|
|
path is ignored to avoid wiping the configured destination. The save itself — and
|
|
the radar-config filename prefix — is the unchanged desktop action.
|
|
"""
|
|
if path.strip():
|
|
self._save_path_input.setText(path)
|
|
self._save_name_input.setText(name)
|
|
self._save_snapshot()
|
|
|
|
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,
|
|
# File name of the currently loaded config profile, so the web picker
|
|
# can show what is active (the desktop tracks the full path).
|
|
"active_config": self._active_profile_path.name,
|
|
# Current save path/name, so the web fields can prefill the desktop values.
|
|
"save_path": self._save_path_input.text(),
|
|
"save_name": self._save_name_input.text(),
|
|
# Per-stage capture counts, identical to the desktop history
|
|
# label (raw -> preprocessed -> results), mirrored to the browser.
|
|
"raw_count": len(self._raw_history),
|
|
"preprocessed_count": len(self._pre_history),
|
|
"result_count": len(self._result_history),
|
|
},
|
|
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
|