Technician tool v0.4
Imported from the Google Drive folder 004-technician_tool/v0.4.
This commit is contained in:
1 parent
e161941672
commit
72adfe1cec
19 files changed
+1818
-602
No files matched your search
@@ -0,0 +1,118 @@
|
||||
"""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."
|
||||
Reference in new issue
Block a user