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
+
+
+
+ PositionIndicator
+ QWidget
+
+
+
+ SpinnerWidget
+ QWidget
+
+
+
+
+
+
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)