Turning an SBC's OTG port into a real USB keyboard — and the three traps that ate days of mine
A $20 single-board computer can pretend to be a USB keyboard the host OS can't tell from real hardware — but only after you survive the descriptor cache, the layout-on-the-host gotcha, and Windows silently dropping the first keystroke. Here's the working setup, the three traps, and the warm-up that fixes them.
Turning an SBC's OTG port into a real USB keyboard — and the three traps that ate days of mine
Days. That's the honest count. Building a USB HID keyboard out of a single-board computer's OTG port for GlyphDeck was one of those tasks where every layer of the stack worked exactly as documented — and the integration of all of them, on a Radxa Zero 3W running Armbian, talking to a Windows host, did not. Three different traps cost roughly a day each. None of them are in the kernel docs. All of them are in this post.
If you're standing one of these up for the first time, budget a day for cache nonsense and a day for warm-up nonsense and you'll come out the other side with a programmable USB keyboard a host PC genuinely cannot distinguish from a Logitech.
Why OTG gadget mode
Most modern SBCs — Raspberry Pi Zero/4/5, Radxa Zero series, Orange Pi Zero, etc. — expose at least one USB OTG port. A regular USB-A or USB-C port on a host PC is a host port: it enumerates devices. The OTG port can be flipped to act as a device port, enumerated by something else.
The Linux kernel exposes this through the libcomposite driver and configfs. You build a USB device descriptor at runtime by writing files into /sys/kernel/config/usb_gadget/. There is no compilation step, no out-of-tree module, no dpkg of a vendor blob. It's all in mainline.
flowchart LR
SBC[SBC<br/>OTG port] -- USB cable --> Host[Host PC<br/>USB host controller]
SBC -.runs.-> Gadget[libcomposite<br/>+ usb_f_hid]
Gadget -.creates.-> Node["/dev/hidg0<br/>(write 8-byte reports here)"]
Host -.sees.-> Driver[Generic HID<br/>Keyboard Driver]
Once /dev/hidg0 exists on the SBC, anything you write to it gets delivered to the host as a HID report. That's the entire trick. The rest of this post is what bites you when you actually try to ship one.
Setting up the gadget
Armbian ships libcomposite as a module but configures no gadget by default. Wire one up with a script that runs at boot. Minimum-viable HID keyboard gadget:
#!/bin/bash
# /usr/local/sbin/usb-hid-gadget.sh
set -e
modprobe libcomposite
cd /sys/kernel/config/usb_gadget/
mkdir -p g1
cd g1
# --- Identifiers (this is where the trouble starts — see below)
echo 0x1d6b > idVendor # Linux Foundation
echo 0x0104 > idProduct # Multifunction Composite Gadget
echo 0x0100 > bcdDevice
echo 0x0200 > bcdUSB
mkdir -p strings/0x409
echo "0123456789" > strings/0x409/serialnumber
echo "GlyphDeck" > strings/0x409/manufacturer
echo "HID Macro Pad" > strings/0x409/product
mkdir -p configs/c.1/strings/0x409
echo "HID Config" > configs/c.1/strings/0x409/configuration
echo 250 > configs/c.1/MaxPower
# --- The HID function
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 # 8 bytes per report
# Standard HID Boot Protocol 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
ln -s functions/hid.usb0 configs/c.1/
# --- Bind to the UDC (USB Device Controller)
ls /sys/class/udc > UDC
# --- Make the device node writable by users in dialout
chmod 0660 /dev/hidg0
chgrp dialout /dev/hidg0
Wrap it in a systemd unit so it runs once at boot and the gadget survives until shutdown:
# /etc/systemd/system/usb-hid-gadget.service
[Unit]
Description=USB HID Keyboard Gadget
After=systemd-modules-load.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/usb-hid-gadget.sh
[Install]
WantedBy=multi-user.target
systemctl enable --now usb-hid-gadget.service, and ls /dev/hidg0 should show the node. Plug into a host. If the host's Device Manager (Windows) or lsusb (Linux) reports a "GlyphDeck HID Macro Pad" — the configfs side is done.
That's the easy half.
Trap 1: the identifier mess
The two lines that look most innocent are the first ones to bite:
echo 0x1d6b > idVendor
echo 0x0104 > idProduct
These are the USB vendor and product IDs. The host OS uses them — combined with the manufacturer/product strings — to:
- Pick a driver.
- Cache that decision in its driver database.
- Decide whether to show "new device found" toasts.
Vendor IDs are not free. 0x1d6b belongs to the Linux Foundation, and you do not own it. For a hobby project on your own machine, fine. For anything you ship, the options are: license a vendor ID from the USB-IF (currently US$6,000), use a sub-allocation from a vendor that resells (pid.codes for open hardware, free), or pick something and hope nobody notices. Most SBC keyboard projects use 0x1d6b:0x0104 because that's what the kernel example uses and the Linux Foundation has not historically chased anyone.
The deeper problem is collisions in the host's driver cache. Build two different gadgets on the same SBC — a keyboard today, a mouse next week — and reuse the same VID/PID, and Windows in particular caches the first descriptor it sees against that pair. It may then refuse to re-enumerate cleanly until you uninstall the device from Device Manager. The serial-number string is supposed to disambiguate, but several Windows versions ignore it for HID class devices.
Practical rules:
- Bump
idProduct(orbcdDevice) every time the descriptor materially changes. Adding an interface, changing the report descriptor, changing the report length — all of these are descriptor changes. - Set a real serial number string (
strings/0x409/serialnumber). Some hosts honour it. Linux always does. - Test on Windows, macOS, and Linux before shipping. They cache differently and fail differently.
flowchart TD
Plug[Cable plugged in] --> Enum[Host reads VID/PID]
Enum --> Cache{In driver<br/>cache?}
Cache -- yes --> Reuse[Reuse cached driver +<br/>cached descriptor]
Cache -- no --> Read[Read full descriptor<br/>from device]
Reuse --> Mismatch{Cached descriptor<br/>matches device?}
Mismatch -- no --> Broken[Device misbehaves<br/>silently]
Mismatch -- yes --> Works[Works]
Read --> Works
Broken -.fix.-> Bump[Bump bcdDevice<br/>or idProduct]
If you've ever had a gadget "work on a fresh machine but break on the dev box," this cache is why.
Trap 2: layouts live on the host, not the device
A USB HID keyboard does not send characters. It sends scan codes — opaque numbers meaning "the key in this physical position was pressed." The host OS maps scan codes to characters using its active keyboard layout.
Consequences:
- Sending HID usage
0x14(the key labelledQon a US QWERTY keyboard) producesQon a US-layout host butAon an AZERTY French host. - There is no portable way to send "the letter Q." You send a position and hope the host is configured the way you expect.
- Your gadget cannot query the host's layout. USB HID is unidirectional for this purpose.
The HID Boot Protocol report is fixed at 8 bytes:
Byte 0 Modifier bitmask
0x01 LeftCtrl 0x02 LeftShift 0x04 LeftAlt 0x08 LeftGUI (Win/Cmd)
0x10 RightCtrl 0x20 RightShift 0x40 RightAlt 0x80 RightGUI
Byte 1 Reserved (always 0x00)
Bytes 2-7 Up to six simultaneous key usage codes (USB HID Usage Page 0x07).
0x00 in any slot means "no key in this slot."
Common usage codes (Usage Page 0x07):
| Key | Code | Key | Code |
|---|---|---|---|
a–z |
0x04–0x1D |
Enter |
0x28 |
1–9 |
0x1E–0x26 |
Escape |
0x29 |
0 |
0x27 |
Backspace |
0x2A |
Tab |
0x2B |
Space |
0x2C |
F1–F12 |
0x3A–0x45 |
Arrow Right |
0x4F |
A press is two reports: one with the key held, then one with all zeros to release it. Send a key-down without the matching release and the host thinks the key is stuck — most OSes will then begin typematic auto-repeat, exactly like holding a real key down.
Sending keys from the shell
Once /dev/hidg0 exists, every 8-byte block written to it becomes one HID report. Single a:
# Type a single 'a' key (usage 0x04, no modifier)
printf '\x00\x00\x04\x00\x00\x00\x00\x00' > /dev/hidg0 # press
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0 # release
That's it. The host PC sees a. Now Shift+A:
# Modifier byte: 0x02 = LeftShift; key: 0x04 = a
printf '\x02\x00\x04\x00\x00\x00\x00\x00' > /dev/hidg0
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0
Ctrl+Alt+Del:
# 0x01 LeftCtrl | 0x04 LeftAlt = 0x05; key 0x4C = Delete
printf '\x05\x00\x4c\x00\x00\x00\x00\x00' > /dev/hidg0
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0
A small helper that types whole strings:
#!/bin/bash
# /usr/local/bin/hidtype - type an ASCII string via /dev/hidg0
HIDG=/dev/hidg0
DELAY=0.015 # 15 ms between keystrokes — most hosts accept this fine
# Map a single character to "modifier:usage" pair
char_to_hid() {
local c="$1"
case "$c" in
[a-z]) printf '0x00:0x%02x' $(( $(printf '%d' "'$c") - 97 + 4 )) ;;
[A-Z]) printf '0x02:0x%02x' $(( $(printf '%d' "'$c") - 65 + 4 )) ;;
[1-9]) printf '0x00:0x%02x' $(( $(printf '%d' "'$c") - 49 + 0x1e )) ;;
'0') printf '0x00:0x27' ;;
' ') printf '0x00:0x2c' ;;
$'\n') printf '0x00:0x28' ;;
$'\t') printf '0x00:0x2b' ;;
'!') printf '0x02:0x1e' ;;
'@') printf '0x02:0x1f' ;;
'#') printf '0x02:0x20' ;;
*) return 1 ;;
esac
}
send_report() {
local mod="$1" usage="$2"
printf "$(printf '\\x%02x\\x00\\x%02x\\x00\\x00\\x00\\x00\\x00' "$mod" "$usage")" > "$HIDG"
}
release() {
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > "$HIDG"
}
text="$1"
for (( i=0; i<${#text}; i++ )); do
pair=$(char_to_hid "${text:$i:1}") || continue
mod="${pair%:*}"
use="${pair#*:}"
send_report "$mod" "$use"
release
sleep "$DELAY"
done
Used as:
sudo hidtype "Hello World"
sudo hidtype "user@example.com"$'\n'
Trap 3: Windows drops the first keystroke
This is the one that ate the second day. Gadget set up correctly. hidtype "hello world" to a Linux box produces hello world. To macOS, hello world. To a Windows box, ello world.
This is real, reproducible, and has been around for years across Windows 10 and 11. The first non-zero HID report after a fresh device opens is silently dropped by Windows' HID stack. The mechanism is undocumented; the working theory is that Windows uses the first report to establish endpoint timing and discards the payload while it's doing so.
The fix is a warm-up sequence before any real input:
sequenceDiagram
participant App as Your code
participant HIDG as /dev/hidg0
participant Win as Windows HID stack
Note over App,Win: First open after enumeration
App->>HIDG: 4× zero reports (4× 8 zero bytes)
HIDG->>Win: 4 empty HID reports
Note over Win: stack settles,<br/>first-report bug "consumes"<br/>one of these zeros
App->>HIDG: Shift down
HIDG->>Win: modifier=0x02, no key
App->>HIDG: Shift up
HIDG->>Win: modifier=0x00
Note over App: sleep 150 ms
App->>HIDG: actual keystrokes
HIDG->>Win: real input — all delivered
In code:
warmup() {
# 4 zero reports
for _ in 1 2 3 4; do
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0
done
# Shift nudge — modifier-only press and release
printf '\x02\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0
printf '\x00\x00\x00\x00\x00\x00\x00\x00' > /dev/hidg0
sleep 0.15
}
warmup
hidtype "hello world" # arrives intact on Windows
The Shift nudge is necessary as well as the zero reports — zeros alone don't always shake the stack loose. Shift is chosen because it's a modifier with no observable side effect on the host (no character is typed, no menu opens, no application reacts). Caps Lock would also work but it has visible state. Don't use a regular key for the warm-up; you'll see it appear in whatever app has focus.
You only need the warm-up once per /dev/hidg0 open, not once per keystroke. If your daemon keeps the device open for the lifetime of the process, warm up at startup and never again until reconnect.
Putting it together
A production-ish daemon outline in C# / .NET 10 — the language GlyphDeck is written in:
public sealed class HidController : IDisposable
{
private readonly FileStream _hidg;
public HidController(string devicePath = "/dev/hidg0")
{
_hidg = new FileStream(devicePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
WarmUpForWindows();
}
private void WarmUpForWindows()
{
var zero = new byte[8];
for (int i = 0; i < 4; i++) _hidg.Write(zero, 0, 8);
_hidg.Flush();
var shiftDown = new byte[] { 0x02, 0, 0, 0, 0, 0, 0, 0 };
_hidg.Write(shiftDown, 0, 8);
_hidg.Write(zero, 0, 8);
_hidg.Flush();
Thread.Sleep(150);
}
public void SendKey(byte usage, byte modifier = 0, int holdMs = 15)
{
var report = new byte[] { modifier, 0, usage, 0, 0, 0, 0, 0 };
_hidg.Write(report, 0, 8);
_hidg.Flush();
Thread.Sleep(holdMs);
var release = new byte[8];
_hidg.Write(release, 0, 8);
_hidg.Flush();
}
public void Dispose() => _hidg.Dispose();
}
Bottom line
A $20 SBC plus 60 lines of shell is a programmable USB keyboard the host cannot tell from real hardware. The kernel side is delightfully thin. The host side is where the days go.
/dev/hidg0is just a file. Anything that writes 8 bytes at a time can drive it — bash, Python, C, Rust, your favourite scripting language. The kernel handles the USB layer.- The host owns the layout. You send positions, not characters. Build your character-to-usage table for whichever layout you target and document the assumption.
- Bump the device version on every descriptor change. Host driver caches will bite you otherwise, especially on Windows.
- Always warm up on Windows. Four zero reports plus a Shift nudge plus 150 ms of patience. Skip this and your users will report "the first letter of every macro is missing" and you'll spend a week not believing them.
- Permissions matter.
/dev/hidg0is created root-owned and0600by default. Either run your daemon as root, orchgrp dialoutit and add the user to that group. - One open at a time. Multiple processes writing to
/dev/hidg0simultaneously will produce torn frames. Mediate access through a single daemon.
Every project that ships this rediscovers the cache and the warm-up independently. Now you don't have to.
-EG_
// comments