Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
This commit is contained in:
1 parent
f5b32e1554
commit
22bd853ecf
30 files changed
+1521
-568
No files matched your search
+234
-230
@@ -1,230 +1,234 @@
|
||||
"""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,
|
||||
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)
|
||||
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>"
|
||||
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}</b>{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.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 = f"Seen {f.count}x"
|
||||
if f.first_uptime_ms is not None:
|
||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
||||
if f.resolved and f.resolved_by:
|
||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
||||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||||
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 style='color:#666'>{seen}</p>"
|
||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
if evidence else "")
|
||||
)
|
||||
"""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 " - <i>check it matches the box</i>"
|
||||
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}</b>{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.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 = f"Seen {f.count}x"
|
||||
if f.first_uptime_ms is not None:
|
||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
||||
if f.resolved and f.resolved_by:
|
||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
||||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||||
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 style='color:#666'>{seen}</p>"
|
||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
if evidence else "")
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@@ -25,7 +26,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, factory_images
|
||||
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images
|
||||
from ..flash.plan import Mode, build_plan
|
||||
from ..flash.runner import FlashRunner, Result
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
@@ -168,12 +169,14 @@ class FlashTab(QWidget):
|
||||
def _load_images(self, select_sha: str | None = None) -> None:
|
||||
self._problems = []
|
||||
try:
|
||||
factory = factory_images()
|
||||
bundled = bundled_images()
|
||||
except ImageError as exc:
|
||||
factory = []
|
||||
self._problems.append(f"factory image: {exc}")
|
||||
bundled = []
|
||||
self._problems.append(f"bundled image: {exc}")
|
||||
factory = [i for i in bundled if not i.is_developer]
|
||||
self._reference = factory[0] if factory else None
|
||||
dev, problems = self._library.images(self._reference)
|
||||
dev += [i for i in bundled if i.is_developer]
|
||||
self._problems += problems
|
||||
# Newest developer builds first, then the factory image.
|
||||
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
|
||||
@@ -196,8 +199,12 @@ class FlashTab(QWidget):
|
||||
"Firmware (*.bin);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
built_by, ok = QInputDialog.getText(self, "Who built this firmware?",
|
||||
"Name of the developer who built this image (shown next to it):")
|
||||
if not ok:
|
||||
return
|
||||
try:
|
||||
img = self._library.import_file(Path(path), self._reference)
|
||||
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
|
||||
except (ImageError, OSError) as exc:
|
||||
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
|
||||
return
|
||||
|
||||
+225
-216
@@ -1,216 +1,225 @@
|
||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
"info": ("#2e6da4", "#e3edf7"),
|
||||
"warning": ("#c77c02", "#fdf0d6"),
|
||||
"error": ("#d9534f", "#f9e0df"),
|
||||
"critical": ("#b03a2e", "#f6d5d1"),
|
||||
}
|
||||
|
||||
|
||||
def _swatch(key: str, size: int = 22) -> QIcon:
|
||||
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(QColor(0, 0, 0, 0))
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if key == "CYCLING":
|
||||
g = QLinearGradient(0, 0, size, 0)
|
||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||
g.setColorAt(stop, QColor(c))
|
||||
p.setBrush(QBrush(g))
|
||||
else:
|
||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||
p.setPen(QColor("#777777"))
|
||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class GuidePanel(QWidget):
|
||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
self.picker = QWidget()
|
||||
pl = QVBoxLayout(self.picker)
|
||||
pl.setContentsMargins(0, 0, 0, 0)
|
||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||
self.guide_list = QListWidget()
|
||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||
pl.addWidget(self.guide_list, 1)
|
||||
self.start_btn = QPushButton("Start selected check")
|
||||
self.start_btn.clicked.connect(self._start_selected)
|
||||
pl.addWidget(self.start_btn)
|
||||
|
||||
# --- running guide
|
||||
self.card = QFrame()
|
||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
cl = QVBoxLayout(self.card)
|
||||
self.guide_title = QLabel()
|
||||
self.guide_title.setStyleSheet("color:#666")
|
||||
self.step_text = QLabel(wordWrap=True)
|
||||
f = self.step_text.font()
|
||||
f.setPointSize(f.pointSize() + 2)
|
||||
self.step_text.setFont(f)
|
||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.options = QVBoxLayout()
|
||||
self.wait_bar = QProgressBar()
|
||||
self.wait_bar.setTextVisible(False)
|
||||
self.wait_label = QLabel()
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setStyleSheet("color:#b03a2e")
|
||||
self.continue_btn = QPushButton("Continue")
|
||||
self.continue_btn.clicked.connect(self._proceed)
|
||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||
self.skip_btn.clicked.connect(self._proceed)
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.end_box.setMargin(10)
|
||||
for w in (self.guide_title, self.step_text):
|
||||
cl.addWidget(w)
|
||||
cl.addLayout(self.options)
|
||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||
cl.addWidget(w)
|
||||
cl.addStretch(1)
|
||||
nav = QHBoxLayout()
|
||||
self.back_btn = QPushButton("← Back")
|
||||
self.back_btn.clicked.connect(self._back)
|
||||
self.done_btn = QPushButton("Back to checks list")
|
||||
self.done_btn.clicked.connect(self._cancel)
|
||||
nav.addWidget(self.back_btn)
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.done_btn)
|
||||
cl.addLayout(nav)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.picker, 1)
|
||||
lay.addWidget(self.card, 1)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _start_selected(self) -> None:
|
||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||
if it:
|
||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
def _start(self, guide_id: str) -> None:
|
||||
self.runner.start(guide_id)
|
||||
self.refresh()
|
||||
|
||||
def _answer(self, key: str) -> None:
|
||||
self.runner.answer(key)
|
||||
self.refresh()
|
||||
|
||||
def _proceed(self) -> None:
|
||||
self.runner.proceed()
|
||||
self.refresh()
|
||||
|
||||
def _back(self) -> None:
|
||||
self.runner.back()
|
||||
self.refresh()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.runner.cancel()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
def refresh(self) -> None:
|
||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||
self.runner.tick()
|
||||
v = self.runner.view()
|
||||
running = v.guide is not None
|
||||
self.picker.setVisible(not running)
|
||||
self.card.setVisible(running)
|
||||
if not running:
|
||||
self._fill_picker()
|
||||
self._shown_key = None
|
||||
return
|
||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||
if key != self._shown_key:
|
||||
self._shown_key = key
|
||||
self._render_step(v)
|
||||
if v.waiting:
|
||||
total = v.step.timeout_s
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
|
||||
def _fill_picker(self) -> None:
|
||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||
if [w[0] for w in wanted] == current:
|
||||
return
|
||||
self.guide_list.clear()
|
||||
for gid, title, summary in wanted:
|
||||
it = QListWidgetItem(f"{title}\n {summary}")
|
||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||
self.guide_list.addItem(it)
|
||||
self.guide_list.setCurrentRow(0)
|
||||
|
||||
def _render_step(self, v) -> None:
|
||||
self.guide_title.setText(v.guide.title)
|
||||
while self.options.count():
|
||||
w = self.options.takeAt(0).widget()
|
||||
if w:
|
||||
# Detach now; deleteLater alone leaves it visible until the
|
||||
# event loop processes the deferred delete.
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
is_end = v.end is not None
|
||||
self.step_text.setVisible(not is_end)
|
||||
self.step_text.setText(_esc(v.text))
|
||||
for opt_key, label in v.options:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
if v.step and v.step.type == "led":
|
||||
b.setIcon(_swatch(opt_key))
|
||||
b.setIconSize(QSize(22, 22))
|
||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
self.wait_bar.setVisible(step_type == "wait")
|
||||
self.wait_label.setVisible(step_type == "wait")
|
||||
self.end_box.setVisible(is_end)
|
||||
if is_end:
|
||||
e = v.end
|
||||
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
||||
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
||||
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
||||
+ (f"<ol>{acts}</ol>" if acts else ""))
|
||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
from ..matter.payload import SetupPayload
|
||||
from .matter_qr import MatterQrWidget
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
"info": ("#2e6da4", "#e3edf7"),
|
||||
"warning": ("#c77c02", "#fdf0d6"),
|
||||
"error": ("#d9534f", "#f9e0df"),
|
||||
"critical": ("#b03a2e", "#f6d5d1"),
|
||||
}
|
||||
|
||||
|
||||
def _swatch(key: str, size: int = 22) -> QIcon:
|
||||
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(QColor(0, 0, 0, 0))
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if key == "CYCLING":
|
||||
g = QLinearGradient(0, 0, size, 0)
|
||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||
g.setColorAt(stop, QColor(c))
|
||||
p.setBrush(QBrush(g))
|
||||
else:
|
||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||
p.setPen(QColor("#777777"))
|
||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class GuidePanel(QWidget):
|
||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
|
||||
matter_payload=None):
|
||||
super().__init__(parent)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
self.picker = QWidget()
|
||||
pl = QVBoxLayout(self.picker)
|
||||
pl.setContentsMargins(0, 0, 0, 0)
|
||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||
self.guide_list = QListWidget()
|
||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||
pl.addWidget(self.guide_list, 1)
|
||||
self.start_btn = QPushButton("Start selected check")
|
||||
self.start_btn.clicked.connect(self._start_selected)
|
||||
pl.addWidget(self.start_btn)
|
||||
|
||||
# --- running guide
|
||||
self.card = QFrame()
|
||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
cl = QVBoxLayout(self.card)
|
||||
self.guide_title = QLabel()
|
||||
self.guide_title.setStyleSheet("color:#666")
|
||||
self.step_text = QLabel(wordWrap=True)
|
||||
f = self.step_text.font()
|
||||
f.setPointSize(f.pointSize() + 2)
|
||||
self.step_text.setFont(f)
|
||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.options = QVBoxLayout()
|
||||
self.wait_bar = QProgressBar()
|
||||
self.wait_bar.setTextVisible(False)
|
||||
self.wait_label = QLabel()
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setStyleSheet("color:#b03a2e")
|
||||
self.continue_btn = QPushButton("Continue")
|
||||
self.continue_btn.clicked.connect(self._proceed)
|
||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||
self.skip_btn.clicked.connect(self._proceed)
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.end_box.setMargin(10)
|
||||
self.qr = MatterQrWidget(SetupPayload(), 220)
|
||||
for w in (self.guide_title, self.step_text, self.qr):
|
||||
cl.addWidget(w)
|
||||
cl.addLayout(self.options)
|
||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||
cl.addWidget(w)
|
||||
cl.addStretch(1)
|
||||
nav = QHBoxLayout()
|
||||
self.back_btn = QPushButton("← Back")
|
||||
self.back_btn.clicked.connect(self._back)
|
||||
self.done_btn = QPushButton("Back to checks list")
|
||||
self.done_btn.clicked.connect(self._cancel)
|
||||
nav.addWidget(self.back_btn)
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.done_btn)
|
||||
cl.addLayout(nav)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.picker, 1)
|
||||
lay.addWidget(self.card, 1)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _start_selected(self) -> None:
|
||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||
if it:
|
||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
def _start(self, guide_id: str) -> None:
|
||||
self.runner.start(guide_id)
|
||||
self.refresh()
|
||||
|
||||
def _answer(self, key: str) -> None:
|
||||
self.runner.answer(key)
|
||||
self.refresh()
|
||||
|
||||
def _proceed(self) -> None:
|
||||
self.runner.proceed()
|
||||
self.refresh()
|
||||
|
||||
def _back(self) -> None:
|
||||
self.runner.back()
|
||||
self.refresh()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.runner.cancel()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
def refresh(self) -> None:
|
||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||
self.runner.tick()
|
||||
v = self.runner.view()
|
||||
running = v.guide is not None
|
||||
self.picker.setVisible(not running)
|
||||
self.card.setVisible(running)
|
||||
if not running:
|
||||
self._fill_picker()
|
||||
self._shown_key = None
|
||||
return
|
||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||
if key != self._shown_key:
|
||||
self._shown_key = key
|
||||
self._render_step(v)
|
||||
if v.waiting:
|
||||
total = v.step.timeout_s
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
|
||||
def _fill_picker(self) -> None:
|
||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||
if [w[0] for w in wanted] == current:
|
||||
return
|
||||
self.guide_list.clear()
|
||||
for gid, title, summary in wanted:
|
||||
it = QListWidgetItem(f"{title}\n {summary}")
|
||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||
self.guide_list.addItem(it)
|
||||
self.guide_list.setCurrentRow(0)
|
||||
|
||||
def _render_step(self, v) -> None:
|
||||
self.guide_title.setText(v.guide.title)
|
||||
while self.options.count():
|
||||
w = self.options.takeAt(0).widget()
|
||||
if w:
|
||||
# Detach now; deleteLater alone leaves it visible until the
|
||||
# event loop processes the deferred delete.
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
is_end = v.end is not None
|
||||
self.step_text.setVisible(not is_end)
|
||||
self.step_text.setText(_esc(v.text))
|
||||
show_qr = bool(v.step and v.step.show_qr)
|
||||
if show_qr:
|
||||
self.qr.set_payload(self._matter_payload())
|
||||
self.qr.setVisible(show_qr)
|
||||
for opt_key, label in v.options:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
if v.step and v.step.type == "led":
|
||||
b.setIcon(_swatch(opt_key))
|
||||
b.setIconSize(QSize(22, 22))
|
||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
self.wait_bar.setVisible(step_type == "wait")
|
||||
self.wait_label.setVisible(step_type == "wait")
|
||||
self.end_box.setVisible(is_end)
|
||||
if is_end:
|
||||
e = v.end
|
||||
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
||||
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
||||
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
||||
+ (f"<ol>{acts}</ol>" if acts else ""))
|
||||
@@ -30,10 +30,12 @@ from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from ..matter.payload import SetupPayload, payload_from_log
|
||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .flash_tab import FlashTab
|
||||
from .matter_qr import MatterQrDialog
|
||||
from .guide_panel import GuidePanel
|
||||
from .sd_tab import SdTab
|
||||
|
||||
@@ -118,7 +120,7 @@ class SerialTab(QWidget):
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine)
|
||||
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
||||
self.side_tabs = QTabWidget()
|
||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
@@ -147,6 +149,7 @@ class SerialTab(QWidget):
|
||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||
self.copy_btn.clicked.connect(self._copy_report)
|
||||
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
||||
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||
|
||||
# Report refresh is throttled; lines can arrive at >100/s.
|
||||
self._diag_timer = QTimer(self, interval=400)
|
||||
@@ -205,6 +208,10 @@ class SerialTab(QWidget):
|
||||
self._active_port = port
|
||||
self._manager.connect(port)
|
||||
|
||||
def matter_payload(self) -> SetupPayload:
|
||||
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise."""
|
||||
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [])
|
||||
|
||||
# ---- service system -------------------------------------------------
|
||||
def _service_base(self) -> str:
|
||||
return str(self._settings.value("service/base", DEFAULT_BASE))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import segno
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ..matter.payload import SetupPayload
|
||||
|
||||
|
||||
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
|
||||
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
|
||||
qr = segno.make(text, error="m")
|
||||
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
|
||||
n = len(matrix)
|
||||
module = max(1, size // n)
|
||||
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
|
||||
img.fill(QColor("white"))
|
||||
p = QPainter(img)
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(QColor("black"))
|
||||
for y, row in enumerate(matrix):
|
||||
for x, dark in enumerate(row):
|
||||
if dark:
|
||||
p.drawRect(x * module, y * module, module, module)
|
||||
p.end()
|
||||
return QPixmap.fromImage(img)
|
||||
|
||||
|
||||
class MatterQrWidget(QWidget):
|
||||
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.image = QLabel()
|
||||
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.code = QLabel()
|
||||
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
f = self.code.font()
|
||||
f.setPointSize(f.pointSize() + 4)
|
||||
f.setBold(True)
|
||||
self.code.setFont(f)
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.note.setStyleSheet("color:#555")
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(self.image)
|
||||
lay.addWidget(self.code)
|
||||
lay.addWidget(self.note)
|
||||
self._size = size
|
||||
self.set_payload(payload)
|
||||
|
||||
def set_payload(self, payload: SetupPayload) -> None:
|
||||
self.payload = payload
|
||||
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
|
||||
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
|
||||
src = ("from this box's log" if payload.from_log else
|
||||
"standard code of the v1.0 firmware - connect the box to confirm")
|
||||
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
|
||||
f"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.")
|
||||
|
||||
|
||||
class MatterQrDialog(QDialog):
|
||||
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Matter pairing code")
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(MatterQrWidget(payload, 320))
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
lay.addWidget(buttons)
|
||||
+24
-7
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, QSettings, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
@@ -34,6 +33,7 @@ DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" /
|
||||
|
||||
class _Bridge(QObject):
|
||||
done = Signal(object)
|
||||
listed = Signal(object)
|
||||
|
||||
|
||||
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
|
||||
@@ -52,6 +52,7 @@ class SdTab(QWidget):
|
||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||
self._cards: list[Disk] = []
|
||||
self._busy = False
|
||||
self._listing = False
|
||||
|
||||
# --- 1. settings
|
||||
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
|
||||
@@ -135,6 +136,7 @@ class SdTab(QWidget):
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.done.connect(self._on_done)
|
||||
self._bridge.listed.connect(self._on_listed)
|
||||
for w in (self.url, self.token, self.ssid, self.password):
|
||||
w.textChanged.connect(self._update)
|
||||
self.wifi_box.toggled.connect(self._update)
|
||||
@@ -199,13 +201,28 @@ class SdTab(QWidget):
|
||||
super().showEvent(event)
|
||||
|
||||
def refresh_cards(self) -> None:
|
||||
if self._busy:
|
||||
"""List cards in the background: the UI never waits for a slow card reader."""
|
||||
if self._busy or self._listing:
|
||||
return
|
||||
self._listing = True
|
||||
self.others.setText("Looking for SD cards...")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
result = list_cards()
|
||||
except Exception as exc: # show it, never leave the tab stuck
|
||||
result = ([], [], str(exc))
|
||||
self._bridge.listed.emit(result)
|
||||
|
||||
threading.Thread(target=worker, daemon=True, name="sd-list").start()
|
||||
|
||||
def _on_listed(self, result) -> None:
|
||||
self._listing = False
|
||||
self._cards, others = result[0], result[1]
|
||||
if len(result) > 2:
|
||||
self.others.setText(f"Could not list cards: {result[2]}")
|
||||
self._update()
|
||||
return
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
self._cards, others = list_cards()
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self.card_list.clear()
|
||||
for d in self._cards:
|
||||
self.card_list.addItem(QListWidgetItem(d.label))
|
||||
|
||||
Reference in new issue
Block a user