"""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 = (
"If flashing cannot connect: 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 BOOT, "
"press and release RESET, release BOOT, 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. Erases the Matter pairing and the OTA "
"registration - 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"Compiled {a.built} · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}
"
f"file firmware/{img.name} · build {a.elf_sha[:8]} · "
f"SHA-256 {img.sha256[:16]}... · checksum OK")
else:
info = (f"Put the firmware .bin file from the firmware team into{FIRMWARE_DIR}
"
"and press Refresh list. Only files in this folder can be flashed.")
if self._problems:
info += ("
Not offered: "
+ "; ".join(self._problems) + "")
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("Plan:
{res.hint}
" if res.hint else "" self._show_result(f"Flashing failed. {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"Success. 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"Written, but not verified. 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