From e24aa41e256f06bc04bdf473acbca6b5b2c498a2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:17:06 -0500 Subject: [PATCH] Verify UF2 flashes instead of assuming the copy worked `firmware flash` already copied a .uf2 onto a bootloader drive for rp2 and samd, but it reported success whenever shutil.copyfile did not raise. That is not evidence of anything. A UF2 flash has two failure modes that look identical to success from the host side: - a copy that reports fine and writes nothing (PowerShell's Copy-Item fails non-terminatingly, exit 0, no file), and - a bootloader that silently skips every block whose family ID it does not own, leaving a perfectly successful copy sitting on the drive. So the copy is no longer the proof. The proof is that the volume unmounts: a UF2 bootloader reboots into the new firmware once it has accepted a complete image, and the mount goes with it. That signal comes from the board rather than from the host filesystem. A volume still mounted after --uf2-timeout (default 30s) is now a failure that names the image's family, since a family mismatch is the usual cause. Before copying, the new uf2 module validates the file: block magic, payload bounds, and the block count against the header -- a file that disagrees with its own numBlocks makes a bootloader wait forever rather than flash. Family IDs are reported, not enforced: the upstream registry and CircuitPython's espressif Makefile disagree about 0x540ddf62 (ESP32-C6 vs esp32p4), so a hard check would reject working images. --uf2 forces this path for any port, which is what reaches a board whose UF2 bootloader is not implied by its MicroPython port -- an ESP32-S3 carrying tinyuf2 being the case that matters. Volume discovery moves to INFO_UF2.TXT rather than labels, which differ per family, and asks Windows for drive letters so it works under WSL, where a removable drive is usually not mounted under /mnt at all. Two mounted volumes now stop the command instead of picking one, because guessing overwrites the firmware on a board the caller did not name. test:python needed PYTHONPATH to import the engine at all; three test files were erroring out before this. Co-Authored-By: Claude Opus 5 --- docs/agent-guide.md | 30 +++ package.json | 2 +- python/firmware_engine.py | 230 +++++++++++++----- python/mpftp_cli.py | 8 + python/tests/test_uf2.py | 221 +++++++++++++++++ python/tests/test_uf2_flash.py | 201 ++++++++++++++++ python/uf2.py | 428 +++++++++++++++++++++++++++++++++ 7 files changed, 1061 insertions(+), 59 deletions(-) create mode 100644 python/tests/test_uf2.py create mode 100644 python/tests/test_uf2_flash.py create mode 100644 python/uf2.py 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//