Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ if(BUILD_PLUGINS)
add_subdirectory(src/plugins/rebot_devarm_leader)
add_subdirectory(src/plugins/manus)
add_subdirectory(src/plugins/haptikos)
add_subdirectory(src/plugins/spacemouse)
if(BUILD_PLUGIN_NOITOM_MOCAP)
add_subdirectory(src/plugins/noitom_mocap)
endif()
Expand Down
132 changes: 132 additions & 0 deletions examples/teleop/python/spacemouse_printer_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""
SpaceMouse Printer Example.

Prints the translation axes, rotation axes, and every currently-held button each
frame, via SpaceMouseSource's "spacemouse_translation", "spacemouse_rotation", and
"spacemouse_buttons" outputs. Carries no semantic mapping (a position delta, a
rotation delta, a gripper toggle) -- that belongs in a retargeter (e.g.
SpaceMouseToSe3RelRetargeter) consuming this source's output. The spacemouse plugin
self-discovers its device and is auto-launched by TeleopSession -- no external
process to start manually.
"""

import sys
import time
from pathlib import Path

from isaacteleop.cloudxr import CloudXRLauncher
from isaacteleop.retargeting_engine.deviceio_source_nodes import SpaceMouseSource
from isaacteleop.teleop_session_manager import (
TeleopSession,
TeleopSessionConfig,
PluginConfig,
)


PLUGIN_ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent / "plugins"
PLUGIN_NAME = "spacemouse"
PLUGIN_ROOT_ID = "spacemouse"


def main():
import argparse

parser = argparse.ArgumentParser(description=__doc__)
CloudXRLauncher.add_launcher_arguments(parser)
args = parser.parse_args()

print("\n" + "=" * 80)
print(" SpaceMouse Printer Example")
print("=" * 80)
print("Move or twist the connected SpaceMouse, or press a button.")
print("=" * 80 + "\n")

# ==================================================================
# Setup: Create spacemouse source
# ==================================================================
spacemouse_source = SpaceMouseSource(name="spacemouse")

# ==================================================================
# Configure Plugins
# ==================================================================

plugins = []
if PLUGIN_ROOT_DIR.exists():
plugins.append(
PluginConfig(
plugin_name=PLUGIN_NAME,
plugin_root_id=PLUGIN_ROOT_ID,
search_paths=[PLUGIN_ROOT_DIR],
)
)

# ==================================================================
# Create and run TeleopSession
# ==================================================================

session_config = TeleopSessionConfig(
app_name="SpaceMousePrinterExample",
trackers=[],
pipeline=spacemouse_source,
plugins=plugins,
)

with CloudXRLauncher.launch_context(args):
with TeleopSession(session_config) as session:
start_time = time.time()
prev_pressed: set[int] = set()

while time.time() - start_time < 30.0:
result = session.step()
translation_group = result["spacemouse_translation"]
rotation_group = result["spacemouse_rotation"]
buttons_group = result["spacemouse_buttons"]

elapsed = session.get_elapsed_time()
if translation_group.is_none:
print(
f"[{elapsed:5.1f}s] (no spacemouse data yet)",
end="\r",
flush=True,
)
time.sleep(0.01)
continue

translation = translation_group[0]
rotation = rotation_group[0]
bitmap = buttons_group[0]
pressed = {code for code in range(len(bitmap)) if bitmap[code]}

t_str = " ".join(f"{v:+.2f}" for v in translation)
r_str = " ".join(f"{v:+.2f}" for v in rotation)

# Live status line (overwritten each frame).
names = [f"btn{code}" for code in sorted(pressed)]
print(
f"[{elapsed:5.1f}s] T: [{t_str}] R: [{r_str}] Held: {' '.join(names) or '-'}"
+ " " * 20,
end="\r",
flush=True,
)

# Permanent, scrollable log of every press/release transition -- a
# quick tap can flash by on the status line above before you notice
# it, but every transition is logged here.
for code in sorted(pressed - prev_pressed):
print(f"[{elapsed:5.1f}s] btn{code} down")
for code in sorted(prev_pressed - pressed):
print(f"[{elapsed:5.1f}s] btn{code} up")
prev_pressed = pressed

time.sleep(0.01) # ~100 FPS

print("\nTime limit reached.")

return 0


if __name__ == "__main__":
sys.exit(main())
7 changes: 7 additions & 0 deletions src/core/deviceio_trackers/trackers.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ traits = "PedalRecordingTraits"
max_flatbuffer_size = 256
python_accessor = "get_pedal_data"

[[tracker]]
name = "spacemouse"
table = "SpaceMouseOutput"
class = "SpaceMouseTracker"
max_flatbuffer_size = 512
python_accessor = "get_spacemouse_data"

[[tracker]]
name = "haptic_command"
direction = "push"
Expand Down
42 changes: 42 additions & 0 deletions src/core/schema/fbs/spacemouse.fbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

include "timestamp.fbs";

namespace core;

// Raw state of a 3Dconnexion SpaceMouse-family HID device: the current translation and
// rotation axis readings and the set of currently-held button indices. Carries no
// semantic mapping -- which axis/button means what (a position delta, a rotation delta,
// a toggle) is entirely up to the consuming retargeter.
//
// Validated devices:
// - SpaceMouse Compact, SpaceMouse Wireless, SpaceNavigator for Notebooks,
// 3Dconnexion Universal Receiver.
//
// All fields are always present whenever this table itself is present.
table SpaceMouseOutput {
// Translation axis readings [x, y, z], normalized to [-1, 1]. Index i is the
// value of translation axis i, in the order reported by the device's HID report
// (see spacemouse_plugin.cpp).
translation: [float] (id: 0);

// Rotation axis readings [x, y, z], normalized to [-1, 1], same convention as
// translation.
rotation: [float] (id: 1);
Comment on lines +22 to +26

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use existing Pose schema type to represent the transform?


// Button indices currently held down (bit position in the device's button
// report byte), in no particular order.
pressed_buttons: [ushort] (id: 2);

// Whether the tracker has emitted at least one sample.
is_valid: bool (id: 3);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the record exists then apparently there is at least one sample. The field does not seem to add much value by itself

}

// MCAP recording wrapper for SpaceMouseOutput.
table SpaceMouseOutputRecord {
data: SpaceMouseOutput (id: 0);
timestamp: DeviceDataTimestamp (id: 1);
}

root_type SpaceMouseOutputRecord;
1 change: 1 addition & 0 deletions src/core/schema/python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pybind11_add_module(schema_py
schema_array_views.h
schema_serialized.h
se3_tracker_bindings.h
spacemouse_bindings.h
schema_module.cpp
)

Expand Down
4 changes: 4 additions & 0 deletions src/core/schema/python/schema_module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include "pedals_bindings.h"
#include "pose_bindings.h"
#include "se3_tracker_bindings.h"
#include "spacemouse_bindings.h"
#include "timestamp_bindings.h"

namespace py = pybind11;
Expand Down Expand Up @@ -53,6 +54,9 @@ PYBIND11_MODULE(_schema, m)
// Bind SE3 tracker types (Se3TrackerPose table) for generic 6-DoF pose sources.
core::bind_se3_tracker(m);

// Bind SpaceMouse types (SpaceMouseOutput table) for raw 3Dconnexion axis/button state.
core::bind_spacemouse(m);

// Bind message channel types (MessageChannelMessages table).
core::bind_message_channel(m);

Expand Down
64 changes: 64 additions & 0 deletions src/core/schema/python/spacemouse_bindings.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Python bindings for the SpaceMouse FlatBuffer schema.
// Types: SpaceMouseOutput (table), exposed as an encoded view.

#pragma once

#include "schema_serialized.h"

#include <pybind11/pybind11.h>
#include <schema/spacemouse_generated.h>

#include <cstdint>
#include <string>
#include <vector>

namespace py = pybind11;

namespace core
{

inline void bind_spacemouse(py::module& m)
{
serialized_class<SpaceMouseOutput>(m, "SpaceMouseOutput", "Encoded raw SpaceMouse axis/button state.")
.def(py::init(
[](std::vector<float> translation, std::vector<float> rotation, std::vector<uint16_t> pressed_buttons,
bool is_valid)
{
SpaceMouseOutputT native;
native.translation = std::move(translation);
native.rotation = std::move(rotation);
native.pressed_buttons = std::move(pressed_buttons);
native.is_valid = is_valid;
return pack<SpaceMouseOutput>(native);
}),
py::arg("translation"), py::arg("rotation"), py::arg("pressed_buttons"), py::arg("is_valid"),
"Encode a SpaceMouse axis/button snapshot.")
.def_property_readonly("translation", vector_field(&SpaceMouseOutput::translation))
.def_property_readonly("rotation", vector_field(&SpaceMouseOutput::rotation))
.def_property_readonly("pressed_buttons", vector_field(&SpaceMouseOutput::pressed_buttons))
.def_property_readonly("is_valid", field(&SpaceMouseOutput::is_valid))
.def("__repr__",
[](const Serialized<SpaceMouseOutput>& self)
{
std::string result = "SpaceMouseOutput(pressed_buttons=[";
const auto* buttons = self->pressed_buttons();
if (buttons != nullptr)
{
for (size_t i = 0; i < buttons->size(); ++i)
{
if (i > 0)
result += ", ";
result += std::to_string((*buttons)[i]);
}
}
result += "], is_valid=" + std::to_string(self->is_valid()) + ")";
return result;
});

bind_record<SpaceMouseOutputRecord, SpaceMouseOutput>(m, "SpaceMouseOutputRecord", "SpaceMouseOutput");
}

} // namespace core
23 changes: 23 additions & 0 deletions src/plugins/spacemouse/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(STATUS "Skipping spacemouse plugin (Linux only)")
add_custom_target(spacemouse_plugin
COMMAND ${CMAKE_COMMAND} -E echo "Skipping spacemouse: Linux only")
return()
endif()

add_executable(spacemouse_plugin
main.cpp
spacemouse_plugin.cpp
)

target_link_libraries(spacemouse_plugin PRIVATE
pusherio::pusherio
oxr::oxr_core
isaacteleop_schema
)

install(TARGETS spacemouse_plugin RUNTIME DESTINATION plugins/spacemouse)
install(FILES plugin.yaml README.md DESTINATION plugins/spacemouse)
54 changes: 54 additions & 0 deletions src/plugins/spacemouse/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# SpaceMouse Plugin

Reads a 3Dconnexion SpaceMouse-family device from `/dev/hidraw*` and pushes `SpaceMouseOutput`
via OpenXR. Use with `SpaceMouseTracker` with the same `collection_id`.

Reports raw axis/button state only, with no semantic mapping to position, rotation, or
commands -- that mapping belongs in a retargeter (e.g. `SpaceMouseToSe3RelRetargeter`)
consuming this tracker's output.

Self-discovers its device (the first hidraw device under `/sys/class/hidraw/` whose
`HID_NAME` matches a validated product name), so it needs no arguments to run and can be
auto-launched by `PluginManager` via `PluginConfig` -- no manual process to start.

## Usage

Auto-launched (recommended -- matches how `PluginManager` invokes plugins):

```bash
./spacemouse_plugin --plugin-root-id=spacemouse
```

Manual / standalone, with an explicit device:

```bash
./spacemouse_plugin [device_path] [--combined-report] [--plugin-root-id=<collection_id>]
```

- **device_path**: Optional. Defaults to the first matching hidraw device under
`/sys/class/hidraw/`. Identify a specific device with
`cat /sys/class/hidraw/hidraw*/device/uevent` (look for a `HID_NAME=` line) or `lsusb`.
Reading `/dev/hidraw*` typically requires membership in the `input` group (or a udev
rule granting access).
- **--combined-report**: Only needed with an explicit device path for a "3Dconnexion
Universal Receiver" (auto-discovery sets this automatically); packs translation and
rotation into a single 13-byte report instead of two separate 7-byte reports.
- **collection_id**: Default `spacemouse`. Match this when creating `SpaceMouseTracker`.

## Validated devices

- SpaceMouse Compact
- SpaceMouse Wireless
- SpaceNavigator for Notebooks
- 3Dconnexion Universal Receiver

## Axis/button mapping

Reports the current translation (`[x, y, z]`) and rotation (`[x, y, z]`) axis readings,
normalized to `[-1, 1]`, and the set of currently-held button indices (bit position in the
device's button report byte). Linux only.
Loading
Loading