Remote USB keyboard and mouse
Controlling a PC with USB gadget shenanigans
I set up a mini-PC near my desk for playing around with UKI images. I mounted a monitor to the wall for it, but rather than add a keyboard and mouse I want to control it from my normal desktop PC. Since I frequently need control at a BIOS or Linux TTY level most high-level solutions will not work.
There are a few out of the box “KVM over IP” solutions for this such as TinyPilot or NanoKVM that translate Keyboard, Video, and Mouse into VNC. Thankfully I don’t need the video part. But I was inspired by how the predecessor to TinyPilot works to build my own solution with the hardware I have. The basic idea is make use of a USB OTG capable device (act as a USB peripheral) and the Linux USB HID gadget driver.
I have an Orange Pi Zero 3 I picked up to use as a secondary DNS server that happens to be close to the mini-PC. First I needed to solve physical USB problems. Many “pis” have this problem where they are primarily powered over a singular USB-C connection, and that is also the only connection capable of acting like a USB peripheral. Thankfully this board also has header pins for 5V and GND so I could power it separately and leave the USB-C connection open for peripheral use. I used a normal USB-C to USB-A cable to the mini-PC, but made sure to cut out the 5V line so as to not cause backfeed problems.

Next is software. When you set up the gadget driver you get a
/dev/hidg0
device you can pipe data into. But you can’t
just echo strings into it. It needs “real” raw HID reports. Guess what
generates raw HID reports every time you hit a key or move your mouse?
The /dev/hidraw*
devices on your local machine. Turns out
you can basically just do this:
cat /dev/hidraw6 | ssh orangepi "cat >/dev/hidg0"
This is peak Linux shit and I am all about it. Sometimes you think “Why wouldn’t this work?” and you try it and it actually just works. Chef’s kiss. No notes.
It does help to set up the gadget driver with the same “HID report descriptor” as your actual device. So I made this script to forward both a keyboard and a mouse over SSH:
~/bin/hidjump
#!/bin/bash
set -eu
list_hidraw() {
FILES=/dev/hidraw*
for f in $FILES; do
FILE=${f##*/}
DEVICE="$(cat /sys/class/hidraw/${FILE}/device/uevent | grep HID_NAME | cut -d '=' -f2)"
printf "%s\t%s\n" $FILE "$DEVICE"
done
}
usage() {
echo "${0} <keyboard hidraw name> <mouse hidraw name> <ssh target>"
echo "i.e. ${0} \"Telink Wireless Gaming Keyboard\" \"Logitech G305\" raspberrypi.local"
echo ""
}
if [ "${1:-nada}" == "-h" ]; then
usage
list_hidraw
exit 1
fi
refresh="false"
if [ "${1:-nada}" == "-r" ]; then
refresh="true"
shift
fi
keyboard="${1:-Telink Wireless Gaming Keyboard}"
mouse="${2:-Logitech G305}"
target=${3:-orangepi}
keyboard=$(list_hidraw | grep -m 1 "${keyboard}" | cut -f1)
mouse=$(list_hidraw | grep -m 1 "${mouse}" | cut -f1)
kbd_desc=$(uuencode -m /sys/class/hidraw/${keyboard}/device/report_descriptor report_desc)
mouse_desc=$(uuencode -m /sys/class/hidraw/${mouse}/device/report_descriptor report_desc)
lang=0x409 # English
vendor_id="0x1d6b" # Linux foundation
product_id="0x0104" # Multifunction Composite Gadget
version="0x0100" # v1.0.0
usb_version="0x0200" # USB2
manufacturer="hidjump"
product="hidjump keyboard/mouse"
gadget_name="hidjump"
# treat multiple launches as toggle
pidfile="/tmp/hidjump.pid"
# there can only be one!
if [ -f "${pidfile}" ]; then
pid=$(cat "${pidfile}")
if ps -p "${pid}" >/dev/null; then
echo "hidjump already running! killing it!"
kill "${pid}"
exit 1
fi
fi
echo $$ >"${pidfile}"
cleanup() {
# kill the whole process group, including the background shells
kill 0
rm -f "${pidfile}"
}
trap cleanup EXIT
ssh -T "${target}" /bin/bash <<EOF
sudo su
cleanup_gadget() {
cd "/sys/kernel/config/usb_gadget/${gadget_name}" || return 0
echo "" >UDC || true
rm -f os_desc/c.1 || true
rm -f configs/c.1/hid.usb0 || true
rm -f configs/c.1/hid.usb1 || true
rmdir configs/c.1/strings/*|| true
rmdir configs/c.1 || true
rmdir functions/hid.usb0 || true
rmdir functions/hid.usb1 || true
rmdir strings/* || true
cd /sys/kernel/config/usb_gadget/
rmdir "${gadget_name}"
rm -f /dev/hidg0
rm -f /dev/hidg1
}
make_gadget() {
mkdir "/sys/kernel/config/usb_gadget/${gadget_name}"
cd "/sys/kernel/config/usb_gadget/${gadget_name}"
echo "${vendor_id}" >idVendor
echo "${product_id}" >idProduct
echo "${version}" >bcdDevice
echo "${usb_version}" >bcdUSB
mkdir -p strings/0x409
echo "0" >strings/0x409/serialnumber
echo "${manufacturer}" >strings/0x409/manufacturer
echo "${product}" >strings/0x409/product
mkdir -p configs/c.1
echo 250 >configs/c.1/MaxPower
# 16 for report length is an educated guess for both....
# Keyboard
mkdir -p functions/hid.usb0
echo 1 >functions/hid.usb0/protocol
echo 1 >functions/hid.usb0/subclass
echo 16 >functions/hid.usb0/report_length
printf "${kbd_desc}" | \
uudecode -o /dev/stdout >functions/hid.usb0/report_desc
ln -s functions/hid.usb0 configs/c.1
# Mouse
mkdir -p functions/hid.usb1
echo 2 >functions/hid.usb1/protocol
echo 0 >functions/hid.usb1/subclass
echo 16 >functions/hid.usb1/report_length
printf "${mouse_desc}" | \
uudecode -o /dev/stdout >functions/hid.usb1/report_desc
ln -s functions/hid.usb1 configs/c.1
ls /sys/class/udc >UDC
}
if [ ! -d "/sys/kernel/config/usb_gadget/${gadget_name}" ] || [ "${refresh}" == "true" ]; then
modprobe libcomposite
cleanup_gadget 2>/dev/null
make_gadget
fi
EOF
(
# Send a blank report to clear out modifiers like Ctrl after interrupt
cat "/dev/${keyboard}" | \
ssh -T "${target}" \
"sudo tee /dev/hidg0 >/dev/null; printf \"%16s\" | tr ' ' '\0' | sudo tee /dev/hidg0 >/dev/null"
) &
(
cat "/dev/${mouse}" | \
ssh -T "${target}" "sudo tee /dev/hidg1 >/dev/null"
) &
wait
cleanup
This works remarkably well. I was worried this might not work at the BIOS level after hearing about HID boot protocol, but thankfully it seems to work just fine (at least for my keyboard and mouse).
It’s not without some jank of course. For one, none of the keyboard and mouse input is being eaten. It goes to both the normal desktop and to the controlled PC, so it’s best to switch to a empty workspace while it’s in effect. I also found quitting the script with Ctrl + C would leave Ctrl “held” on the PC. To solve that I needed to echo a blank report. And because it’s forwarding a specific keyboard and mouse and not “input” generally it doesn’t work in a VNC session.
But that’s fine for me. After all it saves me from rolling three feet in a desk chair.
Addendum
I updated the script to support toggling the daemon on and off, making it very easy to keybind the functionality. Since then I have been using this regularly and ran into a fun little pitfall that nearly had me pulling my hair out.
I’m working a lot with a simple Linux framebuffer console on the controlled mini-PC end. Every now and then I would come back to the console and found it “unresponsive”. It responded to VT shifts i.e. Ctrl + Alt F1 , but typing on terminal echoed no characters. I did notice the cursor blinking pattern change when keys were typed however.
Eventually I tried plugging in an actual keyboard that had the benefit of keyboard LEDs and saw that Scroll Lock was enabled. “Scroll Lock” in a VT setting actually stops the console output and leads to exactly this behavior. Turning it off again solves the issue immediately. I thought it was hung up, but it turned out to just be Scroll Lock.
So then I thought it must be a bug in the script. Perhaps something about not cleaning up quite right when stopping. I don’t even have a Scroll Lock key on my keyboard, so it must be badly interpreting a bad packet.
As it turns out, no. You can also enter scroll lock mode with Ctrl + S (and exit it with Ctrl + Q). Since the keyboard is functional on both computers at once I was frequently working with a text editor on my “main” PC. And every now and then I saved the file. 🤦
- Published
- 2025-05-13
- Updated
- 2025-06-09