diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index db0fc25d..99b8fd6b 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -21,9 +21,9 @@ class Widgets(str, enum.Enum): BECQueue = "BECQueue" BECStatusBox = "BECStatusBox" BECWaveformWidget = "BECWaveformWidget" - DeviceBox = "DeviceBox" DeviceComboBox = "DeviceComboBox" DeviceLineEdit = "DeviceLineEdit" + PositionerBox = "PositionerBox" RingProgressBar = "RingProgressBar" ScanControl = "ScanControl" StopButton = "StopButton" @@ -2287,24 +2287,6 @@ class BECWaveformWidget(RPCBase): """ -class DeviceBox(RPCBase): - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. - """ - - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. - """ - - class DeviceComboBox(RPCBase): @property @rpc_call @@ -2359,6 +2341,17 @@ class DeviceLineEdit(RPCBase): """ +class PositionerBox(RPCBase): + @rpc_call + def set_positioner(self, positioner: str): + """ + Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + + class Ring(RPCBase): @rpc_call def _get_all_rpc(self) -> "dict": diff --git a/bec_widgets/widgets/device_box/device_box.pyproject b/bec_widgets/widgets/device_box/device_box.pyproject deleted file mode 100644 index b2b7ae35..00000000 --- a/bec_widgets/widgets/device_box/device_box.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['device_box.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/dock/dock_area.py b/bec_widgets/widgets/dock/dock_area.py index f11a901b..53fde83b 100644 --- a/bec_widgets/widgets/dock/dock_area.py +++ b/bec_widgets/widgets/dock/dock_area.py @@ -83,8 +83,8 @@ class BECDockArea(BECWidget, QWidget): "scan_control": IconAction( icon_path="scan_control.svg", tooltip="Add Scan Control" ), - "device_box": IconAction( - icon_path="device_box.svg", tooltip="Add Device Box" + "positioner_box": IconAction( + icon_path="positioner_box.svg", tooltip="Add Device Box" ), }, ), @@ -132,8 +132,8 @@ class BECDockArea(BECWidget, QWidget): self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect( lambda: self.add_dock(widget="ScanControl", prefix="scan_control") ) - self.toolbar.widgets["menu_devices"].widgets["device_box"].triggered.connect( - lambda: self.add_dock(widget="DeviceBox", prefix="device_box") + self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect( + lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box") ) # Menu Utils diff --git a/bec_widgets/widgets/device_box/__init__.py b/bec_widgets/widgets/positioner_box/__init__.py similarity index 100% rename from bec_widgets/widgets/device_box/__init__.py rename to bec_widgets/widgets/positioner_box/__init__.py diff --git a/bec_widgets/widgets/device_box/device_box.py b/bec_widgets/widgets/positioner_box/positioner_box.py similarity index 73% rename from bec_widgets/widgets/device_box/device_box.py rename to bec_widgets/widgets/positioner_box/positioner_box.py index a6fea702..abf1f063 100644 --- a/bec_widgets/widgets/device_box/device_box.py +++ b/bec_widgets/widgets/positioner_box/positioner_box.py @@ -1,8 +1,11 @@ +""" Module for a PositionerBox widget to control a positioner device.""" + import os import uuid 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.QtCore import Property, Signal, Slot from qtpy.QtGui import QDoubleValidator @@ -12,12 +15,23 @@ from bec_widgets.utils import UILoader from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme +logger = bec_logger.logger -class DeviceBox(BECWidget, QWidget): + +class PositionerBox(BECWidget, QWidget): + """Simple Widget to control a positioner in box form""" + + USER_ACCESS = ["set_positioner"] device_changed = Signal(str, str) def __init__(self, parent=None, device: Positioner = None, *args, **kwargs): - super().__init__(*args, **kwargs) + """Initialize the PositionerBox widget. + + Args: + parent: The parent widget. + device (Positioner): The device to control. + """ + super().__init__(**kwargs) QWidget.__init__(self, parent=parent) self.get_bec_shortcuts() self._device = "" @@ -30,10 +44,11 @@ class DeviceBox(BECWidget, QWidget): self.init_device() def init_ui(self): + """Init the ui""" self.device_changed.connect(self.on_device_change) current_path = os.path.dirname(__file__) - self.ui = UILoader(self).loader(os.path.join(current_path, "device_box.ui")) + self.ui = UILoader(self).loader(os.path.join(current_path, "positioner_box.ui")) self.layout = QVBoxLayout(self) self.layout.addWidget(self.ui) @@ -58,11 +73,17 @@ class DeviceBox(BECWidget, QWidget): self.ui.spinner_widget.start() def init_device(self): - if self.device in self.dev and isinstance(self.dev[self.device], Positioner): + """Init the device view and readback""" + if self._check_device_is_valid(self.device): data = self.dev[self.device].read() self.on_device_readback({"signals": data}, {}) 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) @@ -71,25 +92,53 @@ class DeviceBox(BECWidget, QWidget): @Property(str) def device(self): + """Property to set the device""" return self._device @device.setter def device(self, value: str): + """Setter, checks if device is a string""" if not value or not isinstance(value, str): return old_device = self._device self._device = value self.device_changed.emit(old_device, value) + def set_positioner(self, positioner: str): + """Set the device + + Args: + positioner (Positioner | str) : Positioner to set, accepts str or the device + """ + if isinstance(positioner, Positioner): + positioner = positioner.name + self.device = positioner + + def _check_device_is_valid(self, device: str): + """Check if the device is a positioner + + Args: + device (str): The device name + """ + if device not in self.dev: + logger.info(f"Device {device} not found in the device list") + return False + if not isinstance(self.dev[device], Positioner): + logger.info(f"Device {device} is not a positioner") + return False + return True + @Slot(str, str) def on_device_change(self, old_device: str, new_device: str): - if new_device not in self.dev: - print(f"Device {new_device} not found in the device list") + """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 - if not isinstance(self.dev[new_device], Positioner): - print(f"Device {new_device} is not a positioner") - return - print(f"Device changed from {old_device} to {new_device}") + logger.info(f"Device changed from {old_device} to {new_device}") self._toogle_enable_buttons(True) self.init_device() self.bec_dispatcher.disconnect_slot( @@ -110,6 +159,12 @@ class DeviceBox(BECWidget, QWidget): @Slot(dict, dict) def on_device_readback(self, msg_content: dict, metadata: dict): + """Callback for device readback. + + Args: + msg_content (dict): The message content. + metadata (dict): The message metadata. + """ signals = msg_content.get("signals", {}) # pylint: disable=protected-access hinted_signals = self.dev[self.device]._hints @@ -146,7 +201,12 @@ class DeviceBox(BECWidget, QWidget): pos = (readback_val - limits[0]) / (limits[1] - limits[0]) self.ui.position_indicator.on_position_update(pos) - def update_limits(self, limits): + def update_limits(self, limits: tuple): + """Update limits + + Args: + limits (tuple): Limits of the positioner + """ if limits == self._limits: return self._limits = limits @@ -159,6 +219,7 @@ class DeviceBox(BECWidget, QWidget): @Slot() def on_stop(self): + """Stop call""" request_id = str(uuid.uuid4()) params = { "device": self.device, @@ -177,18 +238,22 @@ class DeviceBox(BECWidget, QWidget): @property def step_size(self): + """Step size for tweak""" return self.ui.step_size.value() @Slot() def on_tweak_right(self): + """Tweak motor right""" self.dev[self.device].move(self.step_size, relative=True) @Slot() def on_tweak_left(self): + """Tweak motor left""" self.dev[self.device].move(-self.step_size, relative=True) @Slot() def on_setpoint_change(self): + """Change the setpoint for the motor""" self.ui.setpoint.clearFocus() setpoint = self.ui.setpoint.text() self.dev[self.device].move(float(setpoint), relative=False) @@ -203,7 +268,7 @@ if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) apply_theme("light") - widget = DeviceBox(device="bpm4i") + widget = PositionerBox(device="bpm4i") widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/positioner_box/positioner_box.pyproject b/bec_widgets/widgets/positioner_box/positioner_box.pyproject new file mode 100644 index 00000000..d327dac8 --- /dev/null +++ b/bec_widgets/widgets/positioner_box/positioner_box.pyproject @@ -0,0 +1 @@ +{'files': ['positioner_box.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/device_box/device_box.ui b/bec_widgets/widgets/positioner_box/positioner_box.ui similarity index 100% rename from bec_widgets/widgets/device_box/device_box.ui rename to bec_widgets/widgets/positioner_box/positioner_box.ui diff --git a/bec_widgets/widgets/device_box/device_box_plugin.py b/bec_widgets/widgets/positioner_box/positioner_box_plugin.py similarity index 58% rename from bec_widgets/widgets/device_box/device_box_plugin.py rename to bec_widgets/widgets/positioner_box/positioner_box_plugin.py index 834dc9b3..48494331 100644 --- a/bec_widgets/widgets/device_box/device_box_plugin.py +++ b/bec_widgets/widgets/positioner_box/positioner_box_plugin.py @@ -1,44 +1,39 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause -import os from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtGui import QIcon -import bec_widgets -from bec_widgets.widgets.device_box.device_box import DeviceBox +from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox DOM_XML = """ - + """ -MODULE_PATH = os.path.dirname(bec_widgets.__file__) - -class DeviceBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None def createWidget(self, parent): - t = DeviceBox(parent) + t = PositionerBox(parent) return t def domXml(self): return DOM_XML def group(self): - return "Device Control" + return "" def icon(self): - icon_path = os.path.join(MODULE_PATH, "assets", "designer_icons", "device_box.png") - return QIcon(icon_path) + return QIcon() def includeFile(self): - return "device_box" + return "positioner_box" def initialize(self, form_editor): self._form_editor = form_editor @@ -50,10 +45,10 @@ class DeviceBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover return self._form_editor is not None def name(self): - return "DeviceBox" + return "PositionerBox" def toolTip(self): - return "A widget for controlling a single positioner. " + return "Simple Widget to control a positioner in box form" def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/device_box/register_device_box.py b/bec_widgets/widgets/positioner_box/register_positioner_box.py similarity index 64% rename from bec_widgets/widgets/device_box/register_device_box.py rename to bec_widgets/widgets/positioner_box/register_positioner_box.py index 1d7aeab8..d5e333de 100644 --- a/bec_widgets/widgets/device_box/register_device_box.py +++ b/bec_widgets/widgets/positioner_box/register_positioner_box.py @@ -6,9 +6,9 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.device_box.device_box_plugin import DeviceBoxPlugin + from bec_widgets.widgets.positioner_box.positioner_box_plugin import PositionerBoxPlugin - QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceBoxPlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBoxPlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py index 675d35d6..99df7d12 100644 --- a/tests/unit_tests/test_bec_dock.py +++ b/tests/unit_tests/test_bec_dock.py @@ -127,10 +127,12 @@ def test_toolbar_add_plot_motor_map(bec_dock_area): assert bec_dock_area.panels["motor_map_1"].widgets[0].config.widget_class == "BECMotorMapWidget" -def test_toolbar_add_device_device_box(bec_dock_area): - bec_dock_area.toolbar.widgets["menu_devices"].widgets["device_box"].trigger() - assert "device_box_1" in bec_dock_area.panels - assert bec_dock_area.panels["device_box_1"].widgets[0].config.widget_class == "DeviceBox" +def test_toolbar_add_device_positioner_box(bec_dock_area): + bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger() + assert "positioner_box_1" in bec_dock_area.panels + assert ( + bec_dock_area.panels["positioner_box_1"].widgets[0].config.widget_class == "PositionerBox" + ) def test_toolbar_add_utils_queue(bec_dock_area): diff --git a/tests/unit_tests/test_device_box.py b/tests/unit_tests/test_device_box.py deleted file mode 100644 index 0cc9f679..00000000 --- a/tests/unit_tests/test_device_box.py +++ /dev/null @@ -1,98 +0,0 @@ -from unittest import mock - -import pytest -from bec_lib.endpoints import MessageEndpoints -from bec_lib.messages import ScanQueueMessage -from qtpy.QtGui import QValidator - -from bec_widgets.widgets.device_box.device_box import DeviceBox - -from .client_mocks import mocked_client - - -@pytest.fixture -def device_box(qtbot, mocked_client): - with mock.patch("bec_widgets.widgets.device_box.device_box.uuid.uuid4") as mock_uuid: - mock_uuid.return_value = "fake_uuid" - db = DeviceBox(device="samx", client=mocked_client) - qtbot.addWidget(db) - yield db - - -def test_device_box(device_box): - assert device_box.device == "samx" - data = device_box.dev["samx"].read() - - setpoint_text = device_box.ui.setpoint.text() - # check that the setpoint is taken correctly after init - assert float(setpoint_text) == data["samx_setpoint"]["value"] - - # check that the precision is taken correctly after init - precision = device_box.dev["samx"].precision - assert setpoint_text == f"{data['samx_setpoint']['value']:.{precision}f}" - - # check that the step size is set according to the device precision - assert device_box.ui.step_size.value() == 10**-precision * 10 - - -def test_device_box_update_limits(device_box): - device_box._limits = None - device_box.update_limits([0, 10]) - assert device_box._limits == [0, 10] - assert device_box.setpoint_validator.bottom() == 0 - assert device_box.setpoint_validator.top() == 10 - assert device_box.setpoint_validator.validate("100", 0) == ( - QValidator.State.Intermediate, - "100", - 0, - ) - - device_box.update_limits(None) - assert device_box._limits is None - assert device_box.setpoint_validator.validate("100", 0) == ( - QValidator.State.Acceptable, - "100", - 0, - ) - - -def test_device_box_on_stop(device_box): - with mock.patch.object(device_box.client.connector, "send") as mock_send: - device_box.on_stop() - params = {"device": "samx", "rpc_id": "fake_uuid", "func": "stop", "args": [], "kwargs": {}} - msg = ScanQueueMessage( - scan_type="device_rpc", - parameter=params, - queue="emergency", - metadata={"RID": "fake_uuid", "response": False}, - ) - mock_send.assert_called_once_with(MessageEndpoints.scan_queue_request(), msg) - - -def test_device_box_setpoint_change(device_box): - with mock.patch.object(device_box.dev["samx"], "move") as mock_move: - device_box.ui.setpoint.setText("100") - device_box.on_setpoint_change() - mock_move.assert_called_once_with(100, relative=False) - - -def test_device_box_on_tweak_right(device_box): - with mock.patch.object(device_box.dev["samx"], "move") as mock_move: - device_box.ui.step_size.setValue(0.1) - device_box.on_tweak_right() - mock_move.assert_called_once_with(0.1, relative=True) - - -def test_device_box_on_tweak_left(device_box): - with mock.patch.object(device_box.dev["samx"], "move") as mock_move: - device_box.ui.step_size.setValue(0.1) - device_box.on_tweak_left() - mock_move.assert_called_once_with(-0.1, relative=True) - - -def test_device_box_setpoint_out_of_range(device_box): - device_box.update_limits([0, 10]) - device_box.ui.setpoint.setText("100") - device_box.on_setpoint_change() - assert device_box.ui.setpoint.text() == "100" - assert device_box.ui.setpoint.hasAcceptableInput() == False diff --git a/tests/unit_tests/test_positioner_box.py b/tests/unit_tests/test_positioner_box.py new file mode 100644 index 00000000..85c9b5a3 --- /dev/null +++ b/tests/unit_tests/test_positioner_box.py @@ -0,0 +1,104 @@ +from unittest import mock + +import pytest +from bec_lib.device import Positioner +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import ScanQueueMessage +from qtpy.QtGui import QValidator + +from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox + +from .client_mocks import mocked_client + + +@pytest.fixture +def positioner_box(qtbot, mocked_client): + with mock.patch("bec_widgets.widgets.positioner_box.positioner_box.uuid.uuid4") as mock_uuid: + mock_uuid.return_value = "fake_uuid" + with mock.patch( + "bec_widgets.widgets.positioner_box.positioner_box.PositionerBox._check_device_is_valid", + return_value=True, + ): + db = PositionerBox(device="samx", client=mocked_client) + qtbot.addWidget(db) + yield db + + +def test_positioner_box(positioner_box): + assert positioner_box.device == "samx" + data = positioner_box.dev["samx"].read() + # Avoid check for Positioner class from BEC in _init_device + + setpoint_text = positioner_box.ui.setpoint.text() + # check that the setpoint is taken correctly after init + assert float(setpoint_text) == data["samx_setpoint"]["value"] + + # check that the precision is taken correctly after isnit + precision = positioner_box.dev["samx"].precision + assert setpoint_text == f"{data['samx_setpoint']['value']:.{precision}f}" + + # check that the step size is set according to the device precision + assert positioner_box.ui.step_size.value() == 10**-precision * 10 + + +def test_positioner_box_update_limits(positioner_box): + positioner_box._limits = None + positioner_box.update_limits([0, 10]) + assert positioner_box._limits == [0, 10] + assert positioner_box.setpoint_validator.bottom() == 0 + assert positioner_box.setpoint_validator.top() == 10 + assert positioner_box.setpoint_validator.validate("100", 0) == ( + QValidator.State.Intermediate, + "100", + 0, + ) + + positioner_box.update_limits(None) + assert positioner_box._limits is None + assert positioner_box.setpoint_validator.validate("100", 0) == ( + QValidator.State.Acceptable, + "100", + 0, + ) + + +def test_positioner_box_on_stop(positioner_box): + with mock.patch.object(positioner_box.client.connector, "send") as mock_send: + positioner_box.on_stop() + params = {"device": "samx", "rpc_id": "fake_uuid", "func": "stop", "args": [], "kwargs": {}} + msg = ScanQueueMessage( + scan_type="device_rpc", + parameter=params, + queue="emergency", + metadata={"RID": "fake_uuid", "response": False}, + ) + mock_send.assert_called_once_with(MessageEndpoints.scan_queue_request(), msg) + + +def test_positioner_box_setpoint_change(positioner_box): + with mock.patch.object(positioner_box.dev["samx"], "move") as mock_move: + positioner_box.ui.setpoint.setText("100") + positioner_box.on_setpoint_change() + mock_move.assert_called_once_with(100, relative=False) + + +def test_positioner_box_on_tweak_right(positioner_box): + with mock.patch.object(positioner_box.dev["samx"], "move") as mock_move: + positioner_box.ui.step_size.setValue(0.1) + positioner_box.on_tweak_right() + mock_move.assert_called_once_with(0.1, relative=True) + + +def test_positioner_box_on_tweak_left(positioner_box): + with mock.patch.object(positioner_box.dev["samx"], "move") as mock_move: + positioner_box.ui.step_size.setValue(0.1) + positioner_box.on_tweak_left() + mock_move.assert_called_once_with(-0.1, relative=True) + + +def test_positioner_box_setpoint_out_of_range(positioner_box): + positioner_box.update_limits([0, 10]) + positioner_box.ui.setpoint.setText("100") + positioner_box.on_setpoint_change() + assert positioner_box.ui.setpoint.text() == "100" + assert positioner_box.ui.setpoint.hasAcceptableInput() == False