"""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 = (
"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
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. 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) -> None:
try:
self._images = official_images()
except ImageError as exc:
self._images = []
self.image_info.setText(f"Firmware file problem: {exc}")
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}
"
f"build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:12]}... verified
"
f"{img.notes}")
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 += "
It already has this firmware. Re-flash only if you suspect the firmware is damaged."
else:
msg += ("
The box runs a different build (probably updated over OTA). "
"Flashing puts the older factory image back; the box updates itself again once online.")
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("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[: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"Success. 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"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)