feat(flomni): make sample transfer abortable via Ctrl+C and GUI abort button

The gripper routines (#GRGET/#GRPUT) run autonomously on the Galil
(thread 3); Ctrl+C previously only killed the BEC-side polling loop
while the controller kept executing, including ongoing motion.

- add FlomniSampleTransferMixin.ftransfer_abort(): dev.ftransy.stop()
  (stop_devices endpoint -> device server -> XQ#STOP), wait for thread 3
  to halt, then put the controller back in posmode; deliberately no
  ensure_gripper_up() - recovery after an abort is manual
- wrap the mntprgs polling loops in ftransfer_get_sample/put_sample in
  try/except KeyboardInterrupt -> ftransfer_abort()
- answering "No" at a confirmation point now also hard-aborts the
  controller routine instead of leaving it parked in #CONFIRL
- ConsoleButtonsWidget ABORT: keep SIGINT to the client, plus a delayed
  (500 ms) backup stop_devices request so the hardware stops even if
  the client is hung; delay ensures the controlled client-side abort
  wins the race against #STOP clearing mntprgs
This commit is contained in:
x12sa
2026-07-07 21:44:57 +02:00
parent 6be7e7f369
commit 936fc294a0
2 changed files with 93 additions and 23 deletions
@@ -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 = ""):
@@ -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):