diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md
index dbc285b0..3c2d45c7 100644
--- a/src/aare/gui/new_gui/README.md
+++ b/src/aare/gui/new_gui/README.md
@@ -45,6 +45,14 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
readout (`focus_measure`). Camera controls (⚙ gain/exposure → `samcam_settings`,
AF → `autofocus`, ◉ → `send_screenshot_db`) live in the tab strip.
- **Gonio / Beamline tabs** ← axis IP video via `VideoThread` (`frame_ready`).
+- **Staff Tools** (⚙ Tools in the top bar, staff only) — a tabbed dialog
+ (`widgets/staff_tools.py`): **Beam/detector** (beam centre/size/mark, ABR
+ GMX/GMY/GMZ tweak + save/goto, mono pitch scan, energy), **Sessions**
+ (p-group selector + open-GUI-session admin: request-close/force-remove,
+ force/end session), **Recovery** (confirmation-code free/take-over/recover/
+ unmount + anneal/park&dry/TELL/init/pedestal/resync), **Local contact**
+ (simulation toggles, device restart, BEC macros/devices, web links), and
+ **Dev help** (error-code registry + last error payload).
- **Preconditions**: data-collection actions are gated by a ring/shutter/hutch
warning (1-hour snooze); mounting is hard-blocked by the hutch PSS;
`sample_missing` shows a modal (suppressed during automation).
diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md
index 76fff602..4db10477 100644
--- a/src/aare/gui/new_gui/ROADMAP.md
+++ b/src/aare/gui/new_gui/ROADMAP.md
@@ -93,8 +93,10 @@ focus), B (gonio/beamline video), I (alert banner + precondition dialogs +
sample-missing). Still deferred from A: smargon bookmarks (Ctrl-click), helical
markers.
-**Phase 3 — staff tooling:** G (beam/detector physics), J (pgroups + sessions),
-H (recovery + local-contact admin), developer help.
+**Phase 3 — staff tooling:** ✅ DONE (single staff Tools dialog). G (beam/detector
+physics), J (pgroups + sessions), H (recovery + local-contact admin), developer
+help. Deferred: full device-state status widget, BEC reinit-method picker,
+tracebacks/error-log live tabs.
**Phase 4 — polish:** L (file builder), F (face detection), M (analysis panels),
N (shortcuts, layout persistence, tutorials, portrait/compact).
diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py
index f92e84ab..e69f1b91 100644
--- a/src/aare/gui/new_gui/main_window.py
+++ b/src/aare/gui/new_gui/main_window.py
@@ -175,6 +175,7 @@ class MainWindow(QWidget):
self.top_bar.request_baton.connect(self.daq.request_baton)
self.top_bar.release_baton.connect(self.daq.release_baton)
self.top_bar.cancel_baton_request.connect(self._on_cancel_baton)
+ self.top_bar.tools_clicked.connect(self._open_staff_tools)
self.daq.baton_request_result.connect(self._on_baton_request_result)
self.daq.baton_response_result.connect(self._on_baton_response_result)
if hasattr(self.daq, "baton_incoming_request"):
@@ -340,6 +341,15 @@ class MainWindow(QWidget):
return
self.daq.mount(sample, reference)
+ def _open_staff_tools(self) -> None:
+ from aare.gui.new_gui.widgets.staff_tools import StaffToolsDialog
+
+ if getattr(self, "_staff_tools", None) is None:
+ self._staff_tools = StaffToolsDialog(
+ self.daq, self._palette, lambda: self.manual.last_status, self)
+ self._staff_tools.show()
+ self._staff_tools.raise_()
+
def _on_sample_missing(self, msg: str) -> None:
self.alert.show_message(msg, "error")
if not self._auto_running: # don't interrupt automation with a modal
diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py
index a3ead96c..737ebf1b 100644
--- a/src/aare/gui/new_gui/top_bar.py
+++ b/src/aare/gui/new_gui/top_bar.py
@@ -51,6 +51,7 @@ class TopBar(QWidget):
request_baton = Signal()
release_baton = Signal()
cancel_baton_request = Signal()
+ tools_clicked = Signal()
def __init__(self, palette: Palette, beamline_label: str, parent=None):
super().__init__(parent)
@@ -110,6 +111,18 @@ class TopBar(QWidget):
self._baton_btn.clicked.connect(self._on_baton_btn)
lay.addWidget(self._baton_btn)
+ # staff tools (hidden unless staff)
+ self._tools_btn = QPushButton("⚙ Tools")
+ self._tools_btn.setCursor(Qt.PointingHandCursor)
+ self._tools_btn.setStyleSheet(
+ f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};"
+ f" border-radius:8px; padding:6px 12px; font-size:12px;"
+ f" color:{p.text_secondary}; }}"
+ )
+ self._tools_btn.clicked.connect(self.tools_clicked)
+ self._tools_btn.setVisible(False)
+ lay.addWidget(self._tools_btn)
+
# avatar
self._avatar = QLabel("--")
self._avatar.setObjectName("Avatar")
@@ -129,6 +142,7 @@ class TopBar(QWidget):
self.toggle.set_mode(mode)
def set_staff(self, is_staff: bool) -> None:
+ self._tools_btn.setVisible(is_staff)
if is_staff != self._is_staff:
self._is_staff = is_staff
if self._last_baton is not None:
diff --git a/src/aare/gui/new_gui/widgets/staff_tools.py b/src/aare/gui/new_gui/widgets/staff_tools.py
new file mode 100644
index 00000000..5cfdd09e
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/staff_tools.py
@@ -0,0 +1,496 @@
+"""Staff-only Tools dialog: Beam · Sessions · Recovery · Local Contact · Dev help.
+
+A tabbed admin surface wired to the existing DAQWorker. Each tab reads live
+values via the ``status_provider`` callable and triggers the matching DAQWorker
+methods. Recovery actions use the confirmation-code flow (password prompt).
+Everything is staff-gated by the caller (the Tools button only shows for staff).
+"""
+
+from __future__ import annotations
+
+import json
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QDialog,
+ QDoubleSpinBox,
+ QFormLayout,
+ QGridLayout,
+ QHBoxLayout,
+ QHeaderView,
+ QInputDialog,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPlainTextEdit,
+ QPushButton,
+ QSpinBox,
+ QTabWidget,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.common.coordinate import AerotechCoordinate, Coordinate
+from aare.gui.new_gui.theme import build_qss
+
+
+def _btn(text: str) -> QPushButton:
+ b = QPushButton(text)
+ b.setCursor(Qt.PointingHandCursor)
+ return b
+
+
+class StaffToolsDialog(QDialog):
+ def __init__(self, daq, palette, status_provider, parent=None):
+ super().__init__(parent)
+ self._daq = daq
+ self._p = palette
+ self._status = status_provider
+ self.setWindowTitle("Staff tools")
+ self.setModal(False)
+ self.resize(820, 640)
+ self.setStyleSheet(build_qss(palette))
+
+ lay = QVBoxLayout(self)
+ self.tabs = QTabWidget()
+ lay.addWidget(self.tabs)
+ self.tabs.addTab(self._build_beam_tab(), "Beam / detector")
+ self.tabs.addTab(self._build_sessions_tab(), "Sessions")
+ self.tabs.addTab(self._build_recovery_tab(), "Recovery")
+ self.tabs.addTab(self._build_local_contact_tab(), "Local contact")
+ self.tabs.addTab(self._build_devhelp_tab(), "Dev help")
+
+ self._connect_signals()
+
+ # ====================================================== signals / loads
+ def _connect_signals(self) -> None:
+ d = self._daq
+ self._safe_connect(d, "staff_pgroups_loaded", self._on_pgroups)
+ self._safe_connect(d, "gui_sessions_loaded", self._on_sessions)
+ self._safe_connect(d, "recovery_action_completed", self._on_recovery_done)
+ self._safe_connect(d, "sample_resync_completed", self._on_recovery_done)
+ self._safe_connect(d, "detector_metadata_resync_completed", self._on_recovery_done)
+ self._safe_connect(d, "local_contact_simulation_state_loaded", self._on_sim_state)
+ self._safe_connect(d, "local_contact_links_loaded", self._on_links)
+ self._safe_connect(d, "bec_user_macros_loaded",
+ lambda v: self._becout.setPlainText("\n".join(v or [])))
+ self._safe_connect(d, "bec_devices_loaded",
+ lambda v: self._becout.setPlainText("\n".join(v or [])))
+ self._safe_connect(d, "error_codes_loaded", self._on_error_codes)
+ self._safe_connect(d, "last_error_payload_changed", self._on_err_payload)
+
+ @staticmethod
+ def _safe_connect(obj, signal_name, slot):
+ sig = getattr(obj, signal_name, None)
+ if sig is not None:
+ try:
+ sig.connect(slot)
+ except Exception:
+ pass
+
+ def _call(self, method: str, *args) -> None:
+ fn = getattr(self._daq, method, None)
+ if callable(fn):
+ fn(*args)
+
+ def showEvent(self, event): # noqa: N802
+ super().showEvent(event)
+ self._refresh_beam()
+ self._call("get_all_pgroups")
+ self._call("load_gui_sessions")
+ self._call("get_error_codes")
+ self._call("load_local_contact_simulation_state")
+ self._call("load_local_contact_links")
+ self._refresh_pgroup_label()
+
+ # ====================================================== Beam / detector
+ def _build_beam_tab(self) -> QWidget:
+ w = QWidget()
+ lay = QVBoxLayout(w)
+
+ # Beam centre
+ form = QFormLayout()
+ self._bc_x = QDoubleSpinBox(); self._bc_x.setRange(-4000, 4000)
+ self._bc_y = QDoubleSpinBox(); self._bc_y.setRange(-4000, 4000)
+ bc_btn = _btn("Set beam centre")
+ bc_btn.clicked.connect(
+ lambda: self._call("beam_center", self._bc_x.value(), self._bc_y.value()))
+ row = QHBoxLayout(); row.addWidget(self._bc_x); row.addWidget(self._bc_y); row.addWidget(bc_btn)
+ form.addRow("Beam centre (px)", self._wrap(row))
+
+ # Beam size (µm)
+ self._bs_x = QDoubleSpinBox(); self._bs_x.setRange(1, 400)
+ self._bs_y = QDoubleSpinBox(); self._bs_y.setRange(1, 400)
+ bs_btn = _btn("Set beam size")
+ bs_btn.clicked.connect(
+ lambda: self._call("beam_size_mm", self._bs_x.value() / 1000.0,
+ self._bs_y.value() / 1000.0))
+ row2 = QHBoxLayout(); row2.addWidget(self._bs_x); row2.addWidget(self._bs_y); row2.addWidget(bs_btn)
+ form.addRow("Beam size (µm)", self._wrap(row2))
+
+ # Beam mark
+ self._bm_label = QLabel("—")
+ bm_add = _btn("Mark current beam")
+ bm_add.clicked.connect(self._beam_mark_add)
+ bm_clear = _btn("Clear marks")
+ bm_clear.clicked.connect(lambda: self._call("beam_mark_clear"))
+ row3 = QHBoxLayout(); row3.addWidget(self._bm_label, 1); row3.addWidget(bm_add); row3.addWidget(bm_clear)
+ form.addRow("Beam mark (px)", self._wrap(row3))
+ lay.addLayout(form)
+
+ # ABR tweak
+ lay.addWidget(self._hsep())
+ lay.addWidget(QLabel("ABR measurement position"))
+ self._abr_step = QDoubleSpinBox(); self._abr_step.setRange(1, 1000); self._abr_step.setValue(10)
+ self._abr_step.setSuffix(" µm")
+ grid = QGridLayout()
+ self._abr_labels = {}
+ for r, axis in enumerate(("x", "y", "z")):
+ grid.addWidget(QLabel(f"GM{axis.upper()}"), r, 0)
+ minus = _btn("−"); minus.setFixedWidth(36)
+ plus = _btn("+"); plus.setFixedWidth(36)
+ minus.clicked.connect(lambda _=False, a=axis: self._abr_tweak(a, -1))
+ plus.clicked.connect(lambda _=False, a=axis: self._abr_tweak(a, +1))
+ val = QLabel("—")
+ self._abr_labels[axis] = val
+ grid.addWidget(minus, r, 1); grid.addWidget(plus, r, 2); grid.addWidget(val, r, 3)
+ steprow = QHBoxLayout(); steprow.addWidget(QLabel("Step")); steprow.addWidget(self._abr_step); steprow.addStretch(1)
+ save = _btn("Save meas. pos"); save.clicked.connect(lambda: self._call("abr_save"))
+ goto = _btn("Goto meas. pos"); goto.clicked.connect(lambda: self._call("abr_goto_meas"))
+ steprow.addWidget(save); steprow.addWidget(goto)
+ lay.addLayout(steprow)
+ lay.addLayout(grid)
+
+ # Monochromator
+ lay.addWidget(self._hsep())
+ lay.addWidget(QLabel("Monochromator"))
+ mono = QHBoxLayout()
+ pitch = _btn("Mono pitch scan"); pitch.clicked.connect(lambda: self._call("mono_pitch_scan"))
+ self._energy = QDoubleSpinBox(); self._energy.setRange(1000, 30000); self._energy.setSingleStep(100)
+ self._energy.setSuffix(" eV"); self._energy.setValue(12400)
+ ch = _btn("Change energy")
+ ch.clicked.connect(lambda: self._call("change_energy", self._energy.value()))
+ mono.addWidget(pitch); mono.addStretch(1); mono.addWidget(self._energy); mono.addWidget(ch)
+ lay.addLayout(mono)
+
+ refresh = _btn("Refresh readouts"); refresh.clicked.connect(self._refresh_beam)
+ lay.addWidget(refresh)
+ lay.addStretch(1)
+ return w
+
+ def _beam_mark_add(self) -> None:
+ geom = getattr(self._status(), "geom", None)
+ loc = getattr(geom, "beam_location_pxl", None) if geom else None
+ if loc is not None:
+ self._call("beam_mark_add", loc.x, loc.y)
+
+ def _abr_tweak(self, axis: str, sign: int) -> None:
+ step_mm = self._abr_step.value() / 1000.0 * sign
+ kw = {"x": 0.0, "y": 0.0, "z": 0.0}
+ kw[axis] = step_mm
+ self._call("abr_tweak",
+ AerotechCoordinate(at_mm=Coordinate(**kw), omega_deg=None))
+
+ def _refresh_beam(self) -> None:
+ s = self._status()
+ geom = getattr(s, "geom", None)
+ diff = getattr(s, "diffraction", None)
+ bc = getattr(diff, "beam_center_pxl", None) if diff else None
+ if bc and len(bc) >= 2:
+ self._bc_x.setValue(float(bc[0])); self._bc_y.setValue(float(bc[1]))
+ bs = getattr(geom, "beam_size_mm", None) if geom else None
+ if bs is not None:
+ self._bs_x.setValue(float(bs.x) * 1000.0); self._bs_y.setValue(float(bs.y) * 1000.0)
+ loc = getattr(geom, "beam_location_pxl", None) if geom else None
+ if loc is not None:
+ self._bm_label.setText(f"{loc.x:.0f}, {loc.y:.0f}")
+ meas = getattr(geom, "aerotech_meas", None) if geom else None
+ if meas is not None:
+ for axis in ("x", "y", "z"):
+ self._abr_labels[axis].setText(f"{getattr(meas, axis):.4f} mm")
+
+ # ====================================================== Sessions / pgroup
+ def _build_sessions_tab(self) -> QWidget:
+ w = QWidget()
+ lay = QVBoxLayout(w)
+ # pgroup
+ pg = QHBoxLayout()
+ pg.addWidget(QLabel("Current p-group:"))
+ self._pgroup_label = QLabel("—")
+ pg.addWidget(self._pgroup_label)
+ pg.addStretch(1)
+ from PySide6.QtWidgets import QComboBox
+ self._pgroup_combo = QComboBox(); self._pgroup_combo.setEditable(True)
+ self._pgroup_combo.setMinimumWidth(160)
+ set_pg = _btn("Set p-group")
+ set_pg.clicked.connect(
+ lambda: self._call("set_pgroup", self._pgroup_combo.currentText().strip()))
+ pg.addWidget(self._pgroup_combo); pg.addWidget(set_pg)
+ lay.addLayout(pg)
+
+ lay.addWidget(self._hsep())
+ lay.addWidget(QLabel("Open GUI sessions"))
+ self._sess_table = QTableWidget(0, 6)
+ self._sess_table.setHorizontalHeaderLabels(
+ ["Session", "User", "Staff", "Baton", "Last seen", "Close req."])
+ self._sess_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ self._sess_table.verticalHeader().setVisible(False)
+ lay.addWidget(self._sess_table, 1)
+
+ actions = QHBoxLayout()
+ refresh = _btn("Refresh"); refresh.clicked.connect(lambda: self._call("load_gui_sessions"))
+ self._grace = QSpinBox(); self._grace.setRange(5, 3600); self._grace.setValue(60); self._grace.setSuffix(" s")
+ req = _btn("Request close"); req.clicked.connect(self._request_close)
+ force = _btn("Force remove"); force.clicked.connect(self._force_remove)
+ actions.addWidget(refresh); actions.addStretch(1)
+ actions.addWidget(self._grace); actions.addWidget(req); actions.addWidget(force)
+ lay.addLayout(actions)
+
+ sess = QHBoxLayout()
+ fs = _btn("Force my session"); fs.clicked.connect(lambda: self._call("force_session"))
+ es = _btn("End my session"); es.clicked.connect(lambda: self._call("end_session"))
+ sess.addStretch(1); sess.addWidget(fs); sess.addWidget(es)
+ lay.addLayout(sess)
+ return w
+
+ def _selected_session_id(self):
+ row = self._sess_table.currentRow()
+ if row < 0:
+ return None
+ item = self._sess_table.item(row, 0)
+ return int(item.text()) if item else None
+
+ def _request_close(self) -> None:
+ sid = self._selected_session_id()
+ if sid is not None:
+ self._call("request_gui_close", sid, self._grace.value())
+
+ def _force_remove(self) -> None:
+ sid = self._selected_session_id()
+ if sid is None:
+ return
+ if QMessageBox.question(self, "Force remove",
+ f"Force-remove session {sid}?") == QMessageBox.Yes:
+ self._call("force_remove_gui_session", sid)
+
+ def _on_pgroups(self, pgroups) -> None:
+ self._pgroup_combo.clear()
+ self._pgroup_combo.addItems([str(p) for p in (pgroups or [])])
+
+ def _refresh_pgroup_label(self) -> None:
+ sess = getattr(self._status(), "session", None)
+ cur = getattr(sess, "current_pgroup", None) if sess else None
+ self._pgroup_label.setText(cur or "—")
+
+ def _on_sessions(self, sessions) -> None:
+ self._sess_table.setRowCount(0)
+ for info in sessions or []:
+ r = self._sess_table.rowCount()
+ self._sess_table.insertRow(r)
+ vals = [
+ str(getattr(info, "session", "")),
+ str(getattr(info, "username", "")),
+ "✓" if getattr(info, "staff", False) else "",
+ "●" if getattr(info, "holds_baton", False) else "",
+ self._age(getattr(info, "last_seen_ts", None)),
+ "yes" if getattr(info, "close_requested", False) else "",
+ ]
+ for c, v in enumerate(vals):
+ self._sess_table.setItem(r, c, QTableWidgetItem(v))
+
+ @staticmethod
+ def _age(ts) -> str:
+ if not ts:
+ return "—"
+ import time
+ secs = max(0, int(time.time() - ts))
+ if secs < 60:
+ return f"{secs}s ago"
+ m, s = divmod(secs, 60)
+ if m < 60:
+ return f"{m}m {s}s ago"
+ h, m = divmod(m, 60)
+ return f"{h}h {m}m ago"
+
+ # ====================================================== Recovery
+ def _build_recovery_tab(self) -> QWidget:
+ w = QWidget()
+ lay = QVBoxLayout(w)
+ warn = QLabel("⚠ Recovery actions can disrupt an active session. "
+ "Confirmation-code actions require a staff code.")
+ warn.setWordWrap(True)
+ warn.setStyleSheet(f"color:{self._p.breakpoint};")
+ lay.addWidget(warn)
+ self._recovery_status = QLabel("")
+ self._recovery_status.setStyleSheet(f"color:{self._p.text_muted};")
+ lay.addWidget(self._recovery_status)
+
+ grid = QGridLayout()
+ coded = [
+ ("Take over beamline", "take_over_beamline"),
+ ("Free beamline", "free_beamline"),
+ ("Recover beamline", "recover_beamline"),
+ ("Recovery unmount", "recovery_unmount_sample"),
+ ]
+ for i, (label, method) in enumerate(coded):
+ b = _btn(label)
+ b.clicked.connect(lambda _=False, m=method, lbl=label: self._coded_action(lbl, m))
+ grid.addWidget(b, i // 2, i % 2)
+ lay.addLayout(grid)
+
+ lay.addWidget(self._hsep())
+ lay.addWidget(QLabel("Maintenance"))
+ grid2 = QGridLayout()
+ plain = [
+ ("Anneal…", self._anneal),
+ ("Park && dry", lambda: self._call("park_and_dry")),
+ ("TELL dry", lambda: self._call("tell_dry")),
+ ("Toggle blower", lambda: self._call("tell_toggle_blower")),
+ ("Resync sample", lambda: self._call("resync_sample")),
+ ("Resync detector meta", lambda: self._call("resync_local_contact_detector_metadata")),
+ ("Init smargon", lambda: self._call("initialise_smargon")),
+ ("Init aerotech", lambda: self._call("initialise_aerotech")),
+ ("Init detector", lambda: self._call("initialise_detector")),
+ ("Take pedestal", lambda: self._call("detector_take_pedestal")),
+ ("Cancel current op", lambda: self._call("cancel")),
+ ]
+ for i, (label, slot) in enumerate(plain):
+ b = _btn(label); b.clicked.connect(lambda _=False, s=slot: s())
+ grid2.addWidget(b, i // 3, i % 3)
+ lay.addLayout(grid2)
+ lay.addStretch(1)
+ return w
+
+ def _coded_action(self, label: str, method: str) -> None:
+ if QMessageBox.question(self, label, f"{label}: are you sure?") != QMessageBox.Yes:
+ return
+ code, ok = QInputDialog.getText(self, label, "Confirmation code:",
+ QLineEdit.Password)
+ if ok and code.strip():
+ self._call(method, code.strip())
+
+ def _anneal(self) -> None:
+ secs, ok = QInputDialog.getDouble(self, "Anneal", "Time (s):", 1.0, 0.1, 60.0, 1)
+ if ok:
+ self._call("anneal", secs)
+
+ def _on_recovery_done(self, message: str) -> None:
+ self._recovery_status.setText(str(message))
+
+ # ====================================================== Local contact
+ def _build_local_contact_tab(self) -> QWidget:
+ w = QWidget()
+ lay = QVBoxLayout(w)
+
+ lay.addWidget(QLabel("Simulation"))
+ self._sim_checks = {}
+ simrow = QHBoxLayout()
+ for dev in ("tell", "bec", "detector", "aerotech", "smargon"):
+ cb = QCheckBox(dev)
+ cb.toggled.connect(
+ lambda on, d=dev: self._call("set_local_contact_simulation", d, on))
+ self._sim_checks[dev] = cb
+ simrow.addWidget(cb)
+ simrow.addStretch(1)
+ lay.addLayout(simrow)
+
+ lay.addWidget(QLabel("Restart / initialise backend"))
+ rrow = QGridLayout()
+ for i, dev in enumerate(("tell", "bec", "detector", "smargon", "aerotech")):
+ b = _btn(f"Restart {dev}")
+ b.clicked.connect(lambda _=False, d=dev: self._call("restart_local_contact_device", d))
+ rrow.addWidget(b, i // 3, i % 3)
+ lay.addLayout(rrow)
+
+ lay.addWidget(QLabel("BEC"))
+ becrow = QHBoxLayout()
+ for label, method in (
+ ("Load macros", "bec_load_user_macros"),
+ ("List macros", "bec_list_all_user_macros"),
+ ("List devices", "bec_list_all_devices"),
+ ("Reinit planner", "bec_reinitialise_planner_and_position_devices"),
+ ):
+ b = _btn(label); b.clicked.connect(lambda _=False, m=method: self._call(m))
+ becrow.addWidget(b)
+ lay.addLayout(becrow)
+ self._becout = QPlainTextEdit(); self._becout.setReadOnly(True); self._becout.setMaximumHeight(120)
+ lay.addWidget(self._becout)
+
+ lay.addWidget(QLabel("Web panels"))
+ self._links_label = QLabel("—"); self._links_label.setOpenExternalLinks(True)
+ self._links_label.setWordWrap(True)
+ lay.addWidget(self._links_label)
+ lay.addStretch(1)
+ return w
+
+ def _on_sim_state(self, state: dict) -> None:
+ for dev, cb in self._sim_checks.items():
+ if dev in (state or {}):
+ cb.blockSignals(True)
+ cb.setChecked(bool(state[dev]))
+ cb.blockSignals(False)
+
+ def _on_links(self, links: dict) -> None:
+ parts = []
+ for k, v in (links or {}).items():
+ if isinstance(v, str) and v.startswith("http"):
+ parts.append(f'{k}')
+ else:
+ parts.append(f"{k}: {v}")
+ self._links_label.setText(" · ".join(parts) if parts else "—")
+
+ # ====================================================== Dev help
+ def _build_devhelp_tab(self) -> QWidget:
+ w = QWidget()
+ lay = QVBoxLayout(w)
+ self._code_filter = QLineEdit(); self._code_filter.setPlaceholderText("Filter error codes…")
+ self._code_filter.textChanged.connect(self._filter_codes)
+ lay.addWidget(self._code_filter)
+ self._codes_table = QTableWidget(0, 1)
+ self._codes_table.setHorizontalHeaderLabels(["Error code"])
+ self._codes_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ self._codes_table.verticalHeader().setVisible(False)
+ lay.addWidget(self._codes_table, 1)
+ self._all_codes: dict = {}
+
+ lay.addWidget(QLabel("Last error payload"))
+ self._err_payload = QPlainTextEdit(); self._err_payload.setReadOnly(True)
+ self._err_payload.setMaximumHeight(180)
+ lay.addWidget(self._err_payload)
+ return w
+
+ def _on_error_codes(self, codes: dict) -> None:
+ self._all_codes = dict(codes or {})
+ self._filter_codes(self._code_filter.text())
+
+ def _filter_codes(self, text: str) -> None:
+ text = (text or "").lower()
+ self._codes_table.setRowCount(0)
+ for name in sorted(self._all_codes):
+ if text and text not in name.lower():
+ continue
+ r = self._codes_table.rowCount()
+ self._codes_table.insertRow(r)
+ self._codes_table.setItem(r, 0, QTableWidgetItem(name))
+
+ def _on_err_payload(self, payload: dict) -> None:
+ try:
+ self._err_payload.setPlainText(json.dumps(payload, indent=2, default=str))
+ except Exception:
+ self._err_payload.setPlainText(str(payload))
+
+ # ====================================================== helpers
+ @staticmethod
+ def _wrap(layout) -> QWidget:
+ wid = QWidget()
+ wid.setLayout(layout)
+ return wid
+
+ def _hsep(self) -> QLabel:
+ line = QLabel()
+ line.setFixedHeight(1)
+ line.setStyleSheet(f"background:{self._p.border_panel};")
+ return line