Technician tool v0.1

Imported from the Google Drive folder 004-technician_tool/v0.1.
This commit is contained in:
kkallas committed 2026-09-26 16:11:07 +02:00
commit e5a33a1c34
28 files changed
+2988

No files matched your search

View File
Whitespace-only changes.
+83
View File
@@ -0,0 +1,83 @@
"""Quick-start cheat sheet shown on launch.
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard"
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a
live board). That build has no Wi-Fi setup portal / SSID-on-SD support;
those exist only in the newer, unbuilt source. The status LED is always
SOLID; the separate small LED toggles every second while alive.
"""
# (swatch colour, LED name, meaning, first action)
LED_TABLE = [
("#2e9e44", "Green", "Everything OK.", "Nothing to do."),
("#8e44ad", "Purple",
"No Internet / InfluxDB or OTA server unreachable.",
"Check the box is commissioned in the Matter app (sticker QR code) and the "
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
("#d64541", "Red",
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
"or failed self-check after update.",
"Connect serial - the log names the failing sensor. Check sensor cables."),
("#2f6fd6", "Blue",
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
"so a broken I2C cable also shows blue.",
"Check the I2C harness first, then the RTC module / coin cell."),
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."),
("#f4f4f4", "White",
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is "
"in progress (Matter button held 5 s).",
"If nobody pressed the Matter button: the SD card is inside the case - "
"unscrew, re-seat or re-provision it."),
("#444444", "Off", "Firmware did not start (or crashed very early).",
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
]
HINTS = [
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. "
"Connect serial and press <i>Reset &amp; capture</i>: the last lines before the "
"reboot name the cause.",
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
"firmware runs. Not blinking = firmware hung or not running.",
"<b>Outside the case</b>: power switch and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"Normally not needed - the tool resets the board over USB.",
"<b>Gathering messages for the first ~2 minutes</b> are normal "
"(the air-quality sensor warms up for 120 s).",
]
STEPS = [
"Switch the sensorbox <b>power switch ON</b>.",
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
"Note the RGB LED colour and compare with the table below.",
]
def cheatsheet_html() -> str:
rows = "".join(
f"<tr><td style='padding:6px'><span style='background:{c};"
f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>"
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
for c, name, meaning, action in LED_TABLE
)
steps = "".join(f"<li>{s}</li>" for s in STEPS)
hints = "".join(f"<li>{h}</li>" for h in HINTS)
return f"""
<h2>Sensorbox quick start</h2>
<ol>{steps}</ol>
<h3>RGB status LED (always solid colour)</h3>
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
<tr><th align='left' style='padding:6px'>LED</th>
<th align='left' style='padding:6px'>Most likely meaning</th>
<th align='left' style='padding:6px'>First action</th></tr>
{rows}
</table>
<h3>Good to know</h3>
<ul>{hints}</ul>
"""
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QPlainTextEdit,
QPushButton,
QTabWidget,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from .. import __version__
from ..core.logline import LineKind, LogLine
from ..core.session import SessionLog
from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports
from .cheatsheet import cheatsheet_html
MAX_LINES = 20000
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
COLORS = {
"E": "#ff6b6b",
"W": "#f5c451",
"I": "#d4d4d4",
LineKind.BOOT: "#6fc3ff",
LineKind.RESET: "#6fc3ff",
LineKind.PANIC: "#ff4dd2",
LineKind.HOST: "#7ee07e",
LineKind.RAW: "#a8a8a8",
}
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
line = Signal(object)
state = Signal(object, str)
class SerialTab(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._bridge = _Bridge()
self._bridge.line.connect(self._on_line)
self._bridge.state.connect(self._on_state)
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._session = SessionLog()
self._ports: list[PortInfo] = []
self._counts = {"E": 0, "W": 0, "boots": 0}
# --- controls
self.port_combo = QComboBox(minimumWidth=320)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh")
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.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")
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):
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)
self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
self.status = QLabel()
layout = QVBoxLayout(self)
layout.addLayout(row1)
layout.addLayout(row2)
layout.addWidget(self.view, 1)
layout.addWidget(self.status)
# --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.connect(self.refresh_ports)
self.connect_btn.clicked.connect(self._toggle_connect)
self.reset_btn.clicked.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)
# Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000)
self._poll.timeout.connect(self._auto_refresh)
self._poll.start()
self.refresh_ports()
self._on_state(State.DISCONNECTED, "")
# ---- ports -------------------------------------------------------
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
idx = self.port_combo.findData(current)
self.port_combo.setCurrentIndex(max(idx, 0))
def _auto_refresh(self) -> None:
if self._manager.is_running:
return
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
if devices != [p.device for p in self._ports]:
self.refresh_ports()
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
# ---- connection --------------------------------------------------
def _toggle_connect(self) -> None:
if self._manager.is_running:
# Session file is closed in _on_state(DISCONNECTED), after the
# reader thread's final queued lines have been written.
self._manager.disconnect()
return
port = self._selected_port()
if port is None:
return
self._counts = {"E": 0, "W": 0, "boots": 0}
self._session.start(port.mac or port.device)
self._manager.connect(port)
def _on_state(self, state: State, detail: str) -> None:
running = state != State.DISCONNECTED
if not running:
self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED)
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
self._update_status()
def _update_status(self) -> None:
sess = f" | Session log: {self._session.path}" if self._session.path else ""
c = self._counts
self.status.setText(
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
f"errors: {c['E']} warnings: {c['W']}{sess}"
)
# ---- lines -------------------------------------------------------
def _on_line(self, line: LogLine) -> None:
self._session.write(line)
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
if line.level in ("E", "W"):
self._counts[line.level] += 1
self._update_status()
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
if allowed is not None and line.kind != LineKind.HOST and not (
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET)
):
return
self._append(line)
def _append(self, line: LogLine) -> None:
sb = self.view.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4
fmt = QTextCharFormat()
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST):
fmt.setFontWeight(QFont.Weight.Bold)
cur = QTextCursor(self.view.document())
cur.movePosition(QTextCursor.MoveOperation.End)
if not self.view.document().isEmpty():
cur.insertBlock()
cur.insertText(line.text, fmt)
if at_bottom:
sb.setValue(sb.maximum())
# ---- misc --------------------------------------------------------
def _save_view(self) -> None:
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
if path:
with open(path, "w", encoding="utf-8") as fh:
fh.write(self.view.toPlainText())
def _open_folder(self) -> None:
self._session.root.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
def shutdown(self) -> None:
self._manager.disconnect()
self._session.stop()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
self.resize(1200, 800)
tabs = QTabWidget()
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab()
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor")
self.setCentralWidget(tabs)
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)