Technician tool v0.4

Imported from the Google Drive folder 004-technician_tool/v0.4.
This commit is contained in:
kkallas committed 2026-09-26 17:26:00 +02:00
1 parent e161941672
commit 72adfe1cec
19 files changed
+1818 -602

No files matched your search

+2 -1
View File
@@ -40,7 +40,8 @@ HINTS = [
"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 "
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"(off = no noise measurements), 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. "
+309
View File
@@ -0,0 +1,309 @@
"""Flash firmware tab: official image only, App-only by default."""
from __future__ import annotations
import datetime as dt
import tempfile
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ..core.session import SESSIONS_DIR
from ..flash.image import FirmwareImage, ImageError, official_images
from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000
HELP = (
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
class _Bridge(QObject):
step = Signal(int, str)
progress = Signal(int)
output = Signal(str)
done = Signal(object)
class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent)
self.serial_tab = serial_tab
self._images: list[FirmwareImage] = []
self._ports: list[PortInfo] = [] # before any signal can call _update()
self._runner: FlashRunner | None = None
self._port: PortInfo | None = None
self._image: FirmwareImage | None = None
# 1. box
box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360)
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh_ports)
self.box_fw = QLabel(wordWrap=True)
bl = QVBoxLayout(box)
row = QHBoxLayout()
row.addWidget(self.port_combo)
row.addWidget(refresh)
row.addStretch(1)
bl.addLayout(row)
bl.addWidget(self.box_fw)
# 2. firmware
fwbox = QGroupBox("2. Firmware (official images only)")
self.image_combo = QComboBox(minimumWidth=360)
self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo)
fl.addWidget(self.image_info)
# 3. mode
modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)")
self.mode_group = QButtonGroup(self)
self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full)
self.rb_app.setChecked(True)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"settings stored on the box.", wordWrap=True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
for n in (app_note, full_note):
n.setContentsMargins(24, 0, 0, 6)
n.setStyleSheet("color:#555")
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
ml.addWidget(w)
# 4. run
runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True)
self.start_btn = QPushButton("Start flashing")
self.start_btn.setMinimumHeight(36)
self.start_btn.clicked.connect(self._start)
self.step_label = QLabel()
self.bar = QProgressBar()
self.result = QLabel(wordWrap=True)
self.result.setMargin(8)
self.result.setTextFormat(Qt.TextFormat.RichText)
self.details_btn = QPushButton("Show esptool output")
self.details_btn.setCheckable(True)
self.output = QPlainTextEdit(readOnly=True)
self.output.setVisible(False)
self.details_btn.toggled.connect(self.output.setVisible)
rl = QVBoxLayout(runbox)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
rl.addWidget(w)
help_lbl = QLabel(HELP, wordWrap=True)
help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl)
rl.addStretch(1) # keep the run column packed at the top
left = QVBoxLayout()
for w in (box, fwbox, modebox):
left.addWidget(w)
left.addStretch(1)
lay = QHBoxLayout(self)
lay.addLayout(left, 1)
lay.addWidget(runbox, 1)
self._bridge = _Bridge()
self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue)
self._bridge.output.connect(self.output.appendPlainText)
self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500)
self._verify_timer.timeout.connect(self._check_verify)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update)
self._load_images()
self.refresh_ports()
self._show_result("", None)
# ------------------------------------------------------------ setup
def _load_images(self) -> None:
try:
self._images = official_images()
except ImageError as exc:
self._images = []
self.image_info.setText(f"<span style='color:#b03a2e'><b>Firmware file problem:</b> {exc}</span>")
self.image_combo.clear()
for img in self._images:
self.image_combo.addItem(img.name)
if not self._images:
self.image_combo.addItem("No official firmware image found in the firmware folder")
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports()
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)
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
self._update()
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
self.refresh_ports()
super().showEvent(event)
# ------------------------------------------------------------ state
def _selected_image(self) -> FirmwareImage | None:
i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
def _update(self) -> None:
img = self._selected_image()
if img:
a = img.app
self.image_info.setText(
f"{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:12]}... verified</small><br>"
f"<small>{img.notes}</small>")
fw = self.serial_tab.engine.firmware
if fw.elf_sha and img:
same = fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha)
msg = f"Box currently runs: {fw.describe()}"
if same:
msg += "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
else:
msg += ("<br><b style='color:#c77c02'>The box runs a different build (probably updated over OTA). "
"Flashing puts the older factory image back; the box updates itself again once online.</b>")
else:
msg = "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
self.box_fw.setText(msg)
self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
self.start_btn.setEnabled(ok)
if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else:
steps += ["Write the complete factory image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"]
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else:
self.plan_label.setText("Select a sensorbox and a firmware image.")
# ------------------------------------------------------------ flash
def _start(self) -> None:
img, port = self._selected_image(), self._selected_port()
if not img or not port:
return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE factory image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok:
return
backup_to = None
if self.backup.isChecked():
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
self._port, self._image = port, img
self.serial_tab.release_port() # esptool needs the port exclusively
self.output.clear()
self.output.appendPlainText("\n".join(plan.summary()))
self._show_result("", None)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit)
self._set_busy(True)
self._runner.start()
def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.rb_full, self.backup, self.confirm_full):
w.setEnabled(not busy)
self._update()
def _on_step(self, i: int, title: str) -> None:
n = len(self._runner.plan.steps) if self._runner else 0
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
def _on_done(self, res: Result) -> None:
self._runner = None
if not res.ok:
self._set_busy(False)
self.step_label.setText("Stopped.")
hint = f"<p>{res.hint}</p>" if res.hint else ""
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True)
return
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.bar.setValue(0)
self._show_result("Firmware written. Waiting for the box to start...", "info")
self.serial_tab.connect_port(self._port, capture=True)
self._verify_ms = 0
self._verify_timer.start()
def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:9]
done = fw.elf_sha is not None and fw.elf_sha.startswith(want)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop()
self._set_busy(False)
self.bar.setValue(100)
if done:
self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok")
else:
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
+35 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtCore import QCoreApplication, QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import (
QCheckBox,
@@ -30,6 +30,7 @@ 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 .flash_tab import FlashTab
from .guide_panel import GuidePanel
MAX_LINES = 20000
@@ -180,6 +181,9 @@ class SerialTab(QWidget):
port = self._selected_port()
if port is None:
return
self._connect(port)
def _connect(self, port: PortInfo) -> None:
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
self._runner.cancel()
@@ -187,8 +191,36 @@ class SerialTab(QWidget):
self._session.start(port.mac or port.device)
self._manager.connect(port)
# ---- hand-off with the flash tab ------------------------------------
@property
def engine(self) -> DiagnosticsEngine:
return self._engine
def release_port(self) -> None:
"""Stop monitoring so esptool can open the port exclusively."""
if self._manager.is_running:
self._manager.disconnect() # joins the reader thread
QCoreApplication.processEvents() # deliver its queued final lines/state now
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
"""Monitor `port`; with capture, restart the box once connected to record its boot."""
self.release_port()
idx = self.port_combo.findData(port.device)
if idx < 0:
self.port_combo.addItem(port.label, port.device)
self._ports.append(port)
idx = self.port_combo.count() - 1
self.port_combo.setCurrentIndex(idx)
self._reset_on_connect = capture
self._connect(port)
def _on_state(self, state: State, detail: str) -> None:
running = state != State.DISCONNECTED
if not running and self._manager.is_running:
return # stale event from a session that was already replaced
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._reset_on_connect = False
QTimer.singleShot(300, self._manager.reset_board)
if not running and self._session.path is not None:
self._session.write_report(self._engine.report().to_text())
self._session.stop()
@@ -315,8 +347,10 @@ class MainWindow(QMainWindow):
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab()
self.flash_tab = FlashTab(self.serial_tab)
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor")
tabs.addTab(self.flash_tab, "Flash firmware")
self.setCentralWidget(tabs)
def closeEvent(self, event): # noqa: N802 (Qt naming)