SDK User Guide

Tutorial

1. Device Connection and Selection

This section describes how to connect to dexterous hand devices in wujihandpy and how to filter devices when multiple devices are present.

1.1 Interface Overview

class Hand:
    def __init__(
        self,
        serial_number: str | None = None,
        *,
        side: str | None = None,
        ...,
    ) -> None:
        ...

1.2 Connecting to a Device

Call wujihandpy.Hand() to create a Hand object and connect to the dexterous hand:

hand = wujihandpy.Hand()

If only one dexterous hand is connected to the system, it will automatically connect to that device.

1.3 Filtering Devices

When multiple dexterous hands are connected to the system, using the no-argument constructor will result in an error. For example:

[ERROR] 2 devices found with specified vendor id (0x0483)
[ERROR]   Device 1 (0483:2000): Serial Number = 337238793233 <-- Matched #1
[ERROR]   Device 2 (0483:2000): Serial Number = 337238873233 <-- Matched #2
[ERROR] To ensure correct device selection, please specify the Serial Number
Traceback (most recent call last):
  File "/workspaces/quickstart.py", line 5, in <module>
    hand = wujihandpy.Hand()
           ^^^^^^^^^^^^^^^^^
RuntimeError: Failed to init.

There are two ways to specify which device to connect to.

Select by serial number

Pass the serial_number parameter to identify the device explicitly:

hand = wujihandpy.Hand(serial_number="337238793233")

This ensures that the program always connects to the correct device instance.

Select by handedness

When the system has exactly one left hand and one right hand connected, the side parameter selects the device directly, without needing to remember any serial number:

left = wujihandpy.Hand(side="left")
right = wujihandpy.Hand(side="right")

The SDK enumerates all matching devices, reads the handedness identifier from each, and connects to the matching one.

side and serial_number are mutually exclusive

Pass only one of them. Passing both raises ValueError. side accepts only the string "left" or "right". Any other value raises ValueError.

Common failure cases:

  • No hand of the requested side is connected → raises ConnectionError with No <side> hand found
  • Two hands of the same side are connected → raises ConnectionError suggesting serial_number instead

1.4 Querying Serial Numbers

The dexterous hand has two types of serial numbers:

  • USB serial number: An identifier in the USB hardware descriptor, used to distinguish multiple devices during connection. The constructor's serial_number parameter uses this number.
  • Product serial number: A product tracking number stored in firmware, used for after-sales service and device management.

Querying the USB serial number

Execute the following command in the terminal to view all connected dexterous hands:

lsusb -v -d 0483:2000 | grep iSerial

Example output:

  iSerial                 3 337238793233
                            ^^^^^^^^^^^^  <-- USB Serial Number

The highlighted number is the USB serial number, which can be passed directly to the constructor's serial_number parameter.

Querying the product serial number

After connecting to a device, call get_product_sn() to retrieve the product serial number:

hand = wujihandpy.Hand()
product_sn = hand.get_product_sn()
print(product_sn)  # e.g. "WJ-DH5-R-2501001"

2. Reading Data

This section describes how to read data in synchronous mode.

2.1 Interface Overview

Calling read_* series functions sends a synchronous read request to the dexterous hand and blocks the current thread until the device returns a result. Function return indicates that the read has completed successfully.

Latency Characteristics of Non-Real-Time Interfaces

Each synchronous request causes approximately 5~10 ms of blocking and is suitable for non-real-time tasks. Do not use these APIs in real-time control loops or high-frequency loops requiring high real-time performance.

For real-time control scenarios, consider:

2.2 Single Value Reading

When the read level matches the object level, the function returns a single scalar value.

class Hand:
    def read_<dataname>(self) -> datatype: ...

class Finger:
    def read_<dataname>(self) -> datatype: ...

class Joint:
    def read_<dataname>(self) -> datatype: ...

Examples Read the dexterous hand's uptime (Hand level) using hand:

time = hand.read_system_time()

Read the current position of the index finger's proximal joint (Joint level) using joint:

position = hand.finger(1).joint(0).read_joint_actual_position()

2.3 Batch Reading

When the read level is lower than the object level, the function returns a matrix containing multiple data points.

class Hand:
    def read_<dataname>(self) -> np.NDArray[datatype]: ...

class Finger:
    def read_<dataname>(self) -> np.NDArray[datatype]: ...

class Joint:
    def read_<dataname>(self) -> np.NDArray[datatype]: ...

Examples Request the current position data for all joints in the entire hand (20 joints in total) using hand (Joint level):

positions = hand.read_joint_actual_position()

The function returns an np.NDArray[np.float64], where each row corresponds to a finger and each column corresponds to a joint:

>>> print(positions)
[[ 0.975  0.523  0.271 -0.450]
 [ 0.382  0.241 -0.003 -0.275]
 [-0.299  0.329  0.067 -0.286]
 [-0.122  0.228  0.315 -0.178]
 [ 0.205  0.087  0.288 -0.149]]

Similarly, you can request all joint positions for a single finger using finger:

>>> print(hand.finger(1).read_joint_actual_position())
[ 0.382  0.241 -0.003 -0.275]

2.4 More Examples

3. Writing Data

This section describes how to write data in synchronous mode.

3.1 Interface Overview

Calling write_* series functions sends a synchronous write request to the dexterous hand and blocks the current thread until the device returns a result. Function return indicates that the write has completed successfully.

Latency Characteristics of Non-Real-Time Interfaces

Each synchronous request causes approximately 5~10 ms of blocking and is suitable for non-real-time tasks. Do not use these APIs in real-time control loops or high-frequency loops requiring high real-time performance.

For real-time control scenarios, consider:

3.2 Single Value Writing

When the write level matches the object level, the function uses a single scalar value.

class Hand:
    def write_<dataname>(self, datatype) -> None: ...

class Finger:
    def write_<dataname>(self, datatype) -> None: ...

class Joint:
    def write_<dataname>(self, datatype) -> None: ...

Examples Enable the index finger's proximal joint (Joint level):

hand.finger(1).joint(0).write_joint_enabled(True)

3.3 Batch Writing

When the write level is lower than the object level, the function can accept either a single scalar value or a matrix containing multiple data points.

class Hand:
    @typing.overload
    def write_<dataname>(self, datatype) -> None: ...
    @typing.overload
    def write_<dataname>(self, np.NDArray[datatype]) -> None: ...

class Finger:
    @typing.overload
    def write_<dataname>(self, datatype) -> None: ...
    @typing.overload
    def write_<dataname>(self, np.NDArray[datatype]) -> None: ...

class Joint:
    @typing.overload
    def write_<dataname>(self, datatype) -> None: ...
    @typing.overload
    def write_<dataname>(self, np.NDArray[datatype]) -> None: ...

Examples Enable all joints in the entire hand (Joint level) using hand:

hand.write_joint_enabled(True)

Or enable the index finger while disabling all other fingers:

hand.write_joint_enabled(
    np.array(
        [
            #  J1     J2     J3     J4
            [False, False, False, False],  # F1
            [ True,  True,  True,  True],  # F2
            [False, False, False, False],  # F3
            [False, False, False, False],  # F4
            [False, False, False, False],  # F5
        ],
        dtype=bool,
    )
)

Similarly, you can write target control angles to all joints in the entire hand:

hand.write_joint_target_position(0.0)
# Equals to
hand.write_joint_target_position(
    np.array(
        [
            # J1   J2   J3   J4
            [0.0, 0.0, 0.0, 0.0],  # F1
            [0.0, 0.0, 0.0, 0.0],  # F2
            [0.0, 0.0, 0.0, 0.0],  # F3
            [0.0, 0.0, 0.0, 0.0],  # F4
            [0.0, 0.0, 0.0, 0.0],  # F5
        ],
        dtype=np.float64,
    )
)

3.4 Invalid Value Writing

Typically, invalid value writes are handled automatically, such as:

  • Angles exceeding the valid range are automatically clamped to upper and lower bounds.

However, this is not absolute. When values have logical errors, such as:

  • Setting the effort limit of a joint to an invalid value (too large or too small)

This will cause the operation to fail and throw a TimeoutError.

3.5 More Examples

4. Real-Time Control

This section describes how to control the dexterous hand in real-time mode in wujihandpy.

4.1 Interface Overview

class Hand:
    def realtime_controller(self, enable_upstream: bool, filter: filter.IFilter) -> IController:
        ...

class IController:
    def set_joint_target_position(self, value_array: np.NDArray[np.float64]) -> None:
        ...
    def get_joint_actual_position(self) -> np.NDArray[np.float64]:
        ...
    def get_joint_actual_effort(self) -> np.NDArray[np.float64]:
        ...
    def close(self) -> None:
        ...

Calling Hand.realtime_controller() puts the dexterous hand into real-time control mode and returns a real-time controller object IController. After entering real-time control mode, a 16 kHz real-time filter starts on the motor side to smooth control signals into a stable 16 kHz stream.

4.2 Filters

The real-time controller requires a filter object (filter.IFilter) to smoothly adjust target values.

The initial value of the filter is automatically set to the actual position of the dexterous hand, so there is no need to worry about initial transition issues.

wujihandpy provides the following commonly used filters:

  • filter.LowPass(cutoff_freq: float): First-order low-pass filter with adjustable cutoff frequency. When set to a cutoff frequency >= 1 kHz, the filter is disabled.

Because filter logic runs on the motor driver board, custom filters are not supported.

4.3 Context Manager

IController can be used as a context manager:

with hand.realtime_controller(
    enable_upstream=False,
    filter=wujihandpy.filter.LowPass(cutoff_freq=2.0),
) as controller:
    ...  # Use controller to perform real-time control

When the with block ends, controller.close() is automatically called, ensuring the dexterous hand correctly exits real-time control mode.

Automatic Destruction

If a context manager is not used, IController will automatically call close() during destruction. Due to Python's garbage collection delay, the dexterous hand may not immediately exit real-time mode. If precise control of exit timing is not needed, you can directly rely on destruction behavior.

4.4 Real-Time Writing

def set_joint_target_position(self, value_array: np.NDArray[np.float64]) -> None: ...

The set_joint_target_position() function sends target control values to the motor driver board as quickly as possible without blocking.

4.5 Real-Time Reading

The motor automatically reports current position and effort at a 1 kHz rate. The following functions are non-blocking and immediately return the latest cached readings.

def get_joint_actual_position(self) -> np.NDArray[np.float64]: ...
def get_joint_actual_effort(self) -> np.NDArray[np.float64]: ...

What is Effort?

Effort is the actuation value in current space, filtered before output. It is not the actual measured current—think of it as relative actuation strength.

Typical use cases:

  • Collision detection: a sudden increase in effort indicates joint obstruction
  • Load monitoring: calculate output percentage via effort / effort_limit

Example

import wujihandpy
import numpy as np

hand = wujihandpy.Hand()
try:
    hand.write_joint_enabled(True)
    # Enter real-time control mode using a first-order low-pass filter with 5 Hz cutoff frequency
    with hand.realtime_controller(
        enable_upstream=True,
        filter=wujihandpy.filter.LowPass(cutoff_freq=5.0)
    ) as controller:
        while True:
            # Real-time write target position (non-blocking)
            controller.set_joint_target_position(np.zeros((5, 4)))
            # Real-time read position and effort (non-blocking)
            position = controller.get_joint_actual_position()
            effort = controller.get_joint_actual_effort()
            print(f"pos: {position[1, :]}  effort: {effort[1, :]}")
finally:
    hand.write_joint_enabled(False)

4.6 Advanced Examples

5. Async Read/Write

This section describes how to read/write data in asynchronous mode in wujihandpy.

5.1 Interface Overview

Both read and write interfaces provide asynchronous versions with function names suffixed with _async for performing non-blocking I/O operations in coroutine environments.

async def read_<dataname>_async(self) -> datatype: ...
async def read_<dataname>_async(self) -> np.NDArray[datatype]: ...  # Batch read

async def write_<dataname>_async(self, value: datatype): ...
async def write_<dataname>_async(self, values: np.NDArray[datatype]): ...  # Batch write

When calling asynchronous interfaces, use await. The event loop is not blocked during the wait. Function return indicates that the operation has completed successfully.

Latency Characteristics of Non-Real-Time Interfaces

Asynchronous requests also require 5~10 ms to complete. They are not inherently faster. They simply do not block the event loop.

Compared to synchronous interfaces, the only difference with asynchronous interfaces is that they allow other coroutine tasks to execute during the wait. Therefore, do not call these APIs in real-time control loops or high-frequency loops requiring high real-time performance.

For real-time control scenarios, consider:

Example

import asyncio
import wujihandpy

async def main():
    hand = wujihandpy.Hand()
    try:
        await hand.write_joint_enabled_async(True)
        # Execute the following tasks in parallel:
        # - Move index finger joint 0 to 1.57 rad (approximately 90° downward)
        # - Move index finger joint 2 to 1.57 rad (approximately 90° downward)
        # - Wait for 0.5 seconds simultaneously
        await asyncio.gather(
            hand.finger(1).joint(0).write_joint_target_position_async(1.57),
            hand.finger(1).joint(2).write_joint_target_position_async(1.57),
            asyncio.sleep(0.5),
        )
    finally:
        await hand.write_joint_enabled_async(False)

# Start the event loop and run the main task
asyncio.run(main())

5.2 Advanced Examples

6. Multi-threaded Operations

This section describes how to perform multi-threaded operations in wujihandpy.

6.1 Interface Overview

By default, wujihandpy performs thread safety checks on the Hand object to prevent data race conditions from concurrent multi-threaded access. If you need to use the same Hand object in a multi-threaded environment, you must first call Hand.disable_thread_safe_check() to disable this check.

class Hand:
    def disable_thread_safe_check(self) -> None:
        ...

6.2 Multi-threading Safety Considerations

Important: After disabling the thread safety check, you are responsible for ensuring thread safety.

  • You must call disable_thread_safe_check() before performing multi-threaded operations on the Hand object
  • You must use synchronization mechanisms such as threading.Lock to protect concurrent access to the Hand object
  • It is recommended to wrap all operations on the Hand object and its derived objects (such as IController) with with lock: blocks

Example The following example demonstrates how to use the real-time controller in a multi-threaded environment:

import threading
import time
import wujihandpy
import numpy as np
import math

hand = wujihandpy.Hand()

# Disable thread safety check to allow multi-threaded access
hand.disable_thread_safe_check()
print("Thread safety check disabled, multi-threaded access enabled")

# Create mutex lock to ensure thread safety
lock = threading.Lock()

def reader_thread(ctrl):
    """Periodically read joint positions"""
    while True:
        with lock:
            position = ctrl.get_joint_actual_position()
        print(f"[Reader] Position:\n{position}")
        time.sleep(0.01)

def writer_thread(ctrl):
    """Periodically write target positions (cosine wave motion)"""
    x = 0
    while True:
        y = (1 - math.cos(x)) * 0.8
        target = np.array(
            [
                [0, 0, 0, 0],  # F1
                [y, 0, y, y],  # F2
                [y, 0, y, y],  # F3
                [y, 0, y, y],  # F4
                [y, 0, y, y],  # F5
            ],
            dtype=np.float64,
        )
        with lock:
            ctrl.set_joint_target_position(target)
        print(f"[Writer] Target: {y:.4f} rad")
        x += math.pi / 100.0
        time.sleep(0.01)

hand.write_joint_enabled(True)
with hand.realtime_controller(
    enable_upstream=True,
    filter=wujihandpy.filter.LowPass(cutoff_freq=5.0)
) as ctrl:
    threads = [
        threading.Thread(target=reader_thread, args=(ctrl,)),
        threading.Thread(target=writer_thread, args=(ctrl,)),
    ]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

6.3 Expected Output

After running the example, the terminal will display output similar to the following:

Thread safety check disabled, multi-threaded access enabled
[Reader] Position:
[[ 0.012  0.003 -0.005  0.008]
 [ 0.015  0.002  0.007  0.011]
 [ 0.009 -0.001  0.004  0.006]
 [ 0.018  0.005  0.012  0.009]
 [ 0.011  0.004  0.008  0.013]]
[Writer] Target: 0.0000 rad
[Reader] Position:
[[ 0.012  0.003 -0.005  0.008]
 [ 0.016  0.002  0.008  0.012]
 [ 0.010 -0.001  0.005  0.007]
 [ 0.019  0.005  0.013  0.010]
 [ 0.012  0.004  0.009  0.014]]
[Writer] Target: 0.0004 rad
[Writer] Target: 0.0016 rad
[Reader] Position:
[[ 0.012  0.003 -0.005  0.008]
 [ 0.018  0.002  0.010  0.014]
 [ 0.012 -0.001  0.007  0.009]
 [ 0.021  0.005  0.015  0.012]
 [ 0.014  0.004  0.011  0.016]]
...
  • The first line indicates that the thread safety check has been disabled
  • [Reader] prints the current joint position matrix (5×4, corresponding to 4 joints for each of 5 fingers)
  • [Writer] prints the current target position (cosine wave, starting from 0 and gradually increasing)
  • The two threads output alternately, and the order may be interleaved (this is normal behavior for multi-threading)
  • F1 (thumb) remains stationary, while F2 (index), F3 (middle), F4 (ring), and F5 (pinky) perform periodic flexion-extension movements

6.4 Advanced Examples

7. USB Disconnect Handling

During operation, the USB connection between the dexterous hand and the computer can drop—for example, when the data cable is unplugged. This section describes how wujihandpy behaves on disconnect and how to catch disconnect exceptions.

7.1 Disconnect Behavior

On disconnect, wujihandpy raises an exception. Catch it to recover without crashing the process.

An operation started after a disconnect raises Python's built-in ConnectionError immediately, without waiting for the 500 ms timeout. Affected interfaces include:

  • Synchronous read/write (read_* / write_*)
  • Asynchronous read/write (read_*_async / write_*_async)

An async request already in flight at the moment of disconnect is an exception: it ends with TimeoutError rather than ConnectionError. If your code runs concurrent async operations, catch their common base class OSError to cover both disconnect and timeout.

7.2 Catching Disconnect Exceptions

Wrap device operations in a try block and catch ConnectionError to handle disconnects:

import wujihandpy
import time


def main():
    hand = wujihandpy.Hand()
    print("Reading. Unplug USB to test disconnect handling.\n")

    try:
        while True:
            v = hand.read_input_voltage()
            print(f"input_voltage: {v:.2f}V")
            time.sleep(0.5)
    except ConnectionError as e:
        print(f"\nDisconnect detected: {e}")


if __name__ == "__main__":
    main()

Run the script, then unplug the USB cable while it reads. The loop is interrupted immediately by ConnectionError, and the program exits normally without crashing.

7.3 Disconnect Detection in Real-Time Control Mode

The real-time controller's non-blocking interfaces (set_joint_target_position(), get_joint_actual_position(), get_joint_actual_effort()) do not raise ConnectionError on disconnect.

Real-time control loops rely on high-speed PDO data. On disconnect, these interfaces simply stop updating the cache instead of raising an exception. To detect a disconnect in real-time scenarios, periodically call a synchronous read (such as read_input_voltage()) as a probe in a low-speed bypass loop, and catch ConnectionError.

When the real-time controller is used as a context manager, the end of the with block automatically calls close() to clean up. close() also returns safely on disconnect, so no extra handling is needed.

7.4 More Examples