Why Embed a Raspberry Pi in a Keyboard?

Putting a Raspberry Pi in a keyboard chassis to act as a USB Human Interface Device (HID) bridges the gap between simple microcontroller macro pads and full-fledged Linux computing. While an Arduino or RP2040 can send keystrokes, a Linux-based Pi Zero allows you to trigger complex bash scripts, interact with network APIs, or manage clipboard buffers directly from your keystrokes before passing them to the host PC.

The direct answer to how this works: you use the Raspberry Pi Zero 2 W (the target board for this build) and configure its USB OTG (On-The-Go) port as a Linux HID gadget using the libcomposite kernel module. The Pi then appears to your host computer as a standard USB keyboard, while Python scripts running on the Pi read physical GPIO switches and write raw HID byte reports to the virtual USB endpoint.

Parts List & Spec Sheet

This build relies on the Pi Zero 2 W because it supports native USB OTG gadget mode out of the box, unlike the full-sized Pi 4 or Pi 5 which require specialized hardware multiplexing to act as USB devices.

Component Exact Variant / Model Notes & Pricing (Approx.)
Microcontroller Raspberry Pi Zero 2 W (with pre-soldered headers) $15 - $20. Must be the "2 W" for adequate RAM to run Python + OS.
Switches Cherry MX Brown (MX1A-L1NN) 4x switches. Tactile bump, 55g actuation. (~$1.00/ea)
Keycaps Standard MX-compatible 1U keycaps Any standard profile (OEM, Cherry, SA).
USB Cable Micro-USB to USB-A Data Cable Critical: Must have data lines (D+/D-), not a charge-only cable.
Chassis Custom 3D printed plate (STL) or acrylic sandwich 1.5mm thick top plate for MX switch friction fit.
Wiring 28 AWG silicone stranded wire Flexible, easy to solder to switch pins.

Wiring the Pi Zero 2 W to Mechanical Switches

Mechanical switches are simple momentary pushbuttons. We wire one side of each switch to a dedicated GPIO pin and the other side to a common Ground (GND). The Pi's internal pull-up resistors will keep the pins HIGH until a switch is pressed, pulling the circuit to GND (LOW).

Pin Mapping Table

Switch Function Cherry MX Pin Pi Zero 2 W GPIO (BCM) Pi Physical Pin
Macro 1 (Key 'A') Pin 1 (Signal) GPIO 17 Pin 11
Macro 2 (Key 'B') Pin 1 (Signal) GPIO 27 Pin 13
Macro 3 (Key 'C') Pin 1 (Signal) GPIO 22 Pin 15
Macro 4 (Key 'D') Pin 1 (Signal) GPIO 23 Pin 16
Common Ground Pin 2 (All Switches) GND Pin 9 (or any GND)
Callout Tip: Switch Orientation
Cherry MX switches have two metal pins. Pin 1 is typically the left pin when looking at the top of the switch with the LED slot at the top. However, for basic two-pin mechanical switches without built-in LEDs, polarity does not matter. Just ensure all Pin 2s are daisy-chained to your common GND wire.

Configuring Linux USB Gadget Mode

Before the Python script can run, the Pi Zero 2 W must be told to act as a USB device rather than a USB host. This requires editing the boot configuration and loading the dwc2 and libcomposite kernel modules. For a deep dive into the kernel-level HID report descriptors, refer to the Linux Kernel HID Gadget Documentation.

  1. Enable dwc2 overlay: Open /boot/firmware/config.txt (or /boot/config.txt on older OS versions) and add dtoverlay=dwc2 to the bottom.
  2. Load modules at boot: Open /boot/firmware/cmdline.txt. Find the word rootwait and insert modules-load=dwc2,libcomposite immediately after it, separated by spaces. Do not add line breaks.
  3. Create the Gadget: You must run a bash script on boot (via rc.local or a systemd service) that creates the gadget directory in /sys/kernel/config/usb_gadget/, sets the Vendor/Product IDs, defines the 8-byte HID report descriptor for a standard keyboard, and binds it to the UDC (USB Device Controller).

Once the gadget script runs successfully, the Pi will create the virtual endpoint file at /dev/hidg0. This is the file our Python script will write to.

The Python HID Controller Script

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 endpoint. For more on the gpiozero Button API, see the gpiozero documentation.

import os
import time
from gpiozero import Button
from signal import pause

# Pin definitions (BCM numbering) for the 4 macro switches
BTN_PINS = [17, 27, 22, 23]
HID_FILE = "/dev/hidg0"

# Standard HID Keycodes (Keyboard/Keypad Page 0x07)
# 0x04 = 'a', 0x05 = 'b', 0x06 = 'c', 0x07 = 'd'
KEYCODES = [0x04, 0x05, 0x06, 0x07]

def send_keystroke(fd, keycode):
    """Sends an 8-byte HID keyboard report and then a release report."""
    # Report format: [modifiers, reserved, key1, key2, key3, key4, key5, key6]
    report = bytes([0x00, 0x00, keycode, 0x00, 0x00, 0x00, 0x00, 0x00])
    os.write(fd, report)
    time.sleep(0.05) # Hold time to ensure host registers the press
    
    # Release key (all zeros)
    empty_report = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
    os.write(fd, empty_report)

def main():
    # 1. Attempt to open the HID gadget endpoint
    try:
        fd = os.open(HID_FILE, os.O_RDWR | os.O_NONBLOCK)
    except FileNotFoundError as e:
        print(f"FATAL: {e}. Is the libcomposite gadget loaded and bound to UDC?")
        return
    except PermissionError as e:
        print(f"FATAL: {e}. Run with sudo or add a udev rule for /dev/hidg0.")
        return

    # 2. Initialize GPIO buttons with internal pull-ups and 50ms debounce
    buttons = [Button(pin, pull_up=True, bounce_time=0.05) for pin in BTN_PINS]

    # 3. Attach event handlers
    for i, btn in enumerate(buttons):
        # Use lambda with default arguments to capture the current index and fd
        btn.when_pressed = lambda fd=fd, kc=KEYCODES[i]: send_keystroke(fd, kc)

    print("Raspberry Pi Keyboard HID active. Press Ctrl+C to exit.")
    
    # 4. Keep script running
    try:
        pause()
    except KeyboardInterrupt:
        print("\nShutting down HID macro pad...")
    finally:
        os.close(fd)

if __name__ == "__main__":
    main()

Debugging: First Three Things to Check When It Fails

Linux USB gadget mode is notoriously fragile during setup. If your host PC doesn't recognize the keyboard or the script crashes, check these three exact failure modes in order.

1. The "No such file or directory" Error

Exact Error String: FileNotFoundError: [Errno 2] No such file or directory: '/dev/hidg0'

  • Cause A (Most Likely): The libcomposite bash setup script failed to run, or the UDC binding step (ls /sys/class/udc > UDC) threw an I/O error.
  • Cause B: You forgot to add modules-load=dwc2,libcomposite to cmdline.txt, meaning the kernel doesn't know how to create the configfs gadget directory.
  • Fix: Run dmesg | grep dwc2 to check for driver initialization errors. Ensure your gadget setup script has execute permissions (chmod +x) and is actually being called by rc.local or systemd.

2. The "Permission Denied" Error

Exact Error String: PermissionError: [Errno 13] Permission denied: '/dev/hidg0'

  • Cause: The /dev/hidg0 endpoint is created by root during the boot sequence. Your standard user account (pi) does not have write access to raw device files.
  • Fix: Run the Python script with sudo, or better yet, create a udev rule in /etc/udev/rules.d/99-hidg.rules containing: KERNEL=="hidg0", SUBSYSTEM=="usb", MODE="0666" to grant all users read/write access.

3. Host PC Shows "USB Device Not Recognized"

Symptom: The Python script runs without errors, but typing on the host PC yields nothing, or Windows chimes with a device failure warning.

  • Cause A (Hardware): You are using a charge-only Micro-USB cable. These cables lack the D+ and D- data wires required for USB enumeration.
  • Cause B (Hardware): You plugged the cable into the PWR IN port on the Pi Zero instead of the USB (OTG) port. The PWR port only accepts power; it cannot transmit data.
  • Cause C (Software): Your HID Report Descriptor in the libcomposite setup script is malformed. The host OS rejects the device during enumeration if the descriptor doesn't perfectly match the 8-byte payload your Python script sends.

Extending and Simplifying the Build

Depending on your end goal, you may want to scale this project up or strip it down.

How to Simplify: Switch to a Raspberry Pi Pico
If you don't actually need a full Linux environment running on your macro pad, ditch the Pi Zero 2 W and use a Raspberry Pi Pico ($4). The Pico is a microcontroller, not a Linux SBC. It supports native USB HID via CircuitPython or MicroPython without any libcomposite kernel hacking. You simply import usb_hid and send keystrokes. It boots instantly, requires no OS maintenance, and is significantly easier to debug.

How to Extend: Add an I2C OLED Display
To turn this into a stream deck or layer-indicator pad, wire a 128x64 SSD1306 OLED display to the Pi Zero's I2C bus (SDA to GPIO 2 / Pin 3, SCL to GPIO 3 / Pin 5). Because you have a full Linux OS running, you can use the luma.oled Python library to render dynamic text, CPU stats, or Discord notifications directly on the keyboard chassis.

Frequently Asked Questions

Can I use a Raspberry Pi 4 or 5 instead of the Zero 2 W for a keyboard?

Not natively. The Raspberry Pi 4 and 5 use a dedicated USB host controller chip (like the VL805 or RP1) that does not support USB Device/Gadget mode out of the box. The Pi Zero series (and the original Pi 1 Model A) route the SoC's native USB OTG lines directly to the micro-USB port, which is a hardware requirement for libcomposite to work. If you want to put a Pi 4 inside a keyboard, it must act as the host computer itself, not as a peripheral keyboard to another PC.

How do I add a rotary encoder to this Raspberry Pi keyboard build?

A rotary encoder (like the EC11) outputs quadrature signals. You can wire the CLK and DT pins to two free GPIO pins (e.g., GPIO 5 and 6) and use the gpiozero RotaryEncoder class. Instead of writing a standard keyboard HID report, you would map the encoder's clockwise and counter-clockwise events to HID Consumer Control codes (like Volume Up 0xE9 and Volume Down 0xEA), which requires adding a second HID function (hid.usb1) to your libcomposite setup script.

Is a Raspberry Pi Pico better than a Pi Zero for keyboard builds?

For 90% of macro pad builds, yes. The Pico is cheaper, draws less power, boots in milliseconds, and has native USB HID support in silicon. The Pi Zero 2 W is only "better" if your macros require heavy lifting that a microcontroller can't handle—such as executing local Python scripts, scraping web APIs, interacting with a local database, or running complex audio triggers before sending a keystroke to the host PC.