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:
@@ -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(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .config_choice_dialog import ConfigChoiceDialog
|
||||
from .device_form_dialog import DeviceFormDialog
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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_())
|
||||
@@ -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_())
|
||||
|
||||
@@ -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
|
||||
|
||||
93
bec_widgets/utils/bec_list.py
Normal file
93
bec_widgets/utils/bec_list.py
Normal 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())
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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_())
|
||||
@@ -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),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_())
|
||||
@@ -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
|
||||
@@ -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_())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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
|
||||
|
||||
128
tests/unit_tests/test_utils_bec_list.py
Normal file
128
tests/unit_tests/test_utils_bec_list.py
Normal 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
|
||||
Reference in New Issue
Block a user