Skip to content

Commit 32ced87

Browse files
committed
tools/flash/dfu: Flatten to drop DFU file roundtrip.
In Pybricks Code, we are flashing bin files directly. Due to the particular quirks of the hubs we deal with, it is most straightforward to do so here as well. We only made the roundtrip because the flashing tools were based on upstream MicroPython.
1 parent 00acc78 commit 32ced87

4 files changed

Lines changed: 293 additions & 973 deletions

File tree

tools/flash/dfu.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2013/2014 Ibrahim Abdelkader <i.abdalkader@gmail.com>
3+
# Copyright (c) 2019-2026 The Pybricks Authors
4+
5+
# The USB DFU protocol implementation in this file was originally copied from
6+
# the OpenMV project (micropython/tools/pydfu.py) and is licensed under the MIT
7+
# license. Rewritten to skip the DFU format roundtrip.
8+
9+
"""Flashes firmware to a LEGO hub over USB using the STM32 DFU protocol.
10+
"""
11+
12+
import errno
13+
import platform
14+
import struct
15+
import sys
16+
from collections.abc import Callable
17+
from typing import NamedTuple
18+
19+
import usb.core
20+
import usb.util
21+
from usb.core import USBError
22+
from tqdm.auto import tqdm
23+
from tqdm.contrib.logging import logging_redirect_tqdm
24+
25+
from lwp3.bytecodes import HubKind
26+
from firmware import AnyFirmwareMetadata
27+
from constants import (
28+
LEGO_USB_VID,
29+
MINDSTORMS_INVENTOR_DFU_USB_PID,
30+
SPIKE_ESSENTIAL_DFU_USB_PID,
31+
SPIKE_PRIME_DFU_USB_PID,
32+
)
33+
34+
BOOTLOADER_SIZE_32K = 32 * 1024
35+
BOOTLOADER_SIZE_64K = 64 * 1024
36+
FLASH_BASE_ADDRESS = 0x08000000
37+
FLASH_SIZE = 1 * 1024 * 1024
38+
39+
# Maps each known DFU USB product ID to the hub it belongs to.
40+
ALL_PIDS = {
41+
MINDSTORMS_INVENTOR_DFU_USB_PID: HubKind.TECHNIC_LARGE,
42+
SPIKE_ESSENTIAL_DFU_USB_PID: HubKind.TECHNIC_SMALL,
43+
SPIKE_PRIME_DFU_USB_PID: HubKind.TECHNIC_LARGE,
44+
}
45+
ALL_DEVICES = [f"{LEGO_USB_VID:04x}:{pid:04x}" for pid in ALL_PIDS.keys()]
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# Low-level STM32 DFU protocol
50+
# ---------------------------------------------------------------------------
51+
52+
# USB control transfer timeout in milliseconds.
53+
_TIMEOUT = 4000
54+
55+
# DFU class requests (AN3156).
56+
_DFU_DNLOAD = 1
57+
_DFU_GETSTATUS = 3
58+
_DFU_CLRSTATUS = 4
59+
_DFU_ABORT = 6
60+
61+
# DFU states returned by GETSTATUS.
62+
_DFU_STATE_DFU_IDLE = 0x02
63+
_DFU_STATE_DFU_DOWNLOAD_BUSY = 0x04
64+
_DFU_STATE_DFU_DOWNLOAD_IDLE = 0x05
65+
_DFU_STATE_DFU_MANIFEST = 0x07
66+
_DFU_STATE_DFU_UPLOAD_IDLE = 0x09
67+
68+
_DFU_DESCRIPTOR_TYPE = 0x21
69+
_DFU_INTERFACE = 0
70+
71+
72+
class CfgDescriptor(NamedTuple):
73+
bLength: int
74+
bDescriptorType: int
75+
bmAttributes: int
76+
wDetachTimeOut: int
77+
wTransferSize: int
78+
bcdDFUVersion: int
79+
80+
CFG_DESCRIPTOR_FORMAT = "<BBBHHH"
81+
82+
def find_dfu_cfg_descriptor(descr: bytes) -> CfgDescriptor | None:
83+
if len(descr) == 9 and descr[0] == 9 and descr[1] == _DFU_DESCRIPTOR_TYPE:
84+
return CfgDescriptor(*struct.unpack(CFG_DESCRIPTOR_FORMAT, bytearray(descr)))
85+
return None
86+
87+
def get_dfu_devices(**kwargs) -> list[usb.core.Device]:
88+
"""Returns the list of connected USB devices that are in DFU mode."""
89+
90+
def is_dfu_device(device: usb.core.Device) -> bool:
91+
"""Returns True if the USB device is in DFU mode."""
92+
for cfg in device:
93+
for intf in cfg:
94+
return intf.bInterfaceClass == 0xFE and intf.bInterfaceSubClass == 1
95+
return False
96+
97+
return list(usb.core.find(find_all=True, custom_match=is_dfu_device, **kwargs))
98+
99+
100+
class DfuDevice:
101+
"""A handle to a single STM32 device in DFU mode."""
102+
103+
def __init__(self, device: usb.core.Device) -> None:
104+
self._dev = device
105+
device.set_configuration()
106+
usb.util.claim_interface(device, _DFU_INTERFACE)
107+
108+
# Find the DFU configuration descriptor to learn the transfer size.
109+
self._cfg_descr = None
110+
for cfg in device.configurations():
111+
self._cfg_descr = find_dfu_cfg_descriptor(cfg.extra_descriptors)
112+
if self._cfg_descr:
113+
break
114+
for itf in cfg.interfaces():
115+
self._cfg_descr = find_dfu_cfg_descriptor(itf.extra_descriptors)
116+
if self._cfg_descr:
117+
break
118+
119+
# Get the device into a known idle state.
120+
for _ in range(4):
121+
status = self._get_status()
122+
if status == _DFU_STATE_DFU_IDLE:
123+
break
124+
elif status in (
125+
_DFU_STATE_DFU_DOWNLOAD_IDLE,
126+
_DFU_STATE_DFU_UPLOAD_IDLE,
127+
):
128+
self._dev.ctrl_transfer(
129+
0x21, _DFU_ABORT, 0, _DFU_INTERFACE, None, _TIMEOUT
130+
)
131+
else:
132+
self._dev.ctrl_transfer(
133+
0x21, _DFU_CLRSTATUS, 0, _DFU_INTERFACE, None, _TIMEOUT
134+
)
135+
136+
def _get_status(self) -> int:
137+
stat = self._dev.ctrl_transfer(
138+
0xA1, _DFU_GETSTATUS, 0, _DFU_INTERFACE, 6, 20000
139+
)
140+
# Firmware can provide an optional string for any error.
141+
if stat[5]:
142+
message = usb.util.get_string(self._dev, stat[5])
143+
if message:
144+
print(message)
145+
return stat[4]
146+
147+
def _check_status(self, stage: str, expected: int) -> None:
148+
status = self._get_status()
149+
if status != expected:
150+
raise SystemExit(f"DFU: {stage} failed (state {status})")
151+
152+
def mass_erase(self) -> None:
153+
"""Erases the entire device."""
154+
self._dev.ctrl_transfer(0x21, _DFU_DNLOAD, 0, _DFU_INTERFACE, b"\x41", _TIMEOUT)
155+
self._check_status("erase", _DFU_STATE_DFU_DOWNLOAD_BUSY)
156+
self._check_status("erase", _DFU_STATE_DFU_DOWNLOAD_IDLE)
157+
158+
def _set_address(self, addr: int) -> None:
159+
buf = struct.pack("<BI", 0x21, addr)
160+
self._dev.ctrl_transfer(0x21, _DFU_DNLOAD, 0, _DFU_INTERFACE, buf, _TIMEOUT)
161+
self._check_status("set address", _DFU_STATE_DFU_DOWNLOAD_BUSY)
162+
self._check_status("set address", _DFU_STATE_DFU_DOWNLOAD_IDLE)
163+
164+
def write_memory(
165+
self,
166+
addr: int,
167+
data: bytes,
168+
progress: Callable[[int], None] | None = None,
169+
) -> None:
170+
"""Writes ``data`` to flash starting at ``addr``.
171+
172+
Assumes the target memory has already been erased. ``progress`` is
173+
called with the number of bytes written after each chunk.
174+
"""
175+
total = len(data)
176+
written = 0
177+
while written < total:
178+
self._set_address(addr + written)
179+
180+
chunk = min(self._cfg_descr.wTransferSize, total - written)
181+
self._dev.ctrl_transfer(
182+
0x21,
183+
_DFU_DNLOAD,
184+
2,
185+
_DFU_INTERFACE,
186+
data[written : written + chunk],
187+
_TIMEOUT,
188+
)
189+
self._check_status("write memory", _DFU_STATE_DFU_DOWNLOAD_BUSY)
190+
self._check_status("write memory", _DFU_STATE_DFU_DOWNLOAD_IDLE)
191+
192+
written += chunk
193+
if progress:
194+
progress(chunk)
195+
196+
def exit(self) -> None:
197+
"""Exits DFU mode and starts running the firmware."""
198+
self._set_address(FLASH_BASE_ADDRESS)
199+
# Zero-length download triggers the manifestation/reset.
200+
self._dev.ctrl_transfer(0x21, _DFU_DNLOAD, 0, _DFU_INTERFACE, None, _TIMEOUT)
201+
try:
202+
if self._get_status() != _DFU_STATE_DFU_MANIFEST:
203+
print("Failed to reset device")
204+
usb.util.dispose_resources(self._dev)
205+
except OSError:
206+
pass
207+
208+
209+
# ---------------------------------------------------------------------------
210+
# High-level firmware flashing
211+
# ---------------------------------------------------------------------------
212+
213+
def _get_bootloader_size(pid: int, bcd_device: int | None) -> int:
214+
"""Gets the bootloader size for the connected DFU device."""
215+
# New hardware revision of SPIKE Prime released in 2026 has a larger bootloader.
216+
if pid == SPIKE_PRIME_DFU_USB_PID and bcd_device == 0x0300:
217+
return BOOTLOADER_SIZE_64K
218+
return BOOTLOADER_SIZE_32K
219+
220+
221+
def write_firmware(
222+
device: usb.core.Device,
223+
data: bytes,
224+
address: int,
225+
) -> None:
226+
"""Mass-erases the device and writes ``data`` to flash at ``address``.
227+
228+
This is the single low-level entry point: it makes no assumptions about
229+
what the bytes are, it just puts them on the device. Callers are
230+
responsible for validating the firmware before calling this.
231+
"""
232+
dfu = DfuDevice(device)
233+
print("Erasing flash...")
234+
dfu.mass_erase()
235+
print("Writing new firmware...")
236+
with (
237+
logging_redirect_tqdm(),
238+
tqdm(total=len(data), unit="B", unit_scale=True) as pbar,
239+
):
240+
dfu.write_memory(address, data, pbar.update)
241+
dfu.exit()
242+
print("Done.")
243+
244+
245+
def flash_dfu(firmware_bin: bytes, metadata: AnyFirmwareMetadata) -> None:
246+
"""Flashes a firmware image to a connected hub over USB DFU."""
247+
try:
248+
devices = get_dfu_devices(idVendor=LEGO_USB_VID)
249+
if not devices:
250+
print(
251+
"No DFU devices found.",
252+
"Make sure hub is in DFU mode and connected with USB.",
253+
file=sys.stderr,
254+
)
255+
exit(1)
256+
257+
if len(devices) > 1:
258+
print(
259+
"Multiple DFU devices found. Connect at most one.",
260+
file=sys.stderr,
261+
)
262+
exit(1)
263+
264+
device = devices[0]
265+
product_id = int(device.idProduct)
266+
bcd_device = int(device.bcdDevice)
267+
268+
if product_id not in ALL_PIDS:
269+
print(f"Unknown USB product ID: {product_id:04X}", file=sys.stderr)
270+
exit(1)
271+
272+
if ALL_PIDS[product_id] != metadata["device-id"]:
273+
print("Incorrect firmware type for this hub", file=sys.stderr)
274+
exit(1)
275+
276+
bootloader_size = _get_bootloader_size(product_id, bcd_device)
277+
firmware_address = FLASH_BASE_ADDRESS + bootloader_size
278+
279+
write_firmware(device, firmware_bin, firmware_address)
280+
except USBError as e:
281+
if e.errno != errno.EACCES or platform.system() != "Linux":
282+
# not expecting other errors
283+
raise
284+
285+
print(
286+
"Permission to access USB device denied. Did you install udev rules?",
287+
file=sys.stderr,
288+
)
289+
print(
290+
"Run `pybricksdev udev | sudo tee /etc/udev/rules.d/99-pybricksdev.rules` then try again.",
291+
file=sys.stderr,
292+
)
293+
exit(1)

0 commit comments

Comments
 (0)