"""Runs a flash plan: one esptool subprocess per step, in a background thread. A subprocess (instead of calling esptool in-process) keeps esptool's stdout/sys.exit handling away from the GUI and lets the tool read progress line by line. Callbacks are invoked from the worker thread. """ from __future__ import annotations import re import subprocess import sys import threading from collections.abc import Callable from dataclasses import dataclass from .plan import Plan, check_partition_table _PROGRESS = re.compile(r"\((\d{1,3}) ?%\)") # esptool output -> technician-facing hint HINTS = [ (re.compile(r"could not open port|Access is denied|PermissionError|Resource busy", re.I), "The USB port is in use. Close other programs that use the sensorbox port (serial monitors, Arduino IDE) and try again."), (re.compile(r"Failed to connect to ESP32-S3|No serial data received|Wrong boot mode", re.I), "The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, " "release BOOT, then press Start again."), (re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I), "The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."), (re.compile(r"Invalid head of packet|timed out", re.I), "Communication errors. Try another USB cable or port."), ] @dataclass class Result: ok: bool message: str hint: str = "" failed_step: int | None = None class FlashRunner: def __init__(self, plan: Plan, on_step: Callable[[int, str], None], on_progress: Callable[[int], None], on_output: Callable[[str], None], on_done: Callable[[Result], None], esptool_cmd: list[str] | None = None): self.plan = plan self._on_step, self._on_progress = on_step, on_progress self._on_output, self._on_done = on_output, on_done self._cmd = esptool_cmd or [sys.executable, "-m", "esptool"] self._thread: threading.Thread | None = None def start(self) -> None: self._thread = threading.Thread(target=self._run, name="flash", daemon=True) self._thread.start() def join(self, timeout: float | None = None) -> None: if self._thread: self._thread.join(timeout) def _run(self) -> None: try: self._on_done(self._execute()) except Exception as exc: # never leave the UI waiting self._on_done(Result(False, f"Internal error: {exc}")) def _execute(self) -> Result: for i, step in enumerate(self.plan.steps): self._on_step(i, step.title) self._on_progress(0) code, output = self._esptool(step.args) if code != 0: return Result(False, f"Step {i + 1} failed: {step.title}", _hint(output), i) if step.kind == "read_pt": problem = check_partition_table(step.output.read_bytes(), self.plan.image) if problem: return Result(False, problem, failed_step=i) self._on_progress(100) return Result(True, "Firmware written and verified by esptool.") def _esptool(self, args: list[str]) -> tuple[int, str]: cmd = self._cmd + args self._on_output("$ esptool " + " ".join(args)) flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, creationflags=flags) collected: list[str] = [] buf = b"" assert proc.stdout is not None while chunk := proc.stdout.read1(4096): # returns as soon as bytes arrive buf += chunk # esptool redraws progress with '\r'; treat it as a line break. *lines, buf = re.split(rb"[\r\n]", buf) for raw in lines: self._line(raw, collected) if buf: self._line(buf, collected) return proc.wait(), "\n".join(collected) def _line(self, raw: bytes, collected: list[str]) -> None: text = raw.decode("utf-8", "replace").strip() if not text: return collected.append(text) if m := _PROGRESS.search(text): self._on_progress(min(int(m.group(1)), 100)) return # progress lines are not worth listing one by one self._on_output(text) def _hint(output: str) -> str: for pattern, hint in HINTS: if pattern.search(output): return hint return "See the esptool output below. Save it and send it to the firmware team if the problem repeats."