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 1700b3e1..256b6b9a 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 @@ -9,7 +9,7 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority from bec_lib.device import Signal as BECSignal from bec_lib.logger import bec_logger from pydantic import Field, field_validator -from qtpy.QtCore import QSize, QStringListModel, Signal, Slot +from qtpy.QtCore import QSize, QStringListModel, Qt, Signal, Slot from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy from bec_widgets.utils.bec_connector import ConnectionConfig @@ -219,7 +219,9 @@ class DeviceComboBox(BECWidget, QComboBox): self._callback_id = self.bec_dispatcher.client.callbacks.register( EventType.DEVICE_UPDATE, self.on_device_update ) - self.device_config_update.connect(self.update_devices_from_filters) + self.device_config_update.connect( + self.update_devices_from_filters, Qt.ConnectionType.QueuedConnection + ) self.currentTextChanged.connect(self.check_validity) self.check_validity(self.currentText()) @@ -255,6 +257,9 @@ class DeviceComboBox(BECWidget, QComboBox): @SafeSlot() def update_devices_from_filters(self): """Refresh the available device list from current device/readout/signal filters.""" + if getattr(self, "_destroyed", False): + return + self.config.device_filter = [entry.value for entry in self.device_filter] self.config.readout_filter = [entry.value for entry in self.readout_filter] self.config.signal_class_filter = self.signal_class_filter @@ -489,6 +494,8 @@ class DeviceComboBox(BECWidget, QComboBox): action: Device update action emitted by BEC. content: Device update payload. Currently unused. """ + if getattr(self, "_destroyed", False): + return if action in ["add", "remove", "reload"]: self.device_config_update.emit() @@ -496,6 +503,7 @@ class DeviceComboBox(BECWidget, QComboBox): """Cleanup the widget.""" if self._callback_id is not None: self.bec_dispatcher.client.callbacks.remove(self._callback_id) + self._callback_id = None super().cleanup() def get_current_device(self) -> object: diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 89478694..28d67389 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -77,6 +77,7 @@ class SignalComboBox(BECWidget, QComboBox): device_signal_changed = Signal(str) signal_reset = Signal() + device_config_update = Signal() def __init__( self, @@ -138,7 +139,10 @@ class SignalComboBox(BECWidget, QComboBox): self.autocomplete = True self._device_update_register = self.bec_dispatcher.client.callbacks.register( - EventType.DEVICE_UPDATE, self.update_signals_from_filters + EventType.DEVICE_UPDATE, self.on_device_update + ) + self.device_config_update.connect( + self.update_signals_from_filters, Qt.ConnectionType.QueuedConnection ) self.currentTextChanged.connect(self.on_text_changed) @@ -207,6 +211,9 @@ class SignalComboBox(BECWidget, QComboBox): content: Optional callback payload from BEC device updates. Currently unused. metadata: Optional callback metadata from BEC device updates. Currently unused. """ + if getattr(self, "_destroyed", False): + return + self.config.signal_filter = [kind.name for kind in self.signal_filter] if self._signal_class_filter: @@ -247,6 +254,13 @@ class SignalComboBox(BECWidget, QComboBox): ), ) + def on_device_update(self, action: str, content: dict) -> None: + """Refresh filters when BEC reports device configuration changes.""" + if getattr(self, "_destroyed", False): + return + if action in ["add", "remove", "reload"]: + self.device_config_update.emit() + @Property(str) def device(self) -> str: """Selected device.""" @@ -588,7 +602,9 @@ class SignalComboBox(BECWidget, QComboBox): def cleanup(self): """Cleanup the widget.""" - self.bec_dispatcher.client.callbacks.remove(self._device_update_register) + if self._device_update_register is not None: + self.bec_dispatcher.client.callbacks.remove(self._device_update_register) + self._device_update_register = None super().cleanup() @staticmethod diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 14429080..a50b867c 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -907,7 +907,14 @@ class Image(ImageBase): async_signal_name=config.async_signal_name, ) - self.subscriptions["main"].async_signal_name = None + config.async_signal_name = None + if target_device == self._config.device and target_entry == self._config.signal: + config.device = "" + config.signal = "" + config.source = None + config.monitor_type = None + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") self.async_update = False self._sync_device_selection() diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index c998c953..9313c957 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -217,14 +217,14 @@ class Ring(BECWidget, QWidget): match mode: case "manual": - if self.config.mode == "manual": + if self.config.mode == "manual" and self.registered_slot is None: return if self.registered_slot is not None: self.bec_dispatcher.disconnect_slot(*self.registered_slot) self.config.mode = "manual" self.registered_slot = None case "scan": - if self.config.mode == "scan": + if self.config.mode == "scan" and self.registered_slot is not None: return if self.registered_slot is not None: self.bec_dispatcher.disconnect_slot(*self.registered_slot) @@ -383,9 +383,9 @@ class Ring(BECWidget, QWidget): """ current_RID = meta.get("RID", None) if current_RID != self.RID: + self.RID = current_RID self.set_min_max_values(0, msg.get("max_value", 100)) self.set_value(msg.get("value", 0)) - self.update() @SafeSlot(dict, dict) def on_device_readback(self, msg, meta): @@ -404,7 +404,6 @@ class Ring(BECWidget, QWidget): if value is None: return self.set_value(value) - self.update() @SafeSlot(dict, dict) def on_device_progress(self, msg, meta): @@ -424,7 +423,6 @@ class Ring(BECWidget, QWidget): if msg.get("done"): value = max_val self.set_value(value) - self.update() def paintEvent(self, event): if not self.progress_container: diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index e427caa4..8f1e71c6 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -103,7 +103,6 @@ class RingProgressContainerWidget(QWidget): self._hovered_ring = None self._last_hover_global_pos = None self._hover_tooltip.hide() - ring.cleanup() ring.close() ring.deleteLater() self.rings.pop(index) @@ -373,7 +372,7 @@ class RingProgressContainerWidget(QWidget): self._hovered_ring = None self._last_hover_global_pos = None self._hover_tooltip.hide() - for ring in self.rings: + for ring in list(self.rings): ring.close() ring.deleteLater() self.rings = [] diff --git a/tests/unit_tests/test_device_input_widgets.py b/tests/unit_tests/test_device_input_widgets.py index 78763071..b5927c5e 100644 --- a/tests/unit_tests/test_device_input_widgets.py +++ b/tests/unit_tests/test_device_input_widgets.py @@ -1,3 +1,5 @@ +from unittest import mock + import pytest from bec_lib.device import ReadoutPriority @@ -124,6 +126,19 @@ def test_device_input_combobox_disabled_invalid_has_neutral_border(device_input_ assert "red" in device_input_combobox.styleSheet() +def test_device_input_combobox_cleanup_unregisters_callback(qtbot, mocked_client): + with mock.patch.object(mocked_client.callbacks, "remove"): + widget = DeviceComboBox(client=mocked_client) + qtbot.addWidget(widget) + callback_id = widget._callback_id + + widget.close() + widget.deleteLater() + + mocked_client.callbacks.remove.assert_called_once_with(callback_id) + assert widget._callback_id is None + + 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_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index 836e3f4e..743a8140 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -188,10 +188,12 @@ def test_linked_device_combobox_updates_signal_combobox_on_each_text_change( def test_device_signal_input_base_cleanup(qtbot, mocked_client): with mock.patch.object(mocked_client.callbacks, "remove"): widget = SignalComboBox(client=mocked_client) + callback_id = widget._device_update_register widget.close() widget.deleteLater() - mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register) + mocked_client.callbacks.remove.assert_called_once_with(callback_id) + assert widget._device_update_register is None def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox): diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 77b003bd..33f351f2 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -464,6 +464,10 @@ def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch): assert view.subscriptions["main"].async_signal_name is None assert view.async_update is False + assert view.device == "" + assert view.signal == "" + assert view.subscriptions["main"].source is None + assert view.subscriptions["main"].monitor_type is None ##############################################