"""Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED, firmware, start-up milestones, a
findings tree (root causes; their consequences as children; titles only)
and a wrapped detail pane for the selected finding.
"""
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,
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
def _heading(text: str) -> QLabel:
lbl = QLabel(text)
f = lbl.font()
f.setBold(True)
lbl.setFont(f)
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.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
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, 0, 0)
lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be"))
lay.addLayout(led_row)
lay.addWidget(_heading("Firmware"))
lay.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")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1)
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) -> None:
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"
reason = f"
from: {_esc(r.led_reason)}" if r.led_reason else ""
self.led_text.setText(f"{name}{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.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 "#8a8a8a")))
self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 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"What it means: {_esc(f.meaning)}
" + (f"What to do:
{seen}
" + (f"{evidence}
" if evidence else "") )