Skip to main content

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 General Public License version 2 or later (GPLv2+), which keeps it usable by GPL-2.0-only projects as well as by GPLv3 ones. It was previously MIT-licensed, and briefly AGPLv3; 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.

  1. 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 a SteamController will fail with a FileNotFoundError from libusb1 until 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 the libuinput C extension. It is not needed; see "uinput backends" under Development for what it is and why you probably don't want it.
  2. Clone the project:

    git clone https://codeberg.org/Theoreticallyhugo/steamcontroller-original.git
    cd steamcontroller-original
    
  3. 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 optional libuinput C extension; if you have no compiler it says so and carries on, which is fine. Run any installed script with uv run <script>, e.g. uv run sc-xbox.py start (see Usage below), or install it as a standalone tool with uv tool install . / uv tool install '.[gui]' to get the scripts on your PATH without prefixing every invocation with uv run.

  4. 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"
  1. 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 the gui extra (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.py will 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_THRESHOLD set how far a trigger must be pulled to count as a button press (these are your mouse buttons in desktop mode), and STICK_PRESS_THRESHOLD / STICK_RELEASE_THRESHOLD do 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_FRICTION and DEFAULT_SCR_FRICTION control 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

  1. Finish to guess each bytes/bits roles in the usb message (Done).
  2. Understand how to configure haptic feed backs (Done).
  3. Understand how to enable gyroscopes (Done).
  4. Redirect inputs to userland events via uinput (Done).
    • Xbox360 uintput device (Done)
    • Keyboard uintput device (Done)
    • Mouse uintput device with trackball model (Done)
  5. Create a simple xbox event mapper (Done)
  6. 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.
  1. Create a haptic feedback Manager (Paused)
  2. Measure latencies.
  3. Support multiple controller in wireless mode (Done)
  4. Support multiple controller in wired mode
  5. Support correct deconnexion of controllers (with 2sec press on steam button) (Done)
  6. Add support to control light intensity
  7. 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)
  8. Optimize event mapper.
  9. Verify if pairing between a controller and a dongle is possible without steam or add a tools to do it.
  10. Add support to change "music" for power on off.
  11. Create an tool to convert musical notes, to haptic messages.

Control Messages Capture

  1. sudo modprobe usbmon
  2. lsusb -d 28de:1142 and look at bus and device numbers (B & D)
  3. 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 : 00 for Right 01 for Left
  • u16 : Amplitude
  • u16 : Period
  • u16 : count
  • pads the end with 00

Metadata

Release files for steamcontroller-original 2.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for steamcontroller-original 2.0.2
File Size Uploaded Provenance
steamcontroller_original-2.0.2.tar.gz 91.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for steamcontroller-original 2.0.2
File Interpreter ABI Platform
steamcontroller_original-2.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 162.2 kB

Release files / steamcontroller_original-2.0.2.tar.gz

Download URL steamcontroller_original-2.0.2.tar.gz
Size 91.9 kB
Tags Source
SHA-256 checksum
How to use checksums
71a2e81c303925bfd1645629c17395691e65217872dd7dbc3841ff6676344bc0
BLAKE2b-256 checksum
How to use checksums
8364963776573a3c01aeafaa5330840e7ce75104863c75aecd08bd60c832a883
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.6

Release files / steamcontroller_original-2.0.2-py3-none-any.whl

Download URL steamcontroller_original-2.0.2-py3-none-any.whl
Size 70.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
686c5db5aa574fa360b7fd6aac4b48590ee44645d471b9113947199e04f20212
BLAKE2b-256 checksum
How to use checksums
51f23e06e0d6853e1f2836ba7463c007d76ff8d89573fcc768300fa452bbef93
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.6

Security for steamcontroller-original 2.0.2

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.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page