Technician tool v0.3
Imported from the Google Drive folder 004-technician_tool/v0.3.
This commit is contained in:
1 parent
e8b72b743d
commit
e161941672
13 files changed
+2280
-573
No files matched your search
@@ -55,7 +55,8 @@ STEPS = [
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||
"Note the RGB LED colour and compare with the table below.",
|
||||
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
||||
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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 ""))
|
||||
@@ -22,13 +22,15 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from ..core.logline import LineKind, LogLine, host_event
|
||||
from ..core.session import SessionLog
|
||||
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 .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .guide_panel import GuidePanel
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
@@ -63,6 +65,7 @@ class SerialTab(QWidget):
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
@@ -102,9 +105,13 @@ class SerialTab(QWidget):
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine)
|
||||
self.side_tabs = QTabWidget()
|
||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.diag)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
@@ -175,6 +182,7 @@ class SerialTab(QWidget):
|
||||
return
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
@@ -206,6 +214,7 @@ class SerialTab(QWidget):
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._runner.feed(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
@@ -240,6 +249,21 @@ class SerialTab(QWidget):
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
self.diag.show_report(self._engine.report())
|
||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||
|
||||
# ---- guided troubleshooting hooks ------------------------------------
|
||||
def _guide_action(self, action: str) -> bool:
|
||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||
self._manager.reset_board()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _guide_log(self, text: str) -> None:
|
||||
# Technician answers go into the session log and the log view.
|
||||
line = host_event(text)
|
||||
self._session.write(line)
|
||||
self._append(line)
|
||||
self._diag_dirty = True
|
||||
|
||||
def _copy_report(self) -> None:
|
||||
QGuiApplication.clipboard().setText(self._engine.report().to_text())
|
||||
@@ -252,6 +276,7 @@ class SerialTab(QWidget):
|
||||
lines = read_log_file(Path(path))
|
||||
self.view.clear()
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
for line in lines:
|
||||
self._engine.feed(line)
|
||||
|
||||
Reference in new issue
Block a user