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

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
# PlatformIO # PlatformIO
.pio/ .pio/
# Python
__pycache__/
# VS Code # VS Code
.vscode/ .vscode/

View file

@ -22,13 +22,8 @@
"upload": { "upload": {
"maximum_ram_size": 16384, "maximum_ram_size": 16384,
"maximum_size": 120832, "maximum_size": 120832,
"disable_flushing": true,
"native_usb": true,
"offset": "0x2000", "offset": "0x2000",
"protocol": "sam-ba", "protocol": "custom"
"require_upload_port": true,
"use_1200bps_touch": true,
"wait_for_upload_port": true
}, },
"url": "", "url": "",
"vendor": "Custom" "vendor": "Custom"

View file

@ -6,11 +6,9 @@ lässt sich die App-Firmware ohne SWD über USB aktualisieren: Bootloader-Modus
aktivieren, Board erscheint als USB-Laufwerk `VERSABOOT`, `.uf2`-Datei drauf aktivieren, Board erscheint als USB-Laufwerk `VERSABOOT`, `.uf2`-Datei drauf
kopieren. kopieren.
Der Bootloader selbst (Flash, USB-Enumeration, Massenspeicher-Modus, Der komplette Weg — Bootloader-Einstieg, `.uf2`-Erzeugung, Kopieren aufs
Rücksprung in die App) ist auf echter Hardware verifiziert, siehe Laufwerk, automatischer Rücksprung in die neu geschriebene App — ist auf
[Hardwaretest](#hardwaretest-2026-08-05). Der App-seitige `.uf2`-Schreibweg echter Hardware verifiziert, siehe [Hardwaretest](#hardwaretest-2026-08-05).
(Firmware tatsächlich über das Laufwerk aktualisieren) ist noch nicht gebaut,
siehe [Bekannte Einschränkungen](#bekannte-einschränkungen).
Diese Platine hat keinen dedizierten Reset-/Boot-Taster. Bootloader-Modus Diese Platine hat keinen dedizierten Reset-/Boot-Taster. Bootloader-Modus
aktivieren heißt hier: unterste rechte Cherry-MX-Taste (key_id 24) beim aktivieren heißt hier: unterste rechte Cherry-MX-Taste (key_id 24) beim
@ -34,6 +32,12 @@ und das Node-/Makefile-basierte Build-System — stattdessen ein eigenständiges
PlatformIO-Environment, damit dasselbe Tooling wie für die App-Firmware PlatformIO-Environment, damit dasselbe Tooling wie für die App-Firmware
ausreicht. ausreicht.
Für die App-Seite (nicht diesen Bootloader-Build) gibt es unter
[`../uf2conv.py`](../uf2conv.py) eine eigene, minimale Python-3-Neuimplemen-
tierung des `.bin``.uf2`-Konverters (kein Upstream-Code, passendes
Blockformat zu `inc/uf2format.h`), eingebunden über
[`../upload_uf2.py`](../upload_uf2.py) als `env:versapad_usb`-Upload-Hook.
## Build ## Build
```bash ```bash
@ -73,6 +77,17 @@ Bedienung: USB-Kabel ziehen, unterste rechte Taste gedrückt halten, Kabel
wieder einstecken (Taste dabei weiter halten) → Board bootet direkt in wieder einstecken (Taste dabei weiter halten) → Board bootet direkt in
`VERSABOOT`. Ohne gehaltene Taste startet die App normal. `VERSABOOT`. Ohne gehaltene Taste startet die App normal.
## App-Firmware per USB flashen (nach dem einmaligen Bootloader-Flash)
```bash
# Board zuerst in den Bootloader-Modus versetzen: USB ziehen, unterste
# rechte Taste halten, wieder einstecken (siehe oben)
pio run -e versapad_usb --target upload
```
Baut die App-Firmware (Repo-Root, nicht `bootloader/`), erzeugt `firmware.uf2`
und kopiert es aufs `VERSABOOT`-Laufwerk. Kein Atmel-ICE mehr nötig.
## Hardwaretest (2026-08-05) ## Hardwaretest (2026-08-05)
Erster vollständiger Hardwaretest auf einem echten VersaPad-v2-Board über Erster vollständiger Hardwaretest auf einem echten VersaPad-v2-Board über
@ -94,6 +109,14 @@ Atmel-ICE/SWD. Ergebnisse:
für genau dieses Bootloader-Pattern vorgeschrieben, hat im vendorten Code für genau dieses Bootloader-Pattern vorgeschrieben, hat im vendorten Code
gefehlt. Nach dem Fix bootet die App-Firmware zuverlässig, mit und ohne gefehlt. Nach dem Fix bootet die App-Firmware zuverlässig, mit und ohne
angeschlossenen Debugger. angeschlossenen Debugger.
- **Kompletter USB-Flashweg getestet:** `pio run -e versapad_usb --target
upload` (App-Firmware, Repo-Root) baut `firmware.bin`, wandelt es über
[`../uf2conv.py`](../uf2conv.py) in `firmware.uf2` und kopiert es über
[`../upload_uf2.py`](../upload_uf2.py) automatisch aufs erkannte
`VERSABOOT`-Laufwerk. Der Bootloader erkennt den Schreibzugriff und
springt danach selbständig in die neue App — kein manuelles Auswerfen
oder Reset nötig. Voraussetzung: Board zuvor per gehaltener Taste (siehe
oben) in den Bootloader-Modus versetzt.
## Bekannte Einschränkungen ## Bekannte Einschränkungen
@ -109,11 +132,7 @@ Atmel-ICE/SWD. Ergebnisse:
(kein offiziell zugeteilter PID unter Adafruits VID); ein sauber eigener (kein offiziell zugeteilter PID unter Adafruits VID); ein sauber eigener
VID (z. B. über pid.codes) wäre die langfristig korrekte Lösung, ist aber VID (z. B. über pid.codes) wäre die langfristig korrekte Lösung, ist aber
nicht Teil dieses Branches. nicht Teil dieses Branches.
- Die App-Firmware erzeugt noch keine `.uf2`-Datei, nur `.bin`/`.elf` - [`../upload_uf2.py`](../upload_uf2.py) sucht das `VERSABOOT`-Laufwerk aktuell
(SWD-Weg). Der geplante Weg über das `VERSABOOT`-Laufwerk ist damit noch nur über die Windows-API (`GetVolumeInformationW`) — passend zur bisherigen
nicht nutzbar. Dev-Umgebung dieses Projekts, aber nicht plattformübergreifend. Für
- `env:versapad_usb` in der Haupt-`platformio.ini` verwendet macOS/Linux müsste die Laufwerkssuche noch ergänzt werden.
`upload_protocol = sam-ba` — das klassische Arduino/Atmel-SAM-BA-Protokoll,
nicht das UF2/Massenspeicher-Verfahren dieses Bootloaders. Für echtes
USB-Flashen wird stattdessen ein `.bin``.uf2`-Konvertierungsschritt plus
einfaches Kopieren aufs Laufwerk benötigt, kein spezielles Upload-Protokoll.

View file

@ -9,20 +9,23 @@ Das aktive Ziel `versapad_nobl` reserviert den kompletten Bereich
`0x1FB00..0x1FFFF` für Makros und Config. `0x1FB00..0x1FFFF` für Makros und Config.
Ein USB-Bootloader-Pfad wird im Branch `feature/usb-bootloader` aufgebaut Ein USB-Bootloader-Pfad wird im Branch `feature/usb-bootloader` aufgebaut
(`bootloader/`, App-Environment `env:versapad_usb`). Der Bootloader selbst (`bootloader/`, App-Environment `env:versapad_usb`). Der komplette Weg ist
(UF2, `bootloader/`) ist dort auf echter Hardware verifiziert — inklusive dort auf echter Hardware verifiziert: Bootloader-Flash, USB-Enumeration,
eines gefundenen und behobenen Bugs beim Sprung in die App (fehlende Tastencheck-Einstieg (kein physischer Reset-Taster auf diesem Board, siehe
`__DSB()`/`__ISB()` vor dem `bx`, siehe `bootloader/README.md`, `bootloader/README.md`, "Hardware-Bootloader-Einstieg"), App-Firmware per
"Hardwaretest"). `env:versapad_usb`s Linkerskript `.uf2` über `env:versapad_usb --target upload` schreiben, automatischer
Rücksprung in die neue App. Details und ein gefundener/behobener
Hard-Fault-Bug beim Sprung Bootloader→App (fehlende `__DSB()`/`__ISB()` vor
dem `bx`) stehen in `bootloader/README.md`, "Hardwaretest".
`env:versapad_usb`s Linkerskript
(`variants/versapad/linker_scripts/gcc/flash_with_bootloader.ld`) reserviert (`variants/versapad/linker_scripts/gcc/flash_with_bootloader.ld`) reserviert
inzwischen denselben NVM-Bereich wie `flash_without_bootloader.ld`. inzwischen denselben NVM-Bereich wie `flash_without_bootloader.ld`.
Trotzdem noch kein Produktionsziel: `env:versapad_usb` verwendet Trotzdem noch kein Merge-fertiges Produktionsziel: Der `.bin``.uf2`-Weg
`upload_protocol = sam-ba`, das klassische Arduino/Atmel-SAM-BA-Protokoll — (`uf2conv.py`, `upload_uf2.py`, Repo-Root) sucht das `VERSABOOT`-Laufwerk
das spricht der UF2-Bootloader nicht. Die App-Firmware erzeugt außerdem noch bisher nur über die Windows-API, keine macOS/Linux-Unterstützung. Die
keine `.uf2`-Datei. Vor einem Merge nach `master` fehlen also noch die Bootloader-USB-PID (`0x0043`) ist zwar kollisionsfrei verifiziert, aber kein
`.uf2`-Erzeugung und ein Ende-zu-Ende-Test des tatsächlichen USB-Flashwegs offiziell registrierter Wert unter Adafruits VID `0x239A`.
(Datei aufs `VERSABOOT`-Laufwerk kopieren).
Die aktive Boarddatei benennt die MCU als `samd21g17d`, setzt für den Die aktive Boarddatei benennt die MCU als `samd21g17d`, setzt für den
Arduino-Core aber weiterhin das Kompatibilitätsmakro `__SAMD21G18A__`. Der Arduino-Core aber weiterhin das Kompatibilitätsmakro `__SAMD21G18A__`. Der

View file

@ -22,8 +22,11 @@ upload_protocol = custom
extra_scripts = upload_openocd.py extra_scripts = upload_openocd.py
debug_tool = openocd debug_tool = openocd
; ── USB SAM-BA (nur wenn Bootloader geflasht ist) ───────────────────────────── ; ── USB UF2 (nur wenn bootloader/ geflasht ist) ────────────────────────────────
; Der eigene Bootloader spricht UF2/Massenspeicher, kein SAM-BA. upload_uf2.py
; erzeugt aus firmware.bin ein .uf2 und kopiert es aufs VERSABOOT-Laufwerk.
[env:versapad_usb] [env:versapad_usb]
extends = common extends = common
board = versapad board = versapad
upload_protocol = sam-ba upload_protocol = custom
extra_scripts = upload_uf2.py

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()

72
upload_uf2.py Normal file
View file

@ -0,0 +1,72 @@
"""PlatformIO upload hook for env:versapad_usb.
The board's actual bootloader (bootloader/, UF2/mass-storage) does not speak
SAM-BA, so upload_protocol=sam-ba (the board.json default, inherited from an
Arduino-Zero-style template) does not work here. This converts the built
.bin to .uf2 and copies it onto the VERSABOOT mass-storage volume instead --
Windows only for now, matching the rest of this project's dev environment.
"""
import ctypes
import os
import string
import sys
Import("env")
sys.path.insert(0, env.subst("$PROJECT_DIR"))
import uf2conv
VOLUME_LABEL = "VERSABOOT"
DRIVE_UNKNOWN = 0
DRIVE_NO_ROOT_DIR = 1
def find_versaboot_drive(label=VOLUME_LABEL):
for letter in string.ascii_uppercase:
root = f"{letter}:\\"
drive_type = ctypes.windll.kernel32.GetDriveTypeW(root)
if drive_type in (DRIVE_UNKNOWN, DRIVE_NO_ROOT_DIR):
continue
vol_name_buf = ctypes.create_unicode_buffer(261)
fs_name_buf = ctypes.create_unicode_buffer(261)
ok = ctypes.windll.kernel32.GetVolumeInformationW(
ctypes.c_wchar_p(root),
vol_name_buf,
ctypes.sizeof(vol_name_buf),
None,
None,
None,
fs_name_buf,
ctypes.sizeof(fs_name_buf),
)
if ok and vol_name_buf.value == label:
return root
return None
def upload_via_uf2(source, target, env):
build_dir = env.subst("$BUILD_DIR")
bin_path = os.path.join(build_dir, "firmware.bin")
uf2_path = os.path.join(build_dir, "firmware.uf2")
if not os.path.isfile(bin_path):
print(f"error: {bin_path} not found (expected as a normal build product)")
env.Exit(1)
num_blocks = uf2conv.convert(bin_path, uf2_path, base_addr=0x2000, family_id=uf2conv.SAMD21_FAMILY_ID)
print(f"Wrote {uf2_path}: {num_blocks} blocks")
drive = find_versaboot_drive()
if drive is None:
print(f"error: no drive labeled '{VOLUME_LABEL}' found.")
print("Hold the bottom-right Cherry MX key while plugging in USB to enter the bootloader.")
env.Exit(1)
dest = os.path.join(drive, "firmware.uf2")
print(f"Copying {uf2_path} -> {dest}")
with open(uf2_path, "rb") as src, open(dest, "wb") as dst:
dst.write(src.read())
print("Upload triggers a reset into the app on the device side; no further action needed.")
env.Replace(UPLOADCMD=upload_via_uf2)