From de5773662a7c16d34afa8cb604e791551206d4ac Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 23:50:28 +0100 Subject: [PATCH] feat(device-manager): Add DeviceManager Widget for BEC Widget main applications --- bec_widgets/applications/main_app.py | 6 +- .../device_manager_dialogs/__init__.py | 2 + .../config_choice_dialog.py | 49 + .../device_form_dialog.py | 341 +++ .../upload_redis_dialog.py | 720 ++++++ .../device_manager_display_widget.py | 665 +++++ .../device_manager_view.py | 711 +----- .../device_manager_widget.py | 47 +- bec_widgets/utils/bec_list.py | 93 + bec_widgets/utils/error_popups.py | 4 +- .../control/device_manager/__init__.py | 1 + .../device_manager/components/__init__.py | 7 +- .../device_manager/components/constants.py | 161 +- .../device_config_template/__init__.py | 0 .../device_config_template.py | 519 ++++ .../device_config_template/template_items.py | 481 ++++ .../components/device_table/__init__.py | 0 .../components/device_table/device_table.py | 1002 ++++++++ .../device_table/device_table_row.py | 56 + .../components/device_table_view.py | 1129 --------- .../components/dm_config_view.py | 45 +- .../components/dm_docstring_view.py | 9 +- .../components/dm_ophyd_test.py | 418 --- .../components/ophyd_validation/__init__.py | 8 + .../ophyd_validation/ophyd_validation.py | 825 ++++++ .../ophyd_validation_utils.py | 171 ++ .../ophyd_validation/validation_list_item.py | 391 +++ .../control/device_manager/device_manager.py | 4 - tests/unit_tests/conftest.py | 4 + .../test_device_manager_components.py | 2254 +++++++++++------ tests/unit_tests/test_device_manager_view.py | 721 ++++-- tests/unit_tests/test_utils_bec_list.py | 128 + 32 files changed, 7722 insertions(+), 3250 deletions(-) create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py create mode 100644 bec_widgets/utils/bec_list.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/device_table.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py delete mode 100644 bec_widgets/widgets/control/device_manager/components/device_table_view.py delete mode 100644 bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py delete mode 100644 bec_widgets/widgets/control/device_manager/device_manager.py create mode 100644 tests/unit_tests/test_utils_bec_list.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 2eccb700..6f4ba354 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -4,9 +4,7 @@ from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem from bec_widgets.applications.views.developer_view.developer_view import DeveloperView -from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( - DeviceManagerWidget, -) +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea @@ -51,7 +49,7 @@ class BECMainApp(BECMainWindow): self, profile_namespace="main_workspace", auto_profile_namespace=False ) self.ads.setObjectName("MainWorkspace") - self.device_manager = DeviceManagerWidget(self) + self.device_manager = DeviceManagerView(self) self.developer_view = DeveloperView(self) self.add_view( diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py new file mode 100644 index 00000000..b507c9d9 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py @@ -0,0 +1,2 @@ +from .config_choice_dialog import ConfigChoiceDialog +from .device_form_dialog import DeviceFormDialog diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py new file mode 100644 index 00000000..db91597f --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py @@ -0,0 +1,49 @@ +"""Dialog to choose config loading method: replace, add or cancel.""" + +from enum import IntEnum + +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout + + +class ConfigChoiceDialog(QDialog): + class Result(IntEnum): + CANCEL = QDialog.Rejected + ADD = 2 + REPLACE = 3 + + def __init__( + self, + parent=None, + custom_label: str = "Do you want to replace the current config or add to it?", + ): + super().__init__(parent) + self.setWindowTitle("Load Config") + + layout = QVBoxLayout(self) + + label = QLabel(custom_label) + label.setWordWrap(True) + layout.addWidget(label) + + # Use QDialogButtonBox for native layout + self.button_box = QDialogButtonBox(self) + self.cancel_btn = self.button_box.addButton( + "Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept... + ) + self.replace_btn = self.button_box.addButton( + "Replace", QDialogButtonBox.ButtonRole.AcceptRole + ) + self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole) + + layout.addWidget(self.button_box) + + for btn in [self.replace_btn, self.add_btn, self.cancel_btn]: + btn.setMinimumWidth(80) + btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + # Connections using native done(int) + self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE)) + self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD)) + self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL)) + + self.replace_btn.setFocus() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py new file mode 100644 index 00000000..ca31df96 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -0,0 +1,341 @@ +"""Dialogs for device configuration forms and ophyd testing.""" + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components import OphydValidation +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, +) +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + validate_name, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + format_error_to_md, +) + +DEFAULT_DEVICE = "CustomDevice" + + +logger = bec_logger.logger + + +class DeviceManagerOphydValidationDialog(QtWidgets.QDialog): + """Popup dialog to test Ophyd device configurations interactively.""" + + def __init__(self, parent=None, config: dict | None = None): # type:ignore + super().__init__(parent) + self.setWindowTitle("Device Manager Ophyd Test") + self._config_status = ConfigStatus.UNKNOWN.value + self._connection_status = ConnectionStatus.UNKNOWN.value + self._validated_config: dict = {} + self._validation_msg: str = "" + + layout = QtWidgets.QVBoxLayout(self) + + # Core test widget + self.device_manager_ophyd_test = OphydValidation() + layout.addWidget(self.device_manager_ophyd_test) + + # Log/Markdown box for messages + self.text_box = QtWidgets.QTextEdit() + self.text_box.setReadOnly(True) + layout.addWidget(self.text_box) + + # Connect signal for validation messages + + # Load and apply configuration + config = config or {} + self.device_manager_ophyd_test.change_device_configs([config], True, True) + + # Dialog Buttons: equal size, stacked horizontally + button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) + for button in button_box.buttons(): + button.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed + ) + button.clicked.connect(self.accept) + # button_box.setCenterButtons(False) + layout.addWidget(button_box) + self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated) + self._resize_dialog() + self.finished.connect(self._finished) + + def _resize_dialog(self): + """Resize the dialog based on the screen size.""" + app: QtCore.QCoreApplication = QtWidgets.QApplication.instance() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 4:3 ratio + height = int(screen_height * 0.7) + width = int(height * (4 / 3)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (4 / 3)) + + self.resize(width, height) + + def _on_device_validated( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ): + device_name = device_config.get("name", "") + self._config_status = config_status + self._connection_status = connection_status + self._validated_config = device_config + self._validation_msg = validation_msg + self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg)) + + @SafeSlot(int) + def _finished(self, state: int): + self.device_manager_ophyd_test.close() + self.device_manager_ophyd_test.deleteLater() + + @property + def validation_result(self) -> tuple[dict, int, int, str]: + """ + Return the result of the validation as a tuple of + + Returns: + result (Tuple[dict, int, int]): A tuple containing: + validated_config (dict): The validated device configuration. + config_status (int): The configuration status. + connection_status (int): The connection status. + + """ + return ( + self._validated_config, + self._config_status, + self._connection_status, + self._validation_msg, + ) + + +class DeviceFormDialog(QtWidgets.QDialog): + + # Signal emitted when device configuration is accepted, only + # emitted when the user clicks the "Add Device" button + # The integer values indicate if the device config was + # validated: config_status, connection_status + accepted_data = QtCore.Signal(dict, int, int, str, str) + + def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore + super().__init__(parent) + # Track old device name if config is edited + self._old_device_name: str = "" + + # Config validation result + self._validation_result: tuple[dict, int, int, str] = ( + {}, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "", + ) + # Group to variants mapping + self._group_variants: dict[str, list[str]] = { + group: [variant for variant in variants.keys()] + for group, variants in OPHYD_DEVICE_TEMPLATES.items() + } + + self._control_widgets: dict[str, QtWidgets.QWidget] = {} + + # Setup layout + self.setWindowTitle("Device Config Dialog") + layout = QtWidgets.QVBoxLayout(self) + + # Control panel + self._control_box = self.create_control_panel() + layout.addWidget(self._control_box) + + # Device config template display + self._device_config_template = DeviceConfigTemplate(parent=self) + self._frame = QtWidgets.QFrame() + self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self._frame.setFrameShadow(QtWidgets.QFrame.Raised) + frame_layout = QtWidgets.QVBoxLayout(self._frame) + frame_layout.addWidget(self._device_config_template) + layout.addWidget(self._frame) + + # Custom buttons + self.add_btn = QtWidgets.QPushButton(add_btn_text) + self.test_connection_btn = QtWidgets.QPushButton("Test Connection") + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.reset_btn = QtWidgets.QPushButton("Reset Form") + + btn_layout = QtWidgets.QHBoxLayout() + for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn): + btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + btn_layout.addWidget(btn) + btn_box = QtWidgets.QGroupBox("Actions") + btn_box.setLayout(btn_layout) + frame_layout.addWidget(btn_box) + + # Connect signals to explicit slots + self.add_btn.clicked.connect(self._add_config) + self.test_connection_btn.clicked.connect(self._test_connection) + self.reset_btn.clicked.connect(self._reset_config) + self.cancel_btn.clicked.connect(self._reject_config) + + # layout.addWidget(self._device_config_template) + self.update_variant_combo(self._control_widgets["group_combo"].currentText()) + self.finished.connect(self._finished) + + @SafeSlot(int) + def _finished(self, state: int): + for widget in self._control_widgets.values(): + widget.close() + widget.deleteLater() + + @property + def config_validation_result(self) -> tuple[dict, int, int, str]: + """Return the result of the last configuration validation.""" + return self._validation_result + + @config_validation_result.setter + def config_validation_result(self, result: tuple[dict, int, int, str]): + self._validation_result = result + + def set_device_config(self, device_config: dict): + """Set the device configuration in the template form.""" + # Figure out which group and variant this config belongs to + device_class = device_config.get("deviceClass", None) + for group, variants in OPHYD_DEVICE_TEMPLATES.items(): + for variant, template_info in variants.items(): + if template_info.get("deviceClass", None) == device_class: + # Found the matching group and variant + self._control_widgets["group_combo"].setCurrentText(group) + self.update_variant_combo(group) + self._control_widgets["variant_combo"].setCurrentText(variant) + self._device_config_template.set_config_fields(device_config) + return + # If no match found, set to default + self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE) + self.update_variant_combo(DEFAULT_DEVICE) + self._device_config_template.set_config_fields(device_config) + self._old_device_name = device_config.get("name", "") + + def sizeHint(self) -> QtCore.QSize: + return QtCore.QSize(1600, 1000) + + def create_control_panel(self) -> QtWidgets.QGroupBox: + self._control_box = QtWidgets.QGroupBox("Choose a Device Group") + layout = QtWidgets.QGridLayout(self._control_box) + + group_label = QtWidgets.QLabel("Device Group:") + layout.addWidget(group_label, 0, 0) + + group_combo = QtWidgets.QComboBox() + group_combo.addItems(self._group_variants.keys()) + self._control_widgets["group_combo"] = group_combo + layout.addWidget(group_combo, 1, 0) + + variant_label = QtWidgets.QLabel("Variants:") + layout.addWidget(variant_label, 0, 1) + + variant_combo = QtWidgets.QComboBox() + self._control_widgets["variant_combo"] = variant_combo + layout.addWidget(variant_combo, 1, 1) + + group_combo.currentTextChanged.connect(self.update_variant_combo) + variant_combo.currentTextChanged.connect(self.update_device_config_template) + + return self._control_box + + def update_variant_combo(self, group_name: str): + variant_combo = self._control_widgets["variant_combo"] + variant_combo.clear() + variant_combo.addItems(self._group_variants.get(group_name, [])) + if variant_combo.count() <= 1: + variant_combo.setEnabled(False) + else: + variant_combo.setEnabled(True) + + def update_device_config_template(self, variant_name: str): + group_name = self._control_widgets["group_combo"].currentText() + template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {}) + if template_info: + self._device_config_template.change_template(template_info) + else: + self._device_config_template.change_template( + OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE] + ) + + def _add_config(self): + config = self._device_config_template.get_config_fields() + config_status = ConfigStatus.UNKNOWN.value + connection_status = ConnectionStatus.UNKNOWN.value + validation_msg = "" + try: + if DeviceModel.model_validate(config) == DeviceModel.model_validate( + self._validation_result[0] + ): + config_status = self._validation_result[1] + connection_status = self._validation_result[2] + validation_msg = self._validation_result[3] + except Exception: + logger.debug( + f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses." + ) + + if not validate_name(config.get("name", "")): + msg_box = self._create_warning_message_box( + "Invalid Device Name", + f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ", + ) + msg_box.exec() + return + if config_status == ConfigStatus.INVALID.value: + msg_box = self._create_warning_message_box( + "Invalid Device Configuration", + f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}", + ) + msg_box.exec() + return + + self.accepted_data.emit( + config, config_status, connection_status, validation_msg, self._old_device_name + ) + self.accept() + + def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox: + msg_box = QtWidgets.QMessageBox(self) + msg_box.setIcon(QtWidgets.QMessageBox.Warning) + msg_box.setWindowTitle(title) + msg_box.setText(text) + return msg_box + + def _test_connection(self): + config = self._device_config_template.get_config_fields() + dialog = DeviceManagerOphydValidationDialog(self, config=config) + result = dialog.exec() + if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected): + self.config_validation_result = dialog.validation_result + # self._device_config_template.set_config_fields(self.config_validation_result[0]) + + def _reset_config(self): + self._device_config_template.reset_to_defaults() + + def _reject_config(self): + self.reject() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("light") + + dialog = DeviceFormDialog() + dialog.resize(1200, 800) + dialog.show() + sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py new file mode 100644 index 00000000..cb6f52b3 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py @@ -0,0 +1,720 @@ +"""Module for the upload redis dialog in the device manager view.""" + +from __future__ import annotations + +from enum import IntEnum +from functools import partial +from typing import TYPE_CHECKING, Dict, List, Tuple + +from bec_lib.logger import bec_logger +from bec_qthemes import apply_theme, material_icon +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components import OphydValidation +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) +from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar + +if TYPE_CHECKING: + from bec_widgets.utils.colors import AccentColor + +logger = bec_logger.logger + + +class DeviceStatusItem(QtWidgets.QWidget): + """Individual device status item widget for the validation display.""" + + def __init__( + self, device_config: dict, config_status: int, connection_status: int, parent=None + ): + super().__init__(parent) + self.device_name = device_config.get("name", "") + self.device_config: dict = device_config + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._transparent_button_style = "background-color: transparent; border: none;" + + # Get validation icons + self.colors = get_accent_colors() + self._icon_size = (20, 20) + self.icons = get_validation_icons(self.colors, self._icon_size) + + self._setup_ui() + self._update_display() + + def _setup_ui(self): + """Setup the UI for the device status item.""" + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + + # Device name label + self.name_label = QtWidgets.QLabel(self.device_name) + self.name_label.setMinimumWidth(150) + layout.addWidget(self.name_label) + layout.addStretch() + + # Config status icon + self.config_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.config_icon_label) + + # Connection status icon + self.connection_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.connection_icon_label) + + def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton: + button = QtWidgets.QPushButton() + button.setFlat(True) + button.setEnabled(False) + button.setStyleSheet(self._transparent_button_style) + button.setFixedSize(icon_size[0], icon_size[1]) + return button + + def _update_display(self): + """Update the visual display based on current status.""" + # Update config status + config_icon = self.icons["config_status"].get(self.config_status.value) + if config_icon: + self.config_icon_label.setIcon(config_icon) + + # Update connection status + connection_icon = self.icons["connection_status"].get(self.connection_status.value) + if connection_icon: + self.connection_icon_label.setIcon(connection_icon) + + def update_status(self, config_status: int, connection_status: int): + """Update the status and refresh display.""" + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._update_display() + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status < other_data.config_status + else: + return self_data.connection_status < other_data.connection_status + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status > other_data.config_status + else: + return self_data.connection_status > other_data.connection_status + return super().__gt__(other) + + +class ValidationSection(QtWidgets.QGroupBox): + """Section widget for displaying validation results.""" + + def __init__(self, title: str, parent=None): + super().__init__(title, parent=parent) + self._setup_ui() + # self.device_items: Dict[str, DeviceStatusItem] = {} + + def _setup_ui(self): + """Setup the UI for the validation section.""" + layout = QtWidgets.QVBoxLayout(self) + + # Status summary label + summary_layout = QtWidgets.QHBoxLayout() + self.summary_icon = QtWidgets.QLabel() + self.summary_icon.setFixedSize(24, 24) + self.summary_label = QtWidgets.QLabel() + self.summary_label.setWordWrap(True) + summary_layout.addWidget(self.summary_icon) + summary_layout.addWidget(self.summary_label) + layout.addLayout(summary_layout) + + # Scroll area for device items + self.table = QtWidgets.QTableWidget() + self.table.setColumnCount(1) + self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.table.horizontalHeader().hide() + self.table.verticalHeader().hide() + self.table.setShowGrid(False) # r + self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder) + layout.addWidget(self.table) + QtCore.QTimer.singleShot(0, self.adjustSize) + + def add_device(self, device_config: dict, config_status: int, connection_status: int): + """ + Add a device to the validation section. + + Args: + device_config (dict): The device configuration dictionary. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.table.setSortingEnabled(False) + device_name = device_config.get("name", "") + row = self._find_row_by_name(device_name) + if row is not None: + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + widget.update_status(config_status, connection_status) + else: + row_position = self.table.rowCount() + self.table.insertRow(row_position) + sort_item = SortTableItem(device_name) + sort_item.setText("") + self.table.setItem(row_position, 0, sort_item) + device_item = DeviceStatusItem(device_config, config_status, connection_status) + sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item) + self.table.setCellWidget(row_position, 0, device_item) + self.table.resizeRowsToContents() + self.table.setSortingEnabled(True) + + def _find_row_by_name(self, device_name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + item: SortTableItem = self.table.item(row, 0) + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + if widget.device_name == device_name: + return row + return None + + def remove_device(self, device_name: str): + """Remove a device from the table by name.""" + self.table.setSortingEnabled(False) + row = self._find_row_by_name(device_name) + if row is not None: + self.table.removeRow(row) + self.table.setSortingEnabled(True) + + def clear_devices(self): + """Clear all device items.""" + self.table.setSortingEnabled(False) + while self.table.rowCount() > 0: + self.table.removeRow(0) + self.table.setSortingEnabled(True) + + def update_summary(self, text: str, icon: QtGui.QPixmap = None): + """Update the summary label.""" + self.summary_label.setText(text) + if icon: + self.summary_icon.setPixmap(icon) + + +class UploadRedisDialog(QtWidgets.QDialog): + """ + Dialog for uploading device configurations to BEC server with validation checks. + """ + + class UploadAction(IntEnum): + """Enum for upload actions.""" + + CANCEL = QtWidgets.QDialog.Rejected + OK = QtWidgets.QDialog.Accepted + + # Signal to trigger upload after confirmation + upload_confirmed = QtCore.Signal(int) + + def __init__( + self, + parent, + ophyd_test_widget: OphydValidation, + device_configs: dict[str, Tuple[dict, int, int]] | None = None, + ): + super().__init__(parent=parent) + + self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {} + self.ophyd_test_widget = ophyd_test_widget + self._transparent_button_style = "background-color: transparent; border: none;" + + self.colors = get_accent_colors() + self.icons = get_validation_icons(self.colors, (20, 20)) + material_icon_partial = partial(material_icon, size=(24, 24), filled=True) + self._label_icons = { + "success": material_icon_partial("check_circle", color=self.colors.success), + "warning": material_icon_partial("warning", color=self.colors.warning), + "error": material_icon_partial("error", color=self.colors.emergency), + "reload": material_icon_partial("refresh", color=self.colors.default), + "upload": material_icon_partial("cloud_upload", color=self.colors.default), + } + + # Track validation states + self.has_invalid_configs: int = 0 + self.has_untested_connections: int = 0 + self.has_cannot_connect: int = 0 + self._current_progress: int | None = None + + self._setup_ui() + self._update_ui() + # Disable validation features if no ophyd test widget provided, else connect validation + self._validation_connection = self.ophyd_test_widget.validation_completed.connect( + self._update_from_ophyd_device_tests + ) + + def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]): + """ + Update the device configuration in the dialog. + + Args: + device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure + {device_name: (config_dict, config_status, connection_status)}. + """ + self.config_section.clear_devices() + self.device_configs = device_configs + self._update_ui() + + def accept(self): + self.cleanup() + return super().accept() + + def reject(self): + self.cleanup() + return super().reject() + + def cleanup(self): + """Cleanup on dialog finish.""" + self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection) + + def _setup_ui(self): + """Setup the main UI for the dialog.""" + self.setWindowTitle("Upload Configuration to BEC Server") + self.setModal(True) # Blocks interaction with other parts of the app + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(16) + + # Header + header_label = QtWidgets.QLabel("Review Configuration Before Upload") + header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;") + layout.addWidget(header_label) + + # Description + desc_label = QtWidgets.QLabel( + "Please review the configuration and connection status of all devices before uploading to BEC Server." + ) + desc_label.setWordWrap(True) + desc_label.setStyleSheet("color: #666; margin-bottom: 16px;") + layout.addWidget(desc_label) + + # Config validation section + sections_layout = QtWidgets.QHBoxLayout() + self.config_section = ValidationSection("Configuration Validation") + sections_layout.addWidget(self.config_section) + layout.addLayout(sections_layout) + + # Action buttons section + self._setup_action_buttons(layout) + + # Dialog buttons + self._setup_dialog_buttons(layout) + self.adjustSize() + + def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the action buttons section.""" + action_group = QtWidgets.QGroupBox("Actions") + action_layout = QtWidgets.QVBoxLayout(action_group) + + # Validate connections button + button_layout = QtWidgets.QHBoxLayout() + self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections") + self.validate_connections_btn.setIcon(self._label_icons["reload"]) + self.validate_connections_btn.clicked.connect(self._validate_connections) + button_layout.addWidget(self.validate_connections_btn) + button_layout.addStretch() + button_layout.addSpacing(16) + + # Progress bar + self._progress_bar = BECProgressBar(self) + self._progress_bar.setVisible(False) + button_layout.addWidget(self._progress_bar) + action_layout.addLayout(button_layout) + + # Status indicator + status_layout = QtWidgets.QHBoxLayout() + self.status_icon = QtWidgets.QPushButton() + self.status_icon.setFlat(True) + self.status_icon.setEnabled(False) + self.status_icon.setStyleSheet(self._transparent_button_style) + self.status_icon.setFixedSize(24, 24) + self.status_label = QtWidgets.QLabel() + self.status_label.setWordWrap(True) + status_layout.addWidget(self.status_icon) + status_layout.addWidget(self.status_label) + action_layout.addLayout(status_layout) + + parent_layout.addWidget(action_group) + + def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the dialog buttons.""" + button_layout = QtWidgets.QHBoxLayout() + + # Cancel button + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_btn) + + button_layout.addStretch() + + # Upload button + self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server") + self.upload_btn.setIcon(self._label_icons["upload"]) + self.upload_btn.clicked.connect(self._handle_upload) + button_layout.addWidget(self.upload_btn) + + parent_layout.addLayout(button_layout) + + def _populate_device_data(self): + """Populate the dialog with device configuration data.""" + if not self.device_configs: + return + + self.has_invalid_configs = 0 + self.has_untested_connections = 0 + self.has_cannot_connect = 0 + + for device_name, (config, config_status, connection_status) in self.device_configs.items(): + # Add to appropriate sections + self.config_section.add_device(config, config_status, connection_status) + + # Track statistics + if config_status == ConfigStatus.INVALID.value: + self.has_invalid_configs += 1 + if connection_status == ConnectionStatus.UNKNOWN.value: + self.has_untested_connections += 1 + if connection_status == ConnectionStatus.CANNOT_CONNECT.value: + self.has_cannot_connect += 1 + + # Update section summaries + num_devices = len(self.device_configs) + + # Config validation summary + if self.has_invalid_configs > 0: + icon = self._label_icons["error"] + text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid." + else: + icon = self._label_icons["success"] + text = f"All {num_devices} device configurations are valid." + if self.has_untested_connections > 0: + icon = self._label_icons["warning"] + text += f"{self.has_untested_connections} device connections are not tested." + if self.has_cannot_connect > 0: + icon = self._label_icons["warning"] + text += f"{self.has_cannot_connect} device connections cannot be established." + self.config_section.update_summary(text, icon) + + def _update_ui(self): + """Update UI state based on validation results.""" + # Update first the device data + self._populate_device_data() + + # Invalid configuration have highest priority, upload disabled + if self.has_invalid_configs: + self.status_icon.setIcon(self._label_icons["error"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_invalid_configs} device configurations are invalid.", + "Please fix configuration errors before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(False) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("Invalid Configurations") + + # Next priority: connections that cannot be established, error but upload is enabled + elif self.has_cannot_connect: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_cannot_connect} connections cannot be established.", + "Please fix connection issues before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # Next priority: untested connections, warning but upload is enabled + elif self.has_untested_connections: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_untested_connections} connections have not been tested.", + "Consider validating connections before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # All good, upload enabled + else: + self.status_icon.setIcon(self._label_icons["success"]) + self.status_label.setText( + "\n".join( + [ + "All device configurations are valid.", + "All connections have been successfully tested.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("All Connections Validated") + + @SafeSlot() + def _validate_connections(self): + """Request validation of all untested connections.""" + testable_devices: List[dict] = [] + for _, (config, _, connection_status) in self.device_configs.items(): + if connection_status == ConnectionStatus.UNKNOWN.value: + testable_devices.append(config) + elif connection_status == ConnectionStatus.CANNOT_CONNECT.value: + testable_devices.append(config) + + if len(testable_devices) > 0: + self.validate_connections_btn.setEnabled(False) + self._progress_bar.setVisible(True) + self._progress_bar.maximum = len(testable_devices) + self._progress_bar.minimum = 0 + self._progress_bar.set_value(0) + self._current_progress = 0 + self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True) + + @SafeSlot() + def _handle_upload(self): + """Handle the upload button click with appropriate confirmations.""" + # First priority: invalid configurations, block upload + if self.has_invalid_configs: + detailed_text = ( + f"There is {self.has_invalid_configs} device with an invalid configuration." + if self.has_invalid_configs == 1 + else f"There are {self.has_invalid_configs} devices with invalid configurations." + ) + text = " ".join( + [detailed_text, "Invalid configuration can not be uploaded to the BEC Server."] + ) + QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text) + self.done(self.UploadAction.CANCEL) + return + + # Next priority: connections that cannot be established, show warning, but allow to proceed + if self.has_cannot_connect: + detailed_text = ( + f"There is {self.has_cannot_connect} device that cannot connect" + if self.has_cannot_connect == 1 + else f"There are {self.has_cannot_connect} devices that cannot connect." + ) + text = " ".join( + [ + detailed_text, + "These devices may not be reachable and disabled BEC upon loading the config.", + "Consider validating these connections before.", + ] + ) + reply = QtWidgets.QMessageBox.critical( + self, + "Devices cannot Connect", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # If some connections are untested, warn the user + if self.has_untested_connections: + detailed_text = ( + f"There is {self.has_untested_connections} device with untested connections." + if self.has_untested_connections == 1 + else f"There are {self.has_untested_connections} devices with untested connections." + ) + text = " ".join( + [ + detailed_text, + "Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.", + ] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Untested Connections", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # Final confirmation + text = " ".join( + ["You are about to upload the device configurations to BEC Server.", "Please confirm."] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Upload to BEC Server", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.Yes, + ) + if reply == QtWidgets.QMessageBox.Yes: + self.done(self.UploadAction.OK) + else: + self.done(self.UploadAction.CANCEL) + + @SafeSlot(dict, int, int, str) + def _update_from_ophyd_device_tests( + self, + device_config: dict, + config_status: int, + connection_status: int, + validation_message: str = "", + ): + """ + Update device status from ophyd device tests. This has to be with a connection_status that was updated. + + """ + if connection_status == ConnectionStatus.UNKNOWN.value: + return + self.update_device_status(device_config, config_status, connection_status) + + @SafeSlot(dict, int, int) + def update_device_status(self, device_config: dict, config_status: int, connection_status: int): + """Update the status of a specific device.""" + # Update device config status + device_name = device_config.get("name", "") + old_config, _, _ = self.device_configs.get(device_name, (None, None, None)) + if old_config is not None: + self.device_configs[device_name] = (device_config, config_status, connection_status) + if self._current_progress is not None: + self._current_progress += 1 + self._progress_bar.set_value(self._current_progress) + if self._current_progress >= self._progress_bar.maximum: + self._progress_bar.setVisible(False) + self._progress_bar.set_value(0) + self._current_progress = None + self.validation_completed() + self._update_ui() + return + + # Update UI sections + self.config_section.add_device(device_config, config_status, connection_status) + + # Recalculate summaries and UI state + self._update_ui() + + def validation_completed(self): + """Called when connection validation is completed.""" + self.validate_connections_btn.setEnabled(True) + self._update_ui() + + +def main(): # pragma: no cover + """Test the upload redis dialog.""" + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # Sample device configurations for testing + sample_configs = [ + ( + {"name": "motor_x", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_1", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_2", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_z", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_x1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_11", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "detector_21", "deviceClass": "EpicsSignal"}, + ConfigStatus.INVALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "motor_z1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ] + configs = {cfg[0]["name"]: cfg for cfg in sample_configs} + apply_theme("dark") + from unittest import mock + + ophyd_test_widget = mock.MagicMock(spec=OphydValidation) + dialog = UploadRedisDialog( + parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget + ) + dialog.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py new file mode 100644 index 00000000..d33abd29 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import List, Literal, get_args + +import yaml +from bec_lib import config_helper +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.file_utils import DeviceConfigWriter +from bec_lib.logger import bec_logger +from bec_lib.messages import ConfigAction +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_qthemes import apply_theme +from qtpy.QtCore import QMetaObject, QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget + +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + ConfigChoiceDialog, + DeviceFormDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + UploadRedisDialog, +) +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTable, + DMConfigView, + DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConnectionStatus, +) +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, +) + +logger = bec_logger.logger + +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + + +class DeviceManagerDisplayWidget(DockAreaWidget): + """Device Manager main display widget. This contains all sub-widgets and the toolbar.""" + + RPC = False + + request_ophyd_validation = Signal(list, bool, bool) + + def __init__(self, parent=None, client=None, *args, **kwargs): + super().__init__(parent=parent, variant="compact", *args, **kwargs) + + # Push to Redis dialog + self._upload_redis_dialog: UploadRedisDialog | None = None + self._dialog_validation_connection: QMetaObject.Connection | None = None + + self._config_helper = config_helper.ConfigHelper(self.client.connector) + self._shared_selection = SharedSelectionSignal() + + # Device Table View widget + self.device_table_view = DeviceTable(self) + + # Device Config View widget + self.dm_config_view = DMConfigView(self) + + # Docstring View + self.dm_docs_view = DocstringView(self) + + # Ophyd Test view + self.ophyd_widget_view = QWidget(self) + layout = QVBoxLayout(self.ophyd_widget_view) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + self.ophyd_test_view = OphydValidation(self, hide_legend=False) + layout.addWidget(self.ophyd_test_view) + + # Validation Results view + self.validation_results = QTextEdit(self) + self.validation_results.setReadOnly(True) + self.validation_results.setPlaceholderText("Validation results will appear here...") + layout.addWidget(self.validation_results) + self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb) + + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.validation_completed, + (self.device_table_view.update_device_validation,), + ), + ( + self.ophyd_test_view.multiple_validations_completed, + (self.device_table_view.update_multiple_device_validations,), + ), + (self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.change_device_configs,), + ), + ( + self.device_table_view.device_config_in_sync_with_redis, + (self._update_config_enabled_button,), + ), + (self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)), + ]: + for slot in slots: + signal.connect(slot) + + # Add toolbar + self._add_toolbar() + + # Build dock layout using shared helpers + self._build_docks() + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _build_docks(self) -> None: + # Central device table + self.device_table_view_dock = self.new( + self.device_table_view, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Bottom area: docstrings + self.dm_docs_view_dock = self.new( + self.dm_docs_view, + where="bottom", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + # Config view left of docstrings + self.dm_config_view_dock = self.new( + self.dm_config_view, + where="left", + relative_to=self.dm_docs_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Right area: ophyd test + validation + self.ophyd_test_dock_view = self.new( + self.ophyd_widget_view, + where="right", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]}) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Load from disk + load = MaterialIconAction( + text_position="under", + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + save_to_disk = MaterialIconAction( + text_position="under", + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", + ) + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") + + # Add flush config in redis + flush_redis = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Flush current config in BEC Server", + label_text="Flush loaded Config", + ) + flush_redis.action.triggered.connect(self._flush_redis_action) + self.toolbar.components.add_safe("flush_redis", flush_redis) + io_bundle.add_action("flush_redis") + + # Add load config from redis + load_redis = MaterialIconAction( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from BEC Server", + label_text="Get loaded Config", + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + text_position="under", + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in BEC Server", + label_text="Update Config", + ) + update_config_redis.action.setEnabled(False) + + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + # Table actions + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config View", + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction( + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", + ) + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", + ) + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._run_validate_connection) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + @SafeSlot() + @SafeSlot(bool) + def _run_validate_connection(self, connect: bool = True): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + configs = list(self.device_table_view.get_selected_device_configs()) + if not configs: + configs = self.device_table_view.get_device_config() + self.request_ophyd_validation.emit(configs, True, connect) + + def _update_config_enabled_button(self, enabled: bool): + action = self.toolbar.components.get_action("update_config_redis") + action.action.setEnabled(not enabled) + if enabled: + action.action.setToolTip("Push current config to BEC Server") + else: + action.action.setToolTip("Current config is in sync with BEC Server, button disabled.") + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + config_path = self._get_config_base_path() + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_config_base_path(self) -> str: + """Get the base path for device configurations.""" + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + return config_path + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + ALLOWED_EXTS = [".yaml", ".yml"] + filter_str = "YAML files (*.yaml *.yml);;All Files (*)" + initial_filter = "YAML files (*.yaml *.yml);;" + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, + caption="Select Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, + caption="Save Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + if not file_path: + return "" + _, ext = os.path.splitext(file_path) + if ext.lower() not in ALLOWED_EXTS: + file_path += ".yaml" + return file_path + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ + if len(self.device_table_view.get_device_config()) == 0: + # If no config is composed yet, load directly + self.device_table_view.set_device_config(config) + return + dialog = ConfigChoiceDialog(self) + result = dialog.exec() + if result == ConfigChoiceDialog.Result.REPLACE: + self.device_table_view.set_device_config(config) + elif result == ConfigChoiceDialog.Result.ADD: + self.device_table_view.add_device_configs(config) + + @SafeSlot() + def _flush_redis_action(self): + """Action to flush the current config in Redis.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if len(self.client.device_manager.devices) == 0: + logger.info("No devices in BEC Server, nothing to flush.") + QMessageBox.information( + self, "No Devices", "There is currently no config loaded on the BEC Server." + ) + return + reply = _yes_no_question( + self, + "Flush BEC Server Config", + "Do you really want to flush the current config in BEC Server?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.set_busy(enabled=True, text="Flushing configuration in BEC Server...") + self.client.config.reset_config() + logger.info("Successfully flushed configuration in BEC Server.") + self.set_busy(enabled=False) + # Check if config is in sync, enable load redis button + self.device_table_view.device_config_in_sync_with_redis.emit( + self.device_table_view._is_config_in_sync_with_redis() + ) + validation_results = self.device_table_view.get_validation_results() + for config, config_status, connnection_status in validation_results.values(): + if connnection_status == ConnectionStatus.CONNECTED.value: + self.device_table_view.update_device_validation( + config, config_status, ConnectionStatus.CAN_CONNECT, "" + ) + + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if not self.device_table_view.get_device_config(): + # If no config is composed yet, load directly + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + return + reply = _yes_no_question( + self, + "Load currently active config in BEC Server", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """Action to push the current composition to Redis using the upload dialog.""" + # Check if validations are still running + if self.ophyd_test_view.running_ophyd_tests is True: + return QMessageBox.warning( + self, "Validation in Progress", "Please wait for the validation to finish." + ) + + # Get all device configurations with their validation status + validation_results = self.device_table_view.get_validation_results() + # Create and show upload dialog + self._upload_redis_dialog = UploadRedisDialog( + parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view + ) + + # Show dialog + reply = self._upload_redis_dialog.exec_() + + if reply == UploadRedisDialog.UploadAction.OK: + self._push_composition_to_redis(action="set") + elif reply == UploadRedisDialog.UploadAction.CANCEL: + self.ophyd_test_view.cancel_all_validations() + + def _push_composition_to_redis(self, action: ConfigAction): + """Push the current device composition to Redis.""" + if action not in get_args(ConfigAction): + logger.error(f"Invalid config action: {action} for uploading to BEC Server.") + return + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, action) + comm.signals.done.connect(self._handle_push_complete_to_communicator) + comm.signals.error.connect(self._handle_exception_from_communicator) + threadpool.start(comm) + self.set_busy(enabled=True, text="Uploading configuration to BEC Server...") + + def _handle_push_complete_to_communicator(self): + """Handle completion of the config push to Redis.""" + self.set_busy(enabled=False) + self._update_validation_icons_after_upload() + + def _handle_exception_from_communicator(self, exception: Exception): + """Handle exceptions from the config communicator.""" + QMessageBox.critical( + self, + "Error Uploading Config", + f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", + ) + self.set_busy(enabled=False) + self._update_validation_icons_after_upload() + + def _update_validation_icons_after_upload(self): + """Update validation icons after uploading config to Redis.""" + if self.client.device_manager is None: + return + device_names_in_session = list(self.client.device_manager.devices.keys()) + validation_results = self.device_table_view.get_validation_results() + devices_to_update = [] + for config, config_status, connection_status in validation_results.values(): + if config["name"] in device_names_in_session: + devices_to_update.append( + (config, config_status, ConnectionStatus.CONNECTED.value, "") + ) + self.device_table_view.update_multiple_device_validations(devices_to_update) + + @SafeSlot() + def _save_to_disk_action(self): + """Action for the 'save_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path = self._get_file_path(config_path, "save_file") + if file_path: + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + if os.path.exists(file_path): + reply = _yes_no_question( + self, + "Overwrite File", + f"The file '{file_path}' already exists. Do you want to overwrite it?", + ) + if reply != QMessageBox.StandardButton.Yes: + return + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # Table actions + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + @SafeSlot(dict) + def _edit_device_action(self, device_config: dict): + """Action to edit a selected device configuration.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes") + dialog.accepted_data.connect(self._update_device_to_table_from_dialog) + dialog.set_device_config(device_config) + dialog.open() + + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device") + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict, int, int, str, str) + def _update_device_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + if old_device_name and old_device_name != data.get("name", ""): + self.device_table_view.remove_device(old_device_name) + self.device_table_view.update_device_configs([data]) + + @SafeSlot(dict, int, int, str, str) + def _add_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + self.device_table_view.add_device_configs([data]) + + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + configs = self.device_table_view.get_selected_device_configs() + if not configs: + QMessageBox.warning( + self, "No devices selected", "Please select devices from the table to remove." + ) + return + if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.device_table_view.remove_device_configs(configs) + + @SafeSlot(dict, int, int, str, str) + def _ophyd_test_item_clicked_cb( + self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str + ) -> None: + self.validation_results.setMarkdown(md_msg) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerDisplayWidget() + l.addWidget(device_manager_view) + w.show() + w.setWindowTitle("Device Manager View") + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + w.resize(width, height) + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 3029adae..24e80688 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -1,686 +1,73 @@ -from __future__ import annotations +"""Module for Device Manager View.""" -import os -from functools import partial -from typing import List, Literal +from qtpy.QtWidgets import QWidget -import yaml -from bec_lib import config_helper -from bec_lib.bec_yaml_loader import yaml_load -from bec_lib.file_utils import DeviceConfigWriter -from bec_lib.logger import bec_logger -from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path -from bec_qthemes import apply_theme -from qtpy.QtCore import Qt, QThreadPool, QTimer -from qtpy.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QSizePolicy, - QSplitter, - QTextEdit, - QVBoxLayout, - QWidget, +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, ) - -import bec_widgets.widgets.containers.qt_ads as QtAds -from bec_widgets import BECWidget +from bec_widgets.applications.views.view import ViewBase from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.help_inspector.help_inspector import HelpInspector -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.control.device_manager.components import ( - DeviceTableView, - DMConfigView, - DMOphydTest, - DocstringView, -) -from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal -from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( - AvailableDeviceResources, -) -from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( - CommunicateConfigAction, -) -from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - PresetClassDeviceConfigDialog, -) - -logger = bec_logger.logger - -_yes_no_question = partial( - QMessageBox.question, - buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - defaultButton=QMessageBox.StandardButton.No, -) -def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: +class DeviceManagerView(ViewBase): """ - 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. + A view for users to manage devices within the application. """ - 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.Orientation.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 ConfigChoiceDialog(QDialog): - REPLACE = 1 - ADD = 2 - CANCEL = 0 - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Load Config") - layout = QVBoxLayout(self) - - label = QLabel("Do you want to replace the current config or add to it?") - label.setWordWrap(True) - layout.addWidget(label) - - # Buttons: equal size, stacked vertically - self.replace_btn = QPushButton("Replace") - self.add_btn = QPushButton("Add") - self.cancel_btn = QPushButton("Cancel") - btn_layout = QHBoxLayout() - for btn in (self.replace_btn, self.add_btn, self.cancel_btn): - btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - btn_layout.addWidget(btn) - layout.addLayout(btn_layout) - - # Connect signals to explicit slots - self.replace_btn.clicked.connect(self.accept_replace) - self.add_btn.clicked.connect(self.accept_add) - self.cancel_btn.clicked.connect(self.reject_cancel) - - self._result = self.CANCEL - - def accept_replace(self): - self._result = self.REPLACE - self.accept() - - def accept_add(self): - self._result = self.ADD - self.accept() - - def reject_cancel(self): - self._result = self.CANCEL - self.reject() - - def result(self): - return self._result - - -AVAILABLE_RESOURCE_IS_READY = False - - -class DeviceManagerView(BECWidget, QWidget): - - def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent=parent, client=None, *args, **kwargs) - - self._config_helper = config_helper.ConfigHelper(self.client.connector) - self._shared_selection = SharedSelectionSignal() - - # 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 = QtAds.CDockManager(self) - self.dock_manager.setStyleSheet("") - self._root_layout.addWidget(self.dock_manager) - - # Device Table View widget - self.device_table_view = DeviceTableView( - self, shared_selection_signal=self._shared_selection - ) - self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self) - self.device_table_view_dock.setWidget(self.device_table_view) - - # Device Config View widget - self.dm_config_view = DMConfigView(self) - self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self) - self.dm_config_view_dock.setWidget(self.dm_config_view) - - # Docstring View - self.dm_docs_view = DocstringView(self) - self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self) - self.dm_docs_view_dock.setWidget(self.dm_docs_view) - - # Ophyd Test view - self.ophyd_test_view = DMOphydTest(self) - self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) - self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) - - # Help Inspector - widget = QWidget(self) - layout = QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - self.help_inspector = HelpInspector(self) - layout.addWidget(self.help_inspector) - text_box = QTextEdit(self) - text_box.setReadOnly(False) - text_box.setPlaceholderText("Help text will appear here...") - layout.addWidget(text_box) - self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) - self.help_inspector_dock.setWidget(widget) - - # Register callback - self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) - - # Error Logs View - self.error_logs_view = QTextEdit(self) - self.error_logs_view.setReadOnly(True) - self.error_logs_view.setPlaceholderText("Error logs will appear here...") - self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self) - self.error_logs_dock.setWidget(self.error_logs_view) - self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown) - - # Arrange widgets within the QtAds dock manager - # Central widget area - self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) - # Right area - should be pushed into view if something is active - self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, - self.ophyd_test_dock_view, - self.central_dock_area, - ) - # create bottom area (2-arg -> area) - self.bottom_dock_area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock - ) - - # YAML view left of docstrings (docks relative to bottom area) - self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area - ) - - # Error/help area right of docstrings (dock relative to bottom area) - area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, - self.help_inspector_dock, - self.bottom_dock_area, - ) - self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) - - for dock in self.dock_manager.dockWidgets(): - dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False) - dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False) - dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False) - - # Apply stretch after the layout is done - self.set_default_view([2, 8, 2], [7, 3]) - - for signal, slots in [ - ( - self.device_table_view.selected_devices, - (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - ), - ( - self.ophyd_test_view.device_validated, - (self.device_table_view.update_device_validation,), - ), - ( - self.device_table_view.device_configs_changed, - (self.ophyd_test_view.change_device_configs,), - ), - ]: - for slot in slots: - signal.connect(slot) - - # Once available resource is ready, add it to the view again - if AVAILABLE_RESOURCE_IS_READY: - # Available Resources Widget - self.available_devices = AvailableDeviceResources( - self, shared_selection_signal=self._shared_selection - ) - self.available_devices_dock = QtAds.CDockWidget( - self.dock_manager, "Available Devices", self - ) - self.available_devices_dock.setWidget(self.available_devices) - # Connect slots for available reosource - for signal, slots in [ - ( - self.available_devices.selected_devices, - (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - ), - ( - self.device_table_view.device_configs_changed, - (self.available_devices.mark_devices_used,), - ), - ( - self.available_devices.add_selected_devices, - (self.device_table_view.add_device_configs,), - ), - ( - self.available_devices.del_selected_devices, - (self.device_table_view.remove_device_configs,), - ), - ]: - for slot in slots: - signal.connect(slot) - - # Add toolbar - self._add_toolbar() - - def _add_toolbar(self): - self.toolbar = ModularToolBar(self) - - # Add IO actions - self._add_io_actions() - self._add_table_actions() - self.toolbar.show_bundles(["IO", "Table"]) - self._root_layout.insertWidget(0, self.toolbar) - - def _add_io_actions(self): - # Create IO bundle - io_bundle = ToolbarBundle("IO", self.toolbar.components) - - # Load from disk - load = MaterialIconAction( - text_position="under", - icon_name="file_open", - parent=self, - tooltip="Load configuration file from disk", - label_text="Load Config", - ) - self.toolbar.components.add_safe("load", load) - load.action.triggered.connect(self._load_file_action) - io_bundle.add_action("load") - - # Add safe to disk - save_to_disk = MaterialIconAction( - text_position="under", - icon_name="file_save", - parent=self, - tooltip="Save config to disk", - label_text="Save Config", - ) - self.toolbar.components.add_safe("save_to_disk", save_to_disk) - save_to_disk.action.triggered.connect(self._save_to_disk_action) - io_bundle.add_action("save_to_disk") - - # Add load config from redis - load_redis = MaterialIconAction( - text_position="under", - icon_name="cached", - parent=self, - tooltip="Load current config from Redis", - label_text="Get Current Config", - ) - load_redis.action.triggered.connect(self._load_redis_action) - self.toolbar.components.add_safe("load_redis", load_redis) - io_bundle.add_action("load_redis") - - # Update config action - update_config_redis = MaterialIconAction( - text_position="under", - icon_name="cloud_upload", - parent=self, - tooltip="Update current config in Redis", - label_text="Update Config", - ) - update_config_redis.action.setEnabled(False) - update_config_redis.action.triggered.connect(self._update_redis_action) - self.toolbar.components.add_safe("update_config_redis", update_config_redis) - io_bundle.add_action("update_config_redis") - - # Add load config from plugin dir - self.toolbar.add_bundle(io_bundle) - - # Table actions - - def _add_table_actions(self) -> None: - table_bundle = ToolbarBundle("Table", self.toolbar.components) - - # Reset composed view - reset_composed = MaterialIconAction( - text_position="under", - icon_name="delete_sweep", - parent=self, - tooltip="Reset current composed config view", - label_text="Reset Config", - ) - reset_composed.action.triggered.connect(self._reset_composed_view) - self.toolbar.components.add_safe("reset_composed", reset_composed) - table_bundle.add_action("reset_composed") - - # Add device - add_device = MaterialIconAction( - text_position="under", - icon_name="add", - parent=self, - tooltip="Add new device", - label_text="Add Device", - ) - add_device.action.triggered.connect(self._add_device_action) - self.toolbar.components.add_safe("add_device", add_device) - table_bundle.add_action("add_device") - - # Remove device - remove_device = MaterialIconAction( - text_position="under", - icon_name="remove", - parent=self, - tooltip="Remove device", - label_text="Remove Device", - ) - remove_device.action.triggered.connect(self._remove_device_action) - self.toolbar.components.add_safe("remove_device", remove_device) - table_bundle.add_action("remove_device") - - # Rerun validation - rerun_validation = MaterialIconAction( - text_position="under", - icon_name="checklist", - parent=self, - tooltip="Run device validation with 'connect' on selected devices", - label_text="Validate Connection", - ) - rerun_validation.action.triggered.connect(self._rerun_validation_action) - self.toolbar.components.add_safe("rerun_validation", rerun_validation) - table_bundle.add_action("rerun_validation") - - # Add load config from plugin dir - self.toolbar.add_bundle(table_bundle) - - # IO actions - def _coming_soon(self): - return QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.device_manager_widget = DeviceManagerWidget(parent=self) + self.set_content(self.device_manager_widget) @SafeSlot() - def _load_file_action(self): - """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" - try: - plugin_path = plugin_repo_path() - plugin_name = plugin_package_name() - config_path = os.path.join(plugin_path, plugin_name, "device_configs") - except ValueError: - # Get the recovery config path as fallback - config_path = self._get_recovery_config_path() - logger.warning( - f"No plugin repository installed, fallback to recovery config path: {config_path}" - ) + def on_enter(self) -> None: + """Called after the view becomes current/visible. - # Implement the file loading logic here - start_dir = os.path.abspath(config_path) - file_path = self._get_file_path(start_dir, "open_file") - if file_path: - self._load_config_from_file(file_path) - - def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: - if mode == "open_file": - file_path, _ = QFileDialog.getOpenFileName( - self, caption="Select Config File", dir=start_dir - ) - else: - file_path, _ = QFileDialog.getSaveFileName( - self, caption="Save Config File", dir=start_dir - ) - return file_path - - def _load_config_from_file(self, file_path: str): + Default implementation does nothing. Override in subclasses. """ - Load device config from a given file path and update the device table view. - - Args: - file_path (str): Path to the configuration file. - """ - try: - config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] - except Exception as e: - logger.error(f"Failed to load config from file {file_path}. Error: {e}") - return - self._open_config_choice_dialog(config) - - def _open_config_choice_dialog(self, config: List[dict]): - """ - Open a dialog to choose whether to replace or add the loaded config. - - Args: - config (List[dict]): List of device configurations loaded from the file. - """ - dialog = ConfigChoiceDialog(self) - if dialog.exec(): - if dialog.result() == ConfigChoiceDialog.REPLACE: - self.device_table_view.set_device_config(config) - elif dialog.result() == ConfigChoiceDialog.ADD: - self.device_table_view.add_device_configs(config) - - # TODO would we ever like to add the current config to an existing composition - @SafeSlot() - def _load_redis_action(self): - """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" - reply = _yes_no_question( - self, - "Load currently active config", - "Do you really want to discard the current config and reload?", - ) - if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: - self.device_table_view.set_device_config( - self.client.device_manager._get_redis_device_config() - ) - else: - return - - @SafeSlot() - def _update_redis_action(self) -> None | QMessageBox.StandardButton: - """Action to push the current composition to Redis""" - reply = _yes_no_question( - self, - "Push composition to Redis", - "Do you really want to replace the active configuration in the BEC server with the current composition? ", - ) - if reply != QMessageBox.StandardButton.Yes: - return - if self.device_table_view.table.contains_invalid_devices(): - return QMessageBox.warning( - self, "Validation has errors!", "Please resolve before proceeding." - ) - if self.ophyd_test_view.validation_running(): - return QMessageBox.warning( - self, "Validation has not completed.", "Please wait for the validation to finish." - ) - self._push_composition_to_redis() - - def _push_composition_to_redis(self): - config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} - threadpool = QThreadPool.globalInstance() - comm = CommunicateConfigAction(self._config_helper, None, config, "set") - threadpool.start(comm) - - @SafeSlot() - def _save_to_disk_action(self): - """Action for the 'save_to_disk' action to save the current config to disk.""" - # Check if plugin repo is installed... - try: - config_path = self._get_recovery_config_path() - except ValueError: - # Get the recovery config path as fallback - config_path = os.path.abspath(os.path.expanduser("~")) - logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") - - # Implement the file loading logic here - file_path = self._get_file_path(config_path, "save_file") - if file_path: - config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} - with open(file_path, "w") as file: - file.write(yaml.dump(config)) - - # Table actions - @SafeSlot() - def _reset_composed_view(self): - """Action for the 'reset_composed_view' action to reset the composed view.""" - reply = _yes_no_question( - self, - "Clear View", - "You are about to clear the current composed config view, please confirm...", - ) - if reply == QMessageBox.StandardButton.Yes: - self.device_table_view.clear_device_configs() - - # TODO Bespoke Form to add a new device - @SafeSlot() - def _add_device_action(self): - """Action for the 'add_device' action to add a new device.""" - dialog = PresetClassDeviceConfigDialog(parent=self) - dialog.accepted_data.connect(self._add_to_table_from_dialog) - dialog.open() - - @SafeSlot(dict) - def _add_to_table_from_dialog(self, data): - self.device_table_view.add_device_configs([data]) - - @SafeSlot() - def _remove_device_action(self): - """Action for the 'remove_device' action to remove a device.""" - self.device_table_view.remove_selected_rows() - - @SafeSlot() - @SafeSlot(bool) - def _rerun_validation_action(self, connect: bool = True): - """Action for the 'rerun_validation' action to rerun validation on selected devices.""" - configs = self.device_table_view.table.selected_configs() - self.ophyd_test_view.change_device_configs(configs, True, connect) - - ####### Default view has to be done with setting up splitters ######## - def set_default_view( - self, horizontal_weights: list, vertical_weights: list - ): # TODO separate logic for all ads based widgets - """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.Orientation.Horizontal: - splitters_h.append(splitter) - elif splitter.orientation() == Qt.Orientation.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 - ): # TODO separate logic for all ads based widgets - """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) - - def _get_recovery_config_path(self) -> str: - """Get the recovery config path from the log_writer config.""" - # pylint: disable=protected-access - log_writer_config = self.client._service_config.config.get("log_writer", {}) - writer = DeviceConfigWriter(service_config=log_writer_config) - return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + self.device_manager_widget.on_enter() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys - from copy import deepcopy - from bec_lib.bec_yaml_loader import yaml_load + from bec_qthemes import apply_theme from qtpy.QtWidgets import QApplication - from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + from bec_widgets.applications.main_app import BECMainApp app = QApplication(sys.argv) - w = QWidget() - l = QVBoxLayout() - w.setLayout(l) apply_theme("dark") - button = DarkModeButton() - l.addWidget(button) - device_manager_view = DeviceManagerView() - l.addWidget(device_manager_view) - # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" - # cfg = yaml_load(config_path) - # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) - # # config = device_manager_view.client.device_manager._get_redis_device_config() - # device_manager_view.device_table_view.set_device_config(cfg) - w.show() - w.setWindowTitle("Device Manager View") - w.resize(1920, 1080) - # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + device_manager_view = DeviceManagerView() + _app.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=device_manager_view.device_manager_widget, + mini_text="DM", + ) + _app.show() sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py index 8c24a9b9..4129cd96 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -9,7 +9,9 @@ from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtWidgets -from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + DeviceManagerDisplayWidget, +) from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot @@ -18,8 +20,10 @@ logger = bec_logger.logger class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + RPC = False + def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent) + super().__init__(parent=parent, client=client) self.stacked_layout = QtWidgets.QStackedLayout() self.stacked_layout.setContentsMargins(0, 0, 0, 0) self.stacked_layout.setSpacing(0) @@ -27,14 +31,19 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): self.setLayout(self.stacked_layout) # Add device manager view - self.device_manager_view = DeviceManagerView() - self.stacked_layout.addWidget(self.device_manager_view) + self.device_manager_display = DeviceManagerDisplayWidget(parent=self, client=self.client) + self.stacked_layout.addWidget(self.device_manager_display) # 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) + self._initialized = False + + def on_enter(self) -> None: + """Called after the widget becomes visible.""" + if self._initialized is False: + self.stacked_layout.setCurrentWidget(self._overlay_widget) def _customize_overlay(self): self._overlay_widget.setAutoFillBackground(True) @@ -60,33 +69,17 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): def _load_config_from_file_clicked(self): """Handle click on 'Load Config From File' button.""" - start_dir = os.path.expanduser("~") - file_path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, caption="Select Config File", dir=start_dir - ) - if file_path: - self._load_config_from_file(file_path) - - def _load_config_from_file(self, file_path: str): - try: - config = yaml_load(file_path) - except Exception as e: - logger.error(f"Failed to load config from file {file_path}. Error: {e}") - return - config_list = [] - for name, cfg in config.items(): - config_list.append(cfg) - config_list[-1]["name"] = name - self.device_manager_view.device_table_view.set_device_config(config_list) - # self.device_manager_view.ophyd_test.on_device_config_update(config) - self.stacked_layout.setCurrentWidget(self.device_manager_view) + self.device_manager_display._load_file_action() + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) @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.stacked_layout.setCurrentWidget(self.device_manager_view) + self.device_manager_display.device_table_view.set_device_config(config) + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/bec_list.py b/bec_widgets/utils/bec_list.py new file mode 100644 index 00000000..f125d04e --- /dev/null +++ b/bec_widgets/utils/bec_list.py @@ -0,0 +1,93 @@ +from bec_lib.logger import bec_logger +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +logger = bec_logger.logger + + +class BECList(QListWidget): + """List Widget that manages ListWidgetItems with associated widgets.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {} + + def __contains__(self, key: str) -> bool: + return key in self._widget_map + + def add_widget_item(self, key: str, widget: QWidget): + """ + Add a widget to the list, mapping is associated with the given key. + + Args: + key (str): Key to associate with the widget. + widget (QWidget): Widget to add to the list. + """ + if key in self._widget_map: + self.remove_widget_item(key) + + item = QListWidgetItem() + item.setSizeHint(widget.sizeHint()) + self.insertItem(0, item) + self.setItemWidget(item, widget) + self._widget_map[key] = (item, widget) + + def remove_widget_item(self, key: str): + """ + Remove a widget by identifier key. + + Args: + key (str): Key associated with the widget to remove. + """ + if key not in self._widget_map: + return + + item, widget = self._widget_map.pop(key) + row = self.row(item) + self.takeItem(row) + try: + widget.close() + except Exception: + logger.debug(f"Could not close widget properly for key: {key}.") + try: + widget.deleteLater() + except Exception: + logger.debug(f"Could not delete widget properly for key: {key}.") + + def clear_widgets(self): + """Remove and destroy all widget items.""" + for key in list(self._widget_map.keys()): + self.remove_widget_item(key) + self._widget_map.clear() + self.clear() + + def get_widget(self, key: str) -> QWidget | None: + """Return the widget for a given key.""" + entry = self._widget_map.get(key) + return entry[1] if entry else None + + def get_item(self, key: str) -> QListWidgetItem | None: + """Return the QListWidgetItem for a given key.""" + entry = self._widget_map.get(key) + return entry[0] if entry else None + + def get_widgets(self) -> list[QWidget]: + """Return all managed widgets.""" + return [w for _, w in self._widget_map.values()] + + def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None: + """Return the widget associated with a given QListWidgetItem.""" + for itm, widget in self._widget_map.values(): + if itm == item: + return widget + return None + + def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None: + """Return the QListWidgetItem associated with a given widget.""" + for itm, w in self._widget_map.values(): + if w == widget: + return itm + return None + + def get_all_keys(self) -> list[str]: + """Return all keys for managed widgets.""" + return list(self._widget_map.keys()) diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 730fcdce..2e586407 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -10,6 +10,8 @@ from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, logger = bec_logger.logger +RAISE_ERROR_DEFAULT = False + def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs): """ @@ -159,7 +161,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name _slot_params = { "popup_error": bool(slot_kwargs.pop("popup_error", False)), "verify_sender": bool(slot_kwargs.pop("verify_sender", False)), - "raise_error": bool(slot_kwargs.pop("raise_error", False)), + "raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)), } def error_managed(method): diff --git a/bec_widgets/widgets/control/device_manager/__init__.py b/bec_widgets/widgets/control/device_manager/__init__.py index e69de29b..dca7392e 100644 --- a/bec_widgets/widgets/control/device_manager/__init__.py +++ b/bec_widgets/widgets/control/device_manager/__init__.py @@ -0,0 +1 @@ +from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index bec612ee..d3363977 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -1,4 +1,5 @@ -from .device_table_view import DeviceTableView +# from .device_table_view import DeviceTableView +from .device_table.device_table import DeviceTable from .dm_config_view import DMConfigView -from .dm_docstring_view import DocstringView -from .dm_ophyd_test import DMOphydTest +from .dm_docstring_view import DocstringView, docstring_to_markdown +from .ophyd_validation.ophyd_validation import OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py index b3f72051..8ac82f0b 100644 --- a/bec_widgets/widgets/control/device_manager/components/constants.py +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -9,64 +9,105 @@ CONFIG_DATA_ROLE: Final[int] = 118 # TODO 882 keep in sync with headers in device_table_view.py HEADERS_HELP_MD: dict[str, str] = { - "status": "\n".join( - [ - "## Status", - "The current status of the device. Can be one of the following values: ", - "### **LOADED** \n The device with the specified configuration is loaded in the current config.", - "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.", - "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.", - "### **VALID** \n The device config is valid, but the connection has not yet been validated.", - "### **INVALID** \n The device config is invalid and can not be loaded to the current config.", - ] - ), - "name": "\n".join(["## Name ", "The name of the device."]), - "deviceClass": "\n".join( - [ - "## Device Class", - "The device class specifies the type of the device. It will be used to create the instance.", - ] - ), - "readoutPriority": "\n".join( - [ - "## Readout Priority", - "The readout priority of the device. Can be one of the following values: ", - "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", - "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", - "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", - "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", - "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", - ] - ), - "deviceTags": "\n".join( - [ - "## Device Tags", - "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", - ] - ), - "enabled": "\n".join( - [ - "## Enabled", - "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", - ] - ), - "readOnly": "\n".join( - ["## Read Only", "Indicator that a device is read-only or can be modified."] - ), - "onFailure": "\n".join( - [ - "## On Failure", - "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", - "### **buffer** \n The device readback will fall back to the last known value.", - "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", - "### **raise** \n The device readback will raise immediately.", - ] - ), - "softwareTrigger": "\n".join( - [ - "## Software Trigger", - "Indicator whether the device receives a software trigger from BEC during a scan.", - ] - ), - "description": "\n".join(["## Description", "A short description of the device."]), + "valid": { + "long": "\n".join( + [ + "## Valid", + "The current configuration status of the device. Can be one of the following values: ", + "### **VALID** \n The device configuration is valid and can be used.", + "### **INVALID** \n The device configuration is invalid.", + "### **UNKNOWN** \n The device configuration has not been validated yet.", + ] + ), + "short": "Validation status of the device configuration.", + }, + "connect": { + "long": "\n".join( + [ + "## Connect", + "The current connection status of the device. Can be one of the following values: ", + "### **CONNECTED** \n The device is connected and in current session.", + "### **CAN_CONNECT** \n The connection to the device has been validated. It's not yet loaded in the current session.", + "### **CANNOT_CONNECT** \n The connection to the device could not be established.", + "### **UNKNOWN** \n The connection status of the device is unknown.", + ] + ), + "short": "Connection status of the device.", + }, + "name": { + "long": "\n".join(["## Name ", "The name of the device."]), + "short": "Name of the device.", + }, + "deviceClass": { + "long": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "short": "Python class for the device.", + }, + "readoutPriority": { + "long": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "short": "Readout priority of the device for scans in BEC.", + }, + "deviceTags": { + "long": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "short": "Tags associated with the device.", + }, + "enabled": { + "long": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "short": "Enabled status of the device.", + }, + "readOnly": { + "long": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "short": "Read-only status of the device.", + }, + "onFailure": { + "long": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "short": "On failure behavior of the device.", + }, + "softwareTrigger": { + "long": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "short": "Software trigger status of the device.", + }, + "description": { + "long": "\n".join(["## Description", "A short description of the device."]), + "short": "Description of the device.", + }, } diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py new file mode 100644 index 00000000..ad25fa05 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py @@ -0,0 +1,519 @@ +"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV""" + +from copy import deepcopy +from typing import Type + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from pydantic import BaseModel +from pydantic_core import PydanticUndefinedType +from qtpy import QtWidgets + +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +class DeviceConfigTemplate(QtWidgets.QWidget): + """ + Device Configuration Template Widget. + Current supported templates follow the structure in + ophyd_devices.interfaces.device_config_templates.ophyd_templates.OPHYD_DEVICE_TEMPLATES. + + Args: + parent (QtWidgets.QWidget, optional) : Parent widget. Defaults to None. + client (BECClient, optional) : BECClient instance. Defaults to None. + template (dict[str, any], optional) : Device configuration template. If None, + the "CustomDevice" template will be used. Defaults to None. + """ + + RPC = False + + def __init__(self, parent=None, template: dict[str, any] = None): + super().__init__(parent=parent) + if template is None: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + self.template = template + self._device_fields = deepcopy(DEVICE_FIELDS) + self._device_config_fields = deepcopy(DEVICE_CONFIG_FIELDS) + self._unknown_device_config_entry: dict[str, any] = {} + + # Dict to store references to input widgets + self._widgets: dict[str, QtWidgets.QWidget] = {} + + # Two column layout + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 0) + layout.setSpacing(2) + self.setLayout(layout) + + # Left hand side, settings, connection and advanced settings + self._left_layout = QtWidgets.QVBoxLayout() + self._left_layout.setContentsMargins(2, 2, 2, 2) + self._left_layout.setSpacing(4) + # Settings box, name | deviceClass | description + self.settings_box = self._create_settings_box() + # Device Config settings box | dynamic fields from deviceConfig + self.connection_settings_box = self._create_connection_settings_box() + # Advanced Control box | readoutPriority | onFailure | softwareTrigger | enabled | readOnly + self.advanced_control_box = self._create_advanced_control_box() + # Add boxes to left layout + self._left_layout.addWidget(self.settings_box) + self._left_layout.addWidget(self.connection_settings_box) + self._left_layout.addWidget(self.advanced_control_box) + layout.addLayout(self._left_layout) + + # Right hand side, advanced settings + self._right_layout = QtWidgets.QVBoxLayout() + self._right_layout.setContentsMargins(2, 2, 2, 2) + self._right_layout.setSpacing(4) + layout.addLayout(self._right_layout) + # Create Additional Settings box + self.additional_settings_box = self.create_additional_settings() + self._right_layout.addWidget(self.additional_settings_box) + + # Set default values + self.reset_to_defaults() + + def _clear_layout(self, layout: QtWidgets.QLayout) -> None: + """Clear a layout recursively. If the layout contains sub-layouts, they will also be cleared.""" + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().close() + item.widget().deleteLater() + if item.layout(): + self._clear_layout(item.layout()) + + def reset_to_defaults(self) -> None: + """Reset all fields to default values.""" + self._widgets.pop("deviceConfig", None) + self._clear_layout(self.connection_settings_box.layout()) + + # Recreate Connection Settings box + layout: QtWidgets.QGridLayout = self.connection_settings_box.layout() + self._fill_connection_settings_box(self.connection_settings_box, layout) + + # Reset Settings and Advanced Control boxes + for field_name, widget in self._widgets.items(): + if field_name in self.template: + self._set_value_for_widget(widget, self.template[field_name]) + else: + self._set_default_entry(field_name, widget) + + def change_template(self, template: dict[str, any]) -> None: + """ + Change the template and update the form fields accordingly. + + Args: + template (dict[str, any]): New device configuration template. + """ + self.template = template + self.reset_to_defaults() + + def get_config_fields(self) -> dict: + """Retrieve the current configuration from the input fields.""" + config: dict[str, any] = {} + for device_entry, widget in self._widgets.items(): + config[device_entry] = self._get_entry_for_widget(widget) + if self._unknown_device_config_entry: + if "deviceConfig" not in config: + config["deviceConfig"] = {} + config["deviceConfig"].update(self._unknown_device_config_entry) + return config + + def set_config_fields(self, config: dict) -> None: + """ + Set the configuration fields based on the provided config dictionary. + + Args: + config (dict): Configuration dictionary to set the fields. + """ + # Clear storage for unknown entries + self._unknown_device_config_entry.clear() + if self.template.get("deviceClass", "") != config.get("deviceClass", ""): + logger.warning( + f"Device class {config.get('deviceClass', '')} does not match template device class {self.template.get('deviceClass', '')}. Using custom device template." + ) + self.change_template(OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]) + else: + self.reset_to_defaults() + self._fill_fields_from_config(config) + + def _fill_fields_from_config(self, model: dict) -> None: + """ + Fill the form fields base on the provided configuration dictionary. + Please note, deviceConfig is handled separately through _fill_connection_settings_box + as this depends on the template used. + + Args: + model (dict): Configuration dictionary to fill the fields. + """ + for key, value in model.items(): + if key == "name": + wid = self._widgets["name"] + wid.setText(value or "") + elif key == "deviceClass": + wid = self._widgets["deviceClass"] + wid.setText(value or "") + if "deviceClass" in self.template: + wid.setEnabled(False) + else: + wid.setEnabled(True) + elif key == "deviceConfig" and isinstance( + self._widgets.get("deviceConfig", None), dict + ): + # If _widgets["deviceConfig"] is a dict, we have individual widgets for each field + for sub_key, sub_value in value.items(): + widget = self._widgets["deviceConfig"].get(sub_key, None) + if widget is None: + logger.warning( + f"Widget for key {sub_key} not found in deviceConfig widgets." + ) + # Store any unknown entry fields + self._unknown_device_config_entry[sub_key] = sub_value + continue + self._set_value_for_widget(widget, sub_value) + else: + widget = self._widgets.get(key, None) + if widget is not None: + self._set_value_for_widget(widget, value) + + def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None: + """ + Set the value for a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to set the value for. + value (any): The value to set. + """ + if isinstance(widget, (ParameterValueWidget)) and isinstance(value, dict): + for param, val in value.items(): + widget.add_parameter_line(param, val) + elif isinstance(widget, DeviceTagsWidget) and isinstance(value, (list, tuple, set)): + for tag in value: + widget.add_parameter_line(tag or "") + elif isinstance(widget, InputLineEdit): + widget.setText(str(value or "")) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(value)) + elif isinstance(widget, LimitInputWidget): + widget.set_limits(value) + elif isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(value) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, QtWidgets.QTextEdit): + widget.setPlainText(str(value or "")) + else: + logger.warning(f"Unsupported widget type for setting value: {type(widget)}") + + def _get_entry_for_widget(self, widget: QtWidgets.QWidget) -> any: + """ + Get the value from a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to get the value from. + Returns: + any: The value retrieved from the widget. + """ + if isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + return widget.parameters() + elif isinstance(widget, InputLineEdit): + return widget.text().strip() + elif isinstance(widget, ToggleSwitch): + return widget.isChecked() + elif isinstance(widget, LimitInputWidget): + return widget.get_limits() + elif isinstance(widget, QtWidgets.QComboBox): + return widget.currentText() + elif isinstance(widget, QtWidgets.QTextEdit): + return widget.toPlainText() + elif isinstance(widget, dict): + result = {} + for sub_entry, sub_widget in widget.items(): + result[sub_entry] = self._get_entry_for_widget(sub_widget) + return result + else: + logger.warning(f"Unsupported widget type for getting entry: {type(widget)}") + return None + + def _create_device_field( + self, field_name: str, field_info: DeviceConfigField | None = None + ) -> tuple[QtWidgets.QLabel, QtWidgets.QWidget]: + """ + Create a device field based on the field name. If field_info is not provided, + a default label and input widget will be created. + + Args: + field_name (str): Name of the field. + field_info (DeviceConfigField | None, optional): Information about the field. Defaults to None. + """ + if field_info is None: + label = QtWidgets.QLabel(field_name, parent=self) + input_widget = QtWidgets.QLineEdit(parent=self) + return label, input_widget + + label_text = field_info.label + label = QtWidgets.QLabel(label_text, parent=self) + if field_info.required: + label_text = label.text() + label_text += " *" + label.setText(label_text) + label.setStyleSheet("font-weight: bold;") + input_widget = field_info.widget_cls(parent=self) + if field_info.placeholder_text: + if hasattr(input_widget, "setPlaceholderText"): + input_widget.setPlaceholderText(field_info.placeholder_text) + if field_info.static: + input_widget.setEnabled(False) + if field_info.validation_callback: + # Attach validation callback if provided + if isinstance(input_widget, InputLineEdit): + input_widget: InputLineEdit + for callback in field_info.validation_callback: + input_widget.register_validation_callback(callback) + if field_info.default is not None: + # Set default value + if isinstance(input_widget, QtWidgets.QLineEdit): + input_widget.setText(str(field_info.default)) + elif isinstance(input_widget, QtWidgets.QTextEdit): + input_widget.setPlainText(str(field_info.default)) + elif isinstance(input_widget, ToggleSwitch): + input_widget.setChecked(bool(field_info.default)) + elif isinstance(input_widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + index = input_widget.findText(field_info.default) + if index != -1: + input_widget.setCurrentIndex(index) + return label, input_widget + + def _create_group_box_with_grid_layout( + self, title: str + ) -> tuple[QtWidgets.QGroupBox, QtWidgets.QGridLayout]: + """Create a group box with a grid layout.""" + box = QtWidgets.QGroupBox(title) + layout = QtWidgets.QGridLayout(box) + layout.setContentsMargins(4, 8, 4, 8) + layout.setSpacing(4) + box.setLayout(layout) + return box, layout + + def _set_default_entry(self, field_name: str, widget: QtWidgets.QWidget) -> None: + """ + Set the default value for a given field in the form based on the Pydantic model. + + Args: + field_name (str): Name of the field. + widget (QtWidgets.QWidget): The widget to set the default value for. + """ + if field_name == "enabled": + widget.setChecked(True) + return + if field_name == "readOnly": + widget.setChecked(False) + return + default = self._get_default_for_device_config_field(field_name) or "" + widget.setEnabled(True) + if isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(default) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit)): + widget.setText(str(default)) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(default)) + elif isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + widget.clear_widget() + + def _get_default_for_device_config_field(self, field_name: str) -> any: + """ + Get the default value for a given deviceConfig field based on the Pydantic model. + + Args: + field_name (str): Name of the deviceConfig field. + Returns: + any: The default value for the field, or None if not found. + """ + model_properties: dict = DeviceModel.model_json_schema()["properties"] + if field_name in model_properties: + field_info = model_properties[field_name] + default = field_info.get("default", None) + if default: + return default + return None + + ### Box creation methods ### + + def _create_box(self, box_title: str, field_names: list[str]) -> QtWidgets.QGroupBox: + """ + Create a box layout with specific fields. If field_names are in _device_fields, + their corresponding widgets will be used. + """ + # Create box + box, layout = self._create_group_box_with_grid_layout(box_title) + box.setLayout(layout) + + for ii, field_name in enumerate(field_names): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 0) + layout.addWidget(input_widget, ii, 1) + self._widgets[field_name] = input_widget + return box + + def _create_settings_box(self) -> QtWidgets.QGroupBox: + """Create the settings box widget.""" + box = self._create_box("Settings", ["name", "deviceClass", "description"]) + layout = box.layout() + # Set column stretch + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 1) + return box + + def _create_advanced_control_box(self) -> QtWidgets.QGroupBox: + """Create the advanced control box widget.""" + # Set up advanced control box + box = self._create_box("Advanced Control", ["readoutPriority", "onFailure"]) + layout = box.layout() + for ii, field_name in enumerate(["enabled", "readOnly", "softwareTrigger"]): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 2) + layout.addWidget(input_widget, ii, 3) + self._widgets[field_name] = input_widget + return box + + def _create_connection_settings_box(self) -> QtWidgets.QGroupBox: + """Create the connection settings box widget. These are all entries in the deviceConfig field.""" + box, layout = self._create_group_box_with_grid_layout("Connection Settings") + box = self._fill_connection_settings_box(box, layout) + return box + + def _fill_connection_settings_box( + self, box: QtWidgets.QGroupBox, layout: QtWidgets.QGridLayout + ) -> QtWidgets.QGroupBox: + """Fill the connection settings box based on the deviceConfig template.""" + if not self.template.get("deviceConfig", {}): + widget = ParameterValueWidget(parent=self) + widget.setToolTip( + "Add custom deviceConfig entries as key-value pairs in the tree view." + ) + layout.addWidget(widget, 0, 0) + self._widgets["deviceConfig"] = widget + return box + # If template specifies deviceConfig fields, create them + self._widgets["deviceConfig"] = {} + model: Type[BaseModel] = self.template["deviceConfig"] + for field_name, field in model.model_fields.items(): + field_info = self._device_config_fields.get(field_name, None) + default = field.get_default() + if isinstance(default, PydanticUndefinedType): + default = None + if field_info: + if field.is_required(): + field_info.required = True + if field.description: + field_info.placeholder_text = field.description + if default is not None: + field_info.default = default + label, input_widget = self._create_device_field(field_name, field_info) + row = layout.rowCount() + layout.addWidget(label, row, 0) + layout.addWidget(input_widget, row, 1) + self._widgets["deviceConfig"][field_name] = input_widget + return box + + def create_additional_settings(self) -> QtWidgets.QGroupBox: + """Create the additional settings box widget.""" + box, layout = self._create_group_box_with_grid_layout("Additional Settings") + toolbox = QtWidgets.QToolBox(parent=self) + layout.addWidget(toolbox, 0, 0) + user_parameters_widget = ParameterValueWidget(parent=self) + self._widgets["userParameter"] = user_parameters_widget + toolbox.addItem(user_parameters_widget, "User Parameter") + device_tags_widget = DeviceTagsWidget(parent=self) + toolbox.addItem(device_tags_widget, "Device Tags") + toolbox.setCurrentIndex(1) + self._widgets["deviceTags"] = device_tags_widget + return box + + +if __name__ == """__main__""": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import yaml + from bec_qthemes import apply_theme + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + apply_theme("light") + + class TestWidget(QtWidgets.QWidget): + pass + + w = TestWidget() + w_layout = QtWidgets.QVBoxLayout(w) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(20) + dark_mode_button = DarkModeButton() + w_layout.addWidget(dark_mode_button) + test_motor = "EpicsMotor" + config_form = DeviceConfigTemplate(template=OPHYD_DEVICE_TEMPLATES[test_motor][test_motor]) + w_layout.addWidget(config_form) + button_layout = QtWidgets.QHBoxLayout() + button = QtWidgets.QPushButton("Get Config") + button.clicked.connect( + lambda: print("Device Config:", yaml.dump(config_form.get_config_fields(), indent=4)) + ) + button_layout.addWidget(button) + button2 = QtWidgets.QPushButton("Reset") + button2.clicked.connect(config_form.reset_to_defaults) + button_layout.addWidget(button2) + combo = QtWidgets.QComboBox() + combo_keys = [ + "EpicsMotor", + "EpicsSignal", + "EpicsSignalRO", + "EpicsSignalWithRBV", + "CustomDevice", + ] + combo.addItems(combo_keys) + combo.setCurrentText(test_motor) + + def text_changed(text: str) -> None: + if text.startswith("EpicsMotor"): + if text == "EpicsMotor": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsMotor"][text] + elif text.startswith("EpicsSignal"): + if text == "EpicsSignal": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsSignal"][text] + else: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + config_form.change_template(template) + + combo.currentTextChanged.connect(text_changed) + button_layout.addWidget(button) + button_layout.addWidget(combo) + w_layout.addLayout(button_layout) + w.resize(1200, 600) + w.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py new file mode 100644 index 00000000..47d38950 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py @@ -0,0 +1,481 @@ +"""Module for custom input widgets used in device configuration templates.""" + +from ast import literal_eval +from typing import Callable + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from pydantic import BaseModel, ConfigDict +from qtpy import QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.scan_control.scan_group_box import ScanDoubleSpinBox +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +def _try_literal_eval(value: any) -> any: + """Consolidated function for literal evaluation of a value.""" + if value in ["true", "True"]: + return True + if value in ["false", "False"]: + return False + if value == "": + return "" + try: + return literal_eval(f"{value}") + except ValueError: + return value + except Exception: + logger.warning(f"Could not literal_eval value: {value}, returning as string") + return value + + +class InputLineEdit(QtWidgets.QLineEdit): + """ + Custom QLineEdit for input fields with validation. + + Args: + parent (QtWidgets.QWidget, optional): Parent widget. Defaults to None. + config_field (str, optional): Configuration field name. Defaults to "no_field_specified" + required (bool, optional): Whether the field is required. Defaults to True. + placeholder_text (str, optional): Placeholder text for the input field. Defaults to "". + """ + + def __init__( + self, + parent=None, + config_field: str = "no_field_specified", + required: bool = True, + placeholder_text: str = "", + ): + super().__init__(parent) + self._config_field = config_field + self._colors = get_accent_colors() + self._required = required + self.textChanged.connect(self._update_input_field_style) + self._validation_callbacks: list[Callable[[bool], str]] = [] + self.setPlaceholderText(placeholder_text) + self._update_input_field_style() + + def register_validation_callback(self, callback: Callable[[str], bool]) -> None: + """ + Register a custom validation callback. + + Args: + callback (Callable[[str], bool]): A function that takes the input string + and returns True if valid, False otherwise. + """ + self._validation_callbacks.append(callback) + + def apply_theme(self, theme: str) -> None: + """Apply the theme to the widget.""" + self._colors = get_accent_colors() + self._update_input_field_style() + + def _update_input_field_style(self) -> None: + """Update the input field style based on validation.""" + name = self.text() + if not self.is_valid_input(name) and self._required is True: + self.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + return + self.setStyleSheet("") + return + + def is_valid_input(self, name: str) -> bool: + """Validate the input string using plugin helper.""" + name = name.strip() # Remove leading/trailing whitespace + # Run registered validation callbacks + for callback in self._validation_callbacks: + try: + valid = callback(name) + except Exception as exc: + logger.warning( + f"Validation callback raised an exception: {exc}. Defaulting to valid" + ) + valid = True + if not valid: + return False + if not self._required: + return True + if not name: + return False + return True + + +class OnFailureComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the onFailure input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["buffer", "retry", "raise"]) + + +class ReadoutPriorityComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the readoutPriority input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["monitored", "baseline", "async", "continuous", "on_request"]) + + +class LimitInputWidget(QtWidgets.QWidget): + """Custom widget for inputting limits as a tuple (min, max).""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self._layout = QtWidgets.QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + + # Colors + self._colors = get_accent_colors() + + self.min_input = ScanDoubleSpinBox(self, arg_name="min_limit", default=0.0) + self.min_input.setPrefix("Min: ") + self.min_input.setEnabled(False) + self.min_input.setRange(-1e12, 1e12) + self._layout.addWidget(self.min_input) + + self.max_input = ScanDoubleSpinBox(self, arg_name="max_limit", default=0.0) + self.max_input.setPrefix("Max: ") + self.max_input.setRange(-1e12, 1e12) + self.max_input.setEnabled(False) + self._layout.addWidget(self.max_input) + + # Add validity checks + self.min_input.valueChanged.connect(self._check_valid_inputs) + self.max_input.valueChanged.connect(self._check_valid_inputs) + + # Add checkbox to enable/disable limits + self.enable_toggle = ToggleSwitch(self) + self.enable_toggle.setToolTip("Enable editing limits") + self.enable_toggle.setChecked(False) + self.enable_toggle.enabled.connect(self._toggle_limits_enabled) + self._layout.addWidget(self.enable_toggle) + + def reset_defaults(self) -> None: + """Reset limits to default values.""" + self.min_input.setValue(0.0) + self.max_input.setValue(0.0) + self.enable_toggle.setChecked(False) + + def _is_valid_limit(self) -> bool: + """Check if the current limits are valid (min < max).""" + return self.min_input.value() <= self.max_input.value() + + def _check_valid_inputs(self) -> None: + """Check if the current inputs are valid and update styles accordingly.""" + if not self._is_valid_limit(): + self.min_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + self.max_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + else: + self.min_input.setStyleSheet("") + self.max_input.setStyleSheet("") + + def _toggle_limits_enabled(self, enable: bool) -> None: + """Enable or disable the limit inputs based on the checkbox state.""" + self.min_input.setEnabled(enable) + self.max_input.setEnabled(enable) + + def get_limits(self) -> list[float, float]: + """Return the limits as a list [min, max].""" + min_val = self.min_input.value() + max_val = self.max_input.value() + return [min_val, max_val] + + def set_limits(self, limits: tuple) -> None: + """Set the limits from a tuple (min, max).""" + checked_state = self.enable_toggle.isChecked() + if not checked_state: + self.enable_toggle.setChecked(True) + self.min_input.setValue(limits[0]) + self.max_input.setValue(limits[1]) + self.enable_toggle.setChecked(checked_state) + + +class ParameterValueWidget(QtWidgets.QWidget): + """Custom QTreeWidget for user parameters input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(2) + self.tree_widget.setHeaderLabels(["Parameter", "Value"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + header = self.tree_widget.header() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None, value: str | None = None) -> None: + """Add a new row with editable Parameter/Value QLineEdits.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Parameter field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Parameter") + self.tree_widget.setItemWidget(item, 0, param_edit) + + # Value field + value_edit = QtWidgets.QLineEdit(self.tree_widget) + value_edit.setPlaceholderText("Value") + self.tree_widget.setItemWidget(item, 1, value_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + if value is not None: + value_edit.setText(str(value)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> dict: + """Return all parameters as a dictionary {parameter: value}.""" + result = {} + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + value_edit = self.tree_widget.itemWidget(item, 1) + if param_edit and value_edit: + key = param_edit.text().strip() + val = value_edit.text().strip() + if key and val: + result[key] = _try_literal_eval(val) + return result + + +class DeviceTagsWidget(QtWidgets.QWidget): + """Custom QTreeWidget for deviceTags input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(1) + self.tree_widget.setHeaderLabels(["Tags"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None) -> None: + """Add a new row with editable Tag QLineEdit.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Tag field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Tag") + self.tree_widget.setItemWidget(item, 0, param_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> list[str]: + """Return all parameters as a list of tags.""" + result = [] + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + if param_edit: + tag = param_edit.text().strip() + if tag: + result.append(tag) + return result + + +# Validation callback for name field +def validate_name(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").isalnum(): + return False + return True + + +# Validation callback for deviceClass field +def validate_device_cls(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").replace(".", "").isalnum(): + return False + return True + + +def validate_prefix(value: str) -> bool: + """Check that the prefix does not contain spaces.""" + if " " in value: + return False + if not value.replace("_", "").replace(".", "").replace("-", "").replace(":", "").isalnum(): + return False + return True + + +class DeviceConfigField(BaseModel): + """Pydantic model for device configuration fields.""" + + label: str + widget_cls: type[QtWidgets.QWidget] + required: bool = False + static: bool = False + placeholder_text: str | None = None + validation_callback: list[Callable[[str], bool]] | None = None + default: any = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +DEVICE_FIELDS = { + "name": DeviceConfigField( + label="Name", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device name (no spaces or special characters)", + validation_callback=[validate_name], + ), + "deviceClass": DeviceConfigField( + label="Device Class", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device class (no spaces or special characters)", + validation_callback=[validate_device_cls], + ), + "description": DeviceConfigField( + label="Description", + widget_cls=QtWidgets.QTextEdit, + required=False, + placeholder_text="Short device description", + ), + "enabled": DeviceConfigField( + label="Enabled", widget_cls=ToggleSwitch, required=False, default=True + ), + "readOnly": DeviceConfigField( + label="Read Only", widget_cls=ToggleSwitch, required=False, default=False + ), + "softwareTrigger": DeviceConfigField( + label="Software Trigger", widget_cls=ToggleSwitch, required=False, default=False + ), + "readoutPriority": DeviceConfigField( + label="Readout Priority", widget_cls=ReadoutPriorityComboBox, default="baseline" + ), + "onFailure": DeviceConfigField( + label="On Failure", widget_cls=OnFailureComboBox, default="retry" + ), + "userParameter": DeviceConfigField( + label="User Parameters", widget_cls=ParameterValueWidget, static=False + ), + "deviceTags": DeviceConfigField(label="Device Tags", widget_cls=DeviceTagsWidget, static=False), +} + +DEVICE_CONFIG_FIELDS = { + "prefix": DeviceConfigField( + label="Prefix", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS IOC prefix, e.g. X25DA-ES1-MOT:", + validation_callback=[validate_prefix], + ), + "read_pv": DeviceConfigField( + label="Read PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS read PV: e.g. X25DA-ES1-MOT:GET", + validation_callback=[validate_prefix], + ), + "write_pv": DeviceConfigField( + label="Write PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS write PV (if different from read_pv): e.g. X25DA-ES1-MOT:SET", + validation_callback=[validate_prefix], + ), + "limits": DeviceConfigField(label="Limits", widget_cls=LimitInputWidget, static=False), + "DEFAULT": DeviceConfigField(label="DEFAULT FIELD", widget_cls=InputLineEdit, static=False), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py new file mode 100644 index 00000000..5e7b665c --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -0,0 +1,1002 @@ +""" +Module for a TableWidget for the device manager view. Row data is encapsulated +in DeviceTableRow entries. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Callable, Iterable, Tuple + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtGui, QtWidgets +from thefuzz import fuzz + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) + +logger = bec_logger.logger + +_DeviceCfgIter = Iterable[dict[str, Any]] +# DeviceValidationResult: device_config, config_status, connection_status, error_message +_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] + +FUZZY_SEARCH_THRESHOLD = 80 + + +def is_match( + text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool +) -> bool: + """ + Check if the text matches any of the relevant keys in the row data. + + Args: + text (str): The text to search for. + row_data (dict[str, Any]): The row data to search in. + relevant_keys (list[str]): The keys to consider for searching. + enable_fuzzy (bool): Whether to use fuzzy matching. + Returns: + bool: True if a match is found, False otherwise. + """ + for key in relevant_keys: + data = str(row_data.get(key, "") or "") + if enable_fuzzy: + match_ratio = fuzz.partial_ratio(text.lower(), data.lower()) + if match_ratio >= FUZZY_SEARCH_THRESHOLD: + return True + else: + if text.lower() in data.lower(): + return True + return False + + +class TableSortOnHold: + """Context manager for putting table sorting on hold. Works with nested calls.""" + + def __init__(self, table: QtWidgets.QTableWidget) -> None: + self.table = table + self._call_depth = 0 + self._registered_methods = [] + + def register_on_hold_method( + self, method: Callable[[QtWidgets.QTableWidget, bool], None] + ) -> None: + """ + Register a method to be called when sorting is put on hold. + + Args: + method (Callable[[QtWidgets.QTableWidget, bool], None]): The method to register. + The method should accept the QTableWidget and a bool indicating + whether sorting is being enabled (True) or disabled (False). + """ + self._registered_methods.append(method) + + def __enter__(self): + """Enter the context manager""" + self._call_depth += 1 # Needed for nested calls + self.table.setSortingEnabled(False) + for method in self._registered_methods: + method(self.table, False) + + def __exit__(self, *exc): + """Exit the context manager""" + self._call_depth -= 1 # Remove nested calls + if self._call_depth == 0: # Only re-enable sorting on outermost exit + self.table.setSortingEnabled(True) + for method in self._registered_methods: + method(self.table, True) + + +class CenterIconDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to center icons in table cells.""" + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + # First draw the default cell (without icon) + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.icon = QtGui.QIcon() # Create empty icon to avoid default to be drawn at given position + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, option.widget + ) + # Check if there is an icon to draw + icon = index.data(QtCore.Qt.ItemDataRole.DecorationRole) + if not icon: + return + # Draw the icon centered in the cell + icon_size = option.decorationSize + if icon_size.isValid(): + size = icon_size + else: + size = icon.actualSize(option.rect.size()) + + x = option.rect.x() + (option.rect.width() - size.width()) // 2 + y = option.rect.y() + (option.rect.height() - size.height()) // 2 + + icon.paint(painter, QtCore.QRect(QtCore.QPoint(x, y), size)) + + +class CheckBoxDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to handle checkbox interactions in the table.""" + + # Signal to indicate a checkbox was clicked + checkbox_clicked = QtCore.Signal(int, int, bool) # row, column, checked + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + if model and (model.flags(index) & QtCore.Qt.ItemFlag.ItemIsUserCheckable): + old_state = QtCore.Qt.CheckState( + model.data(index, QtCore.Qt.ItemDataRole.CheckStateRole) + ) + new_state = ( + QtCore.Qt.CheckState.Unchecked + if old_state == QtCore.Qt.CheckState.Checked + else QtCore.Qt.CheckState.Checked + ) + model.setData(index, new_state, QtCore.Qt.ItemDataRole.CheckStateRole) + model.setData( + index, + new_state == QtCore.Qt.CheckState.Checked, + QtCore.Qt.ItemDataRole.UserRole, + ) + self.checkbox_clicked.emit( + index.row(), index.column(), new_state == QtCore.Qt.CheckState.Checked + ) + return True + return super().editorEvent(event, model, option, index) + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data < other_data + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data > other_data + return super().__gt__(other) + + +class DeviceTable(BECWidget, QtWidgets.QWidget): + """Custom table to display device configurations.""" + + RPC = False # TODO discuss if this should be available for RPC + + # Signal emitted if devices are added (updated) or removed + # - device_configs: List of device configurations. + # - added: True if devices were added/updated, False if removed. + device_configs_changed = QtCore.Signal(list, bool) + # Signal emitted when device selection changes, emits list of selected device configs + selected_devices = QtCore.Signal(list) + # Signal emitted when a device row is double-clicked, emits the device config + device_row_dbl_clicked = QtCore.Signal(dict) + # Signal emitted when the device config is in sync with Redis + device_config_in_sync_with_redis = QtCore.Signal(bool) + + _auto_size_request = QtCore.Signal() + + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent=parent) + self.headers_key_map: dict[str, str] = { + "Valid": "valid", + "Connect": "connect", + "Name": "name", + "Device Class": "deviceClass", + "Readout Priority": "readoutPriority", + "On Failure": "onFailure", + "Device Tags": "deviceTags", + "Description": "description", + "Enabled": "enabled", + "Read Only": "readOnly", + "Software Trigger": "softwareTrigger", + } + + # General attributes + self._icon_size = (18, 18) + self._colors = get_accent_colors() + self._icons = get_validation_icons(self._colors, self._icon_size) + self._check_box_icons = { + "checked": material_icon( + "check_box", size=(24, 24), color=self._colors.default, convert_to_pixmap=False + ), + "unchecked": material_icon( + "check_box_outline_blank", + size=(24, 24), + color=self._colors.default, + convert_to_pixmap=False, + ), + } + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) + + # Table related attributes + self.row_data: dict[str, DeviceTableRow] = {} + self.table = QtWidgets.QTableWidget(self) + self.table_sort_on_hold = TableSortOnHold(self.table) + self._setup_table() + self.table_sort_on_hold.register_on_hold_method(self._resize_table_policy) + self.table_sort_on_hold.register_on_hold_method(self._set_table_signals_on_hold) + + # Search related attributes + self._searchable_keys: list[str] = ["name", "deviceClass", "deviceTags", "description"] + self._hidden_rows: set[int] = set() + self._enable_fuzzy_search: bool = True + self._setup_search() + + # Add components to layout + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) + + # Connect slots + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) + # Install event filter + self.table.installEventFilter(self) + + def cleanup(self): + """Cleanup resources.""" + self.row_data.clear() # Drop references to row data.. + # self._autosize_timer.stop() + super().cleanup() + + # ------------------------------------------------------------------------- + # Custom hooks for table events + # ------------------------------------------------------------------------- + + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ): + """Handle selection changes in the table.""" + rows = set() + for index in selected.indexes(): + row = index.row() + rows.add(row) + selected_configs = [] + for row in rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + cfg = deepcopy(row_data.data) + cfg.pop("name") + selected_configs.append({device_name: cfg}) + self.selected_devices.emit(selected_configs) + + def _on_cell_double_clicked(self, row: int, column: int): + """Handle double-click events on table cells.""" + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + self.device_row_dbl_clicked.emit(row_data.data) + + def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Customize event filtering for table interactions.""" + if source is self.table: + if event.type() == QtCore.QEvent.Type.KeyPress: + if event.key() in (QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete): + configs = self.get_selected_device_configs() + if configs: + if self._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.remove_device_configs(configs) + return True # Event handled + if event.key() == QtCore.Qt.Key.Key_Escape: + self.table.clearSelection() + return True # handled + return super().eventFilter(source, event) + + def _on_table_checkbox_clicked(self, row: int, column: int, checked: bool): + """Handle checkbox clicks in the table.""" + name_index = list(self.headers_key_map.values()).index("name") + device_name = self._get_cell_data(row, name_index) + row_data = self.row_data.get(device_name) + if not row_data: + return + row_data.data[self.headers_key_map[list(self.headers_key_map.keys())[column]]] = checked + self._on_device_row_data_changed(row_data.data) + + def _on_device_row_data_changed(self, data: dict): + """Handle data change events from device rows.""" + device_name = data.get("name", None) + cfg = deepcopy(data) + cfg.pop("name") + self.selected_devices.emit([{device_name: cfg}]) + self.device_config_in_sync_with_redis.emit(self._is_config_in_sync_with_redis()) + + def _apply_row_filter(self, text_input: str): + """Apply a filter to the table rows based on the filter text.""" + for row in range(self.table.rowCount()): + device_name = self._get_cell_data(row, 2) # Name column + if not device_name: + continue + row_data = self.row_data.get(device_name) + if not row_data: + continue + if is_match( + text_input, row_data.data, self._searchable_keys, self._enable_fuzzy_search + ): + self.table.setRowHidden(row, False) + self._hidden_rows.discard(row) + else: + self.table.setRowHidden(row, True) + self._hidden_rows.add(row) + + def _state_change_fuzzy_search(self, enabled: int): + """Handle state changes for the fuzzy search toggle.""" + self._enable_fuzzy_search = not bool(enabled) + # Re-apply filter with updated fuzzy search setting + current_text = self.search_input.text() + self._apply_row_filter(current_text) + + # ------------------------------------------------------------------------- + # Custom Dialog + # ------------------------------------------------------------------------- + + def _remove_configs_dialog(self, device_names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + device_names (list[str]): List of device names to be removed. + + Returns: + bool: True if the user confirmed removal, False otherwise. + """ + msg = QtWidgets.QMessageBox(self) + msg.setIcon(QtWidgets.QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{device_names[0]}'?" + if len(device_names) == 1 + else f"Remove {len(device_names)} devices?" + ) + separator = "\n" if len(device_names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(device_names)) + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel + ) + msg.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + + res = msg.exec_() + if res == QtWidgets.QMessageBox.StandardButton.Ok: + return True + return False + + # ------------------------------------------------------------------------- + # Setup table + # ------------------------------------------------------------------------- + def _setup_table(self): + """Initializes the table configuration and headers.""" + # Temporary instance to get headers dynamically + headers = list(self.headers_key_map.keys()) + self.table.setColumnCount(len(headers)) + self.table.setHorizontalHeaderLabels(headers) + # Smooth scrolling + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + + # Hide vertical header + self.table.verticalHeader().setVisible(False) + + # Column resize policies + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(6, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(7, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(8, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(9, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(10, QtWidgets.QHeaderView.ResizeMode.Fixed) + + for sizes, col in [ + (0, 85), + (1, 85), + (2, 200), + (3, 200), + (6, 200), + (7, 200), + (8, 90), + (9, 90), + (10, 120), + ]: + self.table.setColumnWidth(sizes, col) + + # Ensure column widths stay fixed + header.setStretchLastSection(False) + + # Sorting + self.table.setSortingEnabled(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(2, QtCore.Qt.SortOrder.AscendingOrder) # Default sort by name + + # Selection behavior + self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.table.horizontalHeader().setHighlightSections(False) + + # Set delegate for checkboxes + checkbox_delegate = CheckBoxDelegate(self.table) + icon_delegate = CenterIconDelegate(self.table) + self.table.setItemDelegateForColumn(0, icon_delegate) # Config status + self.table.setItemDelegateForColumn(1, icon_delegate) # Connection status + self.table.setWordWrap(True) + for col in (8, 9, 10): # enabled, readOnly, softwareTrigger + self.table.setItemDelegateForColumn(col, checkbox_delegate) + checkbox_delegate.checkbox_clicked.connect(self._on_table_checkbox_clicked) + + def _set_table_signals_on_hold(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable table signals.""" + if enable: + table.blockSignals(False) + else: + table.blockSignals(True) + + def _resize_table_policy(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable column resizing.""" + if enable: + table.resizeColumnToContents(2) # Name + table.resizeColumnToContents(3) # Device Class + # table.resizeRowsToContents() + + def _setup_search(self): + """Create components related to the search functionality""" + + # Create search bar + self.search_layout = QtWidgets.QHBoxLayout() + self.search_label = QtWidgets.QLabel("Search:") + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Filter devices (approximate matching)...") + self.search_input.setClearButtonEnabled(True) + self.search_input.textChanged.connect(self._apply_row_filter) + self.search_layout.addWidget(self.search_label) + self.search_layout.addWidget(self.search_input) + + # Add exact match toggle + self.fuzzy_layout = QtWidgets.QHBoxLayout() + self.fuzzy_label = QtWidgets.QLabel("Exact Match:") + self.fuzzy_is_disabled = QtWidgets.QCheckBox() + + self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search) + self.fuzzy_is_disabled.setToolTip( + "Enable approximate matching (OFF) and exact matching (ON)" + ) + self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") + self.fuzzy_layout.addWidget(self.fuzzy_label) + self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) + self.fuzzy_layout.addStretch() + + # Add both search components to the layout + self.search_controls = QtWidgets.QHBoxLayout() + self.search_controls.addLayout(self.search_layout) + self.search_controls.addSpacing(20) # Add some space between the search box and toggle + self.search_controls.addLayout(self.fuzzy_layout) + QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + + # ------------------------------------------------------------------------- + # Row Management, internal methods. + # ------------------------------------------------------------------------- + + def _add_row( + self, + data: dict, + config_status: ConfigStatus | int, + connection_status: ConnectionStatus | int, + ): + """ + Adds a new row at the bottom and populates it with data. The row widgets + are stored in self.row_widgets for easy access. Consider to disable sorting + when adding rows as this method is not responsible for maintaining sort order. + + Args: + data (dict): The device data to populate the row. + config_status (ConfigStatus | int): The configuration validation status. + connection_status (ConnectionStatus | int): The connection status. + """ + with self.table_sort_on_hold: + if data["name"] in self.row_data: + logger.warning(f"Overwriting existing device row for {data['name']}") + self._remove_rows_by_name([data["name"]]) + row_index = self.table.rowCount() + self.table.insertRow(row_index) + + # Create row for the table + device_row = DeviceTableRow(data=data) + device_row.set_validation_status(config_status, connection_status) + + # Populate cells + self._populate_device_row_cells(row_index, device_row) + + def _populate_device_row_cells(self, row: int, device_row: DeviceTableRow): + """Populate the cells of a given row with the widgets from the DeviceTableRow.""" + with self.table_sort_on_hold: + config_status, connect_status = device_row.validation_status + column_keys = list(self.headers_key_map.values()) + for ii, key in enumerate(column_keys): + if key in ("enabled", "readOnly", "softwareTrigger"): # flags for checkboxes + item = SortTableItem() + item.setFlags( + item.flags() + | QtCore.Qt.ItemFlag.ItemIsUserCheckable + | QtCore.Qt.ItemFlag.ItemIsEnabled + ) + item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + elif key in ("valid", "connect"): # status columns + item = SortTableItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignCenter | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + item.setIcon( + self._icons["connection_status"][connect_status] + if key == "connect" + else self._icons["config_status"][config_status] + ) + else: + item = QtWidgets.QTableWidgetItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(row, ii, item) # +2 offset for status columns + self.__update_device_row_data(row, device_row.data) + + def __update_device_row_data(self, row: int, data: dict): + """ + Update an existing device row with new data. + + Args: + row (int): The row index to update. + data (dict): The device data to populate the row. + """ + # Update stored row data + if data["name"] in self.row_data: + self.row_data[data["name"]].set_data(data) + else: + self.row_data[data["name"]] = DeviceTableRow(data) + # Update table cells + with self.table_sort_on_hold: + column_keys = list(self.headers_key_map.values()) # map columns + for key, value in data.items(): + if key not in column_keys: + continue # Skip userParameters and deviceConfig + column = column_keys.index(key) + item = self.table.item(row, column) + if not item: + continue + if key in ("enabled", "readOnly", "softwareTrigger"): + item.setCheckState( + QtCore.Qt.CheckState.Checked if value else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, value) + item.setText("") # No text for checkboxes + elif key == "deviceTags": + item.setText( + ", ".join(value) if isinstance(value, (list, set, tuple)) else str(value) + ) + elif key == "deviceClass": + item.setText( + value.split(".")[-1] + ) # Only show the DeviceClass, not the full module + else: + if value is None: + value = "" + item.setText(str(value)) + self._update_device_row_status( + row, + self.row_data[data["name"]].validation_status[0], + self.row_data[data["name"]].validation_status[1], + ) + self.table.resizeRowToContents(row) + self._on_device_row_data_changed(self.row_data[data["name"]].data) + return True + + def _update_device_row_status( + self, row: int, config_status: int, connection_status: int + ) -> bool: + """ + Update an existing device row's validation status. + + Args: + device_name (str): The name of the device. + config_status (int): The configuration validation status. + connection_status (int): The connection status. + """ + with self.table_sort_on_hold: + item = self.table.item(row, 0) # Config status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, config_status) + item.setIcon(self._icons["config_status"][config_status]) + item = self.table.item(row, 1) # Connect status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, connection_status) + item.setIcon(self._icons["connection_status"][connection_status]) + + # Update the stored row data as well + device_name = self._get_cell_data(row, 2) # Name column + device_row = self.row_data.get(device_name, None) + if not device_row: + return False + device_row: DeviceTableRow + device_row.set_validation_status(config_status, connection_status) + return True + + def _get_cell_data(self, row: int, column: int) -> str | bool | None: + """ + Get the data from a specific cell. + + Args: + row (int): The row index. + column (int): The column index. + """ + item = self.table.item(row, column) + if item is None: + return None + if column in (8, 9, 10): # Checkboxes + return item.checkState() == QtCore.Qt.CheckState.Checked + return item.text() + + def _update_row(self, data: dict) -> int | None: + """ + Update an existing row with new data. + + Args: + data (dict): The device data to populate the row. + Returns: + int | None: The row index if updated, else None. + """ + device_row = self.row_data.get(data.get("name"), {}) + if self._compare_configs(device_row.data, data): + return None # No update needed + row = self._find_row_by_name(data.get("name", "")) + if row is not None: + self.__update_device_row_data(row, data) + return row + + def _compare_configs(self, cfg1: dict, cfg2: dict) -> bool: + """Compare two device configurations for equality.""" + try: + cfg1_model = DeviceModel.model_validate(cfg1) + cfg2_model = DeviceModel.model_validate(cfg2) + return cfg1_model == cfg2_model + except Exception as e: + logger.error(f"Error comparing device configs: {e}") + return False + + def _clear_table(self): + """Remove all rows.""" + with self.table_sort_on_hold: + n_rows = self.table.rowCount() + for _ in range(n_rows): + self.table.removeRow(0) + self.row_data.clear() + + def _find_row_by_name(self, name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + data = self._get_cell_data(row, 2) + if data and data == name: + return row + return None + + def _remove_rows_by_name(self, device_names: list[str]): + """ + Remove a row by device name. + + Args: + device_name (str): The name of the device to remove. + """ + if not device_names: + return + with self.table_sort_on_hold: + for device_name in device_names: + row = self._find_row_by_name(device_name) + if row is None: + logger.warning(f"Device {device_name} not found in table for removal.") + return + self.table.removeRow(row) + self.row_data.pop(device_name, None) + + def _is_config_in_sync_with_redis(self): + """Check if the current config is in sync with Redis.""" + if ( + not self.client + or not self.client.device_manager + or not self.client.device_manager.devices + ): + return False # No proper client connection + redis_config = [ + DeviceModel.model_validate(device._config) + for device in self.client.device_manager.devices.values() + ] + try: + current_config = [ + DeviceModel.model_validate(row_data.data) for row_data in self.row_data.values() + ] + if redis_config == current_config: + return True + else: + return False + except Exception as e: + logger.error(f"Error comparing device configs: {e}") + return False + + # ------------------------------------------------------------------------- + # Public API to manage device configs in the table + # ------------------------------------------------------------------------- + + def get_device_config(self) -> list[dict]: + """ + Get the current device configurations in the table. + + Returns: + list[dict]: The list of device configurations. + """ + cfgs = [ + {"name": device_name, **row_data.data} + for device_name, row_data in self.row_data.items() + ] + return cfgs + + def get_validation_results(self) -> dict[str, Tuple[dict, int, int]]: + """ + Get the current device validation results in the table. + + Returns: + dict[str, Tuple[dict, int, int]]: Dictionary mapping of device name to + (device config, config status, connection status). + """ + return { + row_data.data.get("name"): (row_data.data, *row_data.validation_status) + for row_data in self.row_data.values() + if row_data.data.get("name") is not None + } + + def get_selected_device_configs(self) -> list[dict]: + """ + Get the currently selected device configurations in the table. + + Returns: + list[dict]: The list of selected device configurations. + """ + selected_configs = [] + selected_rows = set() + for index in self.table.selectionModel().selectedIndexes(): + selected_rows.add(index.row()) + for row in selected_rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + selected_configs.append(row_data.data) + return selected_configs + + # ------------------------------------------------------------------------- + # Public API to be called via signals/slots + # ------------------------------------------------------------------------- + + @SafeSlot(list) + def set_device_config(self, device_configs: _DeviceCfgIter): + """ + Set the device config. This will clear any existing configs. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to set. + """ + self.set_busy(True, text="Loading device configurations...") + with self.table_sort_on_hold: + self.clear_device_configs() + cfgs_added = [] + for cfg in device_configs: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + cfgs_added.append(cfg) + self.device_configs_changed.emit(cfgs_added, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot() + def clear_device_configs(self): + """Clear the device configs.""" + self.set_busy(True, text="Clearing device configurations...") + device_configs = self.get_device_config() + with self.table_sort_on_hold: + self._clear_table() + self.device_configs_changed.emit(device_configs, False) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): + """ + Add devices to the config. If a device already exists, it will be replaced. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to add. + """ + self.set_busy(True, text="Adding device configurations...") + already_in_table = [] + not_in_table = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] in self.row_data: + already_in_table.append(cfg) + else: + not_in_table.append(cfg) + with self.table_sort_on_hold: + # Remove existing rows first + if len(already_in_table) > 0: + self._remove_rows_by_name([cfg["name"] for cfg in already_in_table]) + self.device_configs_changed.emit(already_in_table, False) + + all_configs = already_in_table + not_in_table + if len(all_configs) > 0: + for cfg in already_in_table + not_in_table: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + + self.device_configs_changed.emit(already_in_table + not_in_table, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def update_device_configs(self, device_configs: _DeviceCfgIter): + """ + Update devices in the config. If a device does not exist, it will be added. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to update. + """ + self.set_busy(True, text="Loading device configurations...") + cfgs_updated = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] not in self.row_data: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + cfgs_updated.append(cfg) + continue + # Update existing row if device config has changed + row = self._update_row(cfg) + if row is not None: + cfgs_updated.append(cfg) + self.device_configs_changed.emit(cfgs_updated, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): + """ + Remove devices from the config. + + Args: + device_configs (dict[str, dict]): The device configs to remove. + """ + self.set_busy(True, text="Removing device configurations...") + cfgs_to_be_removed = list(device_configs) + with self.table_sort_on_hold: + self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed]) + self.device_configs_changed.emit(cfgs_to_be_removed, False) # + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(str) + def remove_device(self, device_name: str): + """ + Remove a device from the config. + + Args: + device_name (str): The name of the device to remove. + """ + self.set_busy(True, text=f"Removing device configuration for {device_name}...") + row_data = self.row_data.get(device_name) + if not row_data: + logger.warning(f"Device {device_name} not found in table for removal.") + self.set_busy(False, text="") + return + with self.table_sort_on_hold: + self._remove_rows_by_name([row_data.data["name"]]) + cfgs = [{"name": device_name, **row_data.data}] + self.device_configs_changed.emit(cfgs, False) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def update_multiple_device_validations(self, validation_results: _ValidationResultIter): + """ + Slot to update multiple device validation statuses. This is recommended and more + efficient than updating individual device validation statuses which may affect + the performance of the UI when many devices are being updated in quick succession. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to update. + """ + self.set_busy(True, text="Updating device validations in session...") + self.table.setSortingEnabled(False) + for cfg, config_status, connection_status, _ in validation_results: + row = self._find_row_by_name(cfg.get("name", "")) + if row is None: + logger.warning(f"Device {cfg.get('name')} not found in table for session update.") + continue + self._update_device_row_status(row, config_status, connection_status) + self.table.setSortingEnabled(True) + self.set_busy(False, text="") + + @SafeSlot(dict, int, int, str) + def update_device_validation( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ) -> None: + """ + Update the validation status of a device. If multiple devices are being updated in a batch, + consider using the `update_multiple_device_validations` method instead for better performance. + + Args: + + """ + self.set_busy(True, text="Updating device validation status...") + row = self._find_row_by_name(device_config.get("name", "")) + if row is None: + logger.warning( + f"Device {device_config.get('name')} not found in table for validation update." + ) + self.set_busy(False, text="") + return + # Disable here sorting without context manager to avoid triggering of registered + # resizing methods. Those can be quite heavy, thus, should not run on every + # update of a validation status. + self.table.setSortingEnabled(False) + self._update_device_row_status(row, config_status, connection_status) + self.table.setSortingEnabled(True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py new file mode 100644 index 00000000..4a777e08 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py @@ -0,0 +1,56 @@ +"""Module with custom table row for the device manager device table view.""" + +from bec_lib.atlas_models import Device as DeviceModel + +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, +) + + +class DeviceTableRow: + """ + Custom class to hold data and validation status for a device table row. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + + def __init__(self, data: list[str, dict] | None = None): + """Initialize the DeviceTableRow with optional data. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + self._data = {} + self.validation_status: tuple[int, int] = (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + self.set_data(data or {}) + + @property + def data(self) -> dict: + """Get the current data from the row widgets as a dictionary.""" + return self._data + + def set_data(self, data: DeviceModel | dict) -> None: + """Set the data for the row widgets.""" + if isinstance(data, dict): + data = DeviceModel.model_validate(data) + old_data = DeviceModel.model_validate(self._data) if self._data else None + if old_data is not None and old_data == data: + return # No change needed + self._data = data.model_dump() + self.set_validation_status(ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + + def set_validation_status( + self, valid: ConfigStatus | int, connect_status: ConnectionStatus | int + ) -> None: + """ + Set the validation and connection status icons. + + Args: + valid (ConfigStatus | int): The configuration validation status. + connect_status (ConnectionStatus | int): The connection status. + """ + valid = int(valid) + connect_status = int(connect_status) + self.validation_status = valid, connect_status 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 deleted file mode 100644 index 886b02c7..00000000 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ /dev/null @@ -1,1129 +0,0 @@ -"""Module with the device table view implementation.""" - -from __future__ import annotations - -import copy -import json -import textwrap -from contextlib import contextmanager -from functools import partial -from typing import TYPE_CHECKING, Any, Iterable, List, Literal -from uuid import uuid4 - -from bec_lib.atlas_models import Device -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox -from thefuzz import fuzz - -from bec_widgets.utils.bec_signal_proxy import BECSignalProxy -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal -from bec_widgets.widgets.control.device_manager.components.constants import ( - HEADERS_HELP_MD, - MIME_DEVICE_CONFIG, -) -from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus - -if TYPE_CHECKING: # pragma: no cover - from bec_qthemes._theme import AccentColors - -logger = bec_logger.logger - -_DeviceCfgIter = Iterable[dict[str, Any]] - -# Threshold for fuzzy matching, careful with adjusting this. 80 seems good -FUZZY_SEARCH_THRESHOLD = 80 - -# -USER_CHECK_DATA_ROLE = 101 - - -class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): - """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - - def helpEvent( - self, - event: QtCore.QEvent, - view: QtWidgets.QAbstractItemView, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - ): - """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.Type.ToolTip: - return super().helpEvent(event, view, option, index) - model: DeviceFilterProxyModel = index.model() - model_index = model.mapToSource(index) - row_dict = model.sourceModel().get_row_data(model_index) - description = row_dict.get("description", "") - QtWidgets.QToolTip.showText(event.globalPos(), description, view) - return True - - -class CustomDisplayDelegate(DictToolTipDelegate): - _paint_test_role = Qt.ItemDataRole.DisplayRole - - def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: - return "" - - def _test_custom_paint( - self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex - ): - v = index.model().data(index, self._paint_test_role) - return (v is not None), v - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Any, - ): ... - - def paint( - self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex - ) -> None: - (check, value) = self._test_custom_paint(painter, option, index) - if not check: - return super().paint(painter, option, index) - super().paint(painter, option, index) - painter.save() - self._do_custom_paint(painter, option, index, value) - painter.restore() - - -class WrappingTextDelegate(CustomDisplayDelegate): - """A lightweight delegate that wraps text without expensive size recalculation.""" - - def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6): - super().__init__(parent) - self._parent = parent - self.max_width = max_width - self.margin = margin - self._cache = {} # cache text metrics for performance - self._wrapping_text_columns = None - - @property - def wrapping_text_columns(self) -> List[int]: - # Compute once, cache for later - if self._wrapping_text_columns is None: - self._wrapping_text_columns = [] - view = self._parent - proxy: DeviceFilterProxyModel = self._parent.model() - for col in range(proxy.columnCount()): - delegate = view.itemDelegateForColumn(col) - if isinstance(delegate, WrappingTextDelegate): - self._wrapping_text_columns.append(col) - return self._wrapping_text_columns - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: str, - ): - text = str(value) - if not text: - return - painter.save() - painter.setClipRect(option.rect) - - # Use cached layout if available - cache_key = (text, option.rect.width()) - layout = self._cache.get(cache_key) - if layout is None: - layout = self._compute_layout(text, option) - self._cache[cache_key] = layout - - # Draw text - painter.setPen(option.palette.text().color()) - layout.draw(painter, option.rect.topLeft()) - painter.restore() - - def _compute_layout( - self, text: str, option: QtWidgets.QStyleOptionViewItem - ) -> QtGui.QTextLayout: - """Compute and return the text layout for given text and option.""" - layout = self._get_layout(text, option.font) - text_option = QtGui.QTextOption() - text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere) - layout.setTextOption(text_option) - layout.beginLayout() - height = 0 - max_lines = 100 # safety cap, should never be more than 100 lines.. - for _ in range(max_lines): - line = layout.createLine() - if not line.isValid(): - break - line.setLineWidth(option.rect.width() - self.margin) - line.setPosition(QtCore.QPointF(self.margin / 2, height)) - line_height = line.height() - if line_height <= 0: - break # avoid negative or zero height lines to be added - height += line_height - layout.endLayout() - return layout - - def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout: - return QtGui.QTextLayout(text, font_option) - - def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize: - """Return a cached or approximate height; avoids costly recomputation.""" - text = str(index.data(QtCore.Qt.DisplayRole) or "") - view = self._parent - view.initViewItemOption(option) - if view.isColumnHidden(index.column()) or not view.isVisible() or not text: - return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin) - - # Use cache for consistent size computation - cache_key = (text, self.max_width) - if cache_key in self._cache: - layout = self._cache[cache_key] - height = 0 - for i in range(layout.lineCount()): - height += layout.lineAt(i).height() - return QtCore.QSize(self.max_width, int(height + self.margin)) - - # Approximate without layout (fast path) - metrics = option.fontMetrics - pixel_width = max(self._parent.columnWidth(index.column()), 100) - if pixel_width > 2000: # safeguard against uninitialized columns, may return large values - pixel_width = 100 - char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin) - wrapped_lines = textwrap.wrap(text, width=char_per_line) - lines = len(wrapped_lines) - return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) - - def estimate_chars_per_line( - self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int - ) -> int: - """Estimate number of characters that fit in a line for given width.""" - metrics = option.fontMetrics - elided = metrics.elidedText(text, Qt.ElideRight, column_width) - return len(elided.rstrip("…")) - - @SafeSlot(int, int, int) - @SafeSlot(int) - def _on_section_resized( - self, logical_index: int, old_size: int | None = None, new_size: int | None = None - ): - """Only update rows if a wrapped column was resized.""" - self._cache.clear() - # Make sure layout is computed first - QtCore.QTimer.singleShot(0, self._update_row_heights) - - def _update_row_heights(self): - """Efficiently adjust row heights based on wrapped columns.""" - view = self._parent - proxy = view.model() - option = QtWidgets.QStyleOptionViewItem() - view.initViewItemOption(option) - for row in range(proxy.rowCount()): - max_height = 18 - for column in self.wrapping_text_columns: - index = proxy.index(row, column) - delegate = view.itemDelegateForColumn(column) - hint = delegate.sizeHint(option, index) - max_height = max(max_height, hint.height()) - if view.rowHeight(row) != max_height: - view.setRowHeight(row, max_height) - - -class CenterCheckBoxDelegate(CustomDisplayDelegate): - """Custom checkbox delegate to center checkboxes in table cells.""" - - _paint_test_role = USER_CHECK_DATA_ROLE - - def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): - super().__init__(parent) - colors: AccentColors = colors if colors else get_accent_colors() # type: ignore - _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) - self._icon_checked = _icon("check_box") - self._icon_unchecked = _icon("check_box_outline_blank") - - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) - self._icon_checked = _icon("check_box") - self._icon_unchecked = _icon("check_box_outline_blank") - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Literal[ - Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked - ], - ): - pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked - pix_rect = pixmap.rect() - pix_rect.moveCenter(option.rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) - - def editorEvent( - self, - event: QtCore.QEvent, - model: QtCore.QSortFilterProxyModel, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - ): - if event.type() != QtCore.QEvent.Type.MouseButtonRelease: - return False - current = model.data(index, USER_CHECK_DATA_ROLE) - new_state = ( - Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked - ) - return model.setData(index, new_state, USER_CHECK_DATA_ROLE) - - -class DeviceValidatedDelegate(CustomDisplayDelegate): - """Custom delegate for displaying validated device configurations.""" - - def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): - super().__init__(parent) - colors = colors if colors else get_accent_colors() - _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) - self._icons = { - ValidationStatus.PENDING: _icon(color=colors.default), - ValidationStatus.VALID: _icon(color=colors.success), - ValidationStatus.FAILED: _icon(color=colors.emergency), - } - - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) - self._icons = { - ValidationStatus.PENDING: _icon(color=colors.default), - ValidationStatus.VALID: _icon(color=colors.success), - ValidationStatus.FAILED: _icon(color=colors.emergency), - } - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Literal[0, 1, 2], - ): - """ - Paint the validation status icon centered in the cell. - - Args: - painter (QtGui.QPainter): The painter object. - option (QtWidgets.QStyleOptionViewItem): The style options for the item. - index (QModelIndex): The model index of the item. - value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed. - Relates to ValidationStatus enum. - """ - if pixmap := self._icons.get(value): - pix_rect = pixmap.rect() - pix_rect.moveCenter(option.rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) - - -class DeviceTableModel(QtCore.QAbstractTableModel): - """ - Custom Device Table Model for managing device configurations. - - Sort logic is implemented directly on the data of the table view. - """ - - # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed - configs_changed = QtCore.Signal(list, bool) - - def __init__(self, parent: DeviceTableModel | None = None): - super().__init__(parent) - self._device_config: list[dict[str, Any]] = [] - self._validation_status: dict[str, ValidationStatus] = {} - # TODO 882 keep in sync with HEADERS_HELP_MD - self.headers = [ - "status", - "name", - "deviceClass", - "readoutPriority", - "onFailure", - "deviceTags", - "description", - "enabled", - "readOnly", - "softwareTrigger", - ] - self._checkable_columns_enabled = {"enabled": True, "readOnly": True} - self._device_model_schema = Device.model_json_schema() - - ############################################### - ########## Override custom Qt methods ######### - ############################################### - - def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: - return len(self._device_config) - - def columnCount( - self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex() - ) -> int: - return len(self.headers) - - def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): - if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: - if section == 9: # softwareTrigger - return "softTrig" - return self.headers[section] - return None - - def get_row_data(self, index: QtCore.QModelIndex) -> dict: - """Return the row data for the given index.""" - if not index.isValid(): - return {} - return copy.deepcopy(self._device_config[index.row()]) - - def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): - """Return data for the given index and role.""" - if not index.isValid(): - return None - row, col = index.row(), index.column() - - if col == 0 and role == Qt.ItemDataRole.DisplayRole: - dev_name = self._device_config[row].get("name", "") - return self._validation_status.get(dev_name, ValidationStatus.PENDING) - - key = self.headers[col] - value = self._device_config[row].get(key, None) - if value is None: - value = ( - self._device_model_schema.get("properties", {}).get(key, {}).get("default", None) - ) - - if role == Qt.ItemDataRole.DisplayRole: - if key in ("enabled", "readOnly", "softwareTrigger"): - 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 == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"): - return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked - if role == Qt.ItemDataRole.TextAlignmentRole: - if key in ("enabled", "readOnly", "softwareTrigger"): - return Qt.AlignmentFlag.AlignCenter - return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter - if role == Qt.ItemDataRole.FontRole: - font = QtGui.QFont() - return font - return None - - def flags(self, index): - """Flags for the table model.""" - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - key = self.headers[index.column()] - - base_flags = super().flags(index) | ( - Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled - ) - - if key in ("enabled", "readOnly", "softwareTrigger"): - if self._checkable_columns_enabled.get(key, True): - return base_flags | Qt.ItemFlag.ItemIsUserCheckable - else: - return base_flags # disable editing but still visible - return base_flags - - def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: - """ - Method to set the data of the table. - - Args: - index (QModelIndex): The index of the item to modify. - value (Any): The new value to set. - role (Qt.ItemDataRole): The role of the data being set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if not index.isValid(): - return False - key = self.headers[index.column()] - if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE: - if not self._checkable_columns_enabled.get(key, True): - return False # ignore changes if column is disabled - self._device_config[index.row()][key] = value == Qt.CheckState.Checked - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, USER_CHECK_DATA_ROLE]) - return True - return False - - #################################### - ############ Drag and Drop ######### - #################################### - - def mimeTypes(self) -> List[str]: - return [*super().mimeTypes(), MIME_DEVICE_CONFIG] - - def supportedDropActions(self): - return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction - - def dropMimeData(self, data, action, row, column, parent): - if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: - return False - if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: - return False - self.add_device_configs(json.loads(raw_data.toStdString())) - return True - - #################################### - ############ Public methods ######## - #################################### - - def get_device_config(self) -> list[dict[str, Any]]: - """Method to get the device configuration.""" - return copy.deepcopy(self._device_config) - - def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: - _configs = self._device_config if configs is None else configs - return set(cfg.get("name") for cfg in _configs if cfg.get("name") is not None) # type: ignore - - def _name_exists_in_config(self, name: str, exists: bool): - if (name in self.device_names()) == exists: - return True - return not exists - - def add_device_configs(self, device_configs: _DeviceCfgIter): - """ - Add devices to the model. - - Args: - device_configs (_DeviceCfgList): An iterable of device configurations to add. - """ - already_in_list = [] - added_configs = [] - for cfg in device_configs: - if self._name_exists_in_config(name := cfg.get("name", ""), True): - logger.warning(f"Device {name} is already in the config. It will be updated.") - self.remove_configs_by_name([name]) - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(copy.deepcopy(cfg)) - added_configs.append(cfg) - self.endInsertRows() - self.configs_changed.emit(device_configs, True) - - def remove_device_configs(self, device_configs: _DeviceCfgIter): - """ - Remove devices from the model. - - Args: - device_configs (_DeviceCfgList): An iterable of device configurations to remove. - """ - removed = [] - for cfg in device_configs: - if cfg not in self._device_config: - logger.warning(f"Device {cfg.get('name')} does not exist in the model.") - continue - with self._remove_row(self._device_config.index(cfg)) as row: - removed.append(self._device_config.pop(row)) - self.configs_changed.emit(removed, False) - - def remove_configs_by_name(self, names: Iterable[str]): - configs = filter(lambda cfg: cfg is not None, (self.get_by_name(name) for name in names)) - self.remove_device_configs(configs) # type: ignore # Nones are filtered - - def get_by_name(self, name: str) -> dict[str, Any] | None: - for cfg in self._device_config: - if cfg.get("name") == name: - return cfg - logger.warning(f"Device {name} does not exist in the model.") - return None - - @contextmanager - def _remove_row(self, row: int): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - try: - yield row - finally: - self.endRemoveRows() - - def set_device_config(self, device_configs: _DeviceCfgIter): - """ - Replace the device config. - - Args: - device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. - """ - diff_names = self.device_names(device_configs) - self.device_names() - diff = [cfg for cfg in self._device_config if cfg.get("name") in diff_names] - self.beginResetModel() - self._device_config = copy.deepcopy(list(device_configs)) - self.endResetModel() - self.configs_changed.emit(diff, False) - self.configs_changed.emit(device_configs, True) - - def clear_table(self): - """ - Clear the table. - """ - self.beginResetModel() - self._device_config.clear() - self.endResetModel() - self.configs_changed.emit(self._device_config, False) - - def update_validation_status(self, device_name: str, status: int | ValidationStatus): - """ - Handle device status changes. - - Args: - device_name (str): The name of the device. - status (int): The new status of the device. - """ - if isinstance(status, int): - status = ValidationStatus(status) - if device_name not in self.device_names(): - logger.warning(f"Device {device_name} not found in table") - return - self._validation_status[device_name] = status - row = None - for ii, item in enumerate(self._device_config): - if item["name"] == device_name: - row = ii - break - if row is None: - logger.warning( - f"Device {device_name} not found in device_status dict {self._validation_status}" - ) - return - # Emit dataChanged for column 0 (status column) - index = self.index(row, 0) - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) - - def validation_statuses(self): - return copy.deepcopy(self._validation_status) - - -class BECTableView(QtWidgets.QTableView): - """Table View with custom keyPressEvent to delete rows with backspace or delete key""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) - - def model(self) -> DeviceFilterProxyModel: - return super().model() # type: ignore - - def keyPressEvent(self, event) -> None: - """ - Delete selected rows with backspace or delete key - - Args: - event: keyPressEvent - """ - if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): - return self.delete_selected() - return super().keyPressEvent(event) - - def contains_invalid_devices(self): - return ValidationStatus.FAILED in self.model().sourceModel().validation_statuses().values() - - def all_configs(self): - return self.model().sourceModel().get_device_config() - - def selected_configs(self): - return self.model().get_row_data(self.selectionModel().selectedRows()) - - def delete_selected(self): - proxy_indexes = self.selectionModel().selectedRows() - if not proxy_indexes: - return - model: DeviceTableModel = self.model().sourceModel() # access underlying model - self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - - def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: - """ - Map proxy model indices to source model row indices. - - Args: - proxy_indexes (list[QModelIndex]): List of proxy model indices. - - Returns: - list[int]: List of source model row indices. - """ - proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) - return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) - - def _confirm_and_remove_rows( - self, model: DeviceTableModel, source_rows: list[QModelIndex] - ) -> bool: - """ - Prompt the user to confirm removal of rows and remove them from the model if accepted. - - Returns True if rows were removed, False otherwise. - """ - configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] - names = [cfg.get("name", "") for cfg in configs] - if not names: - logger.warning("No device names found for selected rows.") - return False - if self._remove_rows_msg_dialog(names): - model.remove_device_configs(configs) - return True - return False - - def _remove_rows_msg_dialog(self, names: list[str]) -> bool: - """ - Prompt the user to confirm removal of rows and remove them from the model if accepted. - - Args: - names (list[str]): List of device names to be removed. - - Returns: - bool: True if the user confirmed removal, False otherwise. - """ - msg = QMessageBox(self) - msg.setIcon(QMessageBox.Icon.Warning) - msg.setWindowTitle("Confirm device removal") - msg.setText( - f"Remove device '{names[0]}'?" if len(names) == 1 else f"Remove {len(names)} devices?" - ) - separator = "\n" if len(names) < 12 else ", " - msg.setInformativeText("Selected devices: \n" + separator.join(names)) - msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) - msg.setDefaultButton(QMessageBox.StandardButton.Cancel) - - res = msg.exec_() - if res == QMessageBox.StandardButton.Ok: - return True - return False - - -class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): - - def __init__(self, parent=None): - super().__init__(parent) - self._hidden_rows = set() - self._filter_text = "" - self._enable_fuzzy = True - self._filter_columns = [1, 2, 6] # name, deviceClass and description for search - self._status_order = { - ValidationStatus.VALID: 0, - ValidationStatus.PENDING: 1, - ValidationStatus.FAILED: 2, - } - - def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: - return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) - - def sourceModel(self) -> DeviceTableModel: - return super().sourceModel() # type: ignore - - def hide_rows(self, row_indices: list[int]): - """ - Hide specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to hide. - """ - self._hidden_rows.update(row_indices) - self.invalidateFilter() - - def lessThan(self, left, right): - """Add custom sorting for the status column""" - if left.column() != 0 or right.column() != 0: - return super().lessThan(left, right) - left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole) - right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole) - return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99) - - def show_rows(self, row_indices: list[int]): - """ - Show specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to show. - """ - self._hidden_rows.difference_update(row_indices) - self.invalidateFilter() - - def show_all_rows(self): - """ - Show all rows in the model. - """ - self._hidden_rows.clear() - self.invalidateFilter() - - @SafeSlot(int) - def disable_fuzzy_search(self, enabled: int): - self._enable_fuzzy = not bool(enabled) - self.invalidateFilter() - - def setFilterText(self, text: str): - self._filter_text = text.lower() - self.invalidateFilter() - - def filterAcceptsRow(self, source_row: int, source_parent) -> bool: - # No hidden rows, and no filter text - if not self._filter_text and not self._hidden_rows: - return True - # Hide hidden rows - if source_row in self._hidden_rows: - return False - # Check the filter text for each row - model = self.sourceModel() - text = self._filter_text.lower() - for column in self._filter_columns: - index = model.index(source_row, column, source_parent) - data = str(model.data(index, Qt.ItemDataRole.DisplayRole) or "") - if self._enable_fuzzy is True: - match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) - if match_ratio >= FUZZY_SEARCH_THRESHOLD: - return True - else: - if text in data.lower(): - return True - return False - - def flags(self, index): - return super().flags(index) | Qt.ItemFlag.ItemIsDropEnabled - - def supportedDropActions(self): - return self.sourceModel().supportedDropActions() - - def mimeTypes(self): - return self.sourceModel().mimeTypes() - - def dropMimeData(self, data, action, row, column, parent): - sp = self.mapToSource(parent) if parent.isValid() else QtCore.QModelIndex() - return self.sourceModel().dropMimeData(data, action, row, column, sp) - - -class DeviceTableView(BECWidget, QtWidgets.QWidget): - """Device Table View for the device manager.""" - - # Selected device configuration list[dict[str, Any]] - selected_devices = QtCore.Signal(list) # type: ignore - # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed - device_configs_changed = QtCore.Signal(list, bool) # type: ignore - - RPC = False - PLUGIN = False - - def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelectionSignal()): - super().__init__(client=client, parent=parent, theme_update=True) - - self._shared_selection_signal = shared_selection_signal - self._shared_selection_uuid = str(uuid4()) - self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) - - self._layout = QtWidgets.QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(4) - self.setLayout(self._layout) - - # Setup table view - self._setup_table_view() - # Setup search view, needs table proxy to be iniditate - self._setup_search() - # Add widgets to main layout - self._layout.addLayout(self.search_controls) - self._layout.addWidget(self.table) - - # Connect signals - self._model.configs_changed.connect(self.device_configs_changed.emit) - - def get_help_md(self) -> str: - """ - Generate Markdown help for a cell or header. - """ - pos = self.table.mapFromGlobal(QtGui.QCursor.pos()) - model: DeviceTableModel = self._model # access underlying model - index = self.table.indexAt(pos) - if index.isValid(): - column = index.column() - label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) - if label == "softTrig": - label = "softwareTrigger" - return HEADERS_HELP_MD.get(label, "") - return "" - - def _setup_search(self): - """Create components related to the search functionality""" - - # Create search bar - self.search_layout = QtWidgets.QHBoxLayout() - self.search_label = QtWidgets.QLabel("Search:") - self.search_input = QtWidgets.QLineEdit() - self.search_input.setPlaceholderText( - "Filter devices (approximate matching)..." - ) # Default to fuzzy search - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self.proxy.setFilterText) - self.search_layout.addWidget(self.search_label) - self.search_layout.addWidget(self.search_input) - - # Add exact match toggle - self.fuzzy_layout = QtWidgets.QHBoxLayout() - self.fuzzy_label = QtWidgets.QLabel("Exact Match:") - self.fuzzy_is_disabled = QtWidgets.QCheckBox() - - self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search) - self.fuzzy_is_disabled.setToolTip( - "Enable approximate matching (OFF) and exact matching (ON)" - ) - self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") - self.fuzzy_layout.addWidget(self.fuzzy_label) - self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) - self.fuzzy_layout.addStretch() - - # Add both search components to the layout - self.search_controls = QtWidgets.QHBoxLayout() - self.search_controls.addLayout(self.search_layout) - self.search_controls.addSpacing(20) # Add some space between the search box and toggle - self.search_controls.addLayout(self.fuzzy_layout) - QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) - - def _setup_table_view(self) -> None: - """Setup the table view.""" - # Model + Proxy - self.table = BECTableView(self) - self._model = DeviceTableModel(parent=self.table) - self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self._model) - self.table.setModel(self.proxy) - self.table.setSortingEnabled(True) - - # Delegates - colors = get_accent_colors() - self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) - self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) - self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300) - # Add resize handling for wrapped delegate - header = self.table.horizontalHeader() - - self.table.setItemDelegateForColumn(0, self.validated_delegate) # status - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure - self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description - self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger - - # Disable wrapping, use eliding, and smooth scrolling - self.table.setWordWrap(False) - self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) - self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - - # Column resize policies - header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus - header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name - header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority - header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure - header.setSectionResizeMode( - 5, QHeaderView.ResizeMode.Interactive - ) # deviceTags: expand to fill - header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill - header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled - header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly - header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger - - self.table.setColumnWidth(0, 70) - self.table.setColumnWidth(5, 200) - self.table.setColumnWidth(6, 200) - self.table.setColumnWidth(7, 70) - self.table.setColumnWidth(8, 70) - self.table.setColumnWidth(9, 70) - - # Ensure column widths stay fixed - header.setMinimumSectionSize(25) - header.setDefaultSectionSize(90) - header.setStretchLastSection(False) - - # Resize policy for wrapped text delegate - self._resize_proxy = BECSignalProxy( - header.sectionResized, - rateLimit=25, - slot=self.wrapped_delegate._on_section_resized, - timeout=1.0, - ) - - # Selection behavior - self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - # Connect to selection model to get selection changes - self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) - self.table.horizontalHeader().setHighlightSections(False) - - # Connect model signals to autosize request - self._model.rowsInserted.connect(self._request_autosize_columns) - self._model.rowsRemoved.connect(self._request_autosize_columns) - self._model.modelReset.connect(self._request_autosize_columns) - self._model.dataChanged.connect(self._request_autosize_columns) - - def remove_selected_rows(self): - self.table.delete_selected() - - def get_device_config(self) -> list[dict[str, Any]]: - """Get the device config.""" - return self._model.get_device_config() - - def apply_theme(self, theme: str | None = None): - self.checkbox_delegate.apply_theme(theme) - self.validated_delegate.apply_theme(theme) - - ###################################### - ########### Slot API ################# - ###################################### - - def _request_autosize_columns(self, *args): - if not hasattr(self, "_autosize_timer"): - self._autosize_timer = QtCore.QTimer(self) - self._autosize_timer.setSingleShot(True) - self._autosize_timer.timeout.connect(self._autosize_columns) - self._autosize_timer.start(0) - - @SafeSlot() - def _autosize_columns(self): - if self._model.rowCount() == 0: - return - for col in (1, 2, 3): - self.table.resizeColumnToContents(col) - - @SafeSlot(str) - def _handle_shared_selection_signal(self, uuid: str): - if uuid != self._shared_selection_uuid: - self.table.clearSelection() - - @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. - """ - self._shared_selection_signal.proc.emit(self._shared_selection_uuid) - if not (selected_configs := list(self.table.selected_configs())): - return - self.selected_devices.emit(selected_configs) - - ###################################### - ##### Ext. Slot API ################# - ###################################### - - @SafeSlot(list) - def set_device_config(self, device_configs: _DeviceCfgIter): - """ - Set the device config. - - Args: - config (Iterable[str,dict]): The device config to set. - """ - self._model.set_device_config(device_configs) - - @SafeSlot() - def clear_device_configs(self): - """Clear the device configs.""" - self._model.clear_table() - - @SafeSlot(list) - def add_device_configs(self, device_configs: _DeviceCfgIter): - """ - Add devices to the config. - - Args: - device_configs (dict[str, dict]): The device configs to add. - """ - self._model.add_device_configs(device_configs) - - @SafeSlot(list) - def remove_device_configs(self, device_configs: _DeviceCfgIter): - """ - Remove devices from the config. - - Args: - device_configs (dict[str, dict]): The device configs to remove. - """ - self._model.remove_device_configs(device_configs) - - @SafeSlot(str) - def remove_device(self, device_name: str): - """ - Remove a device from the config. - - Args: - device_name (str): The name of the device to remove. - """ - self._model.remove_configs_by_name([device_name]) - - @SafeSlot(str, int) - def update_device_validation( - self, device_name: str, validation_status: int | ValidationStatus - ) -> None: - """ - Update the validation status of a device. - - Args: - device_name (str): The name of the device. - validation_status (int | ValidationStatus): The new validation status. - """ - self._model.update_validation_status(device_name, validation_status) - - -if __name__ == "__main__": - import sys - - import numpy as np - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - widget = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - window = DeviceTableView() - layout.addWidget(window) - # QPushButton - button = QtWidgets.QPushButton("Test status_update") - layout.addWidget(button) - - def _button_clicked(): - names = list(window._model.device_names()) - for name in names: - window.update_device_validation( - name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED - ) - - button.clicked.connect(_button_clicked) - # pylint: disable=protected-access - config = window.client.device_manager._get_redis_device_config() - config.insert( - 0, - { - "name": "TestDevice", - "deviceClass": "bec.devices.MockDevice", - "description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.", - "deviceTags": ["test", "mock", "longtagnameexample"], - "enabled": True, - "readOnly": False, - "softwareTrigger": True, - }, - ) - # names = [cfg.pop("name") for cfg in config] - # config_dict = {name: cfg for name, cfg in zip(names, config)} - window.set_device_config(config) - window.resize(1920, 1200) - widget.show() - sys.exit(app.exec_()) 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 index 245080f3..2202efc3 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -8,42 +8,47 @@ import yaml from bec_lib.logger import bec_logger 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 logger = bec_logger.logger -class DMConfigView(BECWidget, QtWidgets.QWidget): - def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent, theme_update=True) +class DMConfigView(QtWidgets.QWidget): + """Widget to show the config of a selected device in YAML format.""" + + RPC = False + + def __init__(self, parent=None): + super().__init__(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.monaco_editor = MonacoWidget(parent=self) self._customize_monaco() self.stacked_layout.addWidget(self.monaco_editor) - self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + # Overlay widget + self._overlay_text = "Select a single device to view its config." + self._overlay_widget = QtWidgets.QLabel(text=self._overlay_text) self._customize_overlay() self.stacked_layout.addWidget(self._overlay_widget) self.stacked_layout.setCurrentWidget(self._overlay_widget) def _customize_monaco(self): - + """Customize the Monaco editor for YAML display.""" 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) self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) self.monaco_editor.editor.set_line_numbers_mode("off") def _customize_overlay(self): + """Customize the overlay widget.""" self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._overlay_widget.setAutoFillBackground(True) self._overlay_widget.setSizePolicy( @@ -52,13 +57,24 @@ class DMConfigView(BECWidget, QtWidgets.QWidget): @SafeSlot(dict) def on_select_config(self, device: list[dict]): - """Handle selection of a device from the device table.""" + """ + Handle selection of a device from the device table. If more than one device is selected, + show an overlay message. Otherwise, display the device config in YAML format. + + Args: + device (list[dict]): The selected device configuration. + """ if len(device) != 1: text = "" self.stacked_layout.setCurrentWidget(self._overlay_widget) else: try: - text = yaml.dump(device[0], default_flow_style=False) + # Cast set to list to ensure proper YAML dumping + cfg = device[0] + for k, v in cfg.items(): + if isinstance(v, set): + cfg[k] = list(v) + text = yaml.dump(cfg, default_flow_style=False) self.stacked_layout.setCurrentWidget(self.monaco_editor) except Exception: content = traceback.format_exc() @@ -71,12 +87,14 @@ class DMConfigView(BECWidget, QtWidgets.QWidget): self.monaco_editor.set_readonly(True) # Disable editing again -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys + from bec_qthemes import apply_theme from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + apply_theme("dark") widget = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(widget) widget.setLayout(layout) @@ -86,13 +104,14 @@ if __name__ == "__main__": layout.addWidget(config_view) combo_box = QtWidgets.QComboBox() config = config_view.client.device_manager._get_redis_device_config() - combo_box.addItems([""] + [str(v) for v, item in enumerate(config)]) + combo_box.addItems([""] + [f"{v} : {item.get('name', '')}" for v, item in enumerate(config)]) def on_select(text): if text == "": config_view.on_select_config([]) else: - config_view.on_select_config([config[int(text)]]) + index = int(text.split(" : ")[0]) + config_view.on_select_config([config[index]]) combo_box.currentTextChanged.connect(on_select) layout.addWidget(combo_box) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index 553462a0..cb990fd6 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -5,11 +5,9 @@ from __future__ import annotations import inspect import re import textwrap -import traceback from bec_lib.logger import bec_logger -from bec_lib.plugin_helper import get_plugin_class, plugin_package_name -from bec_lib.utils.rpc_utils import rgetattr +from bec_lib.plugin_helper import get_plugin_class from qtpy import QtCore, QtWidgets from bec_widgets.utils.error_popups import SafeSlot @@ -86,7 +84,8 @@ class DocstringView(QtWidgets.QTextEdit): if len(device) != 1: self._set_text("") return - device_class = device[0].get("deviceClass", "") + device_name = list(device[0].keys())[0] + device_class = device[0][device_name].get("deviceClass", "") self.set_device_class(device_class) @SafeSlot(str) @@ -102,7 +101,7 @@ class DocstringView(QtWidgets.QTextEdit): self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys from qtpy.QtWidgets import QApplication 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 deleted file mode 100644 index a73ada11..00000000 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Module to run a static tests for devices from a yaml config.""" - -from __future__ import annotations - -import enum -import re -from collections import deque -from concurrent.futures import CancelledError, Future, ThreadPoolExecutor -from html import escape -from threading import Event, RLock -from typing import Any, Iterable - -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy import QtCore, 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 SafeSlot -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget - -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 - -try: - from ophyd_devices.utils.static_device_test import StaticDeviceTest -except ImportError: - StaticDeviceTest = None - - -class ValidationStatus(int, enum.Enum): - """Validation status for device configurations.""" - - PENDING = 0 # colors.default - VALID = 1 # colors.highlight - FAILED = 2 # colors.emergency - - -class DeviceValidationResult(QtCore.QObject): - """Simple object to inject validation signals into QRunnable.""" - - # Device validation signal, device_name, ValidationStatus as int, error message or '' - device_validated = QtCore.Signal(str, bool, str) - - -class DeviceTester(QtCore.QRunnable): - def __init__(self, config: dict) -> None: - super().__init__() - self.signals = DeviceValidationResult() - self.shutdown_event = Event() - - self._config = config - - self._max_threads = 4 - self._pending_event = Event() - self._lock = RLock() - self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester") - - self._pending_queue: deque[tuple[str, dict]] = deque([]) - self._active: set[str] = set() - - QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set()) - - def run(self): - if StaticDeviceTest is None: - logger.error("Ophyd devices or bec_server not available, cannot run validation.") - return - while not self.shutdown_event.is_set(): - self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s - if len(self._active) >= self._max_threads: - self._pending_event.clear() # it will be set again on removing something from active - continue - with self._lock: - if len(self._pending_queue) > 0: - item, cfg, connect = self._pending_queue.pop() - self._active.add(item) - fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect) - fut.__dict__["__device_name"] = item - fut.add_done_callback(self._done_cb) - self._safe_check_and_clear() - self._cleanup() - - def submit(self, devices: Iterable[tuple[str, dict, bool]]): - with self._lock: - self._pending_queue.extend(devices) - self._pending_event.set() - - @staticmethod - def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]: - tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None - results = tester.run_with_list_output(connect=connect) - return name, results[0].success, results[0].message - - def _safe_check_and_clear(self): - with self._lock: - if len(self._pending_queue) == 0: - self._pending_event.clear() - - def _safe_remove_from_active(self, name: str): - with self._lock: - self._active.remove(name) - self._pending_event.set() # check again once a completed task is removed - - def _done_cb(self, future: Future): - try: - name, success, message = future.result() - except CancelledError: - return - except Exception as e: - name, success, message = future.__dict__["__device_name"], False, str(e) - finally: - self._safe_remove_from_active(future.__dict__["__device_name"]) - self.signals.device_validated.emit(name, success, message) - - def _cleanup(self): ... - - -class ValidationListItem(QtWidgets.QWidget): - """Custom list item widget showing device name and validation status.""" - - def __init__(self, device_name: str, device_config: dict, parent=None): - """ - Initialize the validation list item. - - Args: - device_name (str): The name of the device. - device_config (dict): The configuration of the device. - validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. - parent (QtWidgets.QWidget, optional): The parent widget. - """ - super().__init__(parent) - self.main_layout = QtWidgets.QHBoxLayout(self) - self.main_layout.setContentsMargins(2, 2, 2, 2) - self.main_layout.setSpacing(4) - self.device_name = device_name - self.device_config = device_config - self.validation_msg = "Validation in progress..." - self._setup_ui() - - def _setup_ui(self): - """Setup the UI for the list item.""" - label = QtWidgets.QLabel(self.device_name) - self.main_layout.addWidget(label) - self.main_layout.addStretch() - self._spinner = SpinnerWidget(parent=self) - self._spinner.speed = 80 - self._spinner.setFixedSize(24, 24) - self.main_layout.addWidget(self._spinner) - self._base_style = "font-weight: bold;" - self.setStyleSheet(self._base_style) - self._start_spinner() - - def _start_spinner(self): - """Start the spinner animation.""" - self._spinner.start() - - def _stop_spinner(self): - """Stop the spinner animation.""" - self._spinner.stop() - self._spinner.setVisible(False) - - @SafeSlot() - def on_validation_restart(self): - """Handle validation restart.""" - self.validation_msg = "" - self._start_spinner() - self.setStyleSheet("") # Check if this works as expected - - @SafeSlot(str) - def on_validation_failed(self, error_msg: str): - """Handle validation failure.""" - self.validation_msg = error_msg - colors = get_accent_colors() - self._stop_spinner() - self.main_layout.removeWidget(self._spinner) - self._spinner.deleteLater() - label = QtWidgets.QLabel("") - icon = material_icon("error", color=colors.emergency, size=(24, 24)) - label.setPixmap(icon) - self.main_layout.addWidget(label) - - -class DMOphydTest(BECWidget, QtWidgets.QWidget): - """Widget to test device configurations using ophyd devices.""" - - # Signal to emit the validation status of a device - device_validated = QtCore.Signal(str, int) - # validation_msg in markdown format - validation_msg_md = QtCore.Signal(str) - - def __init__(self, parent=None, client=None): - super().__init__(parent=parent, client=client) - if not READY_TO_TEST: - self.setDisabled(True) - self.tester = None - else: - self.tester = DeviceTester({}) - self.tester.signals.device_validated.connect(self._on_device_validated) - QtCore.QThreadPool.globalInstance().start(self.tester) - self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} - # TODO Consider using the thread pool from BECConnector instead of fetching the global instance! - self._thread_pool = QtCore.QThreadPool.globalInstance() - - self._main_layout = QtWidgets.QVBoxLayout(self) - self._main_layout.setContentsMargins(0, 0, 0, 0) - self._main_layout.setSpacing(0) - - # We add a splitter between the list and the text box - self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) - self._main_layout.addWidget(self.splitter) - - self._setup_list_ui() - - def _setup_list_ui(self): - """Setup the list UI.""" - self._list_widget = QtWidgets.QListWidget(self) - self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.splitter.addWidget(self._list_widget) - # Connect signals - self._list_widget.currentItemChanged.connect(self._on_current_item_changed) - - @SafeSlot(list, bool) - @SafeSlot(list, bool, bool) - def change_device_configs( - self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False - ) -> None: - """Receive an update with device configs. - - Args: - device_configs (list[dict[str, Any]]): The updated device configurations. - """ - for cfg in device_configs: - name = cfg.get("name", "") - if added: - if name in self._device_list_items: - continue - if self.tester: - self._add_device(name, cfg) - self.tester.submit([(name, cfg, connect)]) - continue - if name not in self._device_list_items: - continue - self._remove_list_item(name) - - def _add_device(self, name, cfg): - item = QtWidgets.QListWidgetItem(self._list_widget) - widget = ValidationListItem(device_name=name, device_config=cfg) - - # wrap it in a QListWidgetItem - item.setSizeHint(widget.sizeHint()) - self._list_widget.addItem(item) - self._list_widget.setItemWidget(item, widget) - self._device_list_items[name] = item - - def _remove_list_item(self, device_name: str): - """Remove a device from the list.""" - # Get the list item - item = self._device_list_items.pop(device_name) - - # Retrieve the custom widget attached to the item - widget = self._list_widget.itemWidget(item) - if widget is not None: - widget.deleteLater() # clean up custom widget - - # Remove the item from the QListWidget - row = self._list_widget.row(item) - self._list_widget.takeItem(row) - - @SafeSlot(str, bool, str) - def _on_device_validated(self, device_name: str, success: bool, message: str): - """Handle the device validation result. - - Args: - device_name (str): The name of the device. - success (bool): Whether the validation was successful. - message (str): The validation message. - """ - logger.info(f"Device {device_name} validation result: {success}, message: {message}") - item = self._device_list_items.get(device_name, None) - if not item: - logger.error(f"Device {device_name} not found in the list.") - return - if success: - self._remove_list_item(device_name=device_name) - self.device_validated.emit(device_name, ValidationStatus.VALID.value) - else: - widget: ValidationListItem = self._list_widget.itemWidget(item) - widget.on_validation_failed(message) - self.device_validated.emit(device_name, ValidationStatus.FAILED.value) - - def _on_current_item_changed( - self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem - ): - """Handle the current item change in the list widget. - - Args: - current (QListWidgetItem): The currently selected item. - previous (QListWidgetItem): The previously selected item. - """ - widget: ValidationListItem = self._list_widget.itemWidget(current) - if widget: - try: - formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg) - self.validation_msg_md.emit(formatted_md) - except Exception as e: - logger.error( - f"##Error formatting validation message for device {widget.device_name}:\n{e}" - ) - self.validation_msg_md.emit(widget.validation_msg) - else: - self.validation_msg_md.emit("") - - def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: - """ - Simple HTML formatting for validation messages, wrapping text naturally. - - Args: - device_name (str): The name of the device. - raw_msg (str): The raw validation message. - """ - if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": - return f"### Validation in progress for {device_name}... \n\n" - - # Regex to capture repeated ERROR patterns - pat = re.compile( - r"ERROR:\s*(?P[^\s]+)\s+" - r"(?Pis not valid|is not connectable|failed):\s*" - r"(?P.*?)(?=ERROR:|$)", - re.DOTALL, - ) - blocks = [] - for m in pat.finditer(raw_msg): - dev = m.group("device") - status = m.group("status") - detail = m.group("detail").strip() - lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] - blocks.append("\n\n".join(lines)) - - # Fallback: If no patterns matched, return the raw message - if not blocks: - return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" - - return "\n\n---\n\n".join(blocks) - - def validation_running(self): - return self._device_list_items != {} - - @SafeSlot() - def clear_list(self): - """Clear the device list.""" - self._thread_pool.clear() - if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish - logger.error("Failed to wait for threads to finish. Removing items from the list.") - self._device_list_items.clear() - self._list_widget.clear() - self.validation_msg_md.emit("") - - def remove_device(self, device_name: str): - """Remove a device from the list.""" - item = self._device_list_items.pop(device_name, None) - if item: - self._list_widget.removeItemWidget(item) - - def cleanup(self): - if self.tester: - self.tester.shutdown_event.set() - return super().cleanup() - - -if __name__ == "__main__": - import sys - - from bec_lib.bec_yaml_loader import yaml_load - - # pylint: disable=ungrouped-imports - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - wid = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(wid) - wid.setLayout(layout) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - device_manager_ophyd_test = DMOphydTest() - try: - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" - config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] - except Exception as e: - logger.error(f"Error loading config: {e}") - import os - - import bec_lib - - config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml") - config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] - - config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) - device_manager_ophyd_test.change_device_configs(config, True, True) - layout.addWidget(device_manager_ophyd_test) - device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") - device_manager_ophyd_test.resize(800, 600) - text_box = QtWidgets.QTextEdit() - text_box.setReadOnly(True) - layout.addWidget(text_box) - device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown) - wid.show() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py new file mode 100644 index 00000000..82993770 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py @@ -0,0 +1,8 @@ +from .ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from .validation_list_item import ValidationButton, ValidationListItem diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py new file mode 100644 index 00000000..9838720d --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -0,0 +1,825 @@ +""" +Module with a test widget that allows to run the ophyd_devices static tests +utilities for a device config test. Results are displayed in two lists (running, completed). +In addition, it allows to configure the test parameters. + +-> Connect: Try to establish a connection to the device +-> Timeout: Timeout for connection attempt. Default here is 5s. +-> Force Connect: To force connection even if already connected. + Mostly relevant for ADBase integrations. +""" + +import queue +import weakref +from typing import Any +from uuid import uuid4 + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_list import BECList +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.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + ValidationButton, + ValidationListItem, + format_error_to_md, + get_validation_icons, +) + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server # type: ignore + import ophyd_devices # type: ignore + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class DeviceTestResult(QtCore.QObject): + """Simple object to inject device validation signal to DeviceTest QRunnable.""" + + # ValidationResult: device_config, config_status, connection_status, error_message + device_validated = QtCore.Signal(dict, int, int, str) + device_validation_started = QtCore.Signal(str) + + +class DeviceTest(QtCore.QRunnable): + """QRunnable to run a device test in the QT thread pool.""" + + def __init__( + self, + device_model: DeviceTestModel, + enable_connect: bool, + force_connect: bool, + timeout: float, + ): + super().__init__() + self.uuid = device_model.uuid + test_config = {device_model.device_name: device_model.device_config} + self.tester = StaticDeviceTest(config_dict=test_config) + self.signals = DeviceTestResult() + self.device_config = device_model.device_config + self.enable_connect = enable_connect + self.force_connect = force_connect + self.timeout = timeout + self._cancelled = False + + def cancel(self): + """Cancel the device test.""" + self._cancelled = True + + def run(self): + """Run the device test.""" + if not READY_TO_TEST: + logger.error("Cannot run device test: dependencies not available.") + return + device_name = self.device_config.get("name", "") + self.signals.device_validation_started.emit(device_name) # Emit started signal + if self._cancelled: + logger.debug("Device test cancelled before start.") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{self.device_config.get('name')} was cancelled by user.", + ) + return + results = self.tester.run_with_list_output( + connect=self.enable_connect, + force_connect=self.force_connect, + timeout_per_device=self.timeout, + ) + if not results: + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "Results from OphydDevices StaticDeviceTest are empty.", + ) + return + try: + config_is_valid = int(results[0].config_is_valid) + connection_status = ( + int(results[0].success) if self.enable_connect else ConnectionStatus.UNKNOWN.value + ) + error_message = results[0].message or "" + self.signals.device_validated.emit( + self.device_config, config_is_valid, connection_status, error_message + ) + except Exception as e: + logger.error(f"Error reading results from device test: {e}") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"Error processing device test results: {e}", + ) + + +class ThreadPoolManager(QtCore.QObject): + """ + Manager wrapping QThreadPool to expose a queue for jobs. + It allows queued jobs to be cancelled if they have not yet started. + + Args: + max_workers (int): Maximum number of concurrent workers. + poll_interval_ms (int): Poll interval in milliseconds to check for new jobs. + """ + + validations_are_running = QtCore.Signal(bool) + device_validation_started = QtCore.Signal(str) + device_validated = QtCore.Signal(dict, int, int, str) + + def __init__(self, parent=None, max_workers: int = 4, poll_interval_ms: int = 100): + super().__init__(parent=parent) + self.pool = QtCore.QThreadPool(parent=parent) + self.pool.setMaxThreadCount(max_workers) + + self._queue = queue.Queue() + self._timer = QtCore.QTimer(parent=parent) + self._timer.timeout.connect(self._process_queue) + self.poll_interval_ms = poll_interval_ms + self._timer.setInterval(self.poll_interval_ms) + self._active_tests: dict[str, weakref.ReferenceType[DeviceTest]] = {} + + def start_polling(self): + """Start the polling timer.""" + if not self._timer.isActive(): + self._timer.start() + + def stop_polling(self): + """Stop the polling timer.""" + if self._timer.isActive(): + self._timer.stop() + + def _emit_device_validation_started(self, device_name: str): + """Emit device validation started signal.""" + self.device_validation_started.emit(device_name) + + def _emit_device_validated( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Emit device validated signal.""" + self.device_validated.emit(device_config, config_status, connection_status, error_message) + + def submit(self, device_name: str, device_test: DeviceTest): + """Queue a job for execution.""" + device_test.signals.device_validation_started.connect(self._emit_device_validation_started) + device_test.signals.device_validated.connect(self._emit_device_validated) + self._queue.put((device_name, device_test)) + + def clear_device_in_queue(self, device_name: str): + """Remove a specific device test from the queue.""" + if device_name in self._active_tests: + try: + ref = self._active_tests.pop(device_name) + obj = ref() + if obj and hasattr(obj, "cancel"): + obj.cancel() + obj.signals.device_validated.disconnect() + except KeyError: + logger.debug(f"Device {device_name} not found in active tests during cancellation.") + return + + with self._queue.mutex: + for name, runnable in self._queue.queue: + if name == device_name: # found the device to remove, discard it + runnable.cancel() + runnable.signals.device_validated.disconnect() + self._queue.queue = queue.deque( + item for item in self._queue.queue if item[0] != device_name + ) + break + + def clear_queue(self): + """Remove all queued (not yet started) jobs.""" + running = self.get_active_tests() + scheduled = self.get_scheduled_tests() + for device_name in running + scheduled: + self.clear_device_in_queue(device_name) + + def get_active_tests(self) -> list[str]: + """Return a list of currently active test device names.""" + return list(self._active_tests.keys()) + + def get_scheduled_tests(self) -> list[str]: + """Return a list of currently scheduled (queued) test device names.""" + with self._queue.mutex: + return [device_name for device_name, _ in list(self._queue.queue)] + + def _process_queue(self): + """Start new jobs if there is capacity. Runs with specified poll interval.""" + while not self._queue.empty() and len(self._active_tests) < self.pool.maxThreadCount(): + device_name, runnable = self._queue.get() + runnable.signals.device_validated.connect(self._on_task_finished) + self._active_tests[device_name] = weakref.ref(runnable) + self.pool.start(runnable) + self.validations_are_running.emit(len(self._active_tests) > 0) + + @SafeSlot(dict, int, int, str) + def _on_task_finished( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Handle task finished signal to update active thread count.""" + device_name = device_config.get("name", None) + if device_name: + self._active_tests.pop(device_name, None) + + +class LegendLabel(QtWidgets.QWidget): + """Wrapper widget for legend labels with icon and text for OphydValidation.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(18, 18), convert_to_pixmap=False + ) + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(8) + + # Config Status Legend + config_legend = QtWidgets.QLabel("Config Legend:") + layout.addWidget(config_legend, 0, 0) + for ii, status in enumerate( + [ConfigStatus.UNKNOWN, ConfigStatus.INVALID, ConfigStatus.VALID] + ): + icon = self._icons["config_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(False) + icon_widget.set_enabled_style(False) + icon_widget.setToolTip(f"Device Configuration: {status.description()}") + layout.addWidget(icon_widget, 0, ii + 1) + + # Connection Status Legend + connection_status_legend = QtWidgets.QLabel("Connect Legend:") + layout.addWidget(connection_status_legend, 1, 0) + for ii, status in enumerate( + [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ConnectionStatus.CAN_CONNECT, + ConnectionStatus.CONNECTED, + ] + ): + icon = self._icons["connection_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(False) + icon_widget.set_enabled_style(False) + icon_widget.setToolTip(f"Connection Status: {status.description()}") + layout.addWidget(icon_widget, 1, ii + 1) + layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column + + +class OphydValidation(BECWidget, QtWidgets.QWidget): + """ + Widget to manage and run ophyd device tests. + + Args: + parent (QWidget, optional): Parent widget. Defaults to None. + client (BECClient, optional): BEC client instance. Defaults to None. + hide_legend (bool, optional): Whether to hide the legend. Defaults to False. + """ + + RPC = False + + # ValidationResult: device_config, config_status, connection_status, error_message + validation_completed = QtCore.Signal(dict, int, int, str) + # ValidationResult: device_name, config_status, connection_status, error_message, formatted_error_message + item_clicked = QtCore.Signal(str, int, int, str, str) + # Signal to indicate if validations are currently running + validations_are_running = QtCore.Signal(bool) + # Signal to emit list of ValidationResults (device_config, config_status, connection_status, error_message) at once + multiple_validations_completed = QtCore.Signal(list) + + def __init__(self, parent=None, client=None, hide_legend: bool = False): + super().__init__(parent=parent, client=client, theme_update=True) + self._running_ophyd_tests = False + if not READY_TO_TEST: + self.setDisabled(True) + self.thread_pool_manager = None + else: + self.thread_pool_manager = ThreadPoolManager(parent=self, max_workers=4) + self.thread_pool_manager.validations_are_running.connect(self._set_running_ophyd_tests) + self.thread_pool_manager.device_validated.connect(self._on_device_test_completed) + self.thread_pool_manager.device_validation_started.connect( + self._trigger_validation_started + ) + + self._validation_icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(32, 32), convert_to_pixmap=False + ) + + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(4) + self._colors = get_accent_colors() + + # Setup main UI + self.list_widget = self._create_list_widget_with_label("Running & Failed Validations") + if not hide_legend: + legend_widget = LegendLabel(parent=self) + self._main_layout.addWidget(legend_widget) + self._thread_pool_poll_loop() + + def apply_theme(self, theme: str): + """Apply the current theme to the widget.""" + self._colors = get_accent_colors() + # TODO consider removing as accent colors are the same across themes, or am I wrong? + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + + def _thread_pool_poll_loop(self): + """Start the thread pool polling loop.""" + if self.thread_pool_manager: + self.thread_pool_manager.start_polling() + + def _create_list_widget_with_label(self, label_text: str) -> BECList: + """Setup the running validations section.""" + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Section title + title_layout = QtWidgets.QHBoxLayout() + title_layout.setContentsMargins(0, 0, 0, 0) + title_label = QtWidgets.QLabel(label_text) + title_label.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + status_label = QtWidgets.QLabel("Config | Connect") + status_label.setStyleSheet("font-weight: bold; font-size: 9px; padding: 2px;") + title_layout.addWidget(title_label) + title_layout.addStretch(1) + title_layout.addWidget(status_label) + layout.addLayout(title_layout) + + # Separator line + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + layout.addWidget(separator) + + # List widget for running validations + list_w = BECList(parent=self) + list_w.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + list_w.itemClicked.connect(self._on_item_clicked) + list_w.currentItemChanged.connect(self._on_current_item_changed) + layout.addWidget(list_w) + + # Stop Running validation button + self._stop_validation_button = QtWidgets.QPushButton("Stop Running Validations") + self._stop_validation_button.clicked.connect(self.cancel_all_validations) + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + self._stop_validation_button.setVisible(False) + layout.addWidget(self._stop_validation_button) + self.validations_are_running.connect(self._stop_validation_button.setVisible) + self._main_layout.addWidget(widget) + + return list_w + + ########################## + ### Event Handlers + ########################## + + @SafeSlot(bool) + def _set_running_ophyd_tests(self, running: bool): + """Set the running ophyd tests state.""" + self.running_ophyd_tests = running + + @SafeSlot(QtWidgets.QListWidgetItem, QtWidgets.QListWidgetItem) + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle current item changed.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(current) + if widget: + self._emit_item_clicked(widget) + + @SafeSlot(QtWidgets.QListWidgetItem) + def _on_item_clicked(self, item: QtWidgets.QListWidgetItem): + """Handle click on running item.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(item) + if widget: + self._emit_item_clicked(widget) + + def _emit_item_clicked(self, widget: ValidationListItem): + format_error_msg = format_error_to_md( + widget.device_model.device_name, widget.device_model.validation_msg + ) + self.item_clicked.emit( + widget.device_model.device_name, + widget.device_model.config_status, + widget.device_model.connection_status, + widget.device_model.validation_msg, + format_error_msg, + ) + + ########################### + ### Properties + ########################### + + @SafeProperty(bool, notify=validations_are_running) + # pylint: disable=method-hidden + def running_ophyd_tests(self) -> bool: + """Indicates if validations are currently running.""" + return self._running_ophyd_tests + + @running_ophyd_tests.setter + def running_ophyd_tests(self, value: bool) -> None: + if self._running_ophyd_tests != value: + self._running_ophyd_tests = value + self.validations_are_running.emit(value) + + ########################### + ### Public Methods + ########################### + + @SafeSlot() + def clear_all(self): + """Clear all running and failed validations.""" + self.thread_pool_manager.clear_queue() + self.list_widget.clear_widgets() + + def get_device_configs(self) -> list[dict[str, Any]]: + """ + Get the current device configurations being tested. + + Returns: + list[dict[str, Any]]: List of device configurations. + """ + widgets: list[ValidationListItem] = self.list_widget.get_widgets() + return [widget.device_model.device_config for widget in widgets] + + @SafeSlot(list, bool) + @SafeSlot(list, bool, bool) + @SafeSlot(list, bool, bool, bool, float) + def change_device_configs( + self, + device_configs: list[dict[str, Any]], + added: bool, + connect: bool = False, + force_connect: bool = False, + timeout: float = 5.0, + ) -> None: + """ + Change the device configuration to test. If added is False, existing devices are removed. + Device tests will be removed based on device names. No duplicates are allowed. + + Args: + device_configs (list[dict[str, Any]]): List of device configurations. + added (bool): Whether the devices are added to the existing list. + connect (bool, optional): Whether to attempt connection during validation. Defaults to False. + force_connect (bool, optional): Whether to force connection during validation. Defaults to False. + timeout (float, optional): Timeout for connection attempt. Defaults to 5.0. + """ + if not READY_TO_TEST: + logger.error("Cannot change device configs: dependencies not available.") + return + # Track all devices that are already in the running session from the + # config updates to avoid sending multiple single device validation signals. + # Sending successive single updates may affect the UI performance on the receiving end. + devices_already_in_session = [] + for cfg in device_configs: + device_name = cfg.get("name", None) + if device_name is None: # Config missing name, will be skipped.. + logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.") + continue + if not added: # Remove requested + self._remove_device_config(cfg) + continue + if self._is_device_in_redis_session(cfg.get("name"), cfg): + logger.debug( + f"Device {device_name} already in running session with same config. Skipping." + ) + devices_already_in_session.append( + ( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) + ) + self._remove_device_config(cfg) + continue + if not self._device_already_exists(cfg.get("name")): # New device case + self._add_device_config( + cfg, connect=connect, force_connect=force_connect, timeout=timeout + ) + else: # Update existing, but removing first + logger.info(f"Device {cfg.get('name')} already exists, re-adding it.") + self._remove_device_config(cfg) + self._add_device_config( + cfg, connect=connect, force_connect=force_connect, timeout=timeout + ) + # Send out batch of updates for devices already in session + if devices_already_in_session: + self.multiple_validations_completed.emit(devices_already_in_session) + + def cancel_validation(self, device_name: str) -> None: + """Cancel a running validation for a specific device. + + Args: + device_name (str): Name of the device to cancel validation for. + """ + if not READY_TO_TEST: + logger.error("Cannot cancel validation: dependencies not available.") + return + if self.thread_pool_manager: + self.thread_pool_manager.clear_device_in_queue(device_name) + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + self._on_device_test_completed( + widget.device_model.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{widget.device_model.device_name} was cancelled by user.", + ) + + def cancel_all_validations(self) -> None: + """Cancel all running validations.""" + if not READY_TO_TEST: + logger.error("Cannot cancel validations: dependencies not available.") + return + running = self.thread_pool_manager.get_active_tests() + scheduled = self.thread_pool_manager.get_scheduled_tests() + for device_name in running + scheduled: + self.cancel_validation(device_name) + + ################# + ### Private methods + ################# + + def _device_already_exists(self, device_name: str) -> bool: + return device_name in self.list_widget + + def _add_device_config( + self, device_config: dict[str, Any], connect: bool, force_connect: bool, timeout: float + ) -> None: + device_name = device_config.get("name") + # Check if device is in redis session with same config, if yes don't even bother testing.. + device_test_model = DeviceTestModel( + uuid=f"device_test_{device_name}_uuid_{uuid4()}", + device_name=device_name, + device_config=device_config, + ) + + widget = ValidationListItem( + parent=self, device_model=device_test_model, validation_icons=self._validation_icons + ) + widget.request_rerun_validation.connect(self._on_request_rerun_validation) + self.list_widget.add_widget_item(device_name, widget) + self.__delayed_submit_test(widget, connect, force_connect, timeout) + + def _remove_device_config(self, device_config: dict[str, Any]) -> None: + device_name = device_config.get("name") + if not device_name: + logger.error(f"Device config missing 'name': {device_config}. Cannot remove device.") + return + if not self._device_already_exists(device_name): + logger.debug( + f"Device with name {device_name} not found in OphydValidation, can't remove it." + ) + return + if self.thread_pool_manager: + self.thread_pool_manager.clear_device_in_queue(device_name) + self.list_widget.remove_widget_item(device_name) + + @SafeSlot(str, dict, bool, bool, float) + def _on_request_rerun_validation( + self, + device_name: str, + device_config: dict[str, Any], + connect: bool, + force_connect: bool, + timeout: float, + ) -> None: + """Handle request to re-run validation for a device.""" + if not self._device_already_exists(device_name): + logger.debug( + f"Device with name {device_name} not found in OphydValidation, can't re-run." + ) + return + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget and not widget.is_running: + self.__delayed_submit_test(widget, connect, force_connect, timeout) + else: + logger.debug(f"Device {device_name} is already running validation, cannot re-run.") + + def _emit_device_in_redis_session(self, device_config: dict) -> None: + self.validation_completed.emit( + device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{device_config.get('name')} is OK. Already loaded in running session.", + ) + + def __delayed_submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Delayed submission of device test to ensure UI updates.""" + QtCore.QTimer.singleShot( + 0, lambda: self._submit_test(widget, connect, force_connect, timeout) + ) + + def _submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Submit a device test to the thread pool.""" + if not READY_TO_TEST or StaticDeviceTest is None: + logger.error("Cannot submit device test: dependencies not available.") + return + # Check if device is already in redis session with same config + if self._is_device_in_redis_session( + widget.device_model.device_name, widget.device_model.device_config + ): + logger.info( + f"Device {widget.device_model.device_name} already in running session with same config. " + "Skipping validation." + ) + self.validation_completed.emit( + widget.device_model.device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{widget.device_model.device_name} is OK. Already loaded in running session.", + ) + # Remove widget from list as it's safe to assume it can be loaded. + self._remove_device_config(widget.device_model.device_config) + return + runnable = DeviceTest( + device_model=widget.device_model, + enable_connect=connect, + force_connect=force_connect, + timeout=timeout, + ) + widget.validation_scheduled() + if self.thread_pool_manager: + self.thread_pool_manager.submit(widget.device_model.device_name, runnable) + + def _trigger_validation_started(self, device_name: str) -> None: + """Trigger validation started for a specific device.""" + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + widget.validation_started() + + def _on_device_test_completed( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ) -> None: + """Handle device test completion.""" + device_name = device_config.get("name") + if not self._device_already_exists(device_name): + logger.debug(f"Received test result for unknown device {device_name}. Ignoring.") + return + if config_status == ConfigStatus.VALID.value and connection_status in [ + ConnectionStatus.CONNECTED.value, + ConnectionStatus.CAN_CONNECT.value, + ]: + # Validated successfully, remove item from running list + self.list_widget.remove_widget_item(device_name) + self.validation_completed.emit( + device_config, config_status, connection_status, error_message + ) + return + widget = self.list_widget.get_widget(device_name) + if widget: + widget.on_validation_finished( + validation_msg=error_message, + config_status=config_status, + connection_status=connection_status, + ) + self.validation_completed.emit( + device_config, config_status, connection_status, error_message + ) + + def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool: + """Check if a device is in the running section.""" + dev_obj = self.client.device_manager.devices.get(device_name, None) + if dev_obj is None or dev_obj.enabled is False: + return False + return self._compare_device_configs(dev_obj._config, device_config) + + def _compare_device_configs(self, config1: dict, config2: dict) -> bool: + """Compare two device configurations through the Device model in bec_lib.atlas_models. + + Args: + config1 (dict): The first device configuration. + config2 (dict): The second device configuration. + + Returns: + bool: True if the configurations are equivalent, False otherwise. + """ + try: + model1 = DeviceModel.model_validate(config1) + model2 = DeviceModel.model_validate(config2) + return model1 == model2 + except Exception: + return False + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import os + import random + + import bec_lib + from bec_lib.bec_yaml_loader import yaml_load + from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path + from bec_qthemes import apply_theme + + apply_theme("light") + # Main widget + wid = QtWidgets.QWidget() + w_layout = QtWidgets.QVBoxLayout(wid) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(0) + wid.setLayout(w_layout) + # Check if plugin is installed + + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + cfgs = [""] + cfgs.extend([os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")]) + if plugin_path: + print(f"Adding configs from plugin {plugin_name} at {plugin_path}") + cfg_base_path = os.path.join(plugin_path, plugin_name, "device_configs") + config_files = os.listdir(cfg_base_path) + cfgs.extend( + [os.path.join(cfg_base_path, f) for f in config_files if f.endswith((".yaml", ".yml"))] + ) + + combo_box_configs = QtWidgets.QComboBox() + combo_box_configs.addItems(cfgs) + combo_box_configs.setCurrentIndex(0) + + but_layout = QtWidgets.QHBoxLayout() + but_layout.addWidget(combo_box_configs) + button_reset = QtWidgets.QPushButton("Clear All") + but_layout.addWidget(button_reset) + button_clear_random = QtWidgets.QPushButton("Clear random amount") + but_layout.addWidget(button_clear_random) + w_layout.addLayout(but_layout) + + def _load_config(config_path: str): + current_config = device_manager_ophyd_test.get_device_configs() + device_manager_ophyd_test.change_device_configs(current_config, False) + if not config_path: # empty escape + return + try: + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, False, False, 2.0) + except Exception as e: + logger.error(f"Error loading config {config_path}: {e}") + + def _clear_random_entries(): + current_config = device_manager_ophyd_test.get_device_configs() + n_remove = random.randint(1, len(current_config)) + to_remove = random.sample(current_config, n_remove) + device_manager_ophyd_test.change_device_configs(to_remove, False) + + device_manager_ophyd_test = OphydValidation() + button_reset.clicked.connect(device_manager_ophyd_test.clear_all) + combo_box_configs.currentTextChanged.connect(_load_config) + button_clear_random.clicked.connect(_clear_random_entries) + + w_layout.addWidget(device_manager_ophyd_test) + + # Add text box for results + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + w_layout.addWidget(text_box) + + def _validation_callback( + device_name: str, + config_status: int, + connection_status: int, + error_message: str, + formatted_error_message: str, + ): # type: ignore + text_box.setMarkdown(formatted_error_message) + + device_manager_ophyd_test.item_clicked.connect(_validation_callback) + wid.resize(600, 1000) + wid.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py new file mode 100644 index 00000000..8c4cdf32 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py @@ -0,0 +1,171 @@ +import re +from enum import IntEnum +from functools import partial +from typing import Any, Literal + +from bec_qthemes import material_icon +from pydantic import BaseModel, Field +from qtpy import QtGui + +from bec_widgets.utils.colors import AccentColors + + +def format_error_to_md(device_name: str, raw_msg: str) -> str: + """ + Method to format a raw validation method into markdown for display. + The recognized patterns are: + - "'DEVICE_NAME' is OK. DETAIL" + - "ERROR: 'DEVICE_NAME' is not valid: DETAIL" + - "ERROR: 'DEVICE_NAME' is not connectable: DETAIL" + - "ERROR: 'DEVICE_NAME' failed: DETAIL" + If no patterns matched, the raw message is returned as a code block. + + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + + Returns: + str: The formatted markdown message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" + + # Regex to catch OK pattern + ok_pat = re.compile(r"(?P\S+)\s+is\s+OK\.?(?:\s*(?P.*))?$", re.IGNORECASE) + ok_match = ok_pat.search(raw_msg) + if ok_match: + device = ok_match.group("device") + detail = ok_match.group("detail").strip(".").strip() + return f"## Validation Success for {device}\n```\n{detail}\n```" + + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, + ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) + + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" + + return "\n\n---\n\n".join(blocks) + + +############################ +### Status Enums +############################ + + +class ConfigStatus(IntEnum): + """Validation status for device config validity. This includes the deviceClass check.""" + + INVALID = 0 + VALID = 1 + UNKNOWN = 2 + + def description(self) -> str: + """Get a human-readable description of the config status. + + Returns: + str: The description of the config status. + """ + descriptions = { + ConfigStatus.INVALID: "Invalid Configuration", + ConfigStatus.VALID: "Valid Configuration", + ConfigStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class ConnectionStatus(IntEnum): + """Connection status for device connectivity.""" + + CANNOT_CONNECT = 0 + CAN_CONNECT = 1 + CONNECTED = 2 + UNKNOWN = 3 + + def description(self) -> str: + """Get a human-readable description of the connection status. + + Returns: + str: The description of the connection status. + """ + descriptions = { + ConnectionStatus.CANNOT_CONNECT: "Cannot Connect", + ConnectionStatus.CAN_CONNECT: "Can Connect", + ConnectionStatus.CONNECTED: "Connected and Loaded", + ConnectionStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class DeviceTestModel(BaseModel): + """Model to hold device test parameters and results.""" + + uuid: str + device_name: str + device_config: dict[str, Any] + config_status: int = Field( + default=ConfigStatus.UNKNOWN.value, + description="Validation status of the device configuration.", + ) + connection_status: int = Field( + default=ConnectionStatus.UNKNOWN.value, description="Connection status of the device." + ) + validation_msg: str = Field(default="", description="Message from the last validation attempt.") + + +def get_validation_icons( + colors: AccentColors, icon_size: tuple[int, int], convert_to_pixmap: bool = False +) -> dict[Literal["config_status", "connection_status"], dict[int, QtGui.QPixmap | QtGui.QIcon]]: + """Get icons for validation statuses for ConfigStatus and ConnectionStatus. + + Args: + colors (AccentColors): The accent colors to use for the icons. + icon_size (tuple[int, int]): The size of the icons. + convert_to_pixmap (bool, optional): Whether to convert icons to pixmaps. Defaults to False. + + Returns: + dict: A dictionary with icons for config and connection statuses. + """ + material_icon_partial = partial( + material_icon, size=icon_size, convert_to_pixmap=convert_to_pixmap + ) + icons = { + "config_status": { + ConfigStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConfigStatus.VALID.value: material_icon_partial( + icon_name="check_circle", color=colors.success + ), + ConfigStatus.INVALID.value: material_icon_partial( + icon_name="error", color=colors.emergency + ), + }, + "connection_status": { + ConnectionStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConnectionStatus.CANNOT_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.emergency + ), + ConnectionStatus.CAN_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.success + ), + ConnectionStatus.CONNECTED.value: material_icon_partial( + icon_name="cast_connected", color=colors.success + ), + }, + } + return icons diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py new file mode 100644 index 00000000..3fe84668 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py @@ -0,0 +1,391 @@ +"""Module with validation items and a validation button for device testing UI.""" + +from typing import Literal + +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + get_validation_icons, +) +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +logger = bec_logger.logger + + +class ValidationButton(QtWidgets.QPushButton): + """ + Validation button with flat style and disabled appearance. + + Args: + parent (QtWidgets.QWidget | None): Parent widget. + icon (QtGui.QIcon | None): Icon to display on the button. + """ + + def __init__( + self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None + ) -> None: + super().__init__(parent=parent) + self.transparent_style = "background-color: transparent; border: none;" + if icon: + self.setIcon(icon) + self.setFlat(True) + self.setEnabled(True) + + def setEnabled(self, enabled: bool) -> None: + self.set_enabled_style(enabled) + return super().setEnabled(enabled) + + def set_enabled_style(self, enabled: bool) -> None: + """Set the enabled state of the button with style update. + + Args: + enabled (bool): Whether the button should be enabled. + """ + if enabled: + self.setStyleSheet("") + else: + self.setStyleSheet(self.transparent_style) + + +class ValidationDialog(QtWidgets.QDialog): + """ + Dialog to confirm re-validation with optional parameters. Once accepted, + the settings timeout, connect and force_connect can be retrieved through .result(). + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. + timeout (float, optional): The timeout for the validation. + connect (bool, optional): Whether to attempt connection during validation. + force_connect (bool, optional): Whether to force connection during validation. + """ + + def __init__( + self, parent=None, timeout: float = 5.0, connect: bool = False, force_connect: bool = False + ): + super().__init__(parent) + + self._result: tuple[float, bool, bool] = (timeout, connect, force_connect) + # Setup Dialog UI + self.setWindowTitle("Run Validation") + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + # label + self.label = QtWidgets.QLabel( + "Do you want to re-run validation with the following options?" + ) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + # Setup options (note timeout will be simplified to int) + option_layout = QtWidgets.QVBoxLayout() + option_layout.setSpacing(16) + option_layout.setContentsMargins(0, 0, 0, 0) + + # Timeout + timeout_layout = QtWidgets.QHBoxLayout() + label_timeout = QtWidgets.QLabel("Timeout(s):") + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setRange(1, 300) + self.timeout_spin.setValue(int(timeout)) + timeout_layout.addWidget(label_timeout) + timeout_layout.addWidget(self.timeout_spin) + + # Connect checkbox + self.connect_checkbox = QtWidgets.QCheckBox("Test Connection") + self.connect_checkbox.setChecked(connect) + + # Force Connect checkbox + self.force_connect_checkbox = QtWidgets.QCheckBox("Force Connect") + self.force_connect_checkbox.setChecked(force_connect) + if self.connect_checkbox.isChecked() is False: + self.force_connect_checkbox.setEnabled(False) + # Deactivated if connect is unchecked + self.connect_checkbox.stateChanged.connect(self.force_connect_checkbox.setEnabled) + + # Add widgets to layout + option_layout.addLayout(timeout_layout) + option_layout.addWidget(self.connect_checkbox) + option_layout.addWidget(self.force_connect_checkbox) + layout.addLayout(option_layout) + + # Dialog Buttons: equal size, stacked horizontally + self.button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + self.adjustSize() + + def accept(self): + """Process the dialog acceptance and store the result.""" + self._result = ( + float(self.timeout_spin.value()), + self.connect_checkbox.isChecked(), + self.force_connect_checkbox.isChecked(), + ) + super().accept() + + def result(self): + return self._result + + +class ValidationListItem(QtWidgets.QWidget): + """List item to display device test validation status.""" + + request_rerun_validation = QtCore.Signal(str, dict, bool, bool, float) + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + device_model: DeviceTestModel | None = None, + validation_icons: ( + dict[Literal["config_status", "connection_status"], dict[int, QtGui.QIcon]] | None + ) = None, + icon_size: tuple[int, int] = (32, 32), + ) -> None: + super().__init__(parent=parent) + if device_model is None: + logger.debug("No device config provided to ValidationListItem.") + return + self.device_model: DeviceTestModel = device_model + self.is_running: bool = False + self._colors = get_accent_colors() + self._icon_size = icon_size + self._validation_icons = validation_icons or get_validation_icons( + colors=self._colors, icon_size=self._icon_size, convert_to_pixmap=False + ) + + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self._setup_ui() + + ###################### + ### UI Setup Methods + ###################### + + def _setup_ui(self) -> None: + """Setup the UI elements of the widget.""" + # Device Name Label + label = QtWidgets.QLabel(self.device_model.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + + # Spinner + self._spinner = SpinnerWidget() + self._spinner.speed = 80 + self._spinner.setFixedSize(self._icon_size[0] // 1.5, self._icon_size[1] // 1.5) + self._spinner.setVisible(False) + + # Add to button layout + button_layout.addWidget(self._spinner) + + # Config Status Icon + self.status_button = ValidationButton( + icon=self._validation_icons["config_status"][self.device_model.config_status] + ) + self.status_button.setToolTip("Configuration Status") + self.status_button.clicked.connect(self._on_status_button_clicked) + button_layout.addWidget(self.status_button) + + # Connection Status Icon + self.connection_button = ValidationButton( + icon=self._validation_icons["connection_status"][self.device_model.connection_status] + ) + self.connection_button.setToolTip("Connection Status") + self.connection_button.clicked.connect(self._on_connection_button_clicked) + button_layout.addWidget(self.connection_button) + self.main_layout.addLayout(button_layout) + + ####################### + ### Event Handlers + ####################### + + def _on_status_button_clicked(self) -> None: + """Handle status button click event.""" + timeout, connect, force_connect = 5, False, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + def _on_connection_button_clicked(self) -> None: + """Handle connection button click event.""" + timeout, connect, force_connect = 5, True, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + ######################### + ### Helper Methods + ######################### + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + def _create_validation_dialog_box( + self, timeout: float, connect: bool, force_connect: bool + ) -> QtWidgets.QDialog: + """Create a dialog box to confirm re-validation.""" + return ValidationDialog( + parent=self, timeout=timeout, connect=connect, force_connect=force_connect + ) + + def _update_validation_status( + self, validation_msg: str, config_status: int, connection_status: int + ): + """ + Update the validation status icons and message. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + # Update device config model + self.device_model.validation_msg = validation_msg + self.device_model.config_status = ConfigStatus(config_status).value + self.device_model.connection_status = ConnectionStatus(connection_status).value + + # Update icons + self.status_button.setIcon( + self._validation_icons["config_status"][self.device_model.config_status] + ) + self.connection_button.setIcon( + self._validation_icons["connection_status"][self.device_model.connection_status] + ) + + ########################## + ### Public Methods + ########################## + + @SafeSlot(str, int, int) + def on_validation_finished( + self, validation_msg: str, config_status: int, connection_status: int + ): + """Handle validation finished event. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.is_running = False + self._stop_spinner() + self._update_validation_status(validation_msg, config_status, connection_status) + + # Enable/disable buttons based on status + config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID] + self.status_button.setEnabled(config_but_en) + self.status_button.set_enabled_style(config_but_en) + connect_but_en = connection_status in [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ] + self.connection_button.setEnabled(connect_but_en) + self.connection_button.set_enabled_style(connect_but_en) + + @SafeSlot() + def validation_scheduled(self): + """Handle validation scheduled event.""" + self._update_validation_status( + "Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + self.status_button.setEnabled(False) + self.status_button.set_enabled_style(False) + self.connection_button.setEnabled(False) + self.connection_button.set_enabled_style(False) + self._spinner.setVisible(True) + + @SafeSlot() + def validation_started(self): + """Start validation process.""" + self.is_running = True + self._start_spinner() + self._update_validation_status( + "Validation running...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + + @SafeSlot() + def start_validation(self): + """Start validation process.""" + self.validation_scheduled() + self.validation_started() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("dark") + w = QtWidgets.QWidget() + l = QtWidgets.QVBoxLayout(w) + + # Example device model + device_model = DeviceTestModel( + uuid="1234", + device_name="Test Device", + device_config={"param1": "value1"}, + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, + validation_msg="Initial validation failed.", + ) + + # Create validation list item + validation_item = ValidationListItem(parent=w, device_model=device_model) + l.addWidget(validation_item) + + but = QtWidgets.QPushButton("Start Validation") + but2 = QtWidgets.QPushButton("Finish Validation") + but.clicked.connect(validation_item.start_validation) + but2.clicked.connect( + lambda: validation_item.on_validation_finished( + "Validation successful.", + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ) + ) + l.addWidget(but) + l.addWidget(but2) + + def _print_callback(name, cfg, conn, force, to): + print( + f"Re-run validation requested for dev {name} for config {cfg} with timeout={to}, connect={conn}, force={force}" + ) + + validation_item.request_rerun_validation.connect(_print_callback) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/device_manager.py b/bec_widgets/widgets/control/device_manager/device_manager.py deleted file mode 100644 index 04178cae..00000000 --- a/bec_widgets/widgets/control/device_manager/device_manager.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -This module provides an implementation for the device config view. -The widget is the entry point for users to edit device configurations. -""" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 47a7a1c7..6f81a4cf 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -15,6 +15,10 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module from bec_widgets.utils import error_popups +# Patch to set default RAISE_ERROR_DEFAULT to True for tests +# This means that by default, error popups will raise exceptions during tests +# error_popups.RAISE_ERROR_DEFAULT = True + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index b4454cfd..9964a396 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -1,67 +1,90 @@ """Unit tests for device_manager_components module.""" +from threading import Event +from typing import Generator from unittest import mock import pytest import yaml from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import ( + OPHYD_DEVICE_TEMPLATES, + EpicsMotorDeviceConfigTemplate, +) +from ophyd_devices.utils.static_device_test import TestResult from qtpy import QtCore, QtGui, QtWidgets +from bec_widgets.utils.bec_list import BECList +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.device_manager import DeviceTable, DMConfigView, DocstringView +from bec_widgets.widgets.control.device_manager.components import docstring_to_markdown from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD -from bec_widgets.widgets.control.device_manager.components.device_table_view import ( - USER_CHECK_DATA_ROLE, - BECTableView, - CenterCheckBoxDelegate, - CustomDisplayDelegate, - DeviceFilterProxyModel, - DeviceTableModel, - DeviceTableView, - DeviceValidatedDelegate, - DictToolTipDelegate, - WrappingTextDelegate, +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, ) -from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView -from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import ( - DocstringView, - docstring_to_markdown, +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, + _try_literal_eval, ) -from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus +from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + DeviceTest, + LegendLabel, + OphydValidation, + ThreadPoolManager, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.validation_list_item import ( + ValidationButton, + ValidationDialog, + ValidationListItem, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch -### Constants #### -def test_constants_headers_help_md(): - """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" - assert isinstance(HEADERS_HELP_MD, dict) - expected_keys = { - "status", - "name", - "deviceClass", - "readoutPriority", - "deviceTags", - "enabled", - "readOnly", - "onFailure", - "softwareTrigger", - "description", - } - assert set(HEADERS_HELP_MD.keys()) == expected_keys - for _, value in HEADERS_HELP_MD.items(): - assert isinstance(value, str) - assert value.startswith("## ") # Each entry should start with a markdown header - - -### DM Docstring View #### - - -@pytest.fixture -def docstring_view(qtbot): - """Fixture to create a DocstringView instance.""" - view = DocstringView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view +class TestConstants: + """Test class for constants and configuration values.""" + + def test_headers_help_md(self): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "valid", + "connect", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value["long"], str) + assert isinstance(value["short"], str) + assert value["long"].startswith("## ") # Each entry should start with a markdown header +# Test utility classes for docstring testing class NumPyStyleClass: """Perform simple signal operations. @@ -97,773 +120,1440 @@ class GoogleStyleClass: """ -def test_docstring_view_docstring_to_markdown(): - """Test the docstring_to_markdown function with a sample class.""" - numpy_md = docstring_to_markdown(NumPyStyleClass) - assert "# NumPyStyleClass" in numpy_md - assert "### Parameters" in numpy_md - assert "### Attributes" in numpy_md - assert "### Returns" in numpy_md - assert "```" in numpy_md # Check for code block formatting +class TestDocstringView: + """Test class for DocstringView component.""" - google_md = docstring_to_markdown(GoogleStyleClass) - assert "# GoogleStyleClass" in google_md - assert "### Args" in google_md - assert "### Returns" in google_md - assert "### Raises" in google_md - assert "```" in google_md # Check for code block formatting + @pytest.fixture + def docstring_view(self, qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + def test_docstring_to_markdown(self): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting -def test_docstring_view_on_select_config(docstring_view): - """Test the DocstringView on_select_config method. Called with single and multiple devices.""" - with ( - mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, - mock.patch.object(docstring_view, "_set_text") as mock_set_text, - ): - # Test with single device - docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}]) - mock_set_device_class.assert_called_once_with("NumPyStyleClass") + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting - mock_set_device_class.reset_mock() - # Test with multiple devices, should not show anything - docstring_view.on_select_config( - [{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}] - ) - mock_set_device_class.assert_not_called() - mock_set_text.assert_called_once_with("") - - -def test_docstring_view_set_device_class(docstring_view): - """Test the DocstringView set_device_class method with valid and invalid class names.""" - with mock.patch( - "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" - ) as mock_get_plugin_class: - - # Mock a valid class retrieval - mock_get_plugin_class.return_value = NumPyStyleClass - docstring_view.set_device_class("NumPyStyleClass") - assert "NumPyStyleClass" in docstring_view.toPlainText() - assert "Parameters" in docstring_view.toPlainText() - - # Mock an invalid class retrieval - mock_get_plugin_class.side_effect = ImportError("Class not found") - docstring_view.set_device_class("NonExistentClass") - assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() - - # Test if READY_TO_VIEW is False - with mock.patch( - "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", - False, + def test_on_select_config(self, docstring_view: DocstringView): + """Test the on_select_config method with a sample configuration.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, ): - call_count = mock_get_plugin_class.call_count - docstring_view.set_device_class("NumPyStyleClass") # Should do nothing - assert mock_get_plugin_class.call_count == call_count # No new calls made - - -#### DM Config View #### - - -@pytest.fixture -def dm_config_view(qtbot): - """Fixture to create a DMConfigView instance.""" - view = DMConfigView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view - - -def test_dm_config_view_initialization(dm_config_view): - """Test DMConfigView proper initialization.""" - # Check that the stacked layout is set up correctly - assert dm_config_view.stacked_layout is not None - assert dm_config_view.stacked_layout.count() == 2 - # Assert Monaco editor is initialized - assert dm_config_view.monaco_editor.get_language() == "yaml" - assert dm_config_view.monaco_editor.editor._readonly is True - - # Check overlay widget - assert dm_config_view._overlay_widget is not None - assert dm_config_view._overlay_widget.text() == "Select single device to show config" - - # Check that overlay is initially shown - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget - - -def test_dm_config_view_on_select_config(dm_config_view): - """Test DMConfigView on_select_config with empty selection.""" - # Test with empty list of configs - dm_config_view.on_select_config([]) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget - - # Test with a single config - cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] - dm_config_view.on_select_config(cfgs) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor - text = yaml.dump(cfgs[0], default_flow_style=False) - assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") - - # Test with multiple configs - cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] - dm_config_view.on_select_config(cfgs) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget - assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged - - -### Device Table View #### -# Not sure how to nicely test the delegates. - - -@pytest.fixture -def mock_table_view(qtbot): - """Create a mock table view for delegate testing.""" - table = BECTableView() - qtbot.addWidget(table) - qtbot.waitExposed(table) - yield table - - -@pytest.fixture -def device_table_model(qtbot, mock_table_view): - """Fixture to create a DeviceTableModel instance.""" - model = DeviceTableModel(mock_table_view) - yield model - - -@pytest.fixture -def device_proxy_model(qtbot, mock_table_view, device_table_model): - """Fixture to create a DeviceFilterProxyModel instance.""" - model = DeviceFilterProxyModel(mock_table_view) - model.setSourceModel(device_table_model) - mock_table_view.setModel(model) - yield model - - -@pytest.fixture -def qevent_mock() -> QtCore.QEvent: - """Create a mock QEvent for testing.""" - event = mock.MagicMock(spec=QtCore.QEvent) - yield event - - -@pytest.fixture -def view_mock() -> QtWidgets.QAbstractItemView: - """Create a mock QAbstractItemView for testing.""" - view = mock.MagicMock(spec=QtWidgets.QAbstractItemView) - yield view - - -@pytest.fixture -def index_mock(device_proxy_model) -> QtCore.QModelIndex: - """Create a mock QModelIndex for testing.""" - index = mock.MagicMock(spec=QtCore.QModelIndex) - index.model.return_value = device_proxy_model - yield index - - -@pytest.fixture -def option_mock() -> QtWidgets.QStyleOptionViewItem: - """Create a mock QStyleOptionViewItem for testing.""" - option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem) - yield option - - -@pytest.fixture -def painter_mock() -> QtGui.QPainter: - """Create a mock QPainter for testing.""" - painter = mock.MagicMock(spec=QtGui.QPainter) - yield painter - - -def test_tooltip_delegate( - mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model -): - """Test DictToolTipDelegate tooltip generation.""" - # No ToolTip event - delegate = DictToolTipDelegate(mock_table_view) - qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel - # nothing should happen - with mock.patch.object( - QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False - ) as super_mock: - result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) - - super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock) - assert result is False - - # ToolTip event - qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip - qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20)) - - source_model = device_proxy_model.sourceModel() - with ( - mock.patch.object( - source_model, "get_row_data", return_value={"description": "Mock description"} - ), - mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock), - mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock, - ): - result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) - show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock) - assert result is True - - -def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock): - """Test CustomDisplayDelegate initialization.""" - delegate = CustomDisplayDelegate(mock_table_view) - - # Test _test_custom_paint, with None and a value - def _return_data(): - yield None - yield "Test Value" - - proxy_model = index_mock.model() - with ( - mock.patch.object(proxy_model, "data", side_effect=_return_data()), - mock.patch.object( - QtWidgets.QStyledItemDelegate, "paint", return_value=None - ) as super_paint_mock, - mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock, - ): - delegate.paint(painter_mock, option_mock, index_mock) - super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock) - custom_paint_mock.assert_not_called() - # Call again for the value case - delegate.paint(painter_mock, option_mock, index_mock) - super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock) - assert super_paint_mock.call_count == 2 - custom_paint_mock.assert_called_once_with( - painter_mock, option_mock, index_mock, "Test Value" - ) - - -def test_center_checkbox_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test CenterCheckBoxDelegate initialization.""" - delegate = CenterCheckBoxDelegate(mock_table_view) - - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked) - # Check that the checkbox is centered - pixrect = delegate._icon_checked.rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked) - - model = index_mock.model() - - # Editor event with non-check state role - qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange - assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock) - - # Editor event with check state role but not mouse button event - qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease - with ( - mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked), - mock.patch.object(model, "setData") as mock_model_set, - ): - delegate.editorEvent(qevent_mock, model, option_mock, index_mock) - mock_model_set.assert_called_once_with( - index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE - ) - - -def test_device_validated_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test DeviceValidatedDelegate initialization.""" - # Invalid value - delegate = DeviceValidatedDelegate(mock_table_view) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value") - painter_mock.drawPixmap.assert_not_called() - - # Valid value - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value) - icon = delegate._icons[ValidationStatus.VALID.value] - pixrect = icon.rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon) - - -def test_wrapping_text_delegate_do_custom_paint( - mock_table_view, painter_mock, option_mock, index_mock -): - """Test WrappingTextDelegate _do_custom_paint method.""" - delegate = WrappingTextDelegate(mock_table_view) - - # First case, empty text, nothing should happen - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "") - painter_mock.setPen.assert_not_called() - layout_mock = mock.MagicMock() - - def _layout_comput_return(*args, **kwargs): - return layout_mock - - layout_mock.draw.return_value = None - with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return): - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring") - layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft()) - - -TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20) -TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate." - - -def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock): - """Test WrappingTextDelegate _compute_layout method.""" - delegate = WrappingTextDelegate(mock_table_view) - layout_mock = mock.MagicMock(spec=QtGui.QTextLayout) - - # This combination should yield 4 lines - with mock.patch.object(delegate, "_get_layout", return_value=layout_mock): - layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine) - mock_line.height.return_value = 10 - mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False]) - - option_mock.rect = TEST_RECT_FOR - option_mock.font = QtGui.QFont() - layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock) - assert layout.createLine.call_count == 4 # pylint: disable=E1101 - assert mock_line.setPosition.call_count == 3 - assert mock_line.setPosition.call_args_list[-1] == mock.call( - QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit - ) - - -def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock): - """Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines.""" - delegate = WrappingTextDelegate(mock_table_view) - assert delegate.margin == 6 - with ( - mock.patch.object(mock_table_view, "initViewItemOption"), - mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]), - mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]), - ): - # Test with empty text, should return height + 2*margin - index_mock.data.return_value = "" - option_mock.rect = TEST_RECT_FOR - font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont()) - size = delegate.sizeHint(option_mock, index_mock) - assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin) - - # Now test with the text that should wrap to 4 lines - index_mock.data.return_value = TEST_TEXT_WITH_4_LINES - size = delegate.sizeHint(option_mock, index_mock) - # The estimate goes to 5 lines + 2* margin - expected_lines = 5 - assert size == QtCore.QSize( - 100, font_metrics.height() * expected_lines + 2 * delegate.margin - ) - - -def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model): - """Test WrappingTextDelegate update_row_heights method.""" - device_cfg = DeviceModel( - name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline" - ).model_dump() - # Add single device to config - delegate = WrappingTextDelegate(mock_table_view) - row_heights = [25, 40] - - with mock.patch.object( - delegate, - "sizeHint", - side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])], - ): - mock_table_view.setItemDelegateForColumn(5, delegate) - mock_table_view.setItemDelegateForColumn(6, delegate) - device_proxy_model.sourceModel().set_device_config([device_cfg]) - assert delegate._wrapping_text_columns is None - assert mock_table_view.rowHeight(0) == 30 # Default height - delegate._update_row_heights() - assert delegate._wrapping_text_columns == [5, 6] - assert mock_table_view.rowHeight(0) == max(row_heights) - - -def test_device_validation_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test DeviceValidatedDelegate initialization.""" - delegate = DeviceValidatedDelegate(mock_table_view) - - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID) - # Check that the checkbox is centered - - pixrect = delegate._icons[ValidationStatus.VALID.value].rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with( - pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value] - ) - - # Should not be called if invalid value - delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10) - - # Check that the checkbox is centered - assert painter_mock.drawPixmap.call_count == 1 - - -### -# Test DeviceTableModel & DeviceFilterProxyModel -### - - -def test_device_table_model_data(device_proxy_model): - """Test the device table model data retrieval.""" - source_model = device_proxy_model.sourceModel() - test_device = { - "status": ValidationStatus.PENDING, - "name": "test_device", - "deviceClass": "TestClass", - "readoutPriority": "baseline", - "onFailure": "retry", - "enabled": True, - "readOnly": False, - "softwareTrigger": True, - "deviceTags": ["tag1", "tag2"], - "description": "Test device", - } - source_model.add_device_configs([test_device]) - assert source_model.rowCount() == 1 - assert source_model.columnCount() == 10 - - # Check data retrieval for each column - expected_data = { - 0: ValidationStatus.PENDING, # Default status - 1: "test_device", # name - 2: "TestClass", # deviceClass - 3: "baseline", # readoutPriority - 4: "retry", # onFailure - 5: "tag1, tag2", # deviceTags - 6: "Test device", # description - 7: True, # enabled - 8: False, # readOnly - 9: True, # softwareTrigger - } - - for col, expected in expected_data.items(): - index = source_model.index(0, col) - data = source_model.data(index, QtCore.Qt.DisplayRole) - assert data == expected - - -def test_device_table_model_with_data(device_table_model, device_proxy_model): - """Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data.""" - # Create 3 test devices - names NOT alphabetically sorted - test_devices = [ - { - "name": "zebra_device", - "deviceClass": "TestClass1", - "enabled": True, - "readOnly": False, + # Test with single device + docstring_view.on_select_config([{"test": {"deviceClass": "NumPyStyleClass"}}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [ + {"test": {"deviceClass": "NumPyStyleClass"}}, + {"test": {"deviceClass": "GoogleStyleClass"}}, + ] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + def test_set_device_class(self, docstring_view: DocstringView): + """Test the set_device_class method.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +class TestDMConfigView: + """Test class for DMConfigView component.""" + + @pytest.fixture + def dm_config_view(self, qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + def test_initialization(self, dm_config_view: DMConfigView): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select a single device to view its config." + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + def test_on_select_config(self, dm_config_view: DMConfigView): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +class TestDeviceTableRow: + """Test class for DeviceTableRow component.""" + + @pytest.fixture + def sample_device_data(self) -> dict: + """Sample device data for testing.""" + return { + "name": "test_motor", + "deviceClass": "ophyd.EpicsMotor", "readoutPriority": "baseline", - "deviceTags": ["tag1", "tag2"], - "description": "Test device Z", - }, - { - "name": "alpha_device", - "deviceClass": "TestClass2", - "enabled": False, - "readOnly": True, - "readoutPriority": "primary", - "deviceTags": ["tag3"], - "description": "Test device A", - }, - { - "name": "beta_device", - "deviceClass": "TestClass3", + "onFailure": "retry", + "deviceTags": {"motors", "positioning"}, + "description": "X-axis positioning motor", "enabled": True, "readOnly": False, - "readoutPriority": "secondary", - "deviceTags": [], - "description": "Test device B", - }, - ] + "softwareTrigger": False, + } - # Add devices to source model - device_table_model.add_device_configs(test_devices) + @pytest.fixture + def device_table_row(self, sample_device_data: dict): + """Fixture to create a DeviceTableRow instance.""" + row = DeviceTableRow(data=sample_device_data) + yield row - # Check source model has 3 rows and proper columns - assert device_table_model.rowCount() == 3 - assert device_table_model.columnCount() == 10 - - # Check proxy model propagates the data - assert device_proxy_model.rowCount() == 3 - assert device_proxy_model.columnCount() == 10 - - # Verify data propagation through proxy - check names in original order - for i, expected_device in enumerate(test_devices): - proxy_index = device_proxy_model.index(i, 1) # Column 1 is name - source_index = device_proxy_model.mapToSource(proxy_index) - source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole) - assert source_data == expected_device["name"] - - # Check proxy data matches source - proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole) - assert proxy_data == source_data - - # Verify all columns are accessible - headers = device_table_model.headers - for col, header in enumerate(headers): - header_data = device_table_model.headerData( - col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole + def test_initialization(self, device_table_row: DeviceTableRow, sample_device_data: dict): + """Test DeviceTableRow initialization with sample data.""" + expected_keys = list(DeviceModel.model_fields.keys()) + for key in expected_keys: + assert key in device_table_row.data + if key in sample_device_data: + assert device_table_row.data[key] == sample_device_data[key] + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, ) - assert header_data is not None - - -def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model): - """Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort.""" - # Use same test data as above - zebra, alpha, beta (not alphabetically sorted) - test_devices = [ - { - "status": ValidationStatus.VALID, - "name": "zebra_device", - "deviceClass": "TestClass1", - "enabled": True, - }, - { - "status": ValidationStatus.PENDING, - "name": "alpha_device", - "deviceClass": "TestClass2", - "enabled": False, - }, - { - "status": ValidationStatus.FAILED, - "name": "beta_device", - "deviceClass": "TestClass3", - "enabled": True, - }, - ] - - device_table_model.add_device_configs(test_devices) - - # Verify initial order (unsorted) - assert ( - device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) - == "zebra_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) - == "alpha_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) - == "beta_device" - ) - - # Enable sorting and sort by name column (column 1) - mock_table_view.setSortingEnabled(True) - # header = mock_table_view.horizontalHeader() - # qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton) - device_proxy_model.sort(1, QtCore.Qt.AscendingOrder) - - # After sorting, verify alphabetical order: alpha, beta, zebra - assert ( - device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) - == "alpha_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) - == "beta_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) - == "zebra_device" - ) - - -def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model): - """Test (C): Remove rows from BECTableView and verify propagation.""" - # Set up test data - test_devices = [ - {"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True}, - {"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False}, - {"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True}, - ] - - device_table_model.add_device_configs(test_devices) - assert device_table_model.rowCount() == 3 - assert device_proxy_model.rowCount() == 3 - - # Mock the confirmation dialog to first cancel, then confirm - with mock.patch.object( - mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True] - ) as mock_confirm: - - # Create mock selection for middle device (device_to_remove at row 1) - selection_model = mock.MagicMock() - proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column - selection_model.selectedRows.return_value = [proxy_index_to_remove] - - mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model) - - # Verify the device we're about to remove - device_name_to_remove = device_proxy_model.data( - device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole + device_table_row.set_validation_status(ConfigStatus.VALID, ConnectionStatus.CONNECTED) + assert device_table_row.validation_status == ( + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + new_data = sample_device_data.copy() + new_data["name"] = "updated_motor" + device_table_row.set_data(new_data) + assert device_table_row.data["name"] == new_data.get("name", "") + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, ) - assert device_name_to_remove == "device_to_remove" - - # Call delete_selected method - mock_table_view.delete_selected() - - # Verify confirmation was called - mock_confirm.assert_called_once() - - assert device_table_model.rowCount() == 3 # No change on first call - assert device_proxy_model.rowCount() == 3 - - # Call delete_selected again, this time it should confirm - mock_table_view.delete_selected() - - # Check that the device was removed from source model - assert device_table_model.rowCount() == 2 - assert device_proxy_model.rowCount() == 2 - - # Verify the remaining devices are correct - remaining_names = [] - for i in range(device_proxy_model.rowCount()): - name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole) - remaining_names.append(name) - - assert "device_to_remove" not in remaining_names -def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model): - """Test DeviceFilterProxyModel text filtering functionality.""" - # Set up test data with different device names and classes - test_devices = [ - {"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"}, - {"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"}, - {"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"}, - ] +class TestDeviceTable: + """Test class for DeviceTable component.""" - device_table_model.add_device_configs(test_devices) - assert device_proxy_model.rowCount() == 3 + @pytest.fixture + def device_table(self, qtbot) -> Generator[DeviceTable, None, None]: + """Fixture to create a DeviceTable instance.""" + table = DeviceTable() + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table - # Test filtering by name - device_proxy_model.setFilterText("motor") - assert device_proxy_model.rowCount() == 2 - # Should show 2 rows (motor_x and motor_y) - visible_count = 0 - for i in range(device_proxy_model.rowCount()): - if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): - continue - visible_count += 1 + @pytest.fixture + def sample_devices(self): + """Sample device configurations for testing.""" + return [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] - # Test filtering by device class - device_proxy_model.setFilterText("EpicsDetector") - # Should show 1 row (detector_main) - detector_visible = False - assert device_proxy_model.rowCount() == 1 - for i in range(device_table_model.rowCount()): - if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): - source_index = device_table_model.index(i, 1) # Name column - name = device_table_model.data(source_index, QtCore.Qt.DisplayRole) - if name == "detector_main": - detector_visible = True - break - assert detector_visible + def test_initialization(self, device_table: DeviceTable): + """Test DeviceTable initialization.""" + # Check table setup + assert device_table.table.columnCount() == 11 + assert device_table.table.rowCount() == 0 - # Clear filter - device_proxy_model.setFilterText("") - assert device_proxy_model.rowCount() == 3 - # Should show all 3 rows again - all_visible = all( - device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()) - for i in range(device_table_model.rowCount()) - ) - assert all_visible + # Check headers + expected_headers = [ + "Valid", + "Connect", + "Name", + "Device Class", + "Readout Priority", + "On Failure", + "Device Tags", + "Description", + "Enabled", + "Read Only", + "Software Trigger", + ] + for i, expected_header in enumerate(expected_headers): + actual_header = device_table.table.horizontalHeaderItem(i).text() + assert actual_header == expected_header + + # Check search functionality is set up + assert device_table.search_input is not None + assert device_table.fuzzy_is_disabled.isChecked() is False + assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows + + def test_add_row(self, device_table: DeviceTable, sample_devices: dict): + """Test adding a single device row.""" + device_table.add_device_configs([sample_devices[0]]) + + # Verify row was added + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + assert "motor_x" in device_table.row_data + + # If row is added again, it should overwrite + sample_devices[0]["deviceClass"] = "UpdateClass" + device_table.add_device_configs([sample_devices[0]]) + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + row_data = device_table.row_data["motor_x"] + assert row_data is not None + assert row_data.data.get("deviceClass") == "UpdateClass" + assert device_table._get_cell_data(0, 3) == "UpdateClass" # DeviceClass column + assert device_table._get_cell_data(0, 2) == "motor_x" # Name column + assert device_table._get_cell_data(0, 0) == "" # Icon column, no text + assert device_table._get_cell_data(0, 9) == False # Check Enabled column + assert device_table.table.item(0, 9).checkState() == QtCore.Qt.CheckState.Unchecked + config_status_item = device_table.table.item(0, 0) + assert ( + config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.UNKNOWN.value + ) + + def test_update_row(self, device_table: DeviceTable, sample_devices: dict): + """Test updating an existing device row.""" + device_table.add_device_configs([sample_devices[0]]) + + assert "motor_x" in device_table.row_data + # Update the existing row + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "X-axis motor" + # Change description + sample_devices[0]["description"] = "Updated X-axis motor" + device_table._update_row(sample_devices[0]) + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "Updated X-axis motor" + assert row.validation_status == (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + # Update validation status + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + validation_msg="", + ) + assert row.validation_status == (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value) + config_status_item = device_table.table.item(0, 0) + assert config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.VALID.value + + ##################### + ##### Test public API + ##################### + + def test_set_device_config(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test set device configs methods, must also emit the appropriate signal.""" + with mock.patch.object(device_table, "clear_device_configs") as mock_clear_configs: + ########### + # Test cases I. + # First use case, adding new configs to empty table + device_table.set_device_config(sample_devices) + + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 1 + + # II. + # Second use case, replacing existing configs + device_table.set_device_config(sample_devices) + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 2 + + def test_clear_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test clearing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.clear_device_configs() + + assert len(container) == 1 + assert device_table.table.rowCount() == 0 + + def test_add_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test add device configs method under various scenarios.""" + + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + assert device_table.table.rowCount() == 2 + + # II. + # If added again, old configs should be removed first, and new ones added + # Reset container + container = [] + expected_calls = 2 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 2 + assert container[0][0][1] is False + assert container[1][0][0] == sample_devices + assert container[1][0][1] is True + + # Verify rows were added + assert device_table.table.rowCount() == 2 + assert len(device_table.row_data) == 2 + assert "motor_x" in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_update_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test updating device configurations.""" + + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + # First case I. + # Update to empty table should add rows, and emit signal with added=True + expected_calls = 1 + + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls) as blocker: + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + + # Second case II. + # Update existing configs should modify rows, and change the validation status to unknown + # for the device that was changed + container = [] + sample_devices[0]["description"] = "Modified description" + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == [sample_devices[0]] + assert container[0][0][1] is True + + def test_get_device_config(self, device_table: DeviceTable, sample_devices: dict): + """Test retrieving device configurations.""" + device_table.add_device_configs(sample_devices) + + retrieved_configs = device_table.get_device_config() + assert len(retrieved_configs) == 2 + + # Check that we can find our test devices + device_names = [config["name"] for config in retrieved_configs] + assert "motor_x" in device_names + assert "detector_main" in device_names + + def test_search_functionality(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test search/filter functionality.""" + device_table.add_device_configs(sample_devices) + + # Test filtering by name + qtbot.keyClicks(device_table.search_input, "motor") + qtbot.wait(100) # Allow filter to apply + + # Should show only motor device + visible_rows = 0 + for row in range(device_table.table.rowCount()): + if not device_table.table.isRowHidden(row): + visible_rows += 1 + assert visible_rows == 1 + + def test_remove_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test removing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + + # Remove one device + with qtbot.waitSignal(device_table.device_configs_changed) as blocker: + device_table.remove_device_configs([sample_devices[0]]) + + # Verify signal emission + emitted_configs, added = blocker.args + assert len(emitted_configs) == 1 + assert added is False + + # Verify row was removed + assert device_table.table.rowCount() == 1 + assert "motor_x" not in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_validation_status_update(self, device_table: DeviceTable, sample_devices: dict): + """Test updating validation status.""" + device_table: DeviceTable + device_table.add_device_configs(sample_devices) + + # Update validation status for one device + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + validation_msg="Test passed", + ) + + # Verify status was updated in the row + motor_row = device_table.row_data["motor_x"] + assert motor_row.validation_status == (ConfigStatus.VALID, ConnectionStatus.CONNECTED) + + def test_selection_handling(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test device selection and signal emission.""" + device_table.add_device_configs(sample_devices) + + # Select first row + with qtbot.waitSignal(device_table.selected_devices) as blocker: + device_table.table.selectRow(0) + + # Verify selection signal was emitted + selected_configs = blocker.args[0] + assert len(selected_configs) == 1 + assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"] -### -# Test DeviceTableView -### +class TestOphydValidation: + """ + Test class for the Ophyd test module. This tests the OphydValidation widget, + the validation list items and dialog, and the utility functions related to + device testing and validation. + """ + ################ + ### Ophyd_test_utils tests + ################ -@pytest.fixture -def device_table_view(qtbot): - """Fixture to create a DeviceTableView instance.""" - view = DeviceTableView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view + def test_format_error_to_md(self): + """Test the format_error_to_md utility function.""" + device_name = "non_existing_device" + error_msg = """ERROR: non_existing_device is not valid: 3 validation errors for Device\nenabled\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\ndeviceClass\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nreadoutPriority\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nERROR: non_existing_device is not valid: 'deviceClass'""" + md_output = format_error_to_md(device_name, error_msg) + assert f"## Error for {device_name}\n\n**{device_name} is not valid**" in md_output + assert "3 validation errors for Device" in md_output + def test_description_validation_status(self): + """Test descriptions for ConfigStatus enum values.""" + # ConfigStatus descriptions + assert ConfigStatus.VALID.description() == "Valid Configuration" + assert ConfigStatus.INVALID.description() == "Invalid Configuration" + assert ConfigStatus.UNKNOWN.description() == "Unknown" -def test_device_table_view_initialization(qtbot, device_table_view): - """Test the DeviceTableView search method.""" + # ConnectionStatus descriptions + assert ConnectionStatus.CANNOT_CONNECT.description() == "Cannot Connect" + assert ConnectionStatus.CAN_CONNECT.description() == "Can Connect" + assert ConnectionStatus.CONNECTED.description() == "Connected and Loaded" + assert ConnectionStatus.UNKNOWN.description() == "Unknown" - # Check that the search input fields are properly initialized and connected - qtbot.keyClicks(device_table_view.search_input, "zebra") - qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000) - qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton) - qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000) + def test_device_test_model(self): + """Test the DeviceTestModel""" + data = { + "uuid": "1234", + "device_name": "test_device", + "device_config": {"name": "test_device", "deviceClass": "TestClass"}, + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + assert model.uuid == "1234" + assert model.device_name == "test_device" - # Check table setup + def test_get_validation_icons(self): + """Test the get_validation_icons utility function.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) - # header - header = device_table_view.table.horizontalHeader() - assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags - assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description + # Check that icons for all statuses are present + for status in ConfigStatus: + assert status in icons["config_status"] + assert isinstance(icons["config_status"][status], QtGui.QIcon) - # table selection - assert ( - device_table_view.table.selectionBehavior() - == QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows - ) - assert ( - device_table_view.table.selectionMode() - == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection - ) + for status in ConnectionStatus: + assert status in icons["connection_status"] + assert isinstance(icons["connection_status"][status], QtGui.QIcon) + ################ + ### ValidationListItem tests + ################ -def test_device_table_theme_update(device_table_view): - """Test DeviceTableView apply_theme method.""" - # Check apply theme propagates - with ( - mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply, - mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated, + @pytest.fixture + def validation_button(self, qtbot): + """Fixture to create a ValidationButton instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + icon = icons["config_status"][ConfigStatus.VALID.value] + button = ValidationButton(icon=icon) + + qtbot.addWidget(button) + qtbot.waitExposed(button) + yield button + + def test_validation_button_initialization(self, validation_button: ValidationButton): + """Test ValidationButton initialization.""" + assert validation_button.isFlat() is True + assert validation_button.isEnabled() is True + assert isinstance(validation_button.icon(), QtGui.QIcon) + assert validation_button.styleSheet() == "" + validation_button.setEnabled(False) + assert validation_button.styleSheet() == validation_button.transparent_style + + @pytest.fixture + def validation_dialog(self, qtbot): + """Fixture for ValidationDialog.""" + dialog = ValidationDialog() + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + def test_validation_dialog(self, validation_dialog: ValidationDialog, qtbot): + """Test ValidationDialog initialization.""" + assert validation_dialog.timeout_spin.value() == 5 + assert validation_dialog.connect_checkbox.isChecked() is False + assert validation_dialog.force_connect_checkbox.isChecked() is False + + # Change timeout + validation_dialog.timeout_spin.setValue(10) + # Result should not update yet + assert validation_dialog.result() == (5, False, False) + # Click accept + with qtbot.waitSignal(validation_dialog.accepted): + qtbot.mouseClick( + validation_dialog.button_box.button(QtWidgets.QDialogButtonBox.Ok), + QtCore.Qt.LeftButton, + ) + assert validation_dialog.result() == (10, False, False) + + @pytest.fixture + def device_model(self): + """Fixture to create a sample DeviceTestModel instance.""" + config = DeviceModel( + name="test_device", deviceClass="TestClass", readoutPriority="baseline", enabled=True + ) + data = { + "uuid": "1234", + "device_name": config.name, + "device_config": config.model_dump(), + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + yield model + + @pytest.fixture + def validation_list_item(self, device_model, qtbot): + """Fixture to create a ValidationListItem instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + item = ValidationListItem(device_model=device_model, validation_icons=icons) + qtbot.addWidget(item) + qtbot.waitExposed(item) + yield item + + def test_update_validation_status(self, validation_list_item: ValidationListItem): + """Test updating status in ValidationListItem.""" + # Update to invalid config status + validation_list_item._update_validation_status( + validation_msg="Error occurred", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, + ) + assert validation_list_item.device_model.config_status == ConfigStatus.INVALID.value + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CANNOT_CONNECT.value + ) + assert validation_list_item.device_model.validation_msg == "Error occurred" + + def test_validation_logic(self, validation_list_item: ValidationListItem): + """Test starting and stopping validation spinner.""" + # Schedule validation + validation_list_item.validation_scheduled() + assert validation_list_item.status_button.isEnabled() is False + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.is_running is False + + # Start validation + with mock.patch.object(validation_list_item._spinner, "start") as mock_spinner_start: + validation_list_item.start_validation() + assert validation_list_item.is_running is True + mock_spinner_start.assert_called_once() + + # Finish validation + + with mock.patch.object(validation_list_item._spinner, "stop") as mock_spinner_stop: + + # I. successful validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.VALID.value, + connection_status=ConnectionStatus.CAN_CONNECT.value, + ) + assert validation_list_item.is_running is False + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CAN_CONNECT.value + ) + mock_spinner_stop.assert_called_once() + # Buttons should be disabled after validation finished good + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.status_button.isEnabled() is False + + # Restart validation + validation_list_item.start_validation() + mock_spinner_stop.reset_mock() + + # II. failed validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.UNKNOWN.value, + ) + assert validation_list_item.is_running is False + mock_spinner_stop.assert_called_once() + assert validation_list_item.connection_button.isEnabled() is True + assert validation_list_item.status_button.isEnabled() is True + + #################### + ### OphydValidation widget tests + #################### + + @pytest.fixture + def device_test_runnable(self, device_model, qtbot): + """Fixture to create a DeviceTest instance.""" + widget = QtWidgets.QWidget() # Create a widget because the runnable is not a widget itself + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + widget._runnable_test = DeviceTest( + device_model=device_model, timeout=5, enable_connect=True, force_connect=False + ) + yield widget + + def test_device_test(self, device_test_runnable, qtbot): + """Test DeviceTest runnable initialization.""" + runnable: DeviceTest = device_test_runnable._runnable_test + assert runnable.device_config.get("name") == "test_device" + assert runnable.timeout == 5 + assert runnable.enable_connect is True + assert runnable._cancelled is False + + # Callback validation + container = [] + + def _runnable_callback( + config: dict, config_is_valid: bool, connection_status: bool, error_msg: str + ): + container.append((config, config_is_valid, connection_status, error_msg)) + + runnable.signals.device_validated.connect(_runnable_callback) + + # Callback started + started_container = [] + + def _runnable_started_callback(): + started_container.append(True) + + runnable.signals.device_validation_started.connect(_runnable_started_callback) + + # Should resolve without running test if cancelled + runnable.cancel() + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.UNKNOWN.value + assert connection_status == ConnectionStatus.UNKNOWN.value + assert error_msg == f"{runnable.device_config.get('name', '')} was cancelled by user." + + # Now we run it without cancelling + + # Reset containers + container = [] + started_container = [] + runnable._cancelled = False + with mock.patch.object( + runnable.tester, "run_with_list_output" + ) as mock_run_with_list_output: + mock_run_with_list_output.return_value = [ + TestResult( + name="test_device", + config_is_valid=ConfigStatus.VALID.value, + success=ConnectionStatus.CANNOT_CONNECT.value, + message="All good", + ) + ] + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.VALID.value + assert connection_status == ConnectionStatus.CANNOT_CONNECT.value + assert error_msg == "All good" + + @pytest.fixture + def thread_pool_manager(self, qtbot): + """Fixture to create a ThreadPoolManager instance.""" + widget = QtWidgets.QWidget() # Create a widget because the manager is not a widget itself + widget._pool_manager = ThreadPoolManager() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_thread_pool_manager(self, thread_pool_manager): + """Test ThreadPoolManager initialization.""" + manager: ThreadPoolManager = thread_pool_manager._pool_manager + assert manager.pool.maxThreadCount() == 4 + assert manager._timer.interval() == 100 + + # Test submitting tasks + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + assert len(manager.get_scheduled_tests()) == 2 + assert len(manager.get_active_tests()) == 0 + + # Clear queue + manager.clear_queue() + assert device_test_mock_1.cancel.call_count == 1 + assert device_test_mock_2.cancel.call_count == 1 + assert device_test_mock_1.signals.device_validated.disconnect.call_count == 1 + assert device_test_mock_2.signals.device_validated.disconnect.call_count == 1 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 0 + + def test_thread_pool_process_queue(self, thread_pool_manager, qtbot): + """Test ThreadPoolManager process queue logic.""" + # Submit 2 elements to the queue + manager: ThreadPoolManager = thread_pool_manager._pool_manager + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + + # Validations running cb + container = [] + + def _validations_running_cb(is_true: bool): + container.append(is_true) + + manager.validations_are_running.connect(_validations_running_cb) + with mock.patch.object(manager.pool, "start") as mock_pool_start: + with qtbot.waitSignal(manager.validations_are_running): + # Process queue, should start both tasks + manager._process_queue() + assert mock_pool_start.call_count == 2 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 2 + assert len(container) == 1 + assert container[0] is True + device_test_mock_1.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + device_test_mock_2.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + + # Simulate one task finished + manager._on_task_finished({"name": "test_device"}, True, True, "All good") + assert len(manager.get_active_tests()) == 1 + + # Process queue again, nothing should happen as queue is empty + mock_pool_start.reset_mock() + manager._process_queue() + assert mock_pool_start.call_count == 0 + assert len(manager.get_active_tests()) == 1 + + @pytest.fixture + def legend_label(self, qtbot): + """Fixture to create a TestLegendLabel instance.""" + label = LegendLabel() + qtbot.addWidget(label) + qtbot.waitExposed(label) + yield label + + def test_legend_label(self, legend_label: LegendLabel): + """Test LegendLabel.""" + layout: QtWidgets.QGridLayout = legend_label.layout() + # Verify layout structure + assert layout.rowCount() == 2 + assert layout.columnCount() == 6 + # Assert labels and icons are present + label = layout.itemAtPosition(0, 0).widget() + assert label.text() == "Config Legend:" + label = layout.itemAtPosition(1, 0).widget() + assert label.text() == "Connect Legend:" + + @pytest.fixture + def ophyd_test(self, qtbot): + """Fixture to create an OphydValidation instance. We patch the method that starts the polling loop to avoid side effects.""" + with ( + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop", + return_value=None, + ), + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session", + return_value=False, + ), + ): + widget = OphydValidation() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_ophyd_test_initialization(self, ophyd_test: OphydValidation, qtbot): + """Test OphydValidation widget initialization.""" + assert isinstance(ophyd_test.list_widget, BECList) + assert isinstance(ophyd_test.thread_pool_manager, ThreadPoolManager) + layout = ophyd_test.layout() + # Widget with layout + legend label + assert isinstance(layout.itemAt(1).widget(), LegendLabel) + + # Test clicking the stop validation button + click_event = Event() + + def _stop_validation_button_clicked(): + click_event.set() + + ophyd_test._stop_validation_button.clicked.connect(_stop_validation_button_clicked) + with qtbot.waitSignal(ophyd_test._stop_validation_button.clicked): + # Simulate click + qtbot.mouseClick(ophyd_test._stop_validation_button, QtCore.Qt.LeftButton) + assert click_event.is_set() + + def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): + """Test adding devices to OphydValidation widget.""" + sample_devices = [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] + # Initially empty, add devices + with mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + + # Adding again should overwrite existing ones + with mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_configs: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + assert mock_remove_configs.call_count == 2 # Once for each device + + # Click item in list + item = ophyd_test.list_widget.item(0) + with qtbot.waitSignal(ophyd_test.item_clicked) as blocker: + qtbot.mouseClick( + ophyd_test.list_widget.viewport(), + QtCore.Qt.LeftButton, + pos=ophyd_test.list_widget.visualItemRect(item).center(), + ) + device_name = blocker.args[0] + assert ( + ophyd_test.list_widget.get_widget_for_item(item).device_model.device_name + == device_name + ) + + # Clear running validation + with ( + mock.patch.object( + ophyd_test.thread_pool_manager, "clear_device_in_queue" + ) as mock_clear, + mock.patch.object(ophyd_test, "_on_device_test_completed") as mock_on_completed, + ): + ophyd_test.cancel_validation("motor_x") + mock_clear.assert_called_once_with("motor_x") + mock_on_completed.assert_called_once_with( + sample_devices[0], + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "motor_x was cancelled by user.", + ) + + def test_ophyd_test_submit_test( + self, ophyd_test: OphydValidation, validation_list_item: ValidationListItem, qtbot ): - device_table_view.apply_theme("dark") - mock_apply.assert_called_once_with("dark") - mock_validated.assert_called_once_with("dark") + """Test submitting a device test to the thread pool manager.""" + with ( + mock.patch.object( + validation_list_item, "validation_scheduled" + ) as mock_validation_scheduled, + mock.patch.object(ophyd_test.thread_pool_manager, "submit") as mock_thread_pool_submit, + ): + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_called_once() + mock_thread_pool_submit.assert_called_once() + + mock_validation_scheduled.reset_mock() + mock_thread_pool_submit.reset_mock() + # Assume device is already in Redis + with ( + mock.patch.object(ophyd_test, "_is_device_in_redis_session") as mock_in_redis, + mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_device, + ): + mock_in_redis.return_value = True + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_not_called() + mock_thread_pool_submit.assert_not_called() + assert validation_list_item.device_model.device_config == blocker.args[0] + assert blocker.args[1] is ConfigStatus.VALID.value + assert blocker.args[2] is ConnectionStatus.CONNECTED.value + + def test_ophyd_test_compare_device_configs(self, ophyd_test: OphydValidation): + """Test comparing device configurations.""" + device_config_1 = { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + device_config_2 = device_config_1.copy() + # Should be equal + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is True + + # Change a field + device_config_2["description"] = "Modified description" + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is False + + @pytest.mark.parametrize( + "config_status,connection_status, msg", + [ + (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value, "Validation successful"), + ( + ConfigStatus.INVALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + "Validation failed", + ), + ], + ) + def test_ophyd_test_validation_succeeds( + self, ophyd_test: OphydValidation, qtbot, config_status, connection_status, msg + ): + """Test handling of successful device validation.""" + sample_device = { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + with ( + mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test, + mock.patch.object(ophyd_test, "_is_device_in_redis_session", return_value=False), + ): + ophyd_test.change_device_configs([sample_device], added=True) + + # Emit validation completed signal from thread pool manager + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + validation_item = ophyd_test.list_widget.get_widget_for_item( + ophyd_test.list_widget.item(0) + ) + with mock.patch.object( + validation_item, "on_validation_finished" + ) as mock_on_validation_finished: + ophyd_test.thread_pool_manager.device_validated.emit( + sample_device, config_status, connection_status, msg + ) + if config_status != ConfigStatus.VALID.value: + mock_on_validation_finished.assert_called_once_with( + validation_msg=msg, + config_status=config_status, + connection_status=connection_status, + ) + + assert blocker.args[0] == sample_device + assert blocker.args[1] == config_status + assert blocker.args[2] == connection_status + assert blocker.args[3] == msg -def test_device_table_view_updates(device_table_view): - """Test DeviceTableView methods that update the view and model.""" - # Test theme update triggered.. +class TestDeviceConfigTemplate: - cfgs = [ - {"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True}, - {"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False}, - {"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True}, - ] - with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize: - # Should be called once for rowsInserted - device_table_view.set_device_config(cfgs) - assert device_table_view.get_device_config() == cfgs - mock_autosize.assert_called_once() - # Update validation status, should be called again - device_table_view.update_device_validation("test_device", ValidationStatus.VALID) - assert mock_autosize.call_count == 2 - # Remove a device, should triggere also a _request_autosize_columns call - device_table_view.remove_device_configs([cfgs[0]]) - assert device_table_view.get_device_config() == cfgs[1:] - assert mock_autosize.call_count == 3 - # Remove one device manually - device_table_view.remove_device("another_device") # Should remove the last device - assert device_table_view.get_device_config() == cfgs[2:] - assert mock_autosize.call_count == 4 - # Reset the model should call it once again - device_table_view.clear_device_configs() - assert mock_autosize.call_count == 5 - assert device_table_view.get_device_config() == [] + def test_try_literal_eval(self): + """Test the _try_literal_eval static method.""" + # handle booleans + assert _try_literal_eval("True") is True + assert _try_literal_eval("False") is False + assert _try_literal_eval("true") is True + assert _try_literal_eval("false") is False + # handle empty string + assert _try_literal_eval("") == "" + # Lists + assert _try_literal_eval([0, 1, 2]) == [0, 1, 2] + # Set and tuples + assert _try_literal_eval((1, 2, 3)) == (1, 2, 3) + # Numbers int and float + assert _try_literal_eval("123") == 123 + assert _try_literal_eval("45.67") == 45.67 + # if literal eval fails, return original string + assert _try_literal_eval(" invalid text,,, ") == " invalid text,,, " + def _create_widget_for_device_field(self, field_name: str, qtbot) -> QtWidgets.QWidget: + """Helper method to create a widget for a given device field.""" + field = DEVICE_FIELDS[field_name] + widget = field.widget_cls() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget -def test_device_table_view_get_help_md(device_table_view): - """Test DeviceTableView get_help_md method.""" - with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at: - mock_index_at.isValid = mock.MagicMock(return_value=True) - with mock.patch.object(device_table_view, "_model") as mock_model: - mock_model.headerData = mock.MagicMock(side_effect=["softTrig"]) - # Second call is True, should return the corresponding help md - assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"] + def test_device_fields_name(self, qtbot): + """Test DEVICE_FIELDS content for 'name' field.""" + colors = get_accent_colors() + name_field: DeviceConfigField = DEVICE_FIELDS["name"] + assert name_field.label == "Name" + assert name_field.widget_cls == InputLineEdit + assert name_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("name", qtbot) + if name_field.validation_callback is not None: + for cb in name_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("valid_device_name") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("invalid _name") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_device_class(self, qtbot): + """Test DEVICE_FIELDS content for 'deviceClass' field.""" + colors = get_accent_colors() + device_class_field: DeviceConfigField = DEVICE_FIELDS["deviceClass"] + assert device_class_field.label == "Device Class" + assert device_class_field.widget_cls == InputLineEdit + assert device_class_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("deviceClass", qtbot) + if device_class_field.validation_callback is not None: + for cb in device_class_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("EpicsMotor") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("wrlong-sadnjkas:'&") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_description(self, qtbot): + """Test DEVICE_FIELDS content for 'description' field.""" + description_field: DeviceConfigField = DEVICE_FIELDS["description"] + assert description_field.label == "Description" + assert description_field.widget_cls == QtWidgets.QTextEdit + assert description_field.required is False + assert description_field.placeholder_text == "Short device description" + # Create widget and test + widget: QtWidgets.QTextEdit = self._create_widget_for_device_field("description", qtbot) + + def test_device_fields_toggle_fields(self, qtbot): + """Test DEVICE_FIELDS content for 'enabled' and 'readOnly' fields.""" + for field_name in ["enabled", "readOnly", "softwareTrigger"]: + field: DeviceConfigField = DEVICE_FIELDS[field_name] + assert field.label in ["Enabled", "Read Only", "Software Trigger"] + assert field.widget_cls == ToggleSwitch + assert field.required is False + if field_name == "enabled": + assert field.default is True + else: + assert field.default is False + + @pytest.fixture + def device_config_template(self, qtbot): + """Fixture to create a DeviceConfigTemplate instance.""" + template = DeviceConfigTemplate() + qtbot.addWidget(template) + qtbot.waitExposed(template) + yield template + + def test_device_config_teamplate_default_init( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test DeviceConfigTemplate default initialization.""" + assert ( + device_config_template.template + == OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + ) + + # Check settings box, should have 3 labels, 2 InputLineEdit, 1 QTextEdit + assert len(device_config_template.settings_box.findChildren(QtWidgets.QLabel)) == 3 + assert len(device_config_template.settings_box.findChildren(InputLineEdit)) == 2 + assert len(device_config_template.settings_box.findChildren(QtWidgets.QTextEdit)) == 1 + + # Check advanced control box, should have 5 labels for + # readoutPriority, onFailure, enabled, readOnly, softwareTrigger + assert len(device_config_template.advanced_control_box.findChildren(QtWidgets.QLabel)) == 5 + assert len(device_config_template.advanced_control_box.findChildren(ToggleSwitch)) == 3 + assert ( + len(device_config_template.advanced_control_box.findChildren(ReadoutPriorityComboBox)) + == 1 + ) + assert len(device_config_template.advanced_control_box.findChildren(OnFailureComboBox)) == 1 + + # Check connection box for CustomDevice, should be empty dict. + assert isinstance( + device_config_template.connection_settings_box.layout().itemAt(0).widget(), + ParameterValueWidget, + ) + + # Check additional settings box for CustomDevice, should be empty dict. + tool_box = device_config_template.additional_settings_box.layout().itemAt(0).widget() + assert isinstance(tool_box, QtWidgets.QToolBox) + assert isinstance(device_config_template._widgets["userParameter"], ParameterValueWidget) + assert isinstance(device_config_template._widgets["deviceTags"], DeviceTagsWidget) + + # Check default values and proper widgets in _widgets dict + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + assert isinstance(widget, ParameterValueWidget) + assert widget.parameters() == {} # Default empty dict for CustomDevice template + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_epics_motor( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test the DeviceConfigTemplate for the EpicsMotor device class.""" + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + + # Check that all widgets are created properly + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + for sub_field, sub_widget in widget.items(): + if sub_field in DEVICE_CONFIG_FIELDS: + field = DEVICE_CONFIG_FIELDS[sub_field] + assert isinstance(sub_widget, field.widget_cls) + if sub_field == "limits": + # Limits is LimitInputWidget + sub_widget: LimitInputWidget + assert sub_widget.get_limits() == [0, 0] # Default limits + else: + assert isinstance(widget, InputLineEdit) + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_get_set_config( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + # Test get config for default Custom Device template + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + config = device_config_template.get_config_fields() + for k, v in OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"].items(): + if k == "deviceConfig": + v: EpicsMotorDeviceConfigTemplate + v.model_validate(config["deviceConfig"]) + continue + if isinstance(v, (list, tuple)): + v = tuple(v) + config_value = config[k] + if isinstance(config_value, (list, tuple)): + config_value = tuple(config_value) + assert config_value == v + + # Set config from Model for custom EpicsMotor + model = DeviceModel( + name="motor_x", + deviceClass="ophyd.EpicsMotor", + readoutPriority="baseline", + enabled=False, + deviceConfig={"prefix": "MOTOR_X:", "limits": [-10, 10], "additional_field": 42}, + deviceTags=["motors", "x_axis"], + userParameter={"param1": 100, "param2": "value2"}, + ) + device_config_template.set_config_fields(model.model_dump()) + # Check config + config = device_config_template.get_config_fields() + assert config["name"] == "motor_x" + assert config["deviceClass"] == "ophyd.EpicsMotor" + assert config["readoutPriority"] == "baseline" + assert config["enabled"] is False + assert config["deviceConfig"] == { + "prefix": "MOTOR_X:", + "limits": [-10, 10], + "additional_field": 42, + } + assert set(config["deviceTags"]) == {"motors", "x_axis"} + assert config["userParameter"] == {"param1": 100, "param2": "value2"} + + def test_limit_input_widget(self, qtbot): + """Test LimitInputWidget functionality.""" + colors = get_accent_colors() + widget = LimitInputWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Default limits should be [0, 0] + assert widget.get_limits() == [0, 0] + + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Set limits externally + widget.set_limits([-5, 5]) + assert widget.get_limits() == [-5, 5] + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Enable toggle + with qtbot.waitSignal(widget.enable_toggle.stateChanged): + widget.enable_toggle.setChecked(True) + + assert widget.enable_toggle.isChecked() is True + # Set invalid limits (min >= max) + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(2) + widget.max_input.setValue(1) + + assert widget._is_valid_limit() is False + assert widget.min_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + assert widget.max_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + # Reset to default values + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(0) + widget.max_input.setValue(0) + + assert widget.get_limits() == [0, 0] + assert widget.min_input.styleSheet() == "" + assert widget.max_input.styleSheet() == "" + + def test_parameter_value_widget(self, qtbot): + """Test ParameterValueWidget functionality.""" + widget = ParameterValueWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no parameters + assert widget.parameters() == {} + + # Add parameters + sample_params = {"param1": 10, "param2": "value", "param3": True} + for k, v in sample_params.items(): + widget.add_parameter_line(k, v) + assert widget.parameters() == sample_params + + # Modify a parameter + param1_widget: InputLineEdit = widget.tree_widget.itemWidget( + widget.tree_widget.topLevelItem(0), 1 + ) + with qtbot.waitSignal(param1_widget.textChanged): + param1_widget.setText("20") + updated_params = widget.parameters() + assert updated_params["param1"] == 20 + assert updated_params["param2"] == "value" + assert updated_params["param3"] is True + # Select top item + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + widget.remove_parameter_line() + # Check that param1 is removed + assert widget.parameters() == {"param2": "value", "param3": True} + # Clear all parameters + widget.clear_widget() + assert widget.parameters() == {} + + def test_device_tags_widget(self, qtbot): + """Test DeviceTagsWidget functionality.""" + widget = DeviceTagsWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no tags + assert widget.parameters() == [] + + # Add tags + with qtbot.waitSignal(widget._button_add.clicked): + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 2 + assert widget.parameters() == [] # No value yet means no parameters + + # set tag text + widget_item = widget.tree_widget.topLevelItem(0) + tag_widget: InputLineEdit = widget.tree_widget.itemWidget(widget_item, 0) + with qtbot.waitSignal(tag_widget.textChanged): + tag_widget.setText("motor") + assert widget.parameters() == ["motor"] + + # Remove tag + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + with qtbot.waitSignal(widget._button_remove.clicked): + qtbot.mouseClick(widget._button_remove, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 1 + + # Clear all tags + widget.clear_widget() + assert widget.tree_widget.topLevelItemCount() == 0 diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index a85be731..220fa8cc 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -5,220 +5,657 @@ from unittest import mock import pytest -from qtpy import QtCore -from qtpy.QtWidgets import QFileDialog, QMessageBox +from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from qtpy import QtCore, QtWidgets -from bec_widgets.applications.views.device_manager_view.device_manager_view import ( +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import ( ConfigChoiceDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import ( + DeviceFormDialog, + DeviceManagerOphydValidationDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + DeviceStatusItem, + UploadRedisDialog, + ValidationSection, +) +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + DeviceManagerDisplayWidget, +) +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( DeviceManagerView, + DeviceManagerWidget, ) -from bec_widgets.utils.help_inspector.help_inspector import HelpInspector from bec_widgets.widgets.control.device_manager.components import ( - DeviceTableView, + DeviceTable, DMConfigView, - DMOphydTest, DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + OphydValidation, ) @pytest.fixture -def dm_view(qtbot): - """Fixture for DeviceManagerView.""" - widget = DeviceManagerView() - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget +def device_config() -> dict: + """Fixture for a sample device configuration.""" + return DeviceModel( + name="TestDevice", enabled=True, deviceClass="TestClass", readoutPriority="baseline" + ).model_dump() -@pytest.fixture -def config_choice_dialog(qtbot, dm_view): - """Fixture for ConfigChoiceDialog.""" - dialog = ConfigChoiceDialog(dm_view) - qtbot.addWidget(dialog) - qtbot.waitExposed(dialog) - yield dialog +class TestDeviceManagerViewDialogs: + """Test class for DeviceManagerView dialog interactions.""" + @pytest.fixture + def mock_dm_view(self, qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerDisplayWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget -def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog): - """Test the configuration choice dialog.""" - assert config_choice_dialog is not None - assert config_choice_dialog.parent() == dm_view + @pytest.fixture + def config_choice_dialog(self, qtbot, mock_dm_view): + """Fixture for ConfigChoiceDialog.""" + try: + dialog = ConfigChoiceDialog(mock_dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() - # Test dialog components - with ( - mock.patch.object(config_choice_dialog, "accept") as mock_accept, - mock.patch.object(config_choice_dialog, "reject") as mock_reject, + def test_config_choice_dialog(self, mock_dm_view, config_choice_dialog, qtbot): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == mock_dm_view + + # Test dialog components + with (mock.patch.object(config_choice_dialog, "done") as mock_done,): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.REPLACE) + mock_done.reset_mock() + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.ADD) + mock_done.reset_mock() + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.CANCEL) + + @pytest.fixture + def device_manager_ophyd_test_dialog(self, qtbot): + """Fixture for DeviceManagerOphydValidationDialog.""" + dialog = DeviceManagerOphydValidationDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_manager_ophyd_test_dialog( + self, device_manager_ophyd_test_dialog: DeviceManagerOphydValidationDialog, qtbot ): + """Test the DeviceManagerOphydValidationDialog.""" + dialog = device_manager_ophyd_test_dialog + assert dialog.text_box.toPlainText() == "" - # Replace - qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) - mock_accept.assert_called_once() - mock_reject.assert_not_called() - mock_accept.reset_mock() - assert config_choice_dialog.result() == config_choice_dialog.REPLACE - # Add - qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) - mock_accept.assert_called_once() - mock_reject.assert_not_called() - mock_accept.reset_mock() - assert config_choice_dialog.result() == config_choice_dialog.ADD - # Cancel - qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) - mock_accept.assert_not_called() - mock_reject.assert_called_once() - assert config_choice_dialog.result() == config_choice_dialog.CANCEL + dialog._on_device_validated( + {"name": "TestDevice", "enabled": True}, + config_status=0, + connection_status=0, + validation_msg="All good", + ) + assert dialog.validation_result == ( + {"name": "TestDevice", "enabled": True}, + 0, + 0, + "All good", + ) + assert dialog.text_box.toPlainText() != "" + + @pytest.fixture + def device_form_dialog(self, qtbot): + """Fixture for DeviceFormDialog.""" + dialog = DeviceFormDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_form_dialog(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test the DeviceFormDialog.""" + # Initial state + dialog = device_form_dialog + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES) + + # Test select a group from available templates + variant_combo = dialog._control_widgets["variant_combo"] + assert variant_combo.isEnabled() is False + + with qtbot.waitSignal(group_combo.currentTextChanged): + epics_signal_index = group_combo.findText("EpicsSignal") + group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group + + assert variant_combo.count() == len(OPHYD_DEVICE_TEMPLATES["EpicsSignal"]) + assert variant_combo.isEnabled() is True + + # Check that numb of widgets in connection settings box is correct + fields_in_config = len( + OPHYD_DEVICE_TEMPLATES["EpicsSignal"].get(variant_combo.currentText(), {}) + ) # At this point this should be read_pv & write_pv + connection_settings_layout: QtWidgets.QGridLayout = ( + dialog._device_config_template.connection_settings_box.layout() + ) + assert ( + connection_settings_layout.count() == fields_in_config * 2 + ) # Each field has a label and a widget + + def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test setting device configuration in DeviceFormDialog.""" + dialog = device_form_dialog + sample_config = { + "name": "TestDevice", + "enabled": True, + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "baseline", + "deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"}, + } + DeviceModel.model_validate(sample_config) + dialog.set_device_config(sample_config) + + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.currentText() == "EpicsSignal" + variant_combo: QtWidgets.QComboBox = dialog._control_widgets["variant_combo"] + assert variant_combo.currentText() == "EpicsSignal" + config = dialog._device_config_template.get_config_fields() + assert config["name"] == "TestDevice" + assert config["deviceClass"] == "ophyd.EpicsSignal" + assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" + # Set the validation results, assume that test was running + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.VALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + with qtbot.waitSignal(dialog.accepted_data) as sig_blocker: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + config, _, _, _, _ = sig_blocker.args + mock_warning_box.assert_not_called() + + # Called with config_status invalid should show warning + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.INVALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once() + + # Set to random config without name + + random_config = {"deviceClass": "Unknown"} + dialog.set_device_config(random_config) + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + 0, + 0, + "", + ) + assert group_combo.currentText() == "CustomDevice" + assert variant_combo.currentText() == "CustomDevice" + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once_with( + "Invalid Device Name", + f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + ) + + def test_device_status_item(self, device_config: dict, qtbot): + """Test the DeviceStatusItem widget.""" + item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0) + qtbot.addWidget(item) + qtbot.waitExposed(item) + assert item.device_config == device_config + assert item.device_name == device_config.get("name", "") + assert item.config_status == 0 + assert item.connection_status == 0 + assert "config_status" in item.icons + assert "connection_status" in item.icons + + # Update status + item.update_status(config_status=1, connection_status=2) + assert item.config_status == 1 + assert item.connection_status == 2 + + def test_validation_section(self, device_config: dict, qtbot): + """Test the validation section.""" + device_config_2 = device_config.copy() + device_config_2["name"] = "device_2" + + # Create section + section = ValidationSection(title="Validation Results") + qtbot.addWidget(section) + qtbot.waitExposed(section) + assert section.title() == "Validation Results" + initial_widget_in_container = section.table.rowCount() + + # Add widgets + section.add_device(device_config=device_config, config_status=0, connection_status=0) + assert initial_widget_in_container + 1 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config["name"]) == section.table.rowCount() - 1 + + # Add another device + section.add_device(device_config=device_config_2, config_status=1, connection_status=1) + assert initial_widget_in_container + 2 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config_2["name"]) == section.table.rowCount() - 1 + + # Clear devices + section.clear_devices() + assert section.table.rowCount() == 0 + + # Update test summary label + section.update_summary("2 devices validated, 1 failed.") + assert section.summary_label.text() == "2 devices validated, 1 failed." + + @pytest.fixture + def device_configs_valid(self, device_config: dict): + """Fixture for multiple device configurations.""" + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.VALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_invalid(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.INVALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_unknown(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.UNKNOWN.value, i) + return return_dict + + @pytest.fixture + def upload_redis_dialog(self, qtbot): + """Fixture for UploadRedisDialog.""" + dialog = UploadRedisDialog( + parent=None, ophyd_test_widget=mock.MagicMock(spec=OphydValidation), device_configs={} + ) + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_upload_redis_valid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_valid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_valid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_unknown_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_unknown, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_unknown + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_invalid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_invalid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_invalid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_validate_connections(self, device_configs_invalid, qtbot): + """Test the validate connections method in UploadRedisDialog.""" + configs = device_configs_invalid + ophyd_test_mock = mock.MagicMock(spec=OphydValidation) + try: + dialog = UploadRedisDialog( + parent=None, ophyd_test_widget=ophyd_test_mock, device_configs=configs + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + + with mock.patch.object( + dialog.ophyd_test_widget, "change_device_configs" + ) as mock_change: + dialog._validate_connections() + mock_change.assert_called_once_with( + [cfg for k, (cfg, _, _) in configs.items() if k in ["Device_0", "Device_3"]], + added=True, + connect=True, + ) + finally: + dialog.close() -class TestDeviceManagerViewInitialization: - """Test class for DeviceManagerView initialization and basic components.""" +class TestDeviceManagerView: + """Test class for DeviceManagerView functionality.""" - def test_dock_manager_initialization(self, dm_view): - """Test that the QtAds DockManager is properly initialized.""" - assert dm_view.dock_manager is not None - assert dm_view.dock_manager.centralWidget() is not None + @pytest.fixture + def dm_view(self, qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget - def test_central_widget_is_device_table_view(self, dm_view): - """Test that the central widget is DeviceTableView.""" - central_widget = dm_view.dock_manager.centralWidget().widget() - assert isinstance(central_widget, DeviceTableView) - assert central_widget is dm_view.device_table_view + def test_dm_view_initialization(self, dm_view, qtbot): + """Test DeviceManagerView initialization.""" + assert isinstance(dm_view.device_manager_widget, DeviceManagerWidget) + # If on_enter is called, overlay should be shown initially + dm_widget = dm_view.device_manager_widget + dm_view.on_enter() + assert dm_widget.stacked_layout.currentWidget() == dm_widget._overlay_widget - def test_dock_widgets_exist(self, dm_view): + with mock.patch.object(dm_widget.device_manager_display, "_load_file_action") as mock_load: + # Simulate clicking "Load Config From File" button + with qtbot.waitSignal(dm_widget.button_load_config_from_file.clicked): + qtbot.mouseClick(dm_widget.button_load_config_from_file, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + + # Reset for test loading current config + dm_widget._initialized = False + dm_widget.stacked_layout.setCurrentWidget(dm_widget._overlay_widget) + dm_widget.client.device_manager = mock.MagicMock() + + with mock.patch.object( + dm_widget.client.device_manager, "_get_redis_device_config" + ) as mock_get: + mock_get.return_value = [] + # Simulate clicking "Load Current Config" button + with mock.patch.object( + dm_widget.device_manager_display.device_table_view, "set_device_config" + ) as mock_set: + with qtbot.waitSignal(dm_widget.button_load_current_config.clicked): + qtbot.mouseClick(dm_widget.button_load_current_config, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert ( + dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + ) + mock_set.assert_called_once_with([]) + + @pytest.fixture + def device_manager_display_widget(self, qtbot): + """Fixture for DeviceManagerDisplayWidget within DeviceManagerView.""" + widget = DeviceManagerDisplayWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def device_configs(self, device_config: dict): + """Fixture for multiple device configurations.""" + cfg_iter = [] + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + cfg_iter.append(dev_config_copy) + return cfg_iter + + def test_device_manager_view_add_remove_device( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config + ): + """Test adding a device via the DeviceManagerView.""" + dm_view = device_manager_display_widget + dm_view._add_to_table_from_dialog( + device_config, config_status=0, connection_status=0, msg="" + ) + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [device_config] + + # Remove the device + dm_view.device_table_view.table.selectRow(0) + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [] + + def test_dock_widgets_exist(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that all required dock widgets are created.""" + dm_view = device_manager_display_widget dock_widgets = dm_view.dock_manager.dockWidgets() # Check that we have the expected number of dock widgets - assert len(dock_widgets) >= 4 + assert len(dock_widgets) == 4 # Check for specific widget types widget_types = [dock.widget().__class__ for dock in dock_widgets] + # OphydValidation is used in a layout with a QWidget assert DMConfigView in widget_types - assert DMOphydTest in widget_types assert DocstringView in widget_types + assert DeviceTable in widget_types - def test_toolbar_initialization(self, dm_view): + def test_toolbar_initialization( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test that the toolbar is properly initialized with expected bundles.""" + dm_view = device_manager_display_widget assert dm_view.toolbar is not None assert "IO" in dm_view.toolbar.bundles assert "Table" in dm_view.toolbar.bundles - def test_toolbar_components_exist(self, dm_view): - """Test that all expected toolbar components exist.""" - expected_components = [ - "load", - "save_to_disk", - "load_redis", - "update_config_redis", - "reset_composed", - "add_device", - "remove_device", - "rerun_validation", - ] - - for component in expected_components: - assert dm_view.toolbar.components.exists(component) - - def test_signal_connections(self, dm_view): - """Test that signals are properly connected between components.""" - # Test that device_table_view signals are connected - assert dm_view.device_table_view.selected_devices is not None - assert dm_view.device_table_view.device_configs_changed is not None - - # Test that ophyd_test_view signals are connected - assert dm_view.ophyd_test_view.device_validated is not None - - -class TestDeviceManagerViewIOBundle: - """Test class for DeviceManagerView IO bundle actions.""" - - def test_io_bundle_exists(self, dm_view): + def test_io_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that IO bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget assert "IO" in dm_view.toolbar.bundles - io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"] + io_actions = ["load", "save_to_disk", "flush_redis", "load_redis", "update_config_redis"] for action in io_actions: assert dm_view.toolbar.components.exists(action) - def test_load_file_action_triggered(self, tmp_path, dm_view): + def test_load_file_action_triggered( + self, tmp_path, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test load file action trigger mechanism.""" - + dm_view = device_manager_display_widget with ( - mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), - mock.patch( - "bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load" - ) as mock_yaml_load, - mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog, + mock.patch.object(dm_view, "_get_config_base_path", return_value=tmp_path), + mock.patch.object( + dm_view, "_get_file_path", return_value=str(tmp_path) + ) as mock_get_file, + mock.patch.object(dm_view, "_load_config_from_file") as mock_load_config, ): - mock_yaml_data = {"device1": {"param1": "value1"}} - mock_yaml_load.return_value = mock_yaml_data - # Setup dialog mock dm_view.toolbar.components._components["load"].action.action.triggered.emit() - mock_yaml_load.assert_called_once_with(tmp_path) - mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}]) + mock_get_file.assert_called_once_with(str(tmp_path), "open_file") + mock_load_config.assert_called_once_with(str(tmp_path)) - def test_save_config_to_file(self, tmp_path, dm_view): - """Test saving config to file.""" - yaml_path = tmp_path / "test_save.yaml" - mock_config = [{"name": "device1", "param1": "value1"}] - with ( - mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), - mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path), - mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path), - mock.patch.object( - dm_view.device_table_view, "get_device_config", return_value=mock_config - ), - ): - dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() - assert yaml_path.exists() - - -class TestDeviceManagerViewTableBundle: - """Test class for DeviceManagerView Table bundle actions.""" - - def test_table_bundle_exists(self, dm_view): + def test_table_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that Table bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget assert "Table" in dm_view.toolbar.bundles table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] for action in table_actions: assert dm_view.toolbar.components.exists(action) @mock.patch( - "bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question" + "bec_widgets.applications.views.device_manager_view.device_manager_display_widget._yes_no_question" ) - def test_reset_composed_view(self, mock_question, dm_view): + def test_reset_composed_view( + self, mock_question, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test reset composed view when user confirms.""" + dm_view = device_manager_display_widget with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: - mock_question.return_value = QMessageBox.StandardButton.Yes + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.Yes dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() mock_clear.assert_called_once() mock_clear.reset_mock() - mock_question.return_value = QMessageBox.StandardButton.No + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.No dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() mock_clear.assert_not_called() - def test_add_device_action_connected(self, dm_view): + def test_add_device_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test add device action opens dialog correctly.""" + dm_view = device_manager_display_widget with mock.patch.object(dm_view, "_add_device_action") as mock_add: dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() mock_add.assert_called_once() - def test_remove_device_action(self, dm_view): - """Test remove device action.""" - with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove: - dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() - mock_remove.assert_called_once() + def test_run_validate_connection_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict + ): + """Test run validate connection action is connected.""" + dm_view = device_manager_display_widget - def test_rerun_device_validation(self, dm_view): - """Test rerun device validation action.""" - cfgs = [{"name": "device1", "param1": "value1"}] - with ( - mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change, - mock.patch.object( - dm_view.device_table_view.table, "selected_configs", return_value=cfgs - ), - ): + with mock.patch.object( + dm_view.ophyd_test_view, "change_device_configs" + ) as mock_change_configs: + # First, add device configs to the table + dm_view.device_table_view.add_device_configs(device_configs) + assert mock_change_configs.call_args[0][1] is True # Configs were added + mock_change_configs.reset_mock() + + # Trigger the validate connection action without selection, should validate all dm_view.toolbar.components._components[ "rerun_validation" ].action.action.triggered.emit() - mock_change.assert_called_once_with(cfgs, True, True) + assert len(mock_change_configs.call_args[0][0]) == len(device_configs) + assert mock_change_configs.call_args[0][1:] == (True, True) # Configs were not added + mock_change_configs.reset_mock() + + # Select a single row and trigger again, should only validate that one + dm_view.device_table_view.table.selectRow(0) + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + assert len(mock_change_configs.call_args[0][0]) == 1 diff --git a/tests/unit_tests/test_utils_bec_list.py b/tests/unit_tests/test_utils_bec_list.py new file mode 100644 index 00000000..17fcd30f --- /dev/null +++ b/tests/unit_tests/test_utils_bec_list.py @@ -0,0 +1,128 @@ +"""Tests for the BECList widget.""" + +from unittest.mock import MagicMock + +import pytest +from qtpy import QtWidgets + +from bec_widgets.utils.bec_list import BECList + + +@pytest.fixture +def bec_list(qtbot): + widget = BECList() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def sample_widget(qtbot): + widget = QtWidgets.QLabel("sample") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + +class TestBECList: + def test_add_widget_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key1", sample_widget) + + assert "key1" in bec_list + assert bec_list.count() == 1 + retrieved_widget = bec_list.get_widget("key1") + assert retrieved_widget == sample_widget + retrieved_item = bec_list.get_item("key1") + assert retrieved_item is not None + assert bec_list.itemWidget(retrieved_item) == sample_widget + + def test_add_widget_item_replaces_existing(self, bec_list, sample_widget, qtbot): + bec_list.add_widget_item("key", sample_widget) + replacement = QtWidgets.QLabel("replacement") + qtbot.addWidget(replacement) + qtbot.waitExposed(replacement) + + bec_list.add_widget_item("key", replacement) + + assert bec_list.count() == 1 + assert bec_list.get_widget("key") == replacement + # ensure first widget no longer tracked + assert sample_widget not in bec_list.get_widgets() + + def test_remove_widget_item(self, bec_list, sample_widget, monkeypatch): + bec_list.add_widget_item("key", sample_widget) + + close_mock = MagicMock() + delete_mock = MagicMock() + monkeypatch.setattr(sample_widget, "close", close_mock) + monkeypatch.setattr(sample_widget, "deleteLater", delete_mock) + + bec_list.remove_widget_item("key") + + assert bec_list.count() == 0 + assert "key" not in bec_list + close_mock.assert_called_once() + delete_mock.assert_called_once() + + def test_remove_widget_item_missing_key(self, bec_list): + bec_list.remove_widget_item("missing") + assert bec_list.count() == 0 + + def test_clear_widgets(self, bec_list, qtbot): + for key in ["a", "b", "c"]: + label = QtWidgets.QLabel(key) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + bec_list.clear_widgets() + + assert bec_list.count() == 0 + assert bec_list.get_widgets() == [] + assert bec_list.get_all_keys() == [] + + def test_get_widget_and_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item("key") + assert item is not None + assert bec_list.get_widget_for_item(item) == sample_widget + assert bec_list.get_widget("key") == sample_widget + + def test_get_item_for_widget(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item_for_widget(sample_widget) + assert item is not None + assert bec_list.itemWidget(item) == sample_widget + + def test_get_all_keys(self, bec_list, qtbot): + labels = [] + for key in ["k1", "k2", "k3"]: + label = QtWidgets.QLabel(key) + labels.append(label) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + assert sorted(bec_list.get_all_keys()) == ["k1", "k2", "k3"] + assert set(bec_list.get_widgets()) == set(labels) + + def test_get_widget_for_item_unknown(self, bec_list, sample_widget): + unrelated_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(unrelated_item) is None + + bec_list.add_widget_item("key", sample_widget) + other_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(other_item) is None + + def test_get_item_for_widget_unknown(self, bec_list, qtbot): + label = QtWidgets.QLabel("orphan") + qtbot.addWidget(label) + qtbot.waitExposed(label) + assert bec_list.get_item_for_widget(label) is None + + def test_contains(self, bec_list, sample_widget): + assert "key" not in bec_list + bec_list.add_widget_item("key", sample_widget) + assert "key" in bec_list