New guided check "Sensor data in the database (InfluxDB)", also the last step of the Quick check on the green path. It queries InfluxDB (read token) for the newest point per measurement of the box and tells apart: sensor data arrives / status arrives without sensor data / offline now / never seen / check not possible. Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that sensor readings are lost (stamped 1 Jan 2000): box A4:CB:8F:C9:47:3C sent only device_status and microphone. - service/influx.py: query, CSV parsing, classification (no new deps) - guides: new step type "influx" (host answers via influx_result) - Settings > InfluxDB access (or KOTA_INFLUX_TOKEN); token never logged - service status mapping for tech.influx.* findings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
8.7 KiB
Python
228 lines
8.7 KiB
Python
"""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)
|
|
doing = "Checking InfluxDB" if v.step.type == "influx" else "Watching the live log"
|
|
self.wait_label.setText(f"{doing}... {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")
|
|
waits = step_type in ("wait", "influx")
|
|
self.skip_btn.setVisible(waits)
|
|
self.wait_bar.setVisible(waits)
|
|
self.wait_label.setVisible(waits)
|
|
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 ""))
|