added data saving feature

This commit is contained in:
Ayzen
2026-06-23 12:19:31 +03:00
parent 716fd0b07a
commit 8db14b9482
20 changed files with 1192 additions and 63 deletions
@@ -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),