wip
This commit is contained in:
@@ -17,6 +17,7 @@ _Widgets = {
|
||||
"DigitalTwin": "DigitalTwin",
|
||||
"RestartServer": "RestartServer",
|
||||
"ScanControlXAS": "ScanControlXAS",
|
||||
"Scheduler": "Scheduler",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ designer_plugins = {
|
||||
"debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas",
|
||||
"ScanControlXAS",
|
||||
),
|
||||
"Scheduler": ("debye_bec.bec_widgets.widgets.scheduler.scheduler", "Scheduler"),
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
@@ -22,4 +23,5 @@ widget_icons = {
|
||||
"DigitalTwin": "lightbulb",
|
||||
"RestartServer": "restart_alt",
|
||||
"ScanControlXAS": "tune",
|
||||
"Scheduler": "assignment_add",
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ only place that connects those signals to schedule-specific behavior
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -23,6 +24,8 @@ from qtpy.QtCore import QObject, Signal
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MIN_UPDATE_INTERVAL = 1
|
||||
|
||||
|
||||
class GuardSettings(BaseModel):
|
||||
"""Persisted configuration for one `SignalGuard`."""
|
||||
@@ -54,11 +57,13 @@ class SignalGuard(QObject):
|
||||
on - no extra locking needed on the receiving end.
|
||||
"""
|
||||
|
||||
value_update = Signal(float)
|
||||
paused = Signal(float)
|
||||
resumed = Signal(float)
|
||||
|
||||
def __init__(self, connector, parent=None):
|
||||
def __init__(self, connector, parent=None, dev=None):
|
||||
super().__init__(parent)
|
||||
self.dev = dev
|
||||
self._connector = connector
|
||||
self._lock = threading.Lock()
|
||||
self._clear_event = threading.Event()
|
||||
@@ -69,9 +74,13 @@ class SignalGuard(QObject):
|
||||
self.pause_below: float | None = None
|
||||
self.resume_above: float | None = None
|
||||
self.current_value: float | None = None
|
||||
self.units: str = ""
|
||||
self.prec: int = 3
|
||||
|
||||
self._subscribed_endpoint = None
|
||||
|
||||
self.last_val_update = time.time()
|
||||
|
||||
def configure(self, settings: GuardSettings):
|
||||
"""(Re)configure and (re)subscribe. Safe to call repeatedly, e.g. after editing settings."""
|
||||
self._unsubscribe()
|
||||
@@ -83,16 +92,26 @@ class SignalGuard(QObject):
|
||||
self.current_value = None
|
||||
self._clear_event.set()
|
||||
|
||||
if self.enabled and self.device_name and self.pause_below is not None and self.resume_above is not None:
|
||||
if (
|
||||
self.enabled
|
||||
and self.device_name
|
||||
and self.pause_below is not None
|
||||
and self.resume_above is not None
|
||||
):
|
||||
self._subscribed_endpoint = MessageEndpoints.device_readback(self.device_name)
|
||||
self._connector.register(topics=self._subscribed_endpoint, cb=self._on_readback)
|
||||
description = self.dev[self.device_name].describe()[self.device_name]
|
||||
self.units = description["units"]
|
||||
self.prec = description["precision"]
|
||||
|
||||
def _unsubscribe(self):
|
||||
if self._subscribed_endpoint is not None:
|
||||
try:
|
||||
self._connector.unregister(topics=self._subscribed_endpoint, cb=self._on_readback)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to unsubscribe SignalGuard from %s", self._subscribed_endpoint)
|
||||
logger.exception(
|
||||
f"Failed to unsubscribe SignalGuard from {self._subscribed_endpoint}"
|
||||
)
|
||||
self._subscribed_endpoint = None
|
||||
|
||||
def _on_readback(self, msg):
|
||||
@@ -115,12 +134,21 @@ class SignalGuard(QObject):
|
||||
crossed_resume = True
|
||||
|
||||
if crossed_pause:
|
||||
logger.info("SignalGuard: %s dropped to %s (below %s) - pausing.", self.device_name, value, self.pause_below)
|
||||
logger.info(
|
||||
f"SignalGuard: {self.device_name} dropped to {value} (below {self.pause_below}) - pausing."
|
||||
)
|
||||
self.paused.emit(value)
|
||||
elif crossed_resume:
|
||||
logger.info("SignalGuard: %s recovered to %s (above %s) - resuming.", self.device_name, value, self.resume_above)
|
||||
logger.info(
|
||||
f"SignalGuard: {self.device_name} recovered to {value} (above {self.resume_above}) - resuming."
|
||||
)
|
||||
self.resumed.emit(value)
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - self.last_val_update > MIN_UPDATE_INTERVAL:
|
||||
self.last_val_update = current_time
|
||||
self.value_update.emit(value)
|
||||
|
||||
def is_clear(self) -> bool:
|
||||
"""True if not currently blocking (disabled, or value above the resume threshold)."""
|
||||
return (not self.enabled) or self._clear_event.is_set()
|
||||
|
||||
@@ -20,14 +20,15 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import VariableMessage
|
||||
from bec_qthemes import material_icon
|
||||
from bec_qthemes._theme import AccentColors
|
||||
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 pydantic import ValidationError
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -45,7 +46,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from .endpoints import schedule as schedule_endpoint
|
||||
from .guard import GuardSettings, SignalGuard
|
||||
from .guard import SignalGuard
|
||||
from .guard_dialog import GuardSettingsDialog
|
||||
from .item_dialog import ScheduleItemDialog
|
||||
from .notification_dialog import NotificationSettingsDialog
|
||||
@@ -55,21 +56,9 @@ from .schedule_logic import index_of, pick_next_runnable, protected_prefix_lengt
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# The static, hard-coded example schedule requested for this plugin. A
|
||||
# real deployment would more likely let the user build this list purely
|
||||
# through the UI - the persistence/execution/reconciliation logic below
|
||||
# does not care where the commands came from.
|
||||
DEFAULT_COMMANDS = [
|
||||
"scans.xas_simple_scan(12000, 14000, 2, 10)",
|
||||
"dev.samx.move(12.3, relative=True)",
|
||||
"scans.xas_simple_scan(13000, 17000, 1, 60)",
|
||||
]
|
||||
|
||||
_ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING")
|
||||
_ITEM_ID_ROLE = Qt.UserRole + 1 # QListWidgetItem data role used to map a row back to an item_id
|
||||
|
||||
|
||||
# TODO: Colors should change when the item is selected, otherwise won't be readable
|
||||
ICON_SIZE = 20
|
||||
_ICON_MAP = {
|
||||
ScheduleItemStatus.PENDING: ("hourglass", "#919090"),
|
||||
@@ -158,7 +147,7 @@ class MyButton(QPushButton):
|
||||
class Scheduler(BECWidget, QWidget):
|
||||
"""Schedule, persist and execute a sequence of BEC scan/device commands."""
|
||||
|
||||
# BUG: Icon is not recognized, also no entry is created in designer_plugins.py
|
||||
PLUGIN = True
|
||||
ICON_NAME = "assignment_add"
|
||||
USER_ACCESS = [
|
||||
"run_schedule",
|
||||
@@ -222,7 +211,7 @@ class Scheduler(BECWidget, QWidget):
|
||||
# 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.setInterval(200)
|
||||
self._notes_save_timer.timeout.connect(self._persist_notes)
|
||||
|
||||
self.schedule_changed.connect(self._refresh_ui)
|
||||
@@ -230,9 +219,10 @@ class Scheduler(BECWidget, QWidget):
|
||||
self.schedule: Schedule = self._load_or_seed_schedule()
|
||||
self._reconcile_with_live_state()
|
||||
|
||||
self._guard = SignalGuard(self.connector, parent=self)
|
||||
self._guard = SignalGuard(self.connector, parent=self, dev=self.dev)
|
||||
self._guard.paused.connect(self._on_guard_paused)
|
||||
self._guard.resumed.connect(self._on_guard_resumed)
|
||||
self._guard.value_update.connect(self._update_guard_label)
|
||||
self._guard.configure(self.schedule.guard)
|
||||
|
||||
self._build_ui()
|
||||
@@ -325,13 +315,18 @@ class Scheduler(BECWidget, QWidget):
|
||||
settings_group = QGroupBox("Settings")
|
||||
settings_layout = QVBoxLayout(settings_group)
|
||||
|
||||
settings_row = QHBoxLayout()
|
||||
self.notifications_btn = MyButton("Notifications", "default")
|
||||
guard_row = QHBoxLayout()
|
||||
self.guard_btn = MyButton("Auto-pause", "default")
|
||||
settings_row.addWidget(self.notifications_btn)
|
||||
settings_row.addWidget(self.guard_btn)
|
||||
settings_row.addStretch()
|
||||
settings_layout.addLayout(settings_row)
|
||||
self.guard_status_label = QLabel()
|
||||
guard_row.addWidget(self.guard_btn)
|
||||
guard_row.addWidget(self.guard_status_label)
|
||||
guard_row.addStretch()
|
||||
notifications_row = QHBoxLayout()
|
||||
self.notifications_btn = MyButton("Notifications", "default")
|
||||
notifications_row.addWidget(self.notifications_btn)
|
||||
notifications_row.addStretch()
|
||||
settings_layout.addLayout(guard_row)
|
||||
settings_layout.addLayout(notifications_row)
|
||||
|
||||
self.notifications_btn.clicked.connect(self._on_notifications_clicked)
|
||||
self.guard_btn.clicked.connect(self._on_guard_clicked)
|
||||
@@ -351,10 +346,6 @@ class Scheduler(BECWidget, QWidget):
|
||||
|
||||
layout.addWidget(file_group)
|
||||
|
||||
self.guard_status_label = QLabel()
|
||||
self.guard_status_label.setStyleSheet("color: gray;")
|
||||
layout.addWidget(self.guard_status_label)
|
||||
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
|
||||
@@ -397,12 +388,7 @@ class Scheduler(BECWidget, QWidget):
|
||||
return Schedule.model_validate(msg.value)
|
||||
|
||||
logger.info("No schedule found, create one now")
|
||||
schedule = Schedule(
|
||||
schedule_name=self.schedule_name,
|
||||
items=[
|
||||
ScheduleItem(item_id=str(uuid.uuid4()), command=cmd) for cmd in DEFAULT_COMMANDS
|
||||
],
|
||||
)
|
||||
schedule = Schedule(schedule_name=self.schedule_name, items=[])
|
||||
self.connector.set_and_publish(
|
||||
self._endpoint, VariableMessage(value=schedule.model_dump(mode="json"))
|
||||
)
|
||||
@@ -419,7 +405,7 @@ class Scheduler(BECWidget, QWidget):
|
||||
self._persist_locked()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _on_remote_update(self, msg_content: dict, metadata: dict):
|
||||
def _on_remote_update(self, msg_content: dict, _):
|
||||
value = msg_content.get("value") if msg_content else None
|
||||
if not value or value.get("schedule_name") != self.schedule_name:
|
||||
return
|
||||
@@ -497,17 +483,13 @@ class Scheduler(BECWidget, QWidget):
|
||||
selected_row = None
|
||||
for row, item in enumerate(items):
|
||||
text = f"{item.command}"
|
||||
icon_name, color = _ICON_MAP[item.status]
|
||||
icon = material_icon(
|
||||
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
|
||||
)
|
||||
if item.error:
|
||||
text += f" ({item.error.strip().splitlines()[-1]})"
|
||||
list_item = QListWidgetItem(text, self.list_widget)
|
||||
list_item.setData(_ITEM_ID_ROLE, item.item_id)
|
||||
list_item.setIcon(icon)
|
||||
if item.item_id == self._selected_item_id:
|
||||
selected_row = row
|
||||
self._update_icons()
|
||||
if selected_row is not None:
|
||||
self.list_widget.setCurrentRow(selected_row)
|
||||
else:
|
||||
@@ -523,17 +505,18 @@ class Scheduler(BECWidget, QWidget):
|
||||
self.notes_edit.setPlainText(notes)
|
||||
self.notes_edit.blockSignals(False)
|
||||
|
||||
def _update_guard_label(self, *_):
|
||||
if self._guard.enabled:
|
||||
state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume"
|
||||
value = self._guard.current_value
|
||||
value_text = f"{value:g}" if value is not None else "?"
|
||||
units = self._guard.units
|
||||
value_text = f"{value:.{self._guard.prec}f}" if value is not None else "?"
|
||||
self.guard_status_label.setText(
|
||||
f"Auto-pause: {self._guard.device_name} = {value_text} "
|
||||
f"Enabled: {self._guard.device_name} = {value_text} {units} "
|
||||
f"(pause < {self._guard.pause_below:g}, resume > {self._guard.resume_above:g}) [{state}]"
|
||||
)
|
||||
self.guard_status_label.show()
|
||||
else:
|
||||
self.guard_status_label.hide()
|
||||
self.guard_status_label.setText("Disabled")
|
||||
|
||||
def _update_buttons(self):
|
||||
with self._lock:
|
||||
@@ -568,10 +551,23 @@ class Scheduler(BECWidget, QWidget):
|
||||
self.copy_btn.setEnabled(selected_item is not None)
|
||||
self.paste_btn.setEnabled(self._clipboard_item is not None)
|
||||
|
||||
def _update_icons(self):
|
||||
items = list(self.schedule.items)
|
||||
for row, item in enumerate(items):
|
||||
icon_name, color = _ICON_MAP[item.status]
|
||||
if self._selected_item_id == item.item_id:
|
||||
color = "#FFFFFF"
|
||||
icon = material_icon(
|
||||
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
|
||||
)
|
||||
list_item = self.list_widget.item(row)
|
||||
list_item.setIcon(icon)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_selection_changed(self, row: int):
|
||||
item = self.list_widget.item(row) if row >= 0 else None
|
||||
self._selected_item_id = item.data(_ITEM_ID_ROLE) if item is not None else None
|
||||
self._update_icons()
|
||||
self._update_buttons()
|
||||
|
||||
@SafeSlot()
|
||||
@@ -717,17 +713,17 @@ class Scheduler(BECWidget, QWidget):
|
||||
self._persist_locked()
|
||||
self._guard.configure(self.schedule.guard)
|
||||
self._refresh_ui()
|
||||
self._update_guard_label()
|
||||
|
||||
def _apply_guard_settings(self):
|
||||
"""Re-subscribe the guard after `self.schedule.guard` changes (e.g. from a remote update)."""
|
||||
self._guard.configure(self.schedule.guard)
|
||||
self._update_guard_label()
|
||||
|
||||
@SafeSlot(float)
|
||||
def _on_guard_paused(self, value: float):
|
||||
logger.info(
|
||||
"Auto-pause guard tripped for schedule '%s' (value=%s); interrupting the running scan, if any.",
|
||||
self.schedule_name,
|
||||
value,
|
||||
f"Auto-pause guard tripped for schedule {self.schedule_name} (value = {value}); interrupting the running scan, if any."
|
||||
)
|
||||
if self._current_report is not None and self._current_item_kind == "scan":
|
||||
self._guard_interrupt_requested = True
|
||||
@@ -740,10 +736,8 @@ class Scheduler(BECWidget, QWidget):
|
||||
@SafeSlot(float)
|
||||
def _on_guard_resumed(self, value: float):
|
||||
logger.info(
|
||||
"Auto-pause guard cleared for schedule '%s' (value=%s); the execution loop will "
|
||||
"resume the next scan item automatically.",
|
||||
self.schedule_name,
|
||||
value,
|
||||
f"Auto-pause guard cleared for schedule {self.schedule_name} (value = {value}); the execution loop will "
|
||||
"resume the next scan item automatically."
|
||||
)
|
||||
self.schedule_changed.emit()
|
||||
|
||||
@@ -765,7 +759,7 @@ class Scheduler(BECWidget, QWidget):
|
||||
self._update_buttons()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_edit_clicked(self, row=None):
|
||||
def _on_edit_clicked(self, _):
|
||||
if self._selected_item_id is None:
|
||||
return
|
||||
with self._lock:
|
||||
@@ -1168,8 +1162,7 @@ class Scheduler(BECWidget, QWidget):
|
||||
item.request_id = getattr(request, "requestID", None)
|
||||
self._persist_locked()
|
||||
|
||||
# Wait for completion without blocking the thread indefinitely
|
||||
self._wait_for_report(report)
|
||||
report.wait()
|
||||
|
||||
if self._closing.is_set():
|
||||
# The widget was closed mid-item; exit without changing status in Redis.
|
||||
@@ -1227,19 +1220,6 @@ class Scheduler(BECWidget, QWidget):
|
||||
error=item.error,
|
||||
)
|
||||
|
||||
def _wait_for_report(self, report, timeout: float = 0.5):
|
||||
"""Poll report status to allow thread exit if the widget closes."""
|
||||
while not self._closing.is_set() and not self._abort_requested:
|
||||
# Check if scan report has finished via its internal event or status
|
||||
if (
|
||||
getattr(report, "status", None) == "completed"
|
||||
or getattr(report, "event", None)
|
||||
and report.event.is_set()
|
||||
):
|
||||
break
|
||||
# Non-blocking poll interval
|
||||
time.sleep(timeout)
|
||||
|
||||
def _await_running_item(self, item: ScheduleItem):
|
||||
queue_storage = self.client.queue.queue_storage
|
||||
while not self._abort_requested and not self._closing.is_set():
|
||||
|
||||
Reference in New Issue
Block a user