# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Gamepad controller for SE(3) control."""
import numpy as np
import torch
import weakref
from collections.abc import Callable
from dataclasses import dataclass
from scipy.spatial.transform import Rotation
import carb
import omni
from ..device_base import DeviceBase, DeviceCfg
@dataclass
class Se3GamepadCfg(DeviceCfg):
"""Configuration for SE3 gamepad devices."""
dead_zone: float = 0.01 # For gamepad devices
pos_sensitivity: float = 1.0
rot_sensitivity: float = 1.6
retargeters: None = None
[docs]class Se3Gamepad(DeviceBase):
"""A gamepad controller for sending SE(3) commands as delta poses and binary command (open/close).
This class is designed to provide a gamepad controller for a robotic arm with a gripper.
It uses the gamepad interface to listen to gamepad events and map them to the robot's
task-space commands.
The command comprises of two parts:
* delta pose: a 6D vector of (x, y, z, roll, pitch, yaw) in meters and radians.
* gripper: a binary command to open or close the gripper.
Stick and Button bindings:
============================ ========================= =========================
Description Stick/Button (+ve axis) Stick/Button (-ve axis)
============================ ========================= =========================
Toggle gripper(open/close) X Button X Button
Move along x-axis Left Stick Up Left Stick Down
Move along y-axis Left Stick Left Left Stick Right
Move along z-axis Right Stick Up Right Stick Down
Rotate along x-axis D-Pad Left D-Pad Right
Rotate along y-axis D-Pad Down D-Pad Up
Rotate along z-axis Right Stick Left Right Stick Right
============================ ========================= =========================
.. seealso::
The official documentation for the gamepad interface: `Carb Gamepad Interface <https://docs.omniverse.nvidia.com/dev-guide/latest/programmer_ref/input-devices/gamepad.html>`__.
"""
[docs] def __init__(
self,
cfg: Se3GamepadCfg,
):
"""Initialize the gamepad layer.
Args:
cfg: Configuration object for gamepad settings.
"""
# turn off simulator gamepad control
carb_settings_iface = carb.settings.get_settings()
carb_settings_iface.set_bool("/persistent/app/omniverse/gamepadCameraControl", False)
# store inputs
self.pos_sensitivity = cfg.pos_sensitivity
self.rot_sensitivity = cfg.rot_sensitivity
self.dead_zone = cfg.dead_zone
self._sim_device = cfg.sim_device
# acquire omniverse interfaces
self._appwindow = omni.appwindow.get_default_app_window()
self._input = carb.input.acquire_input_interface()
self._gamepad = self._appwindow.get_gamepad(0)
# note: Use weakref on callbacks to ensure that this object can be deleted when its destructor is called
self._gamepad_sub = self._input.subscribe_to_gamepad_events(
self._gamepad,
lambda event, *args, obj=weakref.proxy(self): obj._on_gamepad_event(event, *args),
)
# bindings for gamepad to command
self._create_key_bindings()
# command buffers
self._close_gripper = False
# When using the gamepad, two values are provided for each axis.
# For example: when the left stick is moved down, there are two evens: `left_stick_down = 0.8`
# and `left_stick_up = 0.0`. If only the value of left_stick_up is used, the value will be 0.0,
# which is not the desired behavior. Therefore, we save both the values into the buffer and use
# the maximum value.
# (positive, negative), (x, y, z, roll, pitch, yaw)
self._delta_pose_raw = np.zeros([2, 6])
# dictionary for additional callbacks
self._additional_callbacks = dict()
def __del__(self):
"""Unsubscribe from gamepad events."""
self._input.unsubscribe_to_gamepad_events(self._gamepad, self._gamepad_sub)
self._gamepad_sub = None
def __str__(self) -> str:
"""Returns: A string containing the information of joystick."""
msg = f"Gamepad Controller for SE(3): {self.__class__.__name__}\n"
msg += f"\tDevice name: {self._input.get_gamepad_name(self._gamepad)}\n"
msg += "\t----------------------------------------------\n"
msg += "\tToggle gripper (open/close): X\n"
msg += "\tMove arm along x-axis: Left Stick Up/Down\n"
msg += "\tMove arm along y-axis: Left Stick Left/Right\n"
msg += "\tMove arm along z-axis: Right Stick Up/Down\n"
msg += "\tRotate arm along x-axis: D-Pad Right/Left\n"
msg += "\tRotate arm along y-axis: D-Pad Down/Up\n"
msg += "\tRotate arm along z-axis: Right Stick Left/Right\n"
return msg
"""
Operations
"""
[docs] def reset(self):
# default flags
self._close_gripper = False
self._delta_pose_raw.fill(0.0)
[docs] def add_callback(self, key: carb.input.GamepadInput, func: Callable):
"""Add additional functions to bind gamepad.
A list of available gamepad keys are present in the
`carb documentation <https://docs.omniverse.nvidia.com/dev-guide/latest/programmer_ref/input-devices/gamepad.html>`__.
Args:
key: The gamepad button to check against.
func: The function to call when key is pressed. The callback function should not
take any arguments.
"""
self._additional_callbacks[key] = func
[docs] def advance(self) -> torch.Tensor:
"""Provides the result from gamepad event state.
Returns:
torch.Tensor: A 7-element tensor containing:
- delta pose: First 6 elements as [x, y, z, rx, ry, rz] in meters and radians.
- gripper command: Last element as a binary value (+1.0 for open, -1.0 for close).
"""
# -- resolve position command
delta_pos = self._resolve_command_buffer(self._delta_pose_raw[:, :3])
# -- resolve rotation command
delta_rot = self._resolve_command_buffer(self._delta_pose_raw[:, 3:])
# -- convert to rotation vector
rot_vec = Rotation.from_euler("XYZ", delta_rot).as_rotvec()
# return the command and gripper state
gripper_value = -1.0 if self._close_gripper else 1.0
delta_pose = np.concatenate([delta_pos, rot_vec])
command = np.append(delta_pose, gripper_value)
return torch.tensor(command, dtype=torch.float32, device=self._sim_device)
"""
Internal helpers.
"""
def _on_gamepad_event(self, event, *args, **kwargs):
"""Subscriber callback to when kit is updated.
Reference:
https://docs.omniverse.nvidia.com/dev-guide/latest/programmer_ref/input-devices/gamepad.html
"""
# check if the event is a button press
cur_val = event.value
if abs(cur_val) < self.dead_zone:
cur_val = 0
# -- button
if event.input == carb.input.GamepadInput.X:
# toggle gripper based on the button pressed
if cur_val > 0.5:
self._close_gripper = not self._close_gripper
# -- left and right stick
if event.input in self._INPUT_STICK_VALUE_MAPPING:
direction, axis, value = self._INPUT_STICK_VALUE_MAPPING[event.input]
# change the value only if the stick is moved (soft press)
self._delta_pose_raw[direction, axis] = value * cur_val
# -- dpad (4 arrow buttons on the console)
if event.input in self._INPUT_DPAD_VALUE_MAPPING:
direction, axis, value = self._INPUT_DPAD_VALUE_MAPPING[event.input]
# change the value only if button is pressed on the DPAD
if cur_val > 0.5:
self._delta_pose_raw[direction, axis] = value
self._delta_pose_raw[1 - direction, axis] = 0
else:
self._delta_pose_raw[:, axis] = 0
# additional callbacks
if event.input in self._additional_callbacks:
self._additional_callbacks[event.input]()
# since no error, we are fine :)
return True
def _create_key_bindings(self):
"""Creates default key binding."""
# map gamepad input to the element in self._delta_pose_raw
# the first index is the direction (0: positive, 1: negative)
# the second index is the axis (0: x, 1: y, 2: z, 3: roll, 4: pitch, 5: yaw)
# the third index is the sensitivity of the command
self._INPUT_STICK_VALUE_MAPPING = {
# forward command
carb.input.GamepadInput.LEFT_STICK_UP: (0, 0, self.pos_sensitivity),
# backward command
carb.input.GamepadInput.LEFT_STICK_DOWN: (1, 0, self.pos_sensitivity),
# right command
carb.input.GamepadInput.LEFT_STICK_RIGHT: (0, 1, self.pos_sensitivity),
# left command
carb.input.GamepadInput.LEFT_STICK_LEFT: (1, 1, self.pos_sensitivity),
# upward command
carb.input.GamepadInput.RIGHT_STICK_UP: (0, 2, self.pos_sensitivity),
# downward command
carb.input.GamepadInput.RIGHT_STICK_DOWN: (1, 2, self.pos_sensitivity),
# yaw command (positive)
carb.input.GamepadInput.RIGHT_STICK_RIGHT: (0, 5, self.rot_sensitivity),
# yaw command (negative)
carb.input.GamepadInput.RIGHT_STICK_LEFT: (1, 5, self.rot_sensitivity),
}
self._INPUT_DPAD_VALUE_MAPPING = {
# pitch command (positive)
carb.input.GamepadInput.DPAD_UP: (1, 4, self.rot_sensitivity * 0.8),
# pitch command (negative)
carb.input.GamepadInput.DPAD_DOWN: (0, 4, self.rot_sensitivity * 0.8),
# roll command (positive)
carb.input.GamepadInput.DPAD_RIGHT: (1, 3, self.rot_sensitivity * 0.8),
# roll command (negative)
carb.input.GamepadInput.DPAD_LEFT: (0, 3, self.rot_sensitivity * 0.8),
}
def _resolve_command_buffer(self, raw_command: np.ndarray) -> np.ndarray:
"""Resolves the command buffer.
Args:
raw_command: The raw command from the gamepad. Shape is (2, 3)
This is a 2D array since gamepad dpad/stick returns two values corresponding to
the positive and negative direction. The first index is the direction (0: positive, 1: negative)
and the second index is value (absolute) of the command.
Returns:
Resolved command. Shape is (3,)
"""
# compare the positive and negative value decide the sign of the value
# if the positive value is larger, the sign is positive (i.e. False, 0)
# if the negative value is larger, the sign is positive (i.e. True, 1)
delta_command_sign = raw_command[1, :] > raw_command[0, :]
# extract the command value
delta_command = raw_command.max(axis=0)
# apply the sign
# if the sign is positive, the value is already positive.
# if the sign is negative, the value is negative after applying the sign.
delta_command[delta_command_sign] *= -1
return delta_command