To build a functional macro keyboard with a Raspberry Pi, you must use a board that natively supports USB OTG (On-The-Go) device mode. Standard Raspberry Pi models (like the Pi 4 or 5) have host-only USB-A ports, meaning they can read a keyboard but cannot act as one. The definitive choice for a Linux-based USB HID (Human Interface Device) keyboard project is the Raspberry Pi Zero 2 W, utilizing its micro-USB OTG port and the Linux libcomposite gadget framework.
The Core Decision: Which Pi for a USB Keyboard?
Before buying parts, you must select the correct board. Attempting to force USB device mode on a Pi 4 requires hacking the USB-C power port, which creates dangerous power-delivery conflicts when plugged into a host PC. Use this decision matrix to select your board:
| Board Variant | Native USB Device (OTG) | Power/Data Conflict | Processing Headroom | Verdict |
|---|---|---|---|---|
| Raspberry Pi 4 / 5 | USB-C port only | Yes (Shares primary 5V power input) | High (Quad-core) | Reject for standalone HID |
| Raspberry Pi Zero W (v1) | Micro-USB port | No (Dedicated data port available) | Low (Single-core 1GHz) | Reject (HID polling latency) |
| Raspberry Pi Zero 2 W | Micro-USB port | No (Dedicated data port available) | High (Quad-core 1GHz) | SELECT THIS BOARD |
Default Recommendation: Buy the Raspberry Pi Zero 2 W (with pre-soldered headers). It provides the quad-core processing needed to run Python GPIO polling and HID report generation without the input lag that plagues the older single-core Zero W.
Parts List & Pin Mapping
This build creates a 4-key macro pad. We are using direct GPIO-to-Ground wiring with internal pull-up resistors enabled in software, which eliminates the need for external pull-up resistors on the breadboard.
Bill of Materials (BOM)
- MCU: Raspberry Pi Zero 2 W (with pre-soldered 40-pin header) — ~$15
- Switches: 4x Cherry MX Brown (PCB mount) or Kailh Choc V1 — ~$4
- Cable: Micro-USB to USB-A Data Cable (Must have 4 internal wires; charge-only cables will fail) — ~$6
- Substrate: 1x Perfboard or 3D-printed macro pad enclosure
- Diodes: 4x 1N4148 switching diodes (Optional for 4-key, mandatory if expanding to a matrix)
GPIO Pin Mapping Table
| Switch Function | GPIO Pin (BCM) | Physical Pin | Wiring Target |
|---|---|---|---|
| Macro 1 (Layer A) | GPIO 17 | Pin 11 | Switch NO Contact |
| Macro 2 (Layer B) | GPIO 27 | Pin 13 | Switch NO Contact |
| Macro 3 (Layer C) | GPIO 22 | Pin 15 | Switch NO Contact |
| Macro 4 (Layer D) | GPIO 23 | Pin 16 | Switch NO Contact |
| Common Ground | GND | Pin 6, 9, 14, etc. | Switch COM Contact |
Step-by-Step: Enabling HID Gadget Mode
The Linux kernel uses the dwc2 driver and libcomposite framework to turn the Pi into a USB peripheral. You must configure the boot files before writing any Python code.
- Enable the dwc2 overlay: Open
/boot/firmware/config.txt(or/boot/config.txton older OS versions) and add this line to the very bottom:dtoverlay=dwc2 - Load the composite module at boot: Open
/boot/firmware/cmdline.txt. This file must remain a single continuous line. Find the wordrootwaitand insert the following text immediately before it, separated by spaces:modules-load=dwc2,libcomposite - Create the Gadget Initialization Script: Create a bash script at
/usr/local/bin/usb-gadget.shto configure the HID endpoint via ConfigFS. According to the Linux Kernel ConfigFS documentation, you must define the vendor ID, product ID, and report descriptor.#!/bin/bash cd /sys/kernel/config/usb_gadget/ mkdir -p g1 cd g1 echo 0x1d6b > idVendor # Linux Foundation echo 0x0104 > idProduct # Multifunction Composite Gadget echo 0x0100 > bcdDevice echo 0x0200 > bcdUSB mkdir -p strings/0x409 echo "100000000001" > strings/0x409/serialnumber echo "ElectricalFlux" > strings/0x409/manufacturer echo "Pi Macro Pad" > strings/0x409/product # Create HID function mkdir -p functions/hid.usb0 echo 1 > functions/hid.usb0/protocol echo 1 > functions/hid.usb0/subclass echo 8 > functions/hid.usb0/report_length # Write standard 8-byte keyboard report descriptor echo -ne '\x05\x01\x09\x06\xa1\x01\x05\x07\x19\xe0\x29\xe7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x03\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x03\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xc0' > functions/hid.usb0/report_desc # Bind configuration mkdir -p configs/c.1/strings/0x409 echo "Config 1" > configs/c.1/strings/0x409/configuration echo 250 > configs/c.1/MaxPower ln -s functions/hid.usb0 configs/c.1/ # Bind UDC (USB Device Controller) ls /sys/class/udc > UDC - Execute and Verify: Run
sudo bash /usr/local/bin/usb-gadget.sh. Checkls /dev/hidg*. You should see/dev/hidg0. If you do, the Pi is now electrically presenting as a USB keyboard to the host.
Python HID Script with Error Handling
This script targets the Raspberry Pi Zero 2 W running Raspberry Pi OS (Bookworm or later). It uses the gpiozero library for hardware debouncing and writes raw 8-byte HID reports directly to the /dev/hidg0 character device.
Note: Install dependencies first via sudo apt install python3-gpiozero.
import time
import os
import sys
from gpiozero import Button
# --- PIN DEFINITIONS (BCM Numbering) ---
PIN_MACRO_1 = 17
PIN_MACRO_2 = 27
PIN_MACRO_3 = 22
PIN_MACRO_4 = 23
# --- HID KEYCODES (USB HID Usage Tables v1.12) ---
# https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf
KEY_A = 0x04
KEY_B = 0x05
KEY_C = 0x06
KEY_D = 0x07
KEY_ENTER = 0x28
HID_DEVICE = '/dev/hidg0'
# Initialize buttons with internal pull-ups (pull_up=True)
# bounce_time=0.05 provides 50ms hardware debouncing
btn1 = Button(PIN_MACRO_1, pull_up=True, bounce_time=0.05)
btn2 = Button(PIN_MACRO_2, pull_up=True, bounce_time=0.05)
btn3 = Button(PIN_MACRO_3, pull_up=True, bounce_time=0.05)
btn4 = Button(PIN_MACRO_4, pull_up=True, bounce_time=0.05)
def send_hid_report(modifier, *keys):
"""Constructs and writes an 8-byte standard HID keyboard report."""
report = bytearray(8)
report[0] = modifier # Modifier byte (Shift, Ctrl, etc.)
report[1] = 0x00 # Reserved byte
# Pack up to 6 simultaneous key presses
for i, key in enumerate(keys[:6]):
report[2 + i] = key
try:
with open(HID_DEVICE, 'wb') as fd:
fd.write(report)
except FileNotFoundError:
print(f"CRITICAL ERROR: {HID_DEVICE} not found. Did the gadget init script run?")
sys.exit(1)
except PermissionError:
print(f"CRITICAL ERROR: Permission denied on {HID_DEVICE}. Run script as root.")
sys.exit(1)
except OSError as e:
print(f"HID Write Error: {e}")
def clear_hid_report():
"""Sends an empty report to release all keys (prevents stuck keys)."""
send_hid_report(0, 0)
# --- CALLBACK FUNCTIONS ---
def on_macro1_press():
print("Macro 1: Sending 'A' + Enter")
send_hid_report(0, KEY_A, KEY_ENTER)
time.sleep(0.05) # Brief hold to ensure host registers keystroke
clear_hid_report()
def on_macro2_press():
print("Macro 2: Sending 'B'")
send_hid_report(0, KEY_B)
time.sleep(0.05)
clear_hid_report()
def on_macro3_press():
print("Macro 3: Sending Ctrl+C (Copy interrupt)")
send_hid_report(0x01, KEY_C) # 0x01 = Left Ctrl
time.sleep(0.05)
clear_hid_report()
def on_macro4_press():
print("Macro 4: Sending 'D'")
send_hid_report(0, KEY_D)
time.sleep(0.05)
clear_hid_report()
# --- EVENT BINDING ---
btn1.when_pressed = on_macro1_press
btn2.when_pressed = on_macro2_press
btn3.when_pressed = on_macro3_press
btn4.when_pressed = on_macro4_press
if __name__ == "__main__":
print("Pi Zero 2 W Macro Keyboard Active. Press Ctrl+C to exit.")
try:
while True:
time.sleep(0.1) # Keep main thread alive
except KeyboardInterrupt:
print("\nShutting down HID reports...")
clear_hid_report()
sys.exit(0)
Debugging: First Three Things to Check When It Fails
USB gadget mode on Linux is notoriously fragile during setup. If your host PC doesn't recognize the keyboard, follow this exact diagnostic path.
1. The Python Script Crashes on Launch
Exact Error String: FileNotFoundError: [Errno 2] No such file or directory: '/dev/hidg0'
- Cause A (Most Likely): The
dtoverlay=dwc2line is missing fromconfig.txt, or theusb-gadget.shscript was never executed. - Cause B: You are using a Raspberry Pi 4/5 and did not configure the specific USB-C OTG overlay, or the host PC is back-feeding power and crashing the dwc2 driver.
- Fix: Run
lsmod | grep dwc2. If it returns nothing, your overlay failed. Checkdmesg | grep dwc2for driver initialization errors.
2. The Script Runs, But Keystrokes Don't Register
Exact Error String: PermissionError: [Errno 13] Permission denied: '/dev/hidg0'
- Cause: The
/dev/hidg0character device is owned byrootwith600permissions by default. - Fix: Run the Python script with
sudo python3 macro_keyboard.py, or create a udev rule in/etc/udev/rules.d/99-hidg.rulescontaining:KERNEL=="hidg0", SUBSYSTEM=="usbmisc", MODE="0666".
3. Host PC Chimes "USB Device Not Recognized"
Exact Error String (Windows): USB device not recognized
Exact Error String (Linux dmesg): device descriptor read/64, error -32
- Cause A (Most Likely): You are using a charge-only micro-USB cable. These cables lack the D+ and D- data lines required for USB enumeration.
- Cause B: The report descriptor in the bash script is malformed. The host OS rejects the device during the descriptor phase.
- Fix: Swap the cable immediately. Verify the cable by plugging it into a phone and confirming you can transfer files, not just charge.
Extending and Simplifying the Build
Once the 4-key baseline is stable, you have two distinct paths depending on your project goals.
How to Extend (Adding OS-Level Features)
Because the Pi Zero 2 W runs a full Linux kernel, you can leverage OS-level integrations that microcontrollers cannot.
Add an I2C OLED Display: Wire an SSD1306 128x64 OLED to GPIO 2 (SDA) and GPIO 3 (SCL). Use the adafruit-circuitpython-ssd1306 library to display the currently active macro layer or system stats (CPU temp, WiFi IP) while the Pi acts as a keyboard to the host.
Add WiFi Macros: Modify the Python script so that Macro 1 triggers an MQTT publish request to your home automation server (e.g., Home Assistant) while simultaneously sending a keystroke to the host PC.
How to Simplify (The Microcontroller Pivot)
If you realize you do not need WiFi, a full Linux OS, or Python scripting, abandon the Pi Zero 2 W and use a Raspberry Pi Pico (RP2040). The Pico is a microcontroller, not a single-board computer. It supports native TinyUSB HID device mode out of the box. By flashing KMK firmware or writing a 20-line CircuitPython script using the usb_hid module, you bypass the Linux libcomposite configuration entirely. The Pico boots in milliseconds, requires no bash initialization scripts, and draws roughly 20mA compared to the Zero 2 W's 120mA+ idle draw.






