airo-robots 2025.4.0__py3-none-any.whl
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.
- airo_robots/__init__.py +1 -0
- airo_robots/awaitable_action.py +85 -0
- airo_robots/drives/__init__.py +0 -0
- airo_robots/drives/hardware/__init__.py +0 -0
- airo_robots/drives/hardware/kelo_robile.py +133 -0
- airo_robots/drives/hardware/manual_robot_testing.py +31 -0
- airo_robots/drives/mobile_robot.py +82 -0
- airo_robots/grippers/__init__.py +5 -0
- airo_robots/grippers/hardware/__init__.py +0 -0
- airo_robots/grippers/hardware/manual_gripper_testing.py +32 -0
- airo_robots/grippers/hardware/robotiq_2f85_urcap.py +253 -0
- airo_robots/grippers/hardware/schunk_egk40_usb.py +270 -0
- airo_robots/grippers/parallel_position_gripper.py +94 -0
- airo_robots/hardware_interaction_utils.py +45 -0
- airo_robots/manipulators/__init__.py +2 -0
- airo_robots/manipulators/bimanual_position_manipulator.py +271 -0
- airo_robots/manipulators/force_torque_sensor.py +22 -0
- airo_robots/manipulators/hardware/__init__.py +0 -0
- airo_robots/manipulators/hardware/manual_manipulator_testing.py +101 -0
- airo_robots/manipulators/hardware/ur_rtde.py +253 -0
- airo_robots/manipulators/position_manipulator.py +233 -0
- airo_robots/py.typed +0 -0
- airo_robots-2025.4.0.dist-info/METADATA +16 -0
- airo_robots-2025.4.0.dist-info/RECORD +26 -0
- airo_robots-2025.4.0.dist-info/WHEEL +5 -0
- airo_robots-2025.4.0.dist-info/top_level.txt +1 -0
airo_robots/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep mypy happy
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import time
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ACTION_STATUS_ENUM(enum.Enum):
|
|
8
|
+
EXECUTING = 1
|
|
9
|
+
SUCCEEDED = 2
|
|
10
|
+
TIMEOUT = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AwaitableAction:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
termination_condition: Callable[..., bool],
|
|
17
|
+
default_timeout: float = 30.0,
|
|
18
|
+
default_sleep_resolution: float = 0.1,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
termination_condition (Callable[..., bool]): Any callable that returns True when the action is completed.
|
|
24
|
+
In the simplest case, it can be a lambda that returns True.
|
|
25
|
+
It can also be true when the gripper is in the desired position.
|
|
26
|
+
Or it could be true after a certain amount of time has passed since the action was started.
|
|
27
|
+
|
|
28
|
+
default_timeout (float, optional): The max waiting time before the wait returns and raises a warning.
|
|
29
|
+
Select an appropriate default value for the command you are creating this AwaitableAction for.
|
|
30
|
+
|
|
31
|
+
default_sleep_resolution (float, optional): The length of the time.sleep() in each iteration.
|
|
32
|
+
Select an appropriate default value for the command you are creating this AwaitableAction for.
|
|
33
|
+
|
|
34
|
+
Note that the scope of this action is to send 1 command, do some other things, then wait for the command to finish.
|
|
35
|
+
If you send multiple commands to the gripper, there is no guarantee that the gripper will execute them in the order you send them,
|
|
36
|
+
as the gripper might preempt intermediate commands after it has finished the current command.
|
|
37
|
+
Such preemption will also not be detected by this action, and hence the wait of a preempted action will either timeout or succeed by accident.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
self.status = ACTION_STATUS_ENUM.EXECUTING
|
|
41
|
+
self.is_action_done = termination_condition
|
|
42
|
+
self._default_timeout = default_timeout
|
|
43
|
+
self._default_sleep_resolution = default_sleep_resolution
|
|
44
|
+
|
|
45
|
+
def wait(self, timeout: Optional[float] = None, sleep_resolution: Optional[float] = None) -> ACTION_STATUS_ENUM:
|
|
46
|
+
"""Busy waiting until the termination condition returns true, or until timeout.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
timeout (float, optional): The max waiting time before the wait returns and raises a warning.
|
|
50
|
+
This prevents infinite loops. Defaults to the value set during creation of the awaitable, which is usually an appropriate value for the command.
|
|
51
|
+
|
|
52
|
+
sleep_resolution (float, optional): The length of the time.sleep() in each iteration.
|
|
53
|
+
higher values will take up less CPU resources but will also cause more latency between the action finishing and
|
|
54
|
+
this method to return. Defaults to the value set during creation of the awaitable, which is usually an appropriate value for the command.
|
|
55
|
+
|
|
56
|
+
Keep in mind that the time.sleep() function has limited accuracy, so the actual sleeping time will be usually higher
|
|
57
|
+
due to scheduling activities of the OS. Take a look [here](https://github.com/airo-ugent/airo-mono/pull/21#discussion_r1132520057)
|
|
58
|
+
for some realistic numbers on the error. The error is independent of the sleep time and is in the order of 0.2ms.
|
|
59
|
+
So the lower the sleep time, the higher the relative error becomes.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
ACTION_STATUS_ENUM: _description_
|
|
64
|
+
"""
|
|
65
|
+
# see #airo-robots/scripts/measure_sleep_accuracy.py for a script that measures the sleep accuracy and
|
|
66
|
+
# the result of some measurements.
|
|
67
|
+
sleep_resolution = sleep_resolution or self._default_sleep_resolution
|
|
68
|
+
timeout = timeout or self._default_timeout
|
|
69
|
+
assert (
|
|
70
|
+
sleep_resolution > 0.001
|
|
71
|
+
), "sleep resolution must be at least 1 ms, otherwise the relative error of a sleep becomes too large to be meaningful"
|
|
72
|
+
if not self.status == ACTION_STATUS_ENUM.EXECUTING:
|
|
73
|
+
return self.status
|
|
74
|
+
while True:
|
|
75
|
+
time.sleep(sleep_resolution)
|
|
76
|
+
timeout -= sleep_resolution
|
|
77
|
+
if self.is_action_done():
|
|
78
|
+
self.status = ACTION_STATUS_ENUM.SUCCEEDED
|
|
79
|
+
return self.status
|
|
80
|
+
if timeout < 0:
|
|
81
|
+
warnings.warn("Action timed out. Make sure this was expected.")
|
|
82
|
+
return ACTION_STATUS_ENUM.TIMEOUT
|
|
83
|
+
|
|
84
|
+
def is_done(self) -> bool:
|
|
85
|
+
return self.status == ACTION_STATUS_ENUM.SUCCEEDED
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
from threading import Thread
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from airo_robots.awaitable_action import AwaitableAction
|
|
7
|
+
from airo_robots.drives.mobile_robot import CompliantLevel, MobileRobot
|
|
8
|
+
from airo_tulip.api.client import KELORobile as KELORobileClient # type: ignore
|
|
9
|
+
from airo_tulip.hardware.platform_driver import PlatformDriverType # type: ignore
|
|
10
|
+
from airo_typing import Vector3DType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KELORobile(MobileRobot):
|
|
14
|
+
"""KELO Robile platform implementation of the MobileRobot interface.
|
|
15
|
+
|
|
16
|
+
The KELO Robile platform consists of several drives with castor wheels which are connected via EtherCAT and can
|
|
17
|
+
be controlled individually. The airo-tulip API, which is used here, provides a higher level interface which
|
|
18
|
+
controls the entire platform."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, robot_ip: str, robot_port: int = 49789):
|
|
21
|
+
"""Connect to the KELO robot.
|
|
22
|
+
|
|
23
|
+
The KELO robot should already be running the airo-tulip server. If this is not the case, any messages
|
|
24
|
+
sent from the client may be queued and executed once the server is started, resulting in unexpected
|
|
25
|
+
and/or sudden movements.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
robot_ip: IP address of the KELO CPU brick.
|
|
29
|
+
robot_port: Port to connect on (default: 49789)."""
|
|
30
|
+
|
|
31
|
+
self._kelo_robile = KELORobileClient(robot_ip, robot_port)
|
|
32
|
+
|
|
33
|
+
# Position control.
|
|
34
|
+
self._control_loop_done = True
|
|
35
|
+
|
|
36
|
+
def align_drives(self, x: float, y: float, a: float, timeout: float = 1.0) -> AwaitableAction:
|
|
37
|
+
"""Align all drives for driving in a direction given by the linear and angular velocities.
|
|
38
|
+
|
|
39
|
+
Beware that sending any other velocity commands before the awaitable is done may cause unexpected behaviour,
|
|
40
|
+
as this affects the "aligned" condition.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
x: The x velocity (linear, m/s).
|
|
44
|
+
y: The y velocity (linear, m/s)
|
|
45
|
+
a: The angular velocity (rad/s).
|
|
46
|
+
timeout: The awaitable will finish after this time at the latest.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
An AwaitableAction which will check if the drives are aligned, or if the timeout has expired."""
|
|
50
|
+
self._kelo_robile.align_drives(x, y, a, timeout=timeout)
|
|
51
|
+
|
|
52
|
+
action_sent_time = time.time_ns()
|
|
53
|
+
return AwaitableAction(
|
|
54
|
+
lambda: self._kelo_robile.are_drives_aligned() or time.time_ns() - action_sent_time > timeout * 1e9,
|
|
55
|
+
default_timeout=2 * timeout,
|
|
56
|
+
default_sleep_resolution=0.002,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def set_platform_velocity_target(self, x: float, y: float, a: float, timeout: float) -> AwaitableAction:
|
|
60
|
+
self._kelo_robile.set_platform_velocity_target(x, y, a, timeout=timeout)
|
|
61
|
+
|
|
62
|
+
action_sent_time = time.time_ns()
|
|
63
|
+
return AwaitableAction(
|
|
64
|
+
lambda: time.time_ns() - action_sent_time > timeout * 1e9,
|
|
65
|
+
default_timeout=2 * timeout,
|
|
66
|
+
default_sleep_resolution=0.002,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _move_platform_to_pose_control_loop(
|
|
70
|
+
self, target_pose: Vector3DType, action_start_time: float, action_timeout_time: float, timeout: float
|
|
71
|
+
) -> None:
|
|
72
|
+
stop = False
|
|
73
|
+
while not stop:
|
|
74
|
+
current_pose = self._kelo_robile.get_odometry()
|
|
75
|
+
delta_pose = target_pose - current_pose
|
|
76
|
+
|
|
77
|
+
vel_vec_angle = np.arctan2(delta_pose[1], delta_pose[0]) - current_pose[2]
|
|
78
|
+
vel_vec_norm = min(np.linalg.norm(delta_pose[:2]), 0.5)
|
|
79
|
+
vel_x = vel_vec_norm * np.cos(vel_vec_angle)
|
|
80
|
+
vel_y = vel_vec_norm * np.sin(vel_vec_angle)
|
|
81
|
+
|
|
82
|
+
delta_angle = np.arctan2(np.sin(delta_pose[2]), np.cos(delta_pose[2]))
|
|
83
|
+
vel_a = max(min(delta_angle, math.pi / 4), -math.pi / 4)
|
|
84
|
+
|
|
85
|
+
command_timeout = (action_timeout_time - time.time_ns()) * 1e-9
|
|
86
|
+
if command_timeout >= 0.0:
|
|
87
|
+
self._kelo_robile.set_platform_velocity_target(vel_x, vel_y, vel_a, timeout=command_timeout)
|
|
88
|
+
|
|
89
|
+
at_target_pose = bool(np.linalg.norm(delta_pose) < 0.01)
|
|
90
|
+
stop = at_target_pose or time.time_ns() - action_start_time > timeout * 1e9
|
|
91
|
+
|
|
92
|
+
self._kelo_robile.set_platform_velocity_target(0.0, 0.0, 0.0)
|
|
93
|
+
self._control_loop_done = True
|
|
94
|
+
|
|
95
|
+
def move_platform_to_pose(self, x: float, y: float, a: float, timeout: float) -> AwaitableAction:
|
|
96
|
+
target_pose = np.array([x, y, a])
|
|
97
|
+
action_start_time = time.time_ns()
|
|
98
|
+
action_timeout_time = action_start_time + timeout * 1e9
|
|
99
|
+
|
|
100
|
+
self._control_loop_done = False # Will be set to True by the below thread once it's finished.
|
|
101
|
+
thread = Thread(
|
|
102
|
+
target=self._move_platform_to_pose_control_loop,
|
|
103
|
+
args=(target_pose, action_start_time, action_timeout_time, timeout),
|
|
104
|
+
)
|
|
105
|
+
thread.start()
|
|
106
|
+
|
|
107
|
+
return AwaitableAction(
|
|
108
|
+
lambda: self._control_loop_done,
|
|
109
|
+
default_timeout=2 * timeout,
|
|
110
|
+
default_sleep_resolution=0.002,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def enable_compliant_mode(
|
|
114
|
+
self, enabled: bool, compliant_level: CompliantLevel = CompliantLevel.COMPLIANT_WEAK
|
|
115
|
+
) -> None:
|
|
116
|
+
if enabled:
|
|
117
|
+
if compliant_level == CompliantLevel.COMPLIANT_WEAK:
|
|
118
|
+
self._kelo_robile.set_driver_type(PlatformDriverType.COMPLIANT_WEAK)
|
|
119
|
+
elif compliant_level == CompliantLevel.COMPLIANT_MODERATE:
|
|
120
|
+
self._kelo_robile.set_driver_type(PlatformDriverType.COMPLIANT_MODERATE)
|
|
121
|
+
else:
|
|
122
|
+
self._kelo_robile.set_driver_type(PlatformDriverType.COMPLIANT_STRONG)
|
|
123
|
+
else:
|
|
124
|
+
self._kelo_robile.set_driver_type(PlatformDriverType.VELOCITY)
|
|
125
|
+
|
|
126
|
+
def get_odometry(self) -> Vector3DType:
|
|
127
|
+
return self._kelo_robile.get_odometry()
|
|
128
|
+
|
|
129
|
+
def get_velocity(self) -> Vector3DType:
|
|
130
|
+
return self._kelo_robile.get_velocity()
|
|
131
|
+
|
|
132
|
+
def reset_odometry(self) -> None:
|
|
133
|
+
self._kelo_robile.reset_odometry()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""code for manual testing of mobile robot base class implementations.
|
|
2
|
+
"""
|
|
3
|
+
from airo_robots.drives.hardware.kelo_robile import KELORobile
|
|
4
|
+
from airo_robots.drives.mobile_robot import MobileRobot
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def manually_test_robot_implementation(robot: MobileRobot) -> None:
|
|
8
|
+
if isinstance(robot, KELORobile):
|
|
9
|
+
input("robot will rotate drives")
|
|
10
|
+
robot.align_drives(0.1, 0.0, 0.0, 1.0).wait()
|
|
11
|
+
|
|
12
|
+
input("robot will now move forward 10cm")
|
|
13
|
+
robot.set_platform_velocity_target(0.1, 0.0, 0.0, 1.0).wait()
|
|
14
|
+
|
|
15
|
+
input("robot will now move left 10cm")
|
|
16
|
+
robot.set_platform_velocity_target(0.0, 0.1, 0.0, 1.0).wait()
|
|
17
|
+
|
|
18
|
+
input("robot will now make two short rotations")
|
|
19
|
+
robot.set_platform_velocity_target(0.0, 0.0, 0.1, 1.0).wait()
|
|
20
|
+
robot.set_platform_velocity_target(0.0, 0.0, -0.1, 1.0).wait()
|
|
21
|
+
|
|
22
|
+
input("robot will now return to original position")
|
|
23
|
+
robot.set_platform_velocity_target(-0.1, -0.1, 0.0, 1.0).wait()
|
|
24
|
+
|
|
25
|
+
input("robot will now move to 10 cm along +X relative from its starting position with position control")
|
|
26
|
+
robot.move_platform_to_pose(0.1, 0.0, 0.0, 10.0).wait()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
mobi = KELORobile("10.10.129.21")
|
|
31
|
+
manually_test_robot_implementation(mobi)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from airo_robots.awaitable_action import AwaitableAction
|
|
5
|
+
from airo_typing import Vector3DType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CompliantLevel(Enum):
|
|
9
|
+
"""The level of compliance expected from the mobile robot.
|
|
10
|
+
|
|
11
|
+
Values may not correspond to identical behaviour on different mobile platforms, but are merely an indication.
|
|
12
|
+
A value of weak means a very compliant robot, whereas a value of strong means a slightly compliant robot."""
|
|
13
|
+
|
|
14
|
+
COMPLIANT_WEAK = 1
|
|
15
|
+
COMPLIANT_MODERATE = 2
|
|
16
|
+
COMPLIANT_STRONG = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MobileRobot(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Base class for a mobile robot.
|
|
22
|
+
|
|
23
|
+
Mobile robots typically allow to set a target velocity for the entire platform and apply torques to their wheels
|
|
24
|
+
to achieve the desired velocity.
|
|
25
|
+
|
|
26
|
+
All values are in metric units:
|
|
27
|
+
- Linear velocities are expressed in meters/second
|
|
28
|
+
- Angular velocities are expressed in radians/second
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def set_platform_velocity_target(self, x: float, y: float, a: float, timeout: float) -> AwaitableAction:
|
|
33
|
+
"""Set the desired platform velocity.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
x: Linear velocity along the X axis.
|
|
37
|
+
y: Linear velocity along the Y axis.
|
|
38
|
+
a: Angular velocity.
|
|
39
|
+
timeout: After this time, the platform will automatically stop.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
An awaitable action."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def move_platform_to_pose(self, x: float, y: float, a: float, timeout: float) -> AwaitableAction:
|
|
46
|
+
"""Move the platform to the given pose, without guarantees about the followed path.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
x: Position along the startup pose's X axis.
|
|
50
|
+
y: Position along the startup pose's Y axis.
|
|
51
|
+
a: Orientation around the startup pose's Z axis.
|
|
52
|
+
timeout: After this time, the platform will automatically stop.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An awaitable action."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def enable_compliant_mode(self, enabled: bool, compliant_level: CompliantLevel) -> None:
|
|
59
|
+
"""Enable compliant mode on the robot.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
enabled: If true, will enable compliant mode. Else, will disable compliant mode.
|
|
63
|
+
compliant_level: The level of compliance to be expected from the robot. Ignored if `enabled` is `False`."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def get_odometry(self) -> Vector3DType:
|
|
67
|
+
"""Get the estimated robot pose as a 3D vector comprising the `x`, `y`, and `theta` values relative to the
|
|
68
|
+
robot's starting pose.
|
|
69
|
+
|
|
70
|
+
Returns: A 3D vector with a value for the `x` position, `y` position, and `theta` angle of the robot."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def reset_odometry(self) -> None:
|
|
74
|
+
"""Reset the robot's odometry to `(0, 0, 0)`."""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def get_velocity(self) -> Vector3DType:
|
|
78
|
+
"""Get the estimated robot's velocity as a 3D vector comprising the `x`, `y` and `theta` values relative to the
|
|
79
|
+
robot's starting pose.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A 3D vector with a value for the `x` velocity, `y` velocity, and `theta` angular velocity of the robot."""
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""code for manual testing of gripper base class implementations.
|
|
2
|
+
"""
|
|
3
|
+
from airo_robots.grippers.parallel_position_gripper import ParallelPositionGripper, ParallelPositionGripperSpecs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def manually_test_gripper_implementation(
|
|
7
|
+
gripper: ParallelPositionGripper, specs: ParallelPositionGripperSpecs
|
|
8
|
+
) -> None:
|
|
9
|
+
input("gripper will now open")
|
|
10
|
+
gripper.move(specs.max_width).wait()
|
|
11
|
+
assert abs(gripper.get_current_width() - specs.max_width) < 0.003
|
|
12
|
+
print(f"gripper width {gripper.get_current_width()} should be close to the requested {specs.max_width}")
|
|
13
|
+
|
|
14
|
+
input("set speed and check that this happens synchronously")
|
|
15
|
+
gripper.speed = 0.02
|
|
16
|
+
print(f"{gripper.speed=}")
|
|
17
|
+
assert abs(gripper.speed - 0.02) < 0.005
|
|
18
|
+
|
|
19
|
+
input("gripper wil now move slowly to 2cm opening ")
|
|
20
|
+
res = gripper.move(0.02)
|
|
21
|
+
print("move started and returned awaitable")
|
|
22
|
+
res.wait()
|
|
23
|
+
print("move completed, gripper should have reached 2cm")
|
|
24
|
+
assert abs(gripper.get_current_width() - 0.02) < 0.003
|
|
25
|
+
|
|
26
|
+
input("reopen gripper fast")
|
|
27
|
+
gripper.move(0.08, 0.15)
|
|
28
|
+
input("close with high force, you can put an object between the fingers to test the force and the grasp detection")
|
|
29
|
+
gripper.max_grasp_force = 200
|
|
30
|
+
gripper.speed = 0.02
|
|
31
|
+
gripper.move(0.02).wait()
|
|
32
|
+
print(f"{gripper.is_an_object_grasped()=}")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import socket
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from airo_robots.awaitable_action import AwaitableAction
|
|
8
|
+
from airo_robots.grippers.hardware.manual_gripper_testing import manually_test_gripper_implementation
|
|
9
|
+
from airo_robots.grippers.parallel_position_gripper import ParallelPositionGripper, ParallelPositionGripperSpecs
|
|
10
|
+
from airo_robots.hardware_interaction_utils import wait_for_condition_with_timeout
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rescale_range(x: float, from_min: float, from_max: float, to_min: float, to_max: float) -> float:
|
|
14
|
+
return to_min + (x - from_min) / (from_max - from_min) * (to_max - to_min)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Robotiq2F85(ParallelPositionGripper):
|
|
18
|
+
"""
|
|
19
|
+
Implementation of the gripper interface for a Robotiq 2F-85 gripper that is connected to a UR robot and is controlled with the Robotiq URCap.
|
|
20
|
+
|
|
21
|
+
The API is available at TCP port 63352 of the UR controller and wraps the Modbus registers of the gripper, as described in
|
|
22
|
+
https://dof.robotiq.com/discussion/2420/control-robotiq-gripper-mounted-on-ur-robot-via-socket-communication-python.
|
|
23
|
+
The control sequence is gripper motor <---- gripper registers<--ModbusSerial(rs485)-- UR controller <--TCP-- remote control
|
|
24
|
+
|
|
25
|
+
This class does 2 things:
|
|
26
|
+
- it communicates over TCP using the above mentioned API to read/write values from/to the gripper's registers.
|
|
27
|
+
- it rescales all those register values into metric units, as required by the gripper interface
|
|
28
|
+
|
|
29
|
+
For more info on how to install the URCap and connecting the gripper's RS-485 connection to a UR robot, see the manual, section 4.8
|
|
30
|
+
https://assets.robotiq.com/website-assets/support_documents/document/2F-85_2F-140_Instruction_Manual_e-Series_PDF_20190206.pdf
|
|
31
|
+
|
|
32
|
+
Make sure you can control the gripper using the robot controller (polyscope) before using this script.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# values obtained from the manual
|
|
36
|
+
# see https://assets.robotiq.com/website-assets/support_documents/document/2F-85_2F-140_Instruction_Manual_e-Series_PDF_20190206.pdf
|
|
37
|
+
ROBOTIQ_2F85_DEFAULT_SPECS = ParallelPositionGripperSpecs(0.085, 0.0, 220, 25, 0.15, 0.02)
|
|
38
|
+
|
|
39
|
+
def __init__(self, host_ip: str, port: int = 63352, fingers_max_stroke: Optional[float] = None) -> None:
|
|
40
|
+
"""
|
|
41
|
+
host_ip: the IP adress of the robot to which the gripper is connected.
|
|
42
|
+
|
|
43
|
+
fingers_max_stroke:
|
|
44
|
+
allow for custom max stroke width (if you have fingertips that are closer together).
|
|
45
|
+
the robotiq will always calibrate such that max opening = 0 and min opening = 255 for its register.
|
|
46
|
+
see manual p42
|
|
47
|
+
"""
|
|
48
|
+
self._gripper_specs = self.ROBOTIQ_2F85_DEFAULT_SPECS
|
|
49
|
+
if fingers_max_stroke:
|
|
50
|
+
self._gripper_specs.max_width = fingers_max_stroke
|
|
51
|
+
self.host_ip = host_ip
|
|
52
|
+
self.port = port
|
|
53
|
+
|
|
54
|
+
self._check_connection()
|
|
55
|
+
|
|
56
|
+
if not self.gripper_is_active():
|
|
57
|
+
self._activate_gripper()
|
|
58
|
+
|
|
59
|
+
super().__init__(self.gripper_specs)
|
|
60
|
+
|
|
61
|
+
def get_current_width(self) -> float:
|
|
62
|
+
register_value = int(self._communicate("GET POS").split(" ")[1])
|
|
63
|
+
width = rescale_range(register_value, 0, 230, self._gripper_specs.max_width, self._gripper_specs.min_width)
|
|
64
|
+
return width
|
|
65
|
+
|
|
66
|
+
def move(self, width: float, speed: Optional[float] = None, force: Optional[float] = None) -> AwaitableAction:
|
|
67
|
+
if speed:
|
|
68
|
+
self.speed = speed
|
|
69
|
+
if force:
|
|
70
|
+
self.max_grasp_force = force
|
|
71
|
+
self._set_target_width(width)
|
|
72
|
+
|
|
73
|
+
# this sleep is required to make sure that the OBJ STATUS
|
|
74
|
+
# of the gripper is already in 'moving' before entering the wait loop.
|
|
75
|
+
time.sleep(0.01)
|
|
76
|
+
|
|
77
|
+
def move_done_condition() -> bool:
|
|
78
|
+
done = abs(self.get_current_width() - width) < 0.002
|
|
79
|
+
done = done or self.is_an_object_grasped()
|
|
80
|
+
return done
|
|
81
|
+
|
|
82
|
+
return AwaitableAction(move_done_condition)
|
|
83
|
+
|
|
84
|
+
def is_an_object_grasped(self) -> bool:
|
|
85
|
+
return int(self._communicate("GET OBJ").split(" ")[1]) == 2
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def speed(self) -> float:
|
|
89
|
+
speed_register_value = self._read_speed_register()
|
|
90
|
+
return rescale_range(
|
|
91
|
+
speed_register_value, 0, 255, self._gripper_specs.min_speed, self._gripper_specs.max_speed
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@speed.setter
|
|
95
|
+
def speed(self, value: float) -> None:
|
|
96
|
+
speed = np.clip(value, self.gripper_specs.min_speed, self.gripper_specs.max_speed)
|
|
97
|
+
speed_register_value = int(
|
|
98
|
+
rescale_range(speed, self._gripper_specs.min_speed, self._gripper_specs.max_speed, 0, 255)
|
|
99
|
+
)
|
|
100
|
+
self._communicate(f"SET SPE {speed_register_value}")
|
|
101
|
+
|
|
102
|
+
def is_value_set() -> bool:
|
|
103
|
+
return self._is_target_value_set(self._read_speed_register(), speed_register_value)
|
|
104
|
+
|
|
105
|
+
wait_for_condition_with_timeout(is_value_set)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def max_grasp_force(self) -> float:
|
|
109
|
+
force_register_value = self._read_force_register()
|
|
110
|
+
# 0 force has a special meaning, cf manual.
|
|
111
|
+
return rescale_range(
|
|
112
|
+
force_register_value, 1, 255, self._gripper_specs.min_force, self._gripper_specs.max_force
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@max_grasp_force.setter
|
|
116
|
+
def max_grasp_force(self, value: float) -> None:
|
|
117
|
+
force = np.clip(value, self.gripper_specs.min_force, self.gripper_specs.max_force)
|
|
118
|
+
force_register_value = int(
|
|
119
|
+
rescale_range(force, self._gripper_specs.min_force, self._gripper_specs.max_force, 1, 255)
|
|
120
|
+
)
|
|
121
|
+
self._communicate(f"SET FOR {force_register_value}")
|
|
122
|
+
|
|
123
|
+
def is_value_set() -> bool:
|
|
124
|
+
return self._is_target_value_set(force_register_value, self._read_force_register())
|
|
125
|
+
|
|
126
|
+
wait_for_condition_with_timeout(is_value_set)
|
|
127
|
+
|
|
128
|
+
###########################
|
|
129
|
+
## non-interface classes ##
|
|
130
|
+
###########################
|
|
131
|
+
async def asyncio_move(self, width: float, speed: Optional[float] = None, force: Optional[float] = None) -> None:
|
|
132
|
+
"""Asyncio (async) move"""
|
|
133
|
+
if speed:
|
|
134
|
+
self.speed = speed
|
|
135
|
+
if force:
|
|
136
|
+
self.max_grasp_force = force
|
|
137
|
+
self._set_target_width(width)
|
|
138
|
+
|
|
139
|
+
while self.is_gripper_moving():
|
|
140
|
+
await asyncio.sleep(0.05)
|
|
141
|
+
|
|
142
|
+
####################
|
|
143
|
+
# Private methods #
|
|
144
|
+
####################
|
|
145
|
+
|
|
146
|
+
def _set_target_width(self, target_width_in_meters: float) -> None:
|
|
147
|
+
"""Sends target width to gripper"""
|
|
148
|
+
target_width_in_meters = np.clip(
|
|
149
|
+
target_width_in_meters, self._gripper_specs.min_width, self._gripper_specs.max_width
|
|
150
|
+
)
|
|
151
|
+
# 230 is 'force closed', cf _write_target_width_to_register.
|
|
152
|
+
target_width_register_value = round(
|
|
153
|
+
rescale_range(target_width_in_meters, self._gripper_specs.min_width, self._gripper_specs.max_width, 230, 0)
|
|
154
|
+
)
|
|
155
|
+
self._write_target_width_to_register(target_width_register_value)
|
|
156
|
+
|
|
157
|
+
def _write_target_width_to_register(self, target_width_register_value: int) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Takes values in range 0 -255
|
|
160
|
+
3 is actually fully open, 230 is fully closed in operating mode (straight fingers) and 255 is fully closed in encompassed mode.
|
|
161
|
+
For the default fingertips, the range 3 - 230 maps approximately to 0mm - 85mm with a quasi linear relation of 0.4mm / unit
|
|
162
|
+
(experimental findings, but manual also mentions this 0.4mm/unit relation)
|
|
163
|
+
|
|
164
|
+
For custom finger tips with a different stroke, it won't be 0.4mm/unit anymore,
|
|
165
|
+
but 230 will still be fully closed due to the self-calibration of the gripper.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
self._communicate(f"SET POS {target_width_register_value}")
|
|
169
|
+
|
|
170
|
+
def is_value_set() -> bool:
|
|
171
|
+
return self._is_target_value_set(target_width_register_value, self._read_target_width_register())
|
|
172
|
+
|
|
173
|
+
wait_for_condition_with_timeout(is_value_set)
|
|
174
|
+
|
|
175
|
+
def _read_target_width_register(self) -> int:
|
|
176
|
+
return int(self._communicate("GET PRE").split(" ")[1])
|
|
177
|
+
|
|
178
|
+
def _read_speed_register(self) -> int:
|
|
179
|
+
return int(self._communicate("GET SPE").split(" ")[1])
|
|
180
|
+
|
|
181
|
+
def _read_force_register(self) -> int:
|
|
182
|
+
return int(self._communicate("GET FOR").split(" ")[1])
|
|
183
|
+
|
|
184
|
+
def is_gripper_moving(self) -> bool:
|
|
185
|
+
# Moving == 0 => detected OR position reached
|
|
186
|
+
return int(self._communicate("GET OBJ").split(" ")[1]) == 0
|
|
187
|
+
|
|
188
|
+
def _check_connection(self) -> None:
|
|
189
|
+
"""validate communication with gripper is possible.
|
|
190
|
+
Raises:
|
|
191
|
+
ConnectionError
|
|
192
|
+
"""
|
|
193
|
+
if not self._communicate("GET STA").startswith("STA"):
|
|
194
|
+
raise ConnectionError("Could not connect to gripper")
|
|
195
|
+
|
|
196
|
+
def _communicate(self, command: str) -> str:
|
|
197
|
+
"""Helper function to communicate with gripper over a tcp socket.
|
|
198
|
+
Args:
|
|
199
|
+
command (str): The GET/SET command string.
|
|
200
|
+
"""
|
|
201
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
202
|
+
try:
|
|
203
|
+
s.connect((self.host_ip, self.port))
|
|
204
|
+
s.sendall(("" + str.strip(command) + "\n").encode())
|
|
205
|
+
|
|
206
|
+
data = s.recv(2**10)
|
|
207
|
+
return data.decode()[:-1]
|
|
208
|
+
except Exception as e:
|
|
209
|
+
raise (e)
|
|
210
|
+
|
|
211
|
+
def _activate_gripper(self) -> None:
|
|
212
|
+
"""Activates the gripper, sets target position to "Open" and sets GTO flag."""
|
|
213
|
+
self._communicate("SET ACT 1")
|
|
214
|
+
wait_for_condition_with_timeout(self.gripper_is_active)
|
|
215
|
+
# initialize gripper
|
|
216
|
+
self._communicate("SET GTO 1") # enable Gripper
|
|
217
|
+
self.speed = self._gripper_specs.max_speed
|
|
218
|
+
self.force = self._gripper_specs.min_force
|
|
219
|
+
|
|
220
|
+
def _deactivate_gripper(self) -> None:
|
|
221
|
+
self._communicate("SET ACT 0")
|
|
222
|
+
wait_for_condition_with_timeout(lambda: self._communicate("GET STA") == "STA 0")
|
|
223
|
+
|
|
224
|
+
def gripper_is_active(self) -> bool:
|
|
225
|
+
return self._communicate("GET STA") == "STA 3"
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _is_target_value_set(target: int, value: int) -> bool:
|
|
229
|
+
"""helper to compare target value to current value and make the force / speed request synchronous"""
|
|
230
|
+
return abs(target - value) < 5
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_empirical_data_on_opening_angles(robot_ip: str) -> None:
|
|
234
|
+
"""used to verify the relation between finger width and register values."""
|
|
235
|
+
gripper = Robotiq2F85(robot_ip)
|
|
236
|
+
gripper._write_target_width_to_register(230)
|
|
237
|
+
input("press")
|
|
238
|
+
for position in [0, 50, 100, 150, 200, 250]:
|
|
239
|
+
gripper._write_target_width_to_register(position)
|
|
240
|
+
input(f"press key for moving to next position {position}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
robot_ip = "10.42.0.162" # hardcoded IP of Victor UR3e
|
|
245
|
+
import click
|
|
246
|
+
|
|
247
|
+
@click.command()
|
|
248
|
+
@click.option("--robot_ip", default=robot_ip, help="IP of UR to which the gripper is connected.")
|
|
249
|
+
def test_robotiq(robot_ip: str) -> None:
|
|
250
|
+
gripper = Robotiq2F85(robot_ip)
|
|
251
|
+
manually_test_gripper_implementation(gripper, gripper._gripper_specs)
|
|
252
|
+
|
|
253
|
+
test_robotiq()
|