diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 13d697f6..08bfc26b 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2502,16 +2502,30 @@ class Image(RPCBase): @property @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - @monitor.setter + @device_name.setter @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. + """ + + @property + @rpc_call + def device_entry(self) -> "str": + """ + The signal/entry name to monitor on the device. + """ + + @device_entry.setter + @rpc_call + def device_entry(self) -> "str": + """ + The signal/entry name to monitor on the device. """ @rpc_call @@ -2617,8 +2631,8 @@ class Image(RPCBase): @rpc_call def image( self, - monitor: "str | tuple | None" = None, - monitor_type: "Literal['auto', '1d', '2d']" = "auto", + device_name: "str | None" = None, + device_entry: "str | None" = None, color_map: "str | None" = None, color_bar: "Literal['simple', 'full'] | None" = None, vrange: "tuple[int, int] | None" = None, @@ -2627,14 +2641,14 @@ class Image(RPCBase): Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ @property diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 69968ac4..a8e9b3b1 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -1,27 +1,25 @@ from __future__ import annotations from collections import defaultdict -from typing import Literal, Sequence +from typing import Literal import numpy as np from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QWidget from bec_widgets.utils import ConnectionConfig -from bec_widgets.utils.colors import Colors +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( - BECDeviceFilter, - ReadoutPriority, -) -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_item import ImageItem +from bec_widgets.widgets.plots.image.toolbar_components.device_selection import ( + DeviceSelection, + DeviceSelectionConnection, + device_selection_bundle, +) from bec_widgets.widgets.plots.plot_base import PlotBase logger = bec_logger.logger @@ -44,11 +42,19 @@ class ImageConfig(ConnectionConfig): class ImageLayerConfig(BaseModel): - monitor: str | tuple | None = Field(None, description="The name of the monitor.") - monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.") - source: Literal["device_monitor_1d", "device_monitor_2d", "auto"] = Field( - "auto", description="The source of the image data." + device_name: str = Field("", description="The device name to monitor.") + device_entry: str = Field("", description="The signal/entry name to monitor on the device.") + monitor_type: Literal["1d", "2d"] | None = Field(None, description="The type of monitor.") + source: Literal["device_monitor_1d", "device_monitor_2d"] | None = Field( + None, description="The source of the image data." ) + async_signal_name: str | None = Field( + None, description="Async signal name (obj_name) used for async endpoints." + ) + connection_status: Literal["connected", "disconnected", "error"] = Field( + "disconnected", description="Current connection status." + ) + connection_error: str | None = Field(None, description="Last connection error, if any.") class Image(ImageBase): @@ -74,8 +80,10 @@ class Image(ImageBase): "autorange.setter", "autorange_mode", "autorange_mode.setter", - "monitor", - "monitor.setter", + "device_name", + "device_name.setter", + "device_entry", + "device_entry.setter", "enable_colorbar", "enable_simple_colorbar", "enable_simple_colorbar.setter", @@ -96,6 +104,8 @@ class Image(ImageBase): "rois", ] + SUPPORTED_SIGNALS = ["AsyncSignal", "AsyncMultiSignal", "DynamicSignal"] + def __init__( self, parent: QWidget | None = None, @@ -108,15 +118,27 @@ class Image(ImageBase): if config is None: config = ImageConfig(widget_class=self.__class__.__name__) self.gui_id = config.gui_id - self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict( - lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto") - ) + self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(ImageLayerConfig) + # Store signal configs separately (not serialized to QSettings) + self._signal_configs: dict[str, dict] = {} + super().__init__( parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs ) + self._device_selection_updating = False + self._autorange_on_next_update = False self._init_toolbar_image() self.layer_removed.connect(self._on_layer_removed) + self.old_scan_id = None self.scan_id = None + self.async_update = False + self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) + + @property + def _config(self) -> ImageLayerConfig: + """Helper property to access the main layer config.""" + return self.subscriptions["main"] ################################## ### Toolbar Initialization @@ -126,38 +148,13 @@ class Image(ImageBase): """ Initializes the toolbar for the image widget. """ - self.device_combo_box = DeviceComboBox( - parent=self, - device_filter=BECDeviceFilter.DEVICE, - readout_priority_filter=[ReadoutPriority.ASYNC], + self.toolbar.add_bundle( + device_selection_bundle(self.toolbar.components, client=self.client) ) - self.device_combo_box.addItem("", None) - self.device_combo_box.setCurrentText("") - self.device_combo_box.setToolTip("Select Device") - self.device_combo_box.setFixedWidth(150) - self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) - - self.dim_combo_box = QComboBox(parent=self) - self.dim_combo_box.addItems(["auto", "1d", "2d"]) - self.dim_combo_box.setCurrentText("auto") - self.dim_combo_box.setToolTip("Monitor Dimension") - self.dim_combo_box.setFixedWidth(100) - self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) - - self.toolbar.components.add_safe( - "image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False) + self.toolbar.connect_bundle( + "device_selection", + DeviceSelectionConnection(self.toolbar.components, target_widget=self), ) - self.toolbar.components.add_safe( - "image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False) - ) - - bundle = ToolbarBundle("monitor_selection", self.toolbar.components) - bundle.add_action("image_device_combo") - bundle.add_action("image_dim_combo") - - self.toolbar.add_bundle(bundle) - self.device_combo_box.currentTextChanged.connect(self.connect_monitor) - self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) crosshair_bundle = self.toolbar.get_bundle("image_crosshair") crosshair_bundle.add_action("image_autorange") @@ -165,7 +162,7 @@ class Image(ImageBase): self.toolbar.show_bundles( [ - "monitor_selection", + "device_selection", "plot_export", "mouse_interaction", "image_crosshair", @@ -178,94 +175,359 @@ class Image(ImageBase): def _adjust_and_connect(self): """ - Adjust the size of the device combo box and populate it with preview signals. + Sync the device selection toolbar with current properties. Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing. - """ - self._populate_preview_signals() - self._reverse_device_items() - self.device_combo_box.setCurrentText("") # set again default to empty string - def _populate_preview_signals(self) -> None: + Note: DeviceComboBox and SignalComboBox auto-populate themselves, no manual population needed. """ - Populate the device combo box with preview-signal devices in the - format '_' and store the tuple(device, signal) in - the item's userData for later use. - """ - preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal") - for device, signal, signal_config in preview_signals: - label = signal_config.get("obj_name", f"{device}_{signal}") - self.device_combo_box.addItem(label, (device, signal, signal_config)) - - def _reverse_device_items(self) -> None: - """ - Reverse the current order of items in the device combo box while - keeping their userData and restoring the previous selection. - """ - current_text = self.device_combo_box.currentText() - items = [ - (self.device_combo_box.itemText(i), self.device_combo_box.itemData(i)) - for i in range(self.device_combo_box.count()) - ] - self.device_combo_box.clear() - for text, data in reversed(items): - self.device_combo_box.addItem(text, data) - if current_text: - self.device_combo_box.setCurrentText(current_text) + self._sync_device_selection() @SafeSlot() - def connect_monitor(self, *args, **kwargs): + def on_device_selection_changed(self, _): """ - Connect the target widget to the selected monitor based on the current device and dimension. - - If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor. + Called when device or signal selection changes in the toolbar. + This reads from the toolbar and updates the widget properties. """ - dim = self.dim_combo_box.currentText() - data = self.device_combo_box.currentData() + if self._device_selection_updating: + return - if isinstance(data, tuple): - self.image(monitor=data, monitor_type="auto") - else: - self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) + self._device_selection_updating = True + try: + try: + action = self.toolbar.components.get_action("device_selection") + except Exception: + return + + if action is None: + return + + device_selection: DeviceSelection = action.widget + device = device_selection.device_combo_box.currentText() + signal_text = device_selection.signal_combo_box.currentText() + + if not device: + self.device_name = "" + return + if not device_selection.device_combo_box.is_valid_input: + return + + if not device_selection.signal_combo_box.is_valid_input: + if self._config.device_entry: + self.device_entry = "" + if device != self._config.device_name: + self.device_name = device + return + + if device == self._config.device_name and signal_text == self._config.device_entry: + return + + # Get the signal config stored in the combobox + signal_config = device_selection.signal_combo_box.get_signal_config() + + if not signal_config: + # Fallback: try to get config from device + try: + device_obj = self.dev[device] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + logger.warning(f"Could not get signal config for {device}.{signal_text}") + signal_config = None + + # Store signal config and set properties which will trigger the connection + self._signal_configs["main"] = signal_config + self.device_name = device + self.device_entry = signal_text + finally: + self._device_selection_updating = False ################################################################################ # Data Acquisition - @SafeProperty(str) - def monitor(self) -> str: + @SafeProperty(str, auto_emit=True) + def device_name(self) -> str: """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - return self.subscriptions["main"].monitor or "" + return self._config.device_name - @monitor.setter - def monitor(self, value: str): + @device_name.setter + def device_name(self, value: str): """ - Set the monitor for the image. + Set the device name for the image. This should be used together with device_entry. + When both device_name and device_entry are set, the widget connects to that device signal. Args: - value(str): The name of the monitor to set. + value(str): The name of the device to monitor. """ - if self.subscriptions["main"].monitor == value: + if not value: + # Clear the monitor if empty device name + if self._config.device_name: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - try: - self.entry_validator.validate_monitor(value) - except ValueError: + + old_device = self._config.device_name + self._config.device_name = value + + # If we have a device_entry, reconnect with the new device + if self._config.device_entry: + # Try to get fresh signal config for the new device + try: + device_obj = self.dev[value] + # Try to get signal config for the current entry + if self._config.device_entry in device_obj._info.get("signals", {}): + self._signal_configs["main"] = device_obj._info["signals"][ + self._config.device_entry + ] + self._setup_connection() + else: + # Signal doesn't exist on new device + logger.warning( + f"Signal '{self._config.device_entry}' doesn't exist on device '{value}'" + ) + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status( + "error", f"Signal '{self._config.device_entry}' doesn't exist" + ) + except (KeyError, AttributeError): + # Device doesn't exist + logger.warning(f"Device '{value}' not found") + if old_device: + self._disconnect_current_monitor() + self._set_connection_status("error", f"Device '{value}' not found") + + # Toolbar sync happens via SafeProperty auto_emit property_changed handling. + + @SafeProperty(str, auto_emit=True) + def device_entry(self) -> str: + """ + The signal/entry name to monitor on the device. + """ + return self._config.device_entry + + @device_entry.setter + def device_entry(self, value: str): + """ + Set the device entry (signal) for the image. This should be used together with device_name. + When set, it will connect to updates from that device signal. + + Args: + value(str): The signal name to monitor. + """ + if not value: + if self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - self.image(monitor=value) + + self._config.device_entry = value + + # If we have a device_name, try to connect + if self._config.device_name: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(value) + if not isinstance(signal_config, dict) or not signal_config.get("signal_class"): + logger.warning( + f"Could not find valid configuration for signal '{value}' " + f"on device '{self._config.device_name}'." + ) + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + return + + self._signal_configs["main"] = signal_config + self._setup_connection() + except (KeyError, AttributeError): + logger.warning( + f"Could not find signal '{value}' on device '{self._config.device_name}'." + ) + # Remove signal config if it can't be fetched + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + + else: + logger.debug(f"device_entry setter: No device set yet for signal '{value}'") @property def main_image(self) -> ImageItem: """Access the main image item.""" return self.layer_manager["main"].image + def _setup_connection(self): + """ + Internal method to setup connection based on current device_name, device_entry, and signal_config. + """ + if not self._config.device_name or not self._config.device_entry: + logger.warning("Cannot setup connection without both device_name and device_entry") + self._set_connection_status("disconnected") + return + + signal_config = self._signal_configs.get("main") + if not signal_config: + logger.warning( + f"Cannot setup connection for {self._config.device_name}.{self._config.device_entry} without signal_config" + ) + self._set_connection_status("error", "Missing signal config") + return + + # Disconnect any existing monitor first + self._disconnect_current_monitor() + + # Determine monitor type and source from signal_config + signal_class = signal_config.get("signal_class", None) + supported_classes = ["PreviewSignal"] + self.SUPPORTED_SIGNALS + + if signal_class not in supported_classes: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' has unsupported signal class '{signal_class}'. " + f"Supported classes: {supported_classes}" + ) + self._set_connection_status("error", f"Unsupported signal class '{signal_class}'") + return + + describe = signal_config.get("describe") or {} + signal_info = describe.get("signal_info") or {} + ndim = signal_info.get("ndim", None) + + if ndim is None: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' does not have a valid 'ndim' in its signal_info." + ) + self._set_connection_status("error", "Missing ndim in signal_info") + return + + config = self.subscriptions["main"] + self.async_update = False + config.async_signal_name = None + + if ndim == 1: + config.source = "device_monitor_1d" + config.monitor_type = "1d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_1d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + elif ndim == 2: + config.source = "device_monitor_2d" + config.monitor_type = "2d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + else: + logger.warning( + f"Unsupported ndim '{ndim}' for monitor '{self._config.device_name}.{self._config.device_entry}'." + ) + self._set_connection_status("error", f"Unsupported ndim '{ndim}'") + return + + self._set_connection_status("connected") + logger.info( + f"Connected to {self._config.device_name}.{self._config.device_entry} with type {config.monitor_type}" + ) + self._autorange_on_next_update = True + + def _disconnect_current_monitor(self): + """ + Internal method to disconnect the current monitor subscriptions. + """ + if not self._config.device_name or not self._config.device_entry: + return + + config = self.subscriptions["main"] + + if self.async_update: + async_signal_name = config.async_signal_name or self._config.device_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, self._config.device_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device_name},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_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device_name},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_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 1d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + elif config.source == "device_monitor_2d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 2d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + + # Reset async state + self.async_update = False + config.async_signal_name = None + self._set_connection_status("disconnected") + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot(popup_error=True) def image( self, - monitor: str | tuple | None = None, - monitor_type: Literal["auto", "1d", "2d"] = "auto", + device_name: str | None = None, + device_entry: str | None = None, color_map: str | None = None, color_bar: Literal["simple", "full"] | None = None, vrange: tuple[int, int] | None = None, @@ -274,30 +536,39 @@ class Image(ImageBase): Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ + # Disconnect existing monitor if any + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() - if self.subscriptions["main"].monitor: - self.disconnect_monitor(self.subscriptions["main"].monitor) - if monitor is None or monitor == "": - logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed") + if not device_name or not device_entry: + if device_name or device_entry: + logger.warning("Both device_name and device_entry must be specified") + else: + logger.info("Disconnecting image monitor") + self.device_name = "" return None - if isinstance(monitor, str): - self.entry_validator.validate_monitor(monitor) - elif isinstance(monitor, Sequence): - self.entry_validator.validate_monitor(monitor[0]) - else: - raise ValueError(f"Invalid monitor type: {type(monitor)}") + # Validate device + self.entry_validator.validate_monitor(device_name) - self.set_image_update(monitor=monitor, type=monitor_type) + # Clear old entry first to avoid reconnect attempts on the new device + if self._config.device_entry: + self.device_entry = "" + + # Set properties to trigger connection + self.device_name = device_name + self.device_entry = device_entry + + # Apply visual settings if color_map is not None: self.main_image.color_map = color_map if color_bar is not None: @@ -305,32 +576,85 @@ class Image(ImageBase): if vrange is not None: self.vrange = vrange - self._sync_device_selection() - return self.main_image def _sync_device_selection(self): """ - Synchronize the device selection with the current monitor. + Synchronize the device and signal comboboxes with the current monitor state. + This ensures the toolbar reflects the device_name and device_entry properties. """ - config = self.subscriptions["main"] - if config.monitor is not None: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - if isinstance(config.monitor, (list, tuple)): - self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}") - else: - self.device_combo_box.setCurrentText(config.monitor) - self.dim_combo_box.setCurrentText(config.monitor_type) - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) - else: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - self.device_combo_box.setCurrentText("") - self.dim_combo_box.setCurrentText("auto") - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"Image ({self.object_name}) toolbar was not ready during init.") + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + target_device = self._config.device_name or "" + target_entry = self._config.device_entry or "" + + # Check if already synced + if ( + device_selection.device_combo_box.currentText() == target_device + and device_selection.signal_combo_box.currentText() == target_entry + ): + return + + device_selection.set_device_and_signal(target_device, target_entry) + + def _sync_device_entry_from_toolbar(self) -> None: + """ + Pull the signal selection from the toolbar if it differs from the current device_entry. + This keeps CLI-driven device_name updates in sync with the signal combobox state. + """ + if self._device_selection_updating: + return + + if not self._config.device_name: + return + + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + if device_selection.device_combo_box.currentText() != self._config.device_name: + return + + signal_text = device_selection.signal_combo_box.currentText() + if not signal_text or signal_text == self._config.device_entry: + return + + signal_config = device_selection.signal_combo_box.get_signal_config() + if not signal_config: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + signal_config = None + + if not signal_config: + return + + self._signal_configs["main"] = signal_config + self._device_selection_updating = True + try: + self.device_entry = signal_text + finally: + self._device_selection_updating = False + + def _set_connection_status(self, status: str, message: str | None = None) -> None: + self._config.connection_status = status + self._config.connection_error = message + self.property_changed.emit("connection_status", status) + self.property_changed.emit("connection_error", message or "") ################################################################################ # Post Processing @@ -411,107 +735,183 @@ class Image(ImageBase): ######################################## # Connections - @SafeSlot() - def set_image_update(self, monitor: str | tuple, type: Literal["1d", "2d", "auto"]): + @SafeSlot(dict, dict) + def on_scan_status(self, msg: dict, meta: dict): """ - Set the image update method for the given monitor. + Initial scan status message handler, which is triggered at the beginning and end of scan. + Needed for setup of AsyncSignal connections. Args: - monitor(str): The name of the monitor to use for the image. - type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + msg(dict): The message content. + meta(dict): The message metadata. """ + current_scan_id = msg.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - # TODO consider moving connecting and disconnecting logic to Image itself if multiple images - if isinstance(monitor, (list, tuple)): - device = self.dev[monitor[0]] - signal = monitor[1] - if len(monitor) == 3: - signal_config = monitor[2] - else: - signal_config = device._info["signals"][signal] - signal_class = signal_config.get("signal_class", None) - if signal_class != "PreviewSignal": - logger.warning(f"Signal '{monitor}' is not a PreviewSignal.") - return + @SafeSlot(dict, dict) + def on_scan_progress(self, msg: dict, meta: dict): + """ + For setting async image readback during scan progress updates if widget is started later than scan. - ndim = signal_config.get("describe", None).get("signal_info", None).get("ndim", None) - if ndim is None: - logger.warning( - f"Signal '{monitor}' does not have a valid 'ndim' in its signal_info." - ) - return + Args: + msg(dict): The message content. + meta(dict): The message metadata. + """ + current_scan_id = meta.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - if ndim == 1: - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif ndim == 2: - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" + def _handle_scan_change(self, current_scan_id: str): + """ + Update internal scan ids and refresh async connections if needed. + Also clears image buffers when scan changes. - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - if type == "1d": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif type == "2d": - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" - elif type == "auto": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "auto" - logger.warning( - f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints." - ) - self.subscriptions["main"].monitor_type = "auto" + Args: + current_scan_id (str): The current scan identifier. + """ + if current_scan_id == self.scan_id: + return - logger.info(f"Connected to {monitor} with type {type}") - self.subscriptions["main"].monitor = monitor + # Scan ID changed - clear buffers and reset image + self.old_scan_id = self.scan_id + self.scan_id = current_scan_id - def disconnect_monitor(self, monitor: str | tuple): + # Clear image buffer for 1D data accumulation + self.main_image.clear() + if hasattr(self.main_image, "buffer"): + self.main_image.buffer = [] + self.main_image.max_len = 0 + + # Reset crosshair if present + if self.crosshair is not None: + self.crosshair.reset() + + # Reconnect async image subscription with new scan_id + if self.async_update: + self._setup_async_image(scan_id=self.scan_id) + + def _get_async_signal_name(self) -> tuple[str, str] | None: + """ + Returns device name and async signal name used for endpoints/messages. + + Returns: + tuple[str, str] | None: (device_name, async_signal_name) or None if not available. + """ + if not self._config.device_name or not self._config.device_entry: + return None + + config = self.subscriptions["main"] + async_signal = config.async_signal_name or self._config.device_entry + return self._config.device_name, async_signal + + def _setup_async_image(self, scan_id: str | None): + """ + (Re)connect async image readback for the current scan. + + Args: + scan_id (str | None): The scan identifier to subscribe to. + """ + if not self.async_update: + return + + config = self.subscriptions["main"] + async_names = self._get_async_signal_name() + if async_names is None: + logger.info("Async image setup skipped because monitor information is incomplete.") + return + + device_name, async_signal = async_names + if config.monitor_type == "1d": + slot = self.on_image_update_1d + elif config.monitor_type == "2d": + slot = self.on_image_update_2d + else: + logger.warning( + f"Async image setup skipped due to unsupported monitor type '{config.monitor_type}'." + ) + return + + # Disconnect any previous scan subscriptions to avoid stale updates. + for prev_scan_id in (self.old_scan_id, self.scan_id): + if prev_scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + slot, MessageEndpoints.device_async_signal(prev_scan_id, device_name, async_signal) + ) + + if scan_id is None: + logger.info("Scan ID not available yet; delaying async image subscription.") + return + + self.bec_dispatcher.connect_slot( + slot, + MessageEndpoints.device_async_signal(scan_id, device_name, async_signal), + from_start=True, + cb_info={"scan_id": scan_id}, + ) + logger.info(f"Setup async image for {device_name}.{async_signal} and scan {scan_id}.") + + def disconnect_monitor(self, device_name: str | None = None, device_entry: str | None = None): """ Disconnect the monitor from the image update signals, both 1D and 2D. Args: - monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals. + device_name(str|None): The name of the device to disconnect. Defaults to current device. + device_entry(str|None): The signal/entry name to disconnect. Defaults to current entry. """ - if isinstance(monitor, (list, tuple)): - if self.subscriptions["main"].source == "device_monitor_1d": + config = self.subscriptions["main"] + target_device = device_name or self._config.device_name + target_entry = device_entry or self._config.device_entry + + if not target_device or not target_entry: + logger.warning("Cannot disconnect monitor without both device_name and device_entry") + 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(monitor[0], monitor[1]) + self.on_image_update_1d, + MessageEndpoints.device_preview(target_device, target_entry), ) - elif self.subscriptions["main"].source == "device_monitor_2d": + elif config.source == "device_monitor_2d": self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(monitor[0], monitor[1]) + self.on_image_update_2d, + MessageEndpoints.device_preview(target_device, target_entry), ) else: logger.warning( - f"Cannot disconnect monitor {monitor} with source {self.subscriptions['main'].source}" + f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" ) return - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].monitor = None + + self.subscriptions["main"].async_signal_name = None + self.async_update = False self._sync_device_selection() ######################################## @@ -521,32 +921,37 @@ class Image(ImageBase): def on_image_update_1d(self, msg: dict, metadata: dict): """ Update the image with 1D data. + For preview signals: metadata doesn't contain scan_id. + For async signals: scan_id is managed via on_scan_status/on_scan_progress. Args: msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] - current_scan_id = metadata.get("scan_id", None) - - if current_scan_id is None: + try: + image = self.main_image + except Exception: return - if current_scan_id != self.scan_id: - self.scan_id = current_scan_id - self.main_image.clear() - self.main_image.buffer = [] - self.main_image.max_len = 0 - if self.crosshair is not None: - self.crosshair.reset() - image_buffer = self.adjust_image_buffer(self.main_image, data) + data = self._get_payload_data(msg) + + if data is None: + logger.warning("No data received for image update from 1D.") + return + + image_buffer = self.adjust_image_buffer(image, data) + if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(image_buffer) + image.set_data(image_buffer) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() - def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray: + @staticmethod + def adjust_image_buffer(image: ImageItem, new_data: np.ndarray) -> np.ndarray: """ Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length. @@ -582,6 +987,7 @@ class Image(ImageBase): ######################################## # 2D updates + @SafeSlot(dict, dict) def on_image_update_2d(self, msg: dict, metadata: dict): """ Update the image with 2D data. @@ -590,14 +996,40 @@ class Image(ImageBase): msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] + try: + image = self.main_image + except Exception: + return + data = self._get_payload_data(msg) + if data is None: + logger.warning("No data received for image update from 2D.") + return if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(data) + image.set_data(data) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() + def _get_payload_data(self, msg: dict) -> np.ndarray | None: + """ + Extract payload from async/preview/monitor1D/2D message structures due to inconsistent formats in backend. + + Args: + msg (dict): The incoming message containing data. + """ + if not self.async_update: + return msg.get("data") + async_names = self._get_async_signal_name() + if async_names is None: + logger.warning("Async payload extraction failed; monitor info incomplete.") + return None + _, async_signal = async_names + return msg.get("signals", {}).get(async_signal, {}).get("value", None) + ################################################################################ # Clean up ################################################################################ @@ -612,28 +1044,33 @@ class Image(ImageBase): """ if layer_name not in self.subscriptions: return - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - config.monitor = None + # For the main layer, disconnect current monitor + if layer_name == "main" and self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) def cleanup(self): """ Disconnect the image update signals and clean up the image. """ self.layer_removed.disconnect(self._on_layer_removed) - for layer_name in list(self.subscriptions.keys()): - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - del self.subscriptions[layer_name] + + # Disconnect current monitor + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self.subscriptions.clear() - # Toolbar cleanup - self.device_combo_box.close() - self.device_combo_box.deleteLater() - self.dim_combo_box.close() - self.dim_combo_box.deleteLater() + # Toolbar cleanup - disconnect the device_selection bundle + try: + self.toolbar.disconnect_bundle("device_selection") + except Exception: # noqa: BLE001 + pass + + self.bec_dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.disconnect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) super().cleanup() @@ -643,6 +1080,7 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication, QHBoxLayout app = QApplication(sys.argv) + apply_theme("dark") win = QWidget() win.setWindowTitle("Image Demo") ml = QHBoxLayout(win) diff --git a/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py new file mode 100644 index 00000000..574fc3b9 --- /dev/null +++ b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py @@ -0,0 +1,253 @@ +from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget + +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox + + +class DeviceSelection(QWidget): + """Device and signal selection widget for image toolbar.""" + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent) + + self.client = client + self.supported_signals = [ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ] + + # Create device combobox with signal class filter + # This will only show devices that have signals matching the supported signal classes + self.device_combo_box = DeviceComboBox( + parent=self, client=self.client, signal_class_filter=self.supported_signals + ) + self.device_combo_box.setToolTip("Select Device") + self.device_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # Configure SignalComboBox to filter by PreviewSignal and supported async signals + # Also filter by ndim (1D and 2D only) for Image widget + self.signal_combo_box = SignalComboBox( + parent=self, + client=self.client, + signal_class_filter=[ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ], + ndim_filter=[1, 2], # Only show 1D and 2D signals for Image widget + store_signal_config=True, + require_device=True, + ) + self.signal_combo_box.setToolTip("Select Signal") + self.signal_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.signal_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # Connect comboboxes together + self.device_combo_box.currentTextChanged.connect(self.signal_combo_box.set_device) + self.device_combo_box.device_reset.connect(self.signal_combo_box.reset_selection) + + # Simple horizontal layout with stretch to fill space + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.device_combo_box, stretch=1) + layout.addWidget(self.signal_combo_box, stretch=1) + + def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None: + """Set the displayed device and signal without emitting selection signals.""" + device_name = device_name or "" + device_entry = device_entry or "" + + self.device_combo_box.blockSignals(True) + self.signal_combo_box.blockSignals(True) + + try: + if device_name: + # Set device in device_combo_box + index = self.device_combo_box.findText(device_name) + if index >= 0: + self.device_combo_box.setCurrentIndex(index) + else: + # Device not found in list, but still set it + self.device_combo_box.setCurrentText(device_name) + + # Only update signal combobox device filter if it's actually changing + # This prevents redundant repopulation which can cause duplicates !!!! + current_device = getattr(self.signal_combo_box, "_device", None) + if current_device != device_name: + self.signal_combo_box.set_device(device_name) + + # Sync signal combobox selection + if device_entry: + # Try to find the signal by component_name (which is what's displayed) + found = False + for i in range(self.signal_combo_box.count()): + text = self.signal_combo_box.itemText(i) + config_data = self.signal_combo_box.itemData(i) + + # Check if this matches our signal + if config_data: + component_name = config_data.get("component_name", "") + if text == component_name or text == device_entry: + self.signal_combo_box.setCurrentIndex(i) + found = True + break + + if not found: + # Fallback: try to match the device_entry directly + index = self.signal_combo_box.findText(device_entry) + if index >= 0: + self.signal_combo_box.setCurrentIndex(index) + else: + # No device set, clear selections + self.device_combo_box.setCurrentText("") + self.signal_combo_box.reset_selection() + finally: + # Always unblock signals + self.device_combo_box.blockSignals(False) + self.signal_combo_box.blockSignals(False) + + def set_connection_status(self, status: str, message: str | None = None) -> None: + tooltip = f"Connection status: {status}" + if message: + tooltip = f"{tooltip}\n{message}" + self.device_combo_box.setToolTip(tooltip) + self.signal_combo_box.setToolTip(tooltip) + + if not self.device_combo_box.is_valid_input or not self.signal_combo_box.is_valid_input: + return + + if status == "error": + style = "border: 1px solid orange;" + else: + style = "border: 1px solid transparent;" + + self.device_combo_box.setStyleSheet(style) + self.signal_combo_box.setStyleSheet(style) + + def cleanup(self): + """Clean up the widget resources.""" + self.device_combo_box.close() + self.device_combo_box.deleteLater() + self.signal_combo_box.close() + self.signal_combo_box.deleteLater() + + +def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle: + """ + Creates a device selection toolbar bundle for Image widget. + + Includes a resizable splitter after the device selection. All subsequent bundles' + actions will appear compactly after the splitter with no gaps. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + client: The BEC client instance. + + Returns: + ToolbarBundle: The device selection toolbar bundle. + """ + device_selection_widget = DeviceSelection(parent=components.toolbar, client=client) + components.add_safe( + "device_selection", WidgetAction(widget=device_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("device_selection", components) + bundle.add_action("device_selection") + + bundle.add_splitter( + name="device_selection_splitter", + target_widget=device_selection_widget, + min_width=210, + max_width=600, + ) + + return bundle + + +class DeviceSelectionConnection(BundleConnection): + """ + Connection helper for the device selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "device_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + self.register_property_sync("device_name", self._sync_from_device_name) + self.register_property_sync("device_entry", self._sync_from_device_entry) + self.register_property_sync("connection_status", self._sync_connection_status) + self.register_property_sync("connection_error", self._sync_connection_status) + + def _widget(self) -> DeviceSelection: + return self.components.get_action("device_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.connect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.connect( + self.target_widget.on_device_selection_changed + ) + self.connect_property_sync(self.target_widget) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.disconnect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.disconnect( + self.target_widget.on_device_selection_changed + ) + self.disconnect_property_sync(self.target_widget) + self._connected = False + widget.cleanup() + + def _sync_from_device_name(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + self.target_widget._sync_device_entry_from_toolbar() + + def _sync_from_device_entry(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + + def _sync_connection_status(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_connection_status( + self.target_widget._config.connection_status, + self.target_widget._config.connection_error, + ) diff --git a/docs/user/widgets/image/image_widget.md b/docs/user/widgets/image/image_widget.md index c5dc3b24..cc02dbd6 100644 --- a/docs/user/widgets/image/image_widget.md +++ b/docs/user/widgets/image/image_widget.md @@ -32,7 +32,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='eiger', monitor_type='2d') +img_widget.image(device_name='eiger', device_entry='preview') img_widget.title = "Camera Image - Eiger Detector" ``` @@ -46,7 +46,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='waveform', monitor_type='1d') +img_widget.image(device_name='waveform', device_entry='data') img_widget.title = "Line Detector Data" # Optional: Set the color map and value range @@ -84,7 +84,7 @@ The Image Widget can be configured for different detectors by specifying the cor ```python # For a 2D camera detector -img_widget = fig.image(monitor='eiger', monitor_type='2d') +img_widget = fig.image(device_name='eiger', device_entry='preview') img_widget.set_title("Eiger Camera Image") ``` @@ -92,7 +92,7 @@ img_widget.set_title("Eiger Camera Image") ```python # For a 1D line detector -img_widget = fig.image(monitor='waveform', monitor_type='1d') +img_widget = fig.image(device_name='waveform', device_entry='data') img_widget.set_title("Line Detector Data") ``` diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index 180d4806..81a9853d 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -59,7 +59,7 @@ def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui mm.map("samx", "samy") curve = wf.plot(x_name="samx", y_name="bpm4i") - im_item = im.image("eiger") + im_item = im.image(device_name="eiger", device_entry="preview") assert curve.__class__.__name__ == "RPCReference" assert curve.__class__ == RPCReference diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index c87bd0f1..14ddcd5f 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -42,7 +42,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): c3 = wf.plot(y=[1, 2, 3], x=[1, 2, 3]) assert c3.object_name == "Curve_0" - im.image(monitor="eiger") + im.image(device_name="eiger", device_entry="preview") mm.map(x_name="samx", y_name="samy") sw.plot(x_name="samx", y_name="samy", z_name="bpm4a") mw.plot(monitor="waveform") @@ -166,14 +166,14 @@ def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj): scans = client.scans im = dock_area.new("Image") - im.image(monitor="eiger") + im.image(device_name="eiger", device_entry="preview") status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False) status.wait() - last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[ - "data" - ].data + last_image_device = client.connector.get_last( + MessageEndpoints.device_preview("eiger", "preview") + )["data"].data last_image_plot = im.main_image.get_data() # check plotted data diff --git a/tests/end-2-end/test_rpc_register_e2e.py b/tests/end-2-end/test_rpc_register_e2e.py index 5eb52198..3e1bfca3 100644 --- a/tests/end-2-end/test_rpc_register_e2e.py +++ b/tests/end-2-end/test_rpc_register_e2e.py @@ -15,7 +15,7 @@ def test_rpc_reference_objects(connected_client_gui_obj): plt.plot(x_name="samx", y_name="bpm4i") im = dock_area.new("Image") - im.image("eiger") + im.image(device_name="eiger", device_entry="preview") motor_map = dock_area.new("MotorMap") motor_map.map("samx", "samy") plt_z = dock_area.new("Waveform") @@ -23,7 +23,8 @@ def test_rpc_reference_objects(connected_client_gui_obj): assert len(plt_z.curves) == 1 assert len(plt.curves) == 1 - assert im.monitor == "eiger" + assert im.device_name == "eiger" + assert im.device_entry == "preview" assert isinstance(im.main_image, RPCReference) image_item = gui._ipython_registry.get(im.main_image._gui_id, None) diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 8307b948..cb5d85b5 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING import numpy as np import pytest +from bec_lib.endpoints import MessageEndpoints from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference @@ -233,7 +234,7 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro scans = bec.scans dev = bec.device_manager.devices # Test rpc calls - img = widget.image(dev.eiger) + img = widget.image(device_name=dev.eiger.name, device_entry="preview") assert img.get_data() is None # Run a scan and plot the image s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) @@ -247,13 +248,13 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000) # Check that last image is equivalent to data in Redis - last_img = bec.device_monitor.get_data( - dev.eiger, count=1 - ) # Get last image from Redis monitor 2D endpoint + last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[ + "data" + ].data assert np.allclose(img.get_data(), last_img) # Now add a device with a preview signal - img = widget.image(["eiger", "preview"]) + img = widget.image(device_name="eiger", device_entry="preview") s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) s.wait() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 48050251..6144468d 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -1,6 +1,7 @@ import numpy as np import pyqtgraph as pg import pytest +from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QPointF from bec_widgets.widgets.plots.image.image import Image @@ -12,6 +13,23 @@ from tests.unit_tests.conftest import create_widget ################################################## +def _set_signal_config( + client, + device_name: str, + signal_name: str, + signal_class: str, + ndim: int, + obj_name: str | None = None, +): + device = client.device_manager.devices[device_name] + device._info["signals"][signal_name] = { + "obj_name": obj_name or signal_name, + "signal_class": signal_class, + "component_name": signal_name, + "describe": {"signal_info": {"ndim": ndim}}, + } + + def test_initialization_defaults(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) assert bec_image_view.color_map == "plasma" @@ -114,32 +132,35 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type): ############################################## -# Preview‑signal update mechanism +# Device/signal update mechanism -def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_1d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 1‑D PreviewSignal connects using the 1‑D path and updates correctly. + Ensure that calling .image() with a 1‑D PreviewSignal connects using the 1‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "waveform1d_img", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 1}}, - } + _set_signal_config( + mocked_client, + "waveform1d", + "img", + signal_class="PreviewSignal", + ndim=1, + obj_name="waveform1d_img", + ) - # Set the image monitor to the preview signal - view.image(monitor=("waveform1d", "img", signal_config)) + view.image(device_name="waveform1d", device_entry="img") # Subscriptions should indicate 1‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_1d" assert sub.monitor_type == "1d" - assert sub.monitor == ("waveform1d", "img", signal_config) + assert view.device_name == "waveform1d" + assert view.device_entry == "img" # Simulate a waveform update from the dispatcher waveform = np.arange(25, dtype=float) @@ -148,29 +169,32 @@ def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.raw_data[0], waveform) -def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_2d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 2‑D PreviewSignal connects using the 2‑D path and updates correctly. + Ensure that calling .image() with a 2‑D PreviewSignal connects using the 2‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "eiger_img2d", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 2}}, - } + _set_signal_config( + mocked_client, + "eiger", + "img2d", + signal_class="PreviewSignal", + ndim=2, + obj_name="eiger_img2d", + ) - # Set the image monitor to the preview signal - view.image(monitor=("eiger", "img2d", signal_config)) + view.image(device_name="eiger", device_entry="img2d") # Subscriptions should indicate 2‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_2d" assert sub.monitor_type == "2d" - assert sub.monitor == ("eiger", "img2d", signal_config) + assert view.device_name == "eiger" + assert view.device_entry == "img2d" # Simulate a 2‑D image update test_data = np.arange(16, dtype=float).reshape(4, 4) @@ -178,38 +202,197 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.image, test_data) +def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch): + """ + Preview/async combobox should omit 0‑D signals. + """ + view = create_widget(qtbot, Image, client=mocked_client) + + def fake_get(signal_class_filter): + signal_classes = ( + signal_class_filter + if isinstance(signal_class_filter, (list, tuple, set)) + else [signal_class_filter] + ) + if "PreviewSignal" in signal_classes: + return [ + ( + "eiger", + "sig0d", + { + "obj_name": "sig0d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 0}}, + }, + ), + ( + "eiger", + "sig2d", + { + "obj_name": "sig2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ), + ] + return [] + + monkeypatch.setattr(view.client.device_manager, "get_bec_signals", fake_get) + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.set_device("eiger") + device_selection.signal_combo_box.update_signals_from_signal_classes() + + texts = [ + device_selection.signal_combo_box.itemText(i) + for i in range(device_selection.signal_combo_box.count()) + ] + assert "sig0d" not in texts + assert "sig2d" in texts + + +def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch): + """ + Verify async signals use obj_name for endpoints/payloads and reconnect with scan_id. + """ + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + assert view.subscriptions["main"].async_signal_name == "async_obj" + assert view.async_update is True + + # Prepare scan ids and capture dispatcher calls + view.old_scan_id = "old_scan" + view.scan_id = "new_scan" + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, from_start=False, cb_info=None: connected.append( + (slot, endpoint, from_start, cb_info) + ), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint: disconnected.append((slot, endpoint)), + ) + + view._setup_async_image(view.scan_id) + + expected_new = MessageEndpoints.device_async_signal("new_scan", "eiger", "async_obj") + expected_old = MessageEndpoints.device_async_signal("old_scan", "eiger", "async_obj") + assert any(ep == expected_new for _, ep, _, _ in connected) + assert any(ep == expected_old for _, ep in disconnected) + + # Payload extraction should use obj_name + payload = np.array([1, 2, 3]) + msg = {"signals": {"async_obj": {"value": payload}}} + assert np.array_equal(view._get_payload_data(msg), payload) + + +def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + view.scan_id = "scan_x" + view.old_scan_id = "scan_y" + view.subscriptions["main"].async_signal_name = "async_obj" + + # Avoid touching real dispatcher + monkeypatch.setattr(view.bec_dispatcher, "disconnect_slot", lambda *args, **kwargs: None) + + view.disconnect_monitor(device_name="eiger", device_entry="img") + + assert view.subscriptions["main"].async_signal_name is None + assert view.async_update is False + + ############################################## -# Device monitor endpoint update mechanism +# Connection guardrails -def test_image_setup_image_2d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="2d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" - assert bec_image_view.subscriptions["main"].monitor_type == "2d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="Signal", ndim=2) + + view.image(device_name="eiger", device_entry="img") + + assert view.subscriptions["main"].source is None + assert view.subscriptions["main"].monitor_type is None + assert view.async_update is False -def test_image_setup_image_1d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="1d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_1d" - assert bec_image_view.subscriptions["main"].monitor_type == "1d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_disconnects_with_missing_entry(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + + view.image(device_name="eiger", device_entry="img") + assert view.device_name == "eiger" + assert view.device_entry == "img" + + view.image(device_name="eiger", device_entry=None) + assert view.device_name == "" + assert view.device_entry == "" -def test_image_setup_image_auto(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="auto") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "auto" - assert bec_image_view.subscriptions["main"].monitor_type == "auto" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_handle_scan_change_clears_buffers_and_resets_crosshair(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0, 2.0])] + view.main_image.max_len = 2 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + reset_called = [] + if view.crosshair is not None: + monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True)) + + view._handle_scan_change("scan_2") + + assert view.old_scan_id == "scan_1" + assert view.scan_id == "scan_2" + assert clear_called == [True] + assert view.main_image.buffer == [] + assert view.main_image.max_len == 0 + if view.crosshair is not None: + assert reset_called == [True] + + +def test_handle_scan_change_reconnects_async(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.async_update = True + + called = [] + monkeypatch.setattr(view, "_setup_async_image", lambda scan_id: called.append(scan_id)) + + view._handle_scan_change("scan_2") + + assert called == ["scan_2"] + + +def test_handle_scan_change_same_scan_noop(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0])] + view.main_image.max_len = 1 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + + view._handle_scan_change("scan_1") + + assert view.scan_id == "scan_1" + assert clear_called == [] + assert view.main_image.buffer == [np.array([1.0])] + assert view.main_image.max_len == 1 def test_image_data_update_2d(qtbot, mocked_client): @@ -245,8 +428,7 @@ def test_toolbar_actions_presence(qtbot, mocked_client): assert bec_image_view.toolbar.components.exists("image_autorange") assert bec_image_view.toolbar.components.exists("lock_aspect_ratio") assert bec_image_view.toolbar.components.exists("image_processing_fft") - assert bec_image_view.toolbar.components.exists("image_device_combo") - assert bec_image_view.toolbar.components.exists("image_dim_combo") + assert bec_image_view.toolbar.components.exists("device_selection") def test_auto_emit_syncs_image_toolbar_actions(qtbot, mocked_client): @@ -327,13 +509,40 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type): ################################### -def test_setup_image_from_toolbar(qtbot, mocked_client): +def test_setup_image_from_toolbar(qtbot, mocked_client, monkeypatch): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.device_combo_box.setCurrentText("eiger") - bec_image_view.dim_combo_box.setCurrentText("2d") + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + mocked_client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img", + { + "obj_name": "img", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) + ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) - assert bec_image_view.monitor == "eiger" + device_selection = bec_image_view.toolbar.components.get_action("device_selection").widget + device_selection.device_combo_box.update_devices_from_filters() + device_selection.device_combo_box.setCurrentText("eiger") + device_selection.signal_combo_box.setCurrentText("img") + + bec_image_view.on_device_selection_changed(None) + qtbot.wait(200) + + assert bec_image_view.device_name == "eiger" + assert bec_image_view.device_entry == "img" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" assert bec_image_view.subscriptions["main"].monitor_type == "2d" assert bec_image_view.main_image.raw_data is None @@ -598,90 +807,59 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): ############################################## -# MonitorSelectionToolbarBundle specific tests +# Device selection toolbar sync ############################################## -def test_monitor_selection_reverse_device_items(qtbot, mocked_client): - """ - Verify that _reverse_device_items correctly reverses the order of items in the - device combobox while preserving the current selection. - """ +def test_device_selection_syncs_from_properties(qtbot, mocked_client, monkeypatch): view = create_widget(qtbot, Image, client=mocked_client) - combo = view.device_combo_box - - # Replace existing items with a deterministic list - combo.clear() - combo.addItem("samx", 1) - combo.addItem("samy", 2) - combo.addItem("samz", 3) - combo.setCurrentText("samy") - - # Reverse the items - view._reverse_device_items() - - # Order should be reversed and selection preserved - assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"] - assert combo.currentText() == "samy" - - -def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch): - """ - Verify that _populate_preview_signals adds preview‑signal devices to the combo‑box - with the correct userData. - """ - view = create_widget(qtbot, Image, client=mocked_client) - - # Provide a deterministic fake device_manager with get_bec_signals - class _FakeDM: - def get_bec_signals(self, _filter): - return [ - ("eiger", "img", {"obj_name": "eiger_img"}), - ("async_device", "img2", {"obj_name": "async_device_img2"}), + _set_signal_config(mocked_client, "eiger", "img2d", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + view.client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img2d", + { + "obj_name": "img2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) + view.device_name = "eiger" + view.device_entry = "img2d" - initial_count = view.device_combo_box.count() + qtbot.wait(200) # Allow signal processing - view._populate_preview_signals() - - # Two new entries should have been added - assert view.device_combo_box.count() == initial_count + 2 - - # The first newly added item should carry tuple userData describing the device/signal - data = view.device_combo_box.itemData(initial_count) - assert isinstance(data, tuple) and data[0] == "eiger" + device_selection = view.toolbar.components.get_action("device_selection").widget + qtbot.waitUntil( + lambda: device_selection.device_combo_box.currentText() == "eiger" + and device_selection.signal_combo_box.currentText() == "img2d", + timeout=1000, + ) -def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch): - """ - Verify that _adjust_and_connect performs the full set-up: - - fills the combobox with preview signals, - - reverses their order, - - and resets the currentText to an empty string. - """ +def test_device_entry_syncs_from_toolbar(qtbot, mocked_client): 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) - # Deterministic fake device_manager - class _FakeDM: - def get_bec_signals(self, _filter): - return [("eiger", "img", {"obj_name": "eiger_img"})] + view.device_name = "eiger" + view.device_entry = "img_a" - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.blockSignals(True) + device_selection.signal_combo_box.setCurrentText("img_b") + device_selection.signal_combo_box.blockSignals(False) - combo = view.device_combo_box - # Start from a clean state - combo.clear() - combo.addItem("", None) - combo.setCurrentText("") + view._sync_device_entry_from_toolbar() - # Execute the method under test - view._adjust_and_connect() - - # Expect exactly two items: preview label followed by the empty default - assert combo.count() == 2 - # Because of the reversal, the preview label comes first - assert combo.itemText(0) == "eiger_img" - # Current selection remains empty - assert combo.currentText() == "" + assert view.device_entry == "img_b"