From e4dc3d505c217b030dc10f54d1c86ed3ec1036ed Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 17:01:13 +0200 Subject: [PATCH] new_gui Phase 3: staff Tools dialog (beam/sessions/recovery/local-contact/dev-help) Staff-only Tools button -> StaffToolsDialog with 5 tabs: - Beam/detector: beam centre/size/mark, ABR GMX/GMY/GMZ tweak + save/goto, mono pitch scan, change energy - Sessions: p-group selector + open-GUI-session admin (request close / force remove, force/end session) - Recovery: confirmation-code actions + maintenance/init/resync ops - Local contact: simulation toggles, device restart, BEC macros/devices, links - Dev help: error-code registry + last error payload Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/gui/new_gui/README.md | 8 + src/aare/gui/new_gui/ROADMAP.md | 6 +- src/aare/gui/new_gui/main_window.py | 10 + src/aare/gui/new_gui/top_bar.py | 14 + src/aare/gui/new_gui/widgets/staff_tools.py | 496 ++++++++++++++++++++ 5 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 src/aare/gui/new_gui/widgets/staff_tools.py 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