1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat(device-manager): Add DeviceManager Widget for BEC Widget main applications

This commit is contained in:
2025-11-20 23:50:28 +01:00
parent fe8e6d9427
commit 82ce27a700
32 changed files with 7722 additions and 3250 deletions

View File

@@ -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(

View File

@@ -0,0 +1,2 @@
from .config_choice_dialog import ConfigChoiceDialog
from .device_form_dialog import DeviceFormDialog

View File

@@ -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()

View File

@@ -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())

View File

@@ -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()

View File

@@ -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_())

View File

@@ -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_())

View File

@@ -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

View File

@@ -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())

View File

@@ -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):

View File

@@ -0,0 +1 @@
from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation

View File

@@ -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

View File

@@ -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.",
},
}

View File

@@ -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_())

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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", "<not found>")
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<device>[^\s]+)\s+"
r"(?P<status>is not valid|is not connectable|failed):\s*"
r"(?P<detail>.*?)(?=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_())

View File

@@ -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

View File

@@ -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_())

View File

@@ -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<device>\S+)\s+is\s+OK\.?(?:\s*(?P<detail>.*))?$", 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<device>[^\s]+)\s+"
r"(?P<status>is not valid|is not connectable|failed):\s*"
r"(?P<detail>.*?)(?=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

View File

@@ -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())

View File

@@ -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.
"""

View File

@@ -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):

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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