Customizing USB Devices
Works with
Any CircuitPython board with native USB
You already know your board can act as a keyboard. This project goes further: you choose exactly which USB devices your board presents, lock down the CIRCUITPY drive so the host computer cannot see or modify your code, and wire up several buttons each sending a different key combination. The result is a dedicated macropad — a small, purpose-built input device that belongs at any desk.
What you will build
A multi-button macropad where each button fires a different shortcut or media command.
A boot.py file controls what USB interfaces the board exposes, and in its final form
the CIRCUITPY storage drive is hidden from the host so the device looks and acts exactly
like commercial hardware.
Example button layout:
| Button | Action |
|---|---|
| Button 1 | Ctrl+C (copy) |
| Button 2 | Ctrl+V (paste) |
| Button 3 | Ctrl+Z (undo) |
| Button 4 | Play / Pause (media key) |
| Button 5 | Volume Up |
What you will need
- Any supported CircuitPython board with native USB
- 4-6 tactile push buttons
- Jumper wires
- Breadboard
Wiring
Wire each button between a unique digital pin and GND. The code uses internal pull-up resistors, so no external resistors are needed.
graph LR
subgraph Board
D2["D2"]
D3["D3"]
D4["D4"]
D5["D5"]
D6["D6"]
GND["GND (shared)"]
end
subgraph Buttons
B1["Button 1"]
B2["Button 2"]
B3["Button 3"]
B4["Button 4"]
B5["Button 5"]
end
D2 --> B1
D3 --> B2
D4 --> B3
D5 --> B4
D6 --> B5
B1 --> GND
B2 --> GND
B3 --> GND
B4 --> GND
B5 --> GND
The code
This project uses two files: boot.py runs once at startup before USB is initialized,
and code.py runs the main loop.
boot.py
import usb_hid
import storage
# Disable the CIRCUITPY USB drive so the host sees only HID devices.
# Remove or comment out the storage line during development — you need
# the drive visible to edit your files.
storage.disable_usb_drive()
# Enable only keyboard and consumer control (media keys).
# Remove Mouse if you do not need it to keep the device descriptor clean.
usb_hid.enable(
(usb_hid.Device.KEYBOARD, usb_hid.Device.CONSUMER_CONTROL)
)
Hiding the drive locks you out of editing
Once storage.disable_usb_drive() is active, you cannot drag files onto CIRCUITPY
from your computer. To re-enable it, hold a specific button while plugging in (add
that logic to boot.py before disabling), or use the CircuitPython REPL over serial
to delete boot.py. A safe pattern: check if a "safe mode" button is held at boot,
and only disable the drive when it is not.
code.py
import board
import digitalio
import time
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.consumer_control_code import ConsumerControlCode
keyboard = Keyboard(usb_hid.devices)
consumer = ConsumerControl(usb_hid.devices)
PINS = [board.D2, board.D3, board.D4, board.D5, board.D6]
buttons = []
for pin in PINS:
btn = digitalio.DigitalInOut(pin)
btn.direction = digitalio.Direction.INPUT
btn.pull = digitalio.Pull.UP
buttons.append(btn)
# Map each button index to an action.
# Each action is a tuple: ("keyboard", [modifier, key]) or ("consumer", code)
ACTIONS = [
("keyboard", [Keycode.CONTROL, Keycode.C]), # Ctrl+C
("keyboard", [Keycode.CONTROL, Keycode.V]), # Ctrl+V
("keyboard", [Keycode.CONTROL, Keycode.Z]), # Ctrl+Z
("consumer", ConsumerControlCode.PLAY_PAUSE), # Play/Pause
("consumer", ConsumerControlCode.VOLUME_INCREMENT), # Volume Up
]
was_pressed = [False] * len(buttons)
while True:
for i, btn in enumerate(buttons):
pressed = not btn.value
if pressed and not was_pressed[i]:
action_type, action = ACTIONS[i]
if action_type == "keyboard":
keyboard.press(*action)
keyboard.release_all()
elif action_type == "consumer":
consumer.press(action)
consumer.release()
was_pressed[i] = pressed
time.sleep(0.02)
How it works
What boot.py is and why it runs before code.py
CircuitPython separates startup into two phases. boot.py runs before USB is
initialized — this is the only window in which you can change what USB devices the
board presents. By the time code.py runs, USB is already enumerated and the host has
been told what it is talking to. If you try to call usb_hid.enable() from code.py,
CircuitPython raises an error. Think of boot.py as the firmware configuration file
and code.py as the application.
Why you might want to hide the CIRCUITPY drive
By default, your board appears as a USB drive alongside the HID interface. That is
useful during development, but it has two downsides for a finished device. First, the
OS may show a "new drive connected" dialog every time you plug in, which is annoying
for something used as a daily tool. Second, anyone with physical access to the computer
could browse or modify your code. Calling storage.disable_usb_drive() in boot.py
makes those files invisible to the host. The drive still exists — you can still access
it over the serial REPL — but from the USB enumeration perspective, the device is
purely HID. The result feels like a commercial product.
Consumer Control codes for media keys
Standard keyboard keycodes cover letters, numbers, and modifier keys, but media
controls — volume, playback, brightness — live in a separate HID usage table called
Consumer Control. The adafruit_hid library exposes these through the
ConsumerControl class and ConsumerControlCode constants. You send them the same
way as regular keys: press() followed by release(). Common codes include
PLAY_PAUSE, VOLUME_INCREMENT, VOLUME_DECREMENT, MUTE, SCAN_NEXT_TRACK, and
SCAN_PREVIOUS_TRACK. Because Consumer Control is a separate HID interface, it needs
its own object — you cannot mix ConsumerControlCode values into a Keyboard.press()
call.
Installing the library
Copy the entire adafruit_hid folder from the CircuitPython library bundle into
CIRCUITPY/lib/. You need these files inside that folder at minimum:
CIRCUITPY/lib/adafruit_hid/
├── keyboard.py
├── keycode.py
├── consumer_control.py
└── consumer_control_code.py
Get the bundle at circuitpython.org/libraries and match the version to your installed CircuitPython.
Remix ideas
Remix idea
Add a small OLED display that shows which "layer" the macropad is in — editing mode, browser mode, gaming mode. Holding a mode button shifts all the other buttons to a new set of shortcuts. See OLED Hello World to get the display running first.
Remix idea
Replace the mechanical buttons with capacitive touch pads for a sleeker build. The
wiring is simpler — no breadboard required. See
Touch to Keyboard for how to read touch
inputs with touchio.
Remix idea
Go further down the rabbit hole and define a custom HID report descriptor so your device presents axes and buttons in a layout that game engines can map directly. See Custom HID Device.
Go deeper
- USB HID Reference — full
adafruit_hidAPI, all Keycode and ConsumerControlCode values, and notes onboot.pyrestrictions - Customizing USB Devices in CircuitPython Credit: Adafruit Learning System