Some files had been saved with CRLF on Windows, so a one-line change showed as a whole-file diff. .gitattributes now stores text as LF (.bat stays CRLF for cmd, .command/.sh LF for macOS). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""Diagnostics side panel: renders a diag.engine.Report.
|
|
|
|
Layout: verdict banner, expected LED (colour + pattern + meaning when the
|
|
box reports it), firmware, the box's own self-report (KOTA lines), 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
|
|
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
|
|
|
|
|
|
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.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.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(self.box_title)
|
|
lay.addWidget(self.box)
|
|
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 " - <i>check it matches the box</i>"
|
|
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
|
|
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
|
|
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
|
|
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
|
self.led_text.setText(f"<b>{name}{pattern}</b>{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())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
|
|
|
self.box_title.setVisible(bool(r.box))
|
|
self.box.setVisible(bool(r.box))
|
|
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
|
|
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b> </td>"
|
|
f"<td><b>{_esc(topic)}</b> </td><td>{_esc(text)}</td></tr>"
|
|
for topic, text, st in r.box) + "</table>")
|
|
|
|
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("<p style='color:#666'>Nothing to show yet.</p>")
|
|
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"<li>{_esc(a)}</li>" for a in f.actions)
|
|
seen = _esc(f.seen_text())
|
|
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
|
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
|
|
for eid, a in f.error_ids.items())
|
|
return (
|
|
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
|
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
|
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
|
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
|
|
+ f"<p style='color:#666'>{seen}</p>"
|
|
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
|
if evidence else "")
|
|
)
|