steamcontroller-original
A standalone userland driver for the original Steam Controller, the one Valve sold from 2015 to 2019, to be used where the steam client can't be installed.
"original" is in the name to be unambiguous about which hardware this drives. It is not for the Steam Deck's built-in controls, and not for any newer Valve controller. If your controller is not the 2015 one with the two round trackpads, this is the wrong project.
It installs as steamcontroller-original and imports as
steamcontroller_original, so it does not collide with the upstream
python-steamcontroller it was forked from. Both can be installed side by
side. The sc-* command names are unchanged and are shared with
upstream, so don't install both if you want a predictable sc-xbox.py on
your PATH.
Two modes are already working with haptic feedback:
- xbox360: gamepad emulator
- desktop: mouse, keyboard mode
The final purpose is to have support for custom mapping created with a stand-alone tool or imported from steam vdf files.
The initial target is GNU/Linux, but I'll welcome any contributor that want to port input generation for other OS (OSX, Windows, *BSD, Android/Linux, ...)
This project is licensed under the GNU Affero General Public License v3.0 or later (AGPLv3+). It was previously MIT-licensed; the original MIT notice is preserved in NOTICE as required by its terms and still applies to the contributions made under it.
Installation
This project targets Python 3.10 or newer and is managed with uv. The test suite is verified on 3.10, 3.11, 3.12, 3.13 and 3.14.
The 3.10 floor comes from the optional gui extra, since PySide6 and
pyqtgraph both require it. The driver itself still runs on 3.9, but 3.9
has been end of life since October 2025, so it isn't declared as
supported.
-
System dependencies (Debian/Ubuntu package names, adjust for your distro):
libusb-1.0-0, required at runtime to actually talk to the controller over USB. Building/installing the project works without it, but any script that opens aSteamControllerwill fail with aFileNotFoundErrorfromlibusb1until it's installed. This is the only system package the driver needs.- Only if you want
sc-gyro-plot.py(the optional gyro data plotter): a working OpenGL/EGL stack (e.g.libgl1-mesa-dri,libglx-mesa0) for Qt to initialize at all, plus, if you want it in an actual window rather than offscreen,libxcb-cursor0 libxcb-xinerama0 libxkbcommon-x11-0. - Optional: a C compiler (e.g.
build-essential) if you want thelibuinputC extension. It is not needed; see "uinput backends" under Development for what it is and why you probably don't want it.
-
Clone the project:
git clone https://codeberg.org/Theoreticallyhugo/steamcontroller-original.git cd steamcontroller-original
-
Install with uv:
uv sync # core driver + daemons only uv sync --extra gui # also installs PySide6/pyqtgraph for sc-gyro-plot.py
This resolves dependencies and creates a
.venv/. It also tries to build the optionallibuinputC extension; if you have no compiler it says so and carries on, which is fine. Run any installed script withuv run <script>, e.g.uv run sc-xbox.py start(see Usage below), or install it as a standalone tool withuv tool install ./uv tool install '.[gui]'to get the scripts on yourPATHwithout prefixing every invocation withuv run. -
Install udev rules (if not already done for steam) in
/etc/udev/rules.d/99-steam-controller.rules:
# replace game group by a valid group on your system
# Steam controller keyboard/mouse mode
SUBSYSTEM=="usb", ATTRS{idVendor}=="28de", GROUP="games", MODE="0660"
# Steam controller gamepad mode
KERNEL=="uinput", MODE="0660", GROUP="games", OPTIONS+="static_node=uinput"
- Reload udev
sudo udevadm control --reload
Usage
Exit Steam first, since it grabs the controller for itself.
Every daemon takes the same commands:
uv run sc-xbox.py start # fork into the background
uv run sc-xbox.py stop
uv run sc-xbox.py restart
uv run sc-xbox.py debug # stay in the foreground, ^C to quit
uv run sc-xbox.py -i 0 start # second controller (index 0-3)
Drop the uv run prefix if you installed with uv tool install. Use
debug when something isn't working: it runs in the foreground and lets
errors reach your terminal instead of syslog.
Modes
| Command | What it gives you |
|---|---|
sc-xbox.py |
Xbox 360 pad. One uinput device, so games see a single standard controller. |
sc-gamepad.py |
Generic pad. Like the above but exposes the triggers as buttons too, and keeps running while the controller is disconnected. |
sc-desktop.py |
Mouse and keyboard, for using the couch without a keyboard. |
sc-mixed.py |
Both: tap STEAM to switch between the pad and desktop mappings. |
Hold STEAM for two seconds to shut the daemon down from the controller, in any mode.
Desktop mode controls
| Control | Does |
|---|---|
| Right pad | Mouse, with trackball-style momentum |
| Left pad | Scroll wheel, also with momentum |
| Stick | Arrow keys |
| Left / right trigger | Right / left mouse button |
| A / B / X / Y | Enter / Backspace / Esc / Play-Pause |
| LB / RB | Volume down / up |
| Left / right grip | Browser back / forward |
| Start / Back | Next / previous track |
| Left pad click | Middle click |
| Right pad click | Space |
| STEAM (tap) | Home page |
Gamepad mode controls
Stick and right pad are the two analog sticks, the left pad is the d-pad,
and the triggers are analog. Face buttons, bumpers, Start/Back and the pad
clicks map to their obvious Xbox equivalents. The grips double as A and B
(sc-xbox.py) or as back/forward (sc-gamepad.py).
Running as a service
res/ holds systemd units for sc-xbox.py and sc-mixed.py. They assume
the scripts are at /usr/bin/, which is not where uv puts them, so point
ExecStart/ExecReload/ExecStop at your actual install (uv tool install . gives you ~/.local/bin/sc-xbox.py) before enabling one.
Note that every daemon defaults to the same pidfile
(/tmp/steamcontroller.pid, or /tmp/steamcontroller{N}.pid with -i N)
regardless of which one you start. That's carried over unchanged from the
original driver, and the PIDFile= lines in res/*.service depend on those
exact paths, so changing one means changing both.
A pidfile left behind by a crash no longer blocks the next start; it is
detected as stale and cleared.
Other tools
sc-dump.py: Dump raw message from the controller.sc-gyro-plot.py: Plot curves from gyro data. Requires theguiextra (uv sync --extra gui/uv tool install '.[gui]') and a working OpenGL/EGL stack; it prints a friendly error and exits instead of crashing if either is missing.sc-test-cmsg.py: Permit to send control message to the contoller. For example:echo 8f07005e 015e01f4 01000000 | uv run sc-test-cmsg.pywill make the controller beep.vdf2json.py: Convert Steam VDF file to JSON.json2vdf.py: Convert back JSON to VDF file.
Both converters read stdin and write stdout by default, or take -i and
-o:
uv run vdf2json.py -i ~/.steam/steam/config/config.vdf -o config.json
uv run json2vdf.py -i config.json -o config.vdf
Tuning
A few thresholds are plain constants, if the defaults don't suit you:
src/events.py:TRIG_PRESS_THRESHOLD/TRIG_RELEASE_THRESHOLDset how far a trigger must be pulled to count as a button press (these are your mouse buttons in desktop mode), andSTICK_PRESS_THRESHOLD/STICK_RELEASE_THRESHOLDdo the same for the stick-as-d-pad. The gap between each pair is hysteresis, so a control resting near the edge doesn't chatter.src/uinput.py:Mouse.DEFAULT_FRICTIONandDEFAULT_SCR_FRICTIONcontrol how long the pointer and the scroll wheel keep coasting.
Re-run uv sync after editing so the installed copy picks up the change.
Development
Run the test suite with uv run pytest. It's fully self-contained. The
uinput backend is mocked in tests/conftest.py, so no root, real
/dev/uinput, or attached controller is needed. The sc-gyro-plot.py
screenshot test additionally needs a working Qt/EGL stack (see
Installation above) and skips itself cleanly if that's not available, as
do the tests that compare the two uinput backends when the C extension
hasn't been built.
To exercise the C path as well:
SC_UINPUT_BACKEND=c uv run pytest
uinput backends
Emulated input is delivered by writing event structs to /dev/uinput.
There are two implementations of that, and they produce byte-identical
output:
SC_UINPUT_BACKEND |
What it uses |
|---|---|
python (default) |
ioctl() and write() straight from Python. No compiled component. |
c |
The libuinput extension built from src/uinput.c, which is what this driver used before. |
The Python backend is the default because it needs no compiler and is, if
anything, slightly faster: each call is a single write() syscall either
way, and crossing into C through ctypes costs more than packing the struct
in Python does (measured at roughly 0.48us per event against 0.73us).
The C path is kept for anyone who wants it:
SC_UINPUT_BACKEND=c uv run sc-desktop.py debug
It needs the extension to have been built, which requires a compiler at install time. If it wasn't built, selecting it reports that clearly rather than falling back silently.
Building a universal wheel
Declaring the C extension is what makes setuptools tag a wheel for one
interpreter and one architecture (cp313-cp313-linux_x86_64), and it does
that even when the compile was skipped. To leave it out and get a portable
wheel instead:
SC_NO_C_EXTENSION=1 uv build
| Build | Wheel | Installs on |
|---|---|---|
uv build |
...-cp313-cp313-linux_x86_64.whl |
that one Python version, that one architecture |
SC_NO_C_EXTENSION=1 uv build |
...-py3-none-any.whl |
any Python 3.10+, any architecture |
Use the second one for anything you publish or hand to someone else. The
driver's default backend is pure Python, so nothing is lost: only
SC_UINPUT_BACKEND=c becomes unavailable, and it reports that clearly.
The plain uv build is still the default so that local development keeps
the C extension around, which is what the backend equivalence tests
compare against.
The event codes (KEY_*, BTN_*, ABS_*, REL_*) are likewise inlined
in steamcontroller_original/input_event_codes.py rather than parsed out of
/usr/include on every import, so kernel headers aren't needed at runtime
either. Regenerate that file if you ever need to:
uv run python -m steamcontroller_original.gen_input_event_codes > src/input_event_codes.py
A test compares the inlined copy against the headers on the machine, so drift shows up rather than going unnoticed.
Wayland
The daemons (sc-xbox.py, sc-desktop.py, sc-mixed.py, sc-gamepad.py)
talk to /dev/uinput directly at the kernel level and never touch a display
server at all, so they work identically under X11, Wayland, or a bare TTY.
sc-gyro-plot.py is the only GUI piece. It's built on PySide6/pyqtgraph
with no X11-specific APIs (verified: nothing in this codebase references
DISPLAY, xcb, or QScreen.grabWindow; the latter is a no-op under
Wayland by design, which is why the screenshot test uses QWidget.grab()
instead). PySide6 ships Qt's native wayland platform plugin, and
QT_QPA_PLATFORM=wayland uv run --extra gui sc-gyro-plot.py will use it
automatically on a Wayland session.
res/config.ui is a Qt Designer file left over from an earlier,
never-finished attempt at a full configuration GUI (see TODO item 6
below), and no code currently loads it.
TODO / Status
- Finish to guess each bytes/bits roles in the usb message (Done).
- Understand how to configure haptic feed backs (Done).
- Understand how to enable gyroscopes (Done).
- Redirect inputs to userland events via uinput (Done).
- Xbox360 uintput device (Done)
- Keyboard uintput device (Done)
- Mouse uintput device with trackball model (Done)
- Create a simple xbox event mapper (Done)
- Create a configurable event mapper (Paused):
- Create an event mapper that reads steam vdf files and maps usb inputs to uinput events.
- Create fallback mappings for unsupported config options.
- Get all possible configurations of steam config file.
- Create a haptic feedback Manager (Paused)
- Measure latencies.
- Support multiple controller in wireless mode (Done)
- Support multiple controller in wired mode
- Support correct deconnexion of controllers (with 2sec press on steam button) (Done)
- Add support to control light intensity
- Add support for gyroscopes in the event mapper:
- Enable gyro condition (always on, or on specific button event)
- Use gyro as mouse (add yaw, pitch, roll accell to mouse event with a scale factor).
- Use gyro as an axis (compute yawn, pitch or roll from quaternion, normalize to -32768 32768 and use it as an axe)
- Optimize event mapper.
- Verify if pairing between a controller and a dongle is possible without steam or add a tools to do it.
- Add support to change "music" for power on off.
- Create an tool to convert musical notes, to haptic messages.
Control Messages Capture
sudo modprobe usbmonlsusb -d 28de:1142and look at bus and device numbers (B & D)sudo cat /sys/kernel/debug/usb/usbmon/Bu | grep Co:B:D:0(B=3 and D=003 for example)
Disable auto feedback on rpad:
81000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
Enable Gyro
87153284 03180000 31020008 07000707 00301400 2f010000 00000000 00000000
Disable Gyro
87153284 03180000 31020008 07000707 00300000 2f010000 00000000 00000000
Stop Controller
9f046f66 66210000 ...
Control Messages formats
Haptic feedback format:
- u8 :
8f - u8 :
07 - u8 :
00for Right01for Left - u16 : Amplitude
- u16 : Period
- u16 : count
- pads the end with
00
Metadata
Release files for steamcontroller-original 2.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| steamcontroller_original-2.0.1.tar.gz | 96.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| steamcontroller_original-2.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 171.8 kB
Release files / steamcontroller_original-2.0.1.tar.gz
| Download URL | steamcontroller_original-2.0.1.tar.gz |
|---|---|
| Size | 96.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e156cd1bc8960771e6e3bc929a9878080a271556f3c6aafef844b03a6d4e4398
|
|
BLAKE2b-256 checksum How to use checksums |
d60cb439c96d027901bde1d78da937cd2757ca1d21771122e3b70c217e11284a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.6
|
Release files / steamcontroller_original-2.0.1-py3-none-any.whl
| Download URL | steamcontroller_original-2.0.1-py3-none-any.whl |
|---|---|
| Size | 75.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9b6e80e1f51f76cb3c084fbcf535123c0265941429315d87db2f7ea6fe629e2a
|
|
BLAKE2b-256 checksum How to use checksums |
e433942baf724fba33c33dc201b2fdce10ae5c06b8ffb78205c0edcc03239a5e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.6
|
Security for steamcontroller-original 2.0.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.