Source code for isaaclab.devices.spacemouse.se3_spacemouse
# 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
"""Spacemouse controller for SE(3) control."""
import hid
import numpy as np
import threading
import time
import torch
from collections.abc import Callable
from dataclasses import dataclass
from scipy.spatial.transform import Rotation
from ..device_base import DeviceBase, DeviceCfg
from .utils import convert_buffer
@dataclass
class Se3SpaceMouseCfg(DeviceCfg):
"""Configuration for SE3 space mouse devices."""
pos_sensitivity: float = 0.4
rot_sensitivity: float = 0.8
retargeters: None = None
[docs]class Se3SpaceMouse(DeviceBase):
"""A space-mouse controller for sending SE(3) commands as delta poses.
This class implements a space-mouse controller to provide commands to a robotic arm with a gripper.
It uses the `HID-API`_ which interfaces with USD and Bluetooth HID-class devices across multiple platforms [1].
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.
Note:
The interface finds and uses the first supported device connected to the computer.
Currently tested for following devices:
- SpaceMouse Compact: https://3dconnexion.com/de/product/spacemouse-compact/
.. _HID-API: https://github.com/libusb/hidapi
"""
[docs] def __init__(self, cfg: Se3SpaceMouseCfg):
"""Initialize the space-mouse layer.
Args:
cfg: Configuration object for space-mouse settings.
"""
# store inputs
self.pos_sensitivity = cfg.pos_sensitivity
self.rot_sensitivity = cfg.rot_sensitivity
self._sim_device = cfg.sim_device
# acquire device interface
self._device = hid.device()
self._find_device()
# read rotations
self._read_rotation = False
# command buffers
self._close_gripper = False
self._delta_pos = np.zeros(3) # (x, y, z)
self._delta_rot = np.zeros(3) # (roll, pitch, yaw)
# dictionary for additional callbacks
self._additional_callbacks = dict()
# run a thread for listening to device updates
self._thread = threading.Thread(target=self._run_device)
self._thread.daemon = True
self._thread.start()
def __del__(self):
"""Destructor for the class."""
self._thread.join()
def __str__(self) -> str:
"""Returns: A string containing the information of joystick."""
msg = f"Spacemouse Controller for SE(3): {self.__class__.__name__}\n"
msg += f"\tManufacturer: {self._device.get_manufacturer_string()}\n"
msg += f"\tProduct: {self._device.get_product_string()}\n"
msg += "\t----------------------------------------------\n"
msg += "\tRight button: reset command\n"
msg += "\tLeft button: toggle gripper command (open/close)\n"
msg += "\tMove mouse laterally: move arm horizontally in x-y plane\n"
msg += "\tMove mouse vertically: move arm vertically\n"
msg += "\tTwist mouse about an axis: rotate arm about a corresponding axis"
return msg
"""
Operations
"""
[docs] def reset(self):
# default flags
self._close_gripper = False
self._delta_pos = np.zeros(3) # (x, y, z)
self._delta_rot = np.zeros(3) # (roll, pitch, yaw)
[docs] def add_callback(self, key: str, func: Callable):
"""Add additional functions to bind spacemouse.
Args:
key: The keyboard 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 spacemouse 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).
"""
rot_vec = Rotation.from_euler("XYZ", self._delta_rot).as_rotvec()
delta_pose = np.concatenate([self._delta_pos, rot_vec])
gripper_value = -1.0 if self._close_gripper else 1.0
command = np.append(delta_pose, gripper_value)
return torch.tensor(command, dtype=torch.float32, device=self._sim_device)
"""
Internal helpers.
"""
def _find_device(self):
"""Find the device connected to computer."""
found = False
# implement a timeout for device search
for _ in range(5):
for device in hid.enumerate():
if (
device["product_string"] == "SpaceMouse Compact"
or device["product_string"] == "SpaceMouse Wireless"
):
# set found flag
found = True
vendor_id = device["vendor_id"]
product_id = device["product_id"]
# connect to the device
self._device.close()
self._device.open(vendor_id, product_id)
# check if device found
if not found:
time.sleep(1.0)
else:
break
# no device found: return false
if not found:
raise OSError("No device found by SpaceMouse. Is the device connected?")
def _run_device(self):
"""Listener thread that keeps pulling new messages."""
# keep running
while True:
# read the device data
data = self._device.read(7)
if data is not None:
# readings from 6-DoF sensor
if data[0] == 1:
self._delta_pos[1] = self.pos_sensitivity * convert_buffer(data[1], data[2])
self._delta_pos[0] = self.pos_sensitivity * convert_buffer(data[3], data[4])
self._delta_pos[2] = self.pos_sensitivity * convert_buffer(data[5], data[6]) * -1.0
elif data[0] == 2 and not self._read_rotation:
self._delta_rot[1] = self.rot_sensitivity * convert_buffer(data[1], data[2])
self._delta_rot[0] = self.rot_sensitivity * convert_buffer(data[3], data[4])
self._delta_rot[2] = self.rot_sensitivity * convert_buffer(data[5], data[6]) * -1.0
# readings from the side buttons
elif data[0] == 3:
# press left button
if data[1] == 1:
# close gripper
self._close_gripper = not self._close_gripper
# additional callbacks
if "L" in self._additional_callbacks:
self._additional_callbacks["L"]()
# right button is for reset
if data[1] == 2:
# reset layer
self.reset()
# additional callbacks
if "R" in self._additional_callbacks:
self._additional_callbacks["R"]()
if data[1] == 3:
self._read_rotation = not self._read_rotation