Refactor radar system
This commit is contained in:
@@ -44,17 +44,42 @@ class _WebActionCall:
|
||||
a request/response so failures reach the browser.
|
||||
"""
|
||||
|
||||
__slots__ = ("done", "error")
|
||||
__slots__ = ("done", "error", "_lock", "_started")
|
||||
|
||||
def __init__(self) -> None:
|
||||
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
|
||||
# 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)
|
||||
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
# Field names a web client may apply: the form's live/display/stable fields plus the
|
||||
# Field names a web client may apply: the form's live/display fields plus the
|
||||
# history command. Derived from the single schema so it can never drift from the form.
|
||||
_LIVE_FIELD_NAMES = web_apply_field_names()
|
||||
|
||||
@@ -89,8 +114,7 @@ class AppWindowWebController(QObject):
|
||||
"""
|
||||
|
||||
# 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.
|
||||
# thread can block on the real outcome and receive the updated state.
|
||||
start_requested = pyqtSignal(object)
|
||||
stop_requested = pyqtSignal(object)
|
||||
remove_last_requested = pyqtSignal(object)
|
||||
@@ -99,7 +123,9 @@ class AppWindowWebController(QObject):
|
||||
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)
|
||||
select_combo_requested = pyqtSignal(int, int, object)
|
||||
next_combo_requested = pyqtSignal(object)
|
||||
apply_settings_requested = pyqtSignal(dict, object)
|
||||
|
||||
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -107,10 +133,13 @@ class AppWindowWebController(QObject):
|
||||
self._status: dict = {}
|
||||
self._live_settings: list = []
|
||||
self._frame: dict | None = None
|
||||
self._pending_lock = threading.Lock()
|
||||
self._pending_calls: set[_WebActionCall] = set()
|
||||
self._closed = False
|
||||
|
||||
# -- Snapshot refresh (Qt main thread) -----------------------------------
|
||||
|
||||
def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None:
|
||||
def update_snapshot(self, *, status: dict, live_settings: list, frame: dict | None) -> None:
|
||||
"""Replace the served snapshots; ``frame`` only when a new one was grabbed."""
|
||||
self._status = status
|
||||
self._live_settings = live_settings
|
||||
@@ -143,11 +172,28 @@ class AppWindowWebController(QObject):
|
||||
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)
|
||||
with self._pending_lock:
|
||||
if self._closed:
|
||||
raise WebActionError("The application is closing")
|
||||
self._pending_calls.add(call)
|
||||
try:
|
||||
signal.emit(*args, 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 shutdown(self) -> None:
|
||||
"""Release waiting HTTP workers and reject queued controls during teardown."""
|
||||
with self._pending_lock:
|
||||
self._closed = True
|
||||
pending = tuple(self._pending_calls)
|
||||
for call in pending:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
|
||||
def start(self) -> None:
|
||||
self._dispatch(self.start_requested)
|
||||
@@ -166,13 +212,15 @@ class AppWindowWebController(QObject):
|
||||
|
||||
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))
|
||||
if type(count) is not int or count < 1:
|
||||
raise ValueError("Recording count must be a positive integer")
|
||||
self._dispatch(self.start_recording_requested, path, name, count)
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> dict:
|
||||
def apply_live_settings(self, fields: dict) -> list:
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self.apply_settings_requested.emit(dict(fields))
|
||||
self._dispatch(self.apply_settings_requested, dict(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
def load_config(self, name: str) -> None:
|
||||
@@ -187,9 +235,19 @@ class AppWindowWebController(QObject):
|
||||
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)."""
|
||||
"""Save the last 1000 runtime measurements to ``path``/``name``."""
|
||||
self._dispatch(self.save_dataset_requested, path, name)
|
||||
|
||||
def select_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
"""Select the matrix B-scan channel through the desktop display controls."""
|
||||
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(self.select_combo_requested, input_pos, output_pos)
|
||||
|
||||
def next_combo(self) -> None:
|
||||
"""Advance the matrix B-scan display to the next configured channel pair."""
|
||||
self._dispatch(self.next_combo_requested)
|
||||
|
||||
|
||||
class AppWindowWebMixin:
|
||||
"""Start/stop the embedded web server and feed it the live plot + settings."""
|
||||
@@ -245,7 +303,15 @@ class AppWindowWebMixin:
|
||||
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.select_combo_requested.connect(
|
||||
lambda inp, out, call: self._run_web_action(call, self._select_display_combo, inp, out)
|
||||
)
|
||||
controller.next_combo_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._select_next_combo)
|
||||
)
|
||||
controller.apply_settings_requested.connect(
|
||||
lambda fields, call: self._run_web_action(call, self._apply_web_live_settings, fields)
|
||||
)
|
||||
|
||||
self._web_controller = controller
|
||||
self._web_update_snapshot() # seed snapshots before the first request
|
||||
@@ -268,17 +334,24 @@ class AppWindowWebMixin:
|
||||
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.
|
||||
"""
|
||||
if getattr(self, "_closing", False):
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
return
|
||||
if not call.begin():
|
||||
return
|
||||
previous_capture = self._web_action_error_capture
|
||||
capture: list[str] = []
|
||||
self._web_action_error_capture = capture
|
||||
error = None
|
||||
try:
|
||||
action(*args)
|
||||
call.error = capture[0] if capture else None
|
||||
error = capture[0] if capture else None
|
||||
self._web_update_snapshot()
|
||||
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
|
||||
call.error = self._exception_summary(exc)
|
||||
error = self._exception_summary(exc)
|
||||
finally:
|
||||
self._web_action_error_capture = previous_capture
|
||||
call.done.set()
|
||||
call.finish(error)
|
||||
|
||||
def _load_web_config(self, name: str) -> None:
|
||||
"""Load a run config chosen in the browser through the shared desktop load path.
|
||||
@@ -289,12 +362,11 @@ class AppWindowWebMixin:
|
||||
"""
|
||||
config_path = _safe_run_config_path(self._run_configs_dir, name)
|
||||
if config_path is None:
|
||||
self._log_warning(f"Ignored web request to load unknown run config: {name}")
|
||||
return
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self._load_config_from_path(config_path)
|
||||
|
||||
def _save_web_dataset(self, path: str, name: str) -> None:
|
||||
"""Save the dataset from the browser via the same handler as the desktop button.
|
||||
"""Save the last 1000 measurements via the shared desktop handler.
|
||||
|
||||
The web fields are remote editors of the shared save-path/name widgets: the name
|
||||
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
|
||||
@@ -313,11 +385,15 @@ class AppWindowWebMixin:
|
||||
path keeps the configured destination), the record count is applied to the shared
|
||||
spinbox, then the unchanged desktop arming action runs.
|
||||
"""
|
||||
if type(count) is not int or not self._record_count.minimum() <= count <= self._record_count.maximum():
|
||||
raise ValueError(
|
||||
f"Recording count must be an integer from {self._record_count.minimum()} "
|
||||
f"to {self._record_count.maximum()}"
|
||||
)
|
||||
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._record_count.setValue(count)
|
||||
self._start_run_with_recording()
|
||||
|
||||
def _web_update_snapshot(self) -> None:
|
||||
@@ -349,6 +425,16 @@ class AppWindowWebMixin:
|
||||
"raw_count": len(self._raw_history),
|
||||
"preprocessed_count": len(self._pre_history),
|
||||
"result_count": len(self._result_history),
|
||||
"sar3d_status": self._sar3d_status_label.text()
|
||||
if self._processing_mode.currentText() == "sar3d" else "",
|
||||
"display_combo": {
|
||||
"enabled": self._is_matrix_radar_model_selected()
|
||||
and self._processing_mode.currentText() == "bscan",
|
||||
"input": self._single_combo_input.text(),
|
||||
"output": self._single_combo_output.text(),
|
||||
"options": self._available_display_combo_keys()
|
||||
if self._is_matrix_radar_model_selected() else [],
|
||||
},
|
||||
},
|
||||
live_settings=self._web_settings_schema(),
|
||||
frame=self._web_grab_frame_if_due(),
|
||||
@@ -377,8 +463,10 @@ class AppWindowWebMixin:
|
||||
}
|
||||
|
||||
def _grab_plot_png_b64(self) -> str | None:
|
||||
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
|
||||
"""Render the active plots; SAR3D status is sent separately as web text."""
|
||||
widget = self._plot_stack.currentWidget()
|
||||
if widget is self._sar3d_plots_container:
|
||||
widget = self._sar3d_plot_grid
|
||||
if widget is None or widget.width() <= 0 or widget.height() <= 0:
|
||||
return None
|
||||
pixmap = widget.grab()
|
||||
@@ -391,6 +479,9 @@ class AppWindowWebMixin:
|
||||
|
||||
def _shutdown_web_ui(self) -> None:
|
||||
"""Stop the server (and its broadcaster) during teardown."""
|
||||
controller = getattr(self, "_web_controller", None)
|
||||
if controller is not None:
|
||||
controller.shutdown()
|
||||
server = getattr(self, "_web_server", None)
|
||||
if server is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
|
||||
Reference in New Issue
Block a user