"""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", "_lock", "_started") def __init__(self) -> None: self.done = threading.Event() self.error: str | None = None self._lock = threading.Lock() self._started = False def begin(self) -> bool: with self._lock: if self.done.is_set() or self._started: return False self._started = True return True def finish(self, error: str | None = None) -> None: with self._lock: if not self.done.is_set(): self.error = error self.done.set() def cancel(self, error: str, *, queued_only: bool = True) -> bool: with self._lock: if self.done.is_set(): return True if queued_only and self._started: return False self.error = error self.done.set() return True # 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 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 and receive the updated state. 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) select_combo_requested = pyqtSignal(int, int, object) next_combo_requested = pyqtSignal(object) apply_settings_requested = pyqtSignal(dict, object) 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 self._pending_lock = threading.Lock() self._pending_calls: set[_WebActionCall] = set() self._closed = False # -- Snapshot refresh (Qt main thread) ----------------------------------- def update_snapshot(self, *, status: dict, live_settings: list, 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() with self._pending_lock: if self._closed: raise WebActionError("The application is closing") self._pending_calls.add(call) try: signal.emit(*args, call) if not call.done.wait(_WEB_ACTION_TIMEOUT_S): if not call.cancel("The desktop did not start the action in time; the request was cancelled"): raise WebActionError("The desktop action is still running; check its status before retrying") if call.error is not None: raise WebActionError(call.error) finally: with self._pending_lock: self._pending_calls.discard(call) def shutdown(self) -> None: """Release waiting HTTP workers and reject queued controls during teardown.""" with self._pending_lock: self._closed = True pending = tuple(self._pending_calls) for call in pending: call.cancel("The application is closing", queued_only=False) 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).""" if type(count) is not int or count < 1: raise ValueError("Recording count must be a positive integer") self._dispatch(self.start_recording_requested, path, name, count) def apply_live_settings(self, fields: dict) -> list: unknown = set(fields) - _LIVE_FIELD_NAMES if unknown: raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}") self._dispatch(self.apply_settings_requested, 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 last 1000 runtime measurements to ``path``/``name``.""" self._dispatch(self.save_dataset_requested, path, name) def select_combo(self, input_pos: int, output_pos: int) -> None: """Select the matrix B-scan channel through the desktop display controls.""" if any(type(pos) is not int or pos < 0 for pos in (input_pos, output_pos)): raise ValueError("Channel positions must be nonnegative integers") self._dispatch(self.select_combo_requested, input_pos, output_pos) def next_combo(self) -> None: """Advance the matrix B-scan display to the next configured channel pair.""" self._dispatch(self.next_combo_requested) 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.select_combo_requested.connect( lambda inp, out, call: self._run_web_action(call, self._select_display_combo, inp, out) ) controller.next_combo_requested.connect( lambda call: self._run_web_action(call, self._select_next_combo) ) controller.apply_settings_requested.connect( lambda fields, call: self._run_web_action(call, self._apply_web_live_settings, fields) ) 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. """ if getattr(self, "_closing", False): call.cancel("The application is closing", queued_only=False) return if not call.begin(): return previous_capture = self._web_action_error_capture capture: list[str] = [] self._web_action_error_capture = capture error = None try: action(*args) error = capture[0] if capture else None self._web_update_snapshot() except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop error = self._exception_summary(exc) finally: self._web_action_error_capture = previous_capture call.finish(error) 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: raise ValueError(f"Unknown run config: {name}") self._load_config_from_path(config_path) def _save_web_dataset(self, path: str, name: str) -> None: """Save the last 1000 measurements via the shared desktop handler. 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 type(count) is not int or not self._record_count.minimum() <= count <= self._record_count.maximum(): raise ValueError( f"Recording count must be an integer from {self._record_count.minimum()} " f"to {self._record_count.maximum()}" ) if path.strip(): self._save_path_input.setText(path) self._save_name_input.setText(name) self._record_count.setValue(count) 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), "sar3d_status": self._sar3d_status_label.text() if self._processing_mode.currentText() == "sar3d" else "", "display_combo": { "enabled": self._is_matrix_radar_model_selected() and self._processing_mode.currentText() == "bscan", "input": self._single_combo_input.text(), "output": self._single_combo_output.text(), "options": self._available_display_combo_keys() if self._is_matrix_radar_model_selected() else [], }, }, 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 active plots; SAR3D status is sent separately as web text.""" widget = self._plot_stack.currentWidget() if widget is self._sar3d_plots_container: widget = self._sar3d_plot_grid 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.""" controller = getattr(self, "_web_controller", None) if controller is not None: controller.shutdown() 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