"""Diagnostics side panel: renders a diag.engine.Report. Layout: verdict banner, the test checklist (start-up milestones plus the online InfluxDB check), a findings tree (root causes; their consequences as children; titles only) and a wrapped detail pane for the selected finding. `details` is a separate widget for the host to place: expected LED (colour + pattern + meaning when the box reports it), firmware and the box's own self-report (KOTA lines). """ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QScrollArea, QSizePolicy, QSplitter, QTextBrowser, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from ..diag.engine import LED, Finding, Report, Severity SEV_COLOR = { Severity.CRITICAL: "#b03a2e", Severity.ERROR: "#d9534f", Severity.WARNING: "#c77c02", Severity.INFO: "#2e6da4", } # verdict banner (background, text) by verdict severity; None = healthy BANNER = { Severity.CRITICAL: ("#f6d5d1", "#7b1e14"), Severity.ERROR: ("#f9dedc", "#8a2019"), Severity.WARNING: ("#fdebc8", "#6b4400"), None: ("#d8f0dd", "#1b5e2a"), } _KEY = Qt.ItemDataRole.UserRole BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")} # Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour) ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"), "running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"), "note": ("", "#444444")} def _heading(text: str) -> QLabel: lbl = QLabel(text) f = lbl.font() f.setBold(True) f.setPointSize(f.pointSize() + 1) lbl.setFont(f) lbl.setContentsMargins(0, 6, 0, 2) return lbl def _esc(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") class DiagnosticsPanel(QWidget): def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setMinimumWidth(380) self.verdict = QLabel(wordWrap=True) self.verdict.setMargin(10) vf = self.verdict.font() vf.setPointSize(vf.pointSize() + 2) vf.setBold(True) self.verdict.setFont(vf) self.led_swatch = QLabel() self.led_swatch.setFixedSize(22, 22) self.led_text = QLabel(wordWrap=True) led_row = QHBoxLayout() led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop) led_row.addWidget(self.led_text, 1) self.firmware = QLabel(wordWrap=True) self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.box_title = _heading("Box self-report (KOTA lines)") self.box = QLabel(wordWrap=True) self.box.setTextFormat(Qt.TextFormat.RichText) self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.milestones = QListWidget() self.milestones.setFrameShape(QFrame.Shape.NoFrame) self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) self.milestones.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.milestones.setWordWrap(True) self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }") mf = self.milestones.font() mf.setPointSize(mf.pointSize() + 1) self.milestones.setFont(mf) self.tree = QTreeWidget() self.tree.setHeaderHidden(True) self.tree.setIndentation(16) self.tree.setTextElideMode(Qt.TextElideMode.ElideRight) self.detail = QTextBrowser() self.detail.setOpenExternalLinks(False) findings = QSplitter(Qt.Orientation.Vertical) findings.addWidget(self.tree) findings.addWidget(self.detail) findings.setSizes([220, 260]) lay = QVBoxLayout(self) lay.setContentsMargins(4, 0, 4, 0) lay.addWidget(self.verdict) lay.addWidget(_heading("Test checklist (current boot)")) lay.addWidget(self.milestones) lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(findings, 1) # Box details: placed by the host (a separate tab). inner = QWidget() dl = QVBoxLayout(inner) dl.addWidget(_heading("Status LED should be")) dl.addLayout(led_row) dl.addWidget(_heading("Firmware")) dl.addWidget(self.firmware) self.qr_btn = QPushButton("Matter pairing QR code...") self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) dl.addWidget(self.box_title) dl.addWidget(self.box) dl.addStretch(1) self.details = QScrollArea() self.details.setWidgetResizable(True) self.details.setFrameShape(QFrame.Shape.NoFrame) self.details.setWidget(inner) self._findings: dict[str, Finding] = {} self._selected: str | None = None # key -> expanded, chosen by the user; survives the rebuild on refresh. self._expanded: dict[str, bool] = {} self.tree.currentItemChanged.connect(self._on_select) self.tree.itemCollapsed.connect(lambda it: self._remember(it, False)) self.tree.itemExpanded.connect(lambda it: self._remember(it, True)) # ------------------------------------------------------------------ def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None: """extra_rows: (state, text) appended to the checklist, state in ROW_STATE.""" bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING]) if r.lines == 0: bg, fg = "#e6e6e6", "#333333" self.verdict.setText(r.verdict) self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}") name, colour = LED[r.led] self.led_swatch.setStyleSheet( f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}") ask = "" if r.led == "UNKNOWN" else " - check it matches the box" pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else "" meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else "" source = " (reported by the box)" if r.led_exact else "" reason = f"
from: {_esc(r.led_reason)}" if r.led_reason else "" self.led_text.setText(f"{name}{pattern}{meaning}{source}{ask}{reason}") fw = r.firmware sd = {True: "reads Wi-Fi settings from the SD card", False: "does not read Wi-Fi settings from the SD card", None: "Wi-Fi-from-SD support unknown"} self.firmware.setText(f"{_esc(fw.describe())}
{sd[fw.supports_sd_wifi]}") self.box_title.setVisible(bool(r.box)) self.box.setVisible(bool(r.box)) self.box.setText("" + "".join( f"" f"" for topic, text, st in r.box) + "
{BOX_STATE[st][1]} {_esc(topic)} {_esc(text)}
") self.milestones.clear() for _, label, t in r.milestones: done = t is not None it = QListWidgetItem(("✔ " if done else "○ ") + label + (f" ({t / 1000:.1f} s)" if done else "")) it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b"))) self.milestones.addItem(it) for state, text in extra_rows: mark, colour = ROW_STATE[state] it = QListWidgetItem(f"{mark} {text}" if mark else text) it.setForeground(QBrush(QColor(colour))) if state != "note": f = it.font() f.setBold(True) it.setFont(f) it.setToolTip(text) self.milestones.addItem(it) height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count())) self.milestones.setFixedHeight(min(max(height, 16), 560) + 6) self._fill_tree(r) def _fill_tree(self, r: Report) -> None: self.tree.blockSignals(True) self.tree.clear() self._findings = {} if not r.roots: it = QTreeWidgetItem(["No problems detected in the log so far."]) it.setForeground(0, QBrush(QColor("#1b7a33"))) self.tree.addTopLevelItem(it) for rc in r.roots: top = self._item(rc.finding, bold=True) self.tree.addTopLevelItem(top) for c in rc.consequences: top.addChild(self._item(c, prefix="↳ ")) top.setExpanded(self._expanded.get(rc.finding.key, True)) for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier), ("Resolved during this session", "grp:resolved", r.resolved)): if not items: continue grp = QTreeWidgetItem([f"{title} ({len(items)})"]) grp.setData(0, _KEY, key) grp.setForeground(0, QBrush(QColor("#666666"))) self.tree.addTopLevelItem(grp) for f in items: grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else "")) grp.setExpanded(self._expanded.get(key, False)) # Keep the selection across refreshes; default to the top root cause. target = self._selected if self._selected in self._findings else ( r.roots[0].finding.key if r.roots else None) self.tree.blockSignals(False) self._select(target) def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None: if key := item.data(0, _KEY): self._expanded[key] = expanded def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem: it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"]) it.setData(0, _KEY, f.key) it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333")))) it.setToolTip(0, f.title) if bold: font = it.font(0) font.setBold(True) it.setFont(0, font) self._findings[f.key] = f return it def _select(self, key: str | None) -> None: if key is None: self.detail.setHtml("

Nothing to show yet.

") return match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive) for it in match: if it.data(0, _KEY) == key: self.tree.setCurrentItem(it) return def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None: key = cur.data(0, _KEY) if cur else None f = self._findings.get(key or "") if f is None: return self._selected = key self.detail.setHtml(_detail_html(f)) def _detail_html(f: Finding) -> str: colour = SEV_COLOR.get(f.severity, "#333") actions = "".join(f"
  • {_esc(a)}
  • " for a in f.actions) seen = _esc(f.seen_text()) evidence = "
    ".join(_esc(e) for e in f.evidence) codes = "".join(f"
  • {_esc(eid)}" + (f" - {_esc(a)}" if a else "") + "
  • " for eid, a in f.error_ids.items()) return ( f"

    {_esc(f.title)}

    " f"

    What it means: {_esc(f.meaning)}

    " + (f"

    What to do:

      {actions}
    " if actions else "") + (f"

    Firmware error codes (error_ids.csv):

    " if codes else "") + f"

    {seen}

    " + (f"

    {evidence}

    " if evidence else "") )