413 lines
19 KiB
Python
413 lines
19 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 threading
|
|
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
|
|
from python_app.webui.controller import WebActionError
|
|
|
|
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
|
_DEFAULT_PORT = 8080
|
|
# Upper bound on how long a browser control call waits for the GUI thread to run and
|
|
# report the action. Comfortably above a real save/start, but bounded so a wedged GUI
|
|
# thread surfaces as an error instead of hanging the HTTP worker forever.
|
|
_WEB_ACTION_TIMEOUT_S = 30.0
|
|
|
|
|
|
class _WebActionCall:
|
|
"""One synchronous web action: the GUI thread fills the result, the web thread waits.
|
|
|
|
The web (uvicorn) thread emits a control signal carrying this object and blocks on
|
|
:attr:`done`; the GUI thread runs the desktop action, records any surfaced error in
|
|
:attr:`error`, and sets the event. This turns the fire-and-forget signal bridge into
|
|
a request/response so failures reach the browser.
|
|
"""
|
|
|
|
__slots__ = ("done", "error")
|
|
|
|
def __init__(self) -> None:
|
|
self.done = threading.Event()
|
|
self.error: str | None = None
|
|
# 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.
|
|
"""
|
|
|
|
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
|
|
# thread can block on the real outcome. apply_settings stays fire-and-forget: it is
|
|
# validated up front and returns the live schema, not a pass/fail.
|
|
start_requested = pyqtSignal(object)
|
|
stop_requested = pyqtSignal(object)
|
|
remove_last_requested = pyqtSignal(object)
|
|
single_capture_requested = pyqtSignal(object)
|
|
capture_requested = pyqtSignal(object)
|
|
start_recording_requested = pyqtSignal(str, str, int, object)
|
|
load_config_requested = pyqtSignal(str, object)
|
|
save_dataset_requested = pyqtSignal(str, str, object)
|
|
apply_settings_requested = pyqtSignal(dict)
|
|
|
|
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 _dispatch(self, signal, *args) -> None:
|
|
"""Emit a control signal and block until the GUI thread reports the outcome.
|
|
|
|
Runs on the web worker thread (the routes call this via a thread pool, so the
|
|
event loop is never blocked). Raises :class:`WebActionError` if the desktop
|
|
action surfaced an error or did not finish within the timeout.
|
|
"""
|
|
call = _WebActionCall()
|
|
signal.emit(*args, call)
|
|
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
|
|
raise WebActionError("The desktop did not complete the action in time")
|
|
if call.error is not None:
|
|
raise WebActionError(call.error)
|
|
|
|
def start(self) -> None:
|
|
self._dispatch(self.start_requested)
|
|
|
|
def stop(self) -> None:
|
|
self._dispatch(self.stop_requested)
|
|
|
|
def single_capture(self) -> None:
|
|
self._dispatch(self.single_capture_requested)
|
|
|
|
def capture_tmp_reference(self) -> None:
|
|
self._dispatch(self.capture_requested)
|
|
|
|
def remove_last_measurement(self) -> None:
|
|
self._dispatch(self.remove_last_requested)
|
|
|
|
def start_recording(self, path: str, name: str, count: int) -> None:
|
|
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
|
|
self._dispatch(self.start_recording_requested, path, name, int(count))
|
|
|
|
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; the load itself then runs
|
|
synchronously on the Qt side and any load error is surfaced too.
|
|
"""
|
|
if _safe_run_config_path(self._run_configs_dir, name) is None:
|
|
raise ValueError(f"Unknown run config: {name}")
|
|
self._dispatch(self.load_config_requested, name)
|
|
|
|
def save_dataset(self, path: str, name: str) -> None:
|
|
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
|
|
self._dispatch(self.save_dataset_requested, 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)
|
|
|
|
# Capture slot for errors a web-triggered action surfaces (None = no web
|
|
# action in flight). Read default-safe by `_show_error`/`_show_exception`.
|
|
self._web_action_error_capture: list[str] | None = None
|
|
|
|
controller = AppWindowWebController(self._run_configs_dir, parent=self)
|
|
# Each control signal carries a _WebActionCall the wrapper finalizes, so the
|
|
# browser learns whether the desktop action actually succeeded.
|
|
controller.start_requested.connect(
|
|
lambda call: self._run_web_action(call, self._start_run)
|
|
)
|
|
controller.stop_requested.connect(
|
|
lambda call: self._run_web_action(call, self._stop_run)
|
|
)
|
|
controller.single_capture_requested.connect(
|
|
lambda call: self._run_web_action(call, self._start_single_capture)
|
|
)
|
|
controller.capture_requested.connect(
|
|
lambda call: self._run_web_action(call, self._capture_tmp_reference)
|
|
)
|
|
controller.remove_last_requested.connect(
|
|
lambda call: self._run_web_action(call, self._remove_last_runtime_history)
|
|
)
|
|
controller.start_recording_requested.connect(
|
|
lambda path, name, count, call: self._run_web_action(
|
|
call, self._start_web_recording, path, name, count
|
|
)
|
|
)
|
|
controller.load_config_requested.connect(
|
|
lambda name, call: self._run_web_action(call, self._load_web_config, name)
|
|
)
|
|
controller.save_dataset_requested.connect(
|
|
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
|
|
)
|
|
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 _run_web_action(self, call: _WebActionCall, action, *args) -> None:
|
|
"""Run a web-triggered desktop action on the GUI thread, capturing its outcome.
|
|
|
|
Errors the action reports through ``_show_error``/``_show_exception`` are
|
|
captured into the call (and still logged/shown on the desktop) instead of
|
|
vanishing from the browser's view. Re-entrancy-safe: a nested action — e.g. a
|
|
modal error dialog pumping the event loop in GUI mode — saves and restores the
|
|
capture slot, so each action only sees its own first error.
|
|
"""
|
|
previous_capture = self._web_action_error_capture
|
|
capture: list[str] = []
|
|
self._web_action_error_capture = capture
|
|
try:
|
|
action(*args)
|
|
call.error = capture[0] if capture else None
|
|
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
|
|
call.error = self._exception_summary(exc)
|
|
finally:
|
|
self._web_action_error_capture = previous_capture
|
|
call.done.set()
|
|
|
|
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 _start_web_recording(self, path: str, name: str, count: int) -> None:
|
|
"""Arm disk recording from the browser via the same handler as the desktop button.
|
|
|
|
The save path/name fields are mirrored exactly like ``_save_web_dataset`` (blank
|
|
path keeps the configured destination), the record count is applied to the shared
|
|
spinbox, then the unchanged desktop arming action runs.
|
|
"""
|
|
if path.strip():
|
|
self._save_path_input.setText(path)
|
|
self._save_name_input.setText(name)
|
|
if count >= 1:
|
|
self._record_count.setValue(min(count, self._record_count.maximum()))
|
|
self._start_run_with_recording()
|
|
|
|
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(),
|
|
# Default record count + live disk-recording progress for the web UI.
|
|
"record_count": int(self._record_count.value()),
|
|
"recording": self._recording_status(),
|
|
# 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
|