From 6e398e807740a476dcd8600529a34beb7d4dfe0e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 21 Jan 2026 16:03:49 +0100 Subject: [PATCH] feat(device_combobox): device filter added based on its signal classes --- .../base_classes/device_input_base.py | 40 +++++++++++- .../device_combobox/device_combobox.py | 61 +++++++++++++++++-- tests/unit_tests/test_device_input_base.py | 22 +++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py index 24b9c7db..8db1a14a 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py @@ -32,6 +32,7 @@ class DeviceInputConfig(ConnectionConfig): default: str | None = None arg_name: str | None = None apply_filter: bool = True + signal_class_filter: list[str] = [] @field_validator("device_filter") @classmethod @@ -125,11 +126,13 @@ class DeviceInputBase(BECWidget): current_device = WidgetIO.get_value(widget=self, as_string=True) self.config.device_filter = self.device_filter self.config.readout_filter = self.readout_filter + self.config.signal_class_filter = self.signal_class_filter if self.apply_filter is False: return all_dev = self.dev.enabled_devices + devs = self._filter_devices_by_signal_class(all_dev) # Filter based on device class - devs = [dev for dev in all_dev if self._check_device_filter(dev)] + devs = [dev for dev in devs if self._check_device_filter(dev)] # Filter based on readout priority devs = [dev for dev in devs if self._check_readout_filter(dev)] self.devices = [device.name for device in devs] @@ -190,6 +193,27 @@ class DeviceInputBase(BECWidget): self.config.apply_filter = value self.update_devices_from_filters() + @SafeProperty("QStringList") + def signal_class_filter(self) -> list[str]: + """ + Get the signal class filter for devices. + + Returns: + list[str]: List of signal class names used for filtering devices. + """ + return self.config.signal_class_filter + + @signal_class_filter.setter + def signal_class_filter(self, value: list[str] | None): + """ + Set the signal class filter and update the device list. + + Args: + value (list[str] | None): List of signal class names to filter by. + """ + self.config.signal_class_filter = value or [] + self.update_devices_from_filters() + @SafeProperty(bool) def filter_to_device(self): """Include devices in filters.""" @@ -379,6 +403,20 @@ class DeviceInputBase(BECWidget): """ return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter) + def _filter_devices_by_signal_class( + self, devices: list[Device | BECSignal | ComputedSignal | Positioner] + ) -> list[Device | BECSignal | ComputedSignal | Positioner]: + """Filter devices by signal class, if a signal class filter is set.""" + if not self.config.signal_class_filter: + return devices + if not self.client or not hasattr(self.client, "device_manager"): + return [] + signals = FilterIO.update_with_signal_class( + widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client + ) + allowed_devices = {device_name for device_name, _, _ in signals} + return [dev for dev in devices if dev.name in allowed_devices] + def _check_readout_filter( self, device: Device | BECSignal | ComputedSignal | Positioner ) -> bool: diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index f6bca8d4..cd07ef6d 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -27,6 +27,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox): available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied. default: Default device name. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. + signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown. """ USER_ACCESS = ["set_device", "devices"] @@ -51,6 +52,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox): available_devices: list[str] | None = None, default: str | None = None, arg_name: str | None = None, + signal_class_filter: list[str] | None = None, **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) @@ -63,6 +65,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox): self._is_valid_input = False self._accent_colors = get_accent_colors() self._set_first_element_as_empty = False + # We do not consider the config that is passed here, this produced problems # with QtDesigner, since config and input arguments may differ and resolve properly # Implementing this logic and config recoverage is postponed. @@ -85,6 +88,10 @@ class DeviceComboBox(DeviceInputBase, QComboBox): # Device filter default is None if device_filter is not None: self.set_device_filter(device_filter) + + if signal_class_filter is not None: + self.signal_class_filter = signal_class_filter + # Set default device if passed if default is not None: self.set_device(default) @@ -184,18 +191,62 @@ class DeviceComboBox(DeviceInputBase, QComboBox): if __name__ == "__main__": # pragma: no cover # pylint: disable=import-outside-toplevel - from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget + from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + ) from bec_widgets.utils.colors import apply_theme app = QApplication([]) apply_theme("dark") widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) + widget.setWindowTitle("DeviceComboBox demo") + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Device filter controls")) + controls = QHBoxLayout() + layout.addLayout(controls) + + class_input = QLineEdit() + class_input.setPlaceholderText("signal_class_filter (comma-separated), e.g. AsyncSignal") + controls.addWidget(class_input) + + filter_device = QCheckBox("Device") + filter_positioner = QCheckBox("Positioner") + filter_signal = QCheckBox("Signal") + filter_computed = QCheckBox("ComputedSignal") + controls.addWidget(filter_device) + controls.addWidget(filter_positioner) + controls.addWidget(filter_signal) + controls.addWidget(filter_computed) + combo = DeviceComboBox() - combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"] + combo.set_first_element_as_empty = True layout.addWidget(combo) + + def _apply_filters(): + raw = class_input.text().strip() + if raw: + combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()] + else: + combo.signal_class_filter = [] + combo.filter_to_device = filter_device.isChecked() + combo.filter_to_positioner = filter_positioner.isChecked() + combo.filter_to_signal = filter_signal.isChecked() + combo.filter_to_computed_signal = filter_computed.isChecked() + + class_input.textChanged.connect(_apply_filters) + filter_device.toggled.connect(_apply_filters) + filter_positioner.toggled.connect(_apply_filters) + filter_signal.toggled.connect(_apply_filters) + filter_computed.toggled.connect(_apply_filters) + _apply_filters() + widget.show() app.exec_() diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 7ab73e94..52fac530 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -9,6 +9,7 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp DeviceInputBase, DeviceInputConfig, ) +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from .client_mocks import mocked_client from .conftest import create_widget @@ -142,3 +143,24 @@ def test_device_input_base_properties(device_input_base): ReadoutPriority.MONITORED, ReadoutPriority.ON_REQUEST, ] + + +def test_device_combobox_signal_class_filter(qtbot, mocked_client): + """Test device filtering via signal_class_filter on combobox.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ("samx", "async_signal", {"signal_class": "AsyncSignal"}), + ("samy", "async_signal", {"signal_class": "AsyncSignal"}), + ("bpm4i", "async_signal", {"signal_class": "AsyncSignal"}), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=DeviceComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + ) + + devices = [widget.itemText(i) for i in range(widget.count())] + assert set(devices) == {"samx", "samy", "bpm4i"} + assert widget.signal_class_filter == ["AsyncSignal"]