Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions .github/instructions/review.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Focus on backwards compatibility and user experience.
Point out any API changes.
Keep your responses very short.
Do not summarize the changes.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import pyspacemouse
with pyspacemouse.open() as device:
while True:
state = device.read()
print(state.x, state.y, state.z)
if state.nonzero(0.01):
print(state.x, state.y, state.z, state.roll, state.pitch, state.yaw)
```

## API Reference
Expand All @@ -66,6 +67,10 @@ import pyspacemouse
pyspacemouse.get_connected_devices()
# Returns: ["SpaceNavigator", "SpaceMouse Pro", ...]

# List connected SpaceMouse devices with paths
pyspacemouse.get_connected_paths_and_names()
# Returns: [("/dev/hidraw0", "SpaceNavigator"), ...]
Comment thread
peter-mitrano-ar marked this conversation as resolved.

# List all supported device types
pyspacemouse.get_supported_devices()
# Returns: [(name, vendor_id, product_id), ...]
Expand Down Expand Up @@ -141,6 +146,7 @@ with pyspacemouse.open(
) as device:
while True:
device.read() # Triggers callbacks
time.sleep(0.001) # NOTE: avoid larger sleeps, which can cause data to buffer
```
Comment thread
peter-mitrano-ar marked this conversation as resolved.
Comment thread
peter-mitrano-ar marked this conversation as resolved.

### Custom Axis Mapping
Expand Down
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ import pyspacemouse
pyspacemouse.get_connected_devices()
# Returns: ["SpaceNavigator", "SpaceMouse Pro", ...]

# List connected SpaceMouse devices with paths
pyspacemouse.get_connected_paths_and_names()
# Returns: [("/dev/hidraw0", "SpaceNavigator"), ...]
Comment thread
peter-mitrano-ar marked this conversation as resolved.

# List all supported device types
pyspacemouse.get_supported_devices()
# Returns: [(name, vendor_id, product_id), ...]
Expand Down
8 changes: 1 addition & 7 deletions examples/01_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
ensures the device is properly closed when you're done.
"""

import time

import pyspacemouse

# Using context manager (recommended)
Expand All @@ -16,12 +14,8 @@
while True:
state = device.read()

if any(
abs(val) > 0.01
for val in [state.x, state.y, state.z, state.roll, state.pitch, state.yaw]
):
if state.nonzero():
print(
f"x={state.x:+.2f} y={state.y:+.2f} z={state.z:+.2f} "
f"roll={state.roll:+.2f} pitch={state.pitch:+.2f} yaw={state.yaw:+.2f}"
)
Comment thread
peter-mitrano-ar marked this conversation as resolved.
time.sleep(0.01)
2 changes: 1 addition & 1 deletion examples/02_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ def on_any_button(state, buttons):

while True:
device.read() # Must call read() to process callbacks
time.sleep(0.01)
time.sleep(0.01) # NOTE: large sleeps can cause data to buffer
27 changes: 13 additions & 14 deletions examples/03_multi_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,26 @@
useful for dual-hand control or controlling multiple robots.
"""

import time

import pyspacemouse


def main():
# First, discover connected devices
connected = pyspacemouse.get_connected_devices()
print(f"Found {len(connected)} device(s): {connected}")
connected = pyspacemouse.get_connected_paths_and_names()
print(f"Found {len(connected)} spacemouse device(s): {list(connected.values())}")

if len(connected) < 2:
print("This example requires 2 SpaceMouse devices connected.")
print("Tip: Use a 3Dconnexion Universal Receiver with device_index parameter")
return

# Open two devices using device_index
# device_index=0 is the first device, device_index=1 is the second
device_name = connected[0]
# Arbitrarily take the first two devices found
path0 = list(connected.keys())[0]
path1 = list(connected.keys())[1]

with pyspacemouse.open(device=device_name, device_index=0) as left_hand:
with pyspacemouse.open(device=device_name, device_index=1) as right_hand:
# Open two devices by path
with pyspacemouse.open_by_path(path0) as left_hand:
with pyspacemouse.open_by_path(path1) as right_hand:
print(f"Left hand: {left_hand.name}")
print(f"Right hand: {right_hand.name}")
print()
Expand All @@ -34,11 +33,11 @@ def main():
left = left_hand.read()
right = right_hand.read()

print(
f"L: x={left.x:+.2f} y={left.y:+.2f} z={left.z:+.2f} | "
f"R: x={right.x:+.2f} y={right.y:+.2f} z={right.z:+.2f}"
)
time.sleep(0.02)
if left.nonzero() or right.nonzero():
print(
f"Left: x={left.x:+.2f} y={left.y:+.2f} z={left.z:+.2f} | "
f"Right: x={right.x:+.2f} y={right.y:+.2f} z={right.z:+.2f}"
)
Comment thread
peter-mitrano-ar marked this conversation as resolved.
Comment thread
peter-mitrano-ar marked this conversation as resolved.


if __name__ == "__main__":
Expand Down
9 changes: 5 additions & 4 deletions examples/04_open_by_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
- Windows: Uses different path format
"""

import time

import pyspacemouse


Expand All @@ -34,8 +32,11 @@ def main():

while True:
state = device.read()
print(f"x={state.x:+.2f} y={state.y:+.2f} z={state.z:+.2f}")
time.sleep(0.01)
if state.nonzero():
print(
f"x={state.x:+.2f} y={state.y:+.2f} z={state.z:+.2f} "
f"r={state.roll:+.2f} p={state.pitch:+.2f} y={state.yaw:+.2f}"
)
Comment thread
peter-mitrano-ar marked this conversation as resolved.

except FileNotFoundError as e:
print(f"Device path not found: {e}")
Expand Down
18 changes: 12 additions & 6 deletions examples/05_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ def main():

# 1. List connected SpaceMouse devices
print("Connected SpaceMouse devices:")
connected = pyspacemouse.get_connected_devices()
connected = pyspacemouse.get_connected_paths_and_names()
if connected:
for name in connected:
for name in connected.values():
print(f" ✓ {name}")
else:
print(" (none found)")
Expand All @@ -26,11 +26,17 @@ def main():
# 2. List all supported device types
print("Supported device types:")
supported = pyspacemouse.get_supported_devices()
for name, vid, pid in supported:
for supported_name, vid, pid in supported:
# Check if this device type is connected
is_connected = name in connected
status = "✓" if is_connected else " "
print(f" [{status}] {name} (VID: {vid:#06x}, PID: {pid:#06x})")
status = " "
path_if_connected = ""
for path, name in connected.items():
if name == supported_name:
status = "✓"
path_if_connected = f" (path: {path})"
print(
f" [{status}] {supported_name} (VID: {vid:#06x}, PID: {pid:#06x}){path_if_connected}"
Comment thread
peter-mitrano-ar marked this conversation as resolved.
)
print()

# 3. List ALL HID devices (for debugging)
Expand Down
29 changes: 18 additions & 11 deletions examples/09_custom_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ def example_modify_existing():
print(f"Available devices: {list(specs.keys())}")

# Get connected devices
connected = pyspacemouse.get_connected_devices()
connected = pyspacemouse.get_connected_paths_and_names()
if not connected:
print("No devices connected!")
return
if len(connected) > 1:
print("This example only works with one device connected.")
return

device_name = connected[0]
device_name = list(connected.values())[0]
print(f"Using device: {device_name}")

# Get base spec and create modified version
Expand All @@ -49,11 +52,11 @@ def example_modify_existing():
print("Move the SpaceMouse (Ctrl+C to exit)")
print("Y and Z axes are now inverted!\n")

for _ in range(50): # Run for ~5 seconds
for _ in range(500): # Run for ~5 seconds
state = device.read()
if any([state.x, state.y, state.z]):
if state.nonzero():
print(f"x={state.x:+.2f} y={state.y:+.2f} z={state.z:+.2f} (Y/Z inverted)")
time.sleep(0.1)
time.sleep(0.01)


def example_invert_rotations():
Expand All @@ -62,30 +65,34 @@ def example_invert_rotations():
print("Example 2: Fix rotation conventions")
print("=" * 60)

connected = pyspacemouse.get_connected_devices()
connected = pyspacemouse.get_connected_paths_and_names()
if not connected:
print("No devices connected!")
return
if len(connected) > 1:
print("This example only works with one device connected.")
return

device_name = list(connected.values())[0]
specs = pyspacemouse.get_device_specs()
base_spec = specs[connected[0]]
base_spec = specs[device_name]

# Invert roll and yaw for right-handed coordinate system
fixed_spec = pyspacemouse.modify_device_info(
base_spec,
name=f"{connected[0]} (Fixed Rotations)",
name=f"{device_name} (Fixed Rotations)",
invert_axes=["roll", "yaw"],
)

with pyspacemouse.open(device_spec=fixed_spec) as device:
print(f"Connected to: {device.name}")
print("Roll and Yaw are now inverted!\n")

for _ in range(30):
for _ in range(500):
state = device.read()
if any([state.roll, state.pitch, state.yaw]):
if state.nonzero():
print(f"roll={state.roll:+.2f} pitch={state.pitch:+.2f} yaw={state.yaw:+.2f}")
time.sleep(0.1)
time.sleep(0.01)


def example_create_custom():
Expand Down
2 changes: 2 additions & 0 deletions pyspacemouse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .api import (
get_all_hid_devices,
get_connected_devices,
get_connected_paths_and_names,
get_supported_devices,
open,
open_by_path,
Expand Down Expand Up @@ -96,6 +97,7 @@
# API
"get_all_hid_devices",
"get_connected_devices",
"get_connected_paths_and_names",
"get_supported_devices",
"open",
"open_by_path",
Expand Down
31 changes: 30 additions & 1 deletion pyspacemouse/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Callable, List, Optional, Sequence, Tuple
from typing import Callable, Dict, List, Optional, Sequence, Tuple

from easyhid import Enumeration

Expand Down Expand Up @@ -321,3 +321,32 @@ def open_with_config(
device=device,
device_index=device_index,
)


def get_connected_paths_and_names() -> Dict[str, str]:
"""Return the paths and names of the supported devices currently connected.

Returns:
Dict of paths: device names (e.g., {"/dev/hidraw0": "SpaceMouse Pro"}).

Raises:
RuntimeError: If HID API is not installed.
"""
try:
hid = Enumeration()
except AttributeError as e:
raise RuntimeError(
"HID API is probably not installed. See https://spacemouse.kubaandrysek.cz for details."
) from e

device_specs = get_device_specs()
devices_by_path = {}

# hid.find() is all connected HID devices,
# device_specs is all supported Spacemouse devices.
for hid_device in hid.find():
for name, spec in device_specs.items():
if hid_device.vendor_id == spec.vendor_id and hid_device.product_id == spec.product_id:
devices_by_path[hid_device.path] = name

return devices_by_path
14 changes: 5 additions & 9 deletions pyspacemouse/pyspacemouse_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ def print_version_cli():

def list_spacemouse_cli():
"""List connected SpaceMouse devices."""
devices = pyspacemouse.get_connected_devices()
if devices:
connected = pyspacemouse.get_connected_paths_and_names()
if connected:
print("Connected SpaceMouse devices:")
for device in devices:
print(f" - {device}")
for path, device in connected.items():
print(f" - {device} ({path})")
else:
print("No connected SpaceMouse devices found.")

Expand Down Expand Up @@ -59,15 +59,11 @@ def test_connect_cli():

while True:
state = device.read()
if any(
abs(val) > 0.01
for val in [state.x, state.y, state.z, state.roll, state.pitch, state.yaw]
):
if state.nonzero():
print(
f"x={state.x:+.2f} y={state.y:+.2f} z={state.z:+.2f} "
f"roll={state.roll:+.2f} pitch={state.pitch:+.2f} yaw={state.yaw:+.2f}"
)
Comment thread
peter-mitrano-ar marked this conversation as resolved.
time.sleep(0.01)

except RuntimeError as e:
print(f"Failed to open SpaceMouse: {e}")
Expand Down
7 changes: 7 additions & 0 deletions pyspacemouse/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ def __getitem__(self, key: str) -> float:
"""Allow dict-like access for backward compatibility."""
return getattr(self, key)

def nonzero(self, threshold: float = 0.01) -> bool:
"""
Check if any axis value exceeds the given threshold.
Used in example scripts to avoid printing zero values when the device is at rest.
"""
return any(abs(getattr(self, axis)) > threshold for axis in AXIS_NAMES)


@dataclass(frozen=True, slots=True)
class DeviceInfo:
Expand Down
1 change: 0 additions & 1 deletion tests/hidapi_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
# while 1:
# state = pyspacemouse.read()
# print(state.x, state.y, state.z)
# time.sleep(0.01)
import time

import hid
Expand Down
5 changes: 1 addition & 4 deletions tests/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,14 @@
# callback()


import time

import pyspacemouse

success = pyspacemouse.open(
dof_callback=pyspacemouse.print_state, button_callback=pyspacemouse.print_buttons
)
if success:
while 1:
while True:
state = pyspacemouse.read()
time.sleep(0.01)

Comment thread
peter-mitrano-ar marked this conversation as resolved.
Comment on lines 47 to 55
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This file is collected by pytest (python_files=test_*.py) and currently executes device I/O and an infinite loop at import time. That can hang the test run (and now busy-loops with no sleep). Move this code under an if __name__ == "__main__": guard (or convert it into a real, bounded pytest test using mocks) and add a small sleep/backoff in the loop if it remains an example.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we don't run any tests yet

# import pyspacemouse
# import time
Expand Down
Loading