Gamepad control through Bluetooth
July 6, 2026 · View on GitHub
The robot can be driven with a Bluetooth gamepad (Xbox or compatible). The gamepad
is paired with the Raspberry Pi and read directly there via the Linux joystick API
(/dev/input/js*), in the same process as the control loop.
Pairing a gamepad allows to drive the robot through two different modes: with a terminal (SSH) or fully headless (no SSH, no terminal). The second mode is particularly useful for demonstration purposes, due to the fact that it allows to drive the robot without any computer connected to it.
Pairing the controller on the Pi
On the Pi, put the Xbox controller in pairing mode (hold the pair button until the Xbox light flashes fast), then:
bluetoothctl
[bluetooth]# power on
[bluetooth]# agent on
[bluetooth]# scan on # wait for the "Xbox Wireless Controller" MAC to appear
[bluetooth]# pair XX:XX:XX:XX:XX:XX
[bluetooth]# trust XX:XX:XX:XX:XX:XX # auto-reconnect on next power-on
[bluetooth]# connect XX:XX:XX:XX:XX:XX
[bluetooth]# scan off
[bluetooth]# quit
Once paired and trusted, the controller reconnects automatically when powered on.
SSH usage
When the controller is connected, the make run command uses it as input automatically instead of the keyboard. The mapping is:
- Left stick:
vx(up/down),vy(left/right) - Right stick (left/right):
vtheta - A: toggle the
walkmove - B: stop the scheduler (writes the stop flag)
- View/Back button: toggle the IMU/gyro display
Axis and button numbers vary between controllers (especially over Bluetooth) — if something doesn't respond as expected, see Remapping.
Every input source emits a normalized command in [-1, 1] per axis. The physical
limits are applied centrally by scale_velocity() in the scheduler, so they are
identical for keyboard, gamepad and sim: vx ±0.7, vy ±0.3, vtheta ±3.0 when
turning in place (vx = vy = 0) and ±1.5 while translating. Tune these via
VX_MAX / VY_MAX / VTHETA_MAX_STATIONARY / VTHETA_MAX_MOVING in
constants.py.
Signs, deadzone and button mapping are constants at the top of
gamepad_input.py; the move/button mapping is set
via GAMEPAD_BUTTON_MOVES in main.py.
Headless mode (no SSH)
The headless mode is an optional service that lets you run the robot without any terminal. To activate it, power off the controller, then run the following commands on your computer:
make gamepad-headless-enable
Once enabled, it launches a daemon on the Pi that waits for a controller to connect over Bluetooth. Once a controller is connected, the Wi-Fi is turned off to free the 2.4 GHz antenna and the following commands are available on the controller:
- Hold START → start the control loop.
- A → toggle the
walkmove. - B → stop the control loop.
- Hold both triggers → power off the Pi cleanly. Wait 10-15 s after that before flipping the robot's power switch off, to give the Pi time to actually halt.
The headless mode persists across reboots, which means that you don't need to connect to the Pi over SSH to enable it again. It is particularly useful for demonstration purposes, as it allows to drive the robot without any computer connected to it.
If the controller disconnects during a session, the robot's velocity is zeroed so it stops moving (torque stays on, holding its pose). Reconnect and press B or power off the robot end the session.
Whenever the daemon is left with no controller connected — because it disconnected, because the control loop crashed, or because the robot just booted — Wi-Fi is (re)enabled automatically. You don't need to power-cycle the robot to get SSH back; simply turning the controller off is enough.
Important
As the Wi-Fi is turned off when the controller is connected, so you won't be able to connect to the Pi over SSH while the controller is connected. If you need to connect to the Pi over SSH, power off the controller
To disable the headless mode, run the following command (after powering off the controller):
make gamepad-headless-disable
Remapping for your controller
Xbox controllers don't all expose the same axis/button numbers — over Bluetooth the
kernel often uses a different layout than the wired xpad one. If a stick or button
doesn't behave as expected, find the real numbers and update the constants.
This requires an SSH session, so headless mode must be disabled first (see above) — otherwise Wi-Fi is off while the controller is connected and you won't be able to reach the Pi. You'll also need the controller connected.
- Install the joystick package on the Pi:
sudo apt install -y joystick
-
Run
jstest /dev/input/js0, then move each stick and press each button one at a time, noting theAxis N/Button Nthat changes:Action Note the number Left stick horizontal / vertical Axis→_AXIS_LX/_AXIS_LYRight stick horizontal Axis→_AXIS_RX(drivesvtheta)A / B / Back / Start Button→XBOX_BUTTONSLeft / right trigger Axis→TRIGGER_AXES(headless power-off gesture) -
Edit the constants at the top of gamepad_input.py:
_AXIS_LX,_AXIS_LY,_AXIS_RX— the stick axis numbers.XBOX_BUTTONS— the button name → number map (at least the ones you use).TRIGGER_AXES— theLT/RTaxis numbers, andTRIGGER_PRESS_THRESHOLD— the raw axis value that counts as "pressed".VX_SIGN,VY_SIGN,VTHETA_SIGN— flip between+1.0/-1.0if a direction is reversed.
-
Which button does what is set by
GAMEPAD_BUTTON_MOVESin main.py (moves) and thestop_button/imu_buttonarguments ofGamepadInputSource(defaults: stop =B, IMU =BACK).
The defaults shipped in the repo (A=0, B=1, Start=11; left stick = axes 0/1, right
stick horizontal = axis 2, triggers = axes 4/5) are verified on an Xbox controller
over Bluetooth — but the exact axis numbers, rest/press values and button mappings are known to vary
across pads, so double-check with jstest on yours.