Files
radar_system/python_app/gui/controllers/web_controller.py
T
2026-10-06 12:32:26 +03:00

335 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
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()