Use a Steam Controller, or a Steam Deck's own controls, on the Linux PC you stream games from. padwire forwards the controller over the network from a Steam Deck or an Android tablet to the host running Sunshine, so Steam there sees a native Steam Controller with full Steam Input, including gyro, trackpads and haptics, while the game streams back over Moonlight.
The host must run Linux. padwire relies on the Linux kernel's built-in USB/IP support (
vhci-hcd) to recreate the controller on the host. Windows and macOS hosts are not supported.
It does the one job people otherwise buy VirtualHere for, using USB/IP: an open protocol whose importing side is already in the Linux kernel. Nothing needs root on the tablet, nothing needs a kernel module on the Deck, and the host runs a stock kernel.
About the name: padwire was developed as usbfwd, and the programs,
services, config paths and Android app still use that working name:
usbfwd-server, usbfwd-attach, /etc/usbfwd/ and so on.
Steam Deck / Android tablet Host (Sunshine + Steam)
┌─────────────────────────────────┐ ┌──────────────────────────┐
│ puck 28de:1304 or wired ctrl │ │ usbfwd-attach (daemon) │
│ ↓ usbfs ioctls │ USB/IP over │ ↓ modprobe + attach │
│ usbfwd-server │──TCP :3240──▶│ vhci-hcd (in-kernel) │
│ · DISCONNECT_CLAIM │ Tailscale, │ ↓ │
│ · SUBMITURB / REAPURBNDELAY │ or a local │ real USB dev, iface 2..5 │
│ │ network │ ↓ hid-generic → hidraw │
└─────────────────────────────────┘ │ Steam Input (full) │
no kernel modules needed └──────────────────────────┘
on the exporting side stock kernel, no patches
usbfwd-server, the exporter, runs where the controller is plugged in. It takes the device away from the local drivers and serves it over USB/IP. It is pure userspace over usbfs, which is what makes it work on SteamOS's read-only root and on Android without root. On Android it runs inside the app.usbfwd-attach, the importer, runs on the host. It attaches the device through the kernel'svhci-hcd, which builds a genuine USB device out of it, and re-attaches whenever the network drops.
The host sees a real USB device with every interface intact. That matters: Steam only accepts the puck's controllers on USB interfaces 2–5, which a virtual HID device cannot provide. The developer notes have the details.
| Device | VID:PID |
|---|---|
| Steam Controller, wired (2026) | 28de:1302 |
| Proteus puck (wireless dongle) | 28de:1304 |
| Nereid internal receiver | 28de:1305 |
| A Steam Deck's own controls | 28de:1205, opt-in — see below |
- Host: Linux, with
vhci-hcd(Fedora:kernel-modules-extra) and theusbiptools, running Steam. Windows and macOS hosts will not work. - Exporter: a Steam Deck, or an Android tablet with USB host support.
- Network: a low-latency path between the two: the same local network, or the same Tailscale tailnet from anywhere. See Network.
- To build: Rust with the
x86_64-unknown-linux-musltarget for the Deck; the Android SDK, NDK andcargo-ndkfor the tablet.
On the machine that runs Sunshine and Steam:
./scripts/host-precheck.shIt is read-only, and reports the kernel, whether vhci-hcd and the usbip
tools are present, whether hid-steam knows the controller's ids, and, if you
use Tailscale, whether it has a direct path.
cargo build --release # the host daemon
./scripts/build-static.sh # static musl binaries for the Deck
make apk # the Android app; see android/README.mdsudo dnf install usbip kernel-modules-extra # Fedora
sudo cp target/release/usbfwd-attach /usr/local/bin/
sudo mkdir -p /etc/usbfwd
sudo cp packaging/usbfwd.toml /etc/usbfwd/
sudoedit /etc/usbfwd/usbfwd.toml # set each exporter's hostname or IP
sudo cp packaging/usbfwd-attach.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now usbfwd-attachUseful without committing to the service:
usbfwd-attach --list # what each configured exporter is offering
usbfwd-attach --once # one attach attempt, then exit
usbfwd-attach --status # what is attached right now
usbfwd-attach --detach-allCopy scripts/install-deck.sh, the static usbfwd-server,
packaging/99-usbfwd.rules and packaging/usbfwd-server.service into one
directory on the Deck, then:
./install-deck.sh --dry-run # what it would do; needs no root
sudo ./install-deck.sh # do itWithout Tailscale, tell it where to listen, e.g. --bind 192.168.1.20 (see
Network).
The exporter runs as a systemd user service, so root is only needed for the
udev rule and loginctl enable-linger. Everything the installer writes
survives SteamOS updates. --uninstall removes it all, and --help lists the
rest (--deck-controls, --prefetch, --allow, …).
Check what it will export, and watch it work:
~/.local/bin/usbfwd-server --list
journalctl --user -u usbfwd-server -fThe same steps by hand
sudo cp packaging/99-usbfwd.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
# keep the rule across SteamOS updates
echo /etc/udev/rules.d/99-usbfwd.rules | sudo tee /etc/atomic-update.conf.d/usbfwd.conf
mkdir -p ~/.local/bin ~/.config/systemd/user
cp usbfwd-server ~/.local/bin/
cp usbfwd-server.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now usbfwd-server
sudo loginctl enable-linger "$USER"Build and install the APK (see android/README.md), then
plug the controller in. The app launches on its own and gets USB permission
from the plug-in itself, so that is the whole interaction. Tick
Low-latency prefetch for snappier input.
| Option | What it does |
|---|---|
--prefetch |
Keeps a request queued on the controller at all times, so input is captured the moment it happens rather than when the host next asks. Noticeably snappier over a real network. Off by default; a checkbox in the Android app. |
--allow <vid:pid,…> |
Which devices to export. The server defaults to every Valve device (28de:*); the Deck installer narrows that to the controller, puck and receiver. |
--toggle-chord <buttons> |
A button chord that hands a forwarded device back and forth. See below. |
--bind <addr> |
Where to listen. Defaults to the Tailscale address, waiting for Tailscale to come up; see Network for running without it. |
--mdns |
Advertise the exporter on the local network, so the host can find it without being told its address. |
The Deck's built-in controls are an ordinary USB device (28de:1205), so they
can be forwarded too. That turns the Deck into a controller for the host, but
while it is forwarded the Deck's own UI stops responding. --toggle-chord
makes that reversible from the controller itself:
sudo ./install-deck.sh --deck-controls # forwards 28de:1205 with an L4+R4 chordHold the chord (for a second by default) and the Deck takes its controls back;
hold it again and the host gets them back. Add 28de:1205 to the host's
devices list and the reattach is automatic.
Before relying on it, confirm the chord against your hardware:
~/.local/bin/usbfwd-server --chord-probeHold the buttons you want and it prints the --toggle-chord value to use.
If the controls ever get stuck on the host, this gives them back without needing the controller:
systemctl --user reload usbfwd-serverThings to know:
- Each toggle is a real USB disconnect and reconnect on the host. Steam re-detects the controller, games may announce it, and the player index can move.
- Pick buttons nothing else uses. While the controls are back on the Deck, the Deck's own Steam sees the chord too.
USB/IP is unencrypted, and it costs a network round trip for every transfer, so the link matters more than anything in the code. There are two ways to run it.
The exporter listens only on its Tailscale address, and waits for Tailscale to
come up if it has not yet. Tailscale supplies the encryption, and its ACLs are
the access control, so this is the safe way to use it outside your home, or
between two sites. On the host, set each exporter's MagicDNS name in
/etc/usbfwd/usbfwd.toml.
Make sure the path is direct rather than relayed: tailscale ping <exporter> says which. A path relayed through a DERP server is the difference
between "feels native" and "unplayable"; enable UPnP/NAT-PMP on the router, or
a subnet route, if it will not go direct.
Without Tailscale, the exporter has to be told where to listen:
usbfwd-server --bind 192.168.1.20 # the exporter's own LAN address
usbfwd-server --bind any # every interface
sudo ./install-deck.sh --bind 192.168.1.20The Android app does this on its own: with no Tailscale on the tablet, it listens on every interface.
On the host, either put the exporter's hostname or IP in usbfwd.toml, or run
the exporter with --mdns and set mdns = true under [discovery] so the
host finds it by itself.
Only do this on a network you trust. Anyone who can reach port 3240 can take the controller and see its input, and there is deliberately no pairing scheme: if you need one, use Tailscale or another VPN, which does it better.
- Turn off Wi-Fi power saving on the exporter. It batches packets and shows
up as stutter.
- Steam Deck: SteamOS manages this itself and will undo manual changes. Enable Developer Mode (Settings → System), then turn off Wi-Fi power management under Settings → Developer.
- Other Linux exporters:
sudo iw dev wlan0 set power_save off # make it stick, with NetworkManager: printf '[connection]\nwifi.powersave = 2\n' | sudo tee /etc/NetworkManager/conf.d/wifi-powersave-off.conf
- Prefer 5 GHz, or a cable for the host, and use
--prefetch.
Check these in order, because each one predicts the next:
- The device reached the host as a USB device.
If this is right, Steam will accept the device.
lsusb -t # device on a vhci_hcd bus ls /sys/bus/usb/devices/*/bInterfaceNumber # 1 interface wired, 2–5 for the puck
- Steam recognises it. Controller Settings should identify a Steam Controller.
evtestshows no gyro/accel axes. That is expected until the kernel'shid-steamknows the new controller ids. Steam reads the sensors itself.- Reconnect. Drop the network briefly; the device should come back on its own within a few seconds.
Logs:
journalctl -u usbfwd-attach -f # host
journalctl --user -u usbfwd-server -f # Deck
adb logcat -s usbfwd # AndroidOther things that trip people up:
- Only one host can import a device at a time. A second one is refused.
- Steam on the exporter loses the controller while it is forwarded. That is how it works: the device can only belong to one machine.
- Only HID-style devices work. Isochronous transfers (audio, webcams) are deliberately unsupported.
Working end to end: the wired controller and the puck from an Android tablet over a real tailnet, and the puck through the whole stack on a Linux host, with Steam's gyro, trackpads and haptics working through the forward. The Deck exporter runs on SteamOS and reads its own controls.
Not yet verified: input latency measured rather than inferred, and the chord's button mapping with someone holding the buttons. See what has been verified for the detail.
cargo test --workspacedocs/DEVELOPER-NOTES.md covers the internals: why
USB/IP rather than a virtual HID device, the code layout, how prefetching and
the toggle chord work, how the Deck install survives SteamOS updates, and the
bugs real hardware turned up.
MIT; see LICENSE.
usbfwd is not affiliated with or endorsed by Valve. Steam, Steam Deck and Steam Controller are trademarks of Valve Corporation.