File api.py
File List > pyspacemouse > api.py
Go to the documentation of this file
"""Public API for PySpaceMouse.
This module provides the main functions for discovering and opening SpaceMouse devices.
Usage:
# Simple usage with context manager
with pyspacemouse.open() as device:
while True:
state = device.read()
print(state.x, state.y, state.z)
# Open specific device by path
with pyspacemouse.open_by_path("/dev/hidraw0") as device:
state = device.read()
"""
from __future__ import annotations
import os
import warnings
from pathlib import Path
from typing import Callable, Dict, List, Optional, Sequence, Tuple
from ._hid import get_hid
from .callbacks import ButtonCallback, Config, DofCallback
from .config_helpers import apply_axis_convention
from .device import SpaceMouseDevice
from .loader import get_device_specs
from .types import AxisConvention, DeviceInfo, SpaceMouseState
def get_connected_devices() -> List[str]:
"""Return a list of the supported devices currently connected.
Returns:
List of device names that are both supported and connected.
Empty list if no supported devices are found.
Raises:
ImportError: If the hidapi bindings are not installed.
"""
hid = get_hid()
device_specs = get_device_specs()
devices = []
for hid_info in hid.enumerate():
for name, spec in device_specs.items():
if (
hid_info["vendor_id"] == spec.vendor_id
and hid_info["product_id"] == spec.product_id
):
devices.append(name)
return devices
def get_supported_devices() -> List[Tuple[str, int, int]]:
"""Return a list of all supported device types (from configuration).
Returns:
List of tuples: (device_name, vendor_id, product_id)
"""
return [(name, spec.vendor_id, spec.product_id) for name, spec in get_device_specs().items()]
def get_all_hid_devices() -> List[Tuple[str, str, int, int]]:
"""Return a list of all HID devices connected to the system.
Returns:
List of tuples: (product_string, manufacturer_string, vendor_id, product_id)
Raises:
ImportError: If the hidapi bindings are not installed.
"""
hid = get_hid()
return [
(
hid_info["product_string"] or "",
hid_info["manufacturer_string"] or "",
hid_info["vendor_id"],
hid_info["product_id"],
)
for hid_info in hid.enumerate()
]
def _create_and_open_device(
spec: DeviceInfo,
hid_info,
callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callbacks: Optional[Sequence[DofCallback]] = None,
button_callback: Optional[Callable[[SpaceMouseState, List[int]], None]] = None,
button_callbacks: Optional[Sequence[ButtonCallback]] = None,
nonblocking: bool = True,
axis_convention: Optional[AxisConvention] = None,
is_custom_spec: bool = False,
) -> SpaceMouseDevice:
"""Create, configure and open a SpaceMouseDevice.
This is a shared helper to avoid duplication between open() and open_by_path().
"""
if is_custom_spec and axis_convention is not None:
raise ValueError(
"axis_convention and device_spec are mutually exclusive. "
"Manually change the axis mapping in the spec if you need to."
)
if not is_custom_spec:
axis_convention = (
AxisConvention.LEGACY if axis_convention is None else AxisConvention(axis_convention)
)
if axis_convention == AxisConvention.LEGACY:
warnings.warn(
"AxisConvention.LEGACY is deprecated for built-in device specs "
"and will be removed in a future release. Pass "
"axis_convention=AxisConvention.HID_Z_UP for the recommended "
"right-handed Z-up frame, other values on AxisConvention for application-specific frames, or "
"AxisConvention.HID for raw HID axes.",
DeprecationWarning,
stacklevel=3,
)
spec = apply_axis_convention(spec, axis_convention)
mouse = SpaceMouseDevice(info=spec, hid_info=hid_info, nonblocking=nonblocking)
mouse.configure(
callback=callback,
dof_callback=dof_callback,
dof_callbacks=dof_callbacks,
button_callback=button_callback,
button_callbacks=button_callbacks,
)
mouse.open()
return mouse
def open_by_path(
path: str | bytes | Path,
callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callbacks: Optional[Sequence[DofCallback]] = None,
button_callback: Optional[Callable[[SpaceMouseState, List[int]], None]] = None,
button_callbacks: Optional[Sequence[ButtonCallback]] = None,
nonblocking: bool = True,
device_spec: Optional[DeviceInfo] = None,
axis_convention: Optional[AxisConvention] = None,
) -> SpaceMouseDevice:
"""Open a SpaceMouse device by its HID path.
This is mutually exclusive with open() - use this when you know the
exact device path, use open() for automatic device discovery.
Only Linux reports HID paths that are filesystem paths. macOS reports
opaque service IDs ("DevSrvsID:4296357093") and Windows reports device
interface paths ("\\\\?\\HID#VID_256F..."), so the value is matched against
hid.enumerate() as an opaque string first. Any path from
get_connected_devices_by_path() therefore works on every platform, while
on Linux symlinks and relative paths still resolve as before.
Args:
path: HID path of the device, as reported by
get_connected_devices_by_path() (e.g., "/dev/hidraw0" on Linux)
callback: Called on every state change
dof_callback: Called on axis state changes
dof_callbacks: List of per-axis callbacks
button_callback: Called on button state changes
button_callbacks: List of per-button callbacks
nonblocking: If True, use non-blocking reads (required for callbacks)
device_spec: Optional custom DeviceInfo. If provided, uses this
instead of looking up by VID/PID. Useful for custom
axis mappings or unsupported devices. Custom specs are
used exactly as provided.
axis_convention: Coordinate convention for axis values. If None,
uses the deprecated legacy convention for backward
compatibility. Use AxisConvention.HID_Z_UP for a
right-handed Z-up frame, AxisConvention.ROS for ROS,
or AxisConvention.UNITY for Unity. Mutually exclusive
with device_spec.
Returns:
SpaceMouseDevice instance (use as context manager for auto-cleanup)
Raises:
FileNotFoundError: If no connected HID device has that path
ValueError: If the device at path is not a supported SpaceMouse
(unless device_spec is provided)
ImportError: If the hidapi bindings are not installed.
"""
hid = get_hid()
path = os.fsdecode(path) if isinstance(path, bytes) else str(path)
candidates = hid.enumerate()
hid_info = None
# A HID path is an opaque string, so try an exact match first - that is the
# only thing that works on macOS and Windows.
for candidate in candidates:
if os.fsdecode(candidate["path"]) == path:
hid_info = candidate
break
# Otherwise fall back to filesystem resolution, so Linux callers can pass a
# symlink or a relative path to a /dev/hidrawN node.
if hid_info is None and Path(path).exists():
resolved = Path(path).resolve()
for candidate in candidates:
try:
if Path(os.fsdecode(candidate["path"])).resolve() == resolved:
hid_info = candidate
break
except Exception:
continue # Not a path the filesystem can make sense of.
if hid_info is None:
raise FileNotFoundError(f"No HID device found at path '{path}'.")
# Use provided spec or find matching device specification
is_custom_spec = device_spec is not None
if is_custom_spec:
spec = device_spec
else:
all_specs = get_device_specs()
spec = None
for device_s in all_specs.values():
if (
hid_info["vendor_id"] == device_s.vendor_id
and hid_info["product_id"] == device_s.product_id
):
spec = device_s
break
if spec is None:
raise ValueError(
f"Device at '{path}' (VID={hid_info['vendor_id']:#06x}, "
f"PID={hid_info['product_id']:#06x}) is not a supported SpaceMouse. "
f"Use device_spec parameter for custom/unsupported devices."
)
print(f"{spec.name} found at {path}")
return _create_and_open_device(
spec=spec,
hid_info=hid_info,
callback=callback,
dof_callback=dof_callback,
dof_callbacks=dof_callbacks,
button_callback=button_callback,
button_callbacks=button_callbacks,
nonblocking=nonblocking,
axis_convention=axis_convention,
is_custom_spec=is_custom_spec,
)
def open(
callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callback: Optional[Callable[[SpaceMouseState], None]] = None,
dof_callbacks: Optional[Sequence[DofCallback]] = None,
button_callback: Optional[Callable[[SpaceMouseState, List[int]], None]] = None,
button_callbacks: Optional[Sequence[ButtonCallback]] = None,
nonblocking: bool = True,
device: Optional[str] = None,
device_index: int = 0,
device_spec: Optional[DeviceInfo] = None,
axis_convention: Optional[AxisConvention] = None,
) -> SpaceMouseDevice:
"""Open a SpaceMouse device by name or auto-detection.
Use as a context manager for automatic cleanup:
with pyspacemouse.open() as device:
state = device.read()
Args:
callback: Called on every state change
dof_callback: Called on axis state changes
dof_callbacks: List of per-axis callbacks
button_callback: Called on button state changes
button_callbacks: List of per-button callbacks
nonblocking: If True, use non-blocking reads (required for callbacks)
device: Device name to open. If None, uses first found device.
device_index: Which instance to open if multiple same devices connected
device_spec: Optional custom DeviceInfo. If provided, uses this
instead of looking up from TOML. Useful for custom
axis mappings. The device/device_index are still used
to find the HID device. Custom specs are used exactly
as provided.
axis_convention: Coordinate convention for axis values. If None,
uses the deprecated legacy convention for backward
compatibility. Use AxisConvention.HID_Z_UP for a
geometrically consistent right-handed Z-up frame,
AxisConvention.ROS for ROS, AxisConvention.UNITY for
Unity, or AxisConvention.HID for raw HID values
(Z down).
Mutually exclusive with device_spec.
Returns:
SpaceMouseDevice instance (use as context manager for auto-cleanup)
Raises:
RuntimeError: If no device is found
ValueError: If the specified device name is not recognized
ImportError: If the hidapi bindings are not installed.
"""
hid = get_hid()
device_specs = get_device_specs()
# Auto-detect device if not specified
if device is None:
connected = get_connected_devices()
if not connected:
raise RuntimeError("No connected or supported devices found.")
device = connected[0]
if device not in device_specs:
raise ValueError(f"Unknown device: '{device}'. Available: {list(device_specs.keys())}")
# Use provided spec exactly as-is, or get from TOML and apply convention.
is_custom_spec = device_spec is not None
spec = device_spec if is_custom_spec else device_specs[device]
# Find matching HID devices
found = []
for hid_info in hid.enumerate():
if hid_info["vendor_id"] == spec.vendor_id and hid_info["product_id"] == spec.product_id:
found.append(hid_info)
if not found:
raise RuntimeError(f"Device '{device}' not found.")
# Select device by index
if device_index >= len(found):
device_index = 0
print(f"{device} found")
return _create_and_open_device(
spec=spec,
hid_info=found[device_index],
callback=callback,
dof_callback=dof_callback,
dof_callbacks=dof_callbacks,
button_callback=button_callback,
button_callbacks=button_callbacks,
nonblocking=nonblocking,
axis_convention=axis_convention,
is_custom_spec=is_custom_spec,
)
def open_with_config(
config: Config,
nonblocking: bool = True,
device: Optional[str] = None,
device_index: int = 0,
axis_convention: Optional[AxisConvention] = None,
) -> SpaceMouseDevice:
"""Open a SpaceMouse device using a Config object.
Args:
config: Configuration with callback definitions
nonblocking: If True, use non-blocking reads
device: Device name to open
device_index: Which instance to open if multiple connected
axis_convention: Coordinate convention for axis values (see open()).
Returns:
SpaceMouseDevice instance (use as context manager for auto-cleanup)
"""
return open(
callback=config.callback,
dof_callback=config.dof_callback,
dof_callbacks=config.dof_callbacks,
button_callback=config.button_callback,
button_callbacks=config.button_callbacks,
nonblocking=nonblocking,
device=device,
device_index=device_index,
axis_convention=axis_convention,
)
def get_connected_devices_by_path() -> 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:
ImportError: If the hidapi bindings are not installed.
"""
hid = get_hid()
device_specs = get_device_specs()
devices_by_path = {}
# hid.enumerate() is all connected HID devices,
# device_specs is all supported Spacemouse devices.
for hid_info in hid.enumerate():
for name, spec in device_specs.items():
if (
hid_info["vendor_id"] == spec.vendor_id
and hid_info["product_id"] == spec.product_id
):
devices_by_path[os.fsdecode(hid_info["path"])] = name
return devices_by_path