1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

fix(device-form-dialog): Adapt device-form-dialog ophyd validation test

This commit is contained in:
2026-01-08 07:52:07 +01:00
committed by Jan Wyzula
parent c41ef4401d
commit 0467d88010
4 changed files with 102 additions and 25 deletions

View File

@@ -1,5 +1,7 @@
"""Dialogs for device configuration forms and ophyd testing."""
from typing import Any, Iterable, Tuple
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
@@ -20,6 +22,7 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
)
DEFAULT_DEVICE = "CustomDevice"
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
logger = bec_logger.logger
@@ -194,6 +197,9 @@ class DeviceFormDialog(QtWidgets.QDialog):
for widget in self._control_widgets.values():
widget.close()
widget.deleteLater()
if self._wait_dialog is not None:
self._wait_dialog.close()
self._wait_dialog.deleteLater()
@property
def config_validation_result(self) -> tuple[dict, int, int, str]:
@@ -274,14 +280,36 @@ class DeviceFormDialog(QtWidgets.QDialog):
Create and show a validation progress dialog while validating the device configuration.
The dialog will be modal and prevent user interaction until validation is complete.
"""
wait_dialog = QtWidgets.QProgressDialog("Validating… please wait", None, 0, 0, parent=self)
wait_dialog = QtWidgets.QProgressDialog(
"Validating config… please wait", None, 0, 0, parent=self
)
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
wait_dialog.setCancelButton(None)
wait_dialog.setMinimumDuration(0)
return wait_dialog
@SafeSlot(list)
def _handle_devices_already_in_session_results(
self, validation_results: _ValidationResultIter
) -> None:
"""Handle completion if device is already in session."""
if len(validation_results) != 1:
logger.error(
"Expected a single device validation result, but got multiple. Using first result."
)
result = validation_results[0] if len(validation_results) > 0 else None
if result is None:
logger.error(
f"Received validation results: {validation_results} of unexpected length 0. Returning."
)
return
device_config, config_status, connection_status, validation_msg = result
self._handle_validation_result(
device_config, config_status, connection_status, validation_msg
)
@SafeSlot(dict, int, int, str)
def _validation_complete(
def _handle_validation_result(
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
):
"""Handle completion of validation."""
@@ -297,8 +325,8 @@ class DeviceFormDialog(QtWidgets.QDialog):
f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation ."
)
self._validation_result = (device_config, config_status, connection_status, validation_msg)
self._wait_dialog.finished.emit(0)
if self._wait_dialog is not None:
self._wait_dialog.accept()
self._wait_dialog.close()
self._wait_dialog.deleteLater()
self._wait_dialog = None
@@ -327,10 +355,25 @@ class DeviceFormDialog(QtWidgets.QDialog):
self._wait_dialog = self._create_validation_dialog()
ophyd_validation = OphydValidation()
ophyd_validation.validation_completed.connect(self._validation_complete)
ophyd_validation.change_device_configs([config], True, False)
ophyd_validation.validation_completed.connect(self._handle_validation_result)
ophyd_validation.multiple_validations_completed.connect(
self._handle_devices_already_in_session_results
)
res = self._wait_dialog.exec() # This will block until the validation is complete
# NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
# and therefore must not have the signal emitted immediately in the same event loop iteration.
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
QtCore.QTimer.singleShot(
0, lambda: ophyd_validation.change_device_configs([config], True, False)
)
# NOTE If dialog was already close, this means that a validation callback was already received
# which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above,
# this should not happen, but we keep the check for safety.
if self._wait_dialog is not None:
self._wait_dialog.exec() # This will block until the validation is complete
config, config_status, connection_status, validation_msg = self._validation_result

View File

@@ -104,10 +104,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self.ophyd_test_view.multiple_validations_completed,
(self.device_table_view.update_multiple_device_validations,),
),
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
(self.request_ophyd_validation, (self.ophyd_test_view.device_table_config_changed,)),
(
self.device_table_view.device_configs_changed,
(self.ophyd_test_view.change_device_configs,),
(self.ophyd_test_view.device_table_config_changed,),
),
(
self.device_table_view.device_config_in_sync_with_redis,
@@ -591,7 +591,8 @@ 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])
self.device_table_view.update_device_configs([data], skip_validation=True)
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
@SafeSlot(dict, int, int, str, str)
def _add_to_table_from_dialog(
@@ -602,7 +603,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
msg: str,
old_device_name: str = "",
):
self.device_table_view.add_device_configs([data])
self.device_table_view.add_device_configs([data], skip_validation=True)
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
@SafeSlot()
def _remove_device_action(self):

View File

@@ -199,7 +199,8 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
# Signal emitted if devices are added (updated) or removed
# - device_configs: List of device configurations.
# - added: True if devices were added/updated, False if removed.
device_configs_changed = QtCore.Signal(list, bool)
# - skip validation: True if validation should be skipped for added/updated devices.
device_configs_changed = QtCore.Signal(list, bool, bool)
# Signal emitted when device selection changes, emits list of selected device configs
selected_devices = QtCore.Signal(list)
# Signal emitted when a device row is double-clicked, emits the device config
@@ -823,7 +824,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
# -------------------------------------------------------------------------
@SafeSlot(list)
def set_device_config(self, device_configs: _DeviceCfgIter):
def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
"""
Set the device config. This will clear any existing configs.
@@ -837,27 +838,31 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
for cfg in device_configs:
self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
cfgs_added.append(cfg)
self.device_configs_changed.emit(cfgs_added, True)
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="")
@SafeSlot()
def clear_device_configs(self):
"""Clear the device configs."""
"""Clear the device configs. Skips validation per default."""
self.set_busy(True, text="Clearing device configurations...")
device_configs = self.get_device_config()
with self.table_sort_on_hold:
self._clear_table()
self.device_configs_changed.emit(device_configs, False)
self.device_configs_changed.emit(
device_configs, 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="")
@SafeSlot(list)
def add_device_configs(self, device_configs: _DeviceCfgIter):
def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
"""
Add devices to the config. If a device already exists, it will be replaced.
Add devices to the config. If a device already exists, it will be replaced. If the validation is
skipped, the device will be added with UNKNOWN state to the table and has to be manually adjusted
by the user later on.
Args:
device_configs (Iterable[dict[str, Any]]): The device configs to add.
@@ -875,20 +880,22 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
# Remove existing rows first
if len(already_in_table) > 0:
self._remove_rows_by_name([cfg["name"] for cfg in already_in_table])
self.device_configs_changed.emit(already_in_table, False)
self.device_configs_changed.emit(
already_in_table, False, True
) # Skip validation for removals
all_configs = already_in_table + not_in_table
if len(all_configs) > 0:
for cfg in already_in_table + not_in_table:
self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
self.device_configs_changed.emit(already_in_table + not_in_table, True)
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="")
@SafeSlot(list)
def update_device_configs(self, device_configs: _DeviceCfgIter):
def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
"""
Update devices in the config. If a device does not exist, it will be added.
@@ -907,7 +914,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
row = self._update_row(cfg)
if row is not None:
cfgs_updated.append(cfg)
self.device_configs_changed.emit(cfgs_updated, True)
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="")
@@ -924,7 +931,9 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
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])
self.device_configs_changed.emit(cfgs_to_be_removed, False) #
self.device_configs_changed.emit(
cfgs_to_be_removed, 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="")
@@ -946,7 +955,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
with self.table_sort_on_hold:
self._remove_rows_by_name([row_data.data["name"]])
cfgs = [{"name": device_name, **row_data.data}]
self.device_configs_changed.emit(cfgs, False)
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="")

View File

@@ -470,9 +470,19 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
widgets: list[ValidationListItem] = self.list_widget.get_widgets()
return [widget.device_model.device_config for widget in widgets]
@SafeSlot(list, bool, bool)
def device_table_config_changed(
self, device_configs: list[dict[str, Any]], added: bool, skip_validation: bool
) -> None:
"""Slot to handle device config changes in the device table."""
self.change_device_configs(
device_configs=device_configs, added=added, skip_validation=skip_validation
)
@SafeSlot(list, bool)
@SafeSlot(list, bool, bool)
@SafeSlot(list, bool, bool, bool, float)
@SafeSlot(list, bool, bool, bool, float, bool)
def change_device_configs(
self,
device_configs: list[dict[str, Any]],
@@ -480,11 +490,17 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
connect: bool = False,
force_connect: bool = False,
timeout: float = 5.0,
skip_validation: bool = False,
) -> None:
"""
Change the device configuration to test. If added is False, existing devices are removed.
Device tests will be removed based on device names. No duplicates are allowed.
For validation runs, results are emitted via the validation_completed signal. Unless devices
are already in the running session with the same config, in which case the combined results
of all such devices are emitted via the multiple_validations_completed signal. NOTE Please make
sure to connect to both signals if you want to capture all results.
Args:
device_configs (list[dict[str, Any]]): List of device configurations.
added (bool): Whether the devices are added to the existing list.
@@ -504,7 +520,7 @@ 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: # Remove requested
if not added or skip_validation is True: # Remove requested
self._remove_device_config(cfg)
continue
if self._is_device_in_redis_session(cfg.get("name"), cfg):
@@ -533,7 +549,14 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
)
# Send out batch of updates for devices already in session
if devices_already_in_session:
self.multiple_validations_completed.emit(devices_already_in_session)
# NOTE: Use singleShot here to ensure that the signal is emitted after all other scheduled
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
# this is relevant for the DeviceFormDialog which opens a modal dialog during validation
# and therefore must not have the signal emitted immediately in the same event loop iteration.
# Otherwise, the dialog would block signal processing.
QtCore.QTimer.singleShot(
0, lambda: self.multiple_validations_completed.emit(devices_already_in_session)
)
def cancel_validation(self, device_name: str) -> None:
"""Cancel a running validation for a specific device.