diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index 5d03537..7eeaf53 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -123,8 +123,24 @@ class flomniGuiTools: # dev.cam_flomni_overview.stop_live_mode() # dev.cam_flomni_gripper.stop_live_mode() # dev.cam_xeye.live_mode = False - if hasattr(self.gui, "flomni"): - self.gui.flomni.delete_all(timeout=self.GUI_RPC_TIMEOUT) + flomni_window = self._flomnigui_get_window() + if flomni_window is not None: + flomni_window.delete_all(timeout=self.GUI_RPC_TIMEOUT) + self._flomnigui_clear_widget_references() + + def _flomnigui_get_window(self): + """Return the flOMNI dock area proxy without falling back to root GUI actions.""" + flomni_window = getattr(self, "flomni_window", None) + if flomni_window is not None: + if not (hasattr(flomni_window, "_is_deleted") and flomni_window._is_deleted()): + return flomni_window + + flomni_window = self.gui.windows.get("flomni") + if flomni_window is not None: + self.flomni_window = flomni_window + return flomni_window + + def _flomnigui_clear_widget_references(self): self.progressbar = None self.text_box = None self.xeyegui = None @@ -231,56 +247,61 @@ class flomniGuiTools: client.get_global_var("tomo_progress") """ + if self.progressbar is None: + return + if hasattr(self.progressbar, "_is_deleted") and self.progressbar._is_deleted(): + self.progressbar = None + return + main_progress_ring = self.progressbar.rings[0] subtomo_progress_ring = self.progressbar.rings[1] - if self.progressbar is not None: - progress = self.progress["projection"] / self.progress["total_projections"] * 100 - subtomo_progress = ( - self.progress["subtomo_projection"] - / self.progress["subtomo_total_projections"] - * 100 - ) - main_progress_ring.set_value(progress) - subtomo_progress_ring.set_value(subtomo_progress) + total_projections = self.progress["total_projections"] or 1 + subtomo_total_projections = self.progress["subtomo_total_projections"] or 1 + progress = self.progress["projection"] / total_projections * 100 + subtomo_progress = self.progress["subtomo_projection"] / subtomo_total_projections * 100 + main_progress_ring.set_value(progress) + subtomo_progress_ring.set_value(subtomo_progress) - # --- format start time for display -------------------------------- - start_str = self.progress.get("tomo_start_time") - if start_str is not None: - import datetime as _dt - start_display = _dt.datetime.fromisoformat(start_str).strftime("%Y-%m-%d %H:%M:%S") + # --- format start time for display -------------------------------- + start_str = self.progress.get("tomo_start_time") + if start_str is not None: + import datetime as _dt + + start_display = _dt.datetime.fromisoformat(start_str).strftime("%Y-%m-%d %H:%M:%S") + else: + start_display = "N/A" + + # --- format estimated remaining time ------------------------------ + remaining_s = self.progress.get("estimated_remaining_time") + if remaining_s is not None and remaining_s >= 0: + import datetime as _dt + + remaining_s = int(remaining_s) + h, rem = divmod(remaining_s, 3600) + m, s = divmod(rem, 60) + if h > 0: + eta_display = f"{h}h {m:02d}m {s:02d}s" + elif m > 0: + eta_display = f"{m}m {s:02d}s" else: - start_display = "N/A" + eta_display = f"{s}s" + else: + eta_display = "N/A" + # ------------------------------------------------------------------ - # --- format estimated remaining time ------------------------------ - remaining_s = self.progress.get("estimated_remaining_time") - if remaining_s is not None and remaining_s >= 0: - import datetime as _dt - remaining_s = int(remaining_s) - h, rem = divmod(remaining_s, 3600) - m, s = divmod(rem, 60) - if h > 0: - eta_display = f"{h}h {m:02d}m {s:02d}s" - elif m > 0: - eta_display = f"{m}m {s:02d}s" - else: - eta_display = f"{s}s" - else: - eta_display = "N/A" - # ------------------------------------------------------------------ - - text = ( - f"Progress report:\n" - f" Tomo type: {self.progress['tomo_type']}\n" - f" Projection: {self.progress['projection']:.0f}\n" - f" Total projections expected {self.progress['total_projections']:.1f}\n" - f" Angle: {self.progress['angle']:.1f}\n" - f" Current subtomo: {self.progress['subtomo']}\n" - f" Current projection within subtomo: {self.progress['subtomo_projection']}\n" - f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n" - f" Scan started: {start_display}\n" - f" Est. remaining: {eta_display}" - ) - self.progressbar.set_center_label(text) + text = ( + f"Progress report:\n" + f" Tomo type: {self.progress['tomo_type']}\n" + f" Projection: {self.progress['projection']:.0f}\n" + f" Total projections expected {self.progress['total_projections']:.1f}\n" + f" Angle: {self.progress['angle']:.1f}\n" + f" Current subtomo: {self.progress['subtomo']}\n" + f" Current projection within subtomo: {self.progress['subtomo_projection']}\n" + f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n" + f" Scan started: {start_display}\n" + f" Est. remaining: {eta_display}" + ) + self.progressbar.set_center_label(text) if __name__ == "__main__": diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index ec80ca6..7f2ff2c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -93,6 +93,16 @@ class XrayEyeAlign: def align(self, keep_shutter_open=False): self.flomni.flomnigui_show_xeyealign() + self.gui.set_dap_params_forwarding(True) + try: + self._align_impl(keep_shutter_open) + finally: + try: + self.gui.set_dap_params_forwarding(False) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {exc}") + + def _align_impl(self, keep_shutter_open=False): if not keep_shutter_open: print( "This routine can be called with paramter keep_shutter_open=True to keep the shutter always open" diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 9d5c5bb..bef6d7c 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -102,6 +102,13 @@ class XRayEye(RPCBase): None """ + @rpc_timeout(20) + @rpc_call + def set_dap_params_forwarding(self, enabled: "bool"): + """ + Connect or disconnect DAP fit parameter forwarding to omny_xray_gui. + """ + @rpc_timeout(20) @rpc_call def submit_fit_array(self, fit_array): diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index f5fd0af..bedd2b5 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -1,14 +1,17 @@ from __future__ import annotations +import threading +import time + from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.widgets.plots.image.image import Image -from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI +from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( @@ -20,11 +23,11 @@ from qtpy.QtWidgets import ( QPushButton, QSizePolicy, QSpinBox, + QTabWidget, + QTextEdit, QToolButton, QVBoxLayout, QWidget, - QTextEdit, - QTabWidget, ) logger = bec_logger.logger @@ -116,16 +119,38 @@ class XRayEye2DControl(BECWidget, QWidget): if tweak: step = int(self._step_size / 5) if direction == "up": - self.dev.omny_xray_gui.mvy.set(step) + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.mvy.set(step), "set X-ray eye Y move" + ) elif direction == "down": - self.dev.omny_xray_gui.mvy.set(-step) + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.mvy.set(-step), "set X-ray eye Y move" + ) elif direction == "left": - self.dev.omny_xray_gui.mvx.set(-step) + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.mvx.set(-step), "set X-ray eye X move" + ) elif direction == "right": - self.dev.omny_xray_gui.mvx.set(step) + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.mvx.set(step), "set X-ray eye X move" + ) else: logger.warning(f"Unknown direction {direction} for move command.") + def _run_device_action_async(self, action, description: str) -> None: + parent = self.parent() + if hasattr(parent, "_run_device_action_async"): + parent._run_device_action_async(action, description) + return + + def runner(): + try: + action() + except Exception as exc: + logger.warning(f"Failed to {description}: {exc}") + + threading.Thread(target=runner, daemon=True).start() + class XRayEye(BECWidget, QWidget): USER_ACCESS = [ @@ -140,6 +165,7 @@ class XRayEye(BECWidget, QWidget): "enable_move_buttons", "enable_move_buttons.setter", "switch_tab", + "set_dap_params_forwarding", "submit_fit_array", ] PLUGIN = True @@ -147,6 +173,22 @@ class XRayEye(BECWidget, QWidget): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._connected_motor = None + self._dap_params_forwarding_connected = False + self._queue_busy = False + self._queue_idle_timer = QTimer(self) + self._queue_idle_timer.setSingleShot(True) + self._queue_idle_timer.setInterval(800) + self._queue_idle_timer.timeout.connect(self._release_queue_toggles) + self._current_step = 0 + self._fit_tab_initialized = False + self._live_view_enabled = False + self._live_view_requested = False + self._live_view_apply_pending = False + self._fit_tab_init_pending = False + self._pending_fit_array = None + self._closing = False + self._device_action_lock = threading.Lock() + self._device_action_threads = set() self.get_bec_shortcuts() self._init_ui() @@ -159,10 +201,16 @@ class XRayEye(BECWidget, QWidget): self.bec_dispatcher.connect_slot( self.getting_camera_status, MessageEndpoints.device_read_configuration(CAMERA[0]) ) + self.bec_dispatcher.connect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + self.bec_dispatcher.connect_slot( + self.getting_xray_gui_readback, MessageEndpoints.device_readback("omny_xray_gui") + ) self.connect_motors() self.resize(800, 600) - QTimer.singleShot(0, self._init_gui_trigger) + QTimer.singleShot(0, self._init_queue_status) def _init_ui(self): self.root_layout = QVBoxLayout(self) @@ -203,7 +251,8 @@ class XRayEye(BECWidget, QWidget): header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addLayout(header_row) - switch_row = QHBoxLayout() + self.switch_row_widget = QWidget(parent=self) + switch_row = QHBoxLayout(self.switch_row_widget) switch_row.setContentsMargins(0, 0, 0, 0) switch_row.setSpacing(8) switch_row.addStretch() @@ -219,7 +268,7 @@ class XRayEye(BECWidget, QWidget): switch_row.addWidget(self.shutter_toggle, 0, Qt.AlignmentFlag.AlignVCenter) switch_row.addWidget(self.camera_running_label, 0, Qt.AlignmentFlag.AlignVCenter) switch_row.addWidget(self.camera_running_toggle, 0, Qt.AlignmentFlag.AlignVCenter) - self.control_panel_layout.addLayout(switch_row) + self.control_panel_layout.addWidget(self.switch_row_widget) # separator self.control_panel_layout.addWidget(self._create_separator()) @@ -280,6 +329,12 @@ class XRayEye(BECWidget, QWidget): self.fit_tab = QWidget(parent=self) self.fit_layout = QVBoxLayout(self.fit_tab) + self.tab_widget.addTab(self.fit_tab, "Fit") + + def _ensure_fit_tab(self): + if self._fit_tab_initialized: + return + self.waveform_x = Waveform(parent=self.fit_tab) self.waveform_y = Waveform(parent=self.fit_tab) @@ -308,9 +363,6 @@ class XRayEye(BECWidget, QWidget): self.fit_x = self.waveform_x.curves[0] self.fit_y = self.waveform_y.curves[0] - self.waveform_x.dap_params_update.connect(self.on_dap_params) - self.waveform_y.dap_params_update.connect(self.on_dap_params) - for wave in (self.waveform_x, self.waveform_y): wave.x_label = "Angle (deg)" wave.x_grid = True @@ -319,11 +371,10 @@ class XRayEye(BECWidget, QWidget): self.fit_layout.addWidget(self.waveform_x) self.fit_layout.addWidget(self.waveform_y) - self.tab_widget.addTab(self.fit_tab, "Fit") + self._fit_tab_initialized = True def _make_connections(self): - # Fetch initial state - self.on_live_view_enabled(True) + # Keep construction lightweight. Live preview is enabled explicitly by GUI actions/RPC. self.step_size.setValue(self.motor_control_2d.step_size) # Make connections @@ -332,6 +383,9 @@ class XRayEye(BECWidget, QWidget): lambda x: self.motor_control_2d.setProperty("step_size", x) ) self.submit_button.clicked.connect(self.submit) + self.tab_widget.currentChanged.connect( + lambda index: self._schedule_fit_tab_init() if index == 1 else None + ) def _create_separator(self): sep = QFrame(parent=self) @@ -340,10 +394,6 @@ class XRayEye(BECWidget, QWidget): sep.setLineWidth(1) return sep - def _init_gui_trigger(self): - self.dev.omny_xray_gui.read() - self.dev.fsh.read() - ################################################################################ # Device Connection logic ################################################################################ @@ -389,6 +439,70 @@ class XRayEye(BECWidget, QWidget): def enable_move_buttons(self, enabled: bool): self.motor_control_2d.setEnabled(enabled) + def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ToggleSwitch, ToggleSwitch]: + return (self.live_preview_toggle, self.shutter_toggle, self.camera_running_toggle) + + def _set_queue_toggles_blocked(self, blocked: bool): + if blocked == self._queue_busy: + return + + self._queue_busy = blocked + self._refresh_queue_toggle_availability() + + def _refresh_queue_toggle_availability(self): + tooltip = "Disabled while scan queue is busy." if self._queue_busy else "" + for toggle in self._queue_guarded_toggles(): + toggle.setEnabled(not self._queue_busy) + toggle.setToolTip(tooltip) + + def _manual_toggle_blocked_by_queue(self) -> bool: + return self._queue_busy and self.sender() in self._queue_guarded_toggles() + + def _update_queue_toggles_from_busy_state(self, busy: bool): + if busy: + self._queue_idle_timer.stop() + self._set_queue_toggles_blocked(True) + return + + if self._queue_busy and not self._queue_idle_timer.isActive(): + self._queue_idle_timer.start() + + def _release_queue_toggles(self): + self._set_queue_toggles_blocked(False) + + def _init_queue_status(self): + try: + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + except Exception as exc: + logger.warning(f"Failed to fetch initial scan queue status for XRayEye: {exc}") + return + + if msg is None: + return + self._update_queue_toggles_from_busy_state(self._is_queue_busy(msg.content)) + + @staticmethod + def _is_queue_busy(msg_content: dict) -> bool: + queues = msg_content.get("queue", {}) if isinstance(msg_content, dict) else {} + primary_queue = queues.get("primary") if isinstance(queues, dict) else None + if primary_queue is None: + return False + + queue_info = getattr(primary_queue, "info", None) + if queue_info is None and isinstance(primary_queue, dict): + queue_info = primary_queue.get("info", []) + if not queue_info: + return False + + idle_statuses = {"STOPPED", "COMPLETED", "IDLE"} + for item in queue_info: + status = getattr(item, "status", None) + if status is None and isinstance(item, dict): + status = item.get("status") + if str(status).upper() not in idle_statuses: + return True + return False + def active_roi(self) -> BaseROI | None: """Return the currently active ROI, or None if no ROI is active.""" return self.roi_manager.single_active_roi @@ -402,6 +516,7 @@ class XRayEye(BECWidget, QWidget): def switch_tab(self, tab: str): if tab == "fit": self.tab_widget.setCurrentIndex(1) + self._schedule_fit_tab_init() else: self.tab_widget.setCurrentIndex(0) @@ -418,52 +533,104 @@ class XRayEye(BECWidget, QWidget): @SafeSlot(bool) @rpc_timeout(20) def on_live_view_enabled(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring live-preview toggle while scan queue is busy.") + return logger.info(f"Live view is enabled: {enabled}") self.live_preview_toggle.blockSignals(True) - if enabled: + try: + self._live_view_requested = bool(enabled) self.live_preview_toggle.checked = enabled - self.image.image(device=CAMERA[0], signal=CAMERA[1]) + finally: self.live_preview_toggle.blockSignals(False) - return + self._schedule_live_view_apply() - self.image.disconnect_monitor(CAMERA[0], CAMERA[1]) - self.live_preview_toggle.checked = enabled - self.live_preview_toggle.blockSignals(False) + def _schedule_live_view_apply(self): + if self._closing or self._live_view_apply_pending: + return + self._live_view_apply_pending = True + QTimer.singleShot(0, self._apply_live_view_state) + + def _apply_live_view_state(self): + self._live_view_apply_pending = False + if self._closing: + return + enabled = self._live_view_requested + if enabled == self._live_view_enabled: + return + try: + if enabled: + self.image.image(device=CAMERA[0], signal=CAMERA[1]) + else: + self.image.disconnect_monitor(CAMERA[0], CAMERA[1]) + self._live_view_enabled = enabled + except Exception as exc: + logger.warning(f"Failed to apply XRayEye live view state {enabled}: {exc}") + return + if enabled != self._live_view_requested: + self._schedule_live_view_apply() @SafeSlot(bool) def camera_running_enabled(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring camera live-mode toggle while scan queue is busy.") + return logger.info(f"Camera running: {enabled}") self.camera_running_toggle.blockSignals(True) - self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled) self.camera_running_toggle.checked = enabled self.camera_running_toggle.blockSignals(False) + self._run_device_action_async( + lambda: self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled), "set camera live mode" + ) @SafeSlot(dict, dict) def getting_camera_status(self, data, meta): - print(f"msg:{data}") - live_mode_enabled = data.get("signals").get(f"{CAMERA[0]}_live_mode_enabled").get("value") + _ = meta + live_mode_enabled = ( + data.get("signals", {}).get(f"{CAMERA[0]}_live_mode_enabled", {}).get("value", False) + ) self.camera_running_toggle.blockSignals(True) self.camera_running_toggle.checked = live_mode_enabled self.camera_running_toggle.blockSignals(False) @SafeSlot(bool) def opening_shutter(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring shutter toggle while scan queue is busy.") + return logger.info(f"Shutter changed from GUI to: {enabled}") self.shutter_toggle.blockSignals(True) if enabled: - self.dev.fsh.fshopen() + self._run_device_action_async(self.dev.fsh.fshopen, "open fast shutter") else: - self.dev.fsh.fshclose() + self._run_device_action_async(self.dev.fsh.fshclose, "close fast shutter") # self.shutter_toggle.checked = enabled self.shutter_toggle.blockSignals(False) @SafeSlot(dict, dict) def getting_shutter_status(self, data, meta): - shutter_open = bool(data.get("signals").get("fsh_shutter").get("value")) + _ = meta + shutter_open = bool(data.get("signals", {}).get("fsh_shutter", {}).get("value", False)) self.shutter_toggle.blockSignals(True) self.shutter_toggle.checked = shutter_open self.shutter_toggle.blockSignals(False) + @SafeSlot(dict, dict) + def on_queue_status_update(self, data, meta): + _ = meta + self._update_queue_toggles_from_busy_state(self._is_queue_busy(data)) + + @SafeSlot(dict, dict) + def getting_xray_gui_readback(self, data, meta): + _ = meta + step = data.get("signals", {}).get("omny_xray_gui_step", {}).get("value") + if step is None: + return + try: + self._current_step = int(step) + except (TypeError, ValueError): + logger.warning(f"Invalid X-ray eye GUI step value: {step!r}") + @SafeSlot(bool, bool) @rpc_timeout(20) def on_motors_enable(self, x_enable: bool, y_enable: bool): @@ -490,41 +657,88 @@ class XRayEye(BECWidget, QWidget): else: self.submit_button.setEnabled(False) + @SafeSlot(bool) + @rpc_timeout(20) + def set_dap_params_forwarding(self, enabled: bool): + """ + Connect or disconnect DAP fit parameter forwarding to omny_xray_gui. + """ + if enabled == self._dap_params_forwarding_connected: + return + + signals = (self.waveform_x.dap_params_update, self.waveform_y.dap_params_update) + if enabled: + for signal in signals: + signal.connect(self.on_dap_params) + self._dap_params_forwarding_connected = True + logger.info("Enabled XRayEye DAP parameter forwarding.") + return + + for signal in signals: + try: + signal.disconnect(self.on_dap_params) + except (TypeError, RuntimeError): + pass + self._dap_params_forwarding_connected = False + logger.info("Disabled XRayEye DAP parameter forwarding.") + @SafeSlot(dict, dict) def on_dap_params(self, data, meta): - print("#######################################") - print("getting dap parameters") - print(f"data: {data}") - print(f"meta: {meta}") + self._ensure_fit_tab() self.waveform_x.auto_range(True) self.waveform_y.auto_range(True) # self.bec_dispatcher.disconnect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui")) curve_id = meta.get("curve_id") if curve_id == "fit-x-SineModel+LinearModel": - self.dev.omny_xray_gui.fit_params_x.set(data).wait() - print(f"setting x data to {data}") + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.fit_params_x.set(data), + "set X-ray eye fit parameters X", + ) else: - self.dev.omny_xray_gui.fit_params_y.set(data).wait() - print(f"setting y data to {data}") + self._run_device_action_async( + lambda: self.dev.omny_xray_gui.fit_params_y.set(data), + "set X-ray eye fit parameters Y", + ) # self.bec_dispatcher.connect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui")) @SafeSlot(bool, bool) def on_tomo_angle_readback(self, data: dict, meta: dict): # TODO implement if needed - print(f"data: {data}") - print(f"meta: {meta}") + _ = data, meta @SafeSlot() @rpc_timeout(20) def submit_fit_array(self, fit_array): self.tab_widget.setCurrentIndex(1) - # self.fix_x.title = " got fit array" - print(f"got fit array {fit_array}") + self._pending_fit_array = fit_array + self._schedule_fit_tab_init() + + def _schedule_fit_tab_init(self): + if self._closing: + return + if self._fit_tab_initialized: + self._apply_pending_fit_array() + return + if self._fit_tab_init_pending: + return + self._fit_tab_init_pending = True + QTimer.singleShot(0, self._ensure_fit_tab_and_apply_pending) + + def _ensure_fit_tab_and_apply_pending(self): + self._fit_tab_init_pending = False + if self._closing: + return + self._ensure_fit_tab() + self._apply_pending_fit_array() + + def _apply_pending_fit_array(self): + if self._pending_fit_array is None or not self._fit_tab_initialized: + return + fit_array = self._pending_fit_array + self._pending_fit_array = None self.waveform_x.curves[0].set_data(x=fit_array[0], y=fit_array[1]) self.waveform_y.curves[0].set_data(x=fit_array[0], y=fit_array[2]) - # self.fit_x.set_data(x=fit_array[0],y=fit_array[1]) - # self.fit_y.set_data(x=fit_array[0],y=fit_array[2]) @SafeSlot() def submit(self): @@ -549,18 +763,35 @@ class XRayEye(BECWidget, QWidget): return # submit roi coordinates - step = int(self.dev.omny_xray_gui.step.read().get("omny_xray_gui_step").get("value")) + step = self._current_step - getattr(self.dev.omny_xray_gui, f"xval_x_{step}").set(roi_center_x) - getattr(self.dev.omny_xray_gui, f"yval_y_{step}").set(roi_center_y) - getattr(self.dev.omny_xray_gui, f"width_x_{step}").set(roi_width) - getattr(self.dev.omny_xray_gui, f"width_y_{step}").set(roi_height) - self.dev.omny_xray_gui.submit.set(1) + self._run_device_action_async( + lambda: self._submit_roi_values( + step, roi_center_x, roi_center_y, roi_width, roi_height + ), + "submit X-ray eye ROI values", + ) finally: self.submit_button.blockSignals(False) + def _submit_roi_values( + self, + step: int, + roi_center_x: float, + roi_center_y: float, + roi_width: float, + roi_height: float, + ) -> None: + getattr(self.dev.omny_xray_gui, f"xval_x_{step}").set(roi_center_x) + getattr(self.dev.omny_xray_gui, f"yval_y_{step}").set(roi_center_y) + getattr(self.dev.omny_xray_gui, f"width_x_{step}").set(roi_width) + getattr(self.dev.omny_xray_gui, f"width_y_{step}").set(roi_height) + self.dev.omny_xray_gui.submit.set(1) + def cleanup(self): """Cleanup connections on widget close -> disconnect slots and stop live mode of camera.""" + self._queue_idle_timer.stop() + self._closing = True if self._connected_motor is not None: self.bec_dispatcher.disconnect_slot( self.on_tomo_angle_readback, MessageEndpoints.device_readback(self._connected_motor) @@ -572,17 +803,74 @@ class XRayEye(BECWidget, QWidget): self.bec_dispatcher.disconnect_slot( self.getting_camera_status, MessageEndpoints.device_read_configuration(CAMERA[0]) ) + self.bec_dispatcher.disconnect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + self.bec_dispatcher.disconnect_slot( + self.getting_xray_gui_readback, MessageEndpoints.device_readback("omny_xray_gui") + ) - getattr(self.dev, CAMERA[0]).stop_live_mode() + self._run_device_action_async( + getattr(self.dev, CAMERA[0]).stop_live_mode, + "stop camera live mode", + allow_during_cleanup=True, + ) + self._join_device_actions(timeout=1.0) super().cleanup() + def _run_device_action_async( + self, action, description: str, *, allow_during_cleanup: bool = False + ) -> threading.Thread | None: + if self._closing and not allow_during_cleanup: + logger.debug(f"Skipping device action during XRayEye cleanup: {description}") + return None + + def runner(): + try: + if self._closing and not allow_during_cleanup: + return + action() + except Exception as exc: + logger.warning(f"Failed to {description}: {exc}") + finally: + with self._device_action_lock: + self._device_action_threads.discard(thread) + + thread = threading.Thread(target=runner, daemon=True, name=f"XRayEye: {description}") + with self._device_action_lock: + if self._closing and not allow_during_cleanup: + return None + self._device_action_threads.add(thread) + thread.start() + return thread + + def _join_device_actions(self, timeout: float) -> None: + deadline = time.monotonic() + timeout + while True: + with self._device_action_lock: + threads = [ + thread + for thread in self._device_action_threads + if thread is not threading.current_thread() + ] + if not threads: + return + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.warning( + f"{len(threads)} XRayEye device action(s) still running during cleanup." + ) + return + for thread in threads: + thread.join(timeout=min(remaining, 0.1)) + if __name__ == "__main__": import sys - from qtpy.QtWidgets import QApplication from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme + from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) apply_theme("light")