Skip to content

Commit 73cea42

Browse files
rwiltzisaaclab-bot[bot]
authored andcommitted
Fix SpaceMouse detection failing when the HID backend reports no product string (#7498)
# Description QA reported that `isaaclab teleop run --teleop_device spacemouse` aborts with `No device found by SpaceMouse. Is the device connected?` while a SpaceMouse Compact (`256f:c635`) is connected and visible on `/dev/hidraw4`, even after applying the `sudo chmod 666 /dev/hidraw<#>` workaround our teleoperation guide documents. ## Root cause The `hidapi` wheel we depend on bundles a **libusb**-backed HID backend, not the hidraw one: $ readelf -d .venv/.../hid.cpython-312-x86_64-linux-gnu.so | grep NEEDED NEEDED libusb-1-150b88da.0.so.0.1.0 $ python -c "import hid; print(hid.enumerate()[0]['path'])" b'7-6:1.0' # a USB path, not /dev/hidraw* That backend reaches the device through `/dev/bus/usb`, and it can only read USB string descriptors for devices the process is allowed to open. Without that access, `hid.enumerate()` still lists the device but returns `product_string == ''`. Detection compared the product string against an exact list: if device["product_string"] == "SpaceMouse Compact" or ... so an empty string matched nothing and the device was reported as absent. The documented `chmod 666 /dev/hidraw<#>` grants nothing to this backend, which is why the workaround appeared to have no effect. This reproduces on any USB HID device the user lacks USB permissions for — no SpaceMouse required: 0x048d:0x5702 product_string='' -> open failed # kernel HID_NAME is "ITE Tech. Inc. ITE Device" 0x1050:0x0407 product_string='YubiKey OTP+FIDO+CCID' # has a udev rule, so strings resolve The exact match is fragile in the other direction too: on a hidraw-backed `hidapi` the product string can come back as the kernel's combined name, e.g. `3Dconnexion SpaceMouse Compact`, which also fails to equal `SpaceMouse Compact`. ## Changes - `Se2SpaceMouse`/`Se3SpaceMouse` now match enumerated devices by USB vendor/product id (`SPACEMOUSE_USB_IDS` in `devices/spacemouse/utils.py`, ids taken from the USB ID repository at https://www.linux-usb.org/usb.ids), covering exactly the models each device class already supported. The product string is kept as a fallback so devices with ids we do not list keep working, and is matched both verbatim and with a leading `3Dconnexion ` stripped. - The "no device found" error now lists the enumerated HID devices and explains that the backend needs access to `/dev/bus/usb`, not `/dev/hidraw*`. - A detected-but-unopenable device now raises that same guidance instead of a bare `OSError: open failed`. - `teleop_imitation.rst`: replaced the ineffective `chmod 666 /dev/hidraw<#>` tip with a udev rule for vendor `256f` (covering both `SUBSYSTEM=="usb"` and `hidraw`), and corrected the Docker recipe to mount `/dev/bus/usb` with the matching `device_cgroup_rules`. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] <!-- backport-active-release --> Backport this pull request to the active release branch after it merges into `develop` ## Checklist Docker and GPU tests run on demand. Push the commits you want tested, then comment `run-ci` on the pull request. - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there (cherry picked from commit 2887452)
1 parent 837435f commit 73cea42

6 files changed

Lines changed: 314 additions & 42 deletions

File tree

docs/source/overview/imitation-learning/teleop_imitation.rst

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,24 +159,41 @@ the key bindings are:
159159
160160
.. tip::
161161

162-
If the SpaceMouse is not detected, you may need to grant additional user permissions by running ``sudo chmod 666 /dev/hidraw<#>`` where ``<#>`` corresponds to the device index
163-
of the connected SpaceMouse.
162+
If the SpaceMouse is not detected, you most likely need additional user permissions. The ``hidapi``
163+
wheel installed by Isaac Lab bundles a backend that talks to the device over ``libusb``, so it needs
164+
read and write access to the USB node under ``/dev/bus/usb`` -- granting access to ``/dev/hidraw*``
165+
alone is **not** sufficient, and without USB access the device is enumerated without a product name.
164166

165-
To determine the device index, list all ``hidraw`` devices by running ``ls -l /dev/hidraw*``.
166-
Identify the device corresponding to the SpaceMouse by running ``cat /sys/class/hidraw/hidraw<#>/device/uevent`` on each of the devices listed
167-
from the prior step.
167+
Grant the permission by installing a udev rule for the 3Dconnexion vendor id:
168168

169-
We recommend using local deployment of Isaac Lab to use the SpaceMouse. If using container deployment (:ref:`deployment-docker`), you must manually mount the SpaceMouse to the ``isaac-lab-base`` container by
170-
adding a ``devices`` attribute with the path to the device in your ``docker-compose.yaml`` file:
169+
.. code:: bash
171170
172-
.. code:: yaml
171+
sudo groupadd -f plugdev && sudo usermod -aG plugdev "$USER"
172+
sudo tee /etc/udev/rules.d/99-spacemouse.rules <<'EOF'
173+
SUBSYSTEM=="usb", ATTR{idVendor}=="256f", TAG+="uaccess", GROUP="plugdev", MODE="0660"
174+
KERNEL=="hidraw*", ATTRS{idVendor}=="256f", TAG+="uaccess", GROUP="plugdev", MODE="0660"
175+
EOF
176+
sudo udevadm control --reload-rules && sudo udevadm trigger
177+
178+
Then unplug and reconnect the SpaceMouse, and log out and back in so the new group membership
179+
applies. The rule grants access to the user on the local seat (``uaccess``) and to members of
180+
``plugdev``, rather than to every account on the machine; the ``plugdev`` membership is what makes
181+
it work over SSH, where there is no local seat. Older 3Dconnexion models such as the SpaceNavigator
182+
for Notebooks enumerate under the Logitech vendor id, so replace ``256f`` with ``046d`` for those.
173183
174-
devices:
175-
- /dev/hidraw<#>:/dev/hidraw<#>
184+
We recommend using local deployment of Isaac Lab to use the SpaceMouse. If using container deployment (:ref:`deployment-docker`), you must give the ``isaac-lab-base`` container access to the USB bus by
185+
mounting it and allowing its device cgroup in your ``docker-compose.yaml`` file:
186+
187+
.. code:: yaml
176188
177-
where ``<#>`` is the device index of the connected SpaceMouse.
189+
volumes:
190+
- /dev/bus/usb:/dev/bus/usb
191+
device_cgroup_rules:
192+
- "c 189:* rmw"
178193
179-
Isaac Lab is only compatible with the SpaceMouse Wireless and SpaceMouse Compact models from 3Dconnexion.
194+
Isaac Lab supports the SpaceMouse Compact, SpaceMouse Wireless and SpaceNavigator for Notebooks
195+
from 3Dconnexion. SE(3) teleoperation additionally supports the SpaceNavigator and the
196+
3Dconnexion Universal Receiver.
180197
181198
182199
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed :class:`~isaaclab.devices.Se2SpaceMouse` and :class:`~isaaclab.devices.Se3SpaceMouse` failing with
5+
``No device found by SpaceMouse`` when a supported 3Dconnexion device was connected. Detection matched the
6+
HID product string exactly, which the ``libusb`` backend bundled in the ``hidapi`` wheels leaves empty
7+
unless the process may open the USB node. Directly attached devices are now matched by their USB vendor
8+
and product ids, with the product string kept as a fallback, and the errors raised when a device cannot be
9+
found or opened name the enumerated devices and the required permissions.
10+
* Fixed SpaceMouse discovery giving up when the first supported device could not be opened. Discovery now
11+
continues to the remaining devices and only reports the open failures if none of them could be opened.
12+
* Added the USB identifier of the 3Dconnexion SpaceNavigator, so the device added in :github:`7544` is also
13+
detected when the HID backend cannot read its product string.

source/isaaclab/isaaclab/devices/spacemouse/se2_spacemouse.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from isaaclab.utils.array import convert_to_torch
2020

2121
from ..device_base import DeviceBase
22-
from .utils import convert_buffer
22+
from .utils import convert_buffer, describe_open_failure, device_not_found_message, resolve_device_name
2323

2424
if TYPE_CHECKING:
2525
from .se2_spacemouse_cfg import Se2SpaceMouseCfg
@@ -44,6 +44,9 @@ class Se2SpaceMouse(DeviceBase):
4444
4545
"""
4646

47+
SUPPORTED_DEVICES = ("SpaceMouse Compact", "SpaceNavigator for Notebooks")
48+
"""Product names of the 3Dconnexion models handled by this device."""
49+
4750
def __init__(self, cfg: Se2SpaceMouseCfg):
4851
"""Initialize the spacemouse layer.
4952
@@ -115,27 +118,41 @@ def advance(self) -> torch.Tensor:
115118
def _find_device(self):
116119
"""Find the device connected to computer."""
117120
found = False
121+
enumerated_devices: list = []
122+
open_failures: list[str] = []
123+
last_error: OSError | None = None
118124
# implement a timeout for device search
119125
for _ in range(5):
120-
for device in hid.enumerate():
121-
if (
122-
device["product_string"] == "SpaceMouse Compact"
123-
or device["product_string"] == "SpaceNavigator for Notebooks"
124-
):
125-
# set found flag
126-
found = True
127-
vendor_id = device["vendor_id"]
128-
product_id = device["product_id"]
129-
# connect to the device
126+
enumerated_devices = hid.enumerate()
127+
# a supported device that cannot be opened must not hide another one that can, so keep
128+
# scanning and only report the failures if no device could be opened at all
129+
open_failures = []
130+
for device in enumerated_devices:
131+
device_name = resolve_device_name(device, self.SUPPORTED_DEVICES)
132+
if device_name is None:
133+
continue
134+
vendor_id = device["vendor_id"]
135+
product_id = device["product_id"]
136+
# connect to the device
137+
try:
130138
self._device.open(vendor_id, product_id)
139+
except OSError as exc:
140+
open_failures.append(describe_open_failure(device_name, vendor_id, product_id, exc))
141+
last_error = exc
142+
continue
143+
# set found flag
144+
found = True
145+
break
131146
# check if device found
132147
if not found:
133148
time.sleep(1.0)
134149
else:
135150
break
136151
# no device found: return false
137152
if not found:
138-
raise OSError("No device found by SpaceMouse. Is the device connected?")
153+
raise OSError(
154+
device_not_found_message(self.SUPPORTED_DEVICES, enumerated_devices, open_failures)
155+
) from last_error
139156

140157
def _run_device(self):
141158
"""Listener thread that keeps pulling new messages."""

source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from scipy.spatial.transform import Rotation
1919

2020
from ..device_base import DeviceBase
21-
from .utils import convert_buffer
21+
from .utils import convert_buffer, describe_open_failure, device_not_found_message, resolve_device_name
2222

2323
if TYPE_CHECKING:
2424
from .se3_spacemouse_cfg import Se3SpaceMouseCfg
@@ -46,6 +46,15 @@ class Se3SpaceMouse(DeviceBase):
4646
4747
"""
4848

49+
SUPPORTED_DEVICES = (
50+
"SpaceMouse Compact",
51+
"SpaceMouse Wireless",
52+
"SpaceNavigator",
53+
"SpaceNavigator for Notebooks",
54+
"3Dconnexion Universal Receiver",
55+
)
56+
"""Product names of the 3Dconnexion models handled by this device."""
57+
4958
def __init__(self, cfg: Se3SpaceMouseCfg):
5059
"""Initialize the space-mouse layer.
5160
@@ -134,32 +143,42 @@ def advance(self) -> torch.Tensor:
134143
def _find_device(self):
135144
"""Find the device connected to computer."""
136145
found = False
146+
enumerated_devices: list = []
147+
open_failures: list[str] = []
148+
last_error: OSError | None = None
137149
# implement a timeout for device search
138150
for _ in range(5):
139-
for device in hid.enumerate():
140-
if (
141-
device["product_string"] == "SpaceMouse Compact"
142-
or device["product_string"] == "SpaceMouse Wireless"
143-
or device["product_string"] == "SpaceNavigator for Notebooks"
144-
or device["product_string"] == "3Dconnexion Universal Receiver"
145-
or device["product_string"] == "SpaceNavigator"
146-
):
147-
# set found flag
148-
found = True
149-
vendor_id = device["vendor_id"]
150-
product_id = device["product_id"]
151-
# connect to the device
152-
self._device.close()
151+
enumerated_devices = hid.enumerate()
152+
# a supported device that cannot be opened must not hide another one that can, so keep
153+
# scanning and only report the failures if no device could be opened at all
154+
open_failures = []
155+
for device in enumerated_devices:
156+
device_name = resolve_device_name(device, self.SUPPORTED_DEVICES)
157+
if device_name is None:
158+
continue
159+
vendor_id = device["vendor_id"]
160+
product_id = device["product_id"]
161+
# connect to the device
162+
try:
153163
self._device.open(vendor_id, product_id)
154-
self._device_name = device["product_string"]
164+
except OSError as exc:
165+
open_failures.append(describe_open_failure(device_name, vendor_id, product_id, exc))
166+
last_error = exc
167+
continue
168+
# set found flag
169+
found = True
170+
self._device_name = device_name
171+
break
155172
# check if device found
156173
if not found:
157174
time.sleep(1.0)
158175
else:
159176
break
160177
# no device found: return false
161178
if not found:
162-
raise OSError("No device found by SpaceMouse. Is the device connected?")
179+
raise OSError(
180+
device_not_found_message(self.SUPPORTED_DEVICES, enumerated_devices, open_failures)
181+
) from last_error
163182

164183
def _run_device(self):
165184
"""Listener thread that keeps pulling new messages."""

source/isaaclab/isaaclab/devices/spacemouse/utils.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
"""Helper functions for SpaceMouse."""
77

8+
from __future__ import annotations
9+
10+
from collections.abc import Sequence
11+
from typing import Any
12+
813
# MIT License
914
#
1015
# Copyright (c) 2022 Stanford Vision and Learning Lab and UT Robot Perception and Learning Lab
@@ -41,6 +46,99 @@ def convert_buffer(b1, b2):
4146
return _scale_to_control(_to_int16(b1, b2))
4247

4348

49+
# USB identifiers of the directly attached 3Dconnexion devices, mapped to the product name that
50+
# selects the HID report layout used by the device listener threads. Identifiers are taken from the
51+
# community-maintained USB ID repository at https://www.linux-usb.org/usb.ids.
52+
# Wireless receivers are deliberately left out: their report layout differs from the cabled devices
53+
# (see the Universal Receiver branch in the listener threads) and is unverified against hardware, so
54+
# they are matched by product string only.
55+
SPACEMOUSE_USB_IDS: dict[tuple[int, int], str] = {
56+
(0x046D, 0xC626): "SpaceNavigator",
57+
(0x256F, 0xC62E): "SpaceMouse Wireless",
58+
(0x256F, 0xC635): "SpaceMouse Compact",
59+
}
60+
"""Mapping from the ``(vendor_id, product_id)`` of a supported SpaceMouse to its product name."""
61+
62+
63+
def resolve_device_name(device: dict[str, Any], supported_names: Sequence[str]) -> str | None:
64+
"""Resolve the product name of an enumerated HID device against the supported SpaceMouse models.
65+
66+
USB identifiers are matched first, because the ``hidapi`` wheels bundle a backend that reaches the
67+
device through ``libusb`` and reports empty product strings unless the process is allowed to open
68+
the USB node. Product strings are only used as a fallback, and are matched both verbatim and with
69+
the ``"3Dconnexion "`` prefix that some HID backends prepend stripped off.
70+
71+
Args:
72+
device: An entry returned by :func:`hid.enumerate`.
73+
supported_names: Product names accepted by the caller.
74+
75+
Returns:
76+
The matched product name, or None if the device is not a supported SpaceMouse.
77+
"""
78+
name = SPACEMOUSE_USB_IDS.get((device["vendor_id"], device["product_id"]))
79+
if name in supported_names:
80+
return name
81+
product_string = (device.get("product_string") or "").strip()
82+
for candidate in (product_string, product_string.removeprefix("3Dconnexion ")):
83+
if candidate in supported_names:
84+
return candidate
85+
return None
86+
87+
88+
_PERMISSION_HINT = (
89+
" Note that on Linux the bundled HID backend reaches the device through libusb, so the user needs"
90+
" read and write access to the USB node under '/dev/bus/usb'; granting access to '/dev/hidraw*'"
91+
" alone is not sufficient. See the teleoperation documentation for the required udev rule."
92+
)
93+
"""Guidance appended to the discovery errors, which are almost always caused by USB permissions."""
94+
95+
96+
def device_not_found_message(
97+
supported_names: Sequence[str],
98+
enumerated_devices: Sequence[dict[str, Any]],
99+
open_failures: Sequence[str] = (),
100+
) -> str:
101+
"""Compose the error raised when no supported SpaceMouse could be opened.
102+
103+
Args:
104+
supported_names: Product names accepted by the caller.
105+
enumerated_devices: The HID devices reported by :func:`hid.enumerate` during the search.
106+
open_failures: Descriptions of the supported devices that were found but could not be opened.
107+
108+
Returns:
109+
An error message naming either the devices that could not be opened, or, when none matched,
110+
the supported models and the HID devices that were seen.
111+
"""
112+
if open_failures:
113+
return (
114+
"Found a supported SpaceMouse but could not open it: " + "; ".join(open_failures) + "." + _PERMISSION_HINT
115+
)
116+
seen = ", ".join(
117+
f"{device['vendor_id']:#06x}:{device['product_id']:#06x} ({device.get('product_string') or 'unnamed'})"
118+
for device in enumerated_devices
119+
)
120+
return (
121+
"No device found by SpaceMouse. Is the device connected?"
122+
f" Supported models: {', '.join(supported_names)}."
123+
f" Enumerated HID devices: {seen or 'none'}."
124+
) + _PERMISSION_HINT
125+
126+
127+
def describe_open_failure(device_name: str, vendor_id: int, product_id: int, error: OSError) -> str:
128+
"""Describe a supported device that was detected but could not be opened.
129+
130+
Args:
131+
device_name: Product name of the detected device.
132+
vendor_id: USB vendor identifier of the detected device.
133+
product_id: USB product identifier of the detected device.
134+
error: The error raised while opening the device.
135+
136+
Returns:
137+
A short description naming the device and the underlying error.
138+
"""
139+
return f"'{device_name}' ({vendor_id:#06x}:{product_id:#06x}): {error}"
140+
141+
44142
"""
45143
Private methods.
46144
"""

0 commit comments

Comments
 (0)