1
0
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:
2026-01-09 13:16:24 +01:00
committed by Christian Appel
parent cc45fed387
commit b38d6dc549
16 changed files with 850 additions and 279 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
{'files': ['device_initialization_progress_bar.py']}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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