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.

Homemade USB-A female to USB-A male right-angle adapter with four removable jumpers. The jumper for the 5V line is not installed.
USB jank comes up a lot in my line of work. I made this little monstrosity years ago.

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