From d2ffddb6d8d2473d8718f5aa650559902067ff12 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 10 Jan 2025 10:32:45 +0100 Subject: [PATCH] feat(widget): add 2d positioner box widget --- bec_widgets/cli/client.py | 21 + .../device_control/positioner_box/__init__.py | 5 +- .../_base/positioner_box_base.py | 67 ++- .../positioner_box/positioner_box.py | 82 +-- .../positioner_box/register_positioner_box.py | 2 +- .../positioner_box_2d/__init__.py | 0 .../positioner_box2_d.pyproject | 1 + .../positioner_box2_d_plugin.py} | 20 +- .../positioner_box_2d/positioner_box_2d.py | 482 +++++++++++++++++ .../positioner_box_2d/positioner_box_2d.ui | 504 ++++++++++++++++++ .../register_positioner_box2_d.py} | 6 +- .../positioner_group/positioner_group.py | 11 +- .../signal_combo_box.pyproject | 1 - tests/unit_tests/test_positioner_box.py | 7 + tests/unit_tests/test_positioner_box_2d.py | 82 +++ 15 files changed, 1215 insertions(+), 76 deletions(-) create mode 100644 bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/__init__.py create mode 100644 bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d.pyproject rename bec_widgets/widgets/control/{device_input/signal_combobox/signal_combo_box_plugin.py => device_control/positioner_box/positioner_box_2d/positioner_box2_d_plugin.py} (61%) create mode 100644 bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py create mode 100644 bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui rename bec_widgets/widgets/control/{device_input/signal_combobox/register_signal_combo_box.py => device_control/positioner_box/positioner_box_2d/register_positioner_box2_d.py} (58%) delete mode 100644 bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box.pyproject create mode 100644 tests/unit_tests/test_positioner_box_2d.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 1c3be217..7c752bca 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -34,6 +34,7 @@ class Widgets(str, enum.Enum): Minesweeper = "Minesweeper" PositionIndicator = "PositionIndicator" PositionerBox = "PositionerBox" + PositionerBox2D = "PositionerBox2D" PositionerControlLine = "PositionerControlLine" ResetButton = "ResetButton" ResumeButton = "ResumeButton" @@ -3235,6 +3236,26 @@ class PositionerBox(RPCBase): """ +class PositionerBox2D(RPCBase): + @rpc_call + def set_positioner_hor(self, positioner: "str | Positioner"): + """ + Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + + @rpc_call + def set_positioner_ver(self, positioner: "str | Positioner"): + """ + Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + + class PositionerBoxBase(RPCBase): @property @rpc_call diff --git a/bec_widgets/widgets/control/device_control/positioner_box/__init__.py b/bec_widgets/widgets/control/device_control/positioner_box/__init__.py index 139039c5..f901505c 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/__init__.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/__init__.py @@ -1,8 +1,11 @@ from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import ( PositionerBox, ) +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import ( + PositionerBox2D, +) from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import ( PositionerControlLine, ) -__ALL__ = ["PositionerBox", "PositionerControlLine"] +__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"] diff --git a/bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py b/bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py index eadd77d0..4ad22b96 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py @@ -1,19 +1,31 @@ -from ast import Tuple import uuid from abc import abstractmethod +from ast import Tuple from typing import Callable, TypedDict from bec_lib.device import Positioner from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger from bec_lib.messages import ScanQueueMessage -from qtpy.QtWidgets import QGroupBox, QDoubleSpinBox, QPushButton, QVBoxLayout, QLabel, QLineEdit +from qtpy.QtWidgets import ( + QDialog, + QDoubleSpinBox, + QGroupBox, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) from bec_widgets.qt_utils.compact_popup import CompactPopupWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import ( PositionIndicator, ) +from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter +from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( + DeviceLineEdit, +) from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget logger = bec_logger.logger @@ -26,6 +38,9 @@ class DeviceUpdateUIComponents(TypedDict): position_indicator: PositionIndicator step_size: QDoubleSpinBox device_box: QGroupBox + stop: QPushButton + tweak_increase: QPushButton + tweak_decrease: QPushButton class PositionerBoxBase(BECWidget, CompactPopupWidget): @@ -43,6 +58,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget): """ super().__init__(**kwargs) CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout) + self._dialog = None self.get_bec_shortcuts() def _check_device_is_valid(self, device: str): @@ -178,3 +194,50 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget): def _swap_readback_signal_connection(self, slot, old_device, new_device): self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device)) self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device)) + + def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None: + """Toogle enable/disable on available buttons + + Args: + enable (bool): Enable buttons + """ + ui["tweak_increase"].setEnabled(enable) + ui["tweak_decrease"].setEnabled(enable) + ui["stop"].setEnabled(enable) + ui["setpoint"].setEnabled(enable) + ui["step_size"].setEnabled(enable) + + def _on_device_change( + self, + old_device: str, + new_device: str, + position_emit: Callable[[float], None], + limit_update: Callable[[tuple[float, float]], None], + on_device_readback: Callable, + ui: DeviceUpdateUIComponents, + ): + logger.info(f"Device changed from {old_device} to {new_device}") + self._toggle_enable_buttons(ui, True) + self._init_device(new_device, position_emit, limit_update) + self._swap_readback_signal_connection(on_device_readback, old_device, new_device) + self._update_device_ui(new_device, ui) + + def _open_dialog_selection(self, set_positioner: Callable): + def _ods(): + """Open dialog window for positioner selection""" + self._dialog = QDialog(self) + self._dialog.setWindowTitle("Positioner Selection") + layout = QVBoxLayout() + line_edit = DeviceLineEdit( + self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER] + ) + line_edit.textChanged.connect(set_positioner) + layout.addWidget(line_edit) + close_button = QPushButton("Close") + close_button.clicked.connect(self._dialog.accept) + layout.addWidget(close_button) + self._dialog.setLayout(layout) + self._dialog.exec() + self._dialog = None + + return _ods diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 1391030f..d4a0805d 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -5,24 +5,19 @@ from __future__ import annotations import os from bec_lib.device import Positioner -from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import Property, Signal, Slot +from qtpy.QtCore import Signal from qtpy.QtGui import QDoubleValidator -from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout +from qtpy.QtWidgets import QDoubleSpinBox -from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase +from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils import UILoader from bec_widgets.utils.colors import get_accent_colors, set_theme +from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( DeviceUpdateUIComponents, ) -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( - DeviceLineEdit, -) - logger = bec_logger.logger @@ -53,7 +48,6 @@ class PositionerBox(PositionerBoxBase): self._device = "" self._limits = None - self._dialog = None if self.current_path == "": self.current_path = os.path.dirname(__file__) @@ -91,40 +85,14 @@ class PositionerBox(PositionerBoxBase): self.setpoint_validator = QDoubleValidator() self.ui.setpoint.setValidator(self.setpoint_validator) self.ui.spinner_widget.start() - self.ui.tool_button.clicked.connect(self._open_dialog_selection) + self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner)) icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False) self.ui.tool_button.setIcon(icon) - def _open_dialog_selection(self): - """Open dialog window for positioner selection""" - self._dialog = QDialog(self) - self._dialog.setWindowTitle("Positioner Selection") - layout = QVBoxLayout() - line_edit = DeviceLineEdit( - self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER] - ) - line_edit.textChanged.connect(self.set_positioner) - layout.addWidget(line_edit) - close_button = QPushButton("Close") - close_button.clicked.connect(self._dialog.accept) - layout.addWidget(close_button) - self._dialog.setLayout(layout) - self._dialog.exec() - self._dialog = None + def force_update_readback(self): + self._init_device(self.device, self.position_update.emit, self.update_limits) - def _toogle_enable_buttons(self, enable: bool) -> None: - """Toogle enable/disable on available buttons - - Args: - enable (bool): Enable buttons - """ - self.ui.tweak_left.setEnabled(enable) - self.ui.tweak_right.setEnabled(enable) - self.ui.stop.setEnabled(enable) - self.ui.setpoint.setEnabled(enable) - self.ui.step_size.setEnabled(enable) - - @Property(str) + @SafeProperty(str) def device(self): """Property to set the device""" return self._device @@ -142,7 +110,7 @@ class PositionerBox(PositionerBoxBase): self.label = value self.device_changed.emit(old_device, value) - @Property(bool) + @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" return not self.ui.tool_button.isVisible() @@ -152,7 +120,7 @@ class PositionerBox(PositionerBoxBase): """Set the device selection visibility""" self.ui.tool_button.setVisible(not value) - @Slot(bool) + @SafeSlot(bool) def show_device_selection(self, value: bool): """Show the device selection @@ -161,7 +129,7 @@ class PositionerBox(PositionerBoxBase): """ self.hide_device_selection = not value - @Slot(str) + @SafeSlot(str) def set_positioner(self, positioner: str | Positioner): """Set the device @@ -172,7 +140,7 @@ class PositionerBox(PositionerBoxBase): positioner = positioner.name self.device = positioner - @Slot(str, str) + @SafeSlot(str, str) def on_device_change(self, old_device: str, new_device: str): """Upon changing the device, a check will be performed if the device is a Positioner. @@ -182,11 +150,14 @@ class PositionerBox(PositionerBoxBase): """ if not self._check_device_is_valid(new_device): return - logger.info(f"Device changed from {old_device} to {new_device}") - self._toogle_enable_buttons(True) - self._init_device(new_device, self.position_update.emit, self.update_limits) - self._swap_readback_signal_connection(self.on_device_readback, old_device, new_device) - self._update_device_ui(new_device, self._device_ui_components(new_device)) + self._on_device_change( + old_device, + new_device, + self.position_update.emit, + self.update_limits, + self.on_device_readback, + self._device_ui_components(new_device), + ) def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents: return { @@ -196,9 +167,12 @@ class PositionerBox(PositionerBoxBase): "setpoint": self.ui.setpoint, "step_size": self.ui.step_size, "device_box": self.ui.device_box, + "stop": self.ui.stop, + "tweak_increase": self.ui.tweak_right, + "tweak_decrease": self.ui.tweak_left, } - @Slot(dict, dict) + @SafeSlot(dict, dict) def on_device_readback(self, msg_content: dict, metadata: dict): """Callback for device readback. @@ -226,7 +200,7 @@ class PositionerBox(PositionerBoxBase): self._limits = limits self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator) - @Slot() + @SafeSlot() def on_stop(self): self._stop_device(self.device) @@ -235,17 +209,17 @@ class PositionerBox(PositionerBoxBase): """Step size for tweak""" return self.ui.step_size.value() - @Slot() + @SafeSlot() def on_tweak_right(self): """Tweak motor right""" self.dev[self.device].move(self.step_size, relative=True) - @Slot() + @SafeSlot() def on_tweak_left(self): """Tweak motor left""" self.dev[self.device].move(-self.step_size, relative=True) - @Slot() + @SafeSlot() def on_setpoint_change(self): """Change the setpoint for the motor""" self.ui.setpoint.clearFocus() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/register_positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/register_positioner_box.py index 52ea6d30..434787d2 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/register_positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/register_positioner_box.py @@ -6,7 +6,7 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import ( + from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box_plugin import ( PositionerBoxPlugin, ) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/__init__.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d.pyproject b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d.pyproject new file mode 100644 index 00000000..0b072d82 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d.pyproject @@ -0,0 +1 @@ +{'files': ['positioner_box_2d.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box_plugin.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d_plugin.py similarity index 61% rename from bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box_plugin.py rename to bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d_plugin.py index 4033c279..ab28cce5 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box_plugin.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box2_d_plugin.py @@ -4,36 +4,38 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import ( + PositionerBox2D, +) DOM_XML = """ - + """ -class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None def createWidget(self, parent): - t = SignalComboBox(parent) + t = PositionerBox2D(parent) return t def domXml(self): return DOM_XML def group(self): - return "BEC Input Widgets" + return "Device Control" def icon(self): - return designer_material_icon(SignalComboBox.ICON_NAME) + return designer_material_icon(PositionerBox2D.ICON_NAME) def includeFile(self): - return "signal_combo_box" + return "positioner_box2_d" def initialize(self, form_editor): self._form_editor = form_editor @@ -45,10 +47,10 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover return self._form_editor is not None def name(self): - return "SignalComboBox" + return "PositionerBox2D" def toolTip(self): - return "" + return "Simple Widget to control two positioners in box form" def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py new file mode 100644 index 00000000..b8e3f3e8 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -0,0 +1,482 @@ +""" Module for a PositionerBox2D widget to control two positioner devices.""" + +from __future__ import annotations + +import os +from typing import Literal + +from bec_lib.device import Positioner +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Signal +from qtpy.QtGui import QDoubleValidator +from qtpy.QtWidgets import QDoubleSpinBox + +from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.utils import UILoader +from bec_widgets.utils.colors import set_theme +from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase +from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( + DeviceUpdateUIComponents, +) + +logger = bec_logger.logger + +MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + +DeviceId = Literal["horizontal", "vertical"] + + +class PositionerBox2D(PositionerBoxBase): + """Simple Widget to control two positioners in box form""" + + ui_file = "positioner_box_2d.ui" + + PLUGIN = True + USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"] + + device_changed_hor = Signal(str, str) + device_changed_ver = Signal(str, str) + # Signals emitted to inform listeners about a position update + position_update_hor = Signal(float) + position_update_ver = Signal(float) + + def __init__( + self, + parent=None, + device_hor: Positioner | str | None = None, + device_ver: Positioner | str | None = None, + **kwargs, + ): + """Initialize the PositionerBox widget. + + Args: + parent: The parent widget. + device_hor (Positioner | str): The first device to control - assigned the horizontal axis. + device_ver (Positioner | str): The second device to control - assigned the vertical axis. + """ + super().__init__(parent=parent, **kwargs) + + self._device_hor = "" + self._device_ver = "" + self._limits_hor = None + self._limits_ver = None + self._dialog = None + if self.current_path == "": + self.current_path = os.path.dirname(__file__) + self.init_ui() + self.device_hor = device_hor + self.device_ver = device_ver + + self.connect_ui() + + def init_ui(self): + """Init the ui""" + self.device_changed_hor.connect(self.on_device_change_hor) + self.device_changed_ver.connect(self.on_device_change_ver) + + self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file)) + self.setpoint_validator_hor = QDoubleValidator() + self.setpoint_validator_ver = QDoubleValidator() + + def connect_ui(self): + """Connect the UI components to signals, data, or routines""" + self.addWidget(self.ui) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + def _init_ui(val: QDoubleValidator, device_id: DeviceId): + ui = self._device_ui_components_hv(device_id) + tweak_inc = ( + self.on_tweak_inc_hor if device_id == "horizontal" else self.on_tweak_inc_ver + ) + tweak_dec = ( + self.on_tweak_dec_hor if device_id == "horizontal" else self.on_tweak_dec_ver + ) + ui["setpoint"].setValidator(val) + ui["setpoint"].returnPressed.connect( + self.on_setpoint_change_hor + if device_id == "horizontal" + else self.on_setpoint_change_ver + ) + ui["stop"].setToolTip("Stop") + ui["step_size"].setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType) + ui["tweak_increase"].clicked.connect(tweak_inc) + ui["tweak_decrease"].clicked.connect(tweak_dec) + + _init_ui(self.setpoint_validator_hor, "horizontal") + _init_ui(self.setpoint_validator_ver, "vertical") + + self.ui.stop_button.button.clicked.connect(self.on_stop) + + self.ui.step_decrease_hor.clicked.connect(self.on_step_dec_hor) + self.ui.step_decrease_ver.clicked.connect(self.on_step_dec_ver) + self.ui.step_increase_hor.clicked.connect(self.on_step_inc_hor) + self.ui.step_increase_ver.clicked.connect(self.on_step_inc_ver) + + self.ui.tool_button_hor.clicked.connect( + self._open_dialog_selection(self.set_positioner_hor) + ) + self.ui.tool_button_ver.clicked.connect( + self._open_dialog_selection(self.set_positioner_ver) + ) + icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False) + self.ui.tool_button_hor.setIcon(icon) + self.ui.tool_button_ver.setIcon(icon) + + step_tooltip = "Step by the step size" + tweak_tooltip = "Tweak by 1/10th the step size" + + for b in [ + self.ui.step_increase_hor, + self.ui.step_increase_ver, + self.ui.step_decrease_hor, + self.ui.step_decrease_ver, + ]: + b.setToolTip(step_tooltip) + + for b in [ + self.ui.tweak_increase_hor, + self.ui.tweak_increase_ver, + self.ui.tweak_decrease_hor, + self.ui.tweak_decrease_ver, + ]: + b.setToolTip(tweak_tooltip) + + icon_options = {"size": (16, 16), "convert_to_pixmap": False} + self.ui.tweak_increase_hor.setIcon( + material_icon(icon_name="keyboard_arrow_right", **icon_options) + ) + self.ui.step_increase_hor.setIcon( + material_icon(icon_name="keyboard_double_arrow_right", **icon_options) + ) + self.ui.tweak_decrease_hor.setIcon( + material_icon(icon_name="keyboard_arrow_left", **icon_options) + ) + self.ui.step_decrease_hor.setIcon( + material_icon(icon_name="keyboard_double_arrow_left", **icon_options) + ) + self.ui.tweak_increase_ver.setIcon( + material_icon(icon_name="keyboard_arrow_up", **icon_options) + ) + self.ui.step_increase_ver.setIcon( + material_icon(icon_name="keyboard_double_arrow_up", **icon_options) + ) + self.ui.tweak_decrease_ver.setIcon( + material_icon(icon_name="keyboard_arrow_down", **icon_options) + ) + self.ui.step_decrease_ver.setIcon( + material_icon(icon_name="keyboard_double_arrow_down", **icon_options) + ) + + @SafeProperty(str) + def device_hor(self): + """SafeProperty to set the device""" + return self._device_hor + + @device_hor.setter + def device_hor(self, value: str): + """Setter, checks if device is a string""" + if not value or not isinstance(value, str): + return + if not self._check_device_is_valid(value): + return + if value == self.device_ver: + return + old_device = self._device_hor + self._device_hor = value + self.label = f"{self._device_hor}, {self._device_ver}" + self.device_changed_hor.emit(old_device, value) + self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor) + + @SafeProperty(str) + def device_ver(self): + """SafeProperty to set the device""" + return self._device_ver + + @device_ver.setter + def device_ver(self, value: str): + """Setter, checks if device is a string""" + if not value or not isinstance(value, str): + return + if not self._check_device_is_valid(value): + return + if value == self.device_hor: + return + old_device = self._device_ver + self._device_ver = value + self.label = f"{self._device_hor}, {self._device_ver}" + self.device_changed_ver.emit(old_device, value) + self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver) + + @SafeProperty(bool) + def hide_device_selection(self): + """Hide the device selection""" + return not self.ui.tool_button_hor.isVisible() + + @hide_device_selection.setter + def hide_device_selection(self, value: bool): + """Set the device selection visibility""" + self.ui.tool_button_hor.setVisible(not value) + self.ui.tool_button_ver.setVisible(not value) + + @SafeProperty(bool) + def hide_device_boxes(self): + """Hide the device selection""" + return not self.ui.device_box_hor.isVisible() + + @hide_device_boxes.setter + def hide_device_boxes(self, value: bool): + """Set the device selection visibility""" + self.ui.device_box_hor.setVisible(not value) + self.ui.device_box_ver.setVisible(not value) + + @SafeSlot(bool) + def show_device_selection(self, value: bool): + """Show the device selection + + Args: + value (bool): Show the device selection + """ + self.hide_device_selection = not value + + @SafeSlot(str) + def set_positioner_hor(self, positioner: str | Positioner): + """Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + if isinstance(positioner, Positioner): + positioner = positioner.name + self.device_hor = positioner + + @SafeSlot(str) + def set_positioner_ver(self, positioner: str | Positioner): + """Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + if isinstance(positioner, Positioner): + positioner = positioner.name + self.device_ver = positioner + + @SafeSlot(str, str) + def on_device_change_hor(self, old_device: str, new_device: str): + """Upon changing the device, a check will be performed if the device is a Positioner. + + Args: + old_device (str): The old device name. + new_device (str): The new device name. + """ + if not self._check_device_is_valid(new_device): + return + self._on_device_change( + old_device, + new_device, + self.position_update_hor.emit, + self.update_limits_hor, + self.on_device_readback_hor, + self._device_ui_components_hv("horizontal"), + ) + + @SafeSlot(str, str) + def on_device_change_ver(self, old_device: str, new_device: str): + """Upon changing the device, a check will be performed if the device is a Positioner. + + Args: + old_device (str): The old device name. + new_device (str): The new device name. + """ + if not self._check_device_is_valid(new_device): + return + self._on_device_change( + old_device, + new_device, + self.position_update_ver.emit, + self.update_limits_ver, + self.on_device_readback_ver, + self._device_ui_components_hv("vertical"), + ) + + def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents: + if device == "horizontal": + return { + "spinner": self.ui.spinner_widget_hor, + "position_indicator": self.ui.position_indicator_hor, + "readback": self.ui.readback_hor, + "setpoint": self.ui.setpoint_hor, + "step_size": self.ui.step_size_hor, + "device_box": self.ui.device_box_hor, + "stop": self.ui.stop_button, + "tweak_increase": self.ui.tweak_increase_hor, + "tweak_decrease": self.ui.tweak_decrease_hor, + } + elif device == "vertical": + return { + "spinner": self.ui.spinner_widget_ver, + "position_indicator": self.ui.position_indicator_ver, + "readback": self.ui.readback_ver, + "setpoint": self.ui.setpoint_ver, + "step_size": self.ui.step_size_ver, + "device_box": self.ui.device_box_ver, + "stop": self.ui.stop_button, + "tweak_increase": self.ui.tweak_increase_ver, + "tweak_decrease": self.ui.tweak_decrease_ver, + } + else: + raise ValueError(f"Device {device} is not represented by this UI") + + def _device_ui_components(self, device: str): + if device == self.device_hor: + return self._device_ui_components_hv("horizontal") + if device == self.device_ver: + return self._device_ui_components_hv("vertical") + + @SafeSlot(dict, dict) + def on_device_readback_hor(self, msg_content: dict, metadata: dict): + """Callback for device readback. + + Args: + msg_content (dict): The message content. + metadata (dict): The message metadata. + """ + self._on_device_readback( + self.device_hor, + self._device_ui_components_hv("horizontal"), + msg_content, + metadata, + self.position_update_hor.emit, + self.update_limits_hor, + ) + + @SafeSlot(dict, dict) + def on_device_readback_ver(self, msg_content: dict, metadata: dict): + """Callback for device readback. + + Args: + msg_content (dict): The message content. + metadata (dict): The message metadata. + """ + self._on_device_readback( + self.device_ver, + self._device_ui_components_hv("vertical"), + msg_content, + metadata, + self.position_update_ver.emit, + self.update_limits_ver, + ) + + def update_limits_hor(self, limits: tuple): + """Update limits + + Args: + limits (tuple): Limits of the positioner + """ + if limits == self._limits_hor: + return + self._limits_hor = limits + self._update_limits_ui(limits, self.ui.position_indicator_hor, self.setpoint_validator_hor) + + def update_limits_ver(self, limits: tuple): + """Update limits + + Args: + limits (tuple): Limits of the positioner + """ + if limits == self._limits_ver: + return + self._limits_ver = limits + self._update_limits_ui(limits, self.ui.position_indicator_ver, self.setpoint_validator_ver) + + @SafeSlot() + def on_stop(self): + self._stop_device(f"{self.device_hor} or {self.device_ver}") + + @SafeProperty(float) + def step_size_hor(self): + """Step size for tweak""" + return self.ui.step_size_hor.value() + + @step_size_hor.setter + def step_size_hor(self, val: float): + """Step size for tweak""" + self.ui.step_size_hor.setValue(val) + + @SafeProperty(float) + def step_size_ver(self): + """Step size for tweak""" + return self.ui.step_size_ver.value() + + @step_size_ver.setter + def step_size_ver(self, val: float): + """Step size for tweak""" + self.ui.step_size_ver.setValue(val) + + @SafeSlot() + def on_tweak_inc_hor(self): + """Tweak device a up""" + self.dev[self.device_hor].move(self.step_size_hor / 10, relative=True) + + @SafeSlot() + def on_tweak_dec_hor(self): + """Tweak device a down""" + self.dev[self.device_hor].move(-self.step_size_hor / 10, relative=True) + + @SafeSlot() + def on_step_inc_hor(self): + """Tweak device a up""" + self.dev[self.device_hor].move(self.step_size_hor, relative=True) + + @SafeSlot() + def on_step_dec_hor(self): + """Tweak device a down""" + self.dev[self.device_hor].move(-self.step_size_hor, relative=True) + + @SafeSlot() + def on_tweak_inc_ver(self): + """Tweak device a up""" + self.dev[self.device_ver].move(self.step_size_ver / 10, relative=True) + + @SafeSlot() + def on_tweak_dec_ver(self): + """Tweak device b down""" + self.dev[self.device_ver].move(-self.step_size_ver / 10, relative=True) + + @SafeSlot() + def on_step_inc_ver(self): + """Tweak device b up""" + self.dev[self.device_ver].move(self.step_size_ver, relative=True) + + @SafeSlot() + def on_step_dec_ver(self): + """Tweak device a down""" + self.dev[self.device_ver].move(-self.step_size_ver, relative=True) + + @SafeSlot() + def on_setpoint_change_hor(self): + """Change the setpoint for device a""" + self.ui.setpoint_hor.clearFocus() + setpoint = self.ui.setpoint_hor.text() + self.dev[self.device_hor].move(float(setpoint), relative=False) + + @SafeSlot() + def on_setpoint_change_ver(self): + """Change the setpoint for device b""" + self.ui.setpoint_ver.clearFocus() + setpoint = self.ui.setpoint_ver.text() + self.dev[self.device_ver].move(float(setpoint), relative=False) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports + + app = QApplication(sys.argv) + set_theme("dark") + widget = PositionerBox2D() + + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui new file mode 100644 index 00000000..1afa1b74 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui @@ -0,0 +1,504 @@ + + + Form + + + + 0 + 0 + 326 + 323 + + + + Form + + + + + + + + + + No positioner selected + + + + 0 + + + + + + + ... + + + + + + + Position + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + + + false + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + false + + + + + + + + + + + + No positioner selected + + + + 0 + + + + + false + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + ... + + + + + + + Position + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + + + + + false + + + + + + + + + + + + + + + 0 + 0 + + + + 1.000000000000000 + + + true + + + 0.500000000000000 + + + 4 + + + 4 + + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 10 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 10 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 10 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 10 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + 0 + 0 + + + + 1.000000000000000 + + + 0.500000000000000 + + + 4 + + + 4 + + + + + + + + + + StopButton + QWidget +
stop_button
+
+ + PositionIndicator + QWidget +
position_indicator
+
+ + SpinnerWidget + QWidget +
spinner_widget
+
+
+ + +
diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/register_signal_combo_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/register_positioner_box2_d.py similarity index 58% rename from bec_widgets/widgets/control/device_input/signal_combobox/register_signal_combo_box.py rename to bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/register_positioner_box2_d.py index 907cb6a6..c48cefbc 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/register_signal_combo_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/register_positioner_box2_d.py @@ -6,11 +6,11 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.control.device_input.signal_combobox.signal_combo_box_plugin import ( - SignalComboBoxPlugin, + from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box2_d_plugin import ( + PositionerBox2DPlugin, ) - QPyDesignerCustomWidgetCollection.addCustomWidget(SignalComboBoxPlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBox2DPlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py index fd342ab7..b50bdaa1 100644 --- a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +++ b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py @@ -4,9 +4,10 @@ from __future__ import annotations from bec_lib.device import Positioner from bec_lib.logger import bec_logger -from qtpy.QtCore import Property, QSize, Signal, Slot +from qtpy.QtCore import QSize, Signal from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget +from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox @@ -32,7 +33,7 @@ class PositionerGroupBox(QGroupBox): self.widget.position_update.connect(self._on_position_update) self.widget.expand.connect(self._on_expand) self.setTitle(self.device_name) - self.widget.init_device() # force readback + self.widget.force_update_readback() def _on_expand(self, expand): if expand: @@ -82,7 +83,7 @@ class PositionerGroup(BECWidget, QWidget): def minimumSizeHint(self): return QSize(300, 30) - @Slot(str) + @SafeSlot(str) def set_positioners(self, device_names: str): """Redraw grid with positioners from device_names string @@ -130,7 +131,7 @@ class PositionerGroup(BECWidget, QWidget): widget = self.sender() self.device_position_update.emit(widget.title(), pos) - @Property(str) + @SafeProperty(str) def devices_list(self): """Device names string separated by space""" return " ".join(self._device_widgets) @@ -144,7 +145,7 @@ class PositionerGroup(BECWidget, QWidget): return self.set_positioners(device_names) - @Property(int) + @SafeProperty(int) def grid_max_cols(self): """Max number of columns for widgets grid""" return self._grid_ncols diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box.pyproject b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box.pyproject deleted file mode 100644 index b01731f9..00000000 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combo_box.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['signal_combobox.py']} diff --git a/tests/unit_tests/test_positioner_box.py b/tests/unit_tests/test_positioner_box.py index d3737530..1e28452c 100644 --- a/tests/unit_tests/test_positioner_box.py +++ b/tests/unit_tests/test_positioner_box.py @@ -158,3 +158,10 @@ def test_positioner_box_open_dialog_selection(qtbot, positioner_box): QTimer.singleShot(100, close_dialog) qtbot.mouseClick(positioner_box.ui.tool_button, Qt.LeftButton) assert positioner_box.device == "samy" + + +def test_device_validity_check_rejects_non_positioner(): + # isinstance checks for PositionerBox are mocked out in the mock client + positioner_box = mock.MagicMock(spec=PositionerBox) + positioner_box.dev = {"test": 5.123} + assert not PositionerBox._check_device_is_valid(positioner_box, "test") diff --git a/tests/unit_tests/test_positioner_box_2d.py b/tests/unit_tests/test_positioner_box_2d.py new file mode 100644 index 00000000..7a732ac8 --- /dev/null +++ b/tests/unit_tests/test_positioner_box_2d.py @@ -0,0 +1,82 @@ +from unittest import mock + +import pytest + +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D + +from .client_mocks import mocked_client +from .conftest import create_widget + + +@pytest.fixture +def positioner_box_2d(qtbot, mocked_client): + """Fixture for PositionerBox widget""" + with mock.patch( + "bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4" + ) as mock_uuid: + mock_uuid.return_value = "fake_uuid" + with mock.patch( + "bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid", + return_value=True, + ): + db = create_widget( + qtbot, PositionerBox2D, device_hor="samx", device_ver="samy", client=mocked_client + ) + yield db + + +def test_positioner_box_2d(positioner_box_2d): + """Test init of 2D positioner box""" + assert positioner_box_2d.device_hor == "samx" + assert positioner_box_2d.device_ver == "samy" + data_hor = positioner_box_2d.dev["samx"].read() + data_ver = positioner_box_2d.dev["samy"].read() + # Avoid check for Positioner class from BEC in _init_device + + setpoint_hor_text = positioner_box_2d.ui.setpoint_hor.text() + setpoint_ver_text = positioner_box_2d.ui.setpoint_ver.text() + # check that the setpoint is taken correctly after init + assert float(setpoint_hor_text) == data_hor["samx_setpoint"]["value"] + assert float(setpoint_ver_text) == data_ver["samy_setpoint"]["value"] + + # check that the precision is taken correctly after init + precision_hor = positioner_box_2d.dev["samx"].precision + precision_ver = positioner_box_2d.dev["samy"].precision + assert setpoint_hor_text == f"{data_hor['samx_setpoint']['value']:.{precision_hor}f}" + assert setpoint_ver_text == f"{data_ver['samy_setpoint']['value']:.{precision_ver}f}" + + # check that the step size is set according to the device precision + assert positioner_box_2d.ui.step_size_hor.value() == 10**-precision_hor * 10 + assert positioner_box_2d.ui.step_size_ver.value() == 10**-precision_ver * 10 + + +def test_positioner_box_move_hor_does_not_affect_ver(positioner_box_2d): + """Test that moving one positioner doesn't affect the other""" + with ( + mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move_hor, + mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move_ver, + ): + positioner_box_2d.ui.step_size_hor.setValue(0.1) + positioner_box_2d.on_tweak_inc_hor() + mock_move_hor.assert_called_once_with(0.01, relative=True) + mock_move_ver.assert_not_called() + with ( + mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move_hor, + mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move_ver, + ): + positioner_box_2d.ui.step_size_ver.setValue(0.1) + positioner_box_2d.on_step_dec_ver() + mock_move_ver.assert_called_once_with(-0.1, relative=True) + mock_move_hor.assert_not_called() + + +def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D): + """Test positioner box setpoint change""" + with mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move: + positioner_box_2d.ui.setpoint_hor.setText("100") + positioner_box_2d.on_setpoint_change_hor() + mock_move.assert_called_once_with(100, relative=False) + with mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move: + positioner_box_2d.ui.setpoint_ver.setText("100") + positioner_box_2d.on_setpoint_change_ver() + mock_move.assert_called_once_with(100, relative=False)