diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 87e7d52..9a463c9 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -771,15 +771,22 @@ class FlomniSampleTransferMixin: self.transfer_step = 0 - time.sleep(1) - while True: - in_progress = bool( - float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip()) - ) - if not in_progress: - break - self.ftransfer_confirm(step_name="get") + try: time.sleep(1) + while True: + in_progress = bool( + float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip()) + ) + if not in_progress: + break + self.ftransfer_confirm(step_name="get") + time.sleep(1) + except KeyboardInterrupt: + self.ftransfer_abort() + raise FlomniError( + "Sample transfer aborted by user (Ctrl+C / GUI abort). Assess gripper and" + " sample state manually before continuing." + ) from None self.ftransfer_controller_disable_mount_mode() self.ensure_gripper_up() @@ -820,17 +827,24 @@ class FlomniSampleTransferMixin: print("The mount process started.") - time.sleep(1) - self.transfer_step = 0 - while True: - in_progress = bool( - float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip()) - ) - if not in_progress: - break - - self.ftransfer_confirm(step_name="put") + try: time.sleep(1) + self.transfer_step = 0 + while True: + in_progress = bool( + float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip()) + ) + if not in_progress: + break + + self.ftransfer_confirm(step_name="put") + time.sleep(1) + except KeyboardInterrupt: + self.ftransfer_abort() + raise FlomniError( + "Sample transfer aborted by user (Ctrl+C / GUI abort). Assess gripper and" + " sample state manually before continuing." + ) from None self.ftransfer_controller_disable_mount_mode() self.ensure_gripper_up() @@ -995,6 +1009,38 @@ class FlomniSampleTransferMixin: ) return in_mount_mode + def ftransfer_abort(self): + """ + Hard abort of a running sample transfer routine on the Galil controller. + + Stops the controller via dev.ftransy.stop(), which publishes a stop + request that the device server turns into motor.stop() and thus + XQ#STOP on the controller. #STOP halts the transfer thread (3), + aborts all motion (AB1) and clears mntprgs/mntmod. Afterwards the + controller is put back into positioning mode. + + Deliberately does NOT call ensure_gripper_up(): after a mid-transfer + abort the gripper may be closed around a partially inserted sample, + so any recovery motion must be assessed and performed manually. + """ + print("Aborting sample transfer: stopping the controller routine.") + dev.ftransy.stop() + # The stop request is asynchronous (Redis -> device server). Wait + # until the transfer thread is actually halted before switching mode: + # #POSMODE refuses while mntprgs=1 and disable_mount_mode would raise. + timeout = 5 + start = time.time() + while dev.ftransy.controller.is_thread_active(3): + if time.time() - start > timeout: + raise FlomniError( + "Transfer abort requested but the controller transfer routine (thread 3)" + f" did not stop within {timeout} s. Check the controller." + ) + time.sleep(0.1) + # Ensure the controller is back in positioning mode. #STOP already + # clears mntmod, so this is mostly a verification step. + self.ftransfer_controller_disable_mount_mode() + def ftransfer_confirm(self, step_name: str = ""): confirm = int(float(dev.ftransy.controller.socket_put_and_receive("MG confirm").strip())) @@ -1011,6 +1057,7 @@ class FlomniSampleTransferMixin: dev.ftransy.controller.socket_put_confirmed("confirm=1") else: print("Stopping.") + self.ftransfer_abort() raise FlomniError("User abort sample transfer.") def save_reference_image(self, image: np.ndarray, file_suffix: str = ""): diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py index 88cb429..e0ee7bc 100644 --- a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py @@ -4,8 +4,11 @@ import os import signal from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import VariableMessage from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout +from qtpy.QtCore import QTimer from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget logger = bec_logger.logger @@ -23,17 +26,22 @@ class ConsoleButtonsWidget(BECWidget, QWidget): parent of the GUI server process), equivalent to pressing Ctrl+C in the console. This works even if the client is blocked inside a motor move or other long call, since it is a real OS signal rather than a - polled flag. - - Not yet wired into any CLI method -- this widget is for standalone - testing first (open it manually with ``gui.new(...)`` and click the - buttons / read back ``response``). + polled flag. As a backup, 500 ms later a device stop request is + published to the device server (same mechanism as the PositionerBox + stop button), so the hardware is stopped even if the client process + is hung or dead. """ USER_ACCESS = ["message", "message.setter", "response", "clear_response"] PLUGIN = True def __init__(self, parent=None, **kwargs): + # Devices for which a backup stop request is published when ABORT is + # pressed (in addition to the SIGINT). An empty list means "stop ALL + # devices". For flomni, stopping ftransy is enough for the sample + # transfer: XQ#STOP is controller-wide, so it aborts all axes on + # transfer controller 1 and halts the #GRGET/#GRPUT thread. + self._backup_stop_devices = list(kwargs.pop("backup_stop_devices", ["ftransy"])) super().__init__(parent=parent, **kwargs) self._response = "" # Captured once at construction time: the GUI server process is a @@ -89,6 +97,21 @@ class ConsoleButtonsWidget(BECWidget, QWidget): def _on_abort(self): logger.warning(f"ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}") os.kill(self._client_pid, signal.SIGINT) + # Backup: direct device stop via the device server, independent of + # the client process. Delayed so the SIGINT-triggered abort handler + # in the client (which still sees mntprgs=1 and aborts in a + # controlled way) wins the race: if the stop landed first, #STOP + # would clear mntprgs and the client transfer loop would exit + # "cleanly" into ensure_gripper_up, which must not happen + # mid-transfer. + QTimer.singleShot(500, self._send_backup_stop) + + @SafeSlot() + def _send_backup_stop(self): + """Publish a stop request for the configured devices to the device server.""" + devices = self._backup_stop_devices + logger.warning(f"ConsoleButtonsWidget: sending backup stop request for {devices}") + self.client.connector.send(MessageEndpoints.stop_devices(), VariableMessage(value=devices)) @SafeProperty(str) def message(self):