"""Serve GUI snapshots and marshal browser controls onto the Qt thread.""" from __future__ import annotations import base64 from collections.abc import Callable from contextlib import suppress import logging import os from pathlib import Path import threading import time from typing import TYPE_CHECKING import pyqtgraph as pg from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget from python_app.gui.settings_defaults import env_enabled from python_app.gui.controllers.config_controller import web_apply_field_names from python_app.webui.controller import WebActionError if TYPE_CHECKING: from python_app.gui.controllers.live_settings_controller import LiveSettingsController from python_app.gui.controllers.log_controller import LogController from python_app.gui.controllers.pipeline_controller import PipelineController from python_app.gui.controllers.preprocess_controller import PreprocessController from python_app.gui.controllers.profile_controller import ProfileController from python_app.gui.controllers.snapshot_controller import SnapshotController from python_app.webui.server import WebUiServer logger = logging.getLogger(__name__) _WEB_ACTION_TIMEOUT_S = 30.0 _GRAB_INTERVAL_S = 0.2 _LIVE_FIELD_NAMES = web_apply_field_names() class _WebActionCall: """A timeout can cancel queued work; running work must never be retried blindly.""" def __init__(self, action: Callable[[], object]) -> None: self.action = action 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 def web_ui_port() -> int: try: port = int(os.environ.get("RADAR_SYSTEM_WEBUI_PORT", "8080")) return port if 1 <= port <= 65535 else 8080 except ValueError: return 8080 class WebBridge(QObject): """Own web lifecycle, queued actions, immutable snapshots and change-gated PNGs.""" _action_requested = pyqtSignal(object) _streaming_changed = pyqtSignal(bool) def __init__( self, parent: QWidget, run_configs_dir: Path, pipeline: PipelineController, preprocess: PreprocessController, snapshots: SnapshotController, profile: ProfileController, live: LiveSettingsController, log: LogController, *, save_path: QLineEdit, save_name: QLineEdit, record_count: QSpinBox, ) -> None: super().__init__(parent) self._parent, self._run_configs_dir = parent, run_configs_dir self._pipeline, self._preprocess, self._snapshots = pipeline, preprocess, snapshots self._profile, self._live, self._log = profile, live, log self._save_path, self._save_name, self._record_count = save_path, save_name, record_count self._server: WebUiServer | None = None self._status: dict = {} self._live_settings: list[dict] = [] self._frame: dict | None = None self._pending_lock = threading.Lock() self._pending_calls: set[_WebActionCall] = set() self._closed = self._streaming = self._grabbing = False self._dirty = True self._last_png: str | None = None self._last_grab_s = 0.0 self._frame_seq = self._snapshot_errors = 0 self._action_requested.connect(self._run_action) self._streaming_changed.connect(self._set_streaming) plots = pipeline.plots plots.stack.currentChanged.connect(self._mark_dirty) for plot in plots.stack.findChildren(pg.PlotWidget): scene = plot.scene() assert scene is not None scene.changed.connect(self._mark_dirty) plot.sigDeviceRangeChanged.connect(self._mark_dirty) def start_server(self) -> None: if self._closed or self._server is not None: return try: from python_app.webui.server import WebUiServer if env_enabled("RADAR_SYSTEM_HEADLESS"): self._parent.resize(1600, 900) self._run_configs_dir.mkdir(parents=True, exist_ok=True) self.refresh_snapshot() self._server = WebUiServer(self, port=web_ui_port()) self._server.start() except Exception: logger.warning("Failed to start web UI", exc_info=True) if self._server is not None: with suppress(Exception): self._server.stop() self._server = None def shutdown(self) -> None: with self._pending_lock: self._closed, self._streaming = True, False pending = tuple(self._pending_calls) for call in pending: call.cancel("The application is closing", queued_only=False) if self._server is not None: with suppress(Exception): self._server.stop() self._server = None def _dispatch(self, action: Callable[[], object]) -> None: call = _WebActionCall(action) with self._pending_lock: if self._closed: raise WebActionError("The application is closing") self._pending_calls.add(call) try: self._action_requested.emit(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 _run_action(self, call: _WebActionCall) -> None: if self._closed or self._pipeline.closing: call.cancel("The application is closing", queued_only=False) return if not call.begin(): return error = None try: with self._log.capture_errors() as captured: call.action() error = captured[0] if captured else None self.refresh_snapshot() except Exception as exc: error = self._log.exception_summary(exc) finally: call.finish(error) # The web thread reads only replaced snapshots; all widget reads happen below. def status(self) -> dict: return dict(self._status) def current_live_settings(self) -> list[dict]: return list(self._live_settings) def peek_frame(self) -> dict | None: return self._frame def list_configs(self) -> list[str]: return sorted(path.name for path in self._run_configs_dir.glob("*.json") if path.is_file()) def start(self) -> None: self._dispatch(self._pipeline.start) def stop(self) -> None: self._dispatch(self._pipeline.stop) def single_capture(self) -> None: self._dispatch(lambda: self._pipeline.start(single_capture=True)) def capture_tmp_reference(self) -> None: self._dispatch(self._preprocess.capture_tmp_reference) def remove_last_measurement(self) -> None: self._dispatch(lambda: self._snapshots.delete_history(remove_last=True)) def clear_history(self) -> None: self._dispatch(lambda: self._snapshots.delete_history(remove_last=False)) def start_recording(self, path: str, name: str, count: int) -> None: if type(count) is not int or count < 1: raise ValueError("Recording count must be a positive integer") self._dispatch(lambda: self._start_recording(path, name, count)) def _start_recording(self, path: str, name: str, count: int) -> None: low, high = self._record_count.minimum(), self._record_count.maximum() if not low <= count <= high: raise ValueError(f"Recording count must be an integer from {low} to {high}") self._set_destination(path, name) self._record_count.setValue(count) self._snapshots.start_recording(count) def _set_destination(self, path: str, name: str) -> None: if path.strip(): self._save_path.setText(path) self._save_name.setText(name) def save_dataset(self, path: str, name: str) -> None: self._dispatch(lambda: self._save_dataset(path, name)) def _save_dataset(self, path: str, name: str) -> None: self._set_destination(path, name) self._snapshots.save() def load_config(self, name: str) -> None: def load() -> None: path = self._run_configs_dir / name if not name or name != Path(name).name or path.suffix.lower() != ".json" or not path.is_file(): raise ValueError(f"Unknown run config: {name}") self._profile.load(path) self._dispatch(load) def apply_live_settings(self, fields: dict) -> list[dict]: fields = dict(fields) unknown = set(fields) - _LIVE_FIELD_NAMES if unknown: raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}") self._dispatch(lambda: self._live.apply_web(fields)) return self.current_live_settings() def select_combo(self, input_pos: int, output_pos: int) -> None: 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(lambda: self._pipeline.plots.select_combo(input_pos, output_pos)) def next_combo(self) -> None: self._dispatch(self._pipeline.plots.select_next_combo) def refresh_snapshot(self) -> None: if self._closed or self._pipeline.closing: return try: pipeline, plots = self._pipeline, self._pipeline.plots input_pos, output_pos = plots.display_key or (0, 0) mode = self._live.active_mode self._status = { "running": pipeline.running, "processor_running": pipeline.processor_running, "ring_name": self._profile.state.config.rings.results.name, "active_config": self._profile.state.path.name, "save_path": self._save_path.text(), "save_name": self._save_name.text(), "record_count": self._record_count.value(), "recording": pipeline.recording.status(), "raw_count": len(pipeline.history.raw), "preprocessed_count": len(pipeline.history.preprocessed), "result_count": len(pipeline.history.results), "sar3d_status": plots.sar3d.status_label.text() if mode == "sar3d" else "", "display_combo": {"enabled": mode == "bscan", "input": input_pos, "output": output_pos, "options": sorted(plots.bscan.history_by_combo)}, } self._live_settings = self._live.config.web_schema() frame = self._grab_frame_if_due() if frame is not None: self._frame = frame except Exception: self._snapshot_errors += 1 if self._snapshot_errors % 200 == 1: logger.warning("Web UI snapshot refresh failed", exc_info=True) def set_frame_streaming(self, enabled: bool) -> None: if not self._closed: self._streaming_changed.emit(enabled) def _set_streaming(self, enabled: bool) -> None: if not self._closed: self._streaming = enabled if enabled: self._dirty = True def _mark_dirty(self, *_args: object) -> None: if not self._closed and not self._grabbing: self._dirty = True def _grab_frame_if_due(self) -> dict | None: if self._closed or not self._streaming or not self._dirty: return None now = time.monotonic() if now - self._last_grab_s < _GRAB_INTERVAL_S: return None self._last_grab_s, self._dirty, self._grabbing = now, False, True try: png = self._grab_png() except Exception: self._dirty = True raise finally: self._grabbing = False if png is None or png == self._last_png: return None self._last_png = png self._frame_seq += 1 return {"type": "frame", "seq": self._frame_seq, "mode": self._live.active_mode, "png_b64": png} def _grab_png(self) -> str | None: widget = self._pipeline.plots.capture_widget 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(buffer.data().data()).decode()