From 5fde098974fc1475596c4bfe4360c08c29032178 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 5 Sep 2025 16:32:14 +0200 Subject: [PATCH] feat(device_manager): add device dialog with presets --- .../device_manager_view.py | 16 +++- bec_widgets/utils/forms_from_types/forms.py | 28 +++++- bec_widgets/utils/forms_from_types/items.py | 17 +++- .../device_item/device_config_dialog.py | 90 +++++++++++++++---- .../device_item/device_config_form.py | 27 +++++- 5 files changed, 151 insertions(+), 27 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 7ff81c0f..980a46dd 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -29,6 +29,10 @@ from bec_widgets.widgets.control.device_manager.components._util import SharedSe from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( AvailableDeviceResources, ) +from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( + DeviceConfigDialog, + PresetClassDeviceConfigDialog, +) logger = bec_logger.logger @@ -277,7 +281,6 @@ class DeviceManagerView(BECWidget, QWidget): # - rerun validation (with/without connect) # IO actions - def _coming_soon(self): return QMessageBox.question( self, @@ -377,15 +380,20 @@ class DeviceManagerView(BECWidget, QWidget): if reply == QMessageBox.StandardButton.Yes: self.device_table_view.clear_device_configs() - # TODO Here we would like to implement a custom popup view, that allows to add new devices - # We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device + # TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device # For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc.. # For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required.. @SafeSlot() def _add_device_action(self): """Action for the 'add_device' action to add a new device.""" # Implement the logic to add a new device - reply = self._coming_soon() + dialog = PresetClassDeviceConfigDialog(parent=self) + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict) + def _add_to_table_from_dialog(self, data): + self.device_table_view.add_device_configs([data]) @SafeSlot() def _remove_device_action(self): diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index db5ad7de..382c4003 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from types import NoneType +from types import GenericAlias, NoneType, UnionType from typing import NamedTuple from bec_lib.logger import bec_logger @@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.items import ( DynamicFormItem, @@ -216,6 +216,9 @@ class PydanticModelForm(TypedForm): self._connect_to_theme_change() + @SafeSlot() + def clear(self): ... + def set_pretty_display_theme(self, theme: str = "dark"): if self._pretty_display: self.setStyleSheet(styles.pretty_display_theme(theme)) @@ -280,3 +283,24 @@ class PydanticModelForm(TypedForm): self.form_data_cleared.emit(None) self.validity_proc.emit(False) return False + + +class PydanticModelFormItem(DynamicFormItem): + def __init__( + self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] + ) -> None: + self._data_model = model + + super().__init__(parent=parent, spec=spec) + self._main_widget.form_data_updated.connect(self._value_changed) + + def _add_main_widget(self) -> None: + + self._main_widget = PydanticModelForm(data_model=self._data_model) + self._layout.addWidget(self._main_widget) + + def getValue(self): + return self._main_widget.get_form_data() + + def setValue(self, value: dict): + self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index c9d83801..ad06596d 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import typing from abc import abstractmethod from decimal import Decimal @@ -12,8 +13,10 @@ from typing import ( Literal, NamedTuple, OrderedDict, + Protocol, TypeVar, get_args, + runtime_checkable, ) from bec_lib.logger import bec_logger @@ -561,7 +564,14 @@ class StrLiteralFormItem(DynamicFormItem): self._main_widget.setCurrentIndex(-1) -WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] +@runtime_checkable +class _ItemTypeFn(Protocol): + def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... + + +WidgetTypeRegistry = OrderedDict[ + str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] +] DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | { # dict literals are ordered already but TypedForm subclasses may modify coppies of this dict @@ -602,7 +612,10 @@ def widget_from_type( widget_types = widget_types or DEFAULT_WIDGET_TYPES for predicate, widget_type in widget_types.values(): if predicate(spec): - return widget_type + if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): + return widget_type + return widget_type(spec) + logger.warning( f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." ) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index 2fc8f70e..927dda1d 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -5,7 +5,8 @@ from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import field_validator +from pydantic import BaseModel, field_validator +from PySide6.QtWidgets import QComboBox, QHBoxLayout from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, @@ -19,6 +20,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) @@ -44,32 +46,33 @@ def _try_literal_eval(value: str): class DeviceConfigDialog(QDialog): RPC = False applied = Signal() + accepted_data = Signal(dict) - def __init__(self, *, parent=None, **kwargs): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. + def __init__( + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs + ): - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ self._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item super().__init__(parent=parent, **kwargs) self._container = QStackedLayout() self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - + self._data = {} self._add_form() self._add_overlay() self._add_buttons() self.setWindowTitle("Add new device") self.setLayout(self._container) - self._form.validate_form() self._overlay_widget.setVisible(False) + self._form._validity.setVisible(True) + self._connect_form() + + def _connect_form(self): + self._form.validity_proc.connect(self.enable_buttons_for_validity) + self._form.validate_form() def _add_form(self): self._form_widget = QWidget() @@ -133,7 +136,11 @@ class DeviceConfigDialog(QDialog): button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) - def _process_action(self): ... + def _process_action(self): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data @SafeSlot(popup_error=True) def apply(self): @@ -146,6 +153,48 @@ class DeviceConfigDialog(QDialog): return super().accept() +class EpicsDeviceConfig(BaseModel): + prefix: str + + +class PresetClassDeviceConfigDialog(DeviceConfigDialog): + def __init__(self, *, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) + self._device_models = { + "Custom": (None, {}), + "EpicsMotor": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignal": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + } + + def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): + for field_name, (value, editable) in constraints.items(): + if (widget := self._form.widget_dict.get(field_name)) is not None: + widget.setValue(value) + if not editable: + widget._set_pretty_display() + + def _replace_form(self, deviceconfig_cls_key): + self._form.deleteLater() + if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None: + devmodel, params = devmodel_params + else: + devmodel, params = None, {} + self._form = DeviceConfigForm(class_deviceconfig_item=devmodel) + self._apply_constraints(params) + self._layout.insertWidget(1, self._form) + self._connect_form() + + def _create_selection_box(self): + layout = QHBoxLayout() + self._selection_box = QComboBox() + self._selection_box.addItems(["Custom", "EpicsMotor", "EpicsSignal"]) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): def __init__( self, @@ -157,6 +206,15 @@ class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): threadpool: QThreadPool | None = None, **kwargs, ): + """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model + for device specification in bec_lib.atlas_models. + + Args: + parent (QObject): the parent QObject + device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. + config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. + action (Literal["update", "add"]): the action which the form should perform on application or acceptance. + """ self._device = device self._q_threadpool = threadpool or QThreadPool() self._config_helper = config_helper or ConfigHelper( @@ -178,11 +236,11 @@ class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): if self._action == "update": self._modify_for_update() + self._form.validity_proc.disconnect(self.enable_buttons_for_validity) else: self._set_schema_to_check_devices() # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) def _modify_for_update(self): for row in self._form.enumerate_form_widgets(): @@ -291,8 +349,8 @@ def main(): # pragma: no cover def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DirectUpdateDeviceConfigDialog(**kwargs) # type: ignore + kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} + dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 0b8c1aeb..a783d988 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -1,16 +1,20 @@ from __future__ import annotations +from functools import partial + from bec_lib.atlas_models import Device as DeviceConfigModel from pydantic import BaseModel from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, + DictFormItem, + FormItemSpec, ) @@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + def __init__( + self, + parent=None, + client=None, + pretty_display=False, + class_deviceconfig_item: type[BaseModel] | None = None, + **kwargs, + ): super().__init__( parent=parent, data_model=DeviceConfigModel, @@ -26,18 +37,28 @@ class DeviceConfigForm(PydanticModelForm): client=client, **kwargs, ) + self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) self._widget_types["optional_bool"] = ( lambda spec: spec.item_type == bool | None, BoolFormItem, ) - self._validity.setVisible(False) + pred, _ = self._widget_types["dict"] + self._widget_types["dict"] = pred, self._custom_device_config_item + self._validity.setVisible(True) self._connect_to_theme_change() self.populate() def _post_init(self): ... + def _custom_device_config_item(self, spec: FormItemSpec): + if spec.name != "deviceConfig": + return DictFormItem + if self._class_deviceconfig_item is not None: + return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) + return DictFormItem + def set_pretty_display_theme(self, theme: str | None = None): if theme is None: theme = get_theme_name()