diff --git a/bec_widgets/examples/device_manager_view/__init__.py b/bec_widgets/examples/device_manager_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py new file mode 100644 index 00000000..d3950dca --- /dev/null +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -0,0 +1,206 @@ +from typing import List + +import PySide6QtAds as QtAds +import yaml +from bec_qthemes import material_icon +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import ( + QPushButton, + QSizePolicy, + QSplitter, + QStackedLayout, + QTreeWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView +from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ( + DeviceManagerOphydTest, +) +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +class DeviceManagerView(BECWidget, QWidget): + + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.dock_manager = CDockManager(self) + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) # TODO will be replaced by explorer widget + self.device_table_view = DeviceTableView(self) + # Placeholder + self.dm_config_view = DMConfigView(self) + + # Placeholder for ophyd test + WebConsole.startup_cmd = "ipython" + self.ophyd_test = DeviceManagerOphydTest(self) + self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self) + self.ophyd_test_dock.setWidget(self.ophyd_test) + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) + self.device_table_view_dock.setWidget(self.device_table_view) + + # Device Table will be central widget + self.dock_manager.setCentralWidget(self.device_table_view_dock) + + self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self) + self.dm_config_view_dock.setWidget(self.dm_config_view) + + # Add the dock widgets to the dock manager + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock) + monaco_yaml_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock + ) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area + ) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + # Fetch all dock areas of the dock widgets (on our case always one dock area) + for dock in self.dock_manager.dockWidgets(): + area = dock.dockAreaWidget() + area.titleBar().setVisible(False) + + # Apply stretch after the layout is done + self.set_default_view([2, 5, 3], [5, 5]) + + # Connect slots + self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager_view = DeviceManagerView() + config = device_manager_view.client.device_manager._get_redis_device_config() + device_manager_view.device_table_view.set_device_config(config) + device_manager_view.show() + device_manager_view.setWindowTitle("Device Manager View") + device_manager_view.resize(1200, 800) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/examples/device_manager_view/device_manager_widget.py new file mode 100644 index 00000000..98e34fef --- /dev/null +++ b/bec_widgets/examples/device_manager_view/device_manager_widget.py @@ -0,0 +1,73 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + + +class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + self.setLayout(self.stacked_layout) + + # Add device manager view + self.device_manager_view = DeviceManagerView() + self.stacked_layout.addWidget(self.device_manager_view) + + # Add overlay widget + self._overlay_widget = QtWidgets.QWidget(self) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_overlay(self): + self._overlay_widget.setStyleSheet( + "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" + ) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + self._overlay_widget.setVisible(True) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_view.device_table_view.set_device_config(config) + self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager = DeviceManagerWidget() + # config = device_manager.client.device_manager._get_redis_device_config() + # device_manager.device_table_view.set_device_config(config) + device_manager.show() + device_manager.setWindowTitle("Device Manager View") + device_manager.resize(1600, 1200) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index b541916b..40f31cec 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -23,20 +23,14 @@ FUZZY_SEARCH_THRESHOLD = 80 class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - @staticmethod - def dict_to_str(d: dict) -> str: - """Convert a dictionary to a formatted string.""" - return json.dumps(d, indent=4) - def helpEvent(self, event, view, option, index): """Override to show tooltip when hovering.""" if event.type() != QtCore.QEvent.ToolTip: return super().helpEvent(event, view, option, index) model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) - row_dict = model.sourceModel().row_data(model_index) - row_dict.pop("description", None) - QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view) + row_dict = model.sourceModel().get_row_data(model_index) + QtWidgets.QToolTip.showText(event.globalPos(), row_dict["description"], view) return True @@ -47,10 +41,10 @@ class CenterCheckBoxDelegate(DictToolTipDelegate): super().__init__(parent) colors = get_accent_colors() self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default + "check_box", size=QtCore.QSize(16, 16), color=colors.default, filled=True ) self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default + "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default, filled=True ) def apply_theme(self, theme: str | None = None): @@ -128,10 +122,9 @@ class DeviceTableModel(QtCore.QAbstractTableModel): "name", "deviceClass", "readoutPriority", + "deviceTags", "enabled", "readOnly", - "deviceTags", - "description", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} @@ -150,7 +143,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel): return self.headers[section] return None - def row_data(self, index: QtCore.QModelIndex) -> dict: + def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} @@ -169,6 +162,8 @@ class DeviceTableModel(QtCore.QAbstractTableModel): return bool(value) if key == "deviceTags": return ", ".join(str(tag) for tag in value) if value else "" + if key == "deviceClass": + return str(value).split(".")[-1] return str(value) if value is not None else "" if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked @@ -436,6 +431,8 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" + selected_device = QtCore.Signal(dict) + RPC = False PLUGIN = False devices_removed = QtCore.Signal(list) @@ -508,10 +505,9 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description + self.table.setItemDelegateForColumn(3, self.wrap_delegate) # deviceTags + self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # readOnly # Column resize policies # TODO maybe we need here a flexible header options as deviceClass @@ -520,13 +516,12 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly - # TODO maybe better stretch... - header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description - self.table.setColumnWidth(3, 82) - self.table.setColumnWidth(4, 82) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) # deviceTags + header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # enabled + header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # readOnly + + self.table.setColumnWidth(3, 70) + self.table.setColumnWidth(4, 70) # Ensure column widths stay fixed header.setMinimumSectionSize(70) @@ -538,6 +533,8 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): # Selection behavior self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) @@ -566,6 +563,45 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): height = delegate.sizeHint(option, index).height() self.table.setRowHeight(row, height) + @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ) -> None: + """ + Handle selection changes in the device table. + + Args: + selected (QtCore.QItemSelection): The selected items. + deselected (QtCore.QItemSelection): The deselected items. + """ + # TODO also hook up logic if a config update is propagated from somewhere! + # selected_indexes = selected.indexes() + selected_indexes = self.table.selectionModel().selectedIndexes() + if not selected_indexes: + return + + source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes] + source_rows = {idx.row() for idx in source_indexes} + # Ignore if multiple are selected + if len(source_rows) > 1: + self.selected_device.emit({}) + return + + # Get the single row + (row,) = source_rows + source_index = self.model.index(row, 0) # pick column 0 or whichever + device = self.model.get_row_data(source_index) + self.selected_device.emit(device) + + @SafeSlot(QtCore.QModelIndex) + def _on_row_selected(self, index: QtCore.QModelIndex): + """Handle row selection in the device table.""" + if not index.isValid(): + return + source_index = self.proxy.mapToSource(index) + device = self.model.get_device_at_index(source_index) + self.selected_device.emit(device) + ###################################### ##### Ext. Slot API ################# ###################################### diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py new file mode 100644 index 00000000..155f82d9 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,71 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import yaml +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + + +class DMConfigView(BECWidget, QtWidgets.QWidget): + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget() + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + # self.monaco_editor.setFixedHeight(600) + self.monaco_editor.set_readonly(True) + + def _customize_overlay(self): + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setStyleSheet( + "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" + ) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: dict): + """Handle selection of a device from the device table.""" + if not device: + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + else: + text = yaml.dump(device, default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + self.monaco_editor.set_readonly(False) # Enable editing + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + config_view = DMConfigView() + config_view.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py new file mode 100644 index 00000000..a2a44f42 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -0,0 +1,333 @@ +"""Module to run a static test for the current config and see if it is valid.""" + +from __future__ import annotations + +import enum + +import bec_lib +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.editors.web_console.web_console import WebConsole + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server + import ophyd_devices + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + + +class ValidationStatus(int, enum.Enum): + """Validation status for device configurations.""" + + UNKNOWN = 0 # colors.default + ERROR = 1 # colors.emergency + VALID = 2 # colors.highlight + CANT_CONNECT = 3 # colors.warning + CONNECTED = 4 # colors.success + + +class DeviceValidationListItem(QtWidgets.QWidget): + """Custom list item widget showing device name and validation status.""" + + status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus + # Signal emitted when device was validated with name, success, msg + device_validated = QtCore.Signal(str, str) + + def __init__( + self, + device_config: dict[str, dict], + status: ValidationStatus, + status_icons: dict[ValidationStatus, QtGui.QPixmap], + validate_icon: QtGui.QPixmap, + parent=None, + static_device_test=None, + ): + super().__init__(parent) + if len(device_config.keys()) > 1: + logger.warning( + f"Multiple devices found for config: {list(device_config.keys())}, using first one" + ) + self._static_device_test = static_device_test + self.device_name = list(device_config.keys())[0] + self.device_config = device_config + self.status: ValidationStatus = status + colors = get_accent_colors() + self._status_icon = status_icons + self._validate_icon = validate_icon + self._setup_ui() + self._update_status_indicator() + + def _setup_ui(self): + """Setup the UI for the list item.""" + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Device name label + self.name_label = QtWidgets.QLabel(self.device_name) + self.name_label.setStyleSheet("font-weight: bold;") + layout.addWidget(self.name_label) + + # Make sure status is on the right + layout.addStretch() + self.request_validation_button = QtWidgets.QPushButton("Validate") + self.request_validation_button.setIcon(self._validate_icon) + if self._static_device_test is None: + self.request_validation_button.setDisabled(True) + else: + self.request_validation_button.clicked.connect(self.on_request_validation) + # self.request_validation_button.setVisible(False) -> Hide it?? + layout.addWidget(self.request_validation_button) + # Status indicator + self.status_indicator = QtWidgets.QLabel() + self._update_status_indicator() + layout.addWidget(self.status_indicator) + + @SafeSlot() + def on_request_validation(self): + """Handle validate button click.""" + if self._static_device_test is None: + logger.warning("Static device test not available.") + return + self._static_device_test.config = self.device_config + # TODO logic if connect is allowed + ret = self._static_device_test.run_with_list_output(connect=False)[0] + if ret.success: + self.set_status(ValidationStatus.VALID) + else: + self.set_status(ValidationStatus.ERROR) + self.device_validated.emit(ret.name, ret.message) + + def _update_status_indicator(self): + """Update the status indicator color based on validation status.""" + self.status_indicator.setPixmap(self._status_icon[self.status]) + + def set_status(self, status: ValidationStatus): + """Update the validation status.""" + self.status = status + self._update_status_indicator() + self.status_changed.emit(self.status) + + def get_status(self) -> ValidationStatus: + """Get the current validation status.""" + return self.status + + +class DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget): + + config_changed = QtCore.Signal( + dict, dict + ) # Signal emitted when the device config changed, new_config, old_config + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + if not READY_TO_TEST: + self._set_disabled() + static_device_test = None + else: + from ophyd_devices.utils.static_device_test import StaticDeviceTest + + static_device_test = StaticDeviceTest(config_dict={}) + self._static_device_test = static_device_test + self._device_config: dict[str, dict] = {} + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(4) + + # Setup icons + colors = get_accent_colors() + self._validate_icon = material_icon( + icon_name="play_arrow", color=colors.default, filled=True + ) + self._status_icons = { + ValidationStatus.UNKNOWN: material_icon( + icon_name="circle", size=(12, 12), color=colors.default, filled=True + ), + ValidationStatus.ERROR: material_icon( + icon_name="circle", size=(12, 12), color=colors.emergency, filled=True + ), + ValidationStatus.VALID: material_icon( + icon_name="circle", size=(12, 12), color=colors.highlight, filled=True + ), + ValidationStatus.CANT_CONNECT: material_icon( + icon_name="circle", size=(12, 12), color=colors.warning, filled=True + ), + ValidationStatus.CONNECTED: material_icon( + icon_name="circle", size=(12, 12), color=colors.success, filled=True + ), + } + + self.setLayout(self._main_layout) + + # splitter + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + self._main_layout.addWidget(self.splitter) + + # Add custom list + self.setup_device_validation_list() + + # Setup text box + self.setup_text_box() + + # Connect signals + self.config_changed.connect(self.on_config_updated) + + @SafeSlot(list) + def on_device_config_update(self, config: list[dict]): + old_cfg = self._device_config + self._device_config = self._compile_device_config_list(config) + self.config_changed.emit(self._device_config, old_cfg) + + def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]: + return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config} + + @SafeSlot(dict, dict) + def on_config_updated(self, new_config: dict, old_config: dict): + """Handle config updates and refresh the validation list.""" + # Find differences for potential re-validation + diffs = self._find_diffs(new_config, old_config) + # Check diff first + for diff in diffs: + if not diff: + continue + if len(diff) > 1: + logger.warning(f"Multiple devices found in diff: {diff}, using first one") + name = list(diff.keys())[0] + if name in self.client.device_manager.devices: + status = ValidationStatus.CONNECTED + else: + status = ValidationStatus.UNKNOWN + if self.get_device_status(diff) is None: + self.add_device(diff, status) + else: + self.update_device_status(diff, status) + + def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]: + """ + Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary. + + Args: + new_config: The first dictionary to compare. + old_config: The second dictionary to compare. + """ + diffs = [] + keys = set(new_config.keys()) | set(old_config.keys()) + for k in keys: + if k not in old_config: # New device + diffs.append({k: new_config[k]}) + continue + if k not in new_config: # Removed device + diffs.append({k: old_config[k]}) + continue + # Compare device config if exists in both + v1, v2 = old_config[k], new_config[k] + if isinstance(v1, dict) and isinstance(v2, dict): + if self._find_diffs(v2, v1): # recurse: something inside changed + diffs.append({k: new_config[k]}) + elif v1 != v2: + diffs.append({k: new_config[k]}) + return diffs + + def setup_device_validation_list(self): + """Setup the device validation list.""" + # Create the custom validation list widget + self.validation_list = QtWidgets.QListWidget() + self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self.validation_list) + # self._main_layout.addWidget(self.validation_list) + + def setup_text_box(self): + """Setup the text box for device validation messages.""" + self.validation_text_box = QtWidgets.QTextEdit() + self.validation_text_box.setReadOnly(True) + self.splitter.addWidget(self.validation_text_box) + # self._main_layout.addWidget(self.validation_text_box) + + @SafeSlot(str, str) + def on_device_validated(self, device_name: str, message: str): + """Handle device validation results.""" + text = f"Device {device_name} was validated. Message: {message}" + self.validation_text_box.setText(text) + + def _set_disabled(self) -> None: + """Disable the full view""" + self.setDisabled(True) + + def add_device( + self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN + ): + """Add a device to the validation list.""" + # Create the custom widget + item_widget = DeviceValidationListItem( + device_config=device_config, + status=status, + status_icons=self._status_icons, + validate_icon=self._validate_icon, + static_device_test=self._static_device_test, + ) + + # Create a list widget item + list_item = QtWidgets.QListWidgetItem() + list_item.setSizeHint(item_widget.sizeHint()) + + # Add item to list and set custom widget + self.validation_list.addItem(list_item) + self.validation_list.setItemWidget(list_item, item_widget) + item_widget.device_validated.connect(self.on_device_validated) + + def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus): + """Update the validation status for a specific device.""" + for i in range(self.validation_list.count()): + item = self.validation_list.item(i) + widget = self.validation_list.itemWidget(item) + if ( + isinstance(widget, DeviceValidationListItem) + and widget.device_config == device_config + ): + widget.set_status(status) + break + + def clear_devices(self): + """Clear all devices from the list.""" + self.validation_list.clear() + + def get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None: + """Get the validation status for a specific device.""" + for i in range(self.validation_list.count()): + item = self.validation_list.item(i) + widget = self.validation_list.itemWidget(item) + if ( + isinstance(widget, DeviceValidationListItem) + and widget.device_config == device_config + ): + return widget.get_status() + return None + + +if __name__ == "__main__": + import sys + + # pylint: disable=ungrouped-imports + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager_ophyd_test = DeviceManagerOphydTest() + cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config() + cfg.append({"name": "Wrong_Device", "type": "test"}) + device_manager_ophyd_test.on_device_config_update(cfg) + device_manager_ophyd_test.show() + device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") + device_manager_ophyd_test.resize(800, 600) + sys.exit(app.exec_())