320 lines
13 KiB
Python
320 lines
13 KiB
Python
"""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
|
|
|
|
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 = False
|
|
self._grabbed_revision: int | None = None # plot revision shown by the last grab; None forces one
|
|
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)
|
|
|
|
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._grabbed_revision = None
|
|
|
|
def _grab_frame_if_due(self) -> dict | None:
|
|
plots = self._pipeline.plots
|
|
if self._closed or not self._streaming or plots.revision == self._grabbed_revision:
|
|
return None
|
|
now = time.monotonic()
|
|
if now - self._last_grab_s < _GRAB_INTERVAL_S:
|
|
return None
|
|
self._last_grab_s = now
|
|
png = self._grab_png()
|
|
# Rendering applies pending resizes and lazy auto-ranges; this image already shows them.
|
|
self._grabbed_revision = plots.revision
|
|
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()
|