Make a Pi Zero Act as a USB Keyboard with configfs HID Gadget

Because the Pi Zero's data port can work as a USB device, the Zero can pretend to be a keyboard. Plug it into any computer and the computer sees a standard USB keyboard, with no driver to install. Your Python program then decides what is typed. This is the basis of macro pads, accessibility switches, automatic test rigs that type into a machine under test, and password-free demo kiosks.

In this advanced tutorial you will build a HID keyboard gadget with the kernel's libcomposite framework and configfs, start it at boot with systemd, and write a Python program that turns a push button on GPIO27 into a key that types a full line of text. Along the way you will learn how USB HID keyboard reports are structured.

How a USB Keyboard Talks to the Computer

A USB keyboard belongs to the Human Interface Device (HID) class. When it is plugged in, it sends a report descriptor that tells the host the format of its data. The standard boot keyboard format uses an 8-byte report sent every time the key state changes.

ByteMeaningExample
0Modifier bitmap: bit 0 Left Ctrl, bit 1 Left Shift, bit 2 Left Alt, bit 3 Left GUI, bits 4 to 7 the right-hand versions0x02 = Left Shift held
1Reserved, always 00x00
2 to 7Up to six usage codes of keys currently pressed0x04 = A, 0x28 = Enter

Usage codes come from the USB HID Usage Tables: letters a to z are 0x04 to 0x1D, digits 1 to 9 are 0x1E to 0x26, 0 is 0x27, Enter is 0x28, Backspace 0x2A, Tab 0x2B and Space 0x2C. To type a capital A you send 02 00 04 00 00 00 00 00 (Shift plus A) and then an all-zero report to release the key. Without the release report, the host thinks the key is still held and auto-repeats it.

Note that the keyboard sends key positions, not characters. The host's keyboard layout setting turns positions into characters, so the code in this tutorial assumes the host uses the US English layout, which is the default on most Indian computers.

Creating the Gadget with configfs

Unlike the ready-made g_ether module, libcomposite lets you build a device from functions by creating directories and writing values under /sys/kernel/config/usb_gadget/. If you followed the Ethernet gadget tutorial, first remove modules-load=dwc2,g_ether from cmdline.txt, because g_ether would claim the USB controller. Keep dtoverlay=dwc2 in config.txt.

One-time setup: enable dwc2 and load libcomposite at boot
# USB controller overlay (skip if already present)
grep -q '^dtoverlay=dwc2' /boot/firmware/config.txt || echo 'dtoverlay=dwc2' | sudo tee -a /boot/firmware/config.txt

# Load the controller driver and gadget framework at boot
echo 'dwc2' | sudo tee -a /etc/modules
echo 'libcomposite' | sudo tee -a /etc/modules
/usr/local/bin/hid-keyboard-gadget.sh: build and bind the keyboard gadget
#!/bin/bash
# Create a USB HID boot keyboard gadget using configfs. Run as root.
set -e
modprobe libcomposite

G=/sys/kernel/config/usb_gadget/pizero_kbd
[ -d "$G" ] && exit 0          # already configured
mkdir -p "$G"
cd "$G"

echo 0x1d6b > idVendor          # Linux Foundation
echo 0x0104 > idProduct         # Multifunction Composite Gadget
echo 0x0100 > bcdDevice         # device version 1.0.0
echo 0x0200 > bcdUSB            # USB 2.0

mkdir -p strings/0x409          # 0x409 = English (US)
echo 'PZ0001'           > strings/0x409/serialnumber
echo 'ProjectsLearner'  > strings/0x409/manufacturer
echo 'Pi Zero Keyboard' > strings/0x409/product

mkdir -p configs/c.1/strings/0x409
echo 'HID keyboard' > configs/c.1/strings/0x409/configuration
echo 250 > configs/c.1/MaxPower # in mA, reported to the host

# HID function: boot-protocol keyboard with 8-byte reports
mkdir -p functions/hid.usb0
echo 1 > functions/hid.usb0/protocol       # 1 = keyboard
echo 1 > functions/hid.usb0/subclass       # 1 = boot interface
echo 8 > functions/hid.usb0/report_length
# Standard keyboard report descriptor (from the Linux kernel gadget docs)
printf '\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

ln -s functions/hid.usb0 configs/c.1/

# Bind to the USB device controller; the host now sees a keyboard
ls /sys/class/udc > UDC
/etc/systemd/system/hid-gadget.service: create the gadget at every boot
[Unit]
Description=USB HID keyboard gadget
After=systemd-modules-load.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/hid-keyboard-gadget.sh

[Install]
WantedBy=multi-user.target

Make the script executable with sudo chmod +x /usr/local/bin/hid-keyboard-gadget.sh, enable the unit with sudo systemctl daemon-reload and sudo systemctl enable hid-gadget.service, then shut down and connect the Zero's USB port to the target computer. After boot, /dev/hidg0 exists on the Zero and the computer lists a new keyboard.

The Macro Pad Program

Wire a push button between GPIO27 (physical pin 13) and GND. The program below types a line of text each time the button is pressed. Run it with sudo python3 macro_pad.py, because /dev/hidg0 is owned by root, or start it from a root systemd service.

macro_pad.py: type a text string through /dev/hidg0 when a button is pressed
#!/usr/bin/env python3
"""One-button macro pad: Pi Zero as a USB HID keyboard (US layout host)."""
import string
import time
from signal import pause

from gpiozero import Button

HID_DEVICE = '/dev/hidg0'
TEXT = 'Hello from Pi Zero! 2 + 2 = 4\n'
SHIFT = 0x02  # Left Shift bit in the modifier byte

# Build the character -> (modifier, usage code) table
KEYMAP = {}
for i, ch in enumerate(string.ascii_lowercase):
    KEYMAP[ch] = (0, 0x04 + i)
    KEYMAP[ch.upper()] = (SHIFT, 0x04 + i)
for i, ch in enumerate('1234567890'):
    KEYMAP[ch] = (0, 0x1E + i)
KEYMAP.update({
    '\n': (0, 0x28), '\t': (0, 0x2B), ' ': (0, 0x2C),
    '-': (0, 0x2D), '=': (0, 0x2E), ';': (0, 0x33),
    ',': (0, 0x36), '.': (0, 0x37), '/': (0, 0x38),
    '!': (SHIFT, 0x1E), '@': (SHIFT, 0x1F), '_': (SHIFT, 0x2D),
    '+': (SHIFT, 0x2E), ':': (SHIFT, 0x33), '?': (SHIFT, 0x38),
})

RELEASE = bytes(8)  # all zeros = no keys pressed

def type_text(text):
    with open(HID_DEVICE, 'wb') as hid:
        for ch in text:
            if ch not in KEYMAP:
                continue  # skip characters we have no mapping for
            modifier, code = KEYMAP[ch]
            hid.write(bytes([modifier, 0, code, 0, 0, 0, 0, 0]))  # press
            hid.flush()
            hid.write(RELEASE)                                     # release
            hid.flush()
            time.sleep(0.01)  # small gap so slow hosts do not drop keys

button = Button(27, bounce_time=0.05)
button.when_pressed = lambda: type_text(TEXT)
print('Macro pad ready; press the button.')
pause()

Every character becomes two reports: a press with the right modifier and usage code, followed by a release. Releasing between characters matters for repeated letters such as the double l in Hello; without a release in between, the host would see one long press instead of two separate presses.

The 10 ms pause after each character keeps typing reliable on slow hosts and inside virtual machines, giving roughly a hundred characters per second at most, which is still far faster than a person types. To send a shortcut such as Ctrl+Alt+T, combine modifier bits in byte 0: Left Ctrl is 0x01 and Left Alt is 0x04, so the report is 05 00 17 00 00 00 00 00, where 0x17 is the usage code for T, followed by the usual release report.

Troubleshooting and Responsible Use

  • No /dev/hidg0: check journalctl -u hid-gadget and lsmod | grep -E 'dwc2|libcomposite'. Make sure g_ether is no longer loaded.
  • Writing to /dev/hidg0 blocks or fails: the Zero is not connected to a host, or is plugged into PWR IN instead of USB.
  • Wrong symbols typed: the host is not using the US layout. Change the host layout or adapt the key map.
  • Script fails if run twice: the gadget directory already exists; the script exits early in that case. Reboot to rebuild it from scratch.

Summary

With dtoverlay=dwc2, libcomposite and a short configfs script, a Pi Zero presents itself as a standard USB boot keyboard, and any program can type by writing 8-byte reports to /dev/hidg0. The same configfs approach can add further functions, such as a mouse or a serial port, to one composite gadget.