Technician tool v0.6

Imported from the Google Drive folder 004-technician_tool/v0.6.
This commit is contained in:
kkallas committed 2026-09-28 17:31:03 +02:00
1 parent f5b32e1554
commit 22bd853ecf
30 files changed
+1521 -568

No files matched your search

+225 -216
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 ""))