zeroArm-sdk 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zeroarm_sdk-0.1.2/LICENSE.txt +21 -0
- zeroarm_sdk-0.1.2/MANIFEST.in +9 -0
- zeroarm_sdk-0.1.2/PKG-INFO +55 -0
- zeroarm_sdk-0.1.2/README.md +32 -0
- zeroarm_sdk-0.1.2/pyproject.toml +36 -0
- zeroarm_sdk-0.1.2/setup.cfg +4 -0
- zeroarm_sdk-0.1.2/setup.py +76 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/__init__.py +9 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/_core.pyx +569 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/arm.py +374 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/arm_types.py +40 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/exceptions.py +15 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk/locale/zh_CN/LC_MESSAGES/zeroArm_sdk.mo +0 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk.egg-info/PKG-INFO +55 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk.egg-info/SOURCES.txt +16 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk.egg-info/dependency_links.txt +1 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk.egg-info/requires.txt +4 -0
- zeroarm_sdk-0.1.2/zeroArm_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) ForceEase Co.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zeroArm_sdk
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Official SDK for ZeroArm robotic arm (proprietary)
|
|
5
|
+
Home-page: https://github.com/HANCKH/zeroArm_python_sdk
|
|
6
|
+
Author: ForceEase Co.
|
|
7
|
+
Author-email: "ForceEase Co." <support@forceease.tech>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/HANCKH/zeroArm_python_sdk
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Cython
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: pyzmq>=25.0
|
|
16
|
+
Requires-Dist: websockets
|
|
17
|
+
Requires-Dist: zeroconf
|
|
18
|
+
Requires-Dist: tqdm
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
|
|
24
|
+
# ZeroArm Python SDK
|
|
25
|
+
|
|
26
|
+
Official Python SDK for ForceEase ZeroArm robotic arms.
|
|
27
|
+
|
|
28
|
+
## Connection Notes
|
|
29
|
+
|
|
30
|
+
- Use `RobotArm().auto_discover_and_connect()` for the default LAN workflow.
|
|
31
|
+
- Use `connect_with_short_sn()` with at least the first 8 serial-number characters in multi-arm environments.
|
|
32
|
+
- mDNS discovery only accepts connectable IPv4 addresses and ignores loopback or unspecified addresses such as `127.0.0.1` and `0.0.0.0`.
|
|
33
|
+
- The controller advertises WebSocket control port `8888` and bridge port `8889`; SDK mode uses the fixed ZMQ command/state ports `6666/6667`.
|
|
34
|
+
|
|
35
|
+
## Current Protocol Coverage
|
|
36
|
+
|
|
37
|
+
- `set_tf(..., plan_mode=0)` uses joint-space planning; `plan_mode=1` uses Cartesian planning.
|
|
38
|
+
- `enable_hold_current()` asks the controller to hold all configured joints at their current feedback positions.
|
|
39
|
+
- `set_joints_batch()` sends a single `raw_msg` command through the normal `commands_<serial>` topic.
|
|
40
|
+
|
|
41
|
+
## Build Notes
|
|
42
|
+
|
|
43
|
+
- `pyproject.toml` declares Cython as a build-system dependency for isolated builds.
|
|
44
|
+
- For direct `setup.py` builds, install `requirements-build.txt` first.
|
|
45
|
+
- `setuptools` is capped below 80 to stay compatible with ROS `colcon-core` in the shared development environment.
|
|
46
|
+
|
|
47
|
+
## Release
|
|
48
|
+
|
|
49
|
+
- Publish Python SDK releases through `.github/workflows/build-wheels.yml`.
|
|
50
|
+
- A GitHub Release with action `published` builds the sdist plus Linux, Windows, and macOS wheels, then uploads all distributions to PyPI in one publish job.
|
|
51
|
+
- The workflow expects `PYPI_API_TOKEN` to be configured as a GitHub Actions secret.
|
|
52
|
+
|
|
53
|
+
## Version
|
|
54
|
+
|
|
55
|
+
Current SDK source version: `0.1.2`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ZeroArm Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for ForceEase ZeroArm robotic arms.
|
|
4
|
+
|
|
5
|
+
## Connection Notes
|
|
6
|
+
|
|
7
|
+
- Use `RobotArm().auto_discover_and_connect()` for the default LAN workflow.
|
|
8
|
+
- Use `connect_with_short_sn()` with at least the first 8 serial-number characters in multi-arm environments.
|
|
9
|
+
- mDNS discovery only accepts connectable IPv4 addresses and ignores loopback or unspecified addresses such as `127.0.0.1` and `0.0.0.0`.
|
|
10
|
+
- The controller advertises WebSocket control port `8888` and bridge port `8889`; SDK mode uses the fixed ZMQ command/state ports `6666/6667`.
|
|
11
|
+
|
|
12
|
+
## Current Protocol Coverage
|
|
13
|
+
|
|
14
|
+
- `set_tf(..., plan_mode=0)` uses joint-space planning; `plan_mode=1` uses Cartesian planning.
|
|
15
|
+
- `enable_hold_current()` asks the controller to hold all configured joints at their current feedback positions.
|
|
16
|
+
- `set_joints_batch()` sends a single `raw_msg` command through the normal `commands_<serial>` topic.
|
|
17
|
+
|
|
18
|
+
## Build Notes
|
|
19
|
+
|
|
20
|
+
- `pyproject.toml` declares Cython as a build-system dependency for isolated builds.
|
|
21
|
+
- For direct `setup.py` builds, install `requirements-build.txt` first.
|
|
22
|
+
- `setuptools` is capped below 80 to stay compatible with ROS `colcon-core` in the shared development environment.
|
|
23
|
+
|
|
24
|
+
## Release
|
|
25
|
+
|
|
26
|
+
- Publish Python SDK releases through `.github/workflows/build-wheels.yml`.
|
|
27
|
+
- A GitHub Release with action `published` builds the sdist plus Linux, Windows, and macOS wheels, then uploads all distributions to PyPI in one publish job.
|
|
28
|
+
- The workflow expects `PYPI_API_TOKEN` to be configured as a GitHub Actions secret.
|
|
29
|
+
|
|
30
|
+
## Version
|
|
31
|
+
|
|
32
|
+
Current SDK source version: `0.1.2`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0,<80", "wheel", "Cython>=3.0", "packaging>=24.2"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zeroArm_sdk"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Official SDK for ZeroArm robotic arm (proprietary)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "ForceEase Co.", email = "support@forceease.tech"},
|
|
13
|
+
]
|
|
14
|
+
dynamic = ["license"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Cython",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"pyzmq >= 25.0",
|
|
22
|
+
"websockets",
|
|
23
|
+
"zeroconf",
|
|
24
|
+
"tqdm",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Source = "https://github.com/HANCKH/zeroArm_python_sdk"
|
|
29
|
+
|
|
30
|
+
[tool.cibuildwheel]
|
|
31
|
+
build = "cp39-* cp310-* cp311-* cp312-*"
|
|
32
|
+
|
|
33
|
+
manylinux-x86_64-image = "quay.io/pypa/manylinux2014_x86_64:latest"
|
|
34
|
+
manylinux-aarch64-image = "quay.io/pypa/manylinux2014_aarch64:latest"
|
|
35
|
+
|
|
36
|
+
archs = ["x86_64", "aarch64"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# setup.py(完整版 - 推荐使用此版本)
|
|
2
|
+
|
|
3
|
+
from setuptools import setup, Extension, Command
|
|
4
|
+
from Cython.Build import cythonize
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
class CleanCython(Command):
|
|
9
|
+
"""自定义命令:清理 Cython 生成的 .c 和 .html 文件"""
|
|
10
|
+
user_options = []
|
|
11
|
+
|
|
12
|
+
def initialize_options(self):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def finalize_options(self):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
def run(self):
|
|
19
|
+
for root, dirs, files in os.walk("zeroArm_sdk"):
|
|
20
|
+
for file in files:
|
|
21
|
+
if file.endswith((".c", ".html", ".so", ".pyd")):
|
|
22
|
+
file_path = os.path.join(root, file)
|
|
23
|
+
print(f"Removing {file_path}")
|
|
24
|
+
os.remove(file_path)
|
|
25
|
+
|
|
26
|
+
setup(
|
|
27
|
+
name="zeroArm_sdk",
|
|
28
|
+
version="0.1.2",
|
|
29
|
+
author="ForceEase Co.",
|
|
30
|
+
author_email="support@forceease.tech",
|
|
31
|
+
url="https://github.com/HANCKH/zeroArm_python_sdk",
|
|
32
|
+
description="Python SDK for ZeroArm robotic arms",
|
|
33
|
+
license="MIT",
|
|
34
|
+
license_files=[],
|
|
35
|
+
long_description=open("README.md", encoding="utf-8").read() if os.path.exists("README.md") else "",
|
|
36
|
+
long_description_content_type="text/markdown",
|
|
37
|
+
packages=["zeroArm_sdk"],
|
|
38
|
+
package_dir={"zeroArm_sdk": "zeroArm_sdk"},
|
|
39
|
+
ext_modules=cythonize([
|
|
40
|
+
Extension(
|
|
41
|
+
"zeroArm_sdk._core",
|
|
42
|
+
sources=["zeroArm_sdk/_core.pyx"],
|
|
43
|
+
)
|
|
44
|
+
], compiler_directives={"language_level": "3"}),
|
|
45
|
+
package_data={
|
|
46
|
+
"zeroArm_sdk": [
|
|
47
|
+
"locale/*/LC_MESSAGES/*.mo",
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
include_package_data=False,
|
|
51
|
+
exclude_package_data={
|
|
52
|
+
"zeroArm_sdk": [
|
|
53
|
+
"*.pyx",
|
|
54
|
+
"*.c",
|
|
55
|
+
"*.html",
|
|
56
|
+
"**/*.pyx",
|
|
57
|
+
"**/*.c",
|
|
58
|
+
"**/*.html",
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
install_requires=[
|
|
62
|
+
"pyzmq>=25.0",
|
|
63
|
+
"websockets",
|
|
64
|
+
"zeroconf",
|
|
65
|
+
"tqdm",
|
|
66
|
+
],
|
|
67
|
+
python_requires=">=3.8",
|
|
68
|
+
classifiers=[
|
|
69
|
+
"Programming Language :: Python :: 3",
|
|
70
|
+
"Programming Language :: Cython",
|
|
71
|
+
"Operating System :: OS Independent",
|
|
72
|
+
],
|
|
73
|
+
cmdclass={
|
|
74
|
+
'clean_cython': CleanCython,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# zeroArm_sdk/__init__.py
|
|
2
|
+
# Copyright (c) 2026 ForceEase Co. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
from .arm import RobotArm
|
|
5
|
+
from .arm_types import JointState, GripperState, Pose, FullArmState
|
|
6
|
+
from .exceptions import ArmNotFoundError, ArmConnectionError, ArmCommandError
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.2"
|
|
9
|
+
__author__ = "ForceEase Co."
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
# zeroArm_sdk/_core.pyx
|
|
2
|
+
# Copyright (c) 2026 Han / ForceEase Co. All Rights Reserved.
|
|
3
|
+
# Core implementation module - to be compiled into .so
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import ipaddress
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
from typing import Dict, List, Optional, Callable
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
import os
|
|
13
|
+
import gettext
|
|
14
|
+
import zmq
|
|
15
|
+
import zmq.asyncio
|
|
16
|
+
import websockets
|
|
17
|
+
from zeroconf import Zeroconf, ServiceBrowser, ServiceListener
|
|
18
|
+
from .arm_types import JointState, GripperState, Pose, FullArmState
|
|
19
|
+
from .exceptions import ArmNotFoundError, ArmConnectionError, ArmCommandError
|
|
20
|
+
|
|
21
|
+
cdef str localedir = os.path.join(os.path.dirname(__file__), "locale")
|
|
22
|
+
cdef object translation = gettext.translation(
|
|
23
|
+
"zeroArm_sdk",
|
|
24
|
+
localedir,
|
|
25
|
+
fallback=True
|
|
26
|
+
)
|
|
27
|
+
cdef str _(str message):
|
|
28
|
+
return translation.gettext(message)
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Discovery Logic
|
|
32
|
+
# =============================================================================
|
|
33
|
+
def _decode_property(value):
|
|
34
|
+
if isinstance(value, bytes):
|
|
35
|
+
return value.decode(errors="ignore")
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_connectable_ip(raw_ip) -> bool:
|
|
40
|
+
ip_text = str(raw_ip or "").strip()
|
|
41
|
+
if not ip_text:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
parsed_ip = ipaddress.ip_address(ip_text)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return False
|
|
47
|
+
return (
|
|
48
|
+
parsed_ip.version == 4
|
|
49
|
+
and not parsed_ip.is_loopback
|
|
50
|
+
and not parsed_ip.is_unspecified
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _append_connectable_ip(ips: List[str], raw_ip):
|
|
55
|
+
ip_text = str(raw_ip or "").strip()
|
|
56
|
+
if _is_connectable_ip(ip_text) and ip_text not in ips:
|
|
57
|
+
ips.append(ip_text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_connectable_ips(info, props: Dict) -> List[str]:
|
|
61
|
+
ips: List[str] = []
|
|
62
|
+
for advertised_ip in str(props.get("ip", "")).split(","):
|
|
63
|
+
_append_connectable_ip(ips, advertised_ip)
|
|
64
|
+
|
|
65
|
+
for addr in getattr(info, "addresses", []) or []:
|
|
66
|
+
try:
|
|
67
|
+
if len(addr) == 4:
|
|
68
|
+
_append_connectable_ip(ips, socket.inet_ntoa(addr))
|
|
69
|
+
elif hasattr(socket, "inet_ntop"):
|
|
70
|
+
_append_connectable_ip(ips, socket.inet_ntop(socket.AF_INET6, addr))
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
return ips
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _int_property(props: Dict, key: str, default: int) -> int:
|
|
77
|
+
try:
|
|
78
|
+
return int(props.get(key, default))
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return default
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ArmListener(ServiceListener):
|
|
84
|
+
def __init__(self):
|
|
85
|
+
self.arms = []
|
|
86
|
+
|
|
87
|
+
def add_service(self, zeroconf, type_str, name):
|
|
88
|
+
info = zeroconf.get_service_info(type_str, name)
|
|
89
|
+
if not info:
|
|
90
|
+
return
|
|
91
|
+
props = {_decode_property(k): _decode_property(v)
|
|
92
|
+
for k, v in info.properties.items()}
|
|
93
|
+
ips = _extract_connectable_ips(info, props)
|
|
94
|
+
try:
|
|
95
|
+
sns = json.loads(props.get("available_sns", "[]"))
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
sns = []
|
|
98
|
+
if sns and ips:
|
|
99
|
+
self.arms = [arm for arm in self.arms if arm.get("name") != name]
|
|
100
|
+
self.arms.append({
|
|
101
|
+
"name": name,
|
|
102
|
+
"ips": ips,
|
|
103
|
+
"sns": sns,
|
|
104
|
+
"control_port": _int_property(props, "control_ws_port", 8888),
|
|
105
|
+
"zmq_cmd_port": _int_property(props, "zmq_cmd_port", 6666),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
def remove_service(self, zeroconf, type_str, name):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def update_service(self, zeroconf, type_str, name):
|
|
112
|
+
self.add_service(zeroconf, type_str, name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def discover_arms(timeout: float = 1.0) -> List[Dict]:
|
|
116
|
+
"""Discover all available ZeroArm devices via mDNS."""
|
|
117
|
+
zeroconf = Zeroconf()
|
|
118
|
+
listener = ArmListener()
|
|
119
|
+
ServiceBrowser(zeroconf, "_armcontrol._tcp.local.", listener)
|
|
120
|
+
await asyncio.sleep(timeout)
|
|
121
|
+
zeroconf.close()
|
|
122
|
+
return listener.arms
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Communicator Class
|
|
127
|
+
# =============================================================================
|
|
128
|
+
cdef class ArmCommunicator:
|
|
129
|
+
cdef str ip
|
|
130
|
+
cdef str sn
|
|
131
|
+
cdef int control_port
|
|
132
|
+
cdef int zmq_cmd_port
|
|
133
|
+
cdef public object last_state
|
|
134
|
+
cdef public object state_callback
|
|
135
|
+
cdef object ctx
|
|
136
|
+
cdef object cmd_pub
|
|
137
|
+
cdef object state_sub
|
|
138
|
+
cdef public bint running
|
|
139
|
+
cdef object _recv_task
|
|
140
|
+
|
|
141
|
+
def __init__(self, str ip, str sn, int control_port=8888, int zmq_cmd_port=6666):
|
|
142
|
+
self.ip = ip
|
|
143
|
+
self.sn = sn
|
|
144
|
+
self.control_port = control_port
|
|
145
|
+
self.zmq_cmd_port = zmq_cmd_port
|
|
146
|
+
self.last_state = None
|
|
147
|
+
self.state_callback = None
|
|
148
|
+
self.ctx = zmq.asyncio.Context()
|
|
149
|
+
self.cmd_pub = self.ctx.socket(zmq.PUB)
|
|
150
|
+
self.state_sub = self.ctx.socket(zmq.SUB)
|
|
151
|
+
self.running = True
|
|
152
|
+
self._recv_task = None
|
|
153
|
+
|
|
154
|
+
async def select_sn(self) -> bool:
|
|
155
|
+
uri = f"ws://{self.ip}:{self.control_port}"
|
|
156
|
+
try:
|
|
157
|
+
async with websockets.connect(uri) as ws:
|
|
158
|
+
msg = json.dumps({"type": "select_sn", "sn": self.sn, "sdk": True})
|
|
159
|
+
await ws.send(msg)
|
|
160
|
+
response = await asyncio.wait_for(ws.recv(), timeout=20.0)
|
|
161
|
+
data = json.loads(response)
|
|
162
|
+
return data.get("type") == "select_ack"
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
async def shutdown_control(self) -> bool:
|
|
167
|
+
uri = f"ws://{self.ip}:{self.control_port}"
|
|
168
|
+
try:
|
|
169
|
+
async with websockets.connect(uri) as ws:
|
|
170
|
+
msg = json.dumps({"type": "shutdown_control", "sn": self.sn})
|
|
171
|
+
await ws.send(msg)
|
|
172
|
+
response = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
173
|
+
data = json.loads(response)
|
|
174
|
+
return data.get("type") == "shutdown_ack"
|
|
175
|
+
except Exception:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
async def connect_zmq(self) -> bool:
|
|
179
|
+
try:
|
|
180
|
+
self.cmd_pub.connect(f"tcp://{self.ip}:{self.zmq_cmd_port}")
|
|
181
|
+
self.state_sub.connect(f"tcp://{self.ip}:{self.zmq_cmd_port + 1}")
|
|
182
|
+
self.state_sub.setsockopt(zmq.SUBSCRIBE, f"states_{self.sn}".encode())
|
|
183
|
+
self._recv_task = asyncio.create_task(self._recv_states_loop())
|
|
184
|
+
return True
|
|
185
|
+
except Exception:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
async def _recv_states_loop(self):
|
|
189
|
+
poller = zmq.asyncio.Poller()
|
|
190
|
+
poller.register(self.state_sub, zmq.POLLIN)
|
|
191
|
+
while self.running:
|
|
192
|
+
events = await poller.poll(timeout=500)
|
|
193
|
+
if not events:
|
|
194
|
+
continue
|
|
195
|
+
topic, payload = await self.state_sub.recv_multipart()
|
|
196
|
+
if topic.decode() == f"states_{self.sn}":
|
|
197
|
+
try:
|
|
198
|
+
raw_state = json.loads(payload)
|
|
199
|
+
parsed_state = self._parse_state(raw_state)
|
|
200
|
+
self.last_state = parsed_state
|
|
201
|
+
if self.state_callback:
|
|
202
|
+
self.state_callback(parsed_state)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
def _parse_state(self, raw: Dict) -> FullArmState:
|
|
207
|
+
joints = []
|
|
208
|
+
for j in raw.get("joints", []):
|
|
209
|
+
joints.append(
|
|
210
|
+
JointState(
|
|
211
|
+
can_id=j.get("can_id", 0),
|
|
212
|
+
position=j.get("position", 0.0),
|
|
213
|
+
velocity=j.get("velocity", 0.0),
|
|
214
|
+
torque=j.get("torque", 0.0),
|
|
215
|
+
enable=j.get("enable", False),
|
|
216
|
+
mode=j.get("mode", 0),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
gripper = None
|
|
220
|
+
if "gripper" in raw:
|
|
221
|
+
g = raw["gripper"]
|
|
222
|
+
gripper = GripperState(
|
|
223
|
+
position=g.get("position", 0.0),
|
|
224
|
+
velocity=g.get("velocity", 0.0),
|
|
225
|
+
torque=g.get("torque", 0.0),
|
|
226
|
+
enable=g.get("enable", False),
|
|
227
|
+
mode=g.get("mode", 0),
|
|
228
|
+
)
|
|
229
|
+
eef_pose = None
|
|
230
|
+
if "eef_pose" in raw:
|
|
231
|
+
p = raw["eef_pose"]
|
|
232
|
+
eef_pose = Pose(
|
|
233
|
+
x=p.get("x", 0.0),
|
|
234
|
+
y=p.get("y", 0.0),
|
|
235
|
+
z=p.get("z", 0.0),
|
|
236
|
+
qw=p.get("qw", 1.0),
|
|
237
|
+
qx=p.get("qx", 0.0),
|
|
238
|
+
qy=p.get("qy", 0.0),
|
|
239
|
+
qz=p.get("qz", 0.0),
|
|
240
|
+
)
|
|
241
|
+
return FullArmState(joints=joints, gripper=gripper, eef_pose=eef_pose)
|
|
242
|
+
|
|
243
|
+
async def send_command(self, cmd: Dict) -> bool:
|
|
244
|
+
try:
|
|
245
|
+
payload = json.dumps(cmd).encode()
|
|
246
|
+
await self.cmd_pub.send_multipart([f"commands_{self.sn}".encode(), payload])
|
|
247
|
+
return True
|
|
248
|
+
except Exception:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
async def send_raw(self, bytes topic, bytes payload) -> bool:
|
|
252
|
+
try:
|
|
253
|
+
await self.cmd_pub.send_multipart([topic, payload])
|
|
254
|
+
return True
|
|
255
|
+
except Exception:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
def set_state_callback(self, callback: Callable[[FullArmState], None]):
|
|
259
|
+
self.state_callback = callback
|
|
260
|
+
|
|
261
|
+
def is_connected(self) -> bool:
|
|
262
|
+
return self.cmd_pub is not None and not self.cmd_pub.closed
|
|
263
|
+
|
|
264
|
+
async def close(self):
|
|
265
|
+
self.running = False
|
|
266
|
+
if self._recv_task is not None:
|
|
267
|
+
try:
|
|
268
|
+
await asyncio.wait_for(self._recv_task, timeout=1.0)
|
|
269
|
+
except asyncio.TimeoutError:
|
|
270
|
+
self._recv_task.cancel()
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
try:
|
|
274
|
+
self.cmd_pub.close()
|
|
275
|
+
self.state_sub.close()
|
|
276
|
+
self.ctx.term()
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# =============================================================================
|
|
282
|
+
# RobotArm Class - Main User Interface (compiled)
|
|
283
|
+
# =============================================================================
|
|
284
|
+
cdef class RobotArm:
|
|
285
|
+
cdef public str sn
|
|
286
|
+
cdef public str ip
|
|
287
|
+
cdef public int control_port
|
|
288
|
+
cdef public int zmq_cmd_port
|
|
289
|
+
cdef public object communicator
|
|
290
|
+
cdef public object _state_callback
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
sn: Optional[str] = None,
|
|
295
|
+
ip: Optional[str] = None,
|
|
296
|
+
int control_port = 8888,
|
|
297
|
+
int zmq_cmd_port = 6666,
|
|
298
|
+
):
|
|
299
|
+
self.sn = sn
|
|
300
|
+
self.ip = ip
|
|
301
|
+
self.control_port = control_port
|
|
302
|
+
self.zmq_cmd_port = zmq_cmd_port
|
|
303
|
+
self.communicator = None
|
|
304
|
+
self._state_callback = None
|
|
305
|
+
|
|
306
|
+
async def find_all_devices(self, timeout: float = 1.0) -> List[Dict]:
|
|
307
|
+
return await discover_arms(timeout)
|
|
308
|
+
|
|
309
|
+
async def auto_discover_and_connect(self, timeout: float = 1.0) -> bool:
|
|
310
|
+
total_steps = 5
|
|
311
|
+
progress = tqdm(total=total_steps, desc=_("Connecting to ZeroArm"), unit="step")
|
|
312
|
+
try:
|
|
313
|
+
progress.set_description(_("Step 1/5: Finding devices"))
|
|
314
|
+
arms = await discover_arms(timeout)
|
|
315
|
+
progress.update(1)
|
|
316
|
+
if not arms:
|
|
317
|
+
progress.close()
|
|
318
|
+
print(_("No ZeroArm found on the network"))
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
progress.set_description(_("Step 2/5: Choosing first arm"))
|
|
322
|
+
arm_info = arms[0]
|
|
323
|
+
self.sn = arm_info["sns"][0]
|
|
324
|
+
self.ip = arm_info["ips"][0]
|
|
325
|
+
self.control_port = int(arm_info.get("control_port", 8888))
|
|
326
|
+
self.zmq_cmd_port = int(arm_info.get("zmq_cmd_port", 6666))
|
|
327
|
+
short_sn = self.sn[:6] + "..." if len(self.sn) > 6 else self.sn
|
|
328
|
+
progress.update(1)
|
|
329
|
+
|
|
330
|
+
progress.set_description(_("Step 3/5: Selecting arm"))
|
|
331
|
+
self.communicator = ArmCommunicator(
|
|
332
|
+
self.ip,
|
|
333
|
+
self.sn,
|
|
334
|
+
self.control_port,
|
|
335
|
+
self.zmq_cmd_port,
|
|
336
|
+
)
|
|
337
|
+
success = await self.communicator.select_sn()
|
|
338
|
+
progress.update(1)
|
|
339
|
+
if not success:
|
|
340
|
+
progress.close()
|
|
341
|
+
print(_("Failed to select arm"))
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
progress.set_description(_("Step 4/5: Waiting for arm ready (1s)"))
|
|
345
|
+
await asyncio.sleep(1.0)
|
|
346
|
+
progress.update(1)
|
|
347
|
+
|
|
348
|
+
progress.set_description(_("Step 5/5: Final connection"))
|
|
349
|
+
await self.communicator.connect_zmq()
|
|
350
|
+
progress.update(1)
|
|
351
|
+
|
|
352
|
+
if self._state_callback:
|
|
353
|
+
self.communicator.set_state_callback(self._state_callback)
|
|
354
|
+
|
|
355
|
+
progress.set_description(_("Connected!"))
|
|
356
|
+
progress.close()
|
|
357
|
+
print(_("Connected successfully: Arm {}").format(short_sn))
|
|
358
|
+
return True
|
|
359
|
+
except Exception:
|
|
360
|
+
progress.close()
|
|
361
|
+
print(_("Connection failed"))
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
async def connect_with_short_sn(self, short_sn: str, timeout: float = 1.0) -> bool:
|
|
365
|
+
arms = await discover_arms(timeout)
|
|
366
|
+
if not arms:
|
|
367
|
+
print(_("No ZeroArm found on the network"))
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
for arm_info in arms:
|
|
371
|
+
for full_sn in arm_info["sns"]:
|
|
372
|
+
if full_sn.startswith(short_sn):
|
|
373
|
+
self.sn = full_sn
|
|
374
|
+
self.ip = arm_info["ips"][0]
|
|
375
|
+
self.control_port = int(arm_info.get("control_port", 8888))
|
|
376
|
+
self.zmq_cmd_port = int(arm_info.get("zmq_cmd_port", 6666))
|
|
377
|
+
short_display = short_sn + "..." if len(full_sn) > 6 else short_sn
|
|
378
|
+
print(_("Found arm: {}").format(short_display))
|
|
379
|
+
return await self.connect()
|
|
380
|
+
|
|
381
|
+
print(_("No arm found starting with '{}'").format(short_sn))
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
async def connect(self) -> bool:
|
|
385
|
+
if not self.sn or not self.ip:
|
|
386
|
+
print(_("Serial number and IP must be set first"))
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
self.communicator = ArmCommunicator(
|
|
390
|
+
self.ip,
|
|
391
|
+
self.sn,
|
|
392
|
+
self.control_port,
|
|
393
|
+
self.zmq_cmd_port,
|
|
394
|
+
)
|
|
395
|
+
success = await self.communicator.select_sn()
|
|
396
|
+
if not success:
|
|
397
|
+
print(_("Failed to select arm"))
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
await asyncio.sleep(1.0)
|
|
401
|
+
await self.communicator.connect_zmq()
|
|
402
|
+
|
|
403
|
+
if self._state_callback:
|
|
404
|
+
self.communicator.set_state_callback(self._state_callback)
|
|
405
|
+
|
|
406
|
+
short_sn = self.sn[:6] + "..." if len(self.sn) > 6 else self.sn
|
|
407
|
+
print(_("Connected to arm: {}").format(short_sn))
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
def is_connected(self) -> bool:
|
|
411
|
+
return self.communicator is not None and self.communicator.is_connected()
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def current_state(self) -> Optional[FullArmState]:
|
|
415
|
+
if self.communicator:
|
|
416
|
+
return self.communicator.last_state
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def on_state_update(self, callback: Callable[[FullArmState], None]):
|
|
420
|
+
self._state_callback = callback
|
|
421
|
+
if self.communicator:
|
|
422
|
+
self.communicator.set_state_callback(callback)
|
|
423
|
+
|
|
424
|
+
async def send_command(self, cmd: Dict):
|
|
425
|
+
if not self.communicator:
|
|
426
|
+
raise ArmConnectionError("Not connected yet")
|
|
427
|
+
await self.communicator.send_command(cmd)
|
|
428
|
+
|
|
429
|
+
async def send_raw_msg(self, raw_payload: str):
|
|
430
|
+
if not self.communicator:
|
|
431
|
+
raise ArmConnectionError("Not connected yet")
|
|
432
|
+
await self.send_command({"type": "raw_msg", "payload": raw_payload})
|
|
433
|
+
|
|
434
|
+
async def set_joint(self, int can_id, float target_pos,
|
|
435
|
+
float target_vel = 0.0, float target_tau = 0.0,
|
|
436
|
+
int mode = 0, bint enable = True, bint set_zero = False,
|
|
437
|
+
max_vel: Optional[float] = None, max_acc: Optional[float] = None,
|
|
438
|
+
max_jerk: Optional[float] = None):
|
|
439
|
+
cmd = {
|
|
440
|
+
"type": "set_joint",
|
|
441
|
+
"can_id": can_id,
|
|
442
|
+
"target_pos": target_pos,
|
|
443
|
+
"target_vel": target_vel,
|
|
444
|
+
"target_tau": target_tau,
|
|
445
|
+
"mode": mode,
|
|
446
|
+
"enable": enable,
|
|
447
|
+
"set_zero": set_zero,
|
|
448
|
+
}
|
|
449
|
+
if max_vel is not None: cmd["max_vel"] = max_vel
|
|
450
|
+
if max_acc is not None: cmd["max_acc"] = max_acc
|
|
451
|
+
if max_jerk is not None: cmd["max_jerk"] = max_jerk
|
|
452
|
+
await self.send_command(cmd)
|
|
453
|
+
|
|
454
|
+
async def set_gripper(self, float target_pos,
|
|
455
|
+
float target_vel = 0.0, float target_tau = 0.0,
|
|
456
|
+
bint set_zero = False):
|
|
457
|
+
cmd = {
|
|
458
|
+
"type": "set_gripper",
|
|
459
|
+
"target_pos": target_pos,
|
|
460
|
+
"target_vel": target_vel,
|
|
461
|
+
"target_tau": target_tau,
|
|
462
|
+
"set_zero": set_zero,
|
|
463
|
+
}
|
|
464
|
+
await self.send_command(cmd)
|
|
465
|
+
|
|
466
|
+
async def set_tf(self, float x, float y, float z,
|
|
467
|
+
float qw, float qx, float qy, float qz,
|
|
468
|
+
int plan_mode = 0):
|
|
469
|
+
cmd = {
|
|
470
|
+
"type": "set_tf",
|
|
471
|
+
"x": x, "y": y, "z": z,
|
|
472
|
+
"qw": qw, "qx": qx, "qy": qy, "qz": qz,
|
|
473
|
+
"plan_mode": plan_mode,
|
|
474
|
+
}
|
|
475
|
+
await self.send_command(cmd)
|
|
476
|
+
|
|
477
|
+
async def enable_hold_current(self, max_vel: Optional[float] = None,
|
|
478
|
+
max_acc: Optional[float] = None,
|
|
479
|
+
max_jerk: Optional[float] = None):
|
|
480
|
+
cmd = {"type": "enable_hold_current"}
|
|
481
|
+
if max_vel is not None: cmd["max_vel"] = max_vel
|
|
482
|
+
if max_acc is not None: cmd["max_acc"] = max_acc
|
|
483
|
+
if max_jerk is not None: cmd["max_jerk"] = max_jerk
|
|
484
|
+
await self.send_command(cmd)
|
|
485
|
+
|
|
486
|
+
async def set_joints_batch(
|
|
487
|
+
self,
|
|
488
|
+
joint_targets: List[tuple[int, float]],
|
|
489
|
+
target_vel: float = 0.0,
|
|
490
|
+
target_tau: float = 0.0,
|
|
491
|
+
mode: int = 0,
|
|
492
|
+
enable: bool = True,
|
|
493
|
+
set_zero: bool = False,
|
|
494
|
+
max_vel: int = 10,
|
|
495
|
+
max_acc: int = 30,
|
|
496
|
+
max_jerk: int = 50,
|
|
497
|
+
wait_for_completion: bool = False,
|
|
498
|
+
timeout: float = 8.0,
|
|
499
|
+
tolerance: float = 0.015
|
|
500
|
+
) -> bool:
|
|
501
|
+
if not self.communicator:
|
|
502
|
+
raise ArmConnectionError("Not connected yet")
|
|
503
|
+
if not joint_targets:
|
|
504
|
+
raise ValueError("joint_targets list cannot be empty")
|
|
505
|
+
|
|
506
|
+
parts = [f"{time.time():.6f}"]
|
|
507
|
+
for can_id, target_pos in joint_targets:
|
|
508
|
+
if not isinstance(can_id, int) or not isinstance(target_pos, (int, float)):
|
|
509
|
+
raise ValueError(f"Invalid joint target: ({can_id}, {target_pos})")
|
|
510
|
+
part = f"{can_id}:{target_pos:.6f}:{target_vel:.6f}:{target_tau:.6f}:{mode}:{int(enable)}:{int(set_zero)}"
|
|
511
|
+
if max_vel is not None:
|
|
512
|
+
part += f":{max_vel:.6f}"
|
|
513
|
+
if max_acc is not None:
|
|
514
|
+
part += f":{max_acc:.6f}"
|
|
515
|
+
if max_jerk is not None:
|
|
516
|
+
part += f":{max_jerk:.6f}"
|
|
517
|
+
parts.append(part)
|
|
518
|
+
|
|
519
|
+
raw_payload = ";".join(parts)
|
|
520
|
+
await self.send_raw_msg(raw_payload)
|
|
521
|
+
|
|
522
|
+
if not wait_for_completion:
|
|
523
|
+
return True
|
|
524
|
+
|
|
525
|
+
start_time = time.time()
|
|
526
|
+
while True:
|
|
527
|
+
state = self.current_state
|
|
528
|
+
if not state:
|
|
529
|
+
await asyncio.sleep(0.02)
|
|
530
|
+
continue
|
|
531
|
+
all_reached = True
|
|
532
|
+
for can_id, target_pos in joint_targets:
|
|
533
|
+
js = next((j for j in state.joints if j.can_id == can_id), None)
|
|
534
|
+
if not js or abs(js.position - target_pos) > tolerance:
|
|
535
|
+
all_reached = False
|
|
536
|
+
break
|
|
537
|
+
if all_reached:
|
|
538
|
+
print(_("All joints reached targets successfully!"))
|
|
539
|
+
return True
|
|
540
|
+
if time.time() - start_time > timeout:
|
|
541
|
+
print(_("Timeout waiting for joints to reach targets (tolerance: {} rad)").format(tolerance))
|
|
542
|
+
return False
|
|
543
|
+
await asyncio.sleep(0.02)
|
|
544
|
+
|
|
545
|
+
async def zero_gravity_mode(self):
|
|
546
|
+
await self.send_command({"type": "zero_gravity_mode"})
|
|
547
|
+
|
|
548
|
+
async def back_to_zero(self):
|
|
549
|
+
await self.send_command({"type": "back_to_zero"})
|
|
550
|
+
|
|
551
|
+
async def back_to_initial(self):
|
|
552
|
+
await self.send_command({"type": "back_to_initial"})
|
|
553
|
+
|
|
554
|
+
async def gracefully_shutdown(self, wait_before_shutdown: float = 10.0):
|
|
555
|
+
await self.back_to_initial()
|
|
556
|
+
await asyncio.sleep(wait_before_shutdown)
|
|
557
|
+
if self.communicator:
|
|
558
|
+
await self.communicator.shutdown_control()
|
|
559
|
+
print(_("Graceful shutdown completed."))
|
|
560
|
+
|
|
561
|
+
async def emergency_shutdown(self):
|
|
562
|
+
if self.communicator:
|
|
563
|
+
await self.communicator.shutdown_control()
|
|
564
|
+
print(_("Emergency shutdown executed."))
|
|
565
|
+
|
|
566
|
+
async def close(self):
|
|
567
|
+
if self.communicator:
|
|
568
|
+
await self.communicator.close()
|
|
569
|
+
print(_("Connection closed."))
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# zeroArm_sdk/arm.py
|
|
2
|
+
# Copyright (c) 2026 Han / ForceEase Co. All Rights Reserved.
|
|
3
|
+
# Public interface for ZeroArm SDK - only for licensed hardware owners
|
|
4
|
+
|
|
5
|
+
import gettext
|
|
6
|
+
import os
|
|
7
|
+
from typing import Dict, List, Optional, Callable, Tuple
|
|
8
|
+
from ._core import RobotArm as _RobotArmImpl
|
|
9
|
+
from .arm_types import FullArmState, JointState, GripperState, Pose
|
|
10
|
+
from .exceptions import ArmNotFoundError, ArmConnectionError, ArmCommandError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RobotArm(_RobotArmImpl):
|
|
14
|
+
"""
|
|
15
|
+
Main class for controlling ZeroArm robotic arm.
|
|
16
|
+
|
|
17
|
+
机械臂控制主类。
|
|
18
|
+
|
|
19
|
+
Features / 主要功能:
|
|
20
|
+
- Automatically find and connect to the arm / 自动发现并连接机械臂
|
|
21
|
+
- Connect using short serial number prefix / 支持使用序列号前缀快速连接
|
|
22
|
+
- Control all joints, gripper, and end-effector pose / 控制所有关节、夹爪、末端位姿
|
|
23
|
+
- Switch control modes (zero gravity, etc.) / 切换控制模式(零重力等)
|
|
24
|
+
- Graceful and emergency shutdown / 优雅关机与紧急停止
|
|
25
|
+
- Real-time state updates / 实时状态更新
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
sn: Optional[str] = None,
|
|
31
|
+
ip: Optional[str] = None,
|
|
32
|
+
control_port: int = 8888,
|
|
33
|
+
zmq_cmd_port: int = 6666,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Create a new RobotArm instance.
|
|
37
|
+
|
|
38
|
+
创建 RobotArm 实例。
|
|
39
|
+
|
|
40
|
+
Args / 参数:
|
|
41
|
+
sn: Full serial number of the arm (optional) / 机械臂完整序列号(可选)
|
|
42
|
+
ip: IP address of the arm (optional) / 机械臂 IP 地址(可选)
|
|
43
|
+
control_port: WebSocket control port, default 8888 / 控制端口,默认 8888
|
|
44
|
+
zmq_cmd_port: ZMQ command port, default 6666 / 指令端口,默认 6666
|
|
45
|
+
|
|
46
|
+
Note / 注意:
|
|
47
|
+
- If both sn and ip are None, use auto_discover_and_connect() to find the arm automatically.
|
|
48
|
+
如果 sn 和 ip 均未提供,可通过 auto_discover_and_connect() 自动发现机械臂。
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(sn, ip, control_port, zmq_cmd_port)
|
|
51
|
+
|
|
52
|
+
async def find_all_devices(self, timeout: float = 1.0) -> List[Dict]:
|
|
53
|
+
"""
|
|
54
|
+
Find all available ZeroArm devices on the network.
|
|
55
|
+
|
|
56
|
+
在局域网内发现所有可用的 ZeroArm 设备。
|
|
57
|
+
|
|
58
|
+
Args / 参数:
|
|
59
|
+
timeout: Maximum time to wait for discovery (seconds, default 1.0) / 发现最大等待时间(秒,默认 1.0)
|
|
60
|
+
|
|
61
|
+
Returns / 返回:
|
|
62
|
+
List[Dict]: List of device information dictionaries / 设备信息字典列表
|
|
63
|
+
|
|
64
|
+
Example / 示例:
|
|
65
|
+
devices = await arm.find_all_devices(timeout=2.0)
|
|
66
|
+
print(devices)
|
|
67
|
+
"""
|
|
68
|
+
return await super().find_all_devices(timeout)
|
|
69
|
+
|
|
70
|
+
async def auto_discover_and_connect(self, timeout: float = 1.0) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Automatically find the first available arm and connect to it.
|
|
73
|
+
Shows a progress bar during connection.
|
|
74
|
+
|
|
75
|
+
自动发现局域网内第一个可用机械臂并连接。
|
|
76
|
+
连接过程中显示进度条。
|
|
77
|
+
|
|
78
|
+
Args / 参数:
|
|
79
|
+
timeout: Maximum wait time for discovery (seconds, default 1.0) / 发现最大等待时间(秒,默认 1.0)
|
|
80
|
+
|
|
81
|
+
Returns / 返回:
|
|
82
|
+
bool: True if connected successfully, False otherwise / 连接成功返回 True,否则 False
|
|
83
|
+
|
|
84
|
+
Example / 示例:
|
|
85
|
+
success = await arm.auto_discover_and_connect(timeout=3.0)
|
|
86
|
+
if success:
|
|
87
|
+
print(_("Arm is ready!")) # 支持本地化:已连接机械臂!
|
|
88
|
+
"""
|
|
89
|
+
return await super().auto_discover_and_connect(timeout)
|
|
90
|
+
|
|
91
|
+
async def connect_with_short_sn(self, short_sn: str, timeout: float = 1.0) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Connect to an arm using a serial number prefix.
|
|
94
|
+
|
|
95
|
+
使用序列号前缀连接机械臂,建议使用前 8 位以避免多臂环境中误匹配。
|
|
96
|
+
|
|
97
|
+
Args / 参数:
|
|
98
|
+
short_sn: Serial number prefix / 序列号前缀(建议至少 8 位)
|
|
99
|
+
timeout: Maximum wait time for discovery (seconds, default 1.0) / 发现最大等待时间(秒,默认 1.0)
|
|
100
|
+
|
|
101
|
+
Returns / 返回:
|
|
102
|
+
bool: True if connected successfully, False otherwise / 连接成功返回 True,否则 False
|
|
103
|
+
|
|
104
|
+
Example / 示例:
|
|
105
|
+
await arm.connect_with_short_sn("F82FCDC0")
|
|
106
|
+
"""
|
|
107
|
+
return await super().connect_with_short_sn(short_sn, timeout)
|
|
108
|
+
|
|
109
|
+
async def connect(self) -> bool:
|
|
110
|
+
"""
|
|
111
|
+
Connect to the arm using current serial number and IP.
|
|
112
|
+
|
|
113
|
+
使用当前已设置的序列号和 IP 进行连接。
|
|
114
|
+
|
|
115
|
+
Returns / 返回:
|
|
116
|
+
bool: True if successful, False otherwise / 连接成功返回 True,否则 False
|
|
117
|
+
|
|
118
|
+
Note / 注意:
|
|
119
|
+
- Must set sn and ip first (via __init__ or manually).
|
|
120
|
+
必须先设置 sn 和 ip。
|
|
121
|
+
"""
|
|
122
|
+
return await super().connect()
|
|
123
|
+
|
|
124
|
+
def is_connected(self) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Check if the arm is currently connected.
|
|
127
|
+
|
|
128
|
+
检查当前是否已连接到机械臂。
|
|
129
|
+
|
|
130
|
+
Returns / 返回:
|
|
131
|
+
bool: True if connected, False otherwise / 已连接返回 True,否则 False
|
|
132
|
+
"""
|
|
133
|
+
return super().is_connected()
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def current_state(self) -> Optional[FullArmState]:
|
|
137
|
+
"""
|
|
138
|
+
Get the latest received arm state.
|
|
139
|
+
|
|
140
|
+
获取机械臂的最新状态(实时更新)。
|
|
141
|
+
|
|
142
|
+
Returns / 返回:
|
|
143
|
+
FullArmState or None: Latest arm state / 最新机械臂状态;若无数据则返回 None
|
|
144
|
+
|
|
145
|
+
Example / 示例:
|
|
146
|
+
state = arm.current_state
|
|
147
|
+
if state:
|
|
148
|
+
print(f"Joint 1: {state.joints[0].position:.3f} rad")
|
|
149
|
+
"""
|
|
150
|
+
return super().current_state
|
|
151
|
+
|
|
152
|
+
def on_state_update(self, callback: Callable[[FullArmState], None]):
|
|
153
|
+
"""
|
|
154
|
+
Set a callback function to be called on every new state update.
|
|
155
|
+
|
|
156
|
+
设置状态更新回调函数,每次接收到新状态时调用。
|
|
157
|
+
|
|
158
|
+
Args / 参数:
|
|
159
|
+
callback: Function that receives FullArmState / 接收 FullArmState 的回调函数
|
|
160
|
+
|
|
161
|
+
Example / 示例:
|
|
162
|
+
def handler(state):
|
|
163
|
+
print(f"End-effector pose: {state.eef_pose}")
|
|
164
|
+
arm.on_state_update(handler)
|
|
165
|
+
"""
|
|
166
|
+
super().on_state_update(callback)
|
|
167
|
+
|
|
168
|
+
async def set_joint(
|
|
169
|
+
self,
|
|
170
|
+
can_id: int,
|
|
171
|
+
target_pos: float,
|
|
172
|
+
target_vel: float = 0.0,
|
|
173
|
+
target_tau: float = 0.0,
|
|
174
|
+
mode: int = 0,
|
|
175
|
+
enable: bool = True,
|
|
176
|
+
set_zero: bool = False,
|
|
177
|
+
max_vel: Optional[float] = None,
|
|
178
|
+
max_acc: Optional[float] = None,
|
|
179
|
+
max_jerk: Optional[float] = None,
|
|
180
|
+
):
|
|
181
|
+
"""
|
|
182
|
+
Move a single joint to target position.
|
|
183
|
+
|
|
184
|
+
控制单个关节运动到目标位置。
|
|
185
|
+
|
|
186
|
+
Args / 参数:
|
|
187
|
+
can_id: Joint CAN ID (usually starts from 1) / 关节 CAN ID(通常从 1 开始)
|
|
188
|
+
target_pos: Target position in radians / 目标位置(弧度)
|
|
189
|
+
target_vel: Target velocity (rad/s), default 0.0 / 目标速度(弧度/秒,默认 0.0)
|
|
190
|
+
target_tau: Target torque (Nm), default 0.0 / 目标力矩(Nm,默认 0.0)
|
|
191
|
+
mode: Control mode (0 = position mode, see arm manual) / 控制模式(0=位置模式,参考臂说明书)
|
|
192
|
+
enable: Enable the joint, default True / 是否使能关节,默认 True
|
|
193
|
+
set_zero: Set current position as zero, default False / 是否将当前位置设为零位,默认 False
|
|
194
|
+
max_vel/max_acc/max_jerk: Optional speed/acceleration/jerk limits / 可选的最大速度/加速度/加加速度限制
|
|
195
|
+
|
|
196
|
+
Example / 示例:
|
|
197
|
+
await arm.set_joint(1, 0.785) # Move joint 1 to 45° / 将关节1移动到45°
|
|
198
|
+
"""
|
|
199
|
+
await super().set_joint(
|
|
200
|
+
can_id,
|
|
201
|
+
target_pos,
|
|
202
|
+
target_vel,
|
|
203
|
+
target_tau,
|
|
204
|
+
mode,
|
|
205
|
+
enable,
|
|
206
|
+
set_zero,
|
|
207
|
+
max_vel,
|
|
208
|
+
max_acc,
|
|
209
|
+
max_jerk,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def set_gripper(
|
|
213
|
+
self,
|
|
214
|
+
target_pos: float,
|
|
215
|
+
target_vel: float = 0.0,
|
|
216
|
+
target_tau: float = 0.0,
|
|
217
|
+
set_zero: bool = False,
|
|
218
|
+
):
|
|
219
|
+
"""
|
|
220
|
+
Control gripper opening/closing.
|
|
221
|
+
|
|
222
|
+
控制夹爪开合。
|
|
223
|
+
|
|
224
|
+
Args / 参数:
|
|
225
|
+
target_pos: Target gripper position / 目标开合位置
|
|
226
|
+
target_vel: Gripper velocity, default 0.0 / 夹爪运动速度,默认 0.0
|
|
227
|
+
target_tau: Gripper torque, default 0.0 / 夹爪力矩,默认 0.0
|
|
228
|
+
set_zero: Set current position as zero, default False / 是否设为零位,默认 False
|
|
229
|
+
|
|
230
|
+
Example / 示例:
|
|
231
|
+
await arm.set_gripper(0.08) # Open gripper to 8cm / 将夹爪打开到8cm
|
|
232
|
+
"""
|
|
233
|
+
await super().set_gripper(target_pos, target_vel, target_tau, set_zero)
|
|
234
|
+
|
|
235
|
+
async def set_tf(
|
|
236
|
+
self,
|
|
237
|
+
x: float,
|
|
238
|
+
y: float,
|
|
239
|
+
z: float,
|
|
240
|
+
qw: float,
|
|
241
|
+
qx: float,
|
|
242
|
+
qy: float,
|
|
243
|
+
qz: float,
|
|
244
|
+
plan_mode: int = 0,
|
|
245
|
+
):
|
|
246
|
+
"""
|
|
247
|
+
Set end-effector position and orientation (quaternion).
|
|
248
|
+
|
|
249
|
+
设置末端执行器位姿(位置 + 四元数姿态)。
|
|
250
|
+
|
|
251
|
+
Args / 参数:
|
|
252
|
+
x, y, z: Position in meters / 位置(米)
|
|
253
|
+
qw, qx, qy, qz: Orientation quaternion / 姿态四元数
|
|
254
|
+
plan_mode: 0 = joint-space planning, 1 = Cartesian planning / 0=关节空间,1=笛卡尔
|
|
255
|
+
|
|
256
|
+
Example / 示例:
|
|
257
|
+
await arm.set_tf(0.5, 0.0, 0.3, 1.0, 0.0, 0.0, 0.0, plan_mode=1)
|
|
258
|
+
"""
|
|
259
|
+
await super().set_tf(x, y, z, qw, qx, qy, qz, plan_mode)
|
|
260
|
+
|
|
261
|
+
async def enable_hold_current(
|
|
262
|
+
self,
|
|
263
|
+
max_vel: Optional[float] = None,
|
|
264
|
+
max_acc: Optional[float] = None,
|
|
265
|
+
max_jerk: Optional[float] = None,
|
|
266
|
+
):
|
|
267
|
+
"""
|
|
268
|
+
Hold all configured joints at their current feedback positions.
|
|
269
|
+
|
|
270
|
+
以当前反馈位置重新保持所有已配置关节。
|
|
271
|
+
"""
|
|
272
|
+
await super().enable_hold_current(max_vel, max_acc, max_jerk)
|
|
273
|
+
|
|
274
|
+
async def set_joints_batch(
|
|
275
|
+
self,
|
|
276
|
+
joint_targets: List[Tuple[int, float]],
|
|
277
|
+
target_vel: float = 0.0,
|
|
278
|
+
target_tau: float = 0.0,
|
|
279
|
+
mode: int = 0,
|
|
280
|
+
enable: bool = True,
|
|
281
|
+
set_zero: bool = False,
|
|
282
|
+
max_vel: int = 10,
|
|
283
|
+
max_acc: int = 30,
|
|
284
|
+
max_jerk: int = 50,
|
|
285
|
+
wait_for_completion: bool = False,
|
|
286
|
+
timeout: float = 8.0,
|
|
287
|
+
tolerance: float = 0.015,
|
|
288
|
+
) -> bool:
|
|
289
|
+
"""
|
|
290
|
+
Move multiple joints simultaneously (recommended for synchronized motion).
|
|
291
|
+
|
|
292
|
+
同时控制多个关节运动(推荐用于同步轨迹规划)。
|
|
293
|
+
|
|
294
|
+
Args / 参数:
|
|
295
|
+
joint_targets: List of (can_id, target_pos) tuples / 关节目标列表:[(can_id, 目标位置), ...]
|
|
296
|
+
target_vel/target_tau: Reserved parameters (currently not used) / 保留参数(当前未使用)
|
|
297
|
+
mode/enable/set_zero: Global mode, enable, zero setting / 全局模式、使能、零位设置
|
|
298
|
+
max_vel/max_acc/max_jerk: Global limits (deg/s, deg/s², deg/s³) / 全局最大速度/加速度/加加速度限制
|
|
299
|
+
wait_for_completion: Wait until all joints reach targets, default False / 是否等待到达目标,默认 False
|
|
300
|
+
timeout: Timeout in seconds (when waiting) / 等待超时时间(秒)
|
|
301
|
+
tolerance: Position tolerance in radians, default 0.015 (~0.86°) / 位置容差(弧度,默认 0.015)
|
|
302
|
+
|
|
303
|
+
Returns / 返回:
|
|
304
|
+
bool: Command sent successfully (or reached targets if waiting) / 命令发送成功(或等待成功)
|
|
305
|
+
|
|
306
|
+
Example / 示例:
|
|
307
|
+
await arm.set_joints_batch([(1, 0.5), (2, -0.3), (5, 1.57)])
|
|
308
|
+
"""
|
|
309
|
+
return await super().set_joints_batch(
|
|
310
|
+
joint_targets,
|
|
311
|
+
target_vel,
|
|
312
|
+
target_tau,
|
|
313
|
+
mode,
|
|
314
|
+
enable,
|
|
315
|
+
set_zero,
|
|
316
|
+
max_vel,
|
|
317
|
+
max_acc,
|
|
318
|
+
max_jerk,
|
|
319
|
+
wait_for_completion,
|
|
320
|
+
timeout,
|
|
321
|
+
tolerance,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def zero_gravity_mode(self):
|
|
325
|
+
"""
|
|
326
|
+
Switch to zero gravity mode (free manual movement).
|
|
327
|
+
|
|
328
|
+
切换到零重力模式(可手动自由拖动机械臂)。
|
|
329
|
+
"""
|
|
330
|
+
await super().zero_gravity_mode()
|
|
331
|
+
|
|
332
|
+
async def back_to_zero(self):
|
|
333
|
+
"""
|
|
334
|
+
Move all joints back to zero position.
|
|
335
|
+
|
|
336
|
+
将所有关节移动回零位。
|
|
337
|
+
"""
|
|
338
|
+
await super().back_to_zero()
|
|
339
|
+
|
|
340
|
+
async def back_to_initial(self):
|
|
341
|
+
"""
|
|
342
|
+
Move all joints to factory initial positions.
|
|
343
|
+
|
|
344
|
+
将所有关节移动到出厂初始位置。
|
|
345
|
+
"""
|
|
346
|
+
await super().back_to_initial()
|
|
347
|
+
|
|
348
|
+
async def gracefully_shutdown(self, wait_before_shutdown: float = 10.0):
|
|
349
|
+
"""
|
|
350
|
+
Gracefully shutdown: move to initial pose → wait → power off.
|
|
351
|
+
|
|
352
|
+
优雅关机:先移动到初始位置 → 等待 → 安全断电。
|
|
353
|
+
|
|
354
|
+
Args / 参数:
|
|
355
|
+
wait_before_shutdown: Wait time after movement (seconds, default 10.0) / 运动完成后等待时间(秒,默认 10.0)
|
|
356
|
+
"""
|
|
357
|
+
await super().gracefully_shutdown(wait_before_shutdown)
|
|
358
|
+
|
|
359
|
+
async def emergency_shutdown(self):
|
|
360
|
+
"""
|
|
361
|
+
Immediate emergency stop and shutdown.
|
|
362
|
+
|
|
363
|
+
紧急停止并关机(立即停止所有运动并断电)。
|
|
364
|
+
Use only in emergency situations / 仅在紧急情况下使用。
|
|
365
|
+
"""
|
|
366
|
+
await super().emergency_shutdown()
|
|
367
|
+
|
|
368
|
+
async def close(self):
|
|
369
|
+
"""
|
|
370
|
+
Close connection and release resources.
|
|
371
|
+
|
|
372
|
+
关闭连接并释放所有资源。
|
|
373
|
+
"""
|
|
374
|
+
await super().close()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# zeroArm_sdk/arm_types.py
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class JointState:
|
|
8
|
+
can_id: int
|
|
9
|
+
position: float # radians
|
|
10
|
+
velocity: float
|
|
11
|
+
torque: float
|
|
12
|
+
enable: bool
|
|
13
|
+
mode: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class GripperState:
|
|
18
|
+
position: float
|
|
19
|
+
velocity: float
|
|
20
|
+
torque: float
|
|
21
|
+
enable: bool
|
|
22
|
+
mode: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Pose:
|
|
27
|
+
x: float
|
|
28
|
+
y: float
|
|
29
|
+
z: float
|
|
30
|
+
qw: float
|
|
31
|
+
qx: float
|
|
32
|
+
qy: float
|
|
33
|
+
qz: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FullArmState:
|
|
38
|
+
joints: List[JointState]
|
|
39
|
+
gripper: Optional[GripperState] = None
|
|
40
|
+
eef_pose: Optional[Pose] = None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# zeroArm_sdk/exceptions.py
|
|
2
|
+
|
|
3
|
+
class ArmNotFoundError(Exception):
|
|
4
|
+
"""No ZeroArm device found on the network."""
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArmConnectionError(Exception):
|
|
9
|
+
"""Failed to connect, select SN, or establish communication."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ArmCommandError(Exception):
|
|
14
|
+
"""Command execution failed or invalid response."""
|
|
15
|
+
pass
|
|
Binary file
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zeroArm_sdk
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Official SDK for ZeroArm robotic arm (proprietary)
|
|
5
|
+
Home-page: https://github.com/HANCKH/zeroArm_python_sdk
|
|
6
|
+
Author: ForceEase Co.
|
|
7
|
+
Author-email: "ForceEase Co." <support@forceease.tech>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/HANCKH/zeroArm_python_sdk
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Cython
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: pyzmq>=25.0
|
|
16
|
+
Requires-Dist: websockets
|
|
17
|
+
Requires-Dist: zeroconf
|
|
18
|
+
Requires-Dist: tqdm
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
|
|
24
|
+
# ZeroArm Python SDK
|
|
25
|
+
|
|
26
|
+
Official Python SDK for ForceEase ZeroArm robotic arms.
|
|
27
|
+
|
|
28
|
+
## Connection Notes
|
|
29
|
+
|
|
30
|
+
- Use `RobotArm().auto_discover_and_connect()` for the default LAN workflow.
|
|
31
|
+
- Use `connect_with_short_sn()` with at least the first 8 serial-number characters in multi-arm environments.
|
|
32
|
+
- mDNS discovery only accepts connectable IPv4 addresses and ignores loopback or unspecified addresses such as `127.0.0.1` and `0.0.0.0`.
|
|
33
|
+
- The controller advertises WebSocket control port `8888` and bridge port `8889`; SDK mode uses the fixed ZMQ command/state ports `6666/6667`.
|
|
34
|
+
|
|
35
|
+
## Current Protocol Coverage
|
|
36
|
+
|
|
37
|
+
- `set_tf(..., plan_mode=0)` uses joint-space planning; `plan_mode=1` uses Cartesian planning.
|
|
38
|
+
- `enable_hold_current()` asks the controller to hold all configured joints at their current feedback positions.
|
|
39
|
+
- `set_joints_batch()` sends a single `raw_msg` command through the normal `commands_<serial>` topic.
|
|
40
|
+
|
|
41
|
+
## Build Notes
|
|
42
|
+
|
|
43
|
+
- `pyproject.toml` declares Cython as a build-system dependency for isolated builds.
|
|
44
|
+
- For direct `setup.py` builds, install `requirements-build.txt` first.
|
|
45
|
+
- `setuptools` is capped below 80 to stay compatible with ROS `colcon-core` in the shared development environment.
|
|
46
|
+
|
|
47
|
+
## Release
|
|
48
|
+
|
|
49
|
+
- Publish Python SDK releases through `.github/workflows/build-wheels.yml`.
|
|
50
|
+
- A GitHub Release with action `published` builds the sdist plus Linux, Windows, and macOS wheels, then uploads all distributions to PyPI in one publish job.
|
|
51
|
+
- The workflow expects `PYPI_API_TOKEN` to be configured as a GitHub Actions secret.
|
|
52
|
+
|
|
53
|
+
## Version
|
|
54
|
+
|
|
55
|
+
Current SDK source version: `0.1.2`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
setup.py
|
|
6
|
+
zeroArm_sdk/__init__.py
|
|
7
|
+
zeroArm_sdk/_core.pyx
|
|
8
|
+
zeroArm_sdk/arm.py
|
|
9
|
+
zeroArm_sdk/arm_types.py
|
|
10
|
+
zeroArm_sdk/exceptions.py
|
|
11
|
+
zeroArm_sdk.egg-info/PKG-INFO
|
|
12
|
+
zeroArm_sdk.egg-info/SOURCES.txt
|
|
13
|
+
zeroArm_sdk.egg-info/dependency_links.txt
|
|
14
|
+
zeroArm_sdk.egg-info/requires.txt
|
|
15
|
+
zeroArm_sdk.egg-info/top_level.txt
|
|
16
|
+
zeroArm_sdk/locale/zh_CN/LC_MESSAGES/zeroArm_sdk.mo
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zeroArm_sdk
|