Remote Control and Gamepad
Overview
Remote controllers and game controllers are the most intuitive control methods for robots, especially indispensable during debugging, teleoperation, and emergency takeover scenarios. This section introduces common remote control solutions and their integration with ROS2.
Game Controllers
PS4 DualShock 4 / PS5 DualSense
| Parameter | DualShock 4 | DualSense (PS5) |
|---|---|---|
| Connection | Bluetooth / USB | Bluetooth / USB |
| Joysticks | 2 analog sticks | 2 analog sticks |
| Triggers | L2/R2 analog triggers | L2/R2 adaptive triggers |
| Buttons | 14 buttons + touchpad | 14 buttons + touchpad |
| Gyroscope | Yes | Yes |
| Vibration | Yes | Haptic feedback |
| Battery Life | 4-8h | 12h |
| Price | ~$40-60 | ~$60-70 |
Linux Pairing:
# Install ds4drv (PS4)
pip install ds4drv
sudo ds4drv
# Or use direct Bluetooth connection
bluetoothctl
> scan on
> pair XX:XX:XX:XX:XX:XX
> connect XX:XX:XX:XX:XX:XX
> trust XX:XX:XX:XX:XX:XX
# Verify
ls /dev/input/js*
# Or
cat /dev/input/js0 | xxd
Testing Tools:
# Install joystick tools
sudo apt install joystick
# Test controller
jstest /dev/input/js0
# Graphical test
sudo apt install jstest-gtk
jstest-gtk
Xbox Controller
| Parameter | Xbox Series | Xbox One |
|---|---|---|
| Connection | Bluetooth/USB/Wireless adapter | USB/Wireless adapter |
| Compatibility | Native Linux support (xpad) | Native Linux support |
| Price | ~$50-65 | ~$35-50 |
Xbox controllers have the best Linux compatibility, recommended for use:
# Xbox controllers are usually auto-detected
# xpad driver is built into the Linux kernel
# For advanced features
sudo apt install xboxdrv
sudo xboxdrv --detach-kernel-driver
Controller Button Mapping
Standard mapping (Xbox layout):
| Button/Axis | Index | Recommended Function |
|---|---|---|
| Left Stick X | axes[0] | Lateral movement/Steering |
| Left Stick Y | axes[1] | Forward/Backward |
| Right Stick X | axes[2] | View/Rotation |
| Right Stick Y | axes[3] | Lift/Pitch |
| LT (L2) | axes[4] | Decelerate |
| RT (R2) | axes[5] | Accelerate |
| A Button | buttons[0] | Confirm/Start |
| B Button | buttons[1] | Cancel/Stop |
| X Button | buttons[2] | Function 1 |
| Y Button | buttons[3] | Function 2 |
| LB (L1) | buttons[4] | Mode switch |
| RB (R1) | buttons[5] | Mode switch |
| Back | buttons[6] | Emergency stop |
| Start | buttons[7] | Enable teleoperation |
RC Controllers
Hobby RC Transmitters
Hobby/RC transmitters are suitable for scenarios requiring long-range, high-reliability control (UAVs, outdoor robots).
| Brand/Model | Channels | Frequency | Protocol | Price |
|---|---|---|---|---|
| FlySky FS-i6X | 6(10) ch | 2.4GHz | IBUS/PPM | ~$50 |
| FlySky FS-i6S | 10 ch | 2.4GHz | IBUS | ~$70 |
| FrSky X9 Lite | 16 ch | 2.4GHz | SBUS/ACCST | ~$80 |
| FrSky Taranis X9D | 16 ch | 2.4GHz | SBUS | ~$200 |
| RadioMaster TX16S | 16 ch | 2.4GHz | Multi-protocol | ~$150 |
Signal Protocols
| Protocol | Signal Type | Features |
|---|---|---|
| PWM | One wire per channel, 1-2ms pulse | Simplest, many wires |
| PPM | Single wire serial, 1-2ms pulse train | One wire for multiple channels |
| SBUS | Inverted UART, 100kbps | 16 channels, digital |
| IBUS | UART, 115200bps | FlySky proprietary, easy to parse |
SBUS Protocol Parsing
SBUS is the standard protocol for FrSky and other high-end transmitters:
- Baud rate: 100000 bps
- Data bits: 8bit, even parity, 2 stop bits
- Signal inverted: Requires hardware inverter or software handling
- Frame length: 25 bytes, containing 16 channels of 11-bit values
# SBUS parsing (MicroPython/Python)
import serial
# SBUS signal is inverted; some USB-to-TTL chips can handle this
# Or use a hardware inverter (a single NPN transistor is sufficient)
ser = serial.Serial('/dev/ttyAMA0', 100000,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_TWO)
def parse_sbus(frame):
"""Parse 25-byte SBUS frame, return 16 channel values (172-1811)"""
if len(frame) != 25 or frame[0] != 0x0F:
return None
channels = []
bits = 0
byte_idx = 1
bit_idx = 0
for ch in range(16):
value = 0
for bit in range(11):
if frame[byte_idx] & (1 << bit_idx):
value |= (1 << bit)
bit_idx += 1
if bit_idx >= 8:
bit_idx = 0
byte_idx += 1
channels.append(value)
return channels
IBUS Protocol Parsing
IBUS is much simpler than SBUS:
import serial
ser = serial.Serial('/dev/ttyUSB0', 115200)
def parse_ibus(frame):
"""Parse IBUS frame, return channel values (1000-2000)"""
if len(frame) != 32 or frame[0] != 0x20 or frame[1] != 0x40:
return None
channels = []
for i in range(14):
low = frame[2 + i * 2]
high = frame[3 + i * 2]
channels.append(low | (high << 8))
return channels
Custom Remote Controller (ESP32)
ESP32 + Joystick + Wireless
Building a custom remote controller with ESP32:
// ESP32 Remote Controller - Transmitter
#include <esp_now.h>
#include <WiFi.h>
#define JOY_X 34 // Analog pin
#define JOY_Y 35
#define BTN_A 32
#define BTN_B 33
// Robot's MAC address
uint8_t robotAddress[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
typedef struct {
int16_t joy_x;
int16_t joy_y;
uint8_t btn_a;
uint8_t btn_b;
} ControlData;
ControlData data;
void setup() {
WiFi.mode(WIFI_STA);
esp_now_init();
esp_now_peer_info_t peerInfo;
memcpy(peerInfo.peer_addr, robotAddress, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
esp_now_add_peer(&peerInfo);
}
void loop() {
data.joy_x = analogRead(JOY_X) - 2048; // Center
data.joy_y = analogRead(JOY_Y) - 2048;
data.btn_a = !digitalRead(BTN_A);
data.btn_b = !digitalRead(BTN_B);
esp_now_send(robotAddress, (uint8_t*)&data, sizeof(data));
delay(20); // 50Hz
}
ROS2 joy Package
Installation and Usage
# Install
sudo apt install ros-humble-joy ros-humble-teleop-twist-joy
# Launch joy node (reads controller)
ros2 run joy joy_node
# View controller data
ros2 topic echo /joy
# Launch teleop (maps controller to cmd_vel)
ros2 launch teleop_twist_joy teleop-launch.py
Joy Message Format
# sensor_msgs/msg/Joy
Header header
float32[] axes # Joystick/trigger values [-1.0, 1.0]
int32[] buttons # Button states [0 or 1]
Custom Controller-to-Velocity Mapping
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Joy
from geometry_msgs.msg import Twist
class JoyTeleop(Node):
def __init__(self):
super().__init__('joy_teleop')
# Parameters
self.declare_parameter('max_linear', 1.0)
self.declare_parameter('max_angular', 2.0)
self.declare_parameter('enable_button', 7) # Start button
self.max_linear = self.get_parameter('max_linear').value
self.max_angular = self.get_parameter('max_angular').value
self.enable_btn = self.get_parameter('enable_button').value
self.joy_sub = self.create_subscription(Joy, '/joy', self.joy_cb, 10)
self.cmd_pub = self.create_publisher(Twist, '/cmd_vel', 10)
self.enabled = False
def joy_cb(self, msg):
# Safety switch: must hold Start button to move
if msg.buttons[self.enable_btn]:
self.enabled = True
else:
self.enabled = False
twist = Twist()
if self.enabled:
# Left stick Y -> Forward/Backward
twist.linear.x = msg.axes[1] * self.max_linear
# Right stick X -> Rotation
twist.angular.z = msg.axes[2] * self.max_angular
self.cmd_pub.publish(twist)
def main():
rclpy.init()
node = JoyTeleop()
rclpy.spin(node)
if __name__ == '__main__':
main()
Launch File
# teleop_launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='joy',
executable='joy_node',
parameters=[{
'dev': '/dev/input/js0',
'deadzone': 0.05,
'autorepeat_rate': 20.0,
}]
),
Node(
package='my_robot_teleop',
executable='joy_teleop',
parameters=[{
'max_linear': 0.5,
'max_angular': 1.5,
'enable_button': 7,
}]
),
])
Safety Design
Deadman's Switch
Teleoperated robots must implement a deadman's switch -- releasing the button causes the robot to stop immediately:
# Implemented in joy_cb
if not msg.buttons[self.enable_btn]:
# Enable button not held -> stop immediately
twist = Twist() # All zeros
self.cmd_pub.publish(twist)
return
Communication Timeout
If the controller signal is lost, the robot should automatically stop:
def __init__(self):
# ...
self.last_joy_time = self.get_clock().now()
self.timeout = 0.5 # 500ms timeout
self.timer = self.create_timer(0.05, self.check_timeout)
def joy_cb(self, msg):
self.last_joy_time = self.get_clock().now()
# ... normal processing
def check_timeout(self):
dt = (self.get_clock().now() - self.last_joy_time).nanoseconds / 1e9
if dt > self.timeout:
# Timeout -> stop
self.cmd_pub.publish(Twist())
Speed Limiting
# Use trigger as speed scaling
speed_scale = (msg.axes[5] + 1.0) / 2.0 # RT: -1~1 -> 0~1
twist.linear.x = msg.axes[1] * self.max_linear * speed_scale
Solution Selection
| Scenario | Recommended Solution | Reason |
|---|---|---|
| ROS2 robot debugging | Xbox controller + joy package | Best compatibility |
| Indoor service robot | PS4/PS5 controller (Bluetooth) | Wireless, good ergonomics |
| Outdoor/UAV | FrSky transmitter (SBUS) | Long range, reliable |
| DIY/Education | ESP32 custom remote | Flexible, educational |
| Competition robot | RadioMaster TX16S | Multi-protocol, professional |
References
- ROS2 joy package: GitHub
- ds4drv: GitHub
- FlySky IBUS Protocol Specification
- FrSky SBUS Protocol Documentation
- ESP-NOW Documentation (Espressif)