diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py index ef743c85..cf10ca27 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py @@ -219,6 +219,7 @@ class _BeamlineStatePillDelegate(QStyledItemDelegate): pill.update_requested.connect(self._manager._update_state_parameters) pill.remove_requested.connect(self._manager._remove_state_requested) pill.scan_interlock_toggle_requested.connect(self._manager._on_interlock_toggle_requested) + pill.scan_interlock_statuses_changed.connect(self._manager._on_interlock_statuses_changed) pill.row_height_changed.connect(lambda name=name: self._manager._sync_pill_item_size(name)) self._manager._state_pills[str(name)] = pill return pill @@ -712,6 +713,19 @@ class BeamlineStateManager(BECWidget, QWidget): return self._refresh_scan_interlock() + @SafeSlot(str, object) + def _on_interlock_statuses_changed(self, state_name: str, statuses: object) -> None: + # Only persist when the state is currently watched; otherwise the pill keeps the + # preference for when it is locked later. + if state_name not in self._interlock_states: + return + try: + self._scan_interlock.add_state_to_interlock(state_name, list(statuses)) + except Exception as exc: + QMessageBox.warning(self, "Cannot Update Scan Interlock", str(exc)) + return + self._refresh_scan_interlock() + def _open_persistent_editors(self, expanded_names: set[str] | None = None) -> None: expanded_names = expanded_names or set() for row in range(self._model.rowCount()): diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index eb278e0d..5df19d7c 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -9,6 +9,7 @@ from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, Qt, Signal from qtpy.QtGui import QColor, QMouseEvent, QPalette from qtpy.QtWidgets import ( QApplication, + QCheckBox, QFormLayout, QGraphicsDropShadowEffect, QHBoxLayout, @@ -64,6 +65,7 @@ class BeamlineStatePill(BECWidget, QWidget): update_requested = Signal(str, object) remove_requested = Signal(str) scan_interlock_toggle_requested = Signal(str, bool) + scan_interlock_statuses_changed = Signal(str, object) row_height_changed = Signal() _STATUS_LABELS = BEAMLINE_STATE_STATUS_LABELS @@ -217,11 +219,22 @@ class BeamlineStatePill(BECWidget, QWidget): self._settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) self._settings_form.addRow("Type", self._state_type_value) + self._interlock_warning_checkbox = QCheckBox( + "Trigger ScanInterlock on WARNING state", self._settings + ) + self._interlock_warning_checkbox.setToolTip( + "By default both VALID and WARNING are accepted. Enable this so a WARNING status also " + "trips the scan interlock (only VALID accepted)." + ) + self._interlock_warning_checkbox.toggled.connect(self._on_interlock_warning_toggled) + self._sync_interlock_warning_checkbox() + settings_layout = QVBoxLayout(self._settings) settings_layout.setContentsMargins(12, 8, 12, 12) settings_layout.setSpacing(8) settings_layout.addLayout(self._settings_form) settings_layout.addLayout(self._config_form_host) + settings_layout.addWidget(self._interlock_warning_checkbox) settings_layout.addLayout(button_layout) layout = QVBoxLayout(self) @@ -320,6 +333,7 @@ class BeamlineStatePill(BECWidget, QWidget): triggered = bool(triggered) and required_statuses is not None if required_statuses is not None: self._interlock_statuses = list(required_statuses) + self._sync_interlock_warning_checkbox() if (required_statuses, triggered) == ( self._interlock_required_statuses, self._interlock_triggered, @@ -343,6 +357,22 @@ class BeamlineStatePill(BECWidget, QWidget): def set_interlock_statuses(self, statuses: list[str]) -> None: """Configure the accepted scan-interlock statuses for this state.""" self._interlock_statuses = list(statuses) + self._sync_interlock_warning_checkbox() + + def _sync_interlock_warning_checkbox(self) -> None: + trigger_on_warning = "warning" not in self._interlock_statuses + self._interlock_warning_checkbox.blockSignals(True) + self._interlock_warning_checkbox.setChecked(trigger_on_warning) + self._interlock_warning_checkbox.blockSignals(False) + + @SafeSlot(bool) + def _on_interlock_warning_toggled(self, trigger_on_warning: bool) -> None: + statuses = ["valid"] if trigger_on_warning else ["valid", "warning"] + if statuses == self._interlock_statuses: + return + self._interlock_statuses = statuses + if self._state_name is not None: + self.scan_interlock_statuses_changed.emit(self._state_name, statuses) @SafeSlot() def _emit_interlock_toggle_requested(self) -> None: diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 0d31febd..16c5f1e3 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -892,6 +892,66 @@ def test_beamline_state_manager_pill_toggle_calls_backend(qtbot, mocked_client): assert beamline_state_manager._interlock_states == {} +def test_beamline_state_pill_settings_warning_checkbox(qtbot, mocked_client): + pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client) + + # Default accepts VALID and WARNING, so the checkbox is unchecked. + assert pill._interlock_warning_checkbox.isChecked() is False + assert pill.interlock_statuses == ["valid", "warning"] + + with qtbot.waitSignal(pill.scan_interlock_statuses_changed) as signal: + pill._interlock_warning_checkbox.setChecked(True) + + assert signal.args == ["limits", ["valid"]] + assert pill.interlock_statuses == ["valid"] + + pill._interlock_warning_checkbox.setChecked(False) + assert pill.interlock_statuses == ["valid", "warning"] + + +def test_beamline_state_pill_settings_warning_checkbox_reflects_statuses(qtbot, mocked_client): + pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client) + + pill.set_interlock_statuses(["valid"]) + assert pill._interlock_warning_checkbox.isChecked() is True + + pill.set_interlock_statuses(["valid", "warning"]) + assert pill._interlock_warning_checkbox.isChecked() is False + + +def test_beamline_state_manager_settings_warning_reenrolls_watched_state(qtbot, mocked_client): + beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) + beamline_state_manager.update_available_states({"states": [_limits_state()]}, {}) + fake_interlock = _FakeScanInterlock( + enabled=True, states_watched={"limits": ["valid", "warning"]} + ) + _install_fake_scan_interlock(beamline_state_manager, fake_interlock) + pill = beamline_state_manager._state_pills["limits"] + assert pill.interlock_statuses == ["valid", "warning"] + + pill._interlock_warning_checkbox.setChecked(True) + + assert fake_interlock.added[-1] == ("limits", ["valid"]) + assert beamline_state_manager._interlock_states == {"limits": ["valid"]} + + +def test_beamline_state_manager_settings_warning_no_backend_when_not_watched(qtbot, mocked_client): + beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) + beamline_state_manager.update_available_states({"states": [_limits_state()]}, {}) + fake_interlock = _FakeScanInterlock() + _install_fake_scan_interlock(beamline_state_manager, fake_interlock) + pill = beamline_state_manager._state_pills["limits"] + + pill._interlock_warning_checkbox.setChecked(True) + + # Not watched yet: no backend write, but the preference is kept and used when locked. + assert fake_interlock.added == [] + assert pill.interlock_statuses == ["valid"] + + pill._interlock_button.click() + assert fake_interlock.added == [("limits", ["valid"])] + + def test_beamline_state_manager_lock_uses_dialog_status_preference( qtbot, mocked_client, monkeypatch ):