2026-05-22

Save to keyboard.

One day after the first key fired. The goal: replace the CircuitPython sketch with QMK and a browser configurator that actually writes keymaps to the device over WebHID. The toolchain fought me. The LTO compiler stripped the descriptor I needed. I got the macro byte encoding wrong twice. And then I pressed the A key on the macropad to type the word "continue" into my AI chat — with bytes I'd written, sent by firmware I'd built, on hardware I'd soldered.

Why bother replacing CircuitPython

The CircuitPython firmware I shipped yesterday worked. Press a key, get a letter. Turn the encoder, get volume. Done. The problem was reconfiguration: changing any binding meant editing code.py on the CIRCUITPY drive and saving. Customers shouldn't have to learn Python to rebind a key. Reconfiguration belongs in a UI.

The standard open-source path here is QMK for the firmware plus VIA as the configurator protocol. QMK is a mature C codebase that handles the matrix scanning, USB descriptors, debouncing, layers, macros, and a hundred other things you don't want to write yourself. VIA is a small binary protocol over USB raw HID that lets a host program read and write the keyboard's keymap at runtime, no reflash needed.

Yesterday's bring-up was "can a keypress reach the host?" Today's was "can a web page rewrite the macropad's behavior?"

Toolchain dead-ends, again

Installing the QMK CLI on macOS is a small adventure of its own. The official Homebrew formula (qmk/qmk/qmk) installs cleanly but then the post-install symlink step fails: Error: No such file or directory — /opt/homebrew/bin/qmk. Known issue when the cached bottle was built for an older macOS than yours. I uninstalled the formula and went the pipx route instead:

brew install pipx
pipx ensurepath
pipx install qmk

That gave me a working qmk binary, but compiling anything still needs the ARM cross-compiler. The mainline Homebrew arm-none-eabi-gcc is GCC 16, which does not bundle newlib, so the first compile blew up with fatal error: stdint.h: No such file or directory. The fix is to use the older QMK-tested toolchain from the osx-cross/arm tap:

brew tap osx-cross/arm
brew install osx-cross/arm/arm-none-eabi-gcc@8
brew install osx-cross/arm/arm-none-eabi-binutils

Both are keg-only on macOS so they don't get put on the path automatically. I prepended them inline:

PATH="/opt/homebrew/opt/arm-none-eabi-gcc@8/bin:\
/opt/homebrew/opt/arm-none-eabi-binutils/bin:$PATH" \
    qmk compile -kb matosic/macropad -km via

Writing the QMK keyboard definition

QMK's modern format is a single keyboard.json per board. Mine declares a 3×3 matrix with the rows on GP29 / GP28 / GP27, columns on GP6 / GP7 / GP0, an EC11 encoder on GP2 / GP1, processor and bootloader set to RP2040, and a placeholder USB VID/PID (0xFEED:0x0001actual pid.codes-assigned PID comes when I file the open-source hardware request later this week).

The pin numbers are the RP2040 GPIO names, not the XIAO silkscreen labels. The XIAO's silk is its own renumbering of the underlying GPIOs — D3 on the silk is GP29 on the chip. I'd documented this in firmware-pinout.md in the PCB repo on bring-up day, so the mapping was a quick lookup. Worth writing those tables down once, in the only place they live, so you never have to re-derive them.

Four small surprises in firmware

The compile broke in four entertaining ways before it worked end-to-end.

USB version regex. I'd set device_version: "0.0.18" to match the v0.0.0.18 PCB revision. QMK's schema validates that field against \d{1,2}\.\d\.\d — single digit per component. The minor "18" failed. Worse, the failure mode is to silently ignore the entire keyboard.json, which cascades into a wall of unrelated errors. Lesson: firmware version and board revision are different things. The firmware version starts at 0.0.1 and tracks QMK builds; the v0.0.0.18 is the PCB. They can drift.

File naming. QMK expects the keyboard's top-level C file to match the keyboard directory name. keyboards/matosic/macropad/ means the C file should be macropad.c, not matosic_macropad.c like I named it. With the wrong name, QMK silently ignored the file, my keyboard_pre_init_kb() never ran, and the backlight stayed off. Once I renamed it, the LED came on immediately and the encoder push started reaching the host. That naming convention is one of those things that's only obvious to people who already know it.

GPIO API rename. QMK renamed all of its pin helpers from setPinOutput / writePinHigh / readPin to gpio_set_pin_output / gpio_write_pin_high / gpio_read_pin. Examples on the internet still use the old names. Mine compiled with implicit-function-declaration errors until I updated everything.

LTO stripped the raw HID descriptor. This was the worst one because it failed silently. I'd turned on link-time optimization ("lto": true) in keyboard.json because it's recommended for flash-constrained builds. With it on, the compiled usb_descriptor.o had no RawReport symbol — LTO had decided the raw HID descriptor block was "unused" because nothing in my code path explicitly references it (it's registered statically by the USB stack at init time). On the host side, that meant my macropad enumerated with only three HID interfaces (keyboard, consumer, system) and no fourth raw HID interface for VIA to talk to. Turning LTO off brought the descriptor back. Don't trust LTO with statically-registered descriptors.

Writing the VIA protocol from scratch in the browser

The browser side of this lives in a single static configure.js. No build step, no framework, no npm install. The whole thing is one IIFE.

VIA's wire format is straightforward once you read enough QMK source code. Each command is 32 bytes out, 32 bytes back, request/response, one in flight at a time. Byte 0 is the command ID. The handful I implemented:

0x01  GET_PROTOCOL_VERSION
0x04  DYNAMIC_KEYMAP_GET_KEYCODE(layer, row, col)
0x05  DYNAMIC_KEYMAP_SET_KEYCODE(layer, row, col, kc_hi, kc_lo)
0x0B  BOOTLOADER_JUMP
0x0F  DYNAMIC_KEYMAP_MACRO_SET_BUFFER(offset_hi, offset_lo, len, ...28 bytes)
0x11  DYNAMIC_KEYMAP_GET_LAYER_COUNT

The transport layer is a serial queue. Every call to viaCommand() chains onto a single promise so only one command is ever in flight; the device's inputreport event resolves whichever request is at the front of the queue. With a 1-second timeout per command so a hung device doesn't lock up the page.

On connect, I read all 36 keycodes (4 layers × 9 positions) plus the protocol version and layer count, stash them, and render the on-screen board with the real device state. On Save, I walk the map of pending changes and call viaSetKeycode for each one. The keycodes are 16-bit; modifier combos like LCMD(KC_C) are encoded as (0x01 << 8) | KC_C = 0x0806. I wrote a small lookup table mapping ~100 of the most common keycodes to glyphs and labels for the UI — everything else falls back to its raw hex.

WebHID on macOS hides what you don't ask for

The first time I tried Connect after the firmware was flashed, the browser found the macropad but device.collections only showed three interfaces — none of them 0xFF60. So my first sendReport(0, …) failed with NotAllowedError: Failed to write the report.

ioreg on macOS told a different story: the device clearly exposed four HID interfaces, including one with PrimaryUsagePage = 65376 (0xFF60) and PrimaryUsage = 97 (0x61). The firmware was right. The browser was hiding the raw HID interface from my page.

The cause: Chromium-on-macOS returns one HIDDevice per USB HID interface of a composite device, not one per physical device. When I called navigator.hid.requestDevice({ filters: [{ vendorId, productId }] }) with a broad VID/PID-only filter, the browser handed me back three HIDDevice objects. I'd been adopting devices[0] — whichever interface the OS happened to surface first — which was not the raw HID one. The fix was four lines:

function pickRawHidDevice(devices) {
    for (const d of devices) {
        for (const c of (d.collections || [])) {
            if (c.usagePage === 0xFF60 && c.usage === 0x61) {
                return d;
            }
        }
    }
    return null;
}

After that, every VIA call worked first try.

The macro that typed "ctonine"

Macros were the last thing I wired. The simplest case I had in mind: bind a key to type continue and press Enter, so I could send "continue" to my AI chat without taking my hands off the macropad to find the keyboard. The textarea on the Configure page accepts the syntax continue{KC_ENTER}; my parser turns that into a byte sequence and writes it to the device's macro buffer over VIA.

First attempt: pressed the key, got ctonine and no Enter. Two bugs at once.

The character scrambling was a timing issue. QMK's macro playback defaults to zero millisecond delay between characters, which means the host USB stack receives reports faster than it can process them and shuffles their order on the way to the application. The fix was setting DYNAMIC_KEYMAP_MACRO_DELAY 10 in the firmware's config.h. Ten milliseconds per character is imperceptible to the user and slow enough for any host to keep up.

The missing Enter was an encoding bug in my own code. QMK's macro byte parser expects a three-byte tap instruction: 0x01 (PREFIX) + 0x01 (TAP_CODE) + keycode. I'd been emitting two: 0x01 + keycode. So when the parser saw my 0x01 0x28 for SS_TAP(KC_ENTER), it read 0x28 as the action code, didn't match any of 1/2/3/4, silently emitted nothing, and moved on. Changing the encoding to three bytes fixed it. Press the key now, "continue" appears cleanly, Enter fires.

Breathing LED, twice

Halfway through I'd added a "probe mode" to the firmware that strobes the backlight at 1 Hz so I could see across the desk that the firmware loop was running. It worked but strobing was unpleasant to look at. So I rewrote it as a proper breathing effect — smooth fade in and out over ~5 seconds, gamma-corrected so the brightness curve looks linear to the eye.

The implementation uses the RP2040's hardware PWM. GP26 multiplexes to PWM slice 5, channel A. I poked the peripheral registers directly through the pico-sdk's struct headers because the pico-sdk's hardware/pwm.c isn't compiled into QMK's ChibiOS-on-RP2040 build — but the register-layout headers are header-only, so direct access works without any extra link dependencies.

Flashed. LEDs went dark. Completely off. The peripheral registers were accepting my writes (no fault), but nothing was reaching the pad. The cause: the RP2040 holds peripherals in reset at boot, and pico-sdk's runtime is what normally unblocks them. QMK on ChibiOS doesn't use pico-sdk's runtime, so the PWM block stayed in reset and all my register writes were silently dropped. Two lines fixed it:

resets_hw->reset &= ~RESETS_RESET_PWM_BITS;
while (!(resets_hw->reset_done & RESETS_RESET_PWM_BITS)) {}

Flashed again. Smooth breathing in, breathing out, repeat.

The closing image

The Macro tab still had continue{KC_ENTER} in the textarea from earlier. I clicked Apply on the A key, clicked Save, watched the browser flash through the VIA writes, then pressed A.

In the chat window: continue. Then a newline. Then a response from the assistant on the other end of the conversation that we'd built this whole pipeline through.

Bytes I wrote in configure.js went out through WebHID, landed on the raw HID interface I'd enabled in rules.mk, got stored in EEPROM by code in quantum/via.c, and got played back by a parser in quantum/send_string/send_string.c at a 10 ms tap interval set in config.h — tapping the matrix at the position I'd soldered a switch over, sending HID keyboard reports my MacBook accepted as keystrokes, eventually rendering as text in a chat window.

The product talks to its host. The host configures the product from the browser. Save to keyboard.

Next up: an actual pid.codes USB PID so we stop using the QMK dev placeholder, the encoder push-switch becoming VIA-remappable (it's outside the matrix on this revision, so it needs a phantom row), and starting on the first production batch of ten units.