0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat: (#569) add signal label widget

add a widget which shows the current value of a signal from BEC.
configurable with many properties in designer. intended for use mainly
in static GUIs.
This commit is contained in:
2025-05-16 10:17:27 +02:00
committed by Jan Wyzula
parent 91195ae0fd
commit 822e7d06ff
9 changed files with 864 additions and 11 deletions

View File

@ -52,6 +52,7 @@ _Widgets = {
"ScanControl": "ScanControl", "ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform", "ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox", "SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit", "SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton", "StopButton": "StopButton",
"TextBox": "TextBox", "TextBox": "TextBox",
@ -3459,6 +3460,78 @@ class SignalComboBox(RPCBase):
""" """
class SignalLabel(RPCBase):
@property
@rpc_call
def custom_label(self) -> "str":
"""
Use a cusom label rather than the signal name
"""
@property
@rpc_call
def custom_units(self) -> "str":
"""
Use a custom unit string
"""
@custom_label.setter
@rpc_call
def custom_label(self) -> "str":
"""
Use a cusom label rather than the signal name
"""
@custom_units.setter
@rpc_call
def custom_units(self) -> "str":
"""
Use a custom unit string
"""
@property
@rpc_call
def decimal_places(self) -> "int":
"""
Format to a given number of decimal_places. Set to 0 to disable.
"""
@decimal_places.setter
@rpc_call
def decimal_places(self) -> "int":
"""
Format to a given number of decimal_places. Set to 0 to disable.
"""
@property
@rpc_call
def show_default_units(self) -> "bool":
"""
Show default units obtained from the signal alongside it
"""
@show_default_units.setter
@rpc_call
def show_default_units(self) -> "bool":
"""
Show default units obtained from the signal alongside it
"""
@property
@rpc_call
def show_select_button(self) -> "bool":
"""
Show the button to select the signal to display
"""
@show_select_button.setter
@rpc_call
def show_select_button(self) -> "bool":
"""
Show the button to select the signal to display
"""
class SignalLineEdit(RPCBase): class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names.""" """Line edit widget for device input with autocomplete for device names."""

View File

@ -1,10 +1,11 @@
from bec_lib.callback_handler import EventType from bec_lib.callback_handler import EventType
from bec_lib.device import Signal from bec_lib.device import Signal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Slot from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@ -60,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ### ### Qt Slots ###
@Slot(str) @SafeSlot(str)
def set_signal(self, signal: str): def set_signal(self, signal: str):
""" """
Set the signal. Set the signal.
@ -76,7 +77,7 @@ class DeviceSignalInputBase(BECWidget):
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}." f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
) )
@Slot(str) @SafeSlot(str)
def set_device(self, device: str | None): def set_device(self, device: str | None):
""" """
Set the device. If device is not valid, device will be set to None which happens Set the device. If device is not valid, device will be set to None which happens
@ -90,8 +91,8 @@ class DeviceSignalInputBase(BECWidget):
self._device = device self._device = device
self.update_signals_from_filters() self.update_signals_from_filters()
@Slot(dict, dict) @SafeSlot(dict, dict)
@Slot() @SafeSlot()
def update_signals_from_filters( def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None self, content: dict | None = None, metadata: dict | None = None
): ):

View File

@ -1,11 +1,13 @@
from bec_lib.device import Positioner from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal, Slot from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase, DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
) )
@ -35,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self, self,
parent=None, parent=None,
client=None, client=None,
config: DeviceSignalInputBase = None, config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None, gui_id: str | None = None,
device: str | None = None, device: str | None = None,
signal_filter: str | list[str] | None = None, signal_filter: str | list[str] | None = None,
@ -65,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None: if default is not None:
self.set_signal(default) self.set_signal(default)
def update_signals_from_filters(self): @SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox""" """Update the filters for the combobox"""
super().update_signals_from_filters() super().update_signals_from_filters(content, metadata)
# pylint: disable=protected-access # pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler: if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0: if len(self._config_signals) > 0:
@ -84,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals") self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False) self.model().item(0).setEnabled(False)
@Slot(str) @SafeSlot(str)
def on_text_changed(self, text: str): def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name. For a positioner, the readback value has to be renamed to the device name.

View File

@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.signal_label.signal_label_plugin import SignalLabelPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLabelPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@ -0,0 +1,456 @@
from __future__ import annotations
import sys
import traceback
from typing import TYPE_CHECKING
from bec_lib.device import Device, Signal
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Signal as QSignal
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
DeviceInputConfig,
)
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
if TYPE_CHECKING:
from bec_lib.client import BECClient
class ChoiceDialog(QDialog):
accepted_output = QSignal(str, str)
CONNECTION_ERROR_STR = "Error: client is not connected!"
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client: BECClient | None = None,
show_hinted: bool = True,
show_normal: bool = False,
show_config: bool = False,
):
if not client or not client.started:
self._display_error()
return
super().__init__(parent=parent)
self.setWindowTitle("Choose device and signal...")
self._accent_colors = get_accent_colors()
layout = QHBoxLayout()
config_dict = config.model_dump() if config is not None else {}
self._device_config = DeviceInputConfig.model_validate(config_dict)
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
self._device_field = DeviceLineEdit(
config=self._device_config, parent=parent, client=client
)
self._signal_field = SignalComboBox(
config=self._signal_config,
device=self._signal_config.device,
parent=parent,
client=client,
)
layout.addWidget(self._device_field)
layout.addWidget(self._signal_field)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
self._signal_field.include_hinted_signals = show_hinted
self._signal_field.include_normal_signals = show_normal
self._signal_field.include_config_signals = show_config
self.setLayout(layout)
self._device_field.textChanged.connect(self._update_device)
self._device_field.setText(config.device if config is not None else "")
def _display_error(self):
try:
super().__init__()
except Exception:
...
layout = QHBoxLayout()
layout.addWidget(QLabel(self.CONNECTION_ERROR_STR))
self.button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.reject)
layout.addWidget(self.button_box)
self.setLayout(layout)
@SafeSlot(str)
def _update_device(self, device: str):
if device in self._device_field.dev:
self._device_field.set_device(device)
self._signal_field.set_device(device)
self._device_field.setStyleSheet(
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
else:
self._device_field.setStyleSheet(
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
self._signal_field.clear()
def accept(self):
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
return super().accept()
class SignalLabel(BECWidget, QWidget):
ICON_NAME = "scoreboard"
RPC = True
PLUGIN = True
USER_ACCESS = [
"custom_label",
"custom_units",
"custom_label.setter",
"custom_units.setter",
"decimal_places",
"decimal_places.setter",
"show_default_units",
"show_default_units.setter",
"show_select_button",
"show_select_button.setter",
]
def __init__(
self,
parent: QWidget | None = None,
client: BECClient | None = None,
device: str | None = None,
signal: str | None = None,
show_select_button: bool = True,
show_default_units: bool = False,
custom_label: str = "",
custom_units: str = "",
**kwargs,
):
"""Initialize the SignalLabel widget.
Args:
parent (QWidget, optional): The parent widget. Defaults to None.
client (BECClient, optional): The BEC client. Defaults to None.
device (str, optional): The device name. Defaults to None.
signal (str, optional): The signal name. Defaults to None.
selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog.
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
show_default_units (bool, optional): Whether to show default units. Defaults to False.
custom_label (str, optional): Custom label for the widget. Defaults to "".
custom_units (str, optional): Custom units for the widget. Defaults to "".
"""
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
super().__init__(parent=parent, client=client, **kwargs)
self._device = device
self._signal = signal
self._custom_label: str = custom_label
self._custom_units: str = custom_units
self._show_default_units: bool = show_default_units
self._decimal_places = 3
self._show_hinted_signals: bool = True
self._show_normal_signals: bool = False
self._show_config_signals: bool = False
self._outer_layout = QHBoxLayout()
self._layout = QHBoxLayout()
self._outer_layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._outer_layout)
self._label = QGroupBox(custom_label)
self._outer_layout.addWidget(self._label)
self._update_label()
self._label.setLayout(self._layout)
self._value: str = ""
self._display = QLabel()
self._layout.addWidget(self._display)
self._select_button = QToolButton()
self._select_button.setIcon(material_icon(icon_name="settings", size=(20, 20)))
self._show_select_button: bool = show_select_button
self._layout.addWidget(self._select_button)
self._display.setMinimumHeight(self._select_button.sizeHint().height())
self.show_select_button = self._show_select_button
self._select_button.clicked.connect(self.show_choice_dialog)
self.get_bec_shortcuts()
self._connected: bool = False
self.connect_device()
def _create_dialog(self):
return ChoiceDialog(
config=self._config,
parent=self,
client=self.client,
show_config=self.show_config_signals,
show_normal=self.show_normal_signals,
show_hinted=self.show_hinted_signals,
)
@SafeSlot()
def _process_dialog(self, device: str, signal: str):
self.disconnect_device()
self.device = device
self.signal = signal
self._update_label()
self.connect_device()
def show_choice_dialog(self):
dialog = self._create_dialog()
dialog.accepted_output.connect(self._process_dialog)
dialog.open()
return dialog
def connect_device(self):
"""Subscribe to the Redis topic for the device to display"""
if not self._connected and self._device and self._device in self.dev:
self._connected = True
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
self._manual_read()
self.set_display_value(self._value)
def disconnect_device(self):
"""Unsubscribe from the Redis topic for the device to display"""
if self._connected:
self._connected = False
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
def _manual_read(self):
if self._device is None or not isinstance(
(device := self.dev.get(self._device)), Device | Signal
):
self._units = ""
self._value = "__"
return
signal: Signal = (
getattr(device, self.signal, None) if isinstance(device, Device) else device
)
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
signal = None
if signal is None:
self._units = ""
self._value = "__"
return
self._value = signal.get()
self._units = signal.get_device_config().get("egu", "")
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
"""
Update the display with the new value.
"""
try:
signal_to_read = self._patch_hinted_signal()
self._value = msg["signals"][signal_to_read]["value"]
self.set_display_value(self._value)
except Exception as e:
self._display.setText("ERROR!")
self._display.setToolTip(
f"Error processing incoming reading: {msg}, handled with exception: {''.join(traceback.format_exception(e))}"
)
def _patch_hinted_signal(self):
if self.dev[self._device]._info["signals"] == {}:
return self._signal
signal_info = self.dev[self._device]._info["signals"][self._signal]
return (
signal_info["obj_name"] if signal_info["kind_str"] == Kind.hinted.name else self._signal
)
@SafeProperty(str)
def device(self) -> str:
"""The device from which to select a signal"""
return self._device or "Not set!"
@device.setter
def device(self, value: str) -> None:
self.disconnect_device()
self._device = value
self._config.device = value
self.connect_device()
self._update_label()
@SafeProperty(str)
def signal(self) -> str:
"""The signal to display"""
return self._signal or "Not set!"
@signal.setter
def signal(self, value: str) -> None:
self.disconnect_device()
self._signal = value
self._config.default = value
self.connect_device()
self._update_label()
@SafeProperty(bool)
def show_select_button(self) -> bool:
"""Show the button to select the signal to display"""
return self._show_select_button
@show_select_button.setter
def show_select_button(self, value: bool) -> None:
self._show_select_button = value
self._select_button.setVisible(value)
@SafeProperty(bool)
def show_default_units(self) -> bool:
"""Show default units obtained from the signal alongside it"""
return self._show_default_units
@show_default_units.setter
def show_default_units(self, value: bool) -> None:
self._show_default_units = value
self.set_display_value(self._value)
@SafeProperty(str)
def custom_label(self) -> str:
"""Use a cusom label rather than the signal name"""
return self._custom_label
@custom_label.setter
def custom_label(self, value: str) -> None:
self._custom_label = value
self._update_label()
@SafeProperty(str)
def custom_units(self) -> str:
"""Use a custom unit string"""
return self._custom_units
@custom_units.setter
def custom_units(self, value: str) -> None:
self._custom_units = value
self.set_display_value(self._value)
@SafeProperty(int)
def decimal_places(self) -> int:
"""Format to a given number of decimal_places. Set to 0 to disable."""
return self._decimal_places
@decimal_places.setter
def decimal_places(self, value: int) -> None:
self._decimal_places = value
self._update_label()
@SafeProperty(bool)
def show_hinted_signals(self) -> bool:
"""In the signal selection menu, show hinted signals"""
return self._show_hinted_signals
@show_hinted_signals.setter
def show_hinted_signals(self, value: bool) -> None:
self._show_hinted_signals = value
@SafeProperty(bool)
def show_config_signals(self) -> bool:
"""In the signal selection menu, show config signals"""
return self._show_config_signals
@show_config_signals.setter
def show_config_signals(self, value: bool) -> None:
self._show_config_signals = value
@SafeProperty(bool)
def show_normal_signals(self) -> bool:
"""In the signal selection menu, show normal signals"""
return self._show_normal_signals
@show_normal_signals.setter
def show_normal_signals(self, value: bool) -> None:
self._show_normal_signals = value
def _format_value(self, value: str):
if self._decimal_places == 0:
return value
try:
return f"{float(value):0.{self._decimal_places}f}"
except ValueError:
return value
@SafeSlot(str)
def set_display_value(self, value: str):
"""Set the display to a given value, appending the units if specified"""
self._display.setText(f"{self._format_value(value)}{self._units_string}")
self._display.setToolTip("")
@property
def _units_string(self):
if self.custom_units or self._show_default_units:
return f" {self.custom_units or self._default_units or ''}"
return ""
@property
def _default_units(self) -> str:
return self._units
@property
def _default_label(self) -> str:
return (
str(self._signal) if self._device == self._signal else f"{self._device} {self._signal}"
)
def _update_label(self):
self._label.setTitle(
self._custom_label if self._custom_label else f"{self._default_label}:"
)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = QWidget()
w.setLayout(QVBoxLayout())
w.layout().addWidget(
SignalLabel(
device="samx",
signal="readback",
custom_label="custom label:",
custom_units=" m/s/s",
show_select_button=False,
)
)
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
l = SignalLabel()
l.device = "bpm4i"
l.signal = "bpm4i"
w.layout().addWidget(l)
w.show()
sys.exit(app.exec_())

View File

@ -0,0 +1 @@
{'files': ['signal_label.py']}

View File

@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
DOM_XML = """
<ui language='c++'>
<widget class='SignalLabel' name='signal_label'>
</widget>
</ui>
"""
class SignalLabelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SignalLabel(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(SignalLabel.ICON_NAME)
def includeFile(self):
return "signal_label"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "SignalLabel"
def toolTip(self):
return "Display the live value of any signal"
def whatsThis(self):
return self.toolTip()

View File

@ -76,6 +76,10 @@ def test_device_signal_base_init(device_signal_base):
def test_device_signal_qproperties(device_signal_base): def test_device_signal_qproperties(device_signal_base):
"""Test if the DeviceSignalInputBase has the correct QProperties""" """Test if the DeviceSignalInputBase has the correct QProperties"""
assert device_signal_base._signal_filter == set()
device_signal_base.include_config_signals = False
device_signal_base.include_normal_signals = False
assert device_signal_base._signal_filter == set()
device_signal_base.include_config_signals = True device_signal_base.include_config_signals = True
assert device_signal_base._signal_filter == {Kind.config} assert device_signal_base._signal_filter == {Kind.config}
device_signal_base.include_normal_signals = True device_signal_base.include_normal_signals = True
@ -129,7 +133,7 @@ def test_signal_combobox(qtbot, device_signal_combobox):
assert device_signal_combobox._hinted_signals == ["fake_signal"] assert device_signal_combobox._hinted_signals == ["fake_signal"]
def test_signal_lineeidt(device_signal_line_edit): def test_signal_lineedit(device_signal_line_edit):
"""Test the signal_combobox""" """Test the signal_combobox"""
assert device_signal_line_edit._signals == [] assert device_signal_line_edit._signals == []

View File

@ -0,0 +1,243 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QDialogButtonBox, QLabel
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
from .client_mocks import mocked_client
SAMX_INFO_DICT = {
"signals": {
"readback": {
"component_name": "readback",
"obj_name": "samx",
"kind_int": 5,
"kind_str": "hinted",
"doc": "",
"describe": {"source": "SIM:samx", "dtype": "integer", "shape": [], "precision": 3},
"metadata": {
"connected": True,
"read_access": True,
"write_access": False,
"timestamp": 123456.789,
"status": None,
"severity": None,
"precision": None,
},
}
},
"setpoint": {
"component_name": "setpoint",
"obj_name": "samx_setpoint",
"kind_int": 1,
"kind_str": "normal",
"doc": "",
"describe": {
"source": "SIM:samx_setpoint",
"dtype": "integer",
"shape": [],
"precision": 3,
},
"metadata": {
"connected": True,
"read_access": True,
"write_access": True,
"timestamp": 1747657955.012516,
"status": None,
"severity": None,
"precision": None,
},
},
}
@pytest.fixture
def signal_label(qtbot, mocked_client: MagicMock):
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
config = DeviceSignalInputBaseConfig(device="samx", default="samx")
widget = SignalLabel(
config=config, custom_label="Test Label", custom_units="m/s", client=mocked_client
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.show()
yield widget
def test_initialization(signal_label: SignalLabel):
"""Test the initialization of the SignalLabel widget."""
assert signal_label.device == "Not set!"
assert signal_label.custom_label == "Test Label"
assert signal_label.custom_units == "m/s"
assert signal_label.show_select_button is True
assert signal_label.show_default_units is False
assert signal_label.decimal_places == 3
signal_label.set_display_value()
assert signal_label._display.text() == ""
def test_initialization_with_device(qtbot, mocked_client: MagicMock):
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
widget = SignalLabel(device="samx", signal="readback", client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.show()
assert widget._label.title() == "samx readback:"
def test_set_display_value(signal_label: SignalLabel, qtbot):
qtbot.addWidget(signal_label)
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.456 m/s"
def test_show_select_button(signal_label: SignalLabel, qtbot):
assert signal_label.show_select_button == True
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
signal_label.show_select_button = False
qtbot.waitUntil(lambda: not signal_label._select_button.isVisible(), timeout=1000)
signal_label.show_select_button = True
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
def test_show_default_units(signal_label: SignalLabel, qtbot):
signal_label.show_default_units = True
assert signal_label.show_default_units is True
signal_label.show_default_units = False
assert signal_label.show_default_units is False
def test_custom_label(signal_label: SignalLabel, qtbot):
signal_label.custom_label = "New Label"
assert signal_label._label.title() == "New Label"
def test_units_in_display(signal_label: SignalLabel, qtbot):
signal_label._value = "1.8"
signal_label.custom_units = "Mfurlong μfortnight⁻¹"
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
def test_decimal_places(signal_label: SignalLabel, qtbot):
signal_label.decimal_places = 2
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.46 m/s"
signal_label.decimal_places = 0
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.456 m/s"
def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["test device"] = MagicMock()
dialog._device_field.setText("test device")
dialog._signal_field.addItem("test signal")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
signal_label._process_dialog.assert_called_once_with("test device", "test signal")
def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot):
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["flux_capacitor"] = MagicMock()
dialog._device_field.setText("flux_capacitor")
dialog._signal_field.addItem("spin_speed")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
assert signal_label._device == "flux_capacitor"
assert signal_label._signal == "spin_speed"
def test_choose_signal_dialog_invalid_device(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.setText("invalid device")
dialog._signal_field.addItem("test signal")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
qtbot.wait(100)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Cancel), QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
signal_label._process_dialog.assert_not_called()
def test_choice_dialog_with_no_client(qtbot):
dialog = ChoiceDialog()
qtbot.addWidget(dialog)
assert dialog.button_box.button(QDialogButtonBox.Ok) is None
assert dialog.button_box.button(QDialogButtonBox.Cancel) is not None
assert dialog.layout().itemAt(0).widget().text() == ChoiceDialog.CONNECTION_ERROR_STR
def test_dialog_has_signals(signal_label: SignalLabel, qtbot):
signal_label._process_dialog = MagicMock()
dialog = signal_label.show_choice_dialog()
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["test device"] = MagicMock()
dialog._device_field.dev["test device"]._info = {
"signals": {"signal 1": {"kind_str": "hinted"}, "signal 2": {"kind_str": "normal"}}
}
dialog._device_field.setText("test device")
assert dialog._signal_field.count() == 2 # the actual signal and the category label
assert dialog._signal_field.currentText() == "signal 1"
def test_set_existing_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
assert signal_label._device == "samx"
assert signal_label._config.device == "samx"
assert signal_label._signal == "readback"
assert signal_label._config.default == "readback"
def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.custom_units = ""
signal_label.device = "samq"
signal_label.signal = "readfront"
assert signal_label._device == "samq"
assert signal_label._config.device == "samq"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
assert signal_label._signal == "readfront"
assert signal_label._config.default == "readfront"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
def test_handle_readback(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
signal_label.custom_units = "μm"
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
assert signal_label._display.text() == "ERROR!"
assert "Error processing incoming reading" in signal_label._display.toolTip()
signal_label.on_device_readback(
{
"signals": {
"samx": {"value": 0.9927490347496489, "timestamp": 1747662246.3741279},
"samx_setpoint": {"value": 1.0, "timestamp": 1747662246.368704},
"samx_motor_is_moving": {"value": 0, "timestamp": 1747662246.373092},
}
},
{},
)
assert signal_label._display.text() == "0.993 μm"
assert signal_label._display.toolTip() == ""