diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 7ec361e..b7dd2bd 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -296,6 +296,36 @@ Flash without rebuild to the next board: Supported flashers: `esp32` (esptool), `rp2` / `samd` (UF2; BOOTSEL first). +### Flashing over UF2 + +`rp2` and `samd` take the UF2 path automatically. `--uf2` forces it for any +port, which is what reaches a board whose UF2 bootloader is not implied by its +MicroPython port — an ESP32-S3 carrying tinyuf2, most usefully: + +```bash +./scripts/mpftp bootloader -d COM7 # or double-tap reset / hold BOOTSEL +./scripts/mpftp firmware flash --port esp32 --uf2 --artifact build/firmware.uf2 +``` + +The artifact must be a `.uf2`; the command refuses a `.bin` rather than copying +something the bootloader will ignore. + +**The copy is not the proof.** A UF2 flash has two failure modes that both look +exactly like success from the host: a copy that reports fine while writing +nothing, and a bootloader that silently skips every block whose family ID it +does not own. So the engine validates the file first (magic, block count against +the header, family IDs), verifies the byte count it wrote, and then waits for +the bootloader volume to **unmount** — which only happens once the board has +accepted a complete image and rebooted into it. A volume still mounted after +`--uf2-timeout` seconds (default 30) is reported as a failure naming the image's +family, because a family mismatch is the usual cause. + +Volumes are found by `INFO_UF2.TXT`, not by label, since labels differ per family +(`RPI-RP2`, `FTHRS3BOOT`, …). If more than one is mounted the command stops and +asks for `--device` rather than guessing which board to overwrite. On WSL a +removable drive is usually *not* mounted under `/mnt`, so discovery also asks +Windows for drive letters; `--device 'D:'` works there too. + ### ESP32 partition autosize If an esp32 build fails because the app image is larger than the `factory` (or diff --git a/package.json b/package.json index 66d625d..8a3e70c 100644 --- a/package.json +++ b/package.json @@ -296,7 +296,7 @@ "watch": "tsc -watch -p ./", "lint": "tsc -p ./ --noEmit", "package": "npx --yes @vscode/vsce package --no-dependencies", - "test:python": "python3 -m unittest discover -s python/tests -v" + "test:python": "PYTHONPATH=python python3 -m unittest discover -s python/tests -v" }, "repository": { "type": "git", diff --git a/python/firmware_engine.py b/python/firmware_engine.py index 0284b9e..81bc167 100644 --- a/python/firmware_engine.py +++ b/python/firmware_engine.py @@ -39,6 +39,8 @@ import sys import time +import uf2 + def _no_window_kwargs() -> dict: """Avoid flashing a blank console on Windows when spawning esptool/make.""" @@ -577,6 +579,11 @@ def resolve_build_toolchains( # Tree / port model # --------------------------------------------------------------------------- # +# How long to wait for a UF2 bootloader volume to unmount after the copy. +# Generous because the board erases and writes flash before it reboots, and a +# false timeout here reports failure on a flash that worked. +UF2_REBOOT_TIMEOUT = 30.0 + # Ports we can flash (others are build-only in the UI). FLASHERS = { "esp32": "esptool", @@ -1429,82 +1436,176 @@ def flash_esp32(ns: argparse.Namespace, mp: Optional[Path], artifact: Path) -> N log_activity("firmware_flash", f"esp32 {ns.board} -> {ns.device}", {"offset": offset}) -def _find_uf2_drive() -> Optional[str]: - """Find a mounted UF2 bootloader drive (RPI-RP2, etc.).""" - roots: list[Path] = [] - if HOST in ("wsl", "linux"): - roots += [Path("/media"), Path("/mnt"), Path("/run/media")] - if HOST == "windows": - for letter in "DEFGHIJKLMNOP": - roots.append(Path(f"{letter}:/")) - checked: list[Path] = [] - for root in roots: - try: - if root.name.endswith(":") or str(root).endswith(":/"): - checked.append(root) - elif root.is_dir(): - for sub in root.iterdir(): - if sub.is_dir(): - # one more level (/media//