feat(devices_views): lock Device Config behind BEC Atlas sign-in, redesign Devices view, show all popups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TCZEjCSXwMdY9sTEqPQWx
This commit is contained in:
Claude committed 2026-10-09 22:33:09 +00:00
1 parent 64594b6572
commit bd226be7e4
10 files changed
+1322 -387

No files matched your search

+4 -3
View File
@@ -162,14 +162,14 @@ class BECMainApp(BECMainWindow):
return
if flavor == "qml":
from bec_widgets.applications.views.devices_views.devices_qml import (
DeviceConfigViewQML as ConfigView,
DeviceConfigGateQML as ConfigView,
)
from bec_widgets.applications.views.devices_views.devices_qml import (
DevicesViewQML as DevicesView,
)
else:
from bec_widgets.applications.views.devices_views.devices_qwidget import (
DeviceConfigViewQWidget as ConfigView,
DeviceConfigGateQWidget as ConfigView,
)
from bec_widgets.applications.views.devices_views.devices_qwidget import (
DevicesViewQWidget as DevicesView,
@@ -178,7 +178,8 @@ class BECMainApp(BECMainWindow):
self.devices_view.browser.open_in_workspace.connect(self._open_device_in_workspace)
self.device_config_view = ConfigView(parent=self)
self.add_view(icon="memory", title="Devices", widget=self.devices_view, mini_text="Devices")
# Adding and configuring devices is beamline staff work: it sits with Admin View.
# Adding and configuring devices is beamline staff work: it sits with Admin View and
# opens only after a BEC Atlas sign-in by an owner of this deployment.
self.add_view(
icon="tune",
title="Device Config",
@@ -271,6 +271,143 @@ def screenshots(app, client, out: Path, theme: str) -> None:
_settle(app, 200)
class _DemoHelper:
"""Config helper for the apply pictures: one device fails, one is still running."""
def __init__(self):
import threading # pylint: disable=import-outside-toplevel
self.release = threading.Event()
def send_config_request(self, action=None, config=None, **_kwargs):
"""Pretend to send one request; ``samx`` waits for :attr:`release`."""
name = next(iter(config or {}), "")
if name == "newmot":
raise RuntimeError("Cannot connect to X05LA-ES-PH:Z.VAL (no answer after 5 s)")
if name == "samx":
self.release.wait(10)
def popups(app, client, out: Path, theme: str) -> None:
"""Every Config popup in both new views, the staff sign-in, and today's dialogs."""
# pylint: disable=import-outside-toplevel, protected-access
from bec_qthemes import apply_theme
apply_theme(theme)
out.mkdir(parents=True, exist_ok=True)
for view in ("qml", "qwidget"):
widget = build(view, "config", client)
widget.resize(1500, 900)
widget.show()
_settle(app, 700)
shot = lambda tag, w=widget: w.grab().save(str(out / f"{theme}_{view}_popup_{tag}.png"))
sheet = widget.open_add_device()
_settle(app, 400)
grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_add.png"))
close_sheet(widget, sheet)
sheet = widget.open_clear_session()
_settle(app, 400)
grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_clear.png"))
close_sheet(widget, sheet)
_settle(app, 200)
helper = _DemoHelper()
widget.editor._helper = helper
sheet = widget.open_review()
_settle(app, 300)
if view == "qml":
widget.view.rootObject().startApply()
else:
widget._start_apply(sheet)
_settle(app, 700)
grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_applying.png"))
helper.release.set()
_settle(app, 700)
grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_popup_applied.png"))
close_sheet(widget, sheet)
_settle(app, 300)
widget.editor.remove("bpm3a")
_settle(app, 400)
shot("toast")
widget.close()
widget.deleteLater()
_settle(app, 200)
gate_cls = _gate_class(view)
gate = gate_cls(client=client)
gate.resize(1500, 900)
gate.show()
gate.access.deployment = "x05la"
gate.access.error = "User name or password is not correct."
gate.access.changed.emit()
_settle(app, 500)
gate.grab().save(str(out / f"{theme}_{view}_popup_signin.png"))
gate.close()
gate.deleteLater()
_settle(app, 200)
_legacy_popups(app, client, out, theme)
def _gate_class(view: str):
# pylint: disable=import-outside-toplevel
if view == "qml":
from bec_widgets.applications.views.devices_views.devices_qml import DeviceConfigGateQML
return DeviceConfigGateQML
from bec_widgets.applications.views.devices_views.devices_qwidget import DeviceConfigGateQWidget
return DeviceConfigGateQWidget
def _legacy_popups(app, client, out: Path, theme: str) -> None:
"""Today's Device Manager dialogs, opened the way its toolbar opens them."""
# pylint: disable=import-outside-toplevel, protected-access
from qtpy.QtWidgets import QMessageBox
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import (
ConfigChoiceDialog,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import (
DeviceFormDialog,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
UploadRedisDialog,
)
legacy = build("legacy", "config", client)
legacy.resize(1500, 900)
legacy.show()
_settle(app, 900)
display = legacy.device_manager_display
dialogs = {}
add = DeviceFormDialog(parent=display, add_btn_text="Add Device")
dialogs["add"] = add
edit = DeviceFormDialog(parent=display, add_btn_text="Apply Changes")
samx = next(c for c in display.device_table_view.get_device_config() if c["name"] == "samx")
edit.set_device_config(samx)
dialogs["edit"] = edit
dialogs["upload"] = UploadRedisDialog(
parent=display, device_configs=display.device_table_view.get_validation_results()
)
dialogs["load_choice"] = ConfigChoiceDialog(display)
flush = QMessageBox(
QMessageBox.Icon.Question,
"Flush BEC Server Config",
"Do you really want to flush the current config in BEC Server?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
display,
)
dialogs["flush"] = flush
for tag, dialog in dialogs.items():
dialog.show()
_settle(app, 500)
dialog.grab().save(str(out / f"{theme}_legacy_popup_{tag}.png"))
dialog.close()
dialog.deleteLater()
_settle(app, 150)
legacy.close()
legacy.deleteLater()
_settle(app, 200)
def grab_sheet(widget, sheet):
"""Picture of a sheet: QWidget dialogs grab themselves, QML popups are inside the view."""
if hasattr(sheet, "grab"):
@@ -350,6 +487,7 @@ def main() -> None:
parser.add_argument("--theme", choices=["light", "dark"], default="dark")
parser.add_argument("--screenshots", type=Path)
parser.add_argument("--bench", action="store_true")
parser.add_argument("--popups", type=Path, help="write every popup picture to this folder")
args = parser.parse_args()
os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy")
@@ -367,9 +505,12 @@ def main() -> None:
if args.screenshots:
for theme in ("light", "dark"):
screenshots(app, client, args.screenshots, theme)
if args.popups:
for theme in ("light", "dark"):
popups(app, client, args.popups, theme)
if args.bench:
print(json.dumps(bench(app, client), indent=2))
if args.screenshots or args.bench:
if args.screenshots or args.bench or args.popups:
os._exit(0) # pylint: disable=protected-access
widget = build(args.view, args.page, client)
widget.resize(1500, 900)
@@ -1149,17 +1149,21 @@ class DeviceBrowser(QObject):
facts = [("Type", info["deviceClass"]), ("Tags", ", ".join(info["tags"]) or "—")]
if not info["enabled"]:
facts.append(("Enabled", "No — disabled in the session"))
value = self.values.get(name)
return {
"name": name,
"kind": kind,
"kindLabel": {"positioner": "Motor", "detector": "Detector"}.get(kind, "Monitor"),
"enabled": info["enabled"],
"subtitle": " · ".join(x for x in (info["deviceClass"], ", ".join(info["tags"])) if x),
"numeric": isinstance(value, (int, float)) and not isinstance(value, bool),
"description": info["description"] or info["deviceClass"],
"valueText": format_value(self.values.get(name), info["precision"]),
"units": info["units"],
"facts": [{"label": k, "value": v} for k, v in facts],
"actionText": {
"positioner": f"Add {name} to the workspace",
"detector": "Open the live image in the workspace",
}.get(kind, f"Plot {name} in the workspace"),
"actionText": {"positioner": "Add to workspace", "detector": "Open live image"}.get(
kind, "Plot in workspace"
),
}
def signal_rows(self) -> list[dict]:
@@ -9,7 +9,7 @@ from __future__ import annotations
from pathlib import Path
from qtpy.QtCore import Property, QObject, QUrl, Signal, Slot
from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget
from qtpy.QtWidgets import QFileDialog, QStackedWidget, QVBoxLayout, QWidget
from bec_widgets.applications.views.devices_views.devices_core import (
DEVICE_CLASSES,
@@ -22,6 +22,7 @@ from bec_widgets.applications.views.devices_views.devices_core import (
DeviceBrowser,
class_docstring,
)
from bec_widgets.applications.views.devices_views.staff_access import StaffAccess
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget
@@ -149,7 +150,11 @@ class DevicesBackend(QObject):
detailName = Property(str, lambda self: self._detail.get("name", ""), notify=changed)
kind = Property(str, lambda self: self.browser.kind, notify=changed)
kinds = Property(
"QVariantList", lambda self: [{"key": k, "label": l} for k, l in KINDS], constant=True
"QVariantList",
lambda self: [
{"key": k, "label": f"{l} {self.browser.kind_counts().get(k, 0)}"} for k, l in KINDS
],
notify=changed,
)
shownText = Property(str, lambda self: self.browser.shown_text(), notify=changed)
selectedIndex = Property(
@@ -236,6 +241,7 @@ class ConfigBackend(QObject):
self._tab = "form"
editor.changed.connect(self.refresh)
editor.toast.connect(self.toast)
self._staff = ""
def refresh(self) -> None:
"""Re-read everything the scene shows."""
@@ -359,6 +365,19 @@ class ConfigBackend(QObject):
rows = Property(QObject, lambda self: self._rows, constant=True)
bar = Property("QVariantMap", lambda self: self.editor.bar_state(), notify=changed)
staff_changed = Signal()
sign_out_requested = Signal()
staffUser = Property(str, lambda self: self._staff, notify=staff_changed)
def set_staff(self, user: str) -> None:
"""Who is signed in through BEC Atlas ("" when locked)."""
self._staff = user
self.staff_changed.emit()
@Slot()
def signOut(self) -> None: # pylint: disable=invalid-name
self.sign_out_requested.emit()
detail = Property("QVariantMap", lambda self: self._detail, notify=detail_changed)
facets = Property("QVariantList", lambda self: self._facets, notify=facets_changed)
problems = Property("QVariantList", lambda self: self.editor.problems(), notify=changed)
@@ -403,6 +422,7 @@ class DeviceConfigViewQML(BECWidget, QWidget):
RPC = False
PLUGIN = False
sign_out_requested = Signal()
def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
@@ -418,6 +438,11 @@ class DeviceConfigViewQML(BECWidget, QWidget):
self, QML_DIR / "DeviceConfigView.qml", {"backend": self.backend}
)
lay.addWidget(self.view)
self.backend.sign_out_requested.connect(self.sign_out_requested)
def set_staff(self, user: str) -> None:
"""Show who is signed in (BEC Atlas) next to Review changes."""
self.backend.set_staff(user)
def open_review(self):
"""Open the review sheet (also reachable from the session bar)."""
@@ -447,3 +472,74 @@ class DeviceConfigViewQML(BECWidget, QWidget):
release_quick_widget(self.view)
self.editor.cleanup()
super().cleanup()
# ------------------------------------------------------------------------------------ staff gate
class StaffBackend(QObject):
"""Bridge between :class:`StaffAccess` and ``LockScreen.qml``."""
changed = Signal()
def __init__(self, access: StaffAccess, parent: QObject):
super().__init__(parent)
self.access = access
access.changed.connect(self.changed)
# pylint: disable=invalid-name, missing-function-docstring
@Slot(str, str)
def signIn(self, user: str, password: str) -> None:
self.access.sign_in(user, password)
busy = Property(bool, lambda self: self.access.busy, notify=changed)
error = Property(str, lambda self: self.access.error, notify=changed)
deployment = Property(str, lambda self: self.access.deployment, notify=changed)
class DeviceConfigGateQML(BECWidget, QWidget):
"""Device Config behind the BEC Atlas staff sign-in (QML version).
The Config view is built on the first sign-in and locks again when the view is left.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
self.get_bec_shortcuts()
self.access = StaffAccess(self.bec_dispatcher, self)
self.staff_backend = StaffBackend(self.access, self)
self.config: DeviceConfigViewQML | None = None
self.stack = QStackedWidget(self)
self.lock_view = create_quick_widget(
self, QML_DIR / "LockScreen.qml", {"backend": self.staff_backend}
)
self.stack.addWidget(self.lock_view)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.stack)
self.access.changed.connect(self._sync)
def _sync(self) -> None:
if self.access.unlocked and self.config is None:
self.config = DeviceConfigViewQML(parent=self, client=self.client)
self.config.sign_out_requested.connect(self.access.sign_out)
self.stack.addWidget(self.config)
if self.config is not None:
self.config.set_staff(self.access.user)
self.stack.setCurrentWidget(
self.config if self.access.unlocked and self.config else self.lock_view
)
def hideEvent(self, event): # pylint: disable=invalid-name
super().hideEvent(event)
if not event.spontaneous():
self.access.sign_out()
def cleanup(self):
release_quick_widget(self.lock_view)
self.access.cleanup()
if self.config is not None:
self.config.close()
self.config.deleteLater()
super().cleanup()
@@ -11,6 +11,7 @@ from qtpy.QtCore import QAbstractTableModel, QModelIndex, QRectF, QSize, Qt, QTi
from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import (
QAbstractItemView,
QBoxLayout,
QButtonGroup,
QCheckBox,
QComboBox,
@@ -47,6 +48,7 @@ from bec_widgets.applications.views.devices_views.devices_core import (
DeviceBrowser,
class_docstring,
)
from bec_widgets.applications.views.devices_views.staff_access import StaffAccess
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.quick.host import ThemeTokens
@@ -146,6 +148,14 @@ def clear_layout(layout) -> None:
clear_layout(item.layout())
def vline() -> QFrame:
"""One-pixel vertical rule."""
line = QFrame()
line.setProperty("role", "rule")
line.setFixedWidth(1)
return line
def hline() -> QFrame:
"""Thin separator line."""
line = QFrame()
@@ -221,7 +231,7 @@ def view_qss(t: ThemeTokens) -> str:
{t.border.name()}; border-radius: 7px; }}
QFrame[role="toast"] {{ background: {t.fg.name()}; border-radius: 8px; }}
QFrame[role="toast"] QLabel {{ color: {t.bg.name()}; font-size: 12px; }}
QFrame[role="toast"] QPushButton {{ color: {t.primary.lighter(140).name() if t.dark
QFrame[role="toast"] QPushButton {{ color: {t.primary.darker(130).name() if t.dark
else t.primary.lighter(160).name()}; background: transparent; border: none;
font-weight: 700; font-size: 12px; }}
QMenu {{ background: {t.card.name()}; color: {t.fg.name()}; border: 1px solid
@@ -324,6 +334,7 @@ class Toasts(QWidget):
row.setContentsMargins(12, 8, 8, 8)
text = label(message)
text.setWordWrap(True)
text.setMinimumWidth(280)
text.setMaximumWidth(380)
row.addWidget(text, 1)
if undo:
@@ -650,11 +661,16 @@ class Sparkline(QWidget):
def paintEvent(self, _event): # pylint: disable=invalid-name
if len(self.points) < 2:
return
tokens = ThemeTokens()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
lo, hi = min(self.points), max(self.points)
span = (hi - lo) or 1.0
w, h = self.width(), self.height()
painter.setPen(QPen(tokens.soft(tokens.border, 0.7), 1, Qt.PenStyle.DashLine))
for frac in (0.0, 0.5, 1.0):
y = 5 + frac * (h - 10)
painter.drawLine(0, int(y), w, int(y))
path = QPainterPath()
for i, v in enumerate(self.points):
x = i / 89 * w
@@ -663,16 +679,112 @@ class Sparkline(QWidget):
path.lineTo(x, y)
else:
path.moveTo(x, y)
painter.setPen(QPen(ThemeTokens().primary, 1.6))
area = QPainterPath(path)
area.lineTo((len(self.points) - 1) / 89 * w, h)
area.lineTo(0, h)
area.closeSubpath()
fill = QColor(tokens.primary)
fill.setAlphaF(0.12)
painter.fillPath(area, fill)
painter.setPen(QPen(tokens.primary, 1.8))
painter.drawPath(path)
end = path.currentPosition()
painter.setBrush(tokens.primary)
painter.drawEllipse(end, 3, 3)
font = QFont()
font.setPixelSize(10)
painter.setFont(font)
painter.setPen(tokens.fg_subtle)
painter.drawText(QRectF(0, 0, w - 4, 14), Qt.AlignmentFlag.AlignRight, f"{hi:.6g}")
painter.drawText(QRectF(0, h - 14, w - 4, 14), Qt.AlignmentFlag.AlignRight, f"{lo:.6g}")
# ----------------------------------------------------------------------------------- Devices view
class DeviceRowDelegate(QStyledItemDelegate):
"""Two-line device row: bold name over what it is, live value on the right."""
def paint(self, painter, option, index):
row = index.data(Qt.ItemDataRole.UserRole)
if row is None:
return
tokens = ThemeTokens()
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect
if option.state & QStyle.StateFlag.State_Selected:
painter.fillRect(rect, tokens.soft(tokens.primary, 0.18))
painter.fillRect(rect.x(), rect.y(), 3, rect.height(), tokens.primary)
elif option.state & QStyle.StateFlag.State_MouseOver:
painter.fillRect(rect, tokens.hover)
painter.setPen(tokens.soft(tokens.border, 0.6))
painter.drawLine(rect.bottomLeft(), rect.bottomRight())
inner = QRectF(rect.adjusted(14, 6, -14, -6))
value_font = QFont(MONO.split(",", maxsplit=1)[0])
value_font.setPixelSize(12)
painter.setFont(value_font)
value_w = min(QFontMetrics(value_font).horizontalAdvance(row["valueText"]) + 4, 150)
painter.setPen(tokens.fg)
painter.drawText(
QRectF(inner.right() - value_w, inner.top(), value_w, inner.height()),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
row["valueText"],
)
text_w = inner.width() - value_w - 12
name_font = QFont(MONO.split(",", maxsplit=1)[0])
name_font.setPixelSize(13)
name_font.setWeight(QFont.Weight.DemiBold)
painter.setFont(name_font)
half = inner.height() / 2
painter.drawText(
QRectF(inner.left(), inner.top(), text_w, half),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
QFontMetrics(name_font).elidedText(row["name"], Qt.TextElideMode.ElideRight, text_w),
)
what_font = QFont()
what_font.setPixelSize(11)
painter.setFont(what_font)
painter.setPen(tokens.fg_muted)
painter.drawText(
QRectF(inner.left(), inner.top() + half, text_w, half),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
QFontMetrics(what_font).elidedText(row["what"], Qt.TextElideMode.ElideRight, text_w),
)
painter.restore()
def sizeHint(self, option, index): # pylint: disable=invalid-name
return QSize(300, 48)
class Panel(QFrame):
"""Bordered card with an upper-case title and an optional hint on the right."""
def __init__(self, title: str, hint: str = "", parent=None):
super().__init__(parent)
self.setProperty("role", "panel")
lay = QVBoxLayout(self)
lay.setContentsMargins(16, 12, 16, 14)
lay.setSpacing(8)
head = QHBoxLayout()
head.setSpacing(8)
self.title = label(title.upper(), "upper")
head.addWidget(self.title)
head.addStretch(1)
self.hint = label(hint, "faint")
head.addWidget(self.hint)
lay.addLayout(head)
self.body = lay
class DevicesViewQWidget(BECWidget, QWidget):
"""Devices for everyone: find a device, see its live value and move it (QWidget version)."""
"""Devices for users: find any device, watch it live, move it and change its settings.
QWidget version. The list on the left is compact; the device fills the rest of the view with
a live panel (motor card, value and trend) next to its settings and readings.
"""
RPC = False
PLUGIN = False
NARROW = 860
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
@@ -682,6 +794,8 @@ class DevicesViewQWidget(BECWidget, QWidget):
self.browser = DeviceBrowser(self.client, self.bec_dispatcher, self)
self.open_in_workspace = self.browser.open_in_workspace
self._motor = None
self._signal_widgets: dict[str, dict] = {}
self._signal_device: str | None = None
self._build()
self.browser.changed.connect(self._sync)
self.browser.values_changed.connect(self._sync_values)
@@ -689,136 +803,240 @@ class DevicesViewQWidget(BECWidget, QWidget):
self.apply_theme("")
self._sync()
# ------------------------------------------------------------------ layout
def _build(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_bar())
body = QHBoxLayout()
body.setSpacing(0)
body.addWidget(self._build_list())
body.addWidget(vline())
body.addWidget(self._build_detail(), 1)
root.addLayout(body, 1)
def _build_bar(self) -> QWidget:
bar = QWidget()
bar.setProperty("pane", "bar")
bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
row = QHBoxLayout(bar)
row.setContentsMargins(12, 8, 12, 8)
row.setContentsMargins(14, 8, 14, 8)
row.setSpacing(10)
self.title_icon = QLabel()
row.addWidget(self.title_icon)
title = label("Devices")
title.setStyleSheet("font-weight: 700;")
row.addWidget(title)
self.search = QLineEdit()
self.search.setPlaceholderText("Search devices by name, tag or description")
self.search.setAccessibleName("Search devices")
self.search.setFixedWidth(300)
self._debounce = QTimer(self, singleShot=True, interval=120)
self._debounce.timeout.connect(lambda: self.browser.set_query(self.search.text()))
self.search.textChanged.connect(lambda _: self._debounce.start())
row.addWidget(self.search)
self.kinds = Segmented(KINDS, self.browser.kind)
row.addSpacing(8)
counts = self.browser.kind_counts()
self.kinds = Segmented(
[(k, f"{t} {counts.get(k, 0)}") for k, t in KINDS], self.browser.kind
)
self.kinds.changed.connect(self.browser.set_kind)
row.addWidget(self.kinds)
row.addStretch(1)
lock = Chip("Device setup: staff only", "neutral", "lock")
lock.setToolTip("Adding devices and changing the session is in Device Config")
row.addWidget(lock)
root.addWidget(bar)
return bar
body = QHBoxLayout()
body.setSpacing(0)
def _build_list(self) -> QWidget:
left = QWidget()
left.setProperty("pane", "card")
left.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
left_lay = QVBoxLayout(left)
left_lay.setContentsMargins(0, 0, 0, 0)
left_lay.setSpacing(0)
self.shown = label("", "muted")
self.shown.setContentsMargins(10, 6, 10, 6)
left_lay.addWidget(self.shown)
self.model = RowsModel([("name", "Name"), ("what", "What it is"), ("valueText", "Value")])
self.table = make_table(self.model, {"name": 150, "valueText": 140})
left.setFixedWidth(360)
lay = QVBoxLayout(left)
lay.setContentsMargins(0, 10, 0, 0)
lay.setSpacing(6)
self.search = QLineEdit()
self.search.setPlaceholderText("Search name, tag or description")
self.search.setAccessibleName("Search devices")
self.search.setClearButtonEnabled(True)
self._debounce = QTimer(self, singleShot=True, interval=120)
self._debounce.timeout.connect(lambda: self.browser.set_query(self.search.text()))
self.search.textChanged.connect(lambda _: self._debounce.start())
search_row = QHBoxLayout()
search_row.setContentsMargins(12, 0, 12, 0)
search_row.addWidget(self.search)
lay.addLayout(search_row)
self.shown = label("", "faint")
self.shown.setContentsMargins(14, 2, 14, 2)
lay.addWidget(self.shown)
self.model = RowsModel([("name", "Device")])
self.table = make_table(self.model, {})
self.table.setItemDelegate(DeviceRowDelegate(self.table))
self.table.horizontalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(48)
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.table.selectionModel().currentRowChanged.connect(self._on_row)
left_lay.addWidget(self.table, 1)
body.addWidget(left, 1)
body.addWidget(self._vline())
lay.addWidget(self.table, 1)
return left
def _build_detail(self) -> QWidget:
self.detail = QWidget()
self.detail.setProperty("pane", "bg")
self.detail.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
detail_scroll = QScrollArea()
detail_scroll.setWidgetResizable(True)
detail_scroll.setWidget(self.detail)
outer = QHBoxLayout(self.detail)
outer.setContentsMargins(16, 12, 16, 12)
column = QWidget()
column.setMaximumWidth(440)
column.setMinimumWidth(360)
outer.addStretch(1)
outer.addWidget(column, 3)
outer.addStretch(1)
d = QVBoxLayout(column)
d.setContentsMargins(0, 0, 0, 0)
d.setSpacing(8)
self._column = column
self.head = QWidget()
head = QVBoxLayout(self.head)
head.setContentsMargins(0, 0, 0, 0)
head.setSpacing(4)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setWidget(self.detail)
d = QVBoxLayout(self.detail)
d.setContentsMargins(24, 18, 24, 18)
d.setSpacing(16)
head = QHBoxLayout()
head.setSpacing(12)
names = QVBoxLayout()
names.setSpacing(2)
title_row = QHBoxLayout()
title_row.setSpacing(10)
self.d_name = label("", "title")
self.d_name.setStyleSheet("font-size: 15px;")
self.d_name.setStyleSheet("font-size: 20px;")
title_row.addWidget(self.d_name)
self.d_kind = Chip("", "neutral")
title_row.addWidget(self.d_kind)
self.d_disabled = Chip("Disabled in this session", "warn", "block")
title_row.addWidget(self.d_disabled)
title_row.addStretch(1)
names.addLayout(title_row)
self.d_desc = label("", "muted")
self.d_desc.setWordWrap(True)
self.d_value = label("", "big")
self.spark = Sparkline()
self.spark_note = label("last ~20 s, live", "faint")
for w in (self.d_name, self.d_desc, self.d_value, self.spark, self.spark_note):
head.addWidget(w)
d.addWidget(self.head)
self.motor_holder = QVBoxLayout()
d.addLayout(self.motor_holder)
self.action = TextButton("", "neutral", "add")
names.addWidget(self.d_desc)
head.addLayout(names, 1)
self.action = TextButton("", "primary", "add")
self.action.clicked.connect(self.browser.request_open)
d.addWidget(self.action, 0, Qt.AlignmentFlag.AlignLeft)
self._signal_widgets: dict[str, dict] = {}
self._signal_device: str | None = None
self.settings_title = label("Settings", "upper")
self.settings_grid = self._signal_grid()
self.readings_title = label("Readings", "upper")
self.readings_grid = self._signal_grid()
d.addSpacing(6)
d.addWidget(self.settings_title)
d.addLayout(self.settings_grid)
d.addSpacing(6)
d.addWidget(self.readings_title)
d.addLayout(self.readings_grid)
d.addSpacing(6)
head.addWidget(self.action, 0, Qt.AlignmentFlag.AlignTop)
d.addLayout(head)
self.columns = QBoxLayout(QBoxLayout.Direction.LeftToRight)
self.columns.setSpacing(16)
left_col = QVBoxLayout()
left_col.setSpacing(16)
right_col = QVBoxLayout()
right_col.setSpacing(16)
self.columns.addLayout(left_col, 1)
self.columns.addLayout(right_col, 1)
d.addLayout(self.columns)
self.motor_holder = QVBoxLayout()
left_col.addLayout(self.motor_holder)
self.live = Panel("Live value", "last ~20 s")
self.d_value = label("", "big")
self.live.body.addWidget(self.d_value)
self.spark = Sparkline()
self.spark.setMinimumHeight(120)
self.spark.setMaximumWidth(16777215)
self.live.body.addWidget(self.spark)
left_col.addWidget(self.live)
self.about = Panel("About")
self.facts = QGridLayout()
self.facts.setHorizontalSpacing(12)
self.facts.setVerticalSpacing(3)
self.facts.setHorizontalSpacing(16)
self.facts.setVerticalSpacing(4)
self.facts.setColumnStretch(1, 1)
d.addLayout(self.facts)
self.about.body.addLayout(self.facts)
left_col.addWidget(self.about)
left_col.addStretch(1)
self.settings = Panel("Settings", "Applied to the device at once")
self.settings_grid = self._signal_grid()
self.settings.body.addLayout(self.settings_grid)
right_col.addWidget(self.settings)
self.readings = Panel("Readings", "live")
self.readings_grid = self._signal_grid()
self.readings.body.addLayout(self.readings_grid)
right_col.addWidget(self.readings)
right_col.addStretch(1)
self.empty = label("Select a device to see it here.", "muted")
d.addWidget(self.empty)
d.addStretch(1)
body.addWidget(detail_scroll, 1)
root.addLayout(body, 1)
return scroll
@staticmethod
def _vline() -> QFrame:
line = QFrame()
line.setProperty("role", "rule")
line.setFixedWidth(1)
return line
def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None:
if current.isValid():
self.browser.select(self.model.rows[current.row()]["name"])
def resizeEvent(self, event): # pylint: disable=invalid-name
super().resizeEvent(event)
# Width left for the two columns: view minus list, rule and the detail margins.
narrow = self.width() - 361 - 48 < self.NARROW
self.columns.setDirection(
QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight
)
@staticmethod
def _signal_grid() -> QGridLayout:
grid = QGridLayout()
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4)
grid.setVerticalSpacing(6)
grid.setColumnStretch(1, 1)
return grid
# ------------------------------------------------------------------ syncing
def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None:
if current.isValid():
self.browser.select(self.model.rows[current.row()]["name"])
@SafeSlot()
def _sync(self) -> None:
rows = self.browser.rows()
self.model.set_rows(rows)
select_row(self.table, self.model, self.browser.selected)
self.shown.setText(self.browser.shown_text())
for key, btn in self.kinds.buttons.items():
btn.setChecked(key == self.browser.kind)
self._sync_detail()
@SafeSlot()
def _sync_values(self) -> None:
self.model.set_rows(self.browser.rows())
detail = self.browser.detail()
if detail.get("name") and detail["kind"] != "positioner":
self.d_value.setText(self._value_html(detail))
self.spark.setVisible(detail["numeric"])
self.live.hint.setVisible(detail["numeric"])
self._update_signals()
@staticmethod
def _value_html(detail: dict) -> str:
unit = detail.get("units", "")
return f"{detail['valueText']}<span style='font-size:15px'> {unit}</span>"
@SafeSlot()
def _sync_spark(self) -> None:
self.spark.set_points(self.browser.spark_points())
def _sync_detail(self) -> None:
detail = self.browser.detail()
has = bool(detail.get("name"))
self.empty.setVisible(not has)
for w in (self.d_name, self.d_kind, self.d_desc, self.action, self.about):
w.setVisible(has)
clear_layout(self.facts)
if not has:
self.d_disabled.hide()
self.live.hide()
self.settings.hide()
self.readings.hide()
self._show_motor(None)
return
kind = detail["kind"]
self._show_motor(detail["name"] if kind == "positioner" else None)
self.d_name.setText(detail["name"])
self.d_kind.set_chip(detail["kindLabel"], "neutral")
self.d_disabled.setVisible(not detail["enabled"])
self.d_desc.setText(detail["description"])
self.live.setVisible(kind != "positioner")
self.d_value.setText(self._value_html(detail))
self.spark.setVisible(detail["numeric"])
self.live.hint.setVisible(detail["numeric"])
self.spark.set_points(self.browser.spark_points())
for i, fact in enumerate(detail["facts"]):
self.facts.addWidget(label(fact["label"], "muted"), i, 0)
self.facts.addWidget(label(fact["value"]), i, 1)
self.action.setText(detail["actionText"])
self.action._icon_name = {"detector": "image", "monitor": "show_chart"}.get(kind, "add")
self.action.refresh_theme(ThemeTokens())
self._build_signals()
def _build_signals(self) -> None:
"""Lay out the settings and readings of the selected device (on selection change)."""
clear_layout(self.settings_grid)
@@ -843,7 +1061,7 @@ class DevicesViewQWidget(BECWidget, QWidget):
edit = QLineEdit()
edit.setPlaceholderText("New value")
edit.setAccessibleName(f"New value for {row['key']}")
edit.setFixedWidth(110)
edit.setFixedWidth(120)
button = TextButton("Set", "neutral")
key = row["key"]
submit = lambda _=False, k=key, e=edit: self.browser.set_signal(k, e.text())
@@ -857,8 +1075,8 @@ class DevicesViewQWidget(BECWidget, QWidget):
grid.addWidget(status, r + 1, 0, 1, 4)
widgets.update(edit=edit, status=status)
self._signal_widgets[row["key"]] = widgets
self.settings_title.setVisible(counts["setting"] > 0)
self.readings_title.setVisible(counts["reading"] > 0)
self.settings.setVisible(counts["setting"] > 0)
self.readings.setVisible(counts["reading"] > 0)
def _update_signals(self) -> None:
"""Refresh values and set results in place, keeping typed text and focus."""
@@ -883,69 +1101,6 @@ class DevicesViewQWidget(BECWidget, QWidget):
if row["statusTone"] == "ok" and widgets["edit"].text():
widgets["edit"].clear()
@SafeSlot()
def _sync(self) -> None:
rows = self.browser.rows()
if self.browser.selected is None and rows:
self.browser.selected = rows[0]["name"]
rows = self.browser.rows()
self.model.set_rows(rows)
select_row(self.table, self.model, self.browser.selected)
self.shown.setText(self.browser.shown_text())
for key, btn in self.kinds.buttons.items():
btn.setChecked(key == self.browser.kind)
self._sync_detail()
@SafeSlot()
def _sync_values(self) -> None:
self.model.set_rows(self.browser.rows())
detail = self.browser.detail()
if detail.get("name") and detail["kind"] != "positioner":
self.d_value.setText(self._value_html(detail))
self._update_signals()
@staticmethod
def _value_html(detail: dict) -> str:
unit = detail.get("units", "")
return f"{detail['valueText']}<span style='font-size:14px'> {unit}</span>"
@SafeSlot()
def _sync_spark(self) -> None:
self.spark.set_points(self.browser.spark_points())
def _sync_detail(self) -> None:
detail = self.browser.detail()
has = bool(detail.get("name"))
self.empty.setVisible(not has)
self.head.setVisible(has)
self.action.setVisible(has)
clear_layout(self.facts)
if not has:
self._show_motor(None)
clear_layout(self.settings_grid)
clear_layout(self.readings_grid)
self.settings_title.hide()
self.readings_title.hide()
return
kind = detail["kind"]
self._show_motor(detail["name"] if kind == "positioner" else None)
self.d_name.setText(detail["name"])
self.d_desc.setText(detail["description"])
self.d_name.setVisible(kind != "positioner")
monitor = kind == "monitor"
self.d_value.setVisible(monitor)
self.spark.setVisible(monitor)
self.spark_note.setVisible(monitor)
self.d_value.setText(self._value_html(detail))
self.spark.set_points(self.browser.spark_points())
for i, fact in enumerate(detail["facts"]):
self.facts.addWidget(label(fact["label"], "muted"), i, 0)
self.facts.addWidget(label(fact["value"]), i, 1)
self.action.setText(detail["actionText"])
self.action._icon_name = {"detector": "image", "monitor": "show_chart"}.get(kind, "add")
self.action.set_variant("primary" if kind == "detector" else "neutral")
self._build_signals()
def _show_motor(self, name: str | None) -> None:
if name is None:
if self._motor is not None:
@@ -956,7 +1111,7 @@ class DevicesViewQWidget(BECWidget, QWidget):
PositionerBoxQWidget,
)
self._motor = PositionerBoxQWidget(parent=self._column, device=name, client=self.client)
self._motor = PositionerBoxQWidget(parent=self.detail, device=name, client=self.client)
self._motor.hide_device_selection = True
self._motor.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.motor_holder.addWidget(self._motor)
@@ -967,7 +1122,11 @@ class DevicesViewQWidget(BECWidget, QWidget):
@SafeSlot(str)
def apply_theme(self, theme: str):
tokens = ThemeTokens()
self.setStyleSheet(view_qss(tokens))
self.setStyleSheet(view_qss(tokens) + f"""
QFrame[role="panel"] {{ background: {tokens.card.name()};
border: 1px solid {tokens.border.name()}; border-radius: 8px; }}
QFrame[role="panel"] QLabel {{ border: none; }}
""")
self.title_icon.setPixmap(material_icon("memory", size=(18, 18), color=tokens.fg_muted))
retheme_icons(self, tokens)
self.update()
@@ -986,6 +1145,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
RPC = False
PLUGIN = False
sign_out_requested = Signal()
def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
@@ -1020,7 +1180,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
body = QHBoxLayout()
body.setSpacing(0)
body.addWidget(self._build_filters())
body.addWidget(DevicesViewQWidget._vline())
body.addWidget(vline())
center = QVBoxLayout()
center.setSpacing(0)
center.addWidget(self._build_table(), 1)
@@ -1029,7 +1189,7 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
center_w = QWidget()
center_w.setLayout(center)
body.addWidget(center_w, 1)
body.addWidget(DevicesViewQWidget._vline())
body.addWidget(vline())
body.addWidget(self._build_inspector())
root.addLayout(body, 1)
@@ -1062,6 +1222,14 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
self.change_chip = Chip()
row.addWidget(self.change_chip)
row.addStretch(1)
self.staff_label = label("", "muted")
self.staff_label.hide()
row.addWidget(self.staff_label)
self.sign_out_btn = tool("Sign out", "logout", "Lock Device Config again")
self.sign_out_btn.clicked.connect(self.sign_out_requested)
self.sign_out_btn.hide()
row.addWidget(self.sign_out_btn)
row.addSpacing(6)
self.review_btn = TextButton("Review changes", "primary", "difference")
self.review_btn.clicked.connect(self.open_review)
row.addWidget(self.review_btn)
@@ -1688,6 +1856,12 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
return sheet
@SafeSlot()
def set_staff(self, user: str) -> None:
"""Show who is signed in (BEC Atlas) next to Review changes."""
self.staff_label.setText(f"Signed in as {user}" if user else "")
self.staff_label.setVisible(bool(user))
self.sign_out_btn.setVisible(bool(user))
def open_review(self) -> Sheet:
"""Sheet listing every difference to the running session, with Apply."""
sheet = Sheet(
@@ -1885,3 +2059,140 @@ class DeviceConfigViewQWidget(BECWidget, QWidget):
if self._sheet is not None:
self._sheet.close()
super().cleanup()
# ------------------------------------------------------------------------------------ staff gate
class DeviceConfigGateQWidget(BECWidget, QWidget):
"""Device Config behind the BEC Atlas staff sign-in (QWidget version).
The Config view is built on the first sign-in and locks again when the view is left.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
self.get_bec_shortcuts()
self.setObjectName("ConfigRoot")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.access = StaffAccess(self.bec_dispatcher, self)
self.config: DeviceConfigViewQWidget | None = None
self.stack = QStackedWidget(self)
self.stack.addWidget(self._build_lock())
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.stack)
self.access.changed.connect(self._sync)
self.apply_theme("")
self._sync()
def _build_lock(self) -> QWidget:
page = QWidget()
outer = QVBoxLayout(page)
outer.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
card = QFrame()
card.setProperty("role", "panel")
card.setFixedWidth(420)
form = QVBoxLayout(card)
form.setContentsMargins(24, 24, 24, 24)
form.setSpacing(12)
self.lock_icon = QLabel()
self.lock_icon.setFixedSize(44, 44)
self.lock_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
form.addWidget(self.lock_icon)
title = label("Device Config is for beamline staff")
title.setStyleSheet("font-size: 17px; font-weight: 700;")
form.addWidget(title)
text = label(
"Sign in with your BEC Atlas account to add, remove and configure devices. "
"Everyone can watch and operate devices in Devices.",
"muted",
)
text.setWordWrap(True)
form.addWidget(text)
form.addSpacing(4)
form.addWidget(label("User name", "fieldlabel"))
self.user = QLineEdit()
self.user.setPlaceholderText("e.g. e12345")
self.user.setAccessibleName("User name")
form.addWidget(self.user)
form.addWidget(label("Password", "fieldlabel"))
self.password = QLineEdit()
self.password.setEchoMode(QLineEdit.EchoMode.Password)
self.password.setAccessibleName("Password")
form.addWidget(self.password)
self.error = label("", "err")
self.error.setWordWrap(True)
form.addWidget(self.error)
foot = QHBoxLayout()
self.deployment = label("", "faint")
foot.addWidget(self.deployment, 1)
self.sign_in = TextButton("Sign in", "primary", "login")
foot.addWidget(self.sign_in)
form.addLayout(foot)
self.user.returnPressed.connect(self.password.setFocus)
self.password.returnPressed.connect(self._submit)
self.sign_in.clicked.connect(self._submit)
row.addWidget(card)
row.addStretch(1)
outer.addLayout(row)
outer.addStretch(1)
self.lock_page = page
return page
def _submit(self) -> None:
self.access.sign_in(self.user.text(), self.password.text())
@SafeSlot()
def _sync(self) -> None:
busy = self.access.busy
self.user.setEnabled(not busy)
self.password.setEnabled(not busy)
self.sign_in.setEnabled(not busy)
self.sign_in.setText("Signing in…" if busy else "Sign in")
self.error.setText(self.access.error)
self.error.setVisible(bool(self.access.error))
self.deployment.setText(
f"Deployment {self.access.deployment}" if self.access.deployment else ""
)
if self.access.unlocked and self.config is None:
self.config = DeviceConfigViewQWidget(parent=self, client=self.client)
self.config.sign_out_requested.connect(self.access.sign_out)
self.stack.addWidget(self.config)
if self.config is not None:
self.config.set_staff(self.access.user)
if self.access.unlocked and self.config is not None:
self.password.clear()
self.stack.setCurrentWidget(self.config)
else:
self.stack.setCurrentWidget(self.lock_page)
def hideEvent(self, event): # pylint: disable=invalid-name
super().hideEvent(event)
if not event.spontaneous():
self.access.sign_out()
@SafeSlot(str)
def apply_theme(self, theme: str):
tokens = ThemeTokens()
self.setStyleSheet(view_qss(tokens) + f"""
QFrame[role="panel"] {{ background: {tokens.card.name()};
border: 1px solid {tokens.border.name()}; border-radius: 10px; }}
QFrame[role="panel"] QLabel {{ border: none; }}
QLabel[role="fieldlabel"] {{ font-size: 12px; font-weight: 600; }}
""")
self.lock_icon.setStyleSheet(
f"background: {rgba(tokens.primary, 0.14)}; border-radius: 22px; border: none;"
)
self.lock_icon.setPixmap(material_icon("lock", size=(22, 22), color=tokens.primary))
retheme_icons(self, tokens)
def cleanup(self):
self.access.cleanup()
if self.config is not None:
self.config.close()
self.config.deleteLater()
super().cleanup()
@@ -17,6 +17,7 @@ Rectangle {
function openReview() { addSheet.close(); clearSheet.close(); reviewSheet.applyMode = false; reviewSheet.open() }
function openAdd() { addSheet.open() }
function openClear() { clearSheet.open() }
function startApply() { if (root.backend.apply()) reviewSheet.applyMode = true }
function closeSheets() { reviewSheet.close(); addSheet.close(); clearSheet.close() }
color: theme.bg
@@ -77,6 +78,19 @@ Rectangle {
iconName: root.bar.changeCount > 0 ? "" : "check"
}
Item { Layout.fillWidth: true }
Text {
visible: root.backend.staffUser !== ""
text: "Signed in as " + root.backend.staffUser
color: theme.fgMuted
font.pixelSize: 12
}
GhostButton {
visible: root.backend.staffUser !== ""
text: "Sign out"
iconName: "logout"
tip: "Lock Device Config again"
onClicked: root.backend.signOut()
}
TextButton {
text: root.bar.reviewText
variant: "primary"
@@ -671,7 +685,7 @@ Rectangle {
required property string message
required property bool undo
required property int index
width: Math.min(440, toastRow.implicitWidth + 24)
width: Math.min(460, toastRow.implicitWidth + 28)
height: toastRow.implicitHeight + 16
radius: 8
color: theme.fg
@@ -680,11 +694,11 @@ Rectangle {
anchors.fill: parent
anchors.margins: 8
anchors.leftMargin: 12
Text { text: toast.message; color: theme.bg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.maximumWidth: 380 }
Text { text: toast.message; color: theme.bg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.maximumWidth: 380; Layout.rightMargin: 8 }
Text {
visible: toast.undo
text: "Undo"
color: Qt.lighter(theme.primary, theme.dark ? 1.4 : 1.6)
color: theme.dark ? Qt.darker(theme.primary, 1.3) : Qt.lighter(theme.primary, 1.6)
font.pixelSize: 12
font.weight: Font.Bold
TapHandler { onTapped: { root.backend.undo(); toastModel.remove(toast.index) } }
@@ -814,7 +828,7 @@ Rectangle {
text: reviewSheet.review.applyText
variant: "primary"
enabled: root.backend.canApply
onClicked: if (root.backend.apply()) reviewSheet.applyMode = true
onClicked: root.startApply()
},
TextButton {
visible: reviewSheet.applyMode
@@ -4,21 +4,103 @@ import QtQuick.Layouts
import BecUi
import "Tones.js" as Tones
// Devices for everyone: find a device by kind and text, see it live, move motors.
// backend: DevicesBackend.
// Devices for users: find any device, watch it live, move it and change its settings.
// A compact list on the left; the device fills the rest with a live panel next to its
// settings and readings. backend: DevicesBackend.
Rectangle {
id: root
required property QtObject backend
readonly property var d: backend.detail
readonly property string mono: "JetBrains Mono, Menlo, DejaVu Sans Mono"
readonly property bool hasDevice: backend.detailName !== ""
readonly property string mono: "DejaVu Sans Mono"
color: theme.bg
component Panel: Rectangle {
id: panel
property string title: ""
property string hint: ""
default property alias content: body.data
Layout.fillWidth: true
implicitHeight: body.implicitHeight + 28
radius: 8
color: theme.card
border.color: theme.border
ColumnLayout {
id: body
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16; topMargin: 12 }
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle { text: panel.title; Layout.topMargin: 0 }
Item { Layout.fillWidth: true }
Text { text: panel.hint; color: theme.fgSubtle; font.pixelSize: 11 }
}
}
}
component SignalRow: ColumnLayout {
id: sig
required property string key
required property bool settable
required property string doc
required property string valueText
required property string statusText
required property string statusTone
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 10
Text {
text: sig.key
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.preferredWidth: 140
elide: Text.ElideRight
HoverHandler { id: keyHover }
ToolTip.visible: keyHover.hovered && sig.doc !== ""
ToolTip.text: sig.doc
}
Text {
text: sig.valueText
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.fillWidth: true
elide: Text.ElideRight
}
InputField {
id: newValue
visible: sig.settable
Layout.preferredWidth: 120
placeholderText: "New value"
Accessible.name: "New value for " + sig.key
onAccepted: root.backend.setSignal(sig.key, text)
}
TextButton {
visible: sig.settable
text: "Set"
onClicked: root.backend.setSignal(sig.key, newValue.text)
}
}
Text {
visible: sig.statusText !== ""
text: sig.statusText
color: Tones.color(theme, sig.statusTone)
font.pixelSize: 12
wrapMode: Text.WordWrap
Layout.fillWidth: true
onTextChanged: if (sig.statusTone === "ok") newValue.clear()
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// ---- toolbar -------------------------------------------------------------------------
// ---- top bar ---------------------------------------------------------------------------
Rectangle {
Layout.fillWidth: true
implicitHeight: 46
@@ -26,19 +108,12 @@ Rectangle {
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 10
Icon { name: "memory"; size: 18; color: theme.fgMuted }
Text { text: "Devices"; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold }
InputField {
id: search
Layout.preferredWidth: 300
placeholderText: "Search devices by name, tag or description"
Accessible.name: "Search devices"
onTextChanged: debounce.restart()
Timer { id: debounce; interval: 120; onTriggered: root.backend.setQuery(search.text) }
}
Item { implicitWidth: 8 }
Segmented {
model: root.backend.kinds
current: root.backend.kind
@@ -62,35 +137,28 @@ Rectangle {
// ---- device list -------------------------------------------------------------------
Rectangle {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.preferredWidth: 360
Layout.fillHeight: true
color: theme.card
ColumnLayout {
anchors.fill: parent
spacing: 0
anchors.topMargin: 10
spacing: 6
InputField {
id: search
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: 12
placeholderText: "Search name, tag or description"
Accessible.name: "Search devices"
onTextChanged: debounce.restart()
Timer { id: debounce; interval: 120; onTriggered: root.backend.setQuery(search.text) }
}
Text {
text: root.backend.shownText
color: theme.fgMuted
font.pixelSize: 12
Layout.margins: 10
Layout.topMargin: 6
Layout.bottomMargin: 6
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 26
color: theme.card
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 16
spacing: 0
Text { text: "Name"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 150 }
Text { text: "What it is"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.fillWidth: true }
Text { text: "Value"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight }
}
color: theme.fgSubtle
font.pixelSize: 11
Layout.leftMargin: 14
}
ListView {
id: list
@@ -117,20 +185,25 @@ Rectangle {
required property bool selected
readonly property string rowName: name
width: ListView.view.width
height: 32
height: 48
color: selected ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.18)
: hover.hovered ? theme.hover : "transparent"
Rectangle { visible: rowItem.selected; width: 3; height: parent.height; color: theme.primary }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border; opacity: 0.6 }
HoverHandler { id: hover }
TapHandler { onTapped: { list.currentIndex = rowItem.index; root.backend.select(rowItem.name) } }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 16
spacing: 0
Text { text: rowItem.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.DemiBold; Layout.preferredWidth: 150; elide: Text.ElideRight }
Text { text: rowItem.what; color: theme.fgMuted; font.pixelSize: 12; Layout.fillWidth: true; elide: Text.ElideRight }
Text { text: rowItem.valueText; color: theme.fg; font.family: root.mono; font.pixelSize: 12; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight }
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text { text: rowItem.name; color: theme.fg; font.family: root.mono; font.pixelSize: 13; font.weight: Font.DemiBold; Layout.fillWidth: true; elide: Text.ElideRight }
Text { text: rowItem.what; color: theme.fgMuted; font.pixelSize: 11; Layout.fillWidth: true; elide: Text.ElideRight }
}
Text { text: rowItem.valueText; color: theme.fg; font.family: root.mono; font.pixelSize: 12; Layout.maximumWidth: 150; elide: Text.ElideRight }
}
}
}
@@ -138,206 +211,193 @@ Rectangle {
}
Rectangle { Layout.fillHeight: true; implicitWidth: 1; color: theme.border }
// ---- detail ------------------------------------------------------------------------
// ---- device ------------------------------------------------------------------------
ScrollView {
id: detailScroll
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
Item {
width: detailScroll.availableWidth
implicitHeight: detail.implicitHeight + 24
implicitHeight: detail.implicitHeight + 36
ColumnLayout {
id: detail
width: Math.min(440, parent.width - 32)
anchors.horizontalCenter: parent.horizontalCenter
y: 12
spacing: 8
x: 24
y: 18
width: parent.width - 48
spacing: 16
Text {
visible: root.backend.detailName === ""
visible: !root.hasDevice
text: "Select a device to see it here."
color: theme.fgMuted
font.pixelSize: 12
}
ColumnLayout {
visible: root.backend.detailName !== ""
spacing: 4
Layout.fillWidth: true
Text { visible: root.d.kind !== "positioner"; text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 15; font.weight: Font.Bold }
Text { text: root.d.description || ""; color: theme.fgMuted; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
Text {
visible: root.d.kind === "monitor"
textFormat: Text.StyledText
text: (root.d.valueText || "") + "<span style='font-size:14px'> " + (root.d.units || "") + "</span>"
color: theme.fg
font.family: root.mono
font.pixelSize: 28
font.weight: Font.DemiBold
}
Canvas {
id: spark
visible: root.d.kind === "monitor"
Layout.fillWidth: true
Layout.maximumWidth: 380
Layout.preferredHeight: 70
property var points: root.backend.spark
onPointsChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
if (!points || points.length < 2) return
let lo = Math.min.apply(null, points), hi = Math.max.apply(null, points)
const span = (hi - lo) || 1
ctx.strokeStyle = theme.primary
ctx.lineWidth = 1.6
ctx.beginPath()
for (let i = 0; i < points.length; i++) {
const x = i / 89 * width, y = height - 5 - (points[i] - lo) / span * (height - 10)
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)
}
ctx.stroke()
}
}
Text { visible: root.d.kind === "monitor"; text: "last ~20 s, live"; color: theme.fgSubtle; font.pixelSize: 11 }
}
Loader {
id: motor
active: root.d.kind === "positioner" && root.backend.motor !== null
visible: active
Layout.fillWidth: true
Layout.preferredHeight: item ? item.implicitHeight : 0
onActiveChanged: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
Component.onCompleted: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
}
TextButton {
visible: root.backend.detailName !== ""
text: root.d.actionText || ""
iconName: root.d.kind === "detector" ? "image" : root.d.kind === "monitor" ? "show_chart" : "add"
variant: root.d.kind === "detector" ? "primary" : "neutral"
onClicked: root.backend.openInWorkspace()
}
SectionTitle { text: "Settings"; visible: root.backend.settings.count > 0 }
Repeater {
model: root.backend.settings
delegate: ColumnLayout {
id: setting
required property string key
required property bool settable
required property string doc
required property string valueText
required property string statusText
required property string statusTone
// header: name, kind, description and the main action
RowLayout {
visible: root.hasDevice
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: setting.key
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.preferredWidth: 130
elide: Text.ElideRight
HoverHandler { id: keyHover }
ToolTip.visible: keyHover.hovered && setting.doc !== ""
ToolTip.text: setting.doc
}
Text {
text: setting.valueText
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.fillWidth: true
elide: Text.ElideRight
}
InputField {
id: newValue
visible: setting.settable
Layout.preferredWidth: 110
placeholderText: "New value"
Accessible.name: "New value for " + setting.key
onAccepted: root.backend.setSignal(setting.key, text)
}
TextButton {
visible: setting.settable
text: "Set"
onClicked: root.backend.setSignal(setting.key, newValue.text)
}
}
Text {
visible: setting.statusText !== ""
text: setting.statusText
color: Tones.color(theme, setting.statusTone)
font.pixelSize: 12
wrapMode: Text.WordWrap
Layout.fillWidth: true
onTextChanged: if (setting.statusTone === "ok") newValue.clear()
}
}
}
SectionTitle { text: "Readings"; visible: root.backend.readings.count > 0 }
Repeater {
model: root.backend.readings
delegate: RowLayout {
id: reading
required property string key
required property string doc
required property string valueText
Layout.fillWidth: true
spacing: 8
Text {
text: reading.key
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.preferredWidth: 130
elide: Text.ElideRight
}
Text {
text: reading.valueText
color: theme.fg
font.family: root.mono
font.pixelSize: 12
Layout.fillWidth: true
elide: Text.ElideRight
spacing: 10
Text { text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 20; font.weight: Font.Bold }
Chip { text: root.d.kindLabel || "" }
Chip { visible: root.d.enabled === false; text: "Disabled in this session"; tone: "warn"; iconName: "block" }
}
Text { text: root.d.description || ""; color: theme.fgMuted; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
}
TextButton {
Layout.alignment: Qt.AlignTop
text: root.d.actionText || ""
iconName: root.d.kind === "detector" ? "image" : root.d.kind === "monitor" ? "show_chart" : "add"
variant: "primary"
onClicked: root.backend.openInWorkspace()
}
}
GridLayout {
visible: root.backend.detailName !== ""
Layout.topMargin: 10
columns: 2
columnSpacing: 12
rowSpacing: 3
visible: root.hasDevice
Layout.fillWidth: true
Repeater {
model: root.d.facts || []
delegate: Text {
required property var modelData
required property int index
text: modelData.label
color: theme.fgMuted
font.pixelSize: 12
Layout.row: index
Layout.column: 0
columns: detail.width < 860 ? 1 : 2
columnSpacing: 16
rowSpacing: 16
// left column: live panel (motor card or value and trend) and about
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
Loader {
id: motor
active: root.d.kind === "positioner" && root.backend.motor !== null
visible: active
Layout.fillWidth: true
Layout.preferredHeight: item ? item.implicitHeight : 0
onActiveChanged: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
Component.onCompleted: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
}
Panel {
visible: root.d.kind !== "positioner"
title: "Live value"
hint: root.d.numeric ? "last ~20 s" : ""
Text {
textFormat: Text.StyledText
text: (root.d.valueText || "") + "<span style='font-size:15px'> " + (root.d.units || "") + "</span>"
color: theme.fg
font.family: root.mono
font.pixelSize: 28
font.weight: Font.DemiBold
}
Canvas {
id: spark
visible: root.d.numeric === true
Layout.fillWidth: true
Layout.preferredHeight: 120
property var points: root.backend.spark
onPointsChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
ctx.strokeStyle = theme.border
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
for (const f of [0, 0.5, 1]) {
const gy = Math.round(5 + f * (height - 10)) + 0.5
ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(width, gy); ctx.stroke()
}
ctx.setLineDash([])
if (!points || points.length < 2) return
const lo = Math.min.apply(null, points), hi = Math.max.apply(null, points)
const span = (hi - lo) || 1
const px = i => i / 89 * width
const py = v => height - 5 - (v - lo) / span * (height - 10)
ctx.beginPath()
for (let i = 0; i < points.length; i++) i ? ctx.lineTo(px(i), py(points[i])) : ctx.moveTo(px(i), py(points[i]))
const last = points.length - 1
ctx.lineTo(px(last), height); ctx.lineTo(0, height); ctx.closePath()
ctx.fillStyle = Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12)
ctx.fill()
ctx.beginPath()
for (let i = 0; i < points.length; i++) i ? ctx.lineTo(px(i), py(points[i])) : ctx.moveTo(px(i), py(points[i]))
ctx.strokeStyle = theme.primary
ctx.lineWidth = 1.8
ctx.stroke()
ctx.fillStyle = theme.primary
ctx.beginPath(); ctx.arc(px(last), py(points[last]), 3, 0, 2 * Math.PI); ctx.fill()
ctx.fillStyle = theme.fgSubtle
ctx.font = "10px sans-serif"
ctx.textAlign = "right"
ctx.fillText(Number(hi.toPrecision(6)).toString(), width - 4, 11)
ctx.fillText(Number(lo.toPrecision(6)).toString(), width - 4, height - 3)
}
}
}
Panel {
title: "About"
GridLayout {
columns: 2
columnSpacing: 16
rowSpacing: 4
Layout.fillWidth: true
Repeater {
model: root.d.facts || []
delegate: Text {
required property var modelData
required property int index
text: modelData.label
color: theme.fgMuted
font.pixelSize: 12
Layout.row: index
Layout.column: 0
}
}
Repeater {
model: root.d.facts || []
delegate: Text {
required property var modelData
required property int index
text: modelData.value
color: theme.fg
font.pixelSize: 13
Layout.row: index
Layout.column: 1
Layout.fillWidth: true
}
}
}
}
}
Repeater {
model: root.d.facts || []
delegate: Text {
required property var modelData
required property int index
text: modelData.value
color: theme.fg
font.pixelSize: 13
Layout.row: index
Layout.column: 1
Layout.fillWidth: true
// right column: settings and readings
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
Panel {
visible: root.backend.settings.count > 0
title: "Settings"
hint: "Applied to the device at once"
Repeater {
model: root.backend.settings
delegate: SignalRow {}
}
}
Panel {
visible: root.backend.readings.count > 0
title: "Readings"
hint: "live"
Repeater {
model: root.backend.readings
delegate: SignalRow {}
}
}
}
}
@@ -0,0 +1,103 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// Staff sign-in in front of Device Config (BEC Atlas, like Admin View). backend: StaffBackend.
Rectangle {
id: root
required property QtObject backend
color: theme.bg
function submit() { root.backend.signIn(user.text, password.text) }
Rectangle {
anchors.centerIn: parent
width: Math.min(420, parent.width - 32)
implicitHeight: form.implicitHeight + 48
radius: 10
color: theme.card
border.color: theme.border
ColumnLayout {
id: form
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 24 }
spacing: 12
Rectangle {
implicitWidth: 44; implicitHeight: 44; radius: 22
color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.14)
Icon { anchors.centerIn: parent; name: "lock"; size: 22; color: theme.primary }
}
Text {
text: "Device Config is for beamline staff"
color: theme.fg
font.pixelSize: 17
font.weight: Font.Bold
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Text {
text: "Sign in with your BEC Atlas account to add, remove and configure devices. Everyone can watch and operate devices in Devices."
color: theme.fgMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
ColumnLayout {
spacing: 4
Layout.fillWidth: true
Layout.topMargin: 4
Text { text: "User name"; color: theme.fg; font.pixelSize: 12; font.weight: Font.DemiBold }
InputField {
id: user
Layout.fillWidth: true
enabled: !root.backend.busy
placeholderText: "e.g. e12345"
Accessible.name: "User name"
focus: true
onAccepted: password.forceActiveFocus()
}
}
ColumnLayout {
spacing: 4
Layout.fillWidth: true
Text { text: "Password"; color: theme.fg; font.pixelSize: 12; font.weight: Font.DemiBold }
InputField {
id: password
Layout.fillWidth: true
enabled: !root.backend.busy
echoMode: TextInput.Password
Accessible.name: "Password"
onAccepted: root.submit()
}
}
Text {
visible: root.backend.error !== ""
text: root.backend.error
color: theme.danger
font.pixelSize: 12
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 4
Text {
text: root.backend.deployment !== "" ? "Deployment " + root.backend.deployment : ""
color: theme.fgSubtle
font.pixelSize: 11
Layout.fillWidth: true
elide: Text.ElideRight
}
TextButton {
text: root.backend.busy ? "Signing in…" : "Sign in"
variant: "primary"
iconName: "login"
enabled: !root.backend.busy
onClicked: root.submit()
}
}
}
}
}
@@ -0,0 +1,147 @@
"""Staff sign-in for *Device Config*, through the same BEC Atlas login as Admin View.
:class:`StaffAccess` is toolkit-free state shared by the QWidget and QML lock screens: it signs in
against BEC Atlas, and only unlocks when the account owns the running deployment.
"""
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
BECAtlasHTTPError,
BECAtlasHTTPService,
)
logger = bec_logger.logger
ATLAS_URL = "https://bec-atlas-prod.psi.ch/api/v1"
SIGN_IN_TIMEOUT_MS = 20_000
class _GateAtlasService(BECAtlasHTTPService):
"""Atlas service that reports failures as a signal instead of message boxes."""
failed = Signal(str)
def _handle_response(self, reply): # pylint: disable=invalid-name
try:
super()._handle_response(reply)
except BECAtlasHTTPError as exc:
text = str(exc)
if "status code 401" in text or "status code 403" in text:
self.failed.emit("User name or password is not correct.")
else:
self.failed.emit("BEC Atlas did not accept the request. Try again.")
logger.warning(f"Device Config sign-in failed: {text}")
def _show_warning(self, text: str):
self.failed.emit(text)
class StaffAccess(QObject):
"""Sign-in state of the staff-only *Device Config* view.
Args:
dispatcher: The BEC dispatcher, for the deployment-info subscription.
parent: Parent, normally the gate widget.
atlas_url: BEC Atlas API base URL.
"""
changed = Signal()
def __init__(self, dispatcher=None, parent: QObject | None = None, atlas_url: str = ATLAS_URL):
super().__init__(parent)
self.dispatcher = dispatcher
self.user = ""
self.error = ""
self.busy = False
self.deployment = ""
# The Atlas service is a QWidget; keep it a hidden child of the gate widget.
self.service = _GateAtlasService(
parent=parent if isinstance(parent, QWidget) else None, base_url=atlas_url
)
self.service.hide()
self.service.authenticated.connect(self._on_authenticated)
self.service.failed.connect(self._on_failed)
self._timeout = QTimer(self, singleShot=True, interval=SIGN_IN_TIMEOUT_MS)
self._timeout.timeout.connect(
lambda: self._on_failed("BEC Atlas did not answer. Check the network and try again.")
)
if dispatcher is not None:
dispatcher.connect_slot(
self.on_deployment, MessageEndpoints.deployment_info(), from_start=True
)
@property
def unlocked(self) -> bool:
"""True while a staff account of this deployment is signed in."""
return bool(self.user)
def on_deployment(self, msg: dict, _meta: dict | None = None) -> None:
"""Track the running deployment; sign-in checks ownership against it."""
try:
info = DeploymentInfoMessage.model_validate(msg)
except Exception: # pylint: disable=broad-except
return
self.service._set_current_deployment_info(info) # pylint: disable=protected-access
self.deployment = str(info.name or "")
self.changed.emit()
def sign_in(self, username: str, password: str) -> None:
"""Ask BEC Atlas to sign in; the view unlocks once ownership is confirmed."""
if self.busy:
return
if not username.strip() or not password:
self.error = "Enter your user name and password."
self.changed.emit()
return
if not self.deployment:
self.error = "This BEC session has no deployment registered with BEC Atlas."
self.changed.emit()
return
self.busy = True
self.error = ""
self._timeout.start()
self.changed.emit()
self.service.login(username=username.strip(), password=password)
def sign_out(self) -> None:
"""Lock the view again and end the Atlas session."""
was_signed_in = self.unlocked
self.user = ""
self.busy = False
self._timeout.stop()
self.changed.emit()
if was_signed_in:
self.service.logout()
def _on_authenticated(self, info: dict) -> None:
self._timeout.stop()
self.busy = False
self.user = str((info or {}).get("email", "")) if info else ""
if info:
self.error = ""
self.changed.emit()
def _on_failed(self, text: str) -> None:
self._timeout.stop()
self.busy = False
self.user = ""
self.error = text
self.changed.emit()
def cleanup(self) -> None:
"""Drop the subscription and end the Atlas session."""
self._timeout.stop()
if self.dispatcher is not None:
self.dispatcher.disconnect_slot(self.on_deployment, MessageEndpoints.deployment_info())
if self.unlocked:
self.service.cleanup() # also ends the session on the Atlas server
else:
self.service._auth_timer.stop() # pylint: disable=protected-access
self.service.deleteLater()
+58
View File
@@ -21,10 +21,12 @@ from bec_widgets.applications.views.devices_views.devices_core import (
signal_entries,
)
from bec_widgets.applications.views.devices_views.devices_qml import (
DeviceConfigGateQML,
DeviceConfigViewQML,
DevicesViewQML,
)
from bec_widgets.applications.views.devices_views.devices_qwidget import (
DeviceConfigGateQWidget,
DeviceConfigViewQWidget,
DevicesViewQWidget,
)
@@ -454,3 +456,59 @@ def test_devices_view_lets_users_change_a_setting(devices_view, qtbot, sync_pool
widgets["edit"].returnPressed.emit()
qtbot.waitUntil(lambda: widgets["status"].text() == "Set to 3", timeout=1000)
velocity.set.assert_called_once_with(3)
# ------------------------------------------------------------------------------------ staff gate
@pytest.fixture(params=[DeviceConfigGateQWidget, DeviceConfigGateQML])
def gate(request, qtbot, mocked_client):
with mock.patch.object(
mocked_client.device_manager,
"_get_redis_device_config",
return_value=[{"name": k, **copy.deepcopy(v)} for k, v in SESSION.items()],
):
widget = create_widget(qtbot, request.param, client=mocked_client)
service = widget.access.service
with (
mock.patch.object(service, "login") as login,
mock.patch.object(service, "logout") as logout,
):
widget.login, widget.logout = login, logout
yield widget
def test_gate_starts_locked_and_explains_missing_input(gate):
assert gate.config is None
gate.access.sign_in("", "")
assert gate.access.error == "Enter your user name and password."
gate.access.sign_in("e12345", "secret")
assert "no deployment" in gate.access.error
gate.login.assert_not_called()
def test_gate_unlocks_for_deployment_owner_and_locks_on_sign_out(gate, qtbot):
gate.access.deployment = "x05la"
gate.access.sign_in("e12345", "secret")
gate.login.assert_called_once_with(username="e12345", password="secret")
assert gate.access.busy
gate.access.service.authenticated.emit({"email": "jan@psi.ch"})
assert gate.access.unlocked
assert gate.config is not None
assert gate.stack.currentWidget() is gate.config
if isinstance(gate, DeviceConfigGateQWidget):
assert gate.config.staff_label.text() == "Signed in as jan@psi.ch"
else:
assert gate.config.backend.staffUser == "jan@psi.ch"
gate.config.sign_out_requested.emit()
assert not gate.access.unlocked
gate.logout.assert_called_once()
assert gate.stack.currentWidget() is not gate.config
def test_gate_shows_atlas_refusal(gate):
gate.access.deployment = "x05la"
gate.access.sign_in("e12345", "wrong")
gate.access.service.failed.emit("User name or password is not correct.")
assert not gate.access.busy
assert not gate.access.unlocked
assert gate.access.error == "User name or password is not correct."
assert gate.config is None