diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 0a5e77c6..0ff4d941 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -72,7 +72,7 @@ class DictBackedTableModel(QAbstractTableModel): def replaceData(self, data: dict): self.delete_rows(list(range(len(self._data)))) self.resetInternalData() - self._data = [[k, v] for k, v in data.items()] + self._data = [[str(k), str(v)] for k, v in data.items()] self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1)) def update_disallowed_keys(self, keys: list[str]): diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index ccf0906c..0f454e95 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -87,14 +87,13 @@ class DeviceBrowser(BECWidget, QWidget): for device, device_obj in self.dev.items(): item = QListWidgetItem(self.dev_list) device_item = DeviceItem( - parent=self, device=device, icon=map_device_type_to_icon(device_obj) + parent=self, + device=device, + devices=self.dev, + icon=map_device_type_to_icon(device_obj), ) - device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) - - device_config = self.dev[device]._config # pylint: disable=protected-access - device_item.set_display_config(device_config) - tooltip = device_config.get("description", "") + tooltip = self.dev[device]._config.get("description", "") device_item.setToolTip(tooltip) device_item.broadcast_size_hint.connect(item.setSizeHint) item.setSizeHint(device_item.sizeHint()) 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 d62b0561..cf1537d2 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 @@ -1,5 +1,4 @@ -import traceback -from threading import Thread +from ast import literal_eval from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS @@ -10,6 +9,7 @@ from qtpy.QtWidgets import ( QApplication, QDialog, QDialogButtonBox, + QLabel, QStackedLayout, QVBoxLayout, QWidget, @@ -26,7 +26,7 @@ logger = bec_logger.logger class _CommSignals(QObject): - error = Signal(str) + error = Signal(Exception) done = Signal() @@ -48,18 +48,17 @@ class _CommunicateUpdate(QRunnable): ) logger.info("Waiting for config reply") reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout) - logger.info("Handling config reply") self.config_helper.handle_update_reply(reply, RID, timeout) + logger.info("Done updating config!") except Exception as e: - self.signals.error.emit( - f"Error updating config: \n {''.join(traceback.format_exception(e))}" - ) + self.signals.error.emit(e) finally: self.signals.done.emit() class DeviceConfigDialog(BECWidget, QDialog): RPC = False + applied = Signal() def __init__( self, @@ -78,6 +77,14 @@ class DeviceConfigDialog(BECWidget, QDialog): self._container = QStackedLayout() self._container.setStackingMode(QStackedLayout.StackAll) + self._layout = QVBoxLayout() + user_warning = QLabel( + "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" + "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." + ) + user_warning.setWordWrap(True) + user_warning.setStyleSheet("QLabel { color: red; }") + self._layout.addWidget(user_warning) self._add_form() self._add_overlay() self._add_buttons() @@ -87,7 +94,6 @@ class DeviceConfigDialog(BECWidget, QDialog): def _add_form(self): self._form_widget = QWidget() - self._layout = QVBoxLayout() self._form_widget.setLayout(self._layout) self._form = DeviceConfigForm() self._layout.addWidget(self._form) @@ -137,13 +143,20 @@ class DeviceConfigDialog(BECWidget, QDialog): def updated_config(self): new_config = self._form.get_form_data() - return { + diff = { k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k) } + # TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved + if diff.get("deviceConfig") is not None: + diff["deviceConfig"] = { + k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items() + } + return diff @SafeSlot() def apply(self): self._process_update_action() + self.applied.emit() @SafeSlot() def accept(self): @@ -181,9 +194,9 @@ class DeviceConfigDialog(BECWidget, QDialog): self._fetch_config() self._fill_form() - @SafeSlot(str) - def update_error(self, e: str): - logger.error(e) + @SafeSlot(Exception, popup_error=True) + def update_error(self, e: Exception): + raise RuntimeError("Failed to update device configuration") from e def _start_waiting_display(self): self._overlay_widget.setVisible(True) 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 18155ae3..6c49afae 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,7 +1,5 @@ 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 @@ -13,7 +11,6 @@ from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, - widget_from_type, ) @@ -46,6 +43,10 @@ class DeviceConfigForm(PydanticModelForm): theme = get_theme_name() self.setStyleSheet(styles.pretty_display_theme(theme)) + def get_form_data(self): + """Get the entered metadata as a dict.""" + return self._md_schema.model_validate(super().get_form_data()).model_dump() + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index 92d6e79b..82691e9b 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from bec_lib.atlas_models import Device as DeviceConfigModel +from bec_lib.devicemanager import DeviceContainer from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, QSize, Qt, Signal @@ -30,9 +31,9 @@ class DeviceItem(ExpandableGroupFrame): RPC = False - def __init__(self, parent, device: str, icon: str = "") -> None: + def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None: super().__init__(parent, title=device, expanded=False, icon=icon) - + self.dev = devices self._drag_pos = None self._expanded_first_time = False self._data = None @@ -54,6 +55,8 @@ class DeviceItem(ExpandableGroupFrame): def _create_edit_dialog(self): dialog = DeviceConfigDialog(parent=self, device=self.device) + dialog.accepted.connect(self._reload_config) + dialog.applied.connect(self._reload_config) dialog.open() @SafeSlot() @@ -62,8 +65,7 @@ class DeviceItem(ExpandableGroupFrame): self._expanded_first_time = True self.form = DeviceConfigForm(parent=self, pretty_display=True) self._contents.layout().addWidget(self.form) - if self._data: - self.form.set_data(self._data) + self._reload_config() self.broadcast_size_hint.emit(self.sizeHint()) super().switch_expanded_state() if self._expanded_first_time: @@ -74,6 +76,11 @@ class DeviceItem(ExpandableGroupFrame): self.adjustSize() self.broadcast_size_hint.emit(self.sizeHint()) + @SafeSlot(popup_error=True) + def _reload_config(self, *_): + self.dev[self.device].read_configuration(cached=False) + self.set_display_config(self.dev[self.device]._config) + def set_display_config(self, config_dict: dict): """Set the displayed information from a device config dict, which must conform to the bec_lib.atlas_models.Device config model.""" diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index eba48e60..3992737e 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: # pragma: no cover @pytest.fixture def device_browser(qtbot, mocked_client): dev_browser = DeviceBrowser(client=mocked_client) + dev_browser.dev["samx"].read_configuration = mock.MagicMock() qtbot.addWidget(dev_browser) qtbot.waitExposed(dev_browser) yield dev_browser @@ -88,8 +89,9 @@ def test_device_item_expansion(device_browser, qtbot): form = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: isinstance(form, DeviceConfigForm), timeout=500) assert widget.expanded + device_browser.dev["samx"].read_configuration.assert_called() assert (name_field := form.widget_dict.get("name")) is not None - assert name_field.getValue() == "samx" + qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index 5ebb4014..866ad749 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -81,8 +81,8 @@ def test_waiting_display(dialog, qtbot): def test_update_cycle(dialog, qtbot): update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}} - def _mock_send(a, c, w): - dialog.client.device_manager.devices["test_device"]._config = c["test_device"] + def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None): + dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send) for item in dialog._form.enumerate_form_widgets():