mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-19 06:45:36 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944e2cedf8 | ||
| cd11a6cce3 | |||
|
|
c98106e594 | ||
| 04f1ff4fe7 | |||
|
|
45ed92494c | ||
| 5fc96bd299 | |||
|
|
1ad5df57fe | ||
| 440e778162 | |||
| fdeb8fcb0f | |||
| 5c90983dd4 | |||
| 4171de1e45 |
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,6 +1,48 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.45.11 (2025-12-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Support for AsyncMultiSignal
|
||||
([`cd11a6c`](https://github.com/bec-project/bec_widgets/commit/cd11a6cce33f3c0642984ae6b2d159c7441e22c6))
|
||||
|
||||
|
||||
## v2.45.10 (2025-12-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **devices**: Minor fix to comply with new config helper in bec_lib
|
||||
([`04f1ff4`](https://github.com/bec-project/bec_widgets/commit/04f1ff4fe7869215f010bf73f7271e063e21f2a2))
|
||||
|
||||
|
||||
## v2.45.9 (2025-12-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc**: Add expiration to GUI registry state updates
|
||||
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
|
||||
|
||||
|
||||
## v2.45.8 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
|
||||
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
|
||||
|
||||
- **notification_banner**: Better contrast in light mode
|
||||
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
|
||||
|
||||
- **notification_banner**: Expired messages are hidden in notification center but still accessible
|
||||
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
|
||||
|
||||
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
|
||||
notifications ids
|
||||
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
|
||||
|
||||
|
||||
## v2.45.7 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -229,6 +229,7 @@ class RPCServer:
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1,
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
|
||||
|
||||
@@ -14,13 +14,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.alarm_handler import Alarms # external enum
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import ErrorInfo
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QObject, QTimer
|
||||
@@ -28,6 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@@ -53,10 +55,10 @@ DARK_PALETTE = {
|
||||
}
|
||||
|
||||
LIGHT_PALETTE = {
|
||||
"base": "#e9ecef",
|
||||
"title": "#212121",
|
||||
"body": "#424242",
|
||||
"separator": "rgba(0,0,0,40)",
|
||||
"base": "#f5f5f7",
|
||||
"title": "#111827",
|
||||
"body": "#374151",
|
||||
"separator": "rgba(15,23,42,40)",
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ class NotificationToast(QFrame):
|
||||
self._kind = kind if isinstance(kind, SeverityKind) else SeverityKind(kind)
|
||||
self._traceback = traceback
|
||||
self._accent_color = QtGui.QColor(SEVERITY[self._kind.value]["color"])
|
||||
self._accent_alpha = 50
|
||||
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
|
||||
self.created = datetime.now()
|
||||
@@ -379,22 +382,31 @@ class NotificationToast(QFrame):
|
||||
|
||||
# buttons (text colour)
|
||||
base_btn_color = palette["title"]
|
||||
card_bg = QtGui.QColor(palette["base"])
|
||||
# tune card background and hover contrast per theme
|
||||
if theme == "light":
|
||||
card_bg.setAlphaF(0.98)
|
||||
btn_hover = self._accent_color.darker(105).name()
|
||||
else:
|
||||
card_bg.setAlphaF(0.88)
|
||||
btn_hover = self._accent_color.name()
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#NotificationToast {
|
||||
background: transparent;
|
||||
f"""
|
||||
#NotificationToast {{
|
||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||
border-radius: 12px;
|
||||
color: %s;
|
||||
}
|
||||
#NotificationToast QPushButton {
|
||||
color: {base_btn_color};
|
||||
border: 1px solid {palette["separator"]};
|
||||
}}
|
||||
#NotificationToast QPushButton {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: %s;
|
||||
color: {base_btn_color};
|
||||
font-size: 14px;
|
||||
}
|
||||
#NotificationToast QPushButton:hover { color: %s; }
|
||||
}}
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
"""
|
||||
% (base_btn_color, base_btn_color, self._accent_color.name())
|
||||
)
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
@@ -407,6 +419,37 @@ class NotificationToast(QFrame):
|
||||
"""
|
||||
)
|
||||
|
||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||
icon = material_icon(
|
||||
icon_name=SEVERITY[self._kind.value]["icon"],
|
||||
color=icon_fg,
|
||||
filled=True,
|
||||
size=(24, 24),
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
self._icon_btn.setIcon(icon)
|
||||
|
||||
badge_bg = QtGui.QColor(self._accent_color)
|
||||
if theme == "light":
|
||||
# darken and strengthen the badge on light cards for contrast
|
||||
badge_bg = badge_bg.darker(115)
|
||||
badge_bg.setAlphaF(0.70)
|
||||
else:
|
||||
badge_bg.setAlphaF(0.30)
|
||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
self.update()
|
||||
|
||||
########################################
|
||||
@@ -488,7 +531,9 @@ class NotificationToast(QFrame):
|
||||
# accent gradient, fades to transparent
|
||||
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
|
||||
accent = QtGui.QColor(self._accent_color)
|
||||
accent.setAlpha(50)
|
||||
if getattr(self, "_theme", "dark") == "light":
|
||||
accent = accent.darker(115)
|
||||
accent.setAlpha(getattr(self, "_accent_alpha", 50))
|
||||
grad.setColorAt(0.0, accent)
|
||||
fade = QtGui.QColor(self._accent_color)
|
||||
fade.setAlpha(0)
|
||||
@@ -690,7 +735,6 @@ class NotificationCentre(QScrollArea):
|
||||
toast.notification_id = notification_id
|
||||
broker = BECNotificationBroker()
|
||||
toast.closed.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.closed.connect(lambda: self._hide_notification(toast))
|
||||
toast.expired.connect(lambda t=toast: self._handle_expire(t))
|
||||
toast.expanded.connect(self._adjust_height)
|
||||
@@ -1016,32 +1060,55 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
"""
|
||||
Called when a new alarm arrives. Builds and pushes a toast to each centre
|
||||
with a shared notification_id, and hooks its close/expire signals.
|
||||
|
||||
Args:
|
||||
msg(dict): The message containing alarm details.
|
||||
meta(dict): Metadata about the alarm.
|
||||
"""
|
||||
msg = msg or {}
|
||||
meta = meta or {}
|
||||
|
||||
centres = WidgetIO.find_widgets(NotificationCentre)
|
||||
kind = self._banner_kind_from_severity(msg.get("severity", 0))
|
||||
|
||||
# Normalise the incoming info payload (can be ErrorInfo, dict or missing entirely)
|
||||
raw_info = msg.get("info")
|
||||
if isinstance(raw_info, dict):
|
||||
try:
|
||||
raw_info = ErrorInfo(**raw_info)
|
||||
except Exception:
|
||||
raw_info = None
|
||||
|
||||
notification_id = getattr(raw_info, "id", None) or uuid4().hex
|
||||
|
||||
# build title and body
|
||||
scan_id = meta.get("scan_id")
|
||||
scan_number = meta.get("scan_number")
|
||||
formatted_trace = self._err_util.format_traceback(msg.get("msg", ""))
|
||||
short_msg = self._err_util.parse_error_message(formatted_trace)
|
||||
title = msg.get("alarm_type", "Alarm")
|
||||
alarm_type = msg.get("alarm_type") or getattr(raw_info, "exception_type", None) or "Alarm"
|
||||
title = alarm_type
|
||||
if scan_number:
|
||||
title += f" - Scan #{scan_number}"
|
||||
body_text = short_msg
|
||||
# build detailed traceback
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", formatted_trace])
|
||||
source = msg.get("source")
|
||||
if source:
|
||||
source_pretty = json.dumps(source, indent=4, default=str)
|
||||
sections.extend(["", "-------- SOURCE --------", source_pretty])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
trace_text = getattr(raw_info, "error_message", None) or msg.get("msg") or ""
|
||||
compact_msg = getattr(raw_info, "compact_error_message", None)
|
||||
|
||||
# Prefer the compact message; fall back to parsing the traceback for a human‑readable snippet
|
||||
body_text = compact_msg or self._err_util.parse_error_message(trace_text)
|
||||
|
||||
# build detailed traceback for the expandable panel
|
||||
detailed_trace: str | None = None
|
||||
if trace_text:
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", trace_text])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
lifetime = 0 if kind == SeverityKind.MAJOR else 5_000
|
||||
|
||||
# generate one ID for all toasts of this event
|
||||
notification_id = uuid4().hex
|
||||
if notification_id in self._active_notifications:
|
||||
return # already posted
|
||||
# record this notification for future centres
|
||||
self._active_notifications[notification_id] = {
|
||||
"title": title,
|
||||
@@ -1059,9 +1126,8 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
lifetime_ms=lifetime,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
# broadcast any close or expire
|
||||
# broadcast close events (expiry is handled locally to keep history)
|
||||
toast.closed.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_status(self, msg: dict, meta: dict) -> None:
|
||||
@@ -1086,6 +1152,13 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
Translate an integer severity (0/1/2) into a SeverityKind enum.
|
||||
Unknown values fall back to SeverityKind.WARNING.
|
||||
"""
|
||||
if isinstance(severity, SeverityKind):
|
||||
return severity
|
||||
if isinstance(severity, str):
|
||||
try:
|
||||
return SeverityKind(severity)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return SeverityKind[Alarms(severity).name] # e.g. WARNING → SeverityKind.WARNING
|
||||
except (ValueError, KeyError):
|
||||
@@ -1164,10 +1237,10 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
# ----- wiring ------------------------------------------------------------
|
||||
self._counter = 1
|
||||
self.info_btn.clicked.connect(lambda: self._post("info"))
|
||||
self.warning_btn.clicked.connect(lambda: self._post("warning"))
|
||||
self.minor_btn.clicked.connect(lambda: self._post("minor"))
|
||||
self.major_btn.clicked.connect(lambda: self._post("major"))
|
||||
self.info_btn.clicked.connect(lambda: self._post(SeverityKind.INFO))
|
||||
self.warning_btn.clicked.connect(lambda: self._post(SeverityKind.WARNING))
|
||||
self.minor_btn.clicked.connect(lambda: self._post(SeverityKind.MINOR))
|
||||
self.major_btn.clicked.connect(lambda: self._post(SeverityKind.MAJOR))
|
||||
# Raise buttons simulate alarms
|
||||
self.raise_warning_btn.clicked.connect(lambda: self._raise_error(Alarms.WARNING))
|
||||
self.raise_minor_btn.clicked.connect(lambda: self._raise_error(Alarms.MINOR))
|
||||
@@ -1183,30 +1256,28 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
indicator.hide_all_requested.connect(self.notification_centre.hide_all)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _post(self, kind):
|
||||
expire = 0 if kind == "error" else 5000
|
||||
trace = (
|
||||
'Traceback (most recent call last):\n File "<stdin>", line 1\nZeroDivisionError: 1/0'
|
||||
if kind == "error"
|
||||
else None
|
||||
)
|
||||
self.notification_centre.add_notification(
|
||||
title=f"{kind.capitalize()} #{self._counter}",
|
||||
body="Lorem ipsum dolor sit amet.",
|
||||
kind=SeverityKind(kind),
|
||||
lifetime_ms=expire,
|
||||
traceback=trace,
|
||||
)
|
||||
def _post(self, kind: SeverityKind):
|
||||
"""
|
||||
Send a simple notification through the broker (non-error case).
|
||||
"""
|
||||
msg = {
|
||||
"severity": kind.value, # handled by broker for SeverityKind
|
||||
"alarm_type": f"{kind.value.capitalize()}",
|
||||
"msg": f"{kind.value.capitalize()} #{self._counter}",
|
||||
}
|
||||
self.notification_broker.post_notification(msg, meta={})
|
||||
self._counter += 1
|
||||
|
||||
def _raise_error(self, severity):
|
||||
"""Simulate an error that would be caught by the notification broker."""
|
||||
self.notification_broker.client.connector.raise_alarm(
|
||||
severity=severity,
|
||||
alarm_type="ValueError",
|
||||
source={"device": "samx", "source": "async_file_writer"},
|
||||
msg=f"test alarm",
|
||||
metadata={"test": 1},
|
||||
info=ErrorInfo(
|
||||
id=uuid4().hex,
|
||||
exception_type="ValueError",
|
||||
error_message="An example error occurred in DemoWindowApp.",
|
||||
compact_error_message="An example error occurred.",
|
||||
),
|
||||
)
|
||||
|
||||
# this part is same as implemented in the BECMainWindow
|
||||
@@ -1225,6 +1296,7 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def main(): # pragma: no cover
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
win = DemoWindow()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -1520,7 +1520,7 @@ class Waveform(PlotBase):
|
||||
|
||||
self.request_dap_update.emit()
|
||||
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> bool:
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if the async signal is found in the BEC device manager.
|
||||
|
||||
@@ -1529,13 +1529,16 @@ class Waveform(PlotBase):
|
||||
signal(str): The entry of the async signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the async signal is found, False otherwise.
|
||||
tuple[bool, str]: A tuple where the first element is True if the async signal is found (False otherwise),
|
||||
and the second element is the signal name (either the original signal or the storage_name for AsyncMultiSignal).
|
||||
"""
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals(
|
||||
["AsyncSignal", "AsyncMultiSignal"]
|
||||
)
|
||||
for entry_name, _, entry_data in bec_async_signals:
|
||||
if entry_name == name and entry_data.get("obj_name") == signal:
|
||||
return True
|
||||
return False
|
||||
return True, entry_data.get("storage_name")
|
||||
return False, signal
|
||||
|
||||
def _setup_async_curve(self, curve: Curve):
|
||||
"""
|
||||
@@ -1546,7 +1549,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
async_signal_found, signal = self._check_async_signal_found(name, signal)
|
||||
|
||||
try:
|
||||
curve.clear_data()
|
||||
|
||||
@@ -55,7 +55,9 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
|
||||
self._config_helper = ConfigHelper(
|
||||
self.client.connector, self.client._service_name, self.client.device_manager
|
||||
)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.init_ui()
|
||||
|
||||
@@ -65,7 +65,7 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._initial_config = {}
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
self.client.connector, self.client._service_name, self.client.device_manager
|
||||
)
|
||||
self._device = device
|
||||
self._action: Literal["update", "add"] = action
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.45.7"
|
||||
version = "2.45.11"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_update_cycle(update_dialog, qtbot):
|
||||
"deviceClass": "TestDevice",
|
||||
"deviceConfig": {"param1": "val1"},
|
||||
"readoutPriority": "monitored",
|
||||
"description": None,
|
||||
"description": "",
|
||||
"readOnly": False,
|
||||
"softwareTrigger": False,
|
||||
"onFailure": "retry",
|
||||
|
||||
Reference in New Issue
Block a user