diff --git a/.github/instructions/review.instructions.md b/.github/instructions/review.instructions.md new file mode 100644 index 0000000..8f7297a --- /dev/null +++ b/.github/instructions/review.instructions.md @@ -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. diff --git a/README.md b/README.md index 167bccd..f0e924f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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"), ...] + # List all supported device types pyspacemouse.get_supported_devices() # Returns: [(name, vendor_id, product_id), ...] @@ -115,6 +120,9 @@ with pyspacemouse.open() as device: ### Callbacks ```python +import pyspacemouse +import time + # Button callback def on_button(state, buttons, pressed): print(f"Button {pressed} pressed!") @@ -141,6 +149,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 ``` ### Custom Axis Mapping diff --git a/docs/README.md b/docs/README.md index 2fa027a..1b59d94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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"), ...] + # List all supported device types pyspacemouse.get_supported_devices() # Returns: [(name, vendor_id, product_id), ...] diff --git a/examples/01_basic.py b/examples/01_basic.py index 3e4a0ac..71d1efd 100644 --- a/examples/01_basic.py +++ b/examples/01_basic.py @@ -4,8 +4,6 @@ ensures the device is properly closed when you're done. """ -import time - import pyspacemouse # Using context manager (recommended) @@ -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}" ) - time.sleep(0.01) diff --git a/examples/03_multi_device.py b/examples/03_multi_device.py index 771f2b6..bf40e7e 100644 --- a/examples/03_multi_device.py +++ b/examples/03_multi_device.py @@ -11,20 +11,21 @@ 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() @@ -34,11 +35,13 @@ 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}" + ) + + time.sleep(0.01) if __name__ == "__main__": diff --git a/examples/04_open_by_path.py b/examples/04_open_by_path.py index 8c8aaa5..c3dc6e8 100644 --- a/examples/04_open_by_path.py +++ b/examples/04_open_by_path.py @@ -9,8 +9,6 @@ - Windows: Uses different path format """ -import time - import pyspacemouse @@ -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}" + ) except FileNotFoundError as e: print(f"Device path not found: {e}") diff --git a/examples/05_discovery.py b/examples/05_discovery.py index 3b0273e..4130151 100644 --- a/examples/05_discovery.py +++ b/examples/05_discovery.py @@ -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)") @@ -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}" + ) print() # 3. List ALL HID devices (for debugging) diff --git a/examples/09_custom_config.py b/examples/09_custom_config.py index 7715845..59c51ff 100644 --- a/examples/09_custom_config.py +++ b/examples/09_custom_config.py @@ -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 @@ -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(): @@ -62,18 +65,22 @@ 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"], ) @@ -81,11 +88,11 @@ def example_invert_rotations(): 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(): diff --git a/pyspacemouse/__init__.py b/pyspacemouse/__init__.py index 03e8d9b..efdff0f 100644 --- a/pyspacemouse/__init__.py +++ b/pyspacemouse/__init__.py @@ -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, @@ -96,6 +97,7 @@ # API "get_all_hid_devices", "get_connected_devices", + "get_connected_paths_and_names", "get_supported_devices", "open", "open_by_path", diff --git a/pyspacemouse/api.py b/pyspacemouse/api.py index cd3e99f..5ae550a 100644 --- a/pyspacemouse/api.py +++ b/pyspacemouse/api.py @@ -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 @@ -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 diff --git a/pyspacemouse/pyspacemouse_cli.py b/pyspacemouse/pyspacemouse_cli.py index 7754b61..94c2ee4 100644 --- a/pyspacemouse/pyspacemouse_cli.py +++ b/pyspacemouse/pyspacemouse_cli.py @@ -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.") @@ -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}" ) - time.sleep(0.01) except RuntimeError as e: print(f"Failed to open SpaceMouse: {e}") diff --git a/pyspacemouse/types.py b/pyspacemouse/types.py index 15a0118..8756899 100644 --- a/pyspacemouse/types.py +++ b/pyspacemouse/types.py @@ -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: diff --git a/tests/hidapi_test.py b/tests/hidapi_test.py index 1e1975b..25dc89b 100644 --- a/tests/hidapi_test.py +++ b/tests/hidapi_test.py @@ -6,7 +6,6 @@ # while 1: # state = pyspacemouse.read() # print(state.x, state.y, state.z) -# time.sleep(0.01) import time import hid diff --git a/tests/test_readme.py b/tests/test_readme.py index d6cfb01..02269d9 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -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) # import pyspacemouse # import time