From c0a1f8e1ffb877022978ee2414bbb6762a1d3c85 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 1 Jul 2026 15:31:12 +0200 Subject: [PATCH] fix(device_combobox): include signals with write_access filter; include in scan control --- bec_widgets/tests/utils.py | 9 +++- .../device_combobox/device_combobox.py | 46 ++++++++++++++++++- .../control/scan_control/scan_group_box.py | 1 + tests/unit_tests/test_device_input_widgets.py | 43 +++++++++++++++++ tests/unit_tests/test_scan_control.py | 1 + 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index b0522a4f..7994c499 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -17,6 +17,7 @@ class FakeDevice(BECDevice): enabled=True, readout_priority=ReadoutPriority.MONITORED, signal_class: str | None = None, + write_access: bool = False, ): super().__init__(name=name) self._enabled = enabled @@ -35,6 +36,8 @@ class FakeDevice(BECDevice): if signal_class is None: signal_class = "AsyncSignal" if readout_priority == ReadoutPriority.ASYNC else "Signal" self._info = { + "read_access": True, + "write_access": write_access, "signals": { self.name: { "kind_str": "hinted", @@ -42,7 +45,7 @@ class FakeDevice(BECDevice): "obj_name": self.name, "signal_class": signal_class, } - } + }, } @property @@ -115,6 +118,8 @@ class FakePositioner(BECPositioner): "name": self.name, } self._info = { + "read_access": None, + "write_access": None, # positioner containers report no write access flag "signals": { "readback": { "kind_str": "hinted", @@ -131,7 +136,7 @@ class FakePositioner(BECPositioner): "component_name": "velocity", "obj_name": f"{self.name}_velocity", }, # config - } + }, } self.signals = { self.name: {"value": self.read_value}, 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 5314123d..c10807a2 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 @@ -42,6 +42,8 @@ class DeviceInputConfig(ConnectionConfig): signal_class_filter: Signal class names used to restrict listed devices. autocomplete: Whether to use the explicit completer model instead of Qt's default editable-combobox completer. + include_signals_with_write_access: Whether to additionally list signals that report + write access, in addition to the devices selected by the other filters. """ device_filter: list[str] = Field(default_factory=list) @@ -52,6 +54,7 @@ class DeviceInputConfig(ConnectionConfig): apply_filter: bool = True signal_class_filter: list[str] = Field(default_factory=list) autocomplete: bool = False + include_signals_with_write_access: bool = False @field_validator("device_filter") @classmethod @@ -115,6 +118,8 @@ class DeviceComboBox(BECWidget, QComboBox): signal_class_filter: Signal class names used to restrict listed devices. autocomplete: If True, use the explicit line-edit style completer. If False, keep Qt's default editable-combobox completion behavior. + include_signals_with_write_access: If True, additionally list signals that report + write access on top of the devices selected by the other filters. **kwargs: Additional keyword arguments passed to ``BECWidget``. """ @@ -146,6 +151,7 @@ class DeviceComboBox(BECWidget, QComboBox): arg_name: str | None = None, signal_class_filter: list[str] | None = None, autocomplete: bool | None = None, + include_signals_with_write_access: bool | None = None, **kwargs, ): self.config = self._process_config(config) @@ -190,6 +196,8 @@ class DeviceComboBox(BECWidget, QComboBox): self.config.autocomplete = autocomplete if self.config.autocomplete: self.autocomplete = True + if include_signals_with_write_access is not None: + self.config.include_signals_with_write_access = include_signals_with_write_access if available_devices is not None: self.set_available_devices(available_devices) @@ -261,10 +269,18 @@ class DeviceComboBox(BECWidget, QComboBox): if not self.apply_filter: return - devices = self._filter_devices_by_signal_class(self.dev.enabled_devices) + enabled_devices = self.dev.enabled_devices + devices = self._filter_devices_by_signal_class(enabled_devices) devices = [device for device in devices if self._check_device_filter(device)] devices = [device for device in devices if self._check_readout_filter(device)] - self.devices = [device.name for device in devices] + device_names = [device.name for device in devices] + if self.include_signals_with_write_access: + seen = set(device_names) + for name in self._signals_with_write_access(enabled_devices): + if name not in seen: + device_names.append(name) + seen.add(name) + self.devices = device_names @SafeSlot(list) def set_available_devices(self, devices: list[str]): @@ -317,6 +333,16 @@ class DeviceComboBox(BECWidget, QComboBox): self.config.signal_class_filter = value or [] self.update_devices_from_filters() + @SafeProperty(bool) + def include_signals_with_write_access(self) -> bool: + """Whether signals with write access are listed in addition to the other filters.""" + return self.config.include_signals_with_write_access + + @include_signals_with_write_access.setter + def include_signals_with_write_access(self, value: bool): + self.config.include_signals_with_write_access = value + self.update_devices_from_filters() + @SafeProperty(bool) def filter_to_device(self): """Include generic Device objects.""" @@ -602,6 +628,18 @@ class DeviceComboBox(BECWidget, QComboBox): ) -> bool: return device.readout_priority in self.readout_filter + @staticmethod + def _signals_with_write_access( + devices: list[Device | BECSignal | ComputedSignal | Positioner], + ) -> list[str]: + """Return the names of devices that report write access in their device info.""" + names = [] + for device in devices: + device_info = getattr(device, "_info", None) or {} + if device_info.get("write_access"): + names.append(device.name) + return names + def _update_validity_style(self, is_valid: bool) -> None: if is_valid or not self.isEnabled(): self.setStyleSheet("") @@ -663,10 +701,12 @@ if __name__ == "__main__": # pragma: no cover filter_positioner = QCheckBox("Positioner") filter_signal = QCheckBox("Signal") filter_computed = QCheckBox("ComputedSignal") + filter_write_access = QCheckBox("Include write-access signals") controls.addWidget(filter_device) controls.addWidget(filter_positioner) controls.addWidget(filter_signal) controls.addWidget(filter_computed) + controls.addWidget(filter_write_access) combo = DeviceComboBox() combo.set_first_element_as_empty = True @@ -679,12 +719,14 @@ if __name__ == "__main__": # pragma: no cover combo.filter_to_positioner = filter_positioner.isChecked() combo.filter_to_signal = filter_signal.isChecked() combo.filter_to_computed_signal = filter_computed.isChecked() + combo.include_signals_with_write_access = filter_write_access.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) + filter_write_access.toggled.connect(_apply_filters) _apply_filters() widget.show() diff --git a/bec_widgets/widgets/control/scan_control/scan_group_box.py b/bec_widgets/widgets/control/scan_control/scan_group_box.py index 93f73b89..840b3063 100644 --- a/bec_widgets/widgets/control/scan_control/scan_group_box.py +++ b/bec_widgets/widgets/control/scan_control/scan_group_box.py @@ -287,6 +287,7 @@ class ScanGroupBox(QGroupBox): arg_name=arg_name, default=default, device_filter=BECDeviceFilter.DEVICE, + include_signals_with_write_access=True, autocomplete=True, ) else: diff --git a/tests/unit_tests/test_device_input_widgets.py b/tests/unit_tests/test_device_input_widgets.py index 880759a9..b77d5e66 100644 --- a/tests/unit_tests/test_device_input_widgets.py +++ b/tests/unit_tests/test_device_input_widgets.py @@ -3,6 +3,7 @@ from unittest import mock import pytest from bec_lib.device import ReadoutPriority +from bec_widgets.tests.utils import FakeDevice from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( BECDeviceFilter, DeviceComboBox, @@ -156,6 +157,48 @@ def test_device_input_combobox_cleanup_clears_callback_before_unregister(qtbot, remove_mock.assert_called_once_with(callback_id) +def test_device_input_combobox_include_signals_with_write_access(qtbot, mocked_client): + settable_signal = FakeDevice("settable_signal", write_access=True) + mocked_client.device_manager.add_devices([settable_signal]) + + widget = DeviceComboBox(client=mocked_client, device_filter=BECDeviceFilter.POSITIONER) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert widget.include_signals_with_write_access is False + assert "samx" in widget.devices + assert "settable_signal" not in widget.devices + assert "bpm4i" not in widget.devices + + widget.include_signals_with_write_access = True + + assert widget.config.include_signals_with_write_access is True + assert "samx" in widget.devices + assert "settable_signal" in widget.devices + assert "bpm4i" not in widget.devices + + widget.include_signals_with_write_access = False + + assert "settable_signal" not in widget.devices + + +def test_device_input_combobox_include_signals_with_write_access_via_kwarg(qtbot, mocked_client): + settable_signal = FakeDevice("settable_signal", write_access=True) + mocked_client.device_manager.add_devices([settable_signal]) + + widget = DeviceComboBox( + client=mocked_client, + device_filter=BECDeviceFilter.POSITIONER, + include_signals_with_write_access=True, + ) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert widget.include_signals_with_write_access is True + assert "samx" in widget.devices + assert "settable_signal" in widget.devices + + def test_get_device_from_input_combobox_init(device_input_combobox): device_input_combobox.setCurrentIndex(0) device_text = device_input_combobox.currentText() diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index b6da2f96..91947e84 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -662,6 +662,7 @@ def test_on_scan_selected(scan_control, scan_name): if isinstance(widget, DeviceComboBox): assert widget.currentText() == "" assert widget.autocomplete is True + assert widget.include_signals_with_write_access is True assert "samx" in widget.devices assert ( "async_device" in widget.devices