feat(flomni): add GUI confirm/abort console for sample transfer

Add a small ConsoleButtonsWidget (Yes/No/Abort + message label) as a
GUI alternative to console yesno() prompts during sample transfer,
docked below the live camera view.

- bec_widgets: new ConsoleButtonsWidget (csaxs_bec/bec_widgets/widgets/
  console_buttons/). Yes/No write to a response property polled by the
  caller; Abort sends a real SIGINT to the BEC client process (captured
  via os.getppid() at construction), stopping any blocking call
  (including an in-progress motor move) exactly like a console Ctrl+C.

- omny_general_tools: add OMNYTools.gui_yesno(), a GUI-backed
  alternative to yesno() with the same message/default/autoconfirm
  semantics, polling the widget's response instead of blocking on
  input(). Not yet used as a general yesno() replacement.

- gui_tools: flomnigui_show_cameras() now also opens the console widget
  docked below the two camera views (set_layout_ratios for relative
  sizing, since dock_area.new() has no pixel width/height kwarg).
  flomnigui_show_gui() now passes an explicit window geometry, right-
  aligned on a 2560x1440 screen.

- flomni: add ftransfer_confirm_dialog(), which uses the GUI console
  when available (falling back to the plain CLI yesno() otherwise) and
  wire it into ftransfer_confirm()'s per-step "All OK? Continue?"
  prompt during sample transfer. Other yesno() call sites (stage-in
  flight-tube check, gripper-move stage-out check) intentionally left
  on the CLI for now.

Known issue: requested window height in flomnigui_show_gui() is not
taking effect as expected at startup; needs follow-up.
This commit is contained in:
x12sa
2026-07-12 07:28:10 +02:00
committed by holler
co-authored by holler
parent 439f3b9e2c
commit c044d2b6c1
10 changed files with 353 additions and 11 deletions
@@ -421,6 +421,22 @@ class FlomniSampleTransferMixin:
if not low:
raise FlomniError("Ftray is not at the 'IN' position. Aborting.")
def ftransfer_confirm_dialog(self, message: str, default: str = "none") -> bool:
"""
Yes/No confirmation for sample-transfer steps.
Uses the GUI console widget (ConsoleButtonsWidget, opened by
flomnigui_show_cameras()) when it's already available, so the
operator can confirm/abort right next to the live camera view.
Falls back to the regular console yesno() prompt if the
cameras/console haven't been opened yet -- e.g. if a transfer step
is called directly, outside the usual ftransfer_get_sample /
ftransfer_put_sample sequence that opens them first.
"""
if self.console is not None:
return self.OMNYTools.gui_yesno(message, gui=self.console, default=default)
return self.OMNYTools.yesno(message, default)
def ftransfer_flomni_stage_in(self):
time.sleep(1)
sample_in_position = dev.flomni_samples.is_sample_slot_used(0)
@@ -879,6 +895,11 @@ class FlomniSampleTransferMixin:
self.check_position_is_valid(new_sample_position)
if new_sample_position == 0:
raise FlomniError(
"The new sample to place cannot be the sample in the sample stage. Aborting."
)
# sample_placed = getattr(
# dev.flomni_samples.sample_placed, f"sample{new_sample_position}"
# ).get()
@@ -888,11 +909,6 @@ class FlomniSampleTransferMixin:
f"There is currently no sample in position [{new_sample_position}]. Aborting."
)
if new_sample_position == 0:
raise FlomniError(
"The new sample to place cannot be the sample in the sample stage. Aborting."
)
# sample_in_sample_stage = dev.flomni_samples.sample_placed.sample0.get()
sample_in_sample_stage = dev.flomni_samples.is_sample_slot_used(0)
if sample_in_sample_stage:
@@ -985,7 +1001,7 @@ class FlomniSampleTransferMixin:
return
self.transfer_step += 1
if self.OMNYTools.yesno("All OK? Continue?", "y"):
if self.ftransfer_confirm_dialog("All OK? Continue?", "y"):
print("OK. continue.")
data = self.client.connector.get_last(
MessageEndpoints.device_preview("cam_flomni_gripper", "preview")
@@ -22,6 +22,14 @@ class flomniGuiToolsError(Exception):
class flomniGuiTools:
# Screen assumed 2560x1440. Window is right-aligned with a small margin
# from the top; width/height tuned from the first test render (which
# came out taller than intended at 1000px).
_SCREEN_WIDTH = 2560
_WINDOW_WIDTH = 1500
_WINDOW_HEIGHT = 850
_WINDOW_TOP_MARGIN = 50
def __init__(self):
self.text_box = None
self.progressbar = None
@@ -31,6 +39,7 @@ class flomniGuiTools:
self.idle_text_box = None
self.camera_gripper_image = None
self.camera_overview_image = None
self.console = None
def set_client(self, client):
self.client = client
@@ -41,7 +50,10 @@ class flomniGuiTools:
self.flomni_window = self.gui.windows["flomni"]
self.gui.flomni.raise_window()
else:
self.flomni_window = self.gui.new("flomni")
# geometry: (pos_x, pos_y, w, h)
pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH
geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT)
self.flomni_window = self.gui.new("flomni", geometry=geometry)
time.sleep(1)
def flomnigui_stop_gui(self):
@@ -90,8 +102,10 @@ class flomniGuiTools:
def flomnigui_show_cameras(self):
self.flomnigui_show_gui()
if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing(
"camera_overview_image"
if (
self._flomnigui_is_missing("camera_gripper_image")
or self._flomnigui_is_missing("camera_overview_image")
or self._flomnigui_is_missing("console")
):
self.flomnigui_remove_all_docks()
self.camera_gripper_image = self.gui.flomni.new("Image")
@@ -117,6 +131,16 @@ class flomniGuiTools:
else:
print("Cannot open camera_overview. Device does not exist.")
# Confirm/abort console, docked below the cameras.
self.console = self.gui.flomni.new(
"ConsoleButtonsWidget", object_name="console", where="bottom"
)
# set_layout_ratios uses relative weights, not pixels -- there is
# no width/height kwarg on dock_area.new(). [5, 1] gives the
# cameras most of the vertical space and keeps the console a
# slim strip at the bottom; adjust to taste once you see it.
self.gui.flomni.set_layout_ratios(vertical=[5, 1])
def flomnigui_remove_all_docks(self):
# dev.cam_flomni_overview.stop_live_mode()
# dev.cam_flomni_gripper.stop_live_mode()
@@ -130,6 +154,7 @@ class flomniGuiTools:
self.idle_text_box = None
self.camera_gripper_image = None
self.camera_overview_image = None
self.console = None
def flomnigui_idle(self):
self.flomnigui_show_gui()
@@ -316,4 +341,4 @@ if __name__ == "__main__":
flomni_gui = flomniGuiTools()
flomni_gui.set_client(client)
flomni_gui.flomnigui_show_gui()
flomni_gui.flomnigui_show_progress()
flomni_gui.flomnigui_show_progress()
@@ -93,6 +93,71 @@ class OMNYTools:
else:
print("Please expicitely confirm y or n.")
def gui_yesno(
self, message: str, gui, default="none", autoconfirm=0, poll_interval: float = 0.1
) -> bool:
"""
GUI-based alternative to yesno(), using a ConsoleButtonsWidget
(csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py)
instead of a blocking console input() prompt.
Not yet wired up as a replacement for yesno() anywhere -- this is
for standalone testing. Once confirmed working, call sites can be
switched over deliberately, one at a time.
Opening the GUI (must already exist and be passed in as `gui`;
this method does not create it):
gui.new("test", timeout=20)
console = gui.test.new("ConsoleButtonsWidget", object_name="console", timeout=20)
Then call, e.g. from an IPython session:
omny_tools.gui_yesno("Continue with sample transfer?", gui=console)
Args:
message (str): Question to display on the widget.
gui: The already-open ConsoleButtonsWidget RPC object (e.g. the
`console` object created above, or later a fixed instance
such as `gui.flomni.console` once wired into flomni's own
gui tools).
default (str): "y" or "n" -- only affects the autoconfirm path,
same as yesno(). There is no "just press enter" equivalent
for a button click, so this has no effect otherwise.
autoconfirm (int): if set together with default="y"/"n", skips
the GUI entirely and returns immediately, same as yesno().
poll_interval (float): seconds between response checks, same
style as the 0.1 s submit-poll in XrayEyeAlign.align().
Returns:
bool: True for "yes", False for "no".
Note on abort: the widget's ABORT button does not write a response
to poll for -- it sends a real SIGINT directly to this process (see
ConsoleButtonsWidget._on_abort), so pressing it raises
KeyboardInterrupt here exactly as a console Ctrl+C would, and
propagates normally out of this method without any special-casing.
"""
if autoconfirm and default == "y":
self.printgreen(message + " Automatically confirming default: yes")
return True
elif autoconfirm and default == "n":
self.printgreen(message + " Automatically confirming default: no")
return False
suffix = {"y": " [Y]/n?", "n": " y/[N]?"}.get(default, " y/n?")
gui.clear_response()
gui.message = message + suffix
while True:
response = gui.response()
if response == "yes":
gui.clear_response()
return True
if response == "no":
gui.clear_response()
return False
time.sleep(poll_interval)
def tweak_cursor(
self, dev1, step1: float, dev2="none", step2: float = "0", special_command="none"
):
+50 -1
View File
@@ -12,6 +12,7 @@ logger = bec_logger.logger
_Widgets = {
"ConsoleButtonsWidget": "ConsoleButtonsWidget",
"SampleStorageWidget": "SampleStorageWidget",
"SAXSWidget": "SAXSWidget",
"SlitControlWidget": "SlitControlWidget",
@@ -20,6 +21,47 @@ _Widgets = {
}
class ConsoleButtonsWidget(RPCBase):
"""Small Yes / No / Abort control widget, intended as a GUI replacement for"""
_IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons"
@property
@rpc_call
def message(self):
"""
None
"""
@message.setter
@rpc_call
def message(self):
"""
None
"""
@rpc_call
def response(self) -> "str":
"""
Current button response: "yes", "no", or "" if no button has been
pressed (or since the last clear_response()).
Note: intentionally a plain method, not @SafeProperty. SafeProperty
only becomes a real Qt property descriptor once a setter is chained
in the class body (see `message` above); a getter-only SafeProperty
is left as an unconverted internal wrapper object, which the RPC
generator then exposes as a callable stub anyway. Since `response`
has no RPC-facing setter, a plain method avoids that trap entirely -
same pattern XRayEye uses for its own read-only `active_roi`.
"""
@rpc_call
def clear_response(self):
"""
None
"""
class SampleStorageWidget(RPCBase):
"""View and correct the FlOMNI sample-storage records."""
@@ -28,7 +70,14 @@ class SampleStorageWidget(RPCBase):
@rpc_call
def refresh(self) -> "None":
"""
Re-read every slot from the device and update the cells.
Re-read all slots (one bulk device round-trip) and update only the
cells whose state actually changed.
Repainting every cell on every 2 s poll — even when nothing changed,
which is almost always the case for a sample magazine — was needless
work on the GUI thread. We diff against the last-seen state and only
call set_state() on cells that differ, so a steady-state poll does no
UI work at all.
"""
@@ -0,0 +1,109 @@
from __future__ import annotations
import os
import signal
from bec_lib import bec_logger
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
class ConsoleButtonsWidget(BECWidget, QWidget):
"""
Small Yes / No / Abort control widget, intended as a GUI replacement for
console prompts (e.g. ``OMNYTools.yesno()``) and as a general-purpose
emergency-stop control.
- Yes / No: set ``response`` to "yes" / "no". A blocking CLI script can
poll ``gui.<name>.response`` and reset it via ``clear_response()``.
- Abort: sends a real SIGINT to the BEC IPython client process (the
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``).
"""
USER_ACCESS = ["message", "message.setter", "response", "clear_response"]
PLUGIN = True
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._response = ""
# Captured once at construction time: the GUI server process is a
# direct child of the BEC IPython client (subprocess.Popen in
# bec_widgets.cli.client_utils), so getppid() here is the client's
# PID. Not re-read later, so a subsequent reparenting (e.g. if the
# client died) can't silently redirect the signal.
self._client_pid = os.getppid()
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
button_row = QHBoxLayout()
self.yes_button = QPushButton("Yes", parent=self)
self.no_button = QPushButton("No", parent=self)
self.abort_button = QPushButton("ABORT", parent=self)
self.abort_button.setStyleSheet(
"background-color: #c0392b; color: white; font-weight: bold;"
)
button_row.addWidget(self.yes_button)
button_row.addWidget(self.no_button)
button_row.addWidget(self.abort_button)
layout.addLayout(button_row)
self.message_label = QLabel("", parent=self)
self.message_label.setWordWrap(True)
layout.addWidget(self.message_label)
self.yes_button.clicked.connect(self._on_yes)
self.no_button.clicked.connect(self._on_no)
self.abort_button.clicked.connect(self._on_abort)
@SafeSlot()
def _on_yes(self):
self._response = "yes"
@SafeSlot()
def _on_no(self):
self._response = "no"
@SafeSlot()
def _on_abort(self):
logger.warning(f"ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}")
os.kill(self._client_pid, signal.SIGINT)
@SafeProperty(str)
def message(self):
return self.message_label.text()
@message.setter
@rpc_timeout(20)
def message(self, text: str):
self.message_label.setText(text)
def response(self) -> str:
"""
Current button response: "yes", "no", or "" if no button has been
pressed (or since the last clear_response()).
Note: intentionally a plain method, not @SafeProperty. SafeProperty
only becomes a real Qt property descriptor once a setter is chained
in the class body (see `message` above); a getter-only SafeProperty
is left as an unconverted internal wrapper object, which the RPC
generator then exposes as a callable stub anyway. Since `response`
has no RPC-facing setter, a plain method avoids that trap entirely -
same pattern XRayEye uses for its own read-only `active_roi`.
"""
return self._response
@SafeSlot()
def clear_response(self):
self._response = ""
@@ -0,0 +1 @@
{'files': ['console_buttons.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons import ConsoleButtonsWidget
DOM_XML = """
<ui language='c++'>
<widget class='ConsoleButtonsWidget' name='console_buttons_widget'>
</widget>
</ui>
"""
class ConsoleButtonsWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ConsoleButtonsWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(ConsoleButtonsWidget.ICON_NAME)
def includeFile(self):
return "console_buttons_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ConsoleButtonsWidget"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons_widget_plugin import ConsoleButtonsWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ConsoleButtonsWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -5,6 +5,10 @@ from __future__ import annotations
# pylint: skip-file
designer_plugins = {
"ConsoleButtonsWidget": (
"csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons",
"ConsoleButtonsWidget",
),
"SampleStorageWidget": (
"csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage",
"SampleStorageWidget",
@@ -22,6 +26,7 @@ designer_plugins = {
}
widget_icons = {
"ConsoleButtonsWidget": "widgets",
"SampleStorageWidget": "widgets",
"SAXSWidget": "table_chart",
"SlitControlWidget": "widgets",