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

This commit is contained in:
x01da
2026-09-02 15:27:01 +02:00
parent ed18193809
commit d54f247007
5 changed files with 371 additions and 77 deletions
@@ -0,0 +1,11 @@
from __future__ import annotations
from enum import Enum
class ScheduleItemStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
@@ -1,25 +1,56 @@
"""Small settings dialog for `notifications.NotificationSettings` - kept separate from the send logic itself."""
"""
Settings dialog for `notifications.NotificationSettings` - kept separate
from the send logic itself (`notifications.py`).
"""
from __future__ import annotations
from qtpy.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QLabel, QVBoxLayout
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from .notifications import NotificationSettings
from .enums import ScheduleItemStatus
from .notifications import NotificationSettings, NotificationTarget, send_notification
logger = bec_logger.logger
def _service_state(client, name: str) -> tuple[bool, list[str]]:
"""
Best-effort read of a messaging service's enabled/scopes state (see
the module docstring in notifications.py for why this is the only
introspection available). Never raises - a client without `messaging`
set up yet just looks like "not enabled, no scopes".
"""
messaging = getattr(client, "messaging", None)
service = getattr(messaging, name, None) if messaging is not None else None
if service is None:
return False, []
return bool(getattr(service, "_enabled", False)), sorted(getattr(service, "_scopes", set()))
class NotificationSettingsDialog(QDialog):
"""Choose whether, and for which item types, finish/fail notifications are sent."""
"""Choose whether/where/for-which-item-types finish/fail notifications are sent."""
def __init__(self, settings: NotificationSettings, parent=None):
def __init__(self, settings: NotificationSettings, client, parent=None):
super().__init__(parent)
self._client = client
self.setWindowTitle("Notifications")
self.setMinimumWidth(380)
layout = QVBoxLayout(self)
info_label = QLabel(
"Sends a BEC notification (see MessageEndpoints.notification) whenever a "
"schedule item finishes or fails. Whether it reaches you as a message depends "
"on your facility's notification routing (SciHub + Signal/Teams/SciLog) being "
"configured for these events - ask your beamline admin if you're not sure."
"Sends a message through the selected BEC messaging service whenever a "
"schedule item finishes or fails."
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
@@ -28,32 +59,173 @@ class NotificationSettingsDialog(QDialog):
self.enabled_check.setChecked(settings.enabled)
layout.addWidget(self.enabled_check)
kind_box = QGroupBox("Notify for")
kind_layout = QVBoxLayout(kind_box)
self.scan_check = QCheckBox("Scans")
self.scan_check.setChecked(settings.notify_scan)
layout.addWidget(self.scan_check)
self.move_check = QCheckBox("Movements")
self.move_check.setChecked(settings.notify_move)
layout.addWidget(self.move_check)
self.rpc_check = QCheckBox("RPC / custom commands")
self.rpc_check.setChecked(settings.notify_rpc)
layout.addWidget(self.rpc_check)
for check in (self.scan_check, self.move_check, self.rpc_check):
self.enabled_check.toggled.connect(check.setEnabled)
check.setEnabled(settings.enabled)
kind_layout.addWidget(check)
layout.addWidget(kind_box)
target_box = QGroupBox("Send via")
target_layout = QVBoxLayout(target_box)
self.scilog_check = QCheckBox("SciLog")
self.teams_check = QCheckBox("Microsoft Teams")
self.signal_check = QCheckBox("Signal Messenger")
for check in (self.scilog_check, self.teams_check, self.signal_check):
target_layout.addWidget(check)
layout.addWidget(target_box)
event_box = QGroupBox("Send on")
event_layout = QVBoxLayout(event_box)
self.completed_check = QCheckBox("Completed")
self.aborted_check = QCheckBox("Aborted")
self.failed_check = QCheckBox("Failed")
for check in (self.completed_check, self.aborted_check, self.failed_check):
event_layout.addWidget(check)
layout.addWidget(event_box)
# Teams: contact/channel can only be a scope an admin has already
# registered for the deployment - there's no way to address an
# arbitrary Teams user from the client, so this is a dropdown, not
# free text (see notifications.py's module docstring).
self.teams_box = QGroupBox("Teams settings")
teams_layout = QVBoxLayout(self.teams_box)
teams_enabled, teams_scopes = _service_state(client, "teams")
self.teams_combo = QComboBox()
self.teams_combo.addItems(teams_scopes)
self.teams_combo.setEnabled(teams_enabled)
teams_layout.addWidget(QLabel("Contact or channel"))
teams_layout.addWidget(self.teams_combo)
if not teams_enabled:
teams_layout.addWidget(_dim_label("Teams messaging is not enabled for this session."))
elif not teams_scopes:
teams_layout.addWidget(_dim_label("No Teams contacts/channels are registered."))
layout.addWidget(self.teams_box)
# Signal: a raw phone number works directly (BEC normalizes it -
# see the docs), so this is an editable combo box: type a number,
# or pick a pre-registered scope if any exist.
self.signal_box = QGroupBox("Signal settings")
signal_layout = QVBoxLayout(self.signal_box)
signal_enabled, signal_scopes = _service_state(client, "signal")
self.signal_combo = QComboBox()
self.signal_combo.setEditable(True)
self.signal_combo.addItems(signal_scopes)
self.signal_combo.setEnabled(signal_enabled)
signal_layout.addWidget(QLabel("Phone number or contact"))
self.signal_combo.setEditText("")
self.signal_combo.lineEdit().setPlaceholderText("e.g. +41791234567")
signal_layout.addWidget(self.signal_combo)
if not signal_enabled:
signal_layout.addWidget(_dim_label("Signal messaging is not enabled for this session."))
layout.addWidget(self.signal_box)
# pre-fill from the persisted settings
{
NotificationTarget.SCILOG: self.scilog_check,
NotificationTarget.TEAMS: self.teams_check,
NotificationTarget.SIGNAL: self.signal_check,
}.get(settings.target, self.scilog_check).setChecked(True)
for event in settings.events:
{
ScheduleItemStatus.COMPLETED: self.completed_check,
ScheduleItemStatus.ABORTED: self.aborted_check,
ScheduleItemStatus.FAILED: self.failed_check,
}.get(event).setChecked(True)
if settings.teams_scope:
idx = self.teams_combo.findText(settings.teams_scope)
if idx >= 0:
self.teams_combo.setCurrentIndex(idx)
if settings.signal_number:
self.signal_combo.setEditText(settings.signal_number)
self.scilog_check.toggled.connect(lambda *_: self._sync_target_widgets(self.scilog_check))
self.teams_check.toggled.connect(lambda *_: self._sync_target_widgets(self.teams_check))
self.signal_check.toggled.connect(lambda *_: self._sync_target_widgets(self.signal_check))
self.test_btn = QPushButton("Send test message")
self.test_btn.clicked.connect(self._on_test_clicked)
layout.addWidget(self.test_btn)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def result_settings(self) -> NotificationSettings:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
self._sync_target_widgets()
def _sync_target_widgets(self, current_button: QCheckBox = None):
if current_button is None:
for button in [self.scilog_check, self.teams_check, self.signal_check]:
if button.isChecked():
current_button = button
break
if current_button.isChecked():
for button in [self.scilog_check, self.teams_check, self.signal_check]:
if button != current_button:
button.setChecked(False)
if current_button == self.teams_check:
self.teams_box.setVisible(True)
self.signal_box.setVisible(False)
elif current_button == self.signal_check:
self.teams_box.setVisible(False)
self.signal_box.setVisible(True)
else:
self.teams_box.setVisible(False)
self.signal_box.setVisible(False)
self.layout().activate()
self.adjustSize()
def _current_settings(self) -> NotificationSettings:
if self.scilog_check.isChecked():
target = NotificationTarget.SCILOG
elif self.teams_check.isChecked():
target = NotificationTarget.TEAMS
elif self.signal_check.isChecked():
target = NotificationTarget.SIGNAL
else:
raise ValueError(f"Target {target} not supported")
events = []
if self.completed_check.isChecked():
events.append(ScheduleItemStatus.COMPLETED)
if self.aborted_check.isChecked():
events.append(ScheduleItemStatus.ABORTED)
if self.failed_check.isChecked():
events.append(ScheduleItemStatus.FAILED)
return NotificationSettings(
enabled=self.enabled_check.isChecked(),
notify_scan=self.scan_check.isChecked(),
notify_move=self.move_check.isChecked(),
notify_rpc=self.rpc_check.isChecked(),
target=target,
events=events,
teams_scope=self.teams_combo.currentText().strip() or None,
signal_number=self.signal_combo.currentText().strip() or None,
)
def _on_test_clicked(self):
settings = self._current_settings()
try:
send_notification(self._client, settings, "Test message from the BEC schedule widget.")
except Exception as exc: # pylint: disable=broad-except
QMessageBox.critical(self, "Test message failed", str(exc))
return
QMessageBox.information(self, "Test message sent", "The test message was sent.")
def result_settings(self) -> NotificationSettings:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
return self._current_settings()
def _dim_label(text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("color: gray;")
label.setWordWrap(True)
return label
@@ -2,42 +2,55 @@
A small, self-contained "tell the user when something finished/failed"
feature, independent of scheduling/execution logic.
Sends via BEC's own notification transport: `MessageEndpoints.notification`
carries a `messages.NotificationMessage`, the same message type/endpoint
BEC's own scan lifecycle events (e.g. "scan_completed") use. Reusing it
means schedule-item notifications are just more entries in the same
mechanism, not a plugin-invented side channel - and if your facility runs
SciHub with messaging-service routing configured (Signal/Teams/SciLog),
they can be forwarded out the same way any other BEC notification would
be, by adding a route for the event names below in that routing config.
Sends through BEC's own `client.messaging` container
(`bec_lib.messaging_services.MessagingContainer`, exposed as `bec.messaging`
in the IPython client) - the same mechanism documented at
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-signal.html
and
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-scilog.html:
Nothing here is scheduling/BECWidget-specific: `notify_item_finished` only
needs a connector, a settings object and a few plain values, so it can be
called from `schedule_widget.py` without either module depending on the
other's internals.
bec.messaging.signal.new("Beamline checks completed.").send(scope="+41791234567")
bec.messaging.scilog.new("Beamline checks completed.").send()
bec.messaging.teams.new("Beamline checks completed.").send(scope=<registered scope>)
Notes on each service, read directly from `bec_lib.messaging_services`
rather than assumed:
- **SciLog** needs no `scope` - it posts to the logbook of the currently
active pgroup automatically.
- **Signal** accepts a raw phone number as `scope` (BEC normalizes it,
defaulting to the Swiss country code for numbers without one - see the
docs above) *or* a pre-registered scope name for a group.
- **Teams** is a plain `MessagingService` with no Teams-specific methods:
a contact/channel can only be addressed by a `scope` an admin has
already registered for the deployment (`service._scopes`) - there is no
way to address an arbitrary Teams user/email directly from the client.
This is why the Teams UI in `notification_dialog.py` is a dropdown of
known scopes rather than free text, while Signal's is free text (with
any registered scopes offered as suggestions).
`client.messaging.<service>._enabled` / `._scopes` are the only
introspection available (no public accessor exists at the time of
writing) - used here and in the dialog to grey out a target that isn't
configured for the current session instead of only failing at send time.
"""
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from enum import Enum
from bec_lib.logger import bec_logger
from bec_lib.messages import MessagingServiceTextContent, NotificationMessage
from pydantic import BaseModel
from .enums import ScheduleItemStatus
logger = bec_logger.logger
# Namespaced so they can't collide with BEC's own built-in event names
# (e.g. "scan_completed"); an admin who wants these routed to Signal/Teams/
# SciLog can add a route for exactly these names in their notification
# routing config (`MessageEndpoints.notification_config()`).
_EVENT_NAMES = {
("scan", True): "schedule_scan_completed",
("scan", False): "schedule_scan_failed",
("move", True): "schedule_move_completed",
("move", False): "schedule_move_failed",
("custom", True): "schedule_rpc_completed",
("custom", False): "schedule_rpc_failed",
}
class NotificationTarget(str, Enum):
SCILOG = "scilog"
TEAMS = "teams"
SIGNAL = "signal"
class NotificationSettings(BaseModel):
@@ -51,6 +64,11 @@ class NotificationSettings(BaseModel):
# any hand-typed command, so it's exposed to the user as "RPC".
notify_rpc: bool = True
target: NotificationTarget = NotificationTarget.SCILOG
events: list[ScheduleItemStatus] = []
teams_scope: str | None = None # a pre-registered Teams scope (channel/contact)
signal_number: str | None = None # a phone number, or a pre-registered Signal scope
def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool:
if not settings.enabled:
@@ -62,33 +80,80 @@ def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool:
}.get(kind, False)
def send_notification(client, settings: NotificationSettings, text: str) -> None:
"""
Send `text` through whichever service `settings.target` selects.
Raises on failure (no target selected, missing Teams scope/Signal
number, the target service not enabled for this session, ...) -
callers that want a fire-and-forget send should catch around this.
`notify_item_finished` does; the dialog's "Send test message" button
deliberately does not, so the operator sees exactly what went wrong.
"""
if settings.target == NotificationTarget.SCILOG:
client.messaging.scilog.new(text).send()
elif settings.target == NotificationTarget.TEAMS:
if not settings.teams_scope:
raise ValueError("No Teams contact/channel selected.")
client.messaging.teams.new(text).send(scope=settings.teams_scope)
elif settings.target == NotificationTarget.SIGNAL:
if not settings.signal_number:
raise ValueError("No Signal phone number/contact set.")
client.messaging.signal.new(text).send(scope=settings.signal_number)
else:
raise ValueError("No notification target selected.")
def notify_item_finished(
connector,
schedule_name: str,
client,
kind: str,
command: str,
success: bool,
scan_number: str,
final_status: ScheduleItemStatus,
settings: NotificationSettings,
error: str | None = None,
):
"""
Sends a `NotificationMessage` if notifications are enabled for `kind`.
Never raises - a notification failing to send should not affect
schedule execution.
Sends a message if notifications are enabled for `kind` and a target
is configured. Never raises - a notification failing to send should
not affect schedule execution.
"""
if not _enabled_for_kind(settings, kind):
return
if final_status not in settings.events:
return
event = _EVENT_NAMES.get((kind, success), "schedule_item_finished")
status_word = "completed" if success else "failed"
text = f"[{schedule_name}] {kind} {status_word}: {command}"
status_word = final_status.name
text = f"**BEC Scheduler Widget**\nStatus: {kind} {status_word}"
if scan_number is not None:
text = text + f"\nScan number: {scan_number}"
text = text + f"\n\nCommand: {command}"
if error:
text += f"\n{error.strip().splitlines()[-1]}"
try:
connector.send(
MessageEndpoints.notification(event),
NotificationMessage(event=event, message=[MessagingServiceTextContent(content=text)]),
)
send_notification(client, settings, text)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to send schedule notification for event '%s'", event)
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
def notify_schedule_state(client, settings, schedule_name, state):
"""
Sends a message if notifications are enabled and a schedule is started or stopped.
Never raises - a notification failing to send should not affect schedule execution.
"""
text = f'**BEC Scheduler Widget**\nSchedule "{schedule_name}" '
if state == "started":
text = text + "has started"
elif state == "aborted":
text = text + "was aborted"
elif state == "widget_closed":
text = text + "has finished prematurely because widget was closed"
elif state == "finished":
text = text + "has finished"
else:
logger.warning(f"Unknown schedule state {state}")
try:
send_notification(client, settings, text)
except Exception: # pylint: disable=broad-except
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
@@ -15,18 +15,10 @@ from typing import Literal
from pydantic import BaseModel, Field
from .enums import ScheduleItemStatus
from .guard import GuardSettings
from .notifications import NotificationSettings
class ScheduleItemStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
# Which tab of the Add/Edit dialog built `command`, and therefore how to
# re-open that dialog pre-filled with the same values for editing. "custom"
# covers hand-typed text (including RPC calls to other widgets), which has
@@ -46,12 +46,18 @@ from qtpy.QtWidgets import (
)
from .endpoints import schedule as schedule_endpoint
from .enums import ScheduleItemStatus
from .guard import SignalGuard
from .guard_dialog import GuardSettingsDialog
from .item_dialog import ScheduleItemDialog
from .notification_dialog import NotificationSettingsDialog
from .notifications import NotificationSettings, notify_item_finished
from .schedule_item import Schedule, ScheduleItem, ScheduleItemStatus
from .notifications import (
NotificationSettings,
NotificationTarget,
notify_item_finished,
notify_schedule_state,
)
from .schedule_item import Schedule, ScheduleItem
from .schedule_logic import index_of, pick_next_runnable, protected_prefix_length
logger = bec_logger.logger
@@ -323,7 +329,9 @@ class Scheduler(BECWidget, QWidget):
guard_row.addStretch()
notifications_row = QHBoxLayout()
self.notifications_btn = MyButton("Notifications", "default")
self.notification_status_label = QLabel()
notifications_row.addWidget(self.notifications_btn)
notifications_row.addWidget(self.notification_status_label)
notifications_row.addStretch()
settings_layout.addLayout(guard_row)
settings_layout.addLayout(notifications_row)
@@ -346,6 +354,9 @@ class Scheduler(BECWidget, QWidget):
layout.addWidget(file_group)
self._update_guard_label()
self._update_notifications_label()
self.apply_theme()
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
@@ -694,14 +705,39 @@ class Scheduler(BECWidget, QWidget):
# ---- notifications / auto-pause settings ---- #
@SafeSlot()
def _on_notifications_clicked(self):
dialog = NotificationSettingsDialog(self.schedule.notifications, parent=self)
dialog = NotificationSettingsDialog(self.schedule.notifications, self.client, parent=self)
if dialog.exec_() != QDialog.Accepted:
return
with self._lock:
self.schedule.notifications = dialog.result_settings()
self._update_notifications_label()
self._persist_locked()
self._refresh_ui()
def _update_notifications_label(self):
if self.schedule.notifications.enabled:
types = []
if self.schedule.notifications.notify_scan:
types.append("scan")
if self.schedule.notifications.notify_move:
types.append("move")
if self.schedule.notifications.notify_rpc:
types.append("rpc")
match self.schedule.notifications.target:
case NotificationTarget.SIGNAL:
target = "Signal Messenger"
case NotificationTarget.TEAMS:
target = "Microsoft Teams"
case NotificationTarget.SCILOG:
target = "Scilog"
events = []
for event in self.schedule.notifications.events:
events.append(event.value)
text = f"Enabled: Notify through {target}, for item type {types}, and events {events}"
else:
text = "Disabled"
self.notification_status_label.setText(text)
@SafeSlot()
def _on_guard_clicked(self):
device_names = sorted(self.dev.keys())
@@ -732,6 +768,7 @@ class Scheduler(BECWidget, QWidget):
except Exception: # pylint: disable=broad-except
logger.exception("Failed to cancel the running scan for the auto-pause guard")
self.schedule_changed.emit()
self._update_guard_label()
@SafeSlot(float)
def _on_guard_resumed(self, value: float):
@@ -1114,7 +1151,9 @@ class Scheduler(BECWidget, QWidget):
super().cleanup()
def _run_all(self, repeat_aborted_item):
logger.info("_run_all was called")
notify_schedule_state(
self.client, self.schedule.notifications, self.schedule_name, "started"
)
namespace = {"scans": self.scans, "dev": self.dev}
while not self._abort_requested and not self._closing.is_set():
with self._lock:
@@ -1126,7 +1165,18 @@ class Scheduler(BECWidget, QWidget):
self._await_running_item(next_item)
else:
self._execute_item(next_item, namespace)
if self._abort_requested:
notify_schedule_state(
self.client, self.schedule.notifications, self.schedule_name, "aborted"
)
elif self._closing.is_set():
notify_schedule_state(
self.client, self.schedule.notifications, self.schedule_name, "widget_closed"
)
else:
notify_schedule_state(
self.client, self.schedule.notifications, self.schedule_name, "finished"
)
with self._lock:
logger.info("in _run_all, set is_running to false")
self.schedule.is_running = False
@@ -1209,13 +1259,17 @@ class Scheduler(BECWidget, QWidget):
else:
final_status = None
if final_status in (ScheduleItemStatus.COMPLETED, ScheduleItemStatus.FAILED):
if final_status in (
ScheduleItemStatus.COMPLETED,
ScheduleItemStatus.FAILED,
ScheduleItemStatus.ABORTED,
):
notify_item_finished(
self.connector,
self.schedule_name,
self.client,
item.kind,
item.command,
success=(final_status == ScheduleItemStatus.COMPLETED),
item.scan_number,
final_status=final_status,
settings=self.schedule.notifications,
error=item.error,
)