From 4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 12 Mar 2026 11:39:33 +0100 Subject: [PATCH] fix(image): disconnecting of 2d monitor --- bec_widgets/widgets/plots/image/image.py | 183 ++++++++++--------- tests/unit_tests/test_image_view_next_gen.py | 157 ++++++++++++++++ 2 files changed, 252 insertions(+), 88 deletions(-) diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 1fdc010b..d2ed8e39 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -270,6 +270,16 @@ class Image(ImageBase): return old_device = self._config.device + old_signal = self._config.signal + old_config = self.subscriptions["main"] + if old_device and old_signal and old_device != value: + self._disconnect_monitor_subscription( + device=old_device, + signal=old_signal, + source=old_config.source, + async_update=self.async_update, + async_signal_name=old_config.async_signal_name, + ) self._config.device = value # If we have a signal, reconnect with the new device @@ -325,6 +335,16 @@ class Image(ImageBase): self._set_connection_status("disconnected") return + old_signal = self._config.signal + old_config = self.subscriptions["main"] + if self._config.device and old_signal and old_signal != value: + self._disconnect_monitor_subscription( + device=self._config.device, + signal=old_signal, + source=old_config.source, + async_update=self.async_update, + async_signal_name=old_config.async_signal_name, + ) self._config.signal = value # If we have a device, try to connect @@ -447,6 +467,61 @@ class Image(ImageBase): ) self._autorange_on_next_update = True + def _disconnect_monitor_subscription( + self, + *, + device: str, + signal: str, + source: Literal["device_monitor_1d", "device_monitor_2d"] | None, + async_update: bool, + async_signal_name: str | None, + ) -> None: + if not device or not signal: + return + + if async_update: + async_signal_name = async_signal_name or signal + ids_to_check = [self.scan_id, self.old_scan_id] + + if source == "device_monitor_1d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_async_signal(scan_id, device, async_signal_name), + ) + logger.info( + f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}" + ) + elif source == "device_monitor_2d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_async_signal(scan_id, device, async_signal_name), + ) + logger.info( + f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}" + ) + return + + if source == "device_monitor_1d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, MessageEndpoints.device_preview(device, signal) + ) + logger.info( + f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}" + ) + elif source == "device_monitor_2d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, MessageEndpoints.device_preview(device, signal) + ) + logger.info( + f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}" + ) + def _disconnect_current_monitor(self): """ Internal method to disconnect the current monitor subscriptions. @@ -455,55 +530,13 @@ class Image(ImageBase): return config = self.subscriptions["main"] - - if self.async_update: - async_signal_name = config.async_signal_name or self._config.signal - ids_to_check = [self.scan_id, self.old_scan_id] - - if config.source == "device_monitor_1d": - for scan_id in ids_to_check: - if scan_id is None: - continue - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, - MessageEndpoints.device_async_signal( - scan_id, self._config.device, async_signal_name - ), - ) - logger.info( - f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}" - ) - elif config.source == "device_monitor_2d": - for scan_id in ids_to_check: - if scan_id is None: - continue - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, - MessageEndpoints.device_async_signal( - scan_id, self._config.device, async_signal_name - ), - ) - logger.info( - f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}" - ) - - else: - if config.source == "device_monitor_1d": - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, - MessageEndpoints.device_preview(self._config.device, self._config.signal), - ) - logger.info( - f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}" - ) - elif config.source == "device_monitor_2d": - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, - MessageEndpoints.device_preview(self._config.device, self._config.signal), - ) - logger.info( - f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}" - ) + self._disconnect_monitor_subscription( + device=self._config.device, + signal=self._config.signal, + source=config.source, + async_update=self.async_update, + async_signal_name=config.async_signal_name, + ) # Reset async state self.async_update = False @@ -860,45 +893,19 @@ class Image(ImageBase): logger.warning("Cannot disconnect monitor without both device and signal") return - if self.async_update: - async_signal_name = config.async_signal_name or target_entry - ids_to_check = [self.scan_id, self.old_scan_id] - if config.source == "device_monitor_1d": - for scan_id in ids_to_check: - if scan_id is None: - continue - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, - MessageEndpoints.device_async_signal( - scan_id, target_device, async_signal_name - ), - ) - elif config.source == "device_monitor_2d": - for scan_id in ids_to_check: - if scan_id is None: - continue - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, - MessageEndpoints.device_async_signal( - scan_id, target_device, async_signal_name - ), - ) - else: - if config.source == "device_monitor_1d": - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, - MessageEndpoints.device_preview(target_device, target_entry), - ) - elif config.source == "device_monitor_2d": - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, - MessageEndpoints.device_preview(target_device, target_entry), - ) - else: - logger.warning( - f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" - ) - return + if config.source not in {"device_monitor_1d", "device_monitor_2d"}: + logger.warning( + f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" + ) + return + + self._disconnect_monitor_subscription( + device=target_device, + signal=target_entry, + source=config.source, + async_update=self.async_update, + async_signal_name=config.async_signal_name, + ) self.subscriptions["main"].async_signal_name = None self.async_update = False diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index dfdb4f98..77b003bd 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -197,6 +197,163 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client): np.testing.assert_array_equal(view.main_image.image, test_data) +def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + _set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2) + + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, *args, **kwargs: connected.append(endpoint), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint), + ) + + view.image(device="eiger", signal="img") + connected.clear() + disconnected.clear() + + view.device = "waveform1d" + + assert MessageEndpoints.device_preview("eiger", "img") in disconnected + assert MessageEndpoints.device_preview("waveform1d", "img") in connected + + +def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch): + """ + Verify that switching device while async_update=True disconnects device_async_signal + endpoints for both scan_id and old_scan_id on the old device before reconnecting to + the new device. + """ + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj" + ) + _set_signal_config( + mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj" + ) + + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, *args, **kwargs: connected.append(endpoint), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint), + ) + + view.image(device="eiger", signal="img") + assert view.async_update is True + assert view.subscriptions["main"].async_signal_name == "async_obj" + + view.scan_id = "scan_current" + view.old_scan_id = "scan_previous" + connected.clear() + disconnected.clear() + + view.device = "waveform1d" + + # Both scan_id and old_scan_id endpoints for the old device must be disconnected + assert ( + MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected + ) + assert ( + MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected + ) + # The new device's async endpoint for the current scan must be connected + assert ( + MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected + ) + + +def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2) + _set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2) + + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, *args, **kwargs: connected.append(endpoint), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint), + ) + + view.image(device="eiger", signal="img_a") + connected.clear() + disconnected.clear() + + view.signal = "img_b" + + assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected + assert MessageEndpoints.device_preview("eiger", "img_b") in connected + + +def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch): + """ + When the current monitor is an async signal, switching to a different signal must + disconnect the previous async endpoint (based on scan_id/async_signal_name) before + reconnecting with the new signal's async endpoint. + """ + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a" + ) + _set_signal_config( + mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b" + ) + + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, *args, **kwargs: connected.append(endpoint), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint), + ) + + # Connect to img_a as an async signal; scan_id is None so no actual subscription is made + view.image(device="eiger", signal="img_a") + assert view.async_update is True + assert view.subscriptions["main"].async_signal_name == "async_obj_a" + assert view.subscriptions["main"].source == "device_monitor_2d" + + # Simulate an active scan so that the async endpoint is real + view.scan_id = "scan_123" + connected.clear() + disconnected.clear() + + # Switch to a different signal + view.signal = "img_b" + + # The previous async endpoint for img_a must have been disconnected + expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a") + assert expected_disconnect in disconnected + + # The new async endpoint for img_b must have been connected + expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b") + assert expected_connect in connected + + def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch): """ Preview/async combobox should omit 0‑D signals.