wip
CI for debye_bec / test (push) Successful in 57s
CI for debye_bec / test (pull_request) Successful in 56s

This commit is contained in:
x01da
2026-09-02 08:10:16 +02:00
parent 851ec945ce
commit 02205b43c5
3 changed files with 267 additions and 7 deletions
@@ -26,6 +26,8 @@ can reopen the dialog pre-filled instead of asking the user to start over.
from __future__ import annotations
import math
from bec_lib.logger import bec_logger
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
BECDeviceFilter,
@@ -146,24 +148,37 @@ class ScheduleItemDialog(QDialog):
form = QFormLayout(tab)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
# self.move_device_combo = QComboBox()
# self.move_device_combo.addItems(list_movable_device_names(self._dev))
# self.move_device_combo.setMaxVisibleItems(12)
self.move_device_combo = DeviceComboBox(self, device_filter=[BECDeviceFilter.POSITIONER])
form.addRow("Device", self.move_device_combo)
self.move_value_spin = QDoubleSpinBox()
self.move_value_spin.setDecimals(6)
self.move_value_spin.setRange(*_DSPIN_RANGE)
form.addRow("Value", self.move_value_spin)
self.move_relative_check = QCheckBox("")
form.addRow("Relative move", self.move_relative_check)
self.move_device_combo.currentIndexChanged.connect(self._adjust_spinbox)
self.tabs.addTab(tab, "Move")
def _adjust_spinbox(self, _) -> None:
prec = self._dev[self.move_device_combo.currentText()].precision
units = self._dev[self.move_device_combo.currentText()].egu()
ll = self._dev[self.move_device_combo.currentText()].low_limit
hl = self._dev[self.move_device_combo.currentText()].high_limit
self.move_value_spin.setDecimals(prec)
self.move_value_spin.setSuffix(f" {units}")
if (hl - ll) > 0:
self.move_value_spin.setMinimum(ll)
self.move_value_spin.setMaximum(hl)
self.move_value_spin.setSingleStep(10 ** round(math.log10((hl - ll) / 100)))
else:
self.move_value_spin.setMinimum(1e6)
self.move_value_spin.setMaximum(-1e6)
self.move_value_spin.setSingleStep(1)
def _collect_move_result(self) -> dict:
device_name = self.move_device_combo.currentText()
if not device_name:
@@ -67,6 +67,7 @@ class Schedule(BaseModel):
schedule_name: str
items: list[ScheduleItem] = Field(default_factory=list)
is_running: bool = False
notes: str = ""
# Settings for two independent, optional features (see notifications.py
# and guard.py) - persisted here alongside the schedule itself so they
@@ -9,6 +9,7 @@ or finished cannot.
from __future__ import annotations
import json
import threading
import time
import traceback
@@ -24,17 +25,20 @@ from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from qtpy.QtCore import Qt, Signal
from pydantic import ValidationError
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import (
QApplication,
QDialog,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
@@ -100,6 +104,8 @@ class ScheduleWidgetConfig(ConnectionConfig):
class MyListWidget(QListWidget):
deletePressed = Signal()
emptySpaceClicked = Signal()
copyPressed = Signal()
pastePressed = Signal()
def keyPressEvent(self, event):
if event.key() == Qt.Key_Delete and self.currentItem() is not None:
@@ -107,6 +113,16 @@ class MyListWidget(QListWidget):
event.accept()
return
if event.matches(QKeySequence.StandardKey.Copy) and self.currentItem() is not None:
self.copyPressed.emit()
event.accept()
return
if event.matches(QKeySequence.StandardKey.Paste):
self.pastePressed.emit()
event.accept()
return
super().keyPressEvent(event)
def mousePressEvent(self, event):
@@ -200,6 +216,14 @@ class Scheduler(BECWidget, QWidget):
self._current_report = None # ScanReport of the item currently executing, if any
self._current_item_kind: str | None = None # kind of the item _current_report belongs to
self._selected_item_id: str | None = None # survives list repopulation on refresh
self._clipboard_item: dict | None = None # copy/paste buffer, see _on_copy_clicked
# Notes are auto-saved on a debounce timer rather than on every
# keystroke - see _on_notes_changed/_persist_notes.
self._notes_save_timer = QTimer(self)
self._notes_save_timer.setSingleShot(True)
self._notes_save_timer.setInterval(600)
self._notes_save_timer.timeout.connect(self._persist_notes)
self.schedule_changed.connect(self._refresh_ui)
@@ -235,12 +259,16 @@ class Scheduler(BECWidget, QWidget):
self.delete_btn = MyButton("Delete", "default")
self.move_up_btn = MyButton("Move Up", "default")
self.move_down_btn = MyButton("Move Down", "default")
self.copy_btn = MyButton("Copy", "default")
self.paste_btn = MyButton("Paste", "default")
for button in (
self.add_btn,
self.edit_btn,
self.delete_btn,
self.move_up_btn,
self.move_down_btn,
self.copy_btn,
self.paste_btn,
):
edit_row.addWidget(button)
edit_row.addStretch()
@@ -253,14 +281,26 @@ class Scheduler(BECWidget, QWidget):
self.list_widget.deletePressed.connect(self._on_delete_clicked)
self.list_widget.emptySpaceClicked.connect(self._on_empty_space_clicked)
self.list_widget.itemDoubleClicked.connect(self._on_edit_clicked)
self.list_widget.copyPressed.connect(self._on_copy_clicked)
self.list_widget.pastePressed.connect(self._on_paste_clicked)
schedule_layout.addWidget(self.list_widget)
schedule_layout.addWidget(QLabel("Notes"))
self.notes_edit = QPlainTextEdit()
self.notes_edit.setPlaceholderText("Notes about this schedule...")
self.notes_edit.setPlainText(self.schedule.notes)
self.notes_edit.setMaximumHeight(100)
self.notes_edit.textChanged.connect(self._on_notes_changed)
schedule_layout.addWidget(self.notes_edit)
self.add_btn.clicked.connect(self._on_add_clicked)
self.edit_btn.clicked.connect(self._on_edit_clicked)
self.delete_btn.clicked.connect(self._on_delete_clicked)
self.move_up_btn.clicked.connect(self._on_move_up_clicked)
self.move_down_btn.clicked.connect(self._on_move_down_clicked)
self.copy_btn.clicked.connect(self._on_copy_clicked)
self.paste_btn.clicked.connect(self._on_paste_clicked)
layout.addWidget(schedule_group)
@@ -298,6 +338,19 @@ class Scheduler(BECWidget, QWidget):
layout.addWidget(settings_group)
file_group = QGroupBox("File")
file_layout = QHBoxLayout(file_group)
self.save_file_btn = MyButton("Save to file", "default")
self.load_file_btn = MyButton("Load from file", "default")
file_layout.addWidget(self.save_file_btn)
file_layout.addWidget(self.load_file_btn)
file_layout.addStretch()
self.save_file_btn.clicked.connect(self._on_save_to_file_clicked)
self.load_file_btn.clicked.connect(self._on_load_from_file_clicked)
layout.addWidget(file_group)
self.guard_status_label = QLabel()
self.guard_status_label.setStyleSheet("color: gray;")
layout.addWidget(self.guard_status_label)
@@ -321,11 +374,15 @@ class Scheduler(BECWidget, QWidget):
self.delete_btn,
self.move_down_btn,
self.move_up_btn,
self.copy_btn,
self.paste_btn,
self.run_btn,
self.abort_btn,
self.reset_btn,
self.notifications_btn,
self.guard_btn,
self.save_file_btn,
self.load_file_btn,
]:
button.apply_theme()
@@ -433,6 +490,7 @@ class Scheduler(BECWidget, QWidget):
def _refresh_ui(self):
with self._lock:
items = list(self.schedule.items)
notes = self.schedule.notes
self.list_widget.blockSignals(True)
self.list_widget.clear()
@@ -456,6 +514,15 @@ class Scheduler(BECWidget, QWidget):
self._selected_item_id = None
self.list_widget.blockSignals(False)
# Don't stomp on notes the operator is actively typing (e.g. a
# remote update, or our own debounce timer firing right after
# local edits) - only resync when the field isn't focused and the
# text actually differs from what we already have.
if not self.notes_edit.hasFocus() and self.notes_edit.toPlainText() != notes:
self.notes_edit.blockSignals(True)
self.notes_edit.setPlainText(notes)
self.notes_edit.blockSignals(False)
if self._guard.enabled:
state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume"
value = self._guard.current_value
@@ -494,6 +561,12 @@ class Scheduler(BECWidget, QWidget):
self.move_down_btn.setEnabled(
can_edit_selected and idx is not None and idx < len(items) - 1
)
# Copy just captures the command text, so it's fine on any item
# (e.g. copying a COMPLETED item's command to reuse it later).
# Paste always goes through add_item(), which already clamps the
# insert position past the protected prefix - see _on_paste_clicked.
self.copy_btn.setEnabled(selected_item is not None)
self.paste_btn.setEnabled(self._clipboard_item is not None)
@SafeSlot()
def _on_selection_changed(self, row: int):
@@ -506,6 +579,122 @@ class Scheduler(BECWidget, QWidget):
self.list_widget.setCurrentRow(-1)
self._refresh_ui()
# ---- notes ---- #
def _on_notes_changed(self):
# Debounced: restart the timer on every keystroke, only persist
# once typing pauses, so we're not writing to Redis on every
# character.
self._notes_save_timer.start()
def _persist_notes(self):
text = self.notes_edit.toPlainText()
with self._lock:
if self.schedule.notes == text:
return
self.schedule.notes = text
self._persist_locked()
# ---- save / restore to file ---- #
@SafeSlot()
def _on_save_to_file_clicked(self):
with self._lock:
data = self.schedule.model_dump(mode="json")
default_name = f"{self.schedule_name}.json"
path, _ = QFileDialog.getSaveFileName(
self, "Save schedule to file", default_name, "JSON files (*.json);;All files (*)"
)
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except OSError as exc:
QMessageBox.critical(self, "Save failed", f"Could not write to {path}:\n{exc}")
return
logger.info(f"Saved schedule '{self.schedule_name}' to {path}")
@SafeSlot()
def _on_load_from_file_clicked(self):
with self._lock:
if self.schedule.is_running:
QMessageBox.warning(
self,
"Cannot load",
"Stop the running schedule (Abort) before loading a new one from file.",
)
return
path, _ = QFileDialog.getOpenFileName(
self, "Load schedule from file", "", "JSON files (*.json);;All files (*)"
)
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
loaded = Schedule.model_validate(data)
except (OSError, json.JSONDecodeError, ValidationError) as exc:
QMessageBox.critical(
self, "Load failed", f"Could not load a schedule from {path}:\n{exc}"
)
return
with self._lock:
current_count = len(self.schedule.items)
confirm = QMessageBox.question(
self,
"Load schedule",
f"Replace the current {current_count} item(s) with {len(loaded.items)} "
f"item(s) from the file?\n\nAll items will be loaded as PENDING, regardless "
f"of their status when the file was saved.",
)
if confirm != QMessageBox.StandardButton.Yes:
return
# Loading a file restores a *definition* (what to run), not a past
# execution: every item comes back PENDING with a fresh item_id and
# its execution bookkeeping cleared, and is_running is always
# forced False here - regardless of what the file says - so we
# never end up "reconciling" against request/scan ids from a
# different session. schedule_name is deliberately left as this
# widget's own name, not overwritten by the file's.
new_items = [
item.model_copy(
update={
"item_id": str(uuid.uuid4()),
"status": ScheduleItemStatus.PENDING,
"request_id": None,
"scan_id": None,
"scan_number": None,
"error": None,
"started_at": None,
"finished_at": None,
}
)
for item in loaded.items
]
with self._lock:
if self.schedule.is_running:
QMessageBox.warning(
self, "Cannot load", "The schedule started running while the dialog was open."
)
return
self.schedule.items = new_items
self.schedule.notes = loaded.notes
self.schedule.guard = loaded.guard
self.schedule.notifications = loaded.notifications
self._persist_locked()
self._guard.configure(self.schedule.guard)
self._selected_item_id = None
logger.info(
f"Loaded schedule from {path} ({len(new_items)} item(s)) into '{self.schedule_name}'"
)
self._refresh_ui()
self._update_buttons()
# ---- notifications / auto-pause settings ---- #
@SafeSlot()
def _on_notifications_clicked(self):
@@ -642,6 +831,55 @@ class Scheduler(BECWidget, QWidget):
except RuntimeError as exc:
QMessageBox.warning(self, "Cannot move item", str(exc))
@SafeSlot()
def _on_copy_clicked(self):
"""
Copy the selected item's command onto an internal clipboard. Only
the command/kind/form_state are captured (not status, timestamps,
request/scan ids, ...) since pasting always creates a fresh,
PENDING item - this is "copy the command", not "duplicate the
history".
"""
if self._selected_item_id is None:
return
with self._lock:
idx = self._index_of_locked(self._selected_item_id)
if idx is None:
return
item = self.schedule.items[idx]
self._clipboard_item = {
"command": item.command,
"kind": item.kind,
"form_state": dict(item.form_state) if item.form_state else None,
}
logger.info(f"Copied schedule item: {self._clipboard_item['command']}")
self._update_buttons()
@SafeSlot()
def _on_paste_clicked(self):
"""
Paste the copied command as a new item, right after whatever is
currently selected (or at the end, if nothing is selected) - same
placement `_on_add_clicked` uses. This goes through `add_item()`
unchanged, so the existing protected-prefix clamping applies here
too: pasting can never land before an item that's already
running or finished.
"""
if self._clipboard_item is None:
return
with self._lock:
idx = self._index_of_locked(self._selected_item_id)
insert_at = None if idx is None else idx + 1
new_id = self.add_item(
self._clipboard_item["command"],
index=insert_at,
kind=self._clipboard_item["kind"],
form_state=self._clipboard_item["form_state"],
)
self._selected_item_id = new_id
self._refresh_ui()
self._update_buttons()
# ------------------------------------------------------------------ #
# RPC-exposed actions (USER_ACCESS)
# ------------------------------------------------------------------ #
@@ -870,6 +1108,12 @@ class Scheduler(BECWidget, QWidget):
"""Stop background execution loops without cancelling server-side scans."""
self._closing.set()
# Flush any not-yet-saved notes edit instead of losing it - the
# debounce timer won't get a chance to fire once we're closing.
if self._notes_save_timer.isActive():
self._notes_save_timer.stop()
self._persist_notes()
# Interrupt guard wait loops if any thread is blocked in self._guard.wait_until_clear()
self._guard.cleanup()