Serial monitor redesign: Test box button, InfluxDB row in the checklist

- Test box: restart + start-up capture + online InfluxDB check (data newer
  than the restart), with a status strip; checklist and findings are the
  main view, live log / guided troubleshooting / box details are tabs,
  rarely used actions in a More menu
- light palette on all systems (macOS dark mode made the colours unreadable)
- new rule influx.invalid_value: -inf from a silent microphone makes
  InfluxDB reject every upload (HTTP 400); guide end names it
- exclusive serial port on macOS/Linux: two tool windows no longer split
  the log between them
- InfluxDB check: `since` filter, neutral wording when nothing new arrives

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
KrzyszurandClaude Opus 5.5 committed 2026-10-01 00:06:48 +02:00
1 parent 66cb804102
commit bb933ff748
13 files changed
+422 -83

No files matched your search

+55 -19
View File
@@ -1,9 +1,11 @@
"""Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED (colour + pattern + meaning when the
box reports it), firmware, the box's own self-report (KOTA lines), start-up
milestones, a findings tree (root causes; their consequences as children;
titles only) and a wrapped detail pane for the selected finding.
Layout: verdict banner, the test checklist (start-up milestones plus the
online InfluxDB check), a findings tree (root causes; their consequences as
children; titles only) and a wrapped detail pane for the selected finding.
`details` is a separate widget for the host to place: expected LED (colour +
pattern + meaning when the box reports it), firmware and the box's own
self-report (KOTA lines).
"""
from __future__ import annotations
@@ -17,6 +19,7 @@ from PySide6.QtWidgets import (
QListWidget,
QListWidgetItem,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QTextBrowser,
@@ -43,13 +46,19 @@ BANNER = {
}
_KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour)
ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"),
"running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"),
"note": ("", "#444444")}
def _heading(text: str) -> QLabel:
lbl = QLabel(text)
f = lbl.font()
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
lbl.setFont(f)
lbl.setContentsMargins(0, 6, 0, 2)
return lbl
@@ -87,7 +96,14 @@ class DiagnosticsPanel(QWidget):
self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.milestones.setWordWrap(True)
self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }")
mf = self.milestones.font()
mf.setPointSize(mf.pointSize() + 1)
self.milestones.setFont(mf)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
@@ -101,22 +117,31 @@ class DiagnosticsPanel(QWidget):
findings.setSizes([220, 260])
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0)
lay.setContentsMargins(4, 0, 4, 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(self.box_title)
lay.addWidget(self.box)
lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(_heading("Test checklist (current boot)"))
lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1)
# Box details: placed by the host (a separate tab).
inner = QWidget()
dl = QVBoxLayout(inner)
dl.addWidget(_heading("Status LED should be"))
dl.addLayout(led_row)
dl.addWidget(_heading("Firmware"))
dl.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")
dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
dl.addWidget(self.box_title)
dl.addWidget(self.box)
dl.addStretch(1)
self.details = QScrollArea()
self.details.setWidgetResizable(True)
self.details.setFrameShape(QFrame.Shape.NoFrame)
self.details.setWidget(inner)
self._findings: dict[str, Finding] = {}
self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh.
@@ -126,7 +151,8 @@ class DiagnosticsPanel(QWidget):
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
# ------------------------------------------------------------------
def show_report(self, r: Report) -> None:
def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None:
"""extra_rows: (state, text) appended to the checklist, state in ROW_STATE."""
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0:
bg, fg = "#e6e6e6", "#333333"
@@ -161,10 +187,20 @@ class DiagnosticsPanel(QWidget):
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")))
it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b")))
self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
for state, text in extra_rows:
mark, colour = ROW_STATE[state]
it = QListWidgetItem(f"{mark} {text}" if mark else text)
it.setForeground(QBrush(QColor(colour)))
if state != "note":
f = it.font()
f.setBold(True)
it.setFont(f)
it.setToolTip(text)
self.milestones.addItem(it)
height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count()))
self.milestones.setFixedHeight(min(max(height, 16), 560) + 6)
self._fill_tree(r)
+204 -47
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import datetime as dt
import os
import sys
import threading
import time
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
@@ -18,12 +21,14 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QTabWidget,
QTextBrowser,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -49,6 +54,13 @@ from .sd_tab import SdTab
from .wifi_tab import WifiTab
MAX_LINES = 20000
TEST_INFLUX_AFTER_S = 30 # Test box: start the online check this long after the restart
INFLUX_ROW = "Sensor data in InfluxDB (online check)"
# test status strip: (background, text colour)
STRIP = {"idle": ("#eef1f4", "#333333"), "running": ("#dde9f7", "#123a63"), "ok": ("#d8f0dd", "#1b5e2a"),
"info": ("#dde9f7", "#123a63"), "warning": ("#fdebc8", "#6b4400"), "error": ("#f9dedc", "#8a2019"),
"critical": ("#f6d5d1", "#7b1e14")}
CLOUD_STATE = {"ok": "ok", "sensors_missing": "bad", "silent": "warn", "never": "warn", "unavailable": "off"}
# "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
@@ -65,6 +77,10 @@ COLORS = {
}
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
@@ -90,82 +106,127 @@ class SerialTab(QWidget):
self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
# --- controls
self.port_combo = QComboBox(minimumWidth=320)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh")
self._settings = QSettings("KOTA", "TechnicianTool")
self.port_combo = QComboBox(minimumWidth=360)
self.refresh_btn = QPushButton("↻")
self.refresh_btn.setToolTip("Look for boxes again")
self.refresh_btn.setFixedWidth(40)
self.test_btn = QPushButton("▶ Test box")
self.test_btn.setToolTip("Restart the box, check its start-up and whether its sensor data reaches "
"InfluxDB (about 4 minutes)")
self.test_btn.setMinimumHeight(38)
self.test_btn.setStyleSheet(
"QPushButton { background:#1f7a3a; color:white; font-weight:bold; font-size:15px; "
"padding:4px 22px; border-radius:6px; border:none; }"
"QPushButton:hover { background:#25904a; } QPushButton:disabled { background:#9dbfa6; }")
self.connect_btn = QPushButton("Connect")
self.reset_btn = QPushButton("Reset && capture")
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
self.connect_btn.setMinimumHeight(32)
# Less frequent actions live in the "More" menu.
self.more_btn = QToolButton(text="More ▾")
self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.more_btn.setMinimumHeight(32)
menu = QMenu(self.more_btn)
self.reset_btn = menu.addAction("Reset && capture (restart the box, no online check)")
menu.addSeparator()
self.service_btn = menu.addAction("Service report...")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
"filled in; check it and press Save there")
self.copy_btn = menu.addAction("Copy report")
self.save_btn = menu.addAction("Save log view as...")
self.folder_btn = menu.addAction("Open logs folder")
self.analyze_btn = menu.addAction("Analyze log file...")
menu.addSeparator()
self.auto_service = menu.addAction("Open service report on disconnect")
self.auto_service.setCheckable(True)
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
self.all_ports = menu.addAction("Show all serial ports")
self.all_ports.setCheckable(True)
self.more_btn.setMenu(menu)
self._active_port: PortInfo | None = None
top = QHBoxLayout()
box_lbl = QLabel("<b>Box:</b>")
for w in (box_lbl, self.port_combo, self.refresh_btn):
top.addWidget(w)
top.addSpacing(12)
top.addWidget(self.test_btn)
top.addWidget(self.connect_btn)
top.addStretch(1)
top.addWidget(self.more_btn)
# Test status strip (what the test is doing / its result).
self.test_status = QLabel(wordWrap=True)
self.test_status.setMargin(8)
tf = self.test_status.font()
tf.setPointSize(tf.pointSize() + 1)
self.test_status.setFont(tf)
# --- live log (a tab on the right)
self.filter_combo = QComboBox()
self.filter_combo.addItems(LEVEL_FILTERS)
self.clear_btn = QPushButton("Clear view")
self.save_btn = QPushButton("Save view as...")
self.folder_btn = QPushButton("Open logs folder")
self.analyze_btn = QPushButton("Analyze log file...")
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
self.copy_btn = QPushButton("Copy report")
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
self.service_btn = QPushButton("Service report...")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
"filled in; check it and press Save there")
self._settings = QSettings("KOTA", "TechnicianTool")
self.auto_service = QCheckBox("Open service report on disconnect")
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
self._active_port: PortInfo | None = None
row1 = QHBoxLayout()
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
self.connect_btn, self.reset_btn):
row1.addWidget(w)
row1.addStretch(1)
row2 = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
row2.addWidget(w)
row2.addStretch(1)
self.view = QPlainTextEdit(readOnly=True)
self.view.setMaximumBlockCount(MAX_LINES)
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(10)
mono.setPointSize(12 if sys.platform == "darwin" else 10)
self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
log_page = QWidget()
ll = QVBoxLayout(log_page)
ll.setContentsMargins(0, 4, 0, 0)
lrow = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn):
lrow.addWidget(w)
lrow.addStretch(1)
ll.addLayout(lrow)
ll.addWidget(self.view, 1)
self.diag = DiagnosticsPanel()
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(log_page, "Live log")
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
self.side_tabs.addTab(self.diag.details, "Box details")
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])
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 1)
split.setSizes([560, 620])
self.status = QLabel()
self.status.setStyleSheet("color:#555")
layout = QVBoxLayout(self)
layout.addLayout(row1)
layout.addLayout(row2)
layout.addLayout(top)
layout.addWidget(self.test_status)
layout.addWidget(split, 1)
layout.addWidget(self.status)
# --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.connect(self.refresh_ports)
self.test_btn.clicked.connect(self._test_box)
self.connect_btn.clicked.connect(self._toggle_connect)
self.reset_btn.clicked.connect(self._manager.reset_board)
self.reset_btn.triggered.connect(self._manager.reset_board)
self.clear_btn.clicked.connect(self.view.clear)
self.save_btn.clicked.connect(self._save_view)
self.folder_btn.clicked.connect(self._open_folder)
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.save_btn.triggered.connect(self._save_view)
self.folder_btn.triggered.connect(self._open_folder)
self.analyze_btn.triggered.connect(self._analyze_file)
self.copy_btn.triggered.connect(self._copy_report)
self.service_btn.triggered.connect(lambda: self._open_service_report(auto=False))
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Test box state: None = no test; else monotonic start time.
self._test_t0: float | None = None
self._test_since: dt.datetime | None = None # UTC; InfluxDB data must be newer
self._influx_started = False
self._cloud: influx.InfluxCheck | None = None # last InfluxDB answer
self._cloud_checking = False
# Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400)
self._diag_timer.timeout.connect(self._refresh_diag)
@@ -221,8 +282,98 @@ class SerialTab(QWidget):
self._diag_dirty = True
self._session.start(port.mac or port.device)
self._active_port = port
self._cloud, self._cloud_checking = None, False
self._manager.connect(port)
# ---- Test box ----------------------------------------------------------
def _test_box(self) -> None:
"""Restart the box, record its start-up, then check InfluxDB online."""
if self._manager.is_running:
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
self._runner.cancel()
self._cloud, self._cloud_checking = None, False
self._manager.reset_board()
else:
port = self._selected_port() or next((p for p in self._ports if p.is_sensorbox), None)
if port is None:
QMessageBox.information(self, "Test box", "No sensorbox found. Check the USB cable (data cable) "
"and that the box's power switch is ON, then press ↻.")
return
self.connect_port(port, capture=True)
self._test_t0 = time.monotonic()
self._test_since = dt.datetime.now(dt.timezone.utc)
self._influx_started = False
self._test_end = None
self._diag_dirty = True
self._guide_log("Test box started: restarting the box and recording its start-up")
def _tick_test(self) -> None:
if self._test_t0 is None:
return
self._diag_dirty = True # countdowns in the strip and the checklist
if not self._manager.is_running:
self._finish_test(None, "Box disconnected - test stopped.")
return
elapsed = time.monotonic() - self._test_t0
if not self._influx_started and elapsed >= TEST_INFLUX_AFTER_S and self.is_connected:
self._influx_started = True
self._runner.start("influx") # polls, routes and records findings like a guided check
self.guide.refresh()
elif self._influx_started and (self._runner.end is not None or self._runner.guide is None):
self._finish_test(self._runner.end)
def _finish_test(self, end, text: str = "") -> None:
self._test_t0 = None
self._test_since = None
self._test_end = end
self._test_text = text
self._guide_log("Test box finished: " + (end.title if end else text or "stopped"))
def _cloud_rows(self) -> list[tuple[str, str]]:
in_check = self._runner.step is not None and self._runner.step.type == "influx"
if self.influx_config() is None:
return [("off", f"{INFLUX_ROW}: not set up - Settings > InfluxDB access")]
if in_check:
left = int(self._runner.view().remaining_s)
last = f" - last answer: {self._cloud.summary}" if self._cloud else ""
rows = [("running", f"{INFLUX_ROW}: checking... {left // 60}:{left % 60:02d} left{last}")]
elif self._cloud is not None:
rows = [(CLOUD_STATE.get(self._cloud.outcome, "warn"), f"{INFLUX_ROW}: {self._cloud.summary}")]
else:
when = (f"starts {TEST_INFLUX_AFTER_S} s after the restart" if self._test_t0 is not None
else "press Test box")
return [("pending", f"{INFLUX_ROW} - {when}")]
if self._cloud is not None:
rows += [("note", f" {d}") for d in self._cloud.details]
return rows
def _update_test_strip(self) -> None:
if self._test_t0 is not None:
el = int(time.monotonic() - self._test_t0)
if self._influx_started:
doing = "checking that the data reaches InfluxDB - " + (
self._runner.view().note or "waiting for the first answer")
else:
ms = self._engine.report().milestones
done = sum(t is not None for _, _, t in ms)
doing = f"recording the start-up ({done} of {len(ms)} checklist steps done)"
key, text = "running", f"<b>Test running {el // 60}:{el % 60:02d}</b> - {_esc(doing)}"
elif getattr(self, "_test_end", None) is not None:
e = self._test_end
key = e.severity
text = (f"<b>Test finished: {_esc(e.title)}</b><br>{_esc(e.text)} "
"Details: Findings below and the <i>Guided troubleshooting</i> tab.")
elif getattr(self, "_test_text", ""):
key, text = "warning", f"<b>{_esc(self._test_text)}</b>"
else:
key = "idle"
text = ("Press <b>▶ Test box</b>: the tool restarts the box, checks its start-up (sensors, SD card, "
"Wi-Fi, clock) and then whether its sensor data reaches InfluxDB. Takes about 4 minutes.")
bg, fg = STRIP[key]
self.test_status.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
self.test_status.setText(text)
def matter_payload(self) -> SetupPayload:
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
@@ -317,6 +468,7 @@ class SerialTab(QWidget):
self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED)
self.test_btn.setEnabled(state != State.WAITING)
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
@@ -376,9 +528,11 @@ class SerialTab(QWidget):
# ---- diagnostics -------------------------------------------------
def _refresh_diag(self) -> None:
self._tick_test()
if self._diag_dirty:
self._diag_dirty = False
self.diag.show_report(self._engine.report())
self.diag.show_report(self._engine.report(), self._cloud_rows())
self._update_test_strip()
self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------
@@ -392,7 +546,9 @@ class SerialTab(QWidget):
if cfg is None:
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
return False
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac)),
since = self._test_since
self._cloud_checking = True
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac, since=since)),
name="influx-check", daemon=True).start()
return True
return False
@@ -409,6 +565,7 @@ class SerialTab(QWidget):
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
def _on_influx(self, check: influx.InfluxCheck) -> None:
self._cloud, self._cloud_checking = check, False
self._runner.influx_result(check.outcome, check.summary, check.details)
self._diag_dirty = True
self.guide.refresh()