added data saving feature
This commit is contained in:
@@ -18,15 +18,37 @@ 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)
|
||||
@@ -66,14 +88,18 @@ class AppWindowWebController(QObject):
|
||||
place, so the web thread reads a consistent value without locking.
|
||||
"""
|
||||
|
||||
start_requested = pyqtSignal()
|
||||
stop_requested = pyqtSignal()
|
||||
remove_last_requested = pyqtSignal()
|
||||
single_capture_requested = pyqtSignal()
|
||||
capture_requested = pyqtSignal()
|
||||
# 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)
|
||||
load_config_requested = pyqtSignal(str)
|
||||
save_dataset_requested = pyqtSignal(str, str)
|
||||
|
||||
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -109,20 +135,38 @@ class AppWindowWebController(QObject):
|
||||
|
||||
# -- 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.start_requested.emit()
|
||||
self._dispatch(self.start_requested)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_requested.emit()
|
||||
self._dispatch(self.stop_requested)
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self.single_capture_requested.emit()
|
||||
self._dispatch(self.single_capture_requested)
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self.capture_requested.emit()
|
||||
self._dispatch(self.capture_requested)
|
||||
|
||||
def remove_last_measurement(self) -> None:
|
||||
self.remove_last_requested.emit()
|
||||
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
|
||||
@@ -135,16 +179,16 @@ class AppWindowWebController(QObject):
|
||||
"""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 instead of silently doing nothing
|
||||
on the Qt side; the actual load runs through the queued signal.
|
||||
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.load_config_requested.emit(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.save_dataset_requested.emit(path, name)
|
||||
self._dispatch(self.save_dataset_requested, path, name)
|
||||
|
||||
|
||||
class AppWindowWebMixin:
|
||||
@@ -168,15 +212,40 @@ class AppWindowWebMixin:
|
||||
# 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)
|
||||
controller.start_requested.connect(self._start_run)
|
||||
controller.stop_requested.connect(self._stop_run)
|
||||
controller.single_capture_requested.connect(self._start_single_capture)
|
||||
controller.capture_requested.connect(self._capture_tmp_reference)
|
||||
controller.remove_last_requested.connect(self._remove_last_runtime_history)
|
||||
# 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)
|
||||
controller.load_config_requested.connect(self._load_web_config)
|
||||
controller.save_dataset_requested.connect(self._save_web_dataset)
|
||||
|
||||
self._web_controller = controller
|
||||
self._web_update_snapshot() # seed snapshots before the first request
|
||||
@@ -190,6 +259,27 @@ class AppWindowWebMixin:
|
||||
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.
|
||||
|
||||
@@ -216,6 +306,20 @@ class AppWindowWebMixin:
|
||||
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).
|
||||
|
||||
@@ -237,6 +341,9 @@ class AppWindowWebMixin:
|
||||
# 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),
|
||||
|
||||
Reference in New Issue
Block a user