mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-04 16:02:51 +01:00
refactor(busy-loader): refactor busy loader to use custom widget
This commit is contained in:
@@ -6,6 +6,7 @@ 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 zmq.devices import Device
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
@@ -56,8 +57,6 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
if device_name:
|
||||
self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name)
|
||||
|
||||
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():
|
||||
@@ -71,6 +70,9 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
self._resize_dialog()
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
# Add and test device config
|
||||
self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True)
|
||||
|
||||
def _resize_dialog(self):
|
||||
"""Resize the dialog based on the screen size."""
|
||||
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
|
||||
@@ -285,7 +287,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
The dialog will be modal and prevent user interaction until validation is complete.
|
||||
"""
|
||||
wait_dialog = QtWidgets.QProgressDialog(
|
||||
"Validating config… please wait", None, 0, 0, parent=self
|
||||
"Validating config... please wait", None, 0, 0, parent=self
|
||||
)
|
||||
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
wait_dialog.setCancelButton(None)
|
||||
@@ -368,7 +370,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
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} ",
|
||||
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal, get_args
|
||||
from typing import TYPE_CHECKING, List, Literal, get_args
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
@@ -11,9 +11,17 @@ 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_qthemes import apply_theme, material_icon
|
||||
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
|
||||
ConfigChoiceDialog,
|
||||
@@ -22,6 +30,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs i
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
UploadRedisDialog,
|
||||
)
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
@@ -38,9 +47,16 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophy
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
)
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -51,6 +67,74 @@ _yes_no_question = partial(
|
||||
)
|
||||
|
||||
|
||||
class CustomBusyWidget(QWidget):
|
||||
"""Custom busy widget to show during device config upload."""
|
||||
|
||||
cancel_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None, client: BECClient | None = None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Widgets
|
||||
progress = DeviceInitializationProgressBar(parent=self, client=client)
|
||||
|
||||
# Spinner
|
||||
spinner = SpinnerWidget(parent=self)
|
||||
scale = self._ui_scale()
|
||||
spinner_size = int(scale * 0.12) if scale else 1
|
||||
spinner_size = max(32, min(spinner_size, 64))
|
||||
spinner.setFixedSize(spinner_size, spinner_size)
|
||||
|
||||
# Cancel button
|
||||
cancel_button = QPushButton("Cancel Upload", parent=self)
|
||||
cancel_button.setIcon(material_icon("cancel"))
|
||||
cancel_button.clicked.connect(self.cancel_requested.emit)
|
||||
button_height = int(spinner_size * 0.9)
|
||||
button_height = max(36, min(button_height, 72))
|
||||
aspect_ratio = 3.8 # width / height, visually stable for text buttons
|
||||
button_width = int(button_height * aspect_ratio)
|
||||
cancel_button.setFixedSize(button_width, button_height)
|
||||
color = get_accent_colors()
|
||||
cancel_button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
background-color: {color.emergency.name()};
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
# Layout
|
||||
content_layout = QVBoxLayout(self)
|
||||
content_layout.setContentsMargins(24, 24, 24, 24)
|
||||
content_layout.setSpacing(16)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(spinner, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(cancel_button, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
def _ui_scale(self) -> int:
|
||||
parent = self.parent()
|
||||
if not parent:
|
||||
return 0
|
||||
return min(parent.width(), parent.height())
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.stop()
|
||||
|
||||
|
||||
class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
|
||||
|
||||
@@ -61,13 +145,22 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", *args, **kwargs)
|
||||
|
||||
# State variable for config upload
|
||||
self._config_upload_active: bool = False
|
||||
|
||||
# Push to Redis dialog
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||
# will block if we use the config_helper from self.client.config._config_helper
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Custom upload widget for busy overlay
|
||||
self._custom_overlay_widget: QWidget | None = None
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTable(self)
|
||||
|
||||
@@ -125,6 +218,43 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
# Build dock layout using shared helpers
|
||||
self._build_docks()
|
||||
|
||||
logger.info("Connecting application about to quit signal to device manager view...")
|
||||
QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler)
|
||||
|
||||
##############################
|
||||
### Custom set busy widget ###
|
||||
##############################
|
||||
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""Create a custom busy state widget for uploading device configurations."""
|
||||
widget = CustomBusyWidget(parent=self, client=self.client)
|
||||
widget.cancel_requested.connect(self._cancel_device_config_upload)
|
||||
return widget
|
||||
|
||||
################################
|
||||
### Application quit handler ###
|
||||
################################
|
||||
|
||||
@SafeSlot()
|
||||
def _about_to_quit_handler(self):
|
||||
"""Handle application about to quit event. If config upload is active, cancel it."""
|
||||
logger.info("Application is quitting, checking for active config upload...")
|
||||
if self._config_upload_active:
|
||||
logger.info("Application is quitting, cancelling active config upload...")
|
||||
self._config_helper.send_config_request(
|
||||
action="cancel", config=None, wait_for_response=True, timeout_s=10
|
||||
)
|
||||
logger.info("Config upload cancelled.")
|
||||
|
||||
def _set_busy_wrapper(self, enabled: bool):
|
||||
"""Thin wrapper around set_busy to flip the state variable."""
|
||||
self._config_upload_active = enabled
|
||||
self.set_busy(enabled=enabled)
|
||||
|
||||
##############################
|
||||
### Toolbar and Dock setup ###
|
||||
##############################
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
@@ -306,6 +436,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
#######################
|
||||
### Action Handlers ###
|
||||
#######################
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _run_validate_connection(self, connect: bool = True):
|
||||
@@ -432,10 +566,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"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()
|
||||
@@ -511,12 +643,49 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
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...")
|
||||
self._set_busy_wrapper(enabled=True)
|
||||
|
||||
def _cancel_device_config_upload(self):
|
||||
"""Cancel the device configuration upload process."""
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel")
|
||||
# Cancelling will raise an exception in the communicator, so we connect to the failure handler
|
||||
comm.signals.error.connect(self._handle_cancel_config_upload_failed)
|
||||
threadpool.start(comm)
|
||||
|
||||
def _handle_cancel_config_upload_failed(self, exception: Exception):
|
||||
"""Handle failure to cancel the config upload."""
|
||||
QMessageBox.critical(self, "Error Cancelling Upload", f"{str(exception)}")
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
devices_to_update = []
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
devices_to_update.append(
|
||||
(config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled")
|
||||
)
|
||||
# Rerun validation of all devices after cancellation
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
self.ophyd_test_view.change_device_configs(
|
||||
[cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False
|
||||
)
|
||||
# Config is in sync with BEC, so we update the state
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(False)
|
||||
|
||||
# Cleanup custom overlay widget
|
||||
if self._custom_overlay_widget is not None:
|
||||
self._custom_overlay_widget.close()
|
||||
self._custom_overlay_widget.deleteLater()
|
||||
self._custom_overlay_widget = None
|
||||
|
||||
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()
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
# Cleanup custom overlay widget
|
||||
if self._custom_overlay_widget is not None:
|
||||
self._custom_overlay_widget.close()
|
||||
self._custom_overlay_widget.deleteLater()
|
||||
self._custom_overlay_widget = None
|
||||
|
||||
def _handle_exception_from_communicator(self, exception: Exception):
|
||||
"""Handle exceptions from the config communicator."""
|
||||
@@ -525,29 +694,11 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"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, "")
|
||||
)
|
||||
# Update validation status in device table view
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
# Remove devices from ophyd validation view
|
||||
self.ophyd_test_view.change_device_configs(
|
||||
[cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True
|
||||
)
|
||||
# Config is in sync with BEC, so we update the state
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(True)
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
if self._custom_overlay_widget is not None:
|
||||
self._custom_overlay_widget.close()
|
||||
self._custom_overlay_widget.deleteLater()
|
||||
self._custom_overlay_widget = None
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
@@ -613,8 +764,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
):
|
||||
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], skip_validation=True)
|
||||
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
|
||||
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
@@ -625,8 +775,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
self.device_table_view.add_device_configs([data], skip_validation=True)
|
||||
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.device_table_view.update_device_configs([data], skip_validation=False)
|
||||
else: # Connection status was tested in dialog
|
||||
# If device is connected, we remove it from the ophyd validation view
|
||||
self.device_table_view.update_device_configs([data], skip_validation=True)
|
||||
# Update validation status in device table view and ophyd validation view
|
||||
self.ophyd_test_view._on_device_test_completed(
|
||||
data, config_status, connection_status, msg
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
|
||||
@@ -36,6 +36,7 @@ _Widgets = {
|
||||
"DarkModeButton": "DarkModeButton",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"DeviceComboBox": "DeviceComboBox",
|
||||
"DeviceInitializationProgressBar": "DeviceInitializationProgressBar",
|
||||
"DeviceLineEdit": "DeviceLineEdit",
|
||||
"Heatmap": "Heatmap",
|
||||
"Image": "Image",
|
||||
@@ -1071,6 +1072,79 @@ class DeviceComboBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DeviceInitializationProgressBar(RPCBase):
|
||||
"""A progress bar that displays the progress of device initialization."""
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, value):
|
||||
"""
|
||||
Set the value of the progress bar.
|
||||
|
||||
Args:
|
||||
value (float): The value to set.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_maximum(self, maximum: float):
|
||||
"""
|
||||
Set the maximum value of the progress bar.
|
||||
|
||||
Args:
|
||||
maximum (float): The maximum value.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_minimum(self, minimum: float):
|
||||
"""
|
||||
Set the minimum value of the progress bar.
|
||||
|
||||
Args:
|
||||
minimum (float): The minimum value.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label_template(self):
|
||||
"""
|
||||
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
|
||||
|
||||
Examples:
|
||||
>>> progressbar.label_template = "$value / $maximum - $percentage %"
|
||||
>>> progressbar.label_template = "$value / $percentage %"
|
||||
"""
|
||||
|
||||
@label_template.setter
|
||||
@rpc_call
|
||||
def label_template(self):
|
||||
"""
|
||||
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
|
||||
|
||||
Examples:
|
||||
>>> progressbar.label_template = "$value / $maximum - $percentage %"
|
||||
>>> progressbar.label_template = "$value / $percentage %"
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def state(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@state.setter
|
||||
@rpc_call
|
||||
def state(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_label(self) -> str:
|
||||
"""
|
||||
Return the label text. mostly used for testing rpc.
|
||||
"""
|
||||
|
||||
|
||||
class DeviceInputBase(RPCBase):
|
||||
"""Mixin base class for device input widgets."""
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import TYPE_CHECKING
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
@@ -15,8 +15,10 @@ from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -38,7 +40,6 @@ class BECWidget(BECConnector):
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -65,18 +66,14 @@ class BECWidget(BECConnector):
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._busy_overlay: "BusyLoaderOverlay" | None = None
|
||||
self._busy_state_widget: QWidget | None = None
|
||||
|
||||
self._loading = False
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
try:
|
||||
overlay = self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if overlay is not None:
|
||||
overlay.setGeometry(self.rect())
|
||||
overlay.raise_()
|
||||
overlay.show()
|
||||
self._loading = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Busy loader init skipped: {exc}")
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
self._adjust_busy_overlay()
|
||||
self._loading = True
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
@@ -97,8 +94,70 @@ class BECWidget(BECConnector):
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""
|
||||
Method to create a custom busy state widget to be shown in the busy overlay.
|
||||
Child classes should overrid this method to provide a custom widget if desired.
|
||||
|
||||
Returns:
|
||||
QWidget: The custom busy state widget.
|
||||
|
||||
NOTE:
|
||||
The implementation here is a SpinnerWidget with a "Loading..." label. This is the default
|
||||
busy state widget for all BECWidgets. However, child classes with specific needs for the
|
||||
busy state can easily overrite this method to provide a custom widget. The signature of
|
||||
the method must be preserved to ensure compatibility with the busy overlay system. If
|
||||
the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up.
|
||||
|
||||
The widget may connect to the _busy_overlay signals foreground_color_changed and
|
||||
scrim_color_changed to update its colors when the theme changes.
|
||||
"""
|
||||
|
||||
# Widget
|
||||
class BusyStateWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# label
|
||||
label = QLabel("Loading...", self)
|
||||
label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
label.setFont(f)
|
||||
|
||||
# spinner
|
||||
spinner = SpinnerWidget(self)
|
||||
spinner.setFixedSize(42, 42)
|
||||
|
||||
# Layout
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
self.setLayout(lay)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.stop()
|
||||
|
||||
widget = BusyStateWidget(self)
|
||||
|
||||
return widget
|
||||
|
||||
def _install_busy_loader(self) -> "BusyLoaderOverlay" | None:
|
||||
"""
|
||||
Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
Returns the overlay instance or None if not a QWidget.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
@@ -107,38 +166,39 @@ class BECWidget(BECConnector):
|
||||
if overlay is None:
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
overlay = install_busy_loader(self, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
|
||||
# Create and set the busy state widget
|
||||
self._busy_state_widget = self.create_busy_state_widget()
|
||||
self._busy_overlay.set_widget(self._busy_state_widget)
|
||||
return overlay
|
||||
|
||||
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
def _adjust_busy_overlay(self) -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
if self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
def set_busy(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
Set the busy state of the widget. This will show or hide the loading overlay, which will
|
||||
block user interaction with the widget and show the busy_state_widget if provided. Per
|
||||
default, the busy state widget is a spinner with "Loading..." text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the loading overlay.
|
||||
text(str, optional): The text to display on the overlay. If None, the text is not changed.
|
||||
enabled(bool): Whether to enable the busy state.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if getattr(self, "_busy_overlay", None) is None:
|
||||
self._ensure_busy_overlay(busy_text=text or "Loading…")
|
||||
if text is not None:
|
||||
self.set_busy_text(text)
|
||||
# If not yet installed, install the busy overlay now together with the busy state widget
|
||||
if self._busy_overlay is None:
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
if enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
self._adjust_busy_overlay()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
@@ -152,19 +212,6 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
return bool(getattr(self, "_loading", False))
|
||||
|
||||
def set_busy_text(self, text: str) -> None:
|
||||
"""
|
||||
Update the text on the loading overlay.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
overlay = self._ensure_busy_overlay(busy_text=text)
|
||||
if overlay is not None:
|
||||
overlay.set_text(text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
@@ -177,8 +224,8 @@ class BECWidget(BECConnector):
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
if overlay is not None:
|
||||
overlay._update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
@@ -304,10 +351,13 @@ class BECWidget(BECConnector):
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
self._busy_overlay = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -15,8 +15,8 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
@@ -53,132 +53,200 @@ class BusyLoaderOverlay(QWidget):
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
foreground_color_changed = Signal(QColor)
|
||||
scrim_color_changed = Signal(QColor)
|
||||
|
||||
def __init__(self, parent: QWidget, opacity: float = 0.85, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAutoFillBackground(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self._opacity = opacity
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self._filter: QObject | None = None
|
||||
|
||||
self._label = QLabel(text, self)
|
||||
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(self._label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
self._label.setFont(f)
|
||||
# Set Main Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(10)
|
||||
self.setLayout(layout)
|
||||
|
||||
self._spinner = SpinnerWidget(self)
|
||||
self._spinner.setFixedSize(42, 42)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(self._label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
# Custom widget placeholder
|
||||
self._custom_widget: QWidget | None = None
|
||||
|
||||
# Add a frame around the content
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
self._update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
@SafeProperty(QColor, notify=scrim_color_changed)
|
||||
def scrim_color(self) -> QColor:
|
||||
"""
|
||||
Update the overlay text.
|
||||
The overlay scrim color.
|
||||
"""
|
||||
return self._scrim_color
|
||||
|
||||
@scrim_color.setter
|
||||
def scrim_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
raise TypeError("scrim_color must be a QColor")
|
||||
self._scrim_color = value
|
||||
self.update()
|
||||
|
||||
@SafeProperty(QColor, notify=foreground_color_changed)
|
||||
def foreground_color(self) -> QColor:
|
||||
"""
|
||||
The overlay foreground color (text, spinner).
|
||||
"""
|
||||
return self._label_color
|
||||
|
||||
@foreground_color.setter
|
||||
def foreground_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
try:
|
||||
color = QColor(value)
|
||||
if not color.isValid():
|
||||
raise ValueError(f"Invalid color: {value}")
|
||||
except Exception:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise ValueError(f"Color {value} is invalid, cannot be converted to QColor")
|
||||
self._label_color = value
|
||||
self.update()
|
||||
|
||||
def set_filter(self, filt: _OverlayEventFilter):
|
||||
"""
|
||||
Set an event filter to keep the overlay sized and stacked over its target.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
filt(QObject): The event filter instance.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
self._filter = filt
|
||||
target = filt._target
|
||||
if self.parent() != target:
|
||||
logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}")
|
||||
target.installEventFilter(self._filter)
|
||||
|
||||
######################
|
||||
### Public methods ###
|
||||
######################
|
||||
|
||||
def set_widget(self, widget: QWidget):
|
||||
"""
|
||||
Set a custom widget as an overlay for the busy overlay.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The custom widget to display.
|
||||
"""
|
||||
lay = self.layout()
|
||||
if lay is None:
|
||||
return
|
||||
self._custom_widget = widget
|
||||
lay.addWidget(widget, 0, Qt.AlignHCenter)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a
|
||||
value outside this range is provided, it will be clamped.
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
|
||||
"""
|
||||
self._opacity = max(0.0, min(1.0, float(opacity)))
|
||||
# Re-apply alpha using the current theme color
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
base = self.scrim_color
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self.scrim_color = base
|
||||
self.update()
|
||||
|
||||
def update_palette(self):
|
||||
##########################
|
||||
### Internal methods ###
|
||||
##########################
|
||||
|
||||
def _update_palette(self):
|
||||
"""
|
||||
Update colors from the current application theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme # type: ignore[attr-defined]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
_app = QApplication.instance()
|
||||
if hasattr(_app, "theme"):
|
||||
theme = _app.theme # type: ignore[attr-defined]
|
||||
_bg = theme.color("BORDER")
|
||||
_fg = theme.color("FG")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
_bg = QColor(30, 30, 30)
|
||||
_fg = QColor(230, 230, 230)
|
||||
|
||||
# Semi-transparent scrim derived from bg
|
||||
self._scrim_color = QColor(self._bg)
|
||||
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self._spinner.update()
|
||||
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
|
||||
self._label.setStyleSheet(f"color: {fg_hex};")
|
||||
base = _bg if isinstance(_bg, QColor) else QColor(str(_bg))
|
||||
base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self.scrim_color = base
|
||||
fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg))
|
||||
self.foreground_color = fg
|
||||
|
||||
# Set the frame style with updated foreground colors
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
#############################
|
||||
### Custom Event Handlers ###
|
||||
#############################
|
||||
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
# Call showEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.showEvent(e)
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
# Call hideEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.hideEvent(e)
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
# Call resizeEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.resizeEvent(e)
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
# TODO should we have this cleanup here?
|
||||
def cleanup(self):
|
||||
"""Cleanup resources used by the overlay."""
|
||||
if self._custom_widget is not None:
|
||||
if hasattr(self._custom_widget, "cleanup"):
|
||||
self._custom_widget.cleanup()
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
target: QWidget, start_loading: bool = False, opacity: float = 0.35
|
||||
) -> BusyLoaderOverlay:
|
||||
"""
|
||||
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
|
||||
|
||||
Args:
|
||||
target(QWidget): The widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay = BusyLoaderOverlay(parent=target, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
overlay.set_filter(_OverlayEventFilter(target, overlay))
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
@@ -188,10 +256,8 @@ def install_busy_loader(
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
|
||||
)
|
||||
def __init__(self, parent=None, start_busy: bool = False):
|
||||
super().__init__(parent=parent, theme_update=True, start_busy=start_busy)
|
||||
|
||||
self._title = QLabel("Demo Content", self)
|
||||
self._title.setAlignment(Qt.AlignCenter)
|
||||
@@ -214,15 +280,14 @@ class DemoWindow(QMainWindow):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget()
|
||||
left = DemoWidget(start_busy=True)
|
||||
right = DemoWidget()
|
||||
|
||||
btn_on = QPushButton("Right → Loading")
|
||||
btn_off = QPushButton("Right → Ready")
|
||||
btn_text = QPushButton("Set custom text")
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
|
||||
|
||||
panel = QWidget()
|
||||
prow = QVBoxLayout(panel)
|
||||
|
||||
@@ -5,10 +5,12 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable, Iterable, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
@@ -26,6 +28,9 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
|
||||
get_validation_icons,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import ConfigAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_DeviceCfgIter = Iterable[dict[str, Any]]
|
||||
@@ -208,6 +213,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Signal emitted when the device config is in sync with Redis
|
||||
device_config_in_sync_with_redis = QtCore.Signal(bool)
|
||||
|
||||
# Request multiple validation updates for devices
|
||||
request_update_multiple_device_validations = QtCore.Signal(list)
|
||||
# Request update after client DEVICE_UPDATE event
|
||||
request_update_after_client_device_update = QtCore.Signal()
|
||||
|
||||
_auto_size_request = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None, client=None):
|
||||
@@ -267,15 +277,66 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
# Connect slots
|
||||
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
||||
self.table.cellDoubleClicked.connect(self._on_cell_double_clicked)
|
||||
self.request_update_multiple_device_validations.connect(
|
||||
self.update_multiple_device_validations
|
||||
)
|
||||
self.request_update_after_client_device_update.connect(self._on_device_config_update)
|
||||
# Install event filter
|
||||
self.table.installEventFilter(self)
|
||||
|
||||
# Add hook to BECClient for DeviceUpdates
|
||||
self.client_callback_id = self.client.callbacks.register(
|
||||
event_type=EventType.DEVICE_UPDATE, callback=self.__on_client_device_update_event
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources."""
|
||||
self.row_data.clear() # Drop references to row data..
|
||||
# self._autosize_timer.stop()
|
||||
self.client.callbacks.remove(self.client_callback_id) # Unregister callback
|
||||
super().cleanup()
|
||||
|
||||
def __on_client_device_update_event(
|
||||
self, action: "ConfigAction", config: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
"""Handle DEVICE_UPDATE events from the BECClient."""
|
||||
self.request_update_after_client_device_update.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_device_config_update(self) -> None:
|
||||
"""Handle device configuration updates from the BECClient."""
|
||||
# Determine the overlapping device configs between Redis and the table
|
||||
device_config_overlap_with_bec = self._get_overlapping_configs()
|
||||
if len(device_config_overlap_with_bec) > 0:
|
||||
# Notify any listeners about the update, the device manager devices will now be up to date
|
||||
self.device_configs_changed.emit(device_config_overlap_with_bec, True, True)
|
||||
|
||||
# Correct all connection statuses in the table which are ConnectionStatus.CONNECTED
|
||||
# to ConnectionStatus.CAN_CONNECT
|
||||
device_status_updates = []
|
||||
validation_results = self.get_validation_results()
|
||||
for device_name, (cfg, config_status, connection_status) in validation_results.items():
|
||||
if device_name is None:
|
||||
continue
|
||||
# Check if config is not in the overlap, but connection status is CONNECTED
|
||||
# Update to CAN_CONNECT
|
||||
if cfg not in device_config_overlap_with_bec:
|
||||
if connection_status == ConnectionStatus.CONNECTED.value:
|
||||
device_status_updates.append(
|
||||
(cfg, config_status, ConnectionStatus.CAN_CONNECT.value, "")
|
||||
)
|
||||
# Update only if there are any updates
|
||||
if len(device_status_updates) > 0:
|
||||
# NOTE We need to emit here a signal to call update_multiple_device_validations
|
||||
# as this otherwise can cause problems with being executed from a python callback
|
||||
# thread which are not properly scheduled in the Qt event loop. We see that this
|
||||
# has caused issues in form of segfaults under certain usage of the UI. Please
|
||||
# do not remove this signal & slot mechanism!
|
||||
self.request_update_multiple_device_validations.emit(device_status_updates)
|
||||
|
||||
# Check if in sync with BEC server session
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Custom hooks for table events
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -769,6 +830,51 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
logger.error(f"Error comparing device configs: {e}")
|
||||
return False
|
||||
|
||||
def _get_overlapping_configs(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the device configs that overlap between the table and the config in the current running BEC session.
|
||||
A device will be ignored if it is disabled in the BEC session.
|
||||
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to check.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: The list of overlapping device configs.
|
||||
"""
|
||||
overlapping_configs = []
|
||||
for cfg in self.get_device_config():
|
||||
device_name = cfg.get("name", None)
|
||||
if device_name is None:
|
||||
continue
|
||||
if self._is_device_in_redis_session(device_name, cfg):
|
||||
overlapping_configs.append(cfg)
|
||||
|
||||
return overlapping_configs
|
||||
|
||||
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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Public API to manage device configs in the table
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -832,7 +938,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to set.
|
||||
skip_validation (bool): Whether to skip validation for the set devices.
|
||||
"""
|
||||
self.set_busy(True, text="Loading device configurations...")
|
||||
self.set_busy(True)
|
||||
with self.table_sort_on_hold:
|
||||
self.clear_device_configs()
|
||||
cfgs_added = []
|
||||
@@ -842,12 +948,12 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.device_configs_changed.emit(cfgs_added, True, skip_validation)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_device_configs(self):
|
||||
"""Clear the device configs. Skips validation per default."""
|
||||
self.set_busy(True, text="Clearing device configurations...")
|
||||
"""Clear the device configs. Skips validation by default."""
|
||||
self.set_busy(True)
|
||||
device_configs = self.get_device_config()
|
||||
with self.table_sort_on_hold:
|
||||
self._clear_table()
|
||||
@@ -856,7 +962,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
) # Skip validation for removals
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@@ -869,7 +975,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to add.
|
||||
skip_validation (bool): Whether to skip validation for the added devices.
|
||||
"""
|
||||
self.set_busy(True, text="Adding device configurations...")
|
||||
self.set_busy(True)
|
||||
already_in_table = []
|
||||
not_in_table = []
|
||||
with self.table_sort_on_hold:
|
||||
@@ -894,7 +1000,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@@ -905,7 +1011,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to update.
|
||||
skip_validation (bool): Whether to skip validation for the updated devices.
|
||||
"""
|
||||
self.set_busy(True, text="Loading device configurations...")
|
||||
self.set_busy(True)
|
||||
cfgs_updated = []
|
||||
with self.table_sort_on_hold:
|
||||
for cfg in device_configs:
|
||||
@@ -920,7 +1026,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.device_configs_changed.emit(cfgs_updated, True, skip_validation)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list)
|
||||
def remove_device_configs(self, device_configs: _DeviceCfgIter):
|
||||
@@ -930,7 +1036,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (dict[str, dict]): The device configs to remove.
|
||||
"""
|
||||
self.set_busy(True, text="Removing device configurations...")
|
||||
self.set_busy(True)
|
||||
cfgs_to_be_removed = list(device_configs)
|
||||
with self.table_sort_on_hold:
|
||||
self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed])
|
||||
@@ -939,7 +1045,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
) # Skip validation for removals
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_device(self, device_name: str):
|
||||
@@ -949,11 +1055,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_name (str): The name of the device to remove.
|
||||
"""
|
||||
self.set_busy(True, text=f"Removing device configuration for {device_name}...")
|
||||
self.set_busy(True)
|
||||
row_data = self.row_data.get(device_name)
|
||||
if not row_data:
|
||||
logger.warning(f"Device {device_name} not found in table for removal.")
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
return
|
||||
with self.table_sort_on_hold:
|
||||
self._remove_rows_by_name([row_data.data["name"]])
|
||||
@@ -961,7 +1067,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list)
|
||||
def update_multiple_device_validations(self, validation_results: _ValidationResultIter):
|
||||
@@ -973,9 +1079,15 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to update.
|
||||
"""
|
||||
self.set_busy(True, text="Updating device validations in session...")
|
||||
self.set_busy(True)
|
||||
self.table.setSortingEnabled(False)
|
||||
logger.info(
|
||||
f"Updating multiple device validation statuses with names {[cfg.get('name', '') for cfg, _, _, _ in validation_results]}..."
|
||||
)
|
||||
for cfg, config_status, connection_status, _ in validation_results:
|
||||
logger.info(
|
||||
f"Updating device {cfg.get('name', '')} with config status {config_status} and connection status {connection_status}..."
|
||||
)
|
||||
row = self._find_row_by_name(cfg.get("name", ""))
|
||||
if row is None:
|
||||
logger.warning(f"Device {cfg.get('name')} not found in table for session update.")
|
||||
@@ -984,7 +1096,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def update_device_validation(
|
||||
@@ -997,13 +1109,13 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
|
||||
"""
|
||||
self.set_busy(True, text="Updating device validation status...")
|
||||
self.set_busy(True)
|
||||
row = self._find_row_by_name(device_config.get("name", ""))
|
||||
if row is None:
|
||||
logger.warning(
|
||||
f"Device {device_config.get('name')} not found in table for validation update."
|
||||
)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
return
|
||||
# Disable here sorting without context manager to avoid triggering of registered
|
||||
# resizing methods. Those can be quite heavy, thus, should not run on every
|
||||
@@ -1013,4 +1125,4 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.table.setSortingEnabled(True)
|
||||
in_sync_with_redis = self._is_config_in_sync_with_redis()
|
||||
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
|
||||
self.set_busy(False, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@@ -548,9 +548,10 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
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 or skip_validation is True: # Remove requested
|
||||
if not added: # Remove requested, holds priority over skip_validation
|
||||
self._remove_device_config(cfg)
|
||||
continue
|
||||
# Check if device is already in running session with the same config
|
||||
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."
|
||||
@@ -563,29 +564,39 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
"Device already in session.",
|
||||
)
|
||||
)
|
||||
# If in addition, the device is to be kept visible after validation, we ensure it is added
|
||||
# and potentially update it's config & validation icons
|
||||
if device_name in self._keep_visible_after_validation:
|
||||
self._add_device_config(
|
||||
cfg,
|
||||
connect=connect,
|
||||
force_connect=force_connect,
|
||||
timeout=timeout,
|
||||
skip_validation=True,
|
||||
)
|
||||
self._on_device_test_completed(
|
||||
cfg,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
self._remove_device_config(cfg)
|
||||
if not self._device_already_exists(device_name):
|
||||
self._add_device_config(
|
||||
cfg,
|
||||
connect=connect,
|
||||
force_connect=force_connect,
|
||||
timeout=timeout,
|
||||
skip_validation=True,
|
||||
)
|
||||
# Now make sure that the existing widget is updated to reflect the CONNECTED & VALID status
|
||||
widget: ValidationListItem = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
self._on_device_test_completed(
|
||||
cfg,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
else: # If not to be kept visible, we ensure it is removed from the list
|
||||
self._remove_device_config(cfg)
|
||||
continue # Now we continue to the next device config
|
||||
if skip_validation is True: # Skip validation requested, so we skip this
|
||||
continue
|
||||
if not self._device_already_exists(cfg.get("name")): # New device case
|
||||
# New device case, that is not in BEC session
|
||||
if not self._device_already_exists(cfg.get("name")):
|
||||
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._remove_device_config(cfg, force_remove=True)
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
@@ -661,13 +672,13 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
if not skip_validation:
|
||||
self.__delayed_submit_test(widget, connect, force_connect, timeout)
|
||||
|
||||
def _remove_device(self, device_name: str) -> None:
|
||||
def _remove_device(self, device_name: str, force_remove: bool = False) -> None:
|
||||
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 device_name in self._keep_visible_after_validation:
|
||||
if device_name in self._keep_visible_after_validation and not force_remove:
|
||||
logger.debug(
|
||||
f"Device with name {device_name} is set to be kept visible after validation, not removing it."
|
||||
)
|
||||
@@ -676,9 +687,11 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
self.thread_pool_manager.clear_device_in_queue(device_name)
|
||||
self.list_widget.remove_widget_item(device_name)
|
||||
|
||||
def _remove_device_config(self, device_config: dict[str, Any]) -> None:
|
||||
def _remove_device_config(
|
||||
self, device_config: dict[str, Any], force_remove: bool = False
|
||||
) -> None:
|
||||
device_name = device_config.get("name")
|
||||
self._remove_device(device_name)
|
||||
self._remove_device(device_name, force_remove=force_remove)
|
||||
|
||||
@SafeSlot(str, dict, bool, bool, float)
|
||||
def _on_request_rerun_validation(
|
||||
|
||||
@@ -12,9 +12,8 @@ class DeviceInitializationProgressBar(BECProgressBar):
|
||||
# Signal emitted for failed device initializations
|
||||
failed_devices_changed = Signal(list)
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
self._latest_device_config_msg: dict | None = None
|
||||
def __init__(self, parent=None, client=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._failed_devices: list[str] = []
|
||||
self.bec_dispatcher.connect_slot(
|
||||
slot=self._update_device_initialization_progress,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['device_initialization_progress_bar.py']}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DeviceInitializationProgressBar' name='device_initialization_progress_bar'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = DeviceInitializationProgressBar(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "device_initialization_progress_bar"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DeviceInitializationProgressBar"
|
||||
|
||||
def toolTip(self):
|
||||
return "A progress bar that displays the progress of device initialization."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import (
|
||||
DeviceInitializationProgressBarPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -38,6 +38,8 @@ class CommunicateConfigAction(QRunnable):
|
||||
self._process(
|
||||
{"action": self.action, "config": self.config, "wait_for_response": False}
|
||||
)
|
||||
elif self.action == "cancel":
|
||||
self._process_cancel()
|
||||
elif self.action in ["add", "update", "remove"]:
|
||||
if (dev_name := self.device or self.config.get("name")) is None:
|
||||
raise ValueError(
|
||||
@@ -73,6 +75,13 @@ class CommunicateConfigAction(QRunnable):
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
|
||||
def _process_cancel(self):
|
||||
logger.info("Cancelling ongoing configuration operation")
|
||||
self.config_helper.send_config_request(
|
||||
action="cancel", config=None, wait_for_response=True, timeout_s=10
|
||||
)
|
||||
logger.info("Done cancelling configuration operation")
|
||||
|
||||
def process_remove_readd(self, dev_name: str):
|
||||
logger.info(f"Removing and readding device: {dev_name}")
|
||||
self.process_simple_action(dev_name, "remove")
|
||||
|
||||
@@ -2,27 +2,16 @@ import pytest
|
||||
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
class _TestBusyWidget(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
theme_update: bool = False,
|
||||
**kwargs,
|
||||
self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
theme_update=theme_update,
|
||||
start_busy=start_busy,
|
||||
busy_text=busy_text,
|
||||
**kwargs,
|
||||
)
|
||||
super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(QLabel("content", self))
|
||||
@@ -30,7 +19,7 @@ class _TestBusyWidget(BECWidget, QWidget):
|
||||
|
||||
@pytest.fixture
|
||||
def widget_busy(qtbot, mocked_client):
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…")
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=True)
|
||||
qtbot.addWidget(w)
|
||||
w.resize(320, 200)
|
||||
w.show()
|
||||
@@ -59,12 +48,21 @@ def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle):
|
||||
overlay = getattr(widget_idle, "_busy_overlay", None)
|
||||
assert overlay is None, "Overlay should be lazily created when idle"
|
||||
|
||||
widget_idle.set_busy(True, "Fetching data…")
|
||||
widget_idle.set_busy(True)
|
||||
overlay = getattr(widget_idle, "_busy_overlay")
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Fetching data…"
|
||||
assert hasattr(widget_idle, "_busy_state_widget")
|
||||
assert overlay._custom_widget is not None
|
||||
|
||||
label = overlay._custom_widget.findChild(QLabel)
|
||||
assert label is not None
|
||||
assert label.text() == "Loading..."
|
||||
|
||||
spinner = overlay._custom_widget.findChild(SpinnerWidget)
|
||||
assert spinner is not None
|
||||
assert spinner.isVisible()
|
||||
assert spinner._started is True
|
||||
|
||||
widget_idle.set_busy(False)
|
||||
qtbot.waitUntil(lambda: overlay.isHidden())
|
||||
@@ -106,19 +104,6 @@ def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy):
|
||||
assert "rgba(128, 128, 128, 110)" in ss
|
||||
|
||||
|
||||
def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle):
|
||||
overlay = getattr(widget_idle, "_busy_overlay", None)
|
||||
assert overlay is None, "Overlay should be created on first text update"
|
||||
|
||||
widget_idle.set_busy_text("Preparing…")
|
||||
overlay = getattr(widget_idle, "_busy_overlay")
|
||||
assert overlay is not None
|
||||
assert overlay.isHidden()
|
||||
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Preparing…"
|
||||
|
||||
|
||||
def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy):
|
||||
overlay = getattr(widget_busy, "_busy_overlay", None)
|
||||
assert overlay is not None, "Busy overlay should exist on a start_busy widget"
|
||||
@@ -131,15 +116,11 @@ def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy):
|
||||
qtbot.waitUntil(lambda: overlay.isHidden())
|
||||
|
||||
# Switch ON again (with new text)
|
||||
widget_busy.set_busy(True, "Back to work…")
|
||||
widget_busy.set_busy(True)
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
# Same overlay instance reused (no duplication)
|
||||
assert getattr(widget_busy, "_busy_overlay") is overlay
|
||||
|
||||
# Label updated
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Back to work…"
|
||||
|
||||
# Geometry follows parent after re-show
|
||||
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# pylint skip
|
||||
# pylint skip-file
|
||||
import pytest
|
||||
from bec_lib.messages import DeviceInitializationProgressMessage
|
||||
|
||||
@@ -6,10 +6,12 @@ from bec_widgets.widgets.progress.device_initialization_progress_bar.device_init
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def progress_bar(qtbot):
|
||||
widget = DeviceInitializationProgressBar()
|
||||
def progress_bar(qtbot, mocked_client):
|
||||
widget = DeviceInitializationProgressBar(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
@@ -361,6 +361,75 @@ class TestDeviceTable:
|
||||
assert device_table.search_input is not None
|
||||
assert device_table.fuzzy_is_disabled.isChecked() is False
|
||||
assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows
|
||||
assert hasattr(device_table, "client_callback_id")
|
||||
|
||||
def test_device_table_client_device_update_callback(
|
||||
self, device_table: DeviceTable, mocked_client, qtbot
|
||||
):
|
||||
"""
|
||||
Test that runs the client device update callback. This should update the status of devices in the table
|
||||
that are in sync with the client.
|
||||
|
||||
I. First test will run a callback when no devices are in the table, should do nothing.
|
||||
II. Second test will add devices all devices from the mocked_client, then remove one
|
||||
device from the client and run the callback. The table should update the status of the
|
||||
removed device to CAN_CONNECT and all others to CONNECTED.
|
||||
"""
|
||||
device_configs_changed_calls = []
|
||||
requested_update_for_multiple_device_validations = []
|
||||
|
||||
def _device_configs_changed_cb(cfgs: list[dict], added: bool, skip_validation: bool):
|
||||
"""Callback to capture device config changes."""
|
||||
device_configs_changed_calls.append((cfgs, added, skip_validation))
|
||||
|
||||
def _requested_update_for_multiple_device_validations_cb(device_names: list):
|
||||
"""Callback to capture requests for multiple device validations."""
|
||||
requested_update_for_multiple_device_validations.append(device_names)
|
||||
|
||||
device_table.device_configs_changed.connect(_device_configs_changed_cb)
|
||||
device_table.request_update_multiple_device_validations.connect(
|
||||
_requested_update_for_multiple_device_validations_cb
|
||||
)
|
||||
|
||||
# I. First test case with no devices in the table
|
||||
with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker:
|
||||
device_table.request_update_after_client_device_update.emit()
|
||||
assert blocker.signal_triggered is True
|
||||
# Table should remain empty, and no updates should have occurred
|
||||
assert not device_configs_changed_calls
|
||||
assert not requested_update_for_multiple_device_validations
|
||||
|
||||
# II. Second test case, add all devices from mocked client to table
|
||||
# Add all devices from mocked client to table.
|
||||
device_configs = mocked_client.device_manager._get_redis_device_config()
|
||||
device_table.add_device_configs(device_configs, skip_validation=True)
|
||||
mocked_client.device_manager.devices.pop("samx") # Remove samx from client
|
||||
with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker:
|
||||
validation_results = {
|
||||
cfg.get("name"): (
|
||||
DeviceModel.model_validate(cfg).model_dump(),
|
||||
ConfigStatus.VALID,
|
||||
ConnectionStatus.CONNECTED,
|
||||
)
|
||||
for cfg in device_configs
|
||||
}
|
||||
with mock.patch.object(
|
||||
device_table, "get_validation_results", return_value=validation_results
|
||||
):
|
||||
device_table.request_update_after_client_device_update.emit()
|
||||
assert blocker.signal_triggered is True
|
||||
# Table should remain empty, and no updates should have occurred
|
||||
# One for add_device_configs, one for the update
|
||||
assert len(device_configs_changed_calls) == 2
|
||||
# The first call should have one more device than the second
|
||||
assert (
|
||||
len(device_configs_changed_calls[0][0])
|
||||
- len(device_configs_changed_calls[1][0])
|
||||
== 1
|
||||
)
|
||||
# Only one device should have been marked for validation update
|
||||
assert len(requested_update_for_multiple_device_validations) == 1
|
||||
assert len(requested_update_for_multiple_device_validations[0]) == 1
|
||||
|
||||
def test_add_row(self, device_table: DeviceTable, sample_devices: dict):
|
||||
"""Test adding a single device row."""
|
||||
@@ -1060,9 +1129,7 @@ class TestOphydValidation:
|
||||
ophyd_test, "_is_device_in_redis_session", return_value=True
|
||||
) as mock_is_device_in_redis_session,
|
||||
mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config,
|
||||
mock.patch.object(
|
||||
ophyd_test, "_on_device_test_completed"
|
||||
) as mock_on_device_test_completed,
|
||||
mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget,
|
||||
):
|
||||
ophyd_test.change_device_configs(
|
||||
[{"name": "device_2", "deviceClass": "TestClass"}],
|
||||
@@ -1070,12 +1137,7 @@ class TestOphydValidation:
|
||||
skip_validation=False,
|
||||
)
|
||||
mock_add_device_config.assert_called_once()
|
||||
mock_on_device_test_completed.assert_called_once_with(
|
||||
{"name": "device_2", "deviceClass": "TestClass"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
mock_get_widget.assert_called_once_with("device_2")
|
||||
|
||||
def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot):
|
||||
"""Test adding devices to OphydValidation widget."""
|
||||
|
||||
@@ -305,7 +305,7 @@ class TestDeviceManagerViewDialogs:
|
||||
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} ",
|
||||
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r}",
|
||||
)
|
||||
mock_create_dialog.assert_not_called()
|
||||
mock_create_validation.assert_not_called()
|
||||
@@ -741,35 +741,3 @@ class TestDeviceManagerView:
|
||||
"rerun_validation"
|
||||
].action.action.triggered.emit()
|
||||
assert len(mock_change_configs.call_args[0][0]) == 1
|
||||
|
||||
def test_update_validation_icons_after_upload(
|
||||
self,
|
||||
device_manager_display_widget: DeviceManagerDisplayWidget,
|
||||
device_configs: list[dict[str, Any]],
|
||||
):
|
||||
"""Test that validation icons are updated after uploading to Redis."""
|
||||
dm_view = device_manager_display_widget
|
||||
|
||||
# Add device configs to the table
|
||||
dm_view.device_table_view.add_device_configs(device_configs)
|
||||
# Update the device manager devices to match what's in the table
|
||||
dm_view.client.device_manager.devices = {cfg["name"]: cfg for cfg in device_configs}
|
||||
|
||||
# Simulate callback
|
||||
dm_view._update_validation_icons_after_upload()
|
||||
|
||||
# Get validation results from the table
|
||||
validation_results = dm_view.device_table_view.get_validation_results()
|
||||
# Check that all devices are connected and status is updated
|
||||
for dev_name, (cfg, _, connection_status) in validation_results.items():
|
||||
assert cfg in device_configs
|
||||
assert connection_status == ConnectionStatus.CONNECTED.value
|
||||
|
||||
# Check that no devices are in ophyd_validation widget
|
||||
# Those should be all cleared after upload
|
||||
cfgs = dm_view.ophyd_test_view.get_device_configs()
|
||||
assert len(cfgs) == 0
|
||||
|
||||
# Check that upload config button is disabled
|
||||
action = dm_view.toolbar.components.get_action("update_config_redis")
|
||||
assert action.action.isEnabled() is False
|
||||
|
||||
Reference in New Issue
Block a user