mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-13 04:00:54 +02:00
Compare commits
6 Commits
feature/sc
...
v2.28.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b80e16a0 | ||
| 7f0098f153 | |||
| 8489ef4a69 | |||
| 13976557fb | |||
|
|
06ad87ce0a | ||
| 00e3713181 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,6 +1,28 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.28.0 (2025-07-21)
|
||||
|
||||
### Features
|
||||
|
||||
- Disable editing while scan active
|
||||
([`1397655`](https://github.com/bec-project/bec_widgets/commit/13976557fbdb71a1161029521d81a655d25dd134))
|
||||
|
||||
- Remove and readd device for config changes
|
||||
([`8489ef4`](https://github.com/bec-project/bec_widgets/commit/8489ef4a69d69b39648b1a9270012f14f95c6121))
|
||||
|
||||
- Save and load config from devicebrowser
|
||||
([`7f0098f`](https://github.com/bec-project/bec_widgets/commit/7f0098f1533d419cc75801c4d6cbea485c7bbf94))
|
||||
|
||||
|
||||
## v2.27.1 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi_tree**: Rois signals are disconnected when roi tree widget is closed
|
||||
([`00e3713`](https://github.com/bec-project/bec_widgets/commit/00e3713181916a432e4e9dec8a0d80205914cf77))
|
||||
|
||||
|
||||
## v2.27.0 (2025-07-17)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
@@ -39,6 +40,9 @@ if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ROILockButton(QToolButton):
|
||||
"""Keeps its icon and checked state in sync with a single ROI."""
|
||||
|
||||
@@ -447,6 +451,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if self.controller and hasattr(self.controller, "rois"):
|
||||
for roi in self.controller.rois: # disconnect all signals from ROIs
|
||||
try:
|
||||
if isinstance(roi, RectangularROI):
|
||||
roi.edgesChanged.disconnect()
|
||||
else:
|
||||
roi.centerChanged.disconnect()
|
||||
roi.penChanged.disconnect()
|
||||
roi.nameChanged.disconnect()
|
||||
except (RuntimeError, TypeError) as e:
|
||||
logger.error(f"Failed to disconnect roi qt signal: {e}")
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import os
|
||||
import re
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_qthemes import material_icon
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QSize, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QFileDialog,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -30,6 +40,7 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
"""
|
||||
|
||||
devices_changed: Signal = Signal()
|
||||
editing_enabled: Signal = Signal(bool)
|
||||
device_update: Signal = Signal(str, dict)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "lists"
|
||||
@@ -47,7 +58,7 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.ini_ui()
|
||||
self.init_ui()
|
||||
self.dev_list: QListWidget = self.ui.device_list
|
||||
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
self.proxy_device_update = SignalProxy(
|
||||
@@ -56,14 +67,22 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.SCAN_STATUS, self.scan_status_changed
|
||||
)
|
||||
self._default_config_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(bec_lib.__file__), "./configs/")
|
||||
)
|
||||
|
||||
self.devices_changed.connect(self.update_device_list)
|
||||
self.ui.add_button.clicked.connect(self._create_add_dialog)
|
||||
self.ui.add_button.setIcon(material_icon("add", size=(20, 20), convert_to_pixmap=False))
|
||||
|
||||
self.init_warning_label()
|
||||
self.init_tool_buttons()
|
||||
|
||||
self.init_device_list()
|
||||
self.update_device_list()
|
||||
|
||||
def ini_ui(self) -> None:
|
||||
def init_ui(self) -> None:
|
||||
"""
|
||||
Initialize the UI by loading the UI file and setting the layout.
|
||||
"""
|
||||
@@ -73,6 +92,27 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
def init_warning_label(self):
|
||||
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
|
||||
self.ui.scan_running_warning.setStyleSheet(
|
||||
"background-color: #fcba03; color: rgb(0, 0, 0);"
|
||||
)
|
||||
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
|
||||
initial_status = scan_status.status if scan_status is not None else "closed"
|
||||
self.set_editing_mode(initial_status not in ["open", "paused"])
|
||||
|
||||
def init_tool_buttons(self):
|
||||
def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ""):
|
||||
button.clicked.connect(slot)
|
||||
button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
|
||||
button.setToolTip(tooltip)
|
||||
|
||||
_setup_button(self.ui.add_button, "add", self._create_add_dialog, "add new device")
|
||||
_setup_button(self.ui.save_button, "save", self._save_to_file, "save config to file")
|
||||
_setup_button(
|
||||
self.ui.import_button, "input", self._load_from_file, "append/merge config from file"
|
||||
)
|
||||
|
||||
def _create_add_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
|
||||
dialog.open()
|
||||
@@ -120,6 +160,7 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
device_item.imminent_deletion.connect(partial(_remove_item, item))
|
||||
self.editing_enabled.connect(device_item.set_editable)
|
||||
self.device_update.connect(device_item.config_update)
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
@@ -130,6 +171,17 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def scan_status_changed(self, scan_info: dict, _: dict):
|
||||
"""disable editing when scans are running and enable editing when they are finished"""
|
||||
msg = ScanStatusMessage.model_validate(scan_info)
|
||||
self.set_editing_mode(msg.status not in ["open", "paused"])
|
||||
|
||||
def set_editing_mode(self, enabled: bool):
|
||||
self.ui.add_button.setEnabled(enabled)
|
||||
self.ui.scan_running_warning.setHidden(enabled)
|
||||
self.editing_enabled.emit(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_device_list(self) -> None:
|
||||
self.init_device_list()
|
||||
@@ -161,6 +213,22 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(not self.regex.search(device))
|
||||
|
||||
@SafeSlot()
|
||||
def _load_from_file(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Update config from file", self._default_config_dir, "Config files (*.yml *.yaml)"
|
||||
)
|
||||
if file_path:
|
||||
self._config_helper.update_session_with_file(file_path)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_file(self):
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save config to file", self._default_config_dir, "Config files (*.yml *.yaml)"
|
||||
)
|
||||
if file_path:
|
||||
self._config_helper.save_current_session(file_path)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -51,11 +51,35 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="import_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="scan_running_warning">
|
||||
<property name="styleSheet">
|
||||
<string notr="true"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>warning</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="device_list"/>
|
||||
</item>
|
||||
|
||||
@@ -24,8 +24,10 @@ class CommunicateConfigAction(QRunnable):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
if action in ["add", "update"] and config is None:
|
||||
raise ValueError("Must supply config to add or update a device")
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.config = config or {}
|
||||
self.action = action
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@@ -37,24 +39,35 @@ class CommunicateConfigAction(QRunnable):
|
||||
raise ValueError(
|
||||
"Must be updating a device or be supplied a name for a new device"
|
||||
)
|
||||
req_args = {
|
||||
"action": self.action,
|
||||
"config": {dev_name: self.config},
|
||||
"wait_for_response": False,
|
||||
}
|
||||
timeout = (
|
||||
self.config_helper.suggested_timeout_s(self.config)
|
||||
if self.config is not None
|
||||
else 20
|
||||
)
|
||||
RID = self.config_helper.send_config_request(**req_args)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
if "deviceConfig" not in self.config or self.action in ["add", "remove"]:
|
||||
self.process_simple_action(dev_name)
|
||||
else:
|
||||
# updating an existing device, but need to recreate it for this change
|
||||
self.process_remove_readd(dev_name)
|
||||
else:
|
||||
raise ValueError(f"action {self.action} is not supported")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
else:
|
||||
self.signals.done.emit()
|
||||
|
||||
def process_simple_action(self, dev_name: str, action: ConfigAction | None = None):
|
||||
req_args = {
|
||||
"action": action or self.action,
|
||||
"config": {dev_name: self.config},
|
||||
"wait_for_response": False,
|
||||
}
|
||||
timeout = (
|
||||
self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20
|
||||
)
|
||||
RID = self.config_helper.send_config_request(**req_args)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
|
||||
def process_remove_readd(self, dev_name: str):
|
||||
logger.info(f"Removing and readding device: {dev_name}")
|
||||
self.process_simple_action(dev_name, "remove")
|
||||
self.process_simple_action(dev_name, "add")
|
||||
logger.info(f"Reinstated {dev_name} successfully!")
|
||||
|
||||
@@ -68,7 +68,7 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self.client.connector, self.client._service_name
|
||||
)
|
||||
self._device = device
|
||||
self._action = action
|
||||
self._action: Literal["update", "add"] = action
|
||||
self._q_threadpool = threadpool or QThreadPool()
|
||||
self.setWindowTitle(f"Edit config for: {device}")
|
||||
self._container = QStackedLayout()
|
||||
@@ -168,6 +168,10 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
diff = {
|
||||
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
|
||||
}
|
||||
if self._initial_config.get("deviceConfig") in [{}, None] and new_config.get(
|
||||
"deviceConfig"
|
||||
) in [{}, None]:
|
||||
diff.pop("deviceConfig", None)
|
||||
if diff.get("deviceConfig") is not None:
|
||||
# TODO: special cased in some parts of device manager but not others, should
|
||||
# be removed in config update as with below issue
|
||||
@@ -176,6 +180,11 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
diff["deviceConfig"] = {
|
||||
k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != ""
|
||||
}
|
||||
|
||||
# Due to above issues, if deviceConfig changes we must remove and recreate the device - so we need the whole config
|
||||
if "deviceConfig" in diff:
|
||||
new_config["deviceConfig"] = diff["deviceConfig"]
|
||||
return new_config
|
||||
return diff
|
||||
|
||||
@SafeSlot(bool)
|
||||
@@ -212,7 +221,7 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._proc_device_config_change(updated_config)
|
||||
|
||||
def _proc_device_config_change(self, config: dict):
|
||||
logger.info(f"Sending request to update device config: {config}")
|
||||
logger.info(f"Sending request to {self._action} device config: {config}")
|
||||
|
||||
self._start_waiting_display()
|
||||
communicate_update = CommunicateConfigAction(
|
||||
|
||||
@@ -140,6 +140,11 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_editable(self, enabled: bool):
|
||||
self.edit_button.setEnabled(enabled)
|
||||
self.delete_button.setEnabled(enabled)
|
||||
|
||||
@SafeSlot(str, dict)
|
||||
def config_update(self, action: ConfigAction, content: dict) -> None:
|
||||
if self.device in content:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.27.0"
|
||||
version = "2.28.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from unittest.mock import ANY, MagicMock
|
||||
from unittest.mock import ANY, MagicMock, call
|
||||
|
||||
import pytest
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
@@ -20,9 +21,41 @@ def test_must_have_a_name(qtbot):
|
||||
qtbot.waitUntil(lambda: error_occurred, timeout=100)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["action", "config", "error"],
|
||||
[("update", None, True), ("remove", None, False), ("add", {}, False)],
|
||||
)
|
||||
def test_action_config_match(action, config, error):
|
||||
def init():
|
||||
return CommunicateConfigAction(
|
||||
ConfigHelper(MagicMock()), device="test", config=config, action=action
|
||||
)
|
||||
|
||||
if error:
|
||||
with pytest.raises(ValueError):
|
||||
init()
|
||||
else:
|
||||
assert init()
|
||||
|
||||
|
||||
def test_wait_for_reply_on_RID():
|
||||
ch = MagicMock(spec=ConfigHelper)
|
||||
ch.send_config_request.return_value = "abcde"
|
||||
cca = CommunicateConfigAction(config_helper=ch, device="samx", config={}, action="update")
|
||||
cca.run()
|
||||
ch.wait_for_config_reply.assert_called_with("abcde", timeout=ANY)
|
||||
ch.wait_for_config_reply.assert_called_once_with("abcde", timeout=ANY)
|
||||
|
||||
|
||||
def test_remove_readd_with_device_config(qtbot):
|
||||
ch = MagicMock(spec=ConfigHelper)
|
||||
ch.send_config_request.return_value = "abcde"
|
||||
cca = CommunicateConfigAction(
|
||||
config_helper=ch, device="samx", config={"deviceConfig": {"arg": "val"}}, action="update"
|
||||
)
|
||||
cca.run()
|
||||
ch.send_config_request.assert_has_calls(
|
||||
[
|
||||
call(action="remove", config=ANY, wait_for_response=False),
|
||||
call(action="add", config=ANY, wait_for_response=False),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -120,6 +120,37 @@ def test_update_cycle(update_dialog, qtbot):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["changes", "result"],
|
||||
[
|
||||
({}, {}),
|
||||
({"readOnly": True}, {"readOnly": True}),
|
||||
({"readOnly": False}, {}),
|
||||
({"readOnly": True, "description": "test"}, {"readOnly": True, "description": "test"}),
|
||||
(
|
||||
{"deviceConfig": {"param1": "'val1'"}},
|
||||
{
|
||||
"enabled": True,
|
||||
"deviceClass": "TestDevice",
|
||||
"deviceConfig": {"param1": "val1"},
|
||||
"readoutPriority": "monitored",
|
||||
"description": None,
|
||||
"readOnly": False,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": set(),
|
||||
"userParameter": {},
|
||||
"name": "test_device",
|
||||
},
|
||||
),
|
||||
({"deviceConfig": {}}, {}),
|
||||
],
|
||||
)
|
||||
def test_update_with_modified_deviceconfig(update_dialog, changes, result):
|
||||
for k, v in changes.items():
|
||||
update_dialog._form.widget_dict[k].setValue(v)
|
||||
assert update_dialog.updated_config() == result
|
||||
|
||||
|
||||
def test_add_form_init_without_name(add_dialog, qtbot):
|
||||
assert (name_widget := add_dialog._form.widget_dict.get("name")) is not None
|
||||
assert isinstance(name_widget, StrFormItem)
|
||||
|
||||
@@ -399,3 +399,35 @@ def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot):
|
||||
assert not roi.movable
|
||||
# Disable global lock again
|
||||
roi_tree.lock_all_action.action.setChecked(False)
|
||||
|
||||
|
||||
def test_cleanup_disconnect_signals(roi_tree, image_widget):
|
||||
"""Test that cleanup disconnects ROI signals so further changes do not update the tree."""
|
||||
# Add a rectangular ROI
|
||||
roi = image_widget.add_roi(kind="rect", name="cleanup_test", pos=(10, 10), size=(20, 20))
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Test that signals are connected before cleanup
|
||||
pre_name = item.text(roi_tree.COL_ROI)
|
||||
pre_coord = item.child(2).text(roi_tree.COL_PROPS)
|
||||
# Change ROI properties to see updates
|
||||
roi.label = "connected_name"
|
||||
roi.setPos(30, 30)
|
||||
# Verify that the tree item updated
|
||||
assert item.text(roi_tree.COL_ROI) == "connected_name"
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) != pre_coord
|
||||
|
||||
# Perform cleanup to disconnect signals
|
||||
roi_tree.cleanup()
|
||||
|
||||
# Store initial state
|
||||
initial_name = item.text(roi_tree.COL_ROI)
|
||||
initial_coord = item.child(2).text(roi_tree.COL_PROPS)
|
||||
|
||||
# Change ROI properties after cleanup
|
||||
roi.label = "changed_name"
|
||||
roi.setPos(50, 50)
|
||||
|
||||
# Verify that the tree item was not updated
|
||||
assert item.text(roi_tree.COL_ROI) == initial_name
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) == initial_coord
|
||||
|
||||
Reference in New Issue
Block a user