Skip to content
Draft
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Support for, and documentation of, named "axis conventions".

### Fixed

- Add `read_latest()` since `read()` alone will return old data if not called quickly enough.
- Add `open_by_path`/`get_connected_devices_by_path` to more clearly handle multiple devices of different types [#47](https://github.com/JakubAndrysek/PySpaceMouse/pull/47).

### Changed

- Deprication warning in `open()` when `AxisConvention.LEGACY` is provided.

## [2.0.0](https://pypi.org/project/pyspacemouse/2.0.0) - 2026-01-17

### Added

### Fixed

### Changed

- Almost completely rewritten, potentially largely untested and unreviewed vibe-coding changes.

### Removed
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fixRelativeLinkDocs:
sed 's/\.\/docs/\./g' README.md > docs/README.md
sed 's/\.\/docs/\./g' CONTRIBUTING.md > docs/CONTRIBUTING.md
sed 's/\.\/docs/\./g' troubleshooting.md > docs/troubleshooting.md
sed 's/\.\/docs/\./g' CHANGELOG.md > docs/CHANGELOG.md

# Docs
docs-build: install-doxygen fixRelativeLinkDocs
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ 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
Expand All @@ -201,6 +200,34 @@ with pyspacemouse.open(device_spec=custom) as device:

See [Custom Device Configuration](./docs/mouseApi/index.md#custom-device-configuration) for full API.

## Laggy data and sleeps

If you put `read()` in a loop with some slow code, or `sleep()` more than ~0.01, you might see the data returned by `read()` start to "lag".
Consider the follow extreme example of what NOT to do:
```python
with pyspacemouse.open() as device:
state = device.read()
if state.nonzero():
print(state)
# VERY BAD!
time.sleep(0.1)
```
If you run this, press the space mouse down, then release, you will see it takes a long time for the state to go back to zero!
This is because `read()` only reads exactly one message from the device, but the OS (at least on linux) will buffer the data for you so no data is dropped.
During the 100ms you are sleeping, many messages can be received and buffered, and this buffer can be surprisingly large.
Then when you release the spacemouse, you have to call `read()` many many times to drain the buffer before you get the final message saying the spacemouse state is all 0s again.
If you would like to run a low-frequency (<100Hz) loop without this buffering behavior, use `read_latest()` instead.

### Solution -- `read_latest()`
```
with pyspacemouse.open() as device:
state = device.read_latest() # <--
if state.nonzero():
print(state)
time.sleep(0.1)
```
Now you have no problems anymore, because `read_latest()` will keep reading until the buffer is empty, and only then return you the latest state.

## CLI

```bash
Expand Down
33 changes: 33 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Support for, and documentation of, named "axis conventions".

### Fixed

- Add `read_latest()` since `read()` alone will return old data if not called quickly enough.
- Add `open_by_path`/`get_connected_devices_by_path` to more clearly handle multiple devices of different types [#47](https://github.com/JakubAndrysek/PySpaceMouse/pull/47).

### Changed

- Deprication warning in `open()` when `AxisConvention.LEGACY` is provided.

## [2.0.0](https://pypi.org/project/pyspacemouse/2.0.0) - 2026-01-17

### Added

### Fixed

### Changed

- Almost completely rewritten, potentially largely untested and unreviewed vibe-coding changes.

### Removed
34 changes: 31 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The optional argument is the threshold used, and is applied per-axis.
import pyspacemouse

# Context manager (recommended) - automatically closes device
with pyspacemouse.open() as device:
with pyspacemouse.open(nonblocking=False) as device:
while True:
state = device.read()
if state.has_motion(0.01):
Expand Down Expand Up @@ -172,10 +172,10 @@ dof_callbacks = [
with pyspacemouse.open(
button_callbacks=button_callbacks,
dof_callbacks=dof_callbacks,
nonblocking=False
) 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
Expand All @@ -195,12 +195,40 @@ custom = pyspacemouse.modify_device_info(
invert_axes=["y", "z", "roll", "yaw"], # Invert these
)

with pyspacemouse.open(device_spec=custom) as device:
with pyspacemouse.open(device_spec=custom, nonblocking=False) as device:
state = device.read()
```

See [Custom Device Configuration](./mouseApi/index.md#custom-device-configuration) for full API.

## Laggy data and sleeps

If you put `read()` in a loop with some slow code, or `sleep()` more than ~0.01, you might see the data returned by `read()` start to "lag".
Consider the follow extreme example of what NOT to do:
```python
with pyspacemouse.open() as device:
state = device.read()
if state.nonzero():
print(state)
# VERY BAD!
time.sleep(0.1)
```
If you run this, press the space mouse down, then release, you will see it takes a long time for the state to go back to zero!
This is because `read()` only reads exactly one message from the device, but the OS (at least on linux) will buffer the data for you so no data is dropped.
During the 100ms you are sleeping, many messages can be received and buffered, and this buffer can be surprisingly large.
Then when you release the spacemouse, you have to call `read()` many many times to drain the buffer before you get the final message saying the spacemouse state is all 0s again.
If you would like to run a low-frequency (<100Hz) loop without this buffering behavior, use `read_latest()` instead.

### Solution -- `read_latest()`
```
with pyspacemouse.open() as device:
state = device.read_latest() # <--
if state.nonzero():
print(state)
time.sleep(0.1)
```
Now you have no problems anymore, because `read_latest()` will keep reading until the buffer is empty, and only then return you the latest state.

## CLI

```bash
Expand Down
55 changes: 22 additions & 33 deletions docs/mouseApi/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,28 @@ More examples can be found in [Examples](./examples.md) page.

## Module pyspacemouse

The module-level API is as follows:

open(callback=None, button_callback=None, button_callback_arr=None, set_nonblocking_loop=True, device=None)
Open a 3D space navigator device. Makes this device the current active device, which enables the module-level read() and close()
calls. For multiple devices, use the read() and close() calls on the returned object instead, and don't use the module-level calls.

Parameters:
callback: If callback is provided, it is called on each HID update with a copy of the current state namedtuple
dof_callback: If dof_callback is provided, it is called only on DOF state changes with the argument (state).
button_callback: If button_callback is provided, it is called on each button push, with the arguments (state_tuple, button_state)
device: name of device to open, as a string like "SpaceNavigator". Must be one of the values in `supported_devices`.
If `None`, chooses the first supported device found.
Returns:
Device object if the device was opened successfully
None if the device could not be opened

read() Return a namedtuple giving the current device state (t,x,y,z,roll,pitch,yaw,button)
close() Close the connection to the current device, if it is open
list_devices() Return a list of supported devices found, or an empty list if none found

`open()` returns a DeviceSpec object.
If you have multiple 3Dconnexion devices, you can use the object-oriented API to access them individually.
Each object has the following API, which functions exactly as the above API, but on a per-device basis:

dev.open() Opens the connection (this is always called by the module-level open command,
so you should not need to use it unless you have called close())
dev.read() Return the state of the device as namedtuple [t,x,y,z,roll,pitch,yaw,button]
dev.close() Close this device

There are also attributes:

dev.connected True if the device is connected, False otherwise
dev.state Convenience property which returns the same value as read()
As described in the examples, you typically want to start by "opening" a device using one of the open functions, then call one of the "read" functions.

### Open Functions

These are module-level functions (e.g. `pyspacemouse.open()`) and should be used in a `with ... as ...` statement.

- `open()`: By default, returns the "first" supported device it finds. Great when you only have one device.
- `open_by_path()`: Opens a specific device (from symlink, `udev` rule, hidraw path, etc.). Great when you need stability across re-plugging or multiple devices.

### Read Functions

These are called on the object returned by the `open` functions.

- `read()`
- `read_latest()`

The main difference is that `read_latest()` handles two comment issues better: old/laggy data, and busy-waiting.
The function `read_latest()` does one blocking call to `read()` which will keep CPU usage low if the device is stationary at all 0s, but will ensure the buffer is drained of data before returning to you the latest data.
This ensures you always get the latest data, regardless of how slowly your loop to `read_latest()` is!
If you use `read()` you must read very quickly (~200Hz in my testing), or the HID/USB buffer does not fill up and you will start to get old data.
This often shows up as "lag" in your application.
In short, if you were previously having

## State Objects

Expand Down
5 changes: 1 addition & 4 deletions examples/02_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
- Axis movements with filtering
"""

import time

import pyspacemouse
from pyspacemouse import AxisConvention

Expand Down Expand Up @@ -50,5 +48,4 @@ def on_any_button(state, buttons):
print()

while True:
device.read() # Must call read() to process callbacks
time.sleep(0.01)
device.read_latest() # Must call read_latest() or read() to process callbacks
6 changes: 3 additions & 3 deletions examples/03_multi_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ def main():
print("Move both devices (Ctrl+C to exit)")

while True:
left = left_hand.read()
right = right_hand.read()
left = left_hand.read_latest()
right = right_hand.read_latest()

if left.has_motion() or right.has_motion():
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)
time.sleep(0.1)


if __name__ == "__main__":
Expand Down
7 changes: 4 additions & 3 deletions examples/05_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ def main():
supported = pyspacemouse.get_supported_devices()
for supported_name, vid, pid in supported:
# Check if this device type is connected
status = " "
count = 0
path_if_connected = ""
for path, name in connected.items():
if name == supported_name:
status = "✓"
count += 1
path_if_connected = f" (path: {path})"
count_str = " " if count == 0 else str(count)
print(
f" [{status}] {supported_name} (VID: {vid:#06x}, PID: {pid:#06x}){path_if_connected}"
f" [{count_str}] {supported_name} (VID: {vid:#06x}, PID: {pid:#06x}){path_if_connected}"
)
print()

Expand Down
2 changes: 1 addition & 1 deletion examples/06_axis_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ def on_z_move(state, value):

while True:
device.read()
time.sleep(0.01)
time.sleep(0.001)
2 changes: 1 addition & 1 deletion examples/08_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ def on_button_change(state, buttons):

while True:
device.read()
time.sleep(0.01)
time.sleep(0.001)
3 changes: 0 additions & 3 deletions examples/09_invert_rotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
Just for demonstration purposes, this would be pretty weird :)
"""

import time

import pyspacemouse
from pyspacemouse import AxisConvention

Expand Down Expand Up @@ -45,7 +43,6 @@ def example_invert_rotations():
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)


if __name__ == "__main__":
Expand Down
29 changes: 29 additions & 0 deletions examples/11_blocking_read_latest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Reading without busy-waiting or getting old/laggy data

Shows you how to read slowly and still get the most recent data.
"""

import time

import pyspacemouse
from pyspacemouse import AxisConvention

with pyspacemouse.open(axis_convention=AxisConvention.HID_Z_UP) as device:
print(f"Connected to: {device.name}")
print("Move the SpaceMouse to see values (Ctrl+C to exit)")

while True:
# with block=True, we avoid polling and wasting CPU,
# because when the device is stationary at 0, it doesn't send any data.
state = device.read_latest(block=True)

# Note how we don't need to check state.has_motion() here,
# because when there is no motion, the read_latest() just blocks.
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}"
)

# This sleep would cause problems if we used just device.read(),
# but with read_latest(), we can read as slowly as we want!
time.sleep(0.1)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ nav:
- Home: 'README.md'
- Contributing: 'CONTRIBUTING.md'
- Troubleshooting: 'troubleshooting.md'
- Changelog: 'CHANGELOG.md'

- API:
- mouseApi/index.md
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ exclude = [
"/Makefile",
"/CONTRIBUTING.md",
"/troubleshooting.md",
"/release.md",
"/CHANGELOG.md",
]

[tool.hatch.build.targets.wheel]
Expand Down
Loading
Loading