Skip to main content

Cross-platform game controller library for robotics, drones, and edge computing

Project description

controlpad

A cross-platform Python library for integrating game controllers into robotics, drone, and edge computing projects.

from controlpad import Gamepad

gp = Gamepad()

@gp.on_axis("left_x", "left_y")
def on_left_stick(x, y):
    robot.set_velocity(x, y)

@gp.on_button_press("cross")
def on_cross():
    robot.jump()

gp.run()

Features

  • Event-driven or polling — use decorator callbacks for clean application code, or poll in your own loop
  • Named axes and buttonsstate.axis("left_x") instead of joystick.get_axis(0)
  • Built-in profiles — DualSense and Xbox out of the box; custom profiles in a few lines
  • Auto-detection — profile is selected automatically from the controller's name string
  • Radial deadzone — circular 2D deadzone for sticks, not a square
  • Expo curves — standard RC-style centre softening for precise control
  • Smoothing — configurable EMA low-pass filter to reduce stick jitter
  • Auto-reconnect — survives USB wobbles and Bluetooth drops without crashing your application
  • Headless Linux support — evdev backend requires no display, works inside ROS nodes and Docker containers
  • CLI toolscontrolpad detect, controlpad monitor for quick diagnostics
  • Session recording & playback — record live controller input to JSON and replay it without hardware; ideal for testing and simulation

Installation

pip install controlpad

For headless Linux (Raspberry Pi, ROS, Docker — no display required):

pip install "controlpad[evdev]"

To check the installed version:

import controlpad
print(controlpad.__version__)   # e.g. "0.2.0"

Quick start

Polling

import time
from controlpad import Gamepad

gp = Gamepad(deadzone=0.08)
gp.connect()

while True:
    state = gp.read()
    print(state.axis("left_x"), state.axis("left_y"))
    print(state.button("cross"))
    print(state.dpad)          # (-1, 0), (0, 1), etc.
    time.sleep(1 / 60)

Event-driven

from controlpad import Gamepad

gp = Gamepad(deadzone=0.08, expo=0.15)

@gp.on_axis("left_x", "left_y")
def on_left_stick(x, y):
    drone.set_velocity(x, y)

@gp.on_axis("r2")           # Trigger: 0.0 → 1.0
def on_throttle(value):
    drone.set_throttle(value)

@gp.on_button_press("triangle")
def take_off():
    drone.take_off()

@gp.on_button_press("cross")
def land():
    drone.land()

@gp.on_disconnect()
def emergency():
    drone.disarm()

gp.run()

Background thread

gp = Gamepad()
thread = gp.run_async()   # Non-blocking — returns immediately

# Your main application continues here
do_other_things()

gp.stop()
thread.join()

Configuration

All options are passed to Gamepad():

Parameter Type Default Description
profile str or ControllerProfile None Profile name or instance. None = auto-detect
index int 0 Which controller to open if multiple are connected
deadzone float 0.05 Stick deadzone radius [0, 1)
expo float 0.0 Expo curve strength [0, 1). 0 = linear
smoothing float 1.0 EMA alpha (0, 1]. Lower = smoother. 1.0 = off
backend str "auto" "auto", "pygame", or "evdev"
headless bool False Force SDL dummy video — no display required
reconnect bool True Auto-reconnect when controller is lost
poll_rate int 60 Polling frequency in Hz for run()

Recording & Playback

controlpad can record a live controller session to a JSON file and replay it later — with no hardware required. This is useful for:

  • Repeatable testing — record a manoeuvre once; replay it in your test suite on any machine
  • Simulation — feed pre-recorded input into your application without a physical controller
  • Debugging — replay an exact input sequence that triggered a bug

Recording

from controlpad import Gamepad

gp = Gamepad(deadzone=0.08)

@gp.on_button_press("options")
def stop():
    gp.stop()

gp.connect()
gp.start_recording()
gp.run()                          # fly/drive manually

session = gp.stop_recording()
session.save("patrol_route.json")

print(f"Recorded {session.snapshot_count} snapshots over {session.duration:.2f}s")

Replaying (no hardware needed)

from controlpad import Gamepad
from controlpad.session import Session

session = Session.load("patrol_route.json")

gp = Gamepad(profile=session.profile_name, deadzone=0.08)

@gp.on_axis("left_x", "left_y")
def steer(x, y):
    drone.set_velocity(x, y)

@gp.on_button_press("cross")
def land():
    drone.land()

gp.playback(session)              # replays at real-time speed by default

Playback options

# Real-time (default)
gp.playback(session, speed=1.0)

# Double speed
gp.playback(session, speed=2.0)

# Instantaneous — all snapshots delivered with no sleeping (ideal for tests)
gp.playback(session, speed=0)

# Override the profile regardless of what was recorded
gp.playback(session, speed=0, profile="xbox")

# Non-blocking — runs in a background thread
thread = gp.playback_async(session, speed=1.0)
thread.join()

Using sessions as test fixtures

speed=0 makes playback instantaneous, so a 30-second recording replays in milliseconds. This makes it practical to use recorded sessions as test fixtures in a normal pytest suite — no controller hardware required on the CI machine.

from controlpad import Gamepad
from controlpad.session import Session

def test_drone_lands_on_cross_press():
    session = Session.load("tests/fixtures/cross_press.json")

    gp = Gamepad(profile="dualsense")
    landed = []

    @gp.on_button_press("cross")
    def on_cross():
        landed.append(True)

    gp.playback(session, speed=0)

    assert len(landed) == 1

Session file format

Sessions are saved as human-readable JSON and are diff-friendly in version control. Each snapshot occupies one logical entry in the snapshots array:

{
  "format_version": "1",
  "controller_name": "DualSense Wireless Controller",
  "profile_name": "dualsense",
  "recorded_at": "2026-04-08T10:30:00+00:00",
  "duration": 5.0167,
  "snapshot_count": 301,
  "snapshots": [
    {"t": 0.0,    "axes": [0.0, 0.0, -1.0, 0.0, 0.0, -1.0], "buttons": [false, ...], "hats": [[0, 0]]},
    {"t": 0.0167, "axes": [0.12, -0.05, -1.0, 0.0, 0.0, -1.0], "buttons": [false, ...], "hats": [[0, 0]]}
  ]
}

Raw hardware values are stored before any profile mapping, deadzone, or filtering — so you can replay a recording through a differently-configured Gamepad and see how your application would have behaved with different settings.


Custom profiles

If your controller is not auto-detected, define your own profile using controlpad detect to find the raw axis indices first:

controlpad detect

Then define and register the profile:

from controlpad import Gamepad, ControllerProfile, register_profile

my_stick = ControllerProfile(
    name="MyJoystick",
    axis_map={
        "x":        0,
        "y":        1,
        "throttle": 2,
        "twist":    3,
    },
    button_map={
        "trigger": 0,
        "thumb":   1,
    },
    invert_axes={"y"},
    trigger_axes={"throttle"},
)

register_profile(my_stick)

gp = Gamepad(profile="myjoystick")

CLI tools

# Identify your controller and print its full axis/button mapping
controlpad detect

# List all built-in profiles
controlpad list

# Live axis/button stream in the terminal (no display needed)
controlpad monitor
controlpad monitor --rate 50 --deadzone 0.08

Examples

The examples/ directory contains ready-to-run scripts:

File What it shows
basic_polling.py Manual polling loop
event_driven.py Decorator-based callbacks
drone_control.py Full drone input mapping
custom_profile.py Registering a custom controller layout
recording_playback.py Recording a session to JSON and replaying it without hardware

Headless / Raspberry Pi

On a Raspberry Pi running headless (no desktop), use either:

# Option 1: headless flag
gp = Gamepad(headless=True)
# Option 2: evdev backend (no pygame, no SDL)
gp = Gamepad(backend="evdev")
# Option 3: environment variable before import
export SDL_VIDEODRIVER=dummy
export SDL_AUDIODRIVER=dummy
python your_script.py

The evdev backend requires pip install "controlpad[evdev]" and the user to be in the input group:

sudo usermod -aG input $USER

Supported controllers

Controller Profile name Tested on
Sony DualSense (CFI-ZCT1W / CFI-ZCT1G) dualsense Raspberry Pi 5 (Linux), macOS 14
Xbox One / Series X|S xbox Linux (xpadneo), macOS
Any HID gamepad generic (auto-fallback) Access axes as axis_0, axis_1, …

Contributions for other controllers are welcome — see CONTRIBUTING.md.


Development setup

Clone the repo and install in editable mode. The -e flag is important — it registers the package with local metadata so controlpad.__version__ resolves correctly when running from source.

git clone https://github.com/ranaweerasupun/controlpad.git
cd controlpad
pip install -e ".[dev]"

Run the tests — no controller hardware required, the test suite uses a mock backend:

pytest

License

MIT — see LICENSE.


Author

Supun Sriyanandagithub.com/ranaweerasupun

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

controlpad-0.2.1.tar.gz (40.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

controlpad-0.2.1-py3-none-any.whl (32.8 kB view details)

Uploaded Python 3

File details

Details for the file controlpad-0.2.1.tar.gz.

File metadata

  • Download URL: controlpad-0.2.1.tar.gz
  • Upload date:
  • Size: 40.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for controlpad-0.2.1.tar.gz
Algorithm Hash digest
SHA256 d6625ca57b10f5f1bf3df9af55b696b1257aeb9a321ab0a6aad38b6df9af9133
MD5 cce9797d967ed05cee57bfac921b5404
BLAKE2b-256 6317f2cbf059bcd414c33270b1c3aa9af7725b98173c2a2b86f08c6a6a29a4a0

See more details on using hashes here.

File details

Details for the file controlpad-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: controlpad-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 32.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for controlpad-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6f31a80d414082e75ed28d4ef31537fd075b01a449c272805fd8704594537a06
MD5 7e13997b1596d58b990ec25055fda64a
BLAKE2b-256 00c48bc4942eeda5deac10441435c2d24326d002f5734ef5d116d5c4f6231d1d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page