diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py
index 83fef461..c17d7239 100644
--- a/bec_widgets/applications/main_app.py
+++ b/bec_widgets/applications/main_app.py
@@ -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",
diff --git a/bec_widgets/applications/views/devices_views/compare_devices_views.py b/bec_widgets/applications/views/devices_views/compare_devices_views.py
index 14679f76..b231aaf2 100644
--- a/bec_widgets/applications/views/devices_views/compare_devices_views.py
+++ b/bec_widgets/applications/views/devices_views/compare_devices_views.py
@@ -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)
diff --git a/bec_widgets/applications/views/devices_views/devices_core.py b/bec_widgets/applications/views/devices_views/devices_core.py
index 6b610f84..611773f2 100644
--- a/bec_widgets/applications/views/devices_views/devices_core.py
+++ b/bec_widgets/applications/views/devices_views/devices_core.py
@@ -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]:
diff --git a/bec_widgets/applications/views/devices_views/devices_qml.py b/bec_widgets/applications/views/devices_views/devices_qml.py
index 9f8e0bc0..5f882203 100644
--- a/bec_widgets/applications/views/devices_views/devices_qml.py
+++ b/bec_widgets/applications/views/devices_views/devices_qml.py
@@ -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()
diff --git a/bec_widgets/applications/views/devices_views/devices_qwidget.py b/bec_widgets/applications/views/devices_views/devices_qwidget.py
index a68d0725..356d41fc 100644
--- a/bec_widgets/applications/views/devices_views/devices_qwidget.py
+++ b/bec_widgets/applications/views/devices_views/devices_qwidget.py
@@ -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']} {unit}"
+
+ @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']} {unit}"
-
- @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()
diff --git a/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml
index 25f11834..2afdd774 100644
--- a/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml
+++ b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml
@@ -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
diff --git a/bec_widgets/applications/views/devices_views/qml/DevicesView.qml b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml
index 13af825b..3c059332 100644
--- a/bec_widgets/applications/views/devices_views/qml/DevicesView.qml
+++ b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml
@@ -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 || "") + " " + (root.d.units || "") + ""
- 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 || "") + " " + (root.d.units || "") + ""
+ 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 {}
+ }
}
}
}
diff --git a/bec_widgets/applications/views/devices_views/qml/LockScreen.qml b/bec_widgets/applications/views/devices_views/qml/LockScreen.qml
new file mode 100644
index 00000000..f29bb379
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/LockScreen.qml
@@ -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()
+ }
+ }
+ }
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/staff_access.py b/bec_widgets/applications/views/devices_views/staff_access.py
new file mode 100644
index 00000000..c9cef373
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/staff_access.py
@@ -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()
diff --git a/tests/unit_tests/test_devices_views.py b/tests/unit_tests/test_devices_views.py
index 83167385..273029eb 100644
--- a/tests/unit_tests/test_devices_views.py
+++ b/tests/unit_tests/test_devices_views.py
@@ -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