A Custom USB Macropad
on an STM32
A programmable macropad built from scratch. An STM32 enumerates as a custom USB HID device, talks both ways with a Linux host, and turns physical key presses into desktop actions. Firmware, USB descriptors, host-side decoder and debouncing all done by hand.
Working now
- Toolchain & first flash (VS Code + CubeIDE)
- GPIO + EXTI: LED blink profiles
- Custom USB HID device enumerating on Linux
- Bidirectional comms: button → host, host → LED
- Python host decoder maps reports to actions
- Software debounce on a single button
Currently building
- Key matrix: row/column scan in the main loop
- Next layout: a 9-key grid on the dev board
- Configurable key macros
The goal was to understand the whole stack, from a GPIO line flipping on the silicon to a window opening on the desktop. So I built the firmware, the USB report format, the host decoder and the debouncing myself instead of grabbing an off-the-shelf keyboard profile.
Getting set up
First job was a comfortable dev loop: VS Code for writing code, CubeIDE for compiling. I flashed the board for the first time with a 'blank' project, then re-flashed ST's example binary to confirm the CubeMX project generation and MCU flashing worked before touching any of my own code.
Re-implementing The Blink
My first piece of code was a plain GPIO blink with a few profiles I could cycle with the onboard user button. Basically rebuilding the factory demo from scratch so I understood what it was doing.
This is where I wrote my first external-interrupt (EXTI) callback, reading the user button on pin PC13 and driving the LD2 LED on pin PA5. Note: the macropad keys are not read via EXTI (see section 06).
Becoming a custom HID device
The core of the project was getting the STM32 to show up as a custom USB HID device instead of a stock keyboard. I set USB up in CubeMX, wired the USB OTG FS data lines (PA12 for D+, PA11 for D-) on a breakout board, and generated the firmware. This worked and enumerated correctly:
$ lsusbBus 007 Device 004: ID 0483:5750 STMicroelectronics Custom HID
A fair amount of the early effort was just observability, i.e. learning how Linux surfaces HID devices. dmesg --follow gave me the VID/PID and the matching hidraw node the instant I plugged in, udevadm info mapped that to a path like /dev/hidraw4, and hidapitester let me open the device and stream input reports.
Two-way traffic, and the bug that ate the stack
The target behaviour: press a button and the device sends an IN report to the host; send an OUT report from the host and the device acts on the first byte (toggling an LED). Device-to-host came together first:
$ sudo ./hidapitester --vidpid 0483:5750 --open --read-input-forever# button press → expected report lands on the host
Host-to-device was where it got interesting. Sending an OUT report did nothing to the LED, and worse, it killed the device-to-host direction too, so button presses afterwards sent nothing at all. The device ran fine right up until the first OUT packet, then went totally silent.
Before I could chase that down, I needed eyes on the device. My first instinct was printf over microcom, which gave me nothing. Turns out microcom wants a UART/serial stream, not the HID interface. The board already exposed a virtual COM port at /dev/ttyACM0, and prints only show up there once you actually push them out with HAL_UART_Transmit. With logging in place, I could finally see what the firmware thought it was receiving.
This meant I could identify a bunch of fixes that needed to be made:
- Endpoint buffer size. The template shipped with a 2-byte (
0x02) endpoint. BumpingCUSTOM_HID_EPIN_SIZEandCUSTOM_HID_EPOUT_SIZEto0x40(64 bytes) let the report payloads through. - The damage was self-inflicted. I'd lifted the OUT-handling changes from a forum post written for an older firmware version, and applied more of them than I should have. The ported code referenced
pdev->pClassData(NULL on my version) and treatedpdev->pUserDataas a struct when it is really an array, so the first OUT packet called a NULL function pointer, the MCU HardFaulted, and the whole USB stack went down with it. - Re-arming the OUT endpoint. Adding
USBD_LL_PrepareReceiveback into the receive path means the endpoint accepts more than one packet. - Thin interrupts. The receive callback runs in interrupt context, so it just sets a
volatileflag and lets the main loop do the real work.
Decoding reports on the host
With the GPIO triggers and HID comms each working on their own, I spun up a fresh CubeMX project to merge them into a single project file. On the host I wrote a small Python listener that opens the custom HID device, decodes each incoming report, and fires off an action in response, for example launching a browser. It leans on the hid and subprocess libraries, both new to me but quick to pick up.
Reading a key (and 96 Firefox windows)
For the macropad itself, key presses get read by scanning a matrix in the main loop, polled on purpose rather than interrupt-driven. The keys sit in a grid of rows and columns. I drive one column at a time and read the rows, and whichever row registers tells me which key sits at that intersection. Walking across the columns each loop covers the whole pad.
The first milestone was a single-button proof of concept with software debounce, which is where I learned the hard way why pull-down resistors matter for a clean read instead of a floating input. Before the debounce existed, and while the button action was still "launch the browser" rather than a harmless print, one physical press registered as 96 separate events. So it opened 96 browser windows.
In progress now: scaling that single-button setup up to the full row/column matrix on the dev board, going from one key to a 9-key grid.
Where it stands
- ✓Breadboard + buttonsDone
- ✓USB HID, custom class, bidirectionalDone
- ✓Software debounceDone
- ✓Host-side report decoderDone
- Key matrix, row/column scanIn progress
- Configurable key macrosPlanned