0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-12 18:51: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",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton",
"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):
"""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.device import Signal
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.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@ -60,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ###
@Slot(str)
@SafeSlot(str)
def set_signal(self, signal: str):
"""
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}."
)
@Slot(str)
@SafeSlot(str)
def set_device(self, device: str | None):
"""
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.update_signals_from_filters()
@Slot(dict, dict)
@Slot()
@SafeSlot(dict, dict)
@SafeSlot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):

View File

@ -1,11 +1,13 @@
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 bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
)
@ -35,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self,
parent=None,
client=None,
config: DeviceSignalInputBase = None,
config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
@ -65,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None:
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"""
super().update_signals_from_filters()
super().update_signals_from_filters(content, metadata)
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@ -84,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@Slot(str)
@SafeSlot(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.
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):
"""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
assert device_signal_base._signal_filter == {Kind.config}
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"]
def test_signal_lineeidt(device_signal_line_edit):
def test_signal_lineedit(device_signal_line_edit):
"""Test the signal_combobox"""
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() == ""