Teaching a Raspberry Pi to Speak Keyboard

usb
hardware
linux
raspberry-pi
Author

Kris Kwiatkowski

Published

June 24, 2025

Abstract

Getting a Raspberry Pi to sit between a keyboard and a PC: reading HID reports, logging them, and passing them through. A USB man-in-the-middle, and an evening’s tour of the USB stack, AppArmor, and dash’s printf.

I spent an evening getting a Raspberry Pi to sit between a keyboard and a PC: reading what the keyboard sends, logging it, and passing it through so the PC still works normally. It’s the hardware equivalent of a man-in-the-middle proxy, except for USB HID instead of TCP. Along the way I learned more about the USB stack, AppArmor, and dash’s printf than I expected. This is the writeup.

The report format

A boot-protocol keyboard sends fixed 8-byte reports on an interrupt IN endpoint. Byte 0 is a modifier bitmask, byte 1 is reserved, and bytes 2 to 7 hold up to six usage IDs for keys currently held down.

byte:  0        1    2  3  4  5  6  7
       mods     --   k1 k2 k3 k4 k5 k6

mods bits: 0x01 LCtrl  0x02 LShift  0x04 LAlt  0x08 LGui
           0x10 RCtrl  0x20 RShift  0x40 RAlt  0x80 RGui

Usage IDs aren’t ASCII. 'a' is 0x04, the digit row '1'..'0' runs 0x1e..0x27, Enter is 0x28. The important subtlety is that a held key appears in every report until release, so decoding each packet blindly gives you aaaaa. You diff against the previous report and only emit newly pressed keys.

prev = set()
for report in stream:
    keys = {k for k in report[2:8] if k > 3}
    for k in keys - prev:
        emit(k)
    prev = keys

That’s the whole decoder, modulo a usage-to-character table and the shift bit.

A detour through Logitech

My keyboard is wireless, so the PC doesn’t see a boot keyboard at all. It sees a Logitech receiver, which wraps keystrokes in its own DJ reports: 15 bytes, report ID 0x20, a device index, a type byte (0x01 for keyboard), and then the familiar boot report starting at byte 3 — minus the reserved byte. Worth knowing if you ever stare at a capture wondering why the reports are the wrong length.

The practical upshot: the receiver exposes several /dev/hidrawN nodes with the same product name, and only one carries the key reports. Dump each one with xxd while typing and keep the node that emits 01 00 0d ... on a keypress.

Making the Pi a keyboard

The read side is easy on Linux: the keyboard shows up as /dev/hidrawN and you read raw reports. The clever part is the Pi pretending to be a keyboard to the downstream PC. That’s USB gadget mode via configfs: you declare a HID function, give it a report descriptor, bind it to the USB device controller, and a /dev/hidg0 appears. Anything you write to that node, the PC receives as keystrokes.

On a Pi 4 there’s a hardware catch worth knowing before you wire anything: only the USB-C port can act as a device. The four USB-A ports hang off an onboard host-only hub chip and can never be a gadget. So the data link to the PC has to come out of USB-C.

#!/bin/bash
# run as: sudo bash gadget.sh
set -e
G=/sys/kernel/config/usb_gadget/kbd

modprobe libcomposite
mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config

mkdir -p "$G" && cd "$G"
echo 0x1d6b > idVendor
echo 0x0104 > idProduct
mkdir -p strings/0x409
echo "Pi keyboard" > strings/0x409/product

mkdir -p functions/hid.usb0
echo 1 > functions/hid.usb0/protocol
echo 1 > functions/hid.usb0/subclass
echo 8 > functions/hid.usb0/report_length
echo 05010906a101050719e029e71500250175019508810295017508810395057501050819012905910295017503910395067508150025650507190029658100c0 \
    | xxd -r -p > functions/hid.usb0/report_desc

mkdir -p configs/c.1
ln -s functions/hid.usb0 configs/c.1/
ls /sys/class/udc > UDC

This needs dtoverlay=dwc2,dr_mode=peripheral in config.txt and modules-load=dwc2 on the kernel command line first, then a reboot. When it’s right, /sys/class/udc lists the controller (fe980000.usb on a Pi 4) and the PC enumerates a new HID keyboard.

The bug that cost me the most time

The PC kept rejecting the gadget:

hid-generic 0003:1D6B:0104: item fetching failed at offset 250/252
hid-generic 0003:1D6B:0104: probe with driver hid-generic failed with error -22

The report descriptor should be 63 bytes. It was 252 — exactly four times too long. The cause: I’d written the descriptor with printf '\x05\x01...' in a script run under sh, and on Raspberry Pi OS sh is dash, whose printf doesn’t understand \x escapes. It had written the literal characters \, x, 0, 5 instead of the byte 0x05. Switching to echo ... | xxd -r -p to materialise the bytes fixed it. Lesson: when a descriptor is a precise length and yours is a clean multiple of it, suspect your encoding, not your bytes.

The forwarder

With a source node and /dev/hidg0 in hand, the bridge is a few lines: read reports from the keyboard, log them, repack to boot format, write them on. The Logitech node gives [report-id, mods, k1..k6], so the repack inserts the reserved byte the boot format expects.

import os, glob, select

name = "Logitech"   # match the keyboard's HID_NAME
nodes = []
for d in glob.glob("/sys/class/hidraw/hidraw*"):
    if name in open(d + "/device/uevent").read():
        nodes.append("/dev/" + os.path.basename(d))

fds = [os.open(n, os.O_RDONLY) for n in nodes]
out = os.open("/dev/hidg0", os.O_WRONLY)

with open("/root/sniffer/raw.txt", "a") as log:
    while True:
        ready, _, _ = select.select(fds, [], [])
        for fd in ready:
            r = os.read(fd, 64)
            if len(r) != 8 or r[0] != 0x01:
                continue
            boot = bytes([r[1], 0]) + r[2:8]   # mods, reserved, 6 keys
            os.write(out, boot)
            log.write(boot.hex() + "\n")
            log.flush()

Run the gadget script and this as two systemd services — the forwarder with Restart=always, since the keyboard’s node may not exist yet at boot and the numbering shifts on replug.

Two traps that aren’t code

AppArmor. On Ubuntu, tshark ships with a profile that blocks reading a pcap from your home directory. The symptom is a flat permission denied on a file you own and have chmod 777’d, which sends you chasing the wrong thing. sudo dmesg | grep DENIED shows the truth; piping through stdin (cat file | tshark -r -) sidesteps it, or disable the one profile.

Power. The Pi 4 draws its power through the same USB-C port you want for data. Run it off a dock and the dock dies when the host disconnects, taking the Pi with it; power it from the host and you get back-power fights and undervoltage throttling (vcgencmd get_throttled returning 0x50005 — undervolted and throttled, now and since boot). The clean fix is a USB-C power/data splitter that isolates the 5V line from D+/D- and blocks back-power, with the Pi on its own 5V supply. GPIO pin 4 / pin 6 does the same job in a pinch.

Where this is useful

Framed honestly, this is an input bridge. The same machinery underlies a programmable macro keyboard, a hardware KVM’s keyboard path, automated UI testing against real USB input, accessibility remappers, and protocol-level HID debugging. It’s a good way to internalise how USB enumeration, endpoints, and report descriptors actually fit together — you can read about them for a long time without it clicking the way one mangled descriptor makes it click.