Technician tool v0.4

Imported from the Google Drive folder 004-technician_tool/v0.4.
This commit is contained in:
kkallas committed 2026-09-26 17:26:00 +02:00
1 parent e161941672
commit 72adfe1cec
19 files changed
+1818 -602

No files matched your search

+118
View File
@@ -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."