Back to portfolio
Embedded Systems · Build Log

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.

In progress
//Status at a glance

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
MCU·STM32 (Nucleo)Firmware·C++ · HAL · CubeMX/IDE/ProgrammerUSB·Custom HID classHost·Python · hid · subprocessDebug·hidapitester · microcom · UART

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.

01Toolchain & first flash

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.

02GPIO + EXTI

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).

03USB enumeration

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:

~/macropad
$ lsusb
Bus 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.

04Bidirectional comms

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:

~/hidapitester
$ 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. Bumping CUSTOM_HID_EPIN_SIZE and CUSTOM_HID_EPOUT_SIZE to 0x40 (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 treated pdev->pUserData as 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_PrepareReceive back 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 volatile flag and lets the main loop do the real work.
05Host decoder

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.

06Software debounce

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.
//Roadmap

Where it stands

  • Breadboard + buttons
    Done
  • USB HID, custom class, bidirectional
    Done
  • Software debounce
    Done
  • Host-side report decoder
    Done
  • Key matrix, row/column scan
    In progress
  • Configurable key macros
    Planned
Built with v0