Add end-to-end USB flashing for the app firmware via UF2

Adds uf2conv.py (minimal, dependency-free .bin -> .uf2 converter
matching bootloader/inc/uf2format.h's block layout) and upload_uf2.py,
a PlatformIO upload hook for env:versapad_usb that finds the mounted
VERSABOOT volume and copies the converted firmware onto it.

env:versapad_usb previously used upload_protocol=sam-ba, the classic
Arduino/Atmel protocol -- the actual bootloader speaks UF2/mass
storage, not SAM-BA, so that upload path never worked. Switched to
upload_protocol=custom with the new hook, and cleaned the now-unused
SAM-BA-specific fields out of boards/versapad.json.

Verified end to end on real hardware: pio run -e versapad_usb
--target upload builds, converts, copies to the VERSABOOT drive, and
the bootloader jumps into the freshly written app on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-05 21:35:19 +02:00
parent f60a29137c
commit d325297063
7 changed files with 210 additions and 32 deletions

83
uf2conv.py Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Minimal .bin -> .uf2 converter for the VersaMCU UF2 bootloader.
Standalone reimplementation of the block format microsoft/uf2-samdx1's
uf2conv.py produces (256-byte payload per 512-byte block); see
bootloader/inc/uf2format.h for the struct this has to match on the device
side. No external dependencies, Python 3 only.
Usage:
python uf2conv.py <input.bin> <output.uf2> [--base 0x2000] [--family 0x68ed2b88]
"""
import argparse
import struct
UF2_MAGIC_START0 = 0x0A324655 # "UF2\n"
UF2_MAGIC_START1 = 0x9E5D5157 # randomly selected, must match the device
UF2_MAGIC_END = 0x0AB16F30 # ditto
UF2_FLAG_FAMILYID_PRESENT = 0x00002000
SAMD21_FAMILY_ID = 0x68ED2B88 # bootloader/inc/uf2format.h, #ifdef SAMD21
PAYLOAD_SIZE = 256 # bytes per block; matches the upstream uf2conv.py convention
def convert(bin_path: str, uf2_path: str, base_addr: int, family_id: int) -> int:
with open(bin_path, "rb") as f:
data = f.read()
# Pad to a whole number of blocks; the bootloader writes payloadSize
# bytes per block regardless of how much of it is real firmware.
if len(data) % PAYLOAD_SIZE != 0:
data += b"\x00" * (PAYLOAD_SIZE - len(data) % PAYLOAD_SIZE)
num_blocks = len(data) // PAYLOAD_SIZE
blocks = []
for block_no in range(num_blocks):
offset = block_no * PAYLOAD_SIZE
chunk = data[offset : offset + PAYLOAD_SIZE]
header = struct.pack(
"<IIIIIIII",
UF2_MAGIC_START0,
UF2_MAGIC_START1,
UF2_FLAG_FAMILYID_PRESENT,
base_addr + offset,
PAYLOAD_SIZE,
block_no,
num_blocks,
family_id,
)
padding = b"\x00" * (476 - PAYLOAD_SIZE)
footer = struct.pack("<I", UF2_MAGIC_END)
blocks.append(header + chunk + padding + footer)
with open(uf2_path, "wb") as f:
f.write(b"".join(blocks))
return num_blocks
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", help="Path to the raw firmware .bin")
parser.add_argument("output", help="Path to write the .uf2 to")
parser.add_argument(
"--base",
type=lambda s: int(s, 0),
default=0x2000,
help="Flash base address the .bin was linked for (default: 0x2000, matches "
"variants/versapad/linker_scripts/gcc/flash_with_bootloader.ld)",
)
parser.add_argument(
"--family",
type=lambda s: int(s, 0),
default=SAMD21_FAMILY_ID,
help="UF2 family ID (default: SAMD21, 0x68ed2b88)",
)
args = parser.parse_args()
num_blocks = convert(args.input, args.output, args.base, args.family)
print(f"Wrote {args.output}: {num_blocks} blocks, base 0x{args.base:08x}")
if __name__ == "__main__":
main()