The report
A user on Windows updated their pad, the flash was interrupted part way, and the board came back dead. Not "in a strange mode": dead. No USB device, no lights, nothing to press. The only way to bring it back is an SWD probe on the debug socket under the case.
That was always the risk, and until now we accepted it. The v1 board updates through the STM32's built-in ROM DFU bootloader: the firmware receives a command, resets into the ROM, and the host erases the flash from address zero and writes the new image. If the write stops half way, the flash holds garbage. On a board with a BOOT0 button you would hold it and try again; this board strapped BOOT0 low on purpose and has no reset line either, so the ROM is only reachable from working firmware. Interrupt the one flash that replaces the working firmware, and there is no working firmware left to reach it from.
Windows made it likelier, not possible. The ROM DFU device needs the WinUSB driver bound to it, a one-time step with Zadig that people skip, redo mid-flash, or get wrong, and every one of those re-enumerates the device while the ROM is in the middle of writing. Linux has its own version of the problem with some xHCI controllers. But the root cause is the same everywhere: the thing doing the writing is not something we control, and it erases before it has anything valid to leave behind.
Firmware 0.10.0 fixes that with a resident bootloader of our own. App 0.13.0 knows how to talk to it. From now on, an interrupted update leaves a pad that is waiting, not a pad that is gone.
Why not two copies of the firmware
The textbook answer is A/B slots: write the new firmware into a second slot, flip a flag, let a small bootloader copy or swap. Then an interrupted write only ever damages the copy you are not running. I wanted that too. The numbers said no.
| Item | Bytes |
|---|---|
| Flash on the STM32F072CB | 131,072 (2 KiB pages) |
| Firmware 0.9.0 image | 69,128 |
| Same image, compressed as hard as deflate goes | 51,405 |
| Keymap slots and settings page at the top of flash | 20,480 |
Two copies of a 69 KB image plus the data pages plus any bootloader at all is about 165 KiB in a 128 KiB part. Even a compressed staging copy lands near 145 KiB, and it would cap every future firmware at roughly 50 KB forever. The feature that grew the image from 42 KB to 69 KB last month, Work Louder's Input app support, is exactly the kind of thing that cap would have forbidden.
So the design gives up one property and keeps the one that matters. What it gives up: if an update fails, the old firmware does not keep running. What it keeps: the pad is never dead and always recoverable over plain USB with no driver. That is the property the report was about.
What the bootloader does
The first 24 KiB of flash now belong to a small program that no update ever touches. The application lives behind it, at an offset, with a 32-byte header the build stamps in: a magic word, the image length, a CRC-32 over the whole image, and the version string. On every power-up the bootloader runs first and decides, in this order:
- Did the application ask for something before it reset? A word in RAM survives a reset and can say "stay in recovery", "go to the ROM DFU", or "just start". The bootloader clears it before acting so nothing loops.
- Is the encoder knob held down? Twenty samples over twenty milliseconds, all pressed, means the owner wants recovery mode. This is the button the board never had.
- Does the application check out? Header magic, length inside the slot, the stack pointer and reset vector where a correctly linked image puts them, and the CRC over every byte. About 140 ms at the reset-default clock.
- If all of that passes, copy the application's interrupt vectors into RAM, remap, and jump. Nothing else was touched: no clocks, no USB, no interrupts, so the application starts from what looks like a reset.
If anything in that list says otherwise, the bootloader goes to recovery mode instead.
Recovery mode
Recovery mode is a USB HID device, "OpenMicro Bootloader", with the keys breathing amber and the underglow off. HID needs no driver on Windows, macOS or Linux, which is the whole reason it is HID and not DFU. The protocol is deliberately dull: the host asks what is installed, announces a new image's length and CRC, sends it in 56-byte chunks that the bootloader programs as they arrive, and asks for a final check. The bootloader validates the finished slot exactly the way it will at the next boot, reports the result, and starts the firmware on request. An upload of the full image takes under six seconds; the pad comes back as itself half a second after that.
Two rules keep recovery mode honest. It refuses to accept an image that has no stamped CRC, so a hand-rolled client cannot leave a half-plausible slot behind, and it refuses an image built for the other board variant, so a prototype build never lands on a production pad. Both were found by review before any hardware saw the code.
Why the pad is never dead
Walk the failures. Unplug during the erase: the slot is blank, the header check fails, recovery mode. Unplug during the transfer: the header is there but the CRC is wrong, recovery mode. The host crashes after the transfer but before the restart: the image is complete and valid, the next power-up simply boots it. The application itself faults while starting, say a bad release: the bootloader notices on the next reset and drops into recovery mode rather than trying again forever. The bootloader itself hits a bug in recovery mode: it retries once, then hands the pad to the ROM DFU so the combined image can be reinstalled, then halts with the debug port live. Every branch ends with a pad that talks over USB.
All of that was exercised on a real pad, on a hotel desk, with no debug probe in the bag: the migration from 0.9.0, the driverless round trip, an upload abandoned after 40 of 1,245 chunks followed by a cold boot straight into recovery, the encoder-hold entry, the ROM DFU escape from the bootloader, and a downgrade to 0.9.0 and back.
What changes for you
- One last ROM DFU flash. A pad on 0.9.0 or older has no bootloader yet, so installing 0.10.0 goes through the ROM one final time and installs the bootloader and the firmware together. On Windows that flash still needs the WinUSB binding; it is the last time. The bootloader is written first, so even that flash is safer than before: interrupt it after the first quarter and the pad comes up in recovery mode instead of dead.
- Every update after that is driverless. The app reboots the pad into recovery, uploads, verifies, restarts. Same button, no Zadig, no hub tricks.
- Recovery is a banner, not a mystery. App 0.13.0 shows a pad in recovery mode the way it shows an available update: a RECOVERY strip at the top with the firmware it holds, Update firmware, and Boot firmware. A pad that never made it back from a failed update is one click from fixed.
- The knob is the recovery button. Hold the encoder while plugging in and the pad enters recovery mode whatever state its firmware is in.
- Update the app first. App 0.12.1 and older do not know the bootloader exists. They still install 0.10.0 correctly through their old ROM DFU path, but they will never see a pad in recovery mode, and their next firmware update would use the ROM again.
- dfu-util users change nothing. The published
openmicro-fw-0.10.0.binis the combined image, flashed at 0x08000000 like every release before it. Your keymap and settings survive both the migration and normal updates: neither path goes near the top pages of flash.
Under the hood
A few things were less obvious than the design suggests, and they are written down in boot/README.md and fw/README.md for anyone doing this on a Cortex-M0.
The M0 has no vector table offset register, so an application at an offset cannot simply own its interrupts. The bootloader copies the application's 192-byte table into the start of SRAM and remaps SRAM to address zero before jumping. Then it turns out embassy's init resets the SYSCFG block on this family, which silently undoes the remap, so the application re-applies it right after init, inside a critical section, before any interrupt can fire through the wrong table.
The boot decision runs before any executor exists. An earlier draft put it inside the async main; a reviewer pointed out that a panic while spawning the task would reset the chip in a loop before the decision code ever ran, with no USB to see it by. The bootloader also refuses to loop on faults: a counter in RAM turns the third consecutive failure into a halt with SWD attached rather than a fourth attempt.
Everything the bootloader, the firmware, the app and the build scripts have to agree on lives in one small no_std crate, layout/: addresses, the header, the handoff words, the protocol, the CRC. The same validation function runs on the pad, in the app before it sends a byte, and in the tests. The bootloader's protocol state machine is pure code over a "programmer" trait and is tested on the host against a RAM model of STM32 flash rules, and the app's uploader is tested against that same state machine, so the two cannot drift apart unnoticed. Final sizes: bootloader 22,848 bytes in its 24 KiB slot, firmware 69,696 bytes in an 84 KiB slot.
Get it
Firmware 0.10.0 and app 0.13.0 ship together as release v0.13.0: notarized macOS DMGs for Apple Silicon and Intel, portable Windows ZIPs for x64 and Arm64, and the firmware as the combined .bin, a factory .hex, and debug ELFs for both the application and the bootloader, with checksums. Take the app update first, then let it install the firmware. If a pad ever shows amber breathing keys, it is not broken: it is waiting for you.