Guide, README, quick start and flashing hints no longer say 'no hub' or 'directly to the laptop'; the requirement is a USB 2.0 type-A plug between box and computer (USB-C only is the failing case). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
17 KiB
Python
365 lines
17 KiB
Python
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
|
|
chosen by their compile date; 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, QUrl, Signal
|
|
from PySide6.QtGui import QDesktopServices
|
|
from PySide6.QtWidgets import (
|
|
QButtonGroup,
|
|
QCheckBox,
|
|
QComboBox,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMessageBox,
|
|
QPlainTextEdit,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QRadioButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ..core.session import SESSIONS_DIR
|
|
from ..diag.firmware import local_build, register_local_build, same_build
|
|
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_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 cable with a USB 2.0 type-A plug on the way, 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 - only files in the tool's firmware/ folder
|
|
fwbox = QGroupBox("2. Firmware (by compile date)")
|
|
self.image_combo = QComboBox(minimumWidth=360)
|
|
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
|
|
self.reload_btn = QPushButton("Refresh list")
|
|
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
|
|
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
|
|
self.folder_btn = QPushButton("Open firmware folder")
|
|
self.folder_btn.clicked.connect(
|
|
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
|
|
self.image_info = QLabel(wordWrap=True)
|
|
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
fl = QVBoxLayout(fwbox)
|
|
fl.addWidget(self.image_combo)
|
|
brow = QHBoxLayout()
|
|
brow.addWidget(self.reload_btn)
|
|
brow.addWidget(self.folder_btn)
|
|
brow.addStretch(1)
|
|
fl.addLayout(brow)
|
|
fl.addWidget(self.image_info)
|
|
self._problems: list[str] = []
|
|
|
|
# 3. mode
|
|
modebox = QGroupBox("3. What to write")
|
|
self.rb_app = QRadioButton("App only (recommended for repairs)")
|
|
self.rb_full = QRadioButton("Full 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, select_sha: str | None = None) -> None:
|
|
self._images, self._problems = folder_images()
|
|
for img in self._images: # lets the Serial monitor show a box build's compile date
|
|
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
|
|
self.image_combo.blockSignals(True)
|
|
self.image_combo.clear()
|
|
for img in self._images:
|
|
self.image_combo.addItem(img.label())
|
|
if not self._images:
|
|
self.image_combo.addItem("No firmware file in the firmware folder")
|
|
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
|
|
self.image_combo.setCurrentIndex(idx) # default: newest compile date
|
|
self.image_combo.blockSignals(False)
|
|
self._update()
|
|
|
|
def _selected_sha(self) -> str | None:
|
|
img = self._selected_image()
|
|
return img.sha256 if img else None
|
|
|
|
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()
|
|
info = ""
|
|
if img:
|
|
a = img.app
|
|
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
|
info = (f"<b>Compiled {a.built}</b> · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
|
|
f"<small>file firmware/{img.name} · build {a.elf_sha[:8]} · "
|
|
f"SHA-256 {img.sha256[:16]}... · checksum OK</small>")
|
|
else:
|
|
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
|
|
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
|
|
if self._problems:
|
|
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
|
|
+ "; ".join(self._problems) + "</span>")
|
|
self.image_info.setText(info)
|
|
# An app-only file has no bootloader/layout: Full mode is impossible.
|
|
full_ok = bool(img and img.kind == "merged")
|
|
self.rb_full.setEnabled(full_ok and self._runner is None)
|
|
if not full_ok and self.rb_full.isChecked():
|
|
self.rb_app.setChecked(True)
|
|
self.box_fw.setText(self._box_text(img))
|
|
|
|
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 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.")
|
|
|
|
def _box_text(self, img: FirmwareImage | None) -> str:
|
|
fw = self.serial_tab.engine.firmware
|
|
if not fw.elf_sha:
|
|
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
|
|
msg = f"Box currently runs: {fw.describe()}"
|
|
if not img:
|
|
return msg
|
|
if same_build(fw.elf_sha, img.app.elf_sha):
|
|
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
|
local = local_build(fw.elf_sha)
|
|
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), img.app.built_at
|
|
if box_built and img_built and img_built > box_built:
|
|
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
|
|
if box_built and img_built and img_built < box_built:
|
|
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
|
|
"probably updated over OTA). The box updates itself again once online.</b>")
|
|
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
|
|
|
|
# ------------------------------------------------------------ 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 image (pairing and OTA registration are erased)")
|
|
if QMessageBox.question(self, "Flash firmware",
|
|
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
|
|
f"File: firmware/{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.backup, self.confirm_full,
|
|
self.reload_btn):
|
|
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[:8]
|
|
done = same_build(fw.elf_sha, self._image.app.elf_sha)
|
|
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[:8] 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)
|
|
|
|
|
|
def _parse_built(s: str | None) -> dt.datetime | None:
|
|
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
|
|
if not s:
|
|
return None
|
|
try:
|
|
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
|
|
except ValueError:
|
|
return None
|