"""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:
    " + "".join(f"
  1. {s}
  2. " for s in steps) + "
") 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"

{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)