Skip to content

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 warnings
from pathlib import Path
from typing import Callable, Dict, List, Optional, Sequence, Tuple

from easyhid import Enumeration

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:
        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 = []

    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.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:
        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

    return [
        (
            dev.product_string or "",
            dev.manufacturer_string or "",
            dev.vendor_id,
            dev.product_id,
        )
        for dev in hid.find()
    ]


def _create_and_open_device(
    spec: DeviceInfo,
    hid_device,
    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, or AxisConvention.HID for raw HID axes.",
                DeprecationWarning,
                stacklevel=3,
            )
        spec = apply_axis_convention(spec, axis_convention)

    mouse = SpaceMouseDevice(info=spec, device=hid_device)
    mouse.configure(
        callback=callback,
        dof_callback=dof_callback,
        dof_callbacks=dof_callbacks,
        button_callback=button_callback,
        button_callbacks=button_callbacks,
    )
    mouse.open()
    hid_device.set_nonblocking(nonblocking)
    return mouse


def open_by_path(
    path: str | 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 filesystem path.

    This is mutually exclusive with open() - use this when you know the
    exact device path, use open() for automatic device discovery.

    Args:
        path: Filesystem path to the HID device (e.g., "/dev/hidraw0")
        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. Mutually exclusive with
                         device_spec.

    Returns:
        SpaceMouseDevice instance (use as context manager for auto-cleanup)

    Raises:
        FileNotFoundError: If the specified path does not exist
        ValueError: If the device at path is not a supported SpaceMouse
                    (unless device_spec is provided)
    """
    path = Path(path)

    if not path.exists():
        raise FileNotFoundError(f"Device path '{path}' does not exist.")

    # Resolve path in case it's relative or a symlink
    path = path.resolve()

    # Find the HID device at this path
    hid = Enumeration()
    hid_device = None

    for dev in hid.device_list:
        dev_path = Path(dev.path).resolve()
        if dev_path == path:
            hid_device = dev
            break

    if hid_device 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_device.vendor_id == device_s.vendor_id
                and hid_device.product_id == device_s.product_id
            ):
                spec = device_s
                break

        if spec is None:
            raise ValueError(
                f"Device at '{path}' (VID={hid_device.vendor_id:#06x}, "
                f"PID={hid_device.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_device=hid_device,
        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, 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
    """
    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
    hid = Enumeration()
    found = []

    for hid_dev in hid.find():
        if hid_dev.vendor_id == spec.vendor_id and hid_dev.product_id == spec.product_id:
            found.append(hid_dev)

    if not found:
        raise RuntimeError(f"Device '{device}' not found.")

    # Select device by index
    if device_index >= len(found):
        device_index = 0

    hid_dev = found[device_index]
    print(f"{device} found")

    return _create_and_open_device(
        spec=spec,
        hid_device=hid_dev,
        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:
        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