Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- name: Run pre-commit
run: pixi run pre-commit run --all-files

- name: SDK unit tests
run: pixi run test-sdk

- name: PythonExample unit tests
run: pixi run test-python-example

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ push.json

# Python package metadata (pip install -e)
*.egg-info/
dist/

# Rust build output
target/

# Dora / local output and logs
Demo/out/
out/
*.code-workspace
8 changes: 0 additions & 8 deletions AmazingHand.code-workspace

This file was deleted.

22 changes: 21 additions & 1 deletion FORK.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Differences from Upstream

See the fork notice in [README.md](README.md). This file is for documenting how this repo differs from [pollen-robotics/AmazingHand](https://github.com/pollen-robotics/AmazingHand) (e.g. layout, tooling, refactors).
See the fork notice in [README.md](README.md). This file documents how this repo differs from [pollen-robotics/AmazingHand](https://github.com/pollen-robotics/AmazingHand).

## Cross-Platform Support (Linux, Windows)

Pixi-based setup; MSVC toolchain for Rust on Windows. See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).

## Canonical Configuration

Shared hand geometry, per-physical-hand calibration, named profiles. See [docs/canonical_hand_config_design.md](docs/canonical_hand_config_design.md).

## AmazingHand SDK

Python package for named poses and raw angles. See [README_PKG.md](README_PKG.md).

## CI

GitHub Actions for lint (pre-commit), SDK, PythonExample, Demo, and AHControl tests. See [.github/workflows/ci.yml](.github/workflows/ci.yml).

## Other Changes

Pixi for dependency management; unit tests for SDK, PythonExample, Demo, and AHControl; Dora/MuJoCo simulation demos; pre-commit hooks.
44 changes: 44 additions & 0 deletions README_PKG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# amazinghand

Python SDK for Amazing Hand robotic hand (Pollen Robotics). Control the hand via named poses and raw angles.

## Install

```bash
pip install amazinghand
```

## Quick start

```python
from amazinghand import AmazingHand, list_poses

print("Available poses:", list_poses())
hand = AmazingHand(profile="default")
hand.apply_pose("rock")
hand.apply_pose("paper")
hand.apply_pose("scissors")
```

## Configuration

Set `AMAZINGHAND_CONFIG` to your config directory, or place config under `~/.config/amazinghand` (Linux) / `%LOCALAPPDATA%\amazinghand` (Windows).

Config resolution order:

1. `AMAZINGHAND_CONFIG` env
2. `config_root` argument to `AmazingHand()`
3. Repo config when running from source
4. User config dir
5. Bundled config (pip-installed)

Environment variables:

- `AMAZINGHAND_CONFIG`: path to directory with `profiles.toml` and `calibration/`
- `AMAZINGHAND_PROFILE`: profile name (default: `default`)

To add a calibration: copy `calibration/right_hand.toml` to your file, fill in servo IDs and `rest_deg`, add a profile in `profiles.toml`, and reference it via `right_hand_calibration` / `left_hand_calibration`.

## License

Apache 2.0
34 changes: 34 additions & 0 deletions docs/maintainer.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,38 @@ act push
act pull_request
```

## Publishing to PyPI

Build the package:

```bash
pixi run build-python
```

Publish to Test PyPI and PyPI:

```bash
pixi run publish-testpypi
pixi run publish-pypi
```

Configure credentials in `~/.pypirc` so twine does not prompt:

```ini
[distutils]
index-servers =
pypi
testpypi

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-xxxxxxxx

[pypi]
username = __token__
password = pypi-xxxxxxxx
```

Replace `pypi-xxxxxxxx` with your API token from PyPI account settings. Create separate tokens for Test PyPI and PyPI if needed.

409 changes: 409 additions & 0 deletions pixi.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ numpy = ">=1.20"
rustypot = "*"
pytest = "*"
pre-commit = "*"
build = "*"
twine = "*"
# Demo/HandTracking, Demo/AHSimulation
opencv-python = ">=4.8.0"
mediapipe = ">=0.10.14,<=0.10.15"
Expand All @@ -44,9 +46,15 @@ qpsolvers = { version = ">=4.7.1", extras = ["quadprog"] }
test-python-example = "cd PythonExample && PYTHONPATH= python -m pytest tests/ -v"
test-demo = "cd Demo && python -m pytest tests/ -v"
test-ahcontrol = "cargo test --manifest-path Demo/Cargo.toml -p AHControl"
test-sdk = "python -m pytest tests/ -v"

# build related
build-ahcontrol = "cargo build --release --manifest-path Demo/Cargo.toml -p AHControl"
build-python = "python -m build"

# publish (set TWINE_USERNAME and TWINE_PASSWORD or use --password from prompt)
publish-testpypi = "python -m build && twine upload --repository testpypi dist/*"
publish-pypi = "python -m build && twine upload dist/*"

# dora demos (run from repo root; when used as submodule, use path from parent repo)
dora-build-angle-simu = "dora build Demo/dataflow_angle_simu.yml"
Expand Down
38 changes: 38 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "amazinghand"
version = "0.1.0"
description = "SDK for Amazing Hand robotic hand (hardware-fork org)"
readme = "README_PKG.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.12"
authors = [{ name = "Julia Jia" }, { name = "Krishan Bhakta" }]
keywords = ["robotics", "hand", "servo", "pollen"]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"numpy>=1.20",
"rustypot",
]

[project.optional-dependencies]
dev = ["pytest>=7.0"]

[project.urls]
Repository = "https://github.com/hardware-fork/AmazingHand"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
pythonpath = ["src"]

[tool.setuptools.package-data]
amazinghand = ["config/*.toml", "config/calibration/*.toml"]
22 changes: 22 additions & 0 deletions src/amazinghand/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright (C) 2026 Julia Jia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Amazing Hand SDK - control robotic hand poses and gestures."""

from amazinghand.client import AmazingHand
from amazinghand.config import get_config_root, load_config
from amazinghand.poses import list_poses

__all__ = ["AmazingHand", "load_config", "get_config_root", "list_poses"]
__version__ = "0.1.0"
144 changes: 144 additions & 0 deletions src/amazinghand/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Code derived from Pollen Robotics AmazingHand.
# See: https://github.com/pollen-robotics/AmazingHand
# Copyright (C) 2026 Julia Jia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""AmazingHand client - high-level API for pose and gesture control."""

import time
from pathlib import Path
from typing import Any

import numpy as np
from rustypot import Scs0009PyController

from amazinghand.config import get_config_root, get_hand_config, load_config
from amazinghand.poses import HAND_LEFT, HAND_RIGHT, get_pose, list_poses

_INTER_SERVO_DELAY = 0.0002
_POST_MOVE_DELAY = 0.005


def _default_port() -> str:
import sys
return "COM3" if sys.platform == "win32" else "/dev/ttyUSB0"


class AmazingHand:
"""Control Amazing Hand robotic hand via named poses and raw angles.

Configuration:
profile: config profile name (default from AMAZINGHAND_PROFILE or 'default')
config_root: path to config directory (default from AMAZINGHAND_CONFIG or auto-detect)
side: 1 = right hand, 2 = left hand (default from profile)

Example:
hand = AmazingHand(profile="default")
hand.apply_pose("rock")
hand.apply_pose("paper")
"""

def __init__(
self,
profile: str | None = None,
config_root: str | Path | None = None,
side: int | None = None,
):
self._config_root = get_config_root(config_root)
self._cfg = load_config(profile=profile, config_root=self._config_root)
self._side = side or self._cfg.get("hand_test_id") or self._cfg.get("side") or HAND_LEFT
self._hand = get_hand_config(self._cfg, self._side)
self._servo_ids = self._hand["servo_ids"]
self._middle_pos = self._hand["middle_pos"]
self._max_speed = self._cfg.get("max_speed", 7)
self._close_speed = self._cfg.get("close_speed", 3)

port = self._cfg.get("port") or _default_port()
self._controller: Scs0009PyController = Scs0009PyController(
serial_port=port,
baudrate=self._cfg["baudrate"],
timeout=self._cfg.get("timeout", 0.5),
)
self._controller.write_torque_enable(self._servo_ids[0], 1)

@property
def side(self) -> int:
"""1 = right, 2 = left."""
return self._side

def apply_pose(self, name: str, speed: float | None = None) -> None:
"""Apply a named pose (e.g. 'rock', 'paper', 'scissors', 'ready')."""
pose = get_pose(name, self._side)
sp = speed if speed is not None else self._max_speed
if name.lower() == "close" or name.lower() == "rock":
for finger_idx, (a1, a2) in enumerate(pose):
s = self._close_speed + 1 if finger_idx == 3 else self._close_speed
self._move_finger(finger_idx, a1, a2, s)
else:
for finger_idx, (a1, a2) in enumerate(pose):
self._move_finger(finger_idx, a1, a2, sp)

def apply_pose_target(self, angles: list[float] | list[tuple[float, float]], speed: float | None = None) -> None:
"""Apply raw joint angles. angles: 8 floats (rad or deg) or 4 (a1,a2) tuples in degrees."""
sp = speed if speed is not None else self._max_speed
if len(angles) == 4 and isinstance(angles[0], (tuple, list)):
pose = [(float(a1), float(a2)) for a1, a2 in angles]
elif len(angles) == 8:
pose = [(angles[i], angles[i + 1]) for i in range(0, 8, 2)]
else:
raise ValueError("angles must be 8 floats or 4 (a1,a2) tuples")
for finger_idx, (a1, a2) in enumerate(pose):
self._move_finger(finger_idx, a1, a2, sp)

def _move_finger(self, finger_idx: int, angle_1_deg: float, angle_2_deg: float, speed: float) -> None:
i, j = 2 * finger_idx, 2 * finger_idx + 1
self._controller.write_goal_speed(self._servo_ids[i], speed)
time.sleep(_INTER_SERVO_DELAY)
self._controller.write_goal_speed(self._servo_ids[j], speed)
time.sleep(_INTER_SERVO_DELAY)
self._controller.write_goal_position(
self._servo_ids[i], np.deg2rad(self._middle_pos[i] + angle_1_deg)
)
self._controller.write_goal_position(
self._servo_ids[j], np.deg2rad(self._middle_pos[j] + angle_2_deg)
)
time.sleep(_POST_MOVE_DELAY)

def torque_enable(self, enable: bool = True) -> None:
"""Enable (True) or disable (False) motors. Use disable when moving hand manually."""
val = 1 if enable else 2
for sid in self._servo_ids:
self._controller.write_torque_enable(sid, val)

def read_positions(self) -> list[float]:
"""Return current joint positions in degrees (8 values, relative to middle)."""
degs = []
for i, sid in enumerate(self._servo_ids):
rad = self._controller.read_present_position(sid)
degs.append(np.rad2deg(rad) - self._middle_pos[i])
return degs

def list_poses(self) -> list[str]:
"""Return available pose names."""
return list_poses()

def close(self) -> None:
"""Release resources. Call when done or use as context manager."""
pass

def __enter__(self) -> "AmazingHand":
return self

def __exit__(self, *args: Any) -> None:
self.close()
Loading