Firmware Update over HID
A stock QMK keyboard on an RP2040 is updated through the bootloader: hold BOOTSEL (or
double-tap reset), wait for the RPI-RP2 drive, copy a .uf2. On a split keyboard that
is two halves, two cables and two drag-and-drops.
PolyKybd updates itself from the running firmware instead. The host streams the new image
over the same raw HID channel it uses for overlays. Both halves store it in a spare flash
area, check it, and then copy it over their own firmware and reboot together. The user
sees the tray app or polyctl fw flash --apply and nothing else.
The flash layout that makes it possible
Section titled “The flash layout that makes it possible”The keyboard carries 8 MB of external flash. The update needs a second 2 MB area next to the running firmware:
| Range | Use |
|---|---|
| 0–2 MB | Running firmware. The linker caps the image at 2 MB, so a build that grows past it fails to link instead of overwriting the staging area. |
| 2–4 MB | Staging: a 4 KB header, the staged image (at most 0x1F5000 bytes), and three reserved regions carved off the top: the 32 KB apply log, the 4 KB crash archive and the 4 KB handedness stamp. |
| 4–8 MB | Font packs and other resources, with QMK’s emulated EEPROM in the top 8 KB. |
The commands
Section titled “The commands”| ID | Command | What it does |
|---|---|---|
0x43 |
FW_UP_GET_VERSION |
Returns the running version string, image size and CRC. polyctl fw version asks this live, every time. |
0x40 |
FW_UP_BEGIN |
Announces the size and CRC32, and starts erasing the staging area on both halves. |
0x41 |
FW_UP_CHUNK |
One 56-byte piece of the image at a given offset. |
0x45 |
FW_UP_SIGNATURE |
The 64-byte Ed25519 signature, in two parts, before COMMIT. See Firmware Signing. |
0x42 |
FW_UP_COMMIT |
Checks the CRC and the signature and stamps the staging header. Answers ., ?, S or !. |
0x44 |
FW_UP_APPLY |
Installs the staged image on both halves and reboots them. |
The firmware-update commands are not gated on PROTOCOL_VERSION. A host can always update
a keyboard, even one whose protocol it does not otherwise support. That is the way out of
a mismatch, so the host gates these actions only on “a device is present”.
Staging: both halves at once
Section titled “Staging: both halves at once”sequenceDiagram
autonumber
participant H as PolyKybdHost
participant M as Master
participant S as Slave
H->>M: FW_UP_BEGIN (size, CRC)
M->>S: FLASH_STAGE BEGIN
Note over M,S: deferred erase, one sector per pass
M-->>H: ready once the slave has erased
loop one chunk per 56 bytes of image
H->>M: FW_UP_CHUNK (offset, 56 B)
M->>S: FLASH_STAGE CHUNK
end
H->>M: FW_UP_SIGNATURE (2 parts)
H->>M: FW_UP_COMMIT
M->>S: FLASH_STAGE COMMIT
Note over M: CRC + Ed25519 check
M-->>H: . / ? / S / !
The master writes each chunk into its own staging area and relays it to the slave in one
split transaction (USER_SYNC_FLASH_STAGE). The slave never talks to the host.
The erase is deferred. Erasing a 4 KB sector blocks the flash for tens of
milliseconds, and erasing all of staging in one go would starve the split link. So each
half erases one sector per main-loop pass, at a limited rate, and the master polls the
slave until it reports ready. QMK’s SPLIT_MAX_CONNECTION_ERRORS is raised to 200 so the
erase does not make QMK declare the link lost.
The keyboard stays usable during the transfer. Housekeeping that would compete for the split link (EEPROM saves, state pushes, slave display refreshes) pauses, and the RGB matrix breathes cyan. You can keep typing.
COMMIT checks what arrived. It compares a CRC32 over the received bytes with the one
announced at BEGIN. On the master it then checks the signature. Only an image that passes
both gets its staging header stamped. An unsigned image raises the
on-keycap prompt and COMMIT answers
? until you press a key.
Applying: the self-flash
Section titled “Applying: the self-flash”APPLY first tells the slave to apply as well (USER_SYNC_RESET, 20 retries, and the
whole round sent once more if the slave did not acknowledge). Both halves then run the
same four stages from their main loops. Each stage returns to the main loop before the
next one, so its console line goes out and a log shows which stage stopped.
-
Release and blank.
clear_keyboard()releases any held key. From here on the matrix is never scanned again, so a key held at that moment would otherwise repeat on the host until USB drops. The RGB matrix turns solid orange, every keycap goes blank, and the status OLED shows Applying Firmware. -
Flush settings. Unsaved settings are written to EEPROM with
core1held in reset, because an EEPROM write can trigger a flash erase. -
Check the staged image in flash. COMMIT only checked the bytes as they arrived in RAM. This stage reads the staged image back from flash and checks its CRC again, because the next stage erases the only working firmware. On a failure the board stays on its current firmware, shows Update FAILED with the reason for 5 seconds, and gives the keycaps their legends back.
-
Copy and reset. A routine that runs from RAM halts
core1, disarms the watchdog, turns interrupts off and copies the image sector by sector. It writes sector 0, which holds the vector table, last. It then compares what it wrote against the source, records the result in flash, and resets the chip.
Two halves that run new firmware boot together, as after a replug. A master that reboots alone would wait on the boot splash for a slave still running the old code, which is why the slave hand-off is retried so hard.
What the board shows
Section titled “What the board shows”| State | RGB | Keycaps | Status OLED |
|---|---|---|---|
| Transfer | breathing cyan | normal legends | Staging… and a progress bar |
| Unsigned image, waiting for you | breathing orange | blank except A / R | the question |
| Applying | solid orange | blank | Applying Firmware |
| Apply refused | orange fades out | legends restored | Update FAILED and the reason, for 5 s |
Orange always means “you cannot type”. A refused apply names one of two reasons, and they call for different actions:
- not staged: nothing reached the staging area. Sending APPLY again cannot help; upload the image again.
- bad checksum: bytes arrived but are damaged. The right half shows the size and both CRCs. A second attempt usually works.
When it goes wrong
Section titled “When it goes wrong”Two records survive that recovery, because they live in the staging area, which a UF2 copy does not touch:
- The apply log in the top 32 KB of staging. Each apply erases it first and then writes one page per sector copied, plus markers around the first sector. An empty log means the copy never started. A partial one names the last sector written.
- The post-copy compare in the staging header: whether the bytes written to the running area match the source, and the first word that differed.
Nothing the firmware prints during the copy reaches the host: interrupts are off and the console only drains from the main loop. A gap in the console timestamps across the apply is expected.
Design notes for contributors
Section titled “Design notes for contributors”- The copy buffer is
uint32_t page_buf[], and the type is the fix. It was once auint8_tarray copied with word instructions. An unrelated change moved it to an odd address, and the unaligned store HardFaulted the core with interrupts off, in a function that never returns. Boards were bricked until reflashed over BOOTSEL. Declaring the buffer as words means no later edit can reintroduce it. - Nothing in the copy may call into flash. The routine erases the code it would
call. GCC turns a fill loop into a call to
memsetand a compare loop intomemcmp, and both live in flash. The routine usesvolatileaccesses to stop that. - COMMIT must not wait for the keypress. It runs inside
raw_hid_receive()on the loop that scans the matrix, so a wait would guarantee the key is never seen. It answers?and the host polls about once a second. - Every cue on the apply path is pushed out at once. The RGB matrix, the status OLED and the keycaps are normally updated by periodic tasks, and on a path that never returns there is no next task. The firmware writes all three directly.