mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 00:21:27 +01:00
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:
10 files changed
+1322
-387
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user