Files
kota-technician-tool/kota_tech/ui/guide_panel.py
T
KrzyszurandClaude Opus 5.5 66cb804102 Checklist: verify that sensor data reaches InfluxDB
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>
2026-10-01 00:06:48 +02:00

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("&", "&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)
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 ""))