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>
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
"""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 data-capable cable with a USB 2.0 type-A plug on the way to the laptop (not USB-C only) 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."
|