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:
@@ -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"
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user