matosicv0.0.0.09
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:0x0001 — actual
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.