roboapi 0.4.2__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.
- roboapi/__init__.py +2 -0
- roboapi/calibration/__init__.py +6 -0
- roboapi/calibration/profile.py +139 -0
- roboapi/calibration/session.py +350 -0
- roboapi/cli.py +215 -0
- roboapi/config/__init__.py +22 -0
- roboapi/config/loader.py +119 -0
- roboapi/config/models.py +191 -0
- roboapi/embodiment.py +251 -0
- roboapi/errors.py +37 -0
- roboapi/geometry.py +337 -0
- roboapi/hardware/__init__.py +5 -0
- roboapi/hardware/base.py +113 -0
- roboapi/hardware/camera.py +167 -0
- roboapi/hardware/franka/__init__.py +5 -0
- roboapi/hardware/franka/arm.py +476 -0
- roboapi/hardware/franka/desk.py +555 -0
- roboapi/hardware/franka/feedback.py +196 -0
- roboapi/hardware/franka/guiding.py +104 -0
- roboapi/hardware/franka/ik.py +543 -0
- roboapi/hardware/franka/limits.py +110 -0
- roboapi/hardware/franka/settings.py +116 -0
- roboapi/hardware/robotiq.py +350 -0
- roboapi/hardware/ur/__init__.py +16 -0
- roboapi/hardware/ur/build.py +92 -0
- roboapi/hardware/ur/compliant.py +128 -0
- roboapi/hardware/ur/dashboard.py +103 -0
- roboapi/hardware/ur/feedback.py +159 -0
- roboapi/hardware/ur/ipc.py +206 -0
- roboapi/hardware/ur/native/CMakeLists.txt +37 -0
- roboapi/hardware/ur/native/dependencies.lock.json +13 -0
- roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
- roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
- roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
- roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
- roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
- roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
- roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
- roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
- roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
- roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
- roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
- roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
- roboapi/hardware/ur/native/worker.cpp +147 -0
- roboapi/hardware/ur/position.py +253 -0
- roboapi/hardware/ur/settings.py +95 -0
- roboapi/images.py +16 -0
- roboapi/initialize.py +137 -0
- roboapi/mcp.py +497 -0
- roboapi/motion.py +125 -0
- roboapi/naming.py +7 -0
- roboapi/operations.py +45 -0
- roboapi/policy/__init__.py +7 -0
- roboapi/policy/client.py +277 -0
- roboapi/policy/protocol.py +166 -0
- roboapi/policy/settings.py +79 -0
- roboapi/product-names.json +12 -0
- roboapi/records.py +149 -0
- roboapi/runtime.py +911 -0
- roboapi/semantics.py +6 -0
- roboapi/tasks.py +337 -0
- roboapi/web/__init__.py +5 -0
- roboapi/web/presets.py +44 -0
- roboapi/web/server.py +419 -0
- roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
- roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
- roboapi/web/static/assets/Inter-OFL.txt +93 -0
- roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
- roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
- roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
- roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
- roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi/web/static/assets/React-LICENSE.txt +23 -0
- roboapi/web/static/assets/Three-LICENSE.txt +23 -0
- roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
- roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
- roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
- roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
- roboapi/web/static/index.html +15 -0
- roboapi-0.4.2.dist-info/METADATA +205 -0
- roboapi-0.4.2.dist-info/RECORD +94 -0
- roboapi-0.4.2.dist-info/WHEEL +5 -0
- roboapi-0.4.2.dist-info/entry_points.txt +2 -0
- roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
- roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Franka model definitions, control settings and the standard end-effector assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ...config.models import ArmConfig
|
|
8
|
+
from .limits import load_limits
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ROBOT_MODELS = ("fr3", "panda")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ToolLoad:
|
|
16
|
+
mass_kg: float
|
|
17
|
+
com_flange_m: tuple[float, float, float]
|
|
18
|
+
inertia_kg_m2: tuple[tuple[float, float, float], ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
STANDARD_TOOL = ToolLoad(
|
|
22
|
+
mass_kg=1.3828473321773211,
|
|
23
|
+
com_flange_m=(0.020587322750448254, 0.0021358917079591813, 0.04633173957609609),
|
|
24
|
+
inertia_kg_m2=((0.00031, 0, 0), (0, 0.00031, 0), (0, 0, 0.00031)),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
COLLISION_TORQUE = (100.0,) * 7 # External joint torques, N·m.
|
|
28
|
+
COLLISION_FORCE = (100.0,) * 6 # Cartesian forces (N) and torques (N·m).
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class JointImpedanceConfig:
|
|
33
|
+
"""Franka's one supported torque-control path."""
|
|
34
|
+
|
|
35
|
+
stiffness: list[float] = field(default_factory=lambda: [40, 30, 50, 25, 35, 25, 10])
|
|
36
|
+
damping: list[float] = field(default_factory=lambda: [4, 6, 5, 5, 3, 2, 1])
|
|
37
|
+
cartesian_stiffness: list[float] = field(default_factory=lambda: [400, 400, 400, 15, 15, 15])
|
|
38
|
+
cartesian_damping: list[float] = field(default_factory=lambda: [37, 37, 37, 2, 2, 2])
|
|
39
|
+
max_delta_tau: float = 2.0
|
|
40
|
+
gains_time_constant: float = 0.30
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IKConfig:
|
|
45
|
+
nominal_dt: float = 0.02
|
|
46
|
+
target_lead_time: float = 0.005
|
|
47
|
+
max_translation_step: float = 0.0225
|
|
48
|
+
max_rotation_step: float = 0.045
|
|
49
|
+
max_joint_step: tuple[float, ...] = (0.06,) * 7
|
|
50
|
+
max_translation_velocity: float = 3.0
|
|
51
|
+
max_rotation_velocity: float = 2.5
|
|
52
|
+
max_joint_velocity: tuple[float, ...] = load_limits("fr3", None).joint_velocity
|
|
53
|
+
regularization: float = 0.01
|
|
54
|
+
singularity_soft_threshold: float = 0.08
|
|
55
|
+
singularity_hard_threshold: float = 0.03
|
|
56
|
+
singularity_max_regularization: float = 0.08
|
|
57
|
+
joint_limit_margin: float = 0.30
|
|
58
|
+
nullspace_gain: float = 0.025
|
|
59
|
+
# Continuous secondary posture objective. The correction is projected onto
|
|
60
|
+
# the structural 7-DoF nullspace after the Cartesian QP, so it cannot trade
|
|
61
|
+
# away task velocity. A zero gain disables this objective.
|
|
62
|
+
posture_reference: tuple[float, ...] = (0.0,) * 7
|
|
63
|
+
posture_weights: tuple[float, ...] = (1.0,) * 7
|
|
64
|
+
posture_gain: float = 0.0
|
|
65
|
+
posture_max_velocity: float = 0.25
|
|
66
|
+
posture_task_velocity_threshold: float = 0.01
|
|
67
|
+
posture_rotation_velocity_threshold: float = 0.03
|
|
68
|
+
translation_feedback_gain: float = 12.0
|
|
69
|
+
rotation_feedback_gain: float = 12.0
|
|
70
|
+
max_joint_acceleration: tuple[float, ...] = load_limits("fr3", None).joint_acceleration
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class FrankaArmConfig(ArmConfig):
|
|
75
|
+
type: str = "franka"
|
|
76
|
+
host: str = ""
|
|
77
|
+
realtime_config: str = "enforce" # enforce | ignore (ignore = dev box without PREEMPT_RT)
|
|
78
|
+
desk_user: str = "franka"
|
|
79
|
+
desk_password_file: str = "~/.config/roboapi/franka-desk-password"
|
|
80
|
+
robot_model: str = "fr3"
|
|
81
|
+
cartesian_angular_speed: float = 0.5
|
|
82
|
+
hybrid_hz: float = 50.0
|
|
83
|
+
trace_capacity: int = 6000
|
|
84
|
+
joint_impedance: JointImpedanceConfig = field(default_factory=JointImpedanceConfig)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def for_model(cls, model: str, *, host: str) -> FrankaArmConfig:
|
|
88
|
+
if model not in ROBOT_MODELS:
|
|
89
|
+
raise ValueError("arm.robot_model must be panda|fr3")
|
|
90
|
+
return cls(host=host, robot_model=model)
|
|
91
|
+
|
|
92
|
+
def validate(self) -> None:
|
|
93
|
+
if not self.host:
|
|
94
|
+
raise ValueError("arm.type=franka requires arm.host")
|
|
95
|
+
if self.realtime_config not in ("enforce", "ignore"):
|
|
96
|
+
raise ValueError("arm.realtime_config must be enforce|ignore")
|
|
97
|
+
if self.robot_model not in ROBOT_MODELS:
|
|
98
|
+
raise ValueError("arm.robot_model must be panda|fr3")
|
|
99
|
+
for name, value, size in (
|
|
100
|
+
("joint_impedance.stiffness", self.joint_impedance.stiffness, 7),
|
|
101
|
+
("joint_impedance.damping", self.joint_impedance.damping, 7),
|
|
102
|
+
("joint_impedance.cartesian_stiffness", self.joint_impedance.cartesian_stiffness, 6),
|
|
103
|
+
("joint_impedance.cartesian_damping", self.joint_impedance.cartesian_damping, 6),
|
|
104
|
+
):
|
|
105
|
+
if len(value) != size or not all(isinstance(x, (int, float)) and float("-inf") < x < float("inf") for x in value):
|
|
106
|
+
raise ValueError(f"arm.{name} must contain {size} finite values")
|
|
107
|
+
if not self.desk_user or not self.desk_password_file:
|
|
108
|
+
raise ValueError("franka Desk user and password file are required")
|
|
109
|
+
if self.hybrid_hz <= 0 or self.trace_capacity < 1:
|
|
110
|
+
raise ValueError("franka hybrid_hz/trace_capacity must be positive")
|
|
111
|
+
for name, value in (
|
|
112
|
+
("joint_impedance.max_delta_tau", self.joint_impedance.max_delta_tau),
|
|
113
|
+
("joint_impedance.gains_time_constant", self.joint_impedance.gains_time_constant),
|
|
114
|
+
):
|
|
115
|
+
if not isinstance(value, (int, float)) or not 0.0 < value < float("inf"):
|
|
116
|
+
raise ValueError(f"arm.{name} must be a positive finite value")
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Robotiq 2F control over Modbus RTU or the URCap TCP socket.
|
|
2
|
+
|
|
3
|
+
Both transports expose opening in [0, 1], where 0 is closed and 1 is open.
|
|
4
|
+
The shared conversion maps this to raw device positions, 0=open and 255=closed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_POS_MAX = 255
|
|
18
|
+
_STA_ACTIVE = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def raw_from_ratio(ratio: float) -> int:
|
|
22
|
+
"""Opening ratio [0,1] (0=closed, 1=open) -> Robotiq raw [0,255] (0=open, 255=closed)."""
|
|
23
|
+
return int(round(_POS_MAX * (1.0 - min(1.0, max(0.0, float(ratio))))))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ratio_from_raw(raw: int) -> float:
|
|
27
|
+
return 1.0 - (float(raw) / _POS_MAX)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _RobotiqBase:
|
|
31
|
+
"""Device logic. Subclasses supply only the transport: open/close, the three commands,
|
|
32
|
+
and one status read.
|
|
33
|
+
|
|
34
|
+
A background thread sends the newest target only, so serial or socket I/O never blocks
|
|
35
|
+
the 30 Hz dispatch loop, and refreshes the position cache on a deadline.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, cfg) -> None:
|
|
39
|
+
self.cfg = cfg
|
|
40
|
+
self._cond = threading.Condition()
|
|
41
|
+
self._target: int | None = None
|
|
42
|
+
self._last_sent: int | None = None
|
|
43
|
+
self._last_raw = 0
|
|
44
|
+
self._stop = False
|
|
45
|
+
self._thread: threading.Thread | None = None
|
|
46
|
+
self._fails = 0
|
|
47
|
+
self._error: str | None = None
|
|
48
|
+
self._connected = False
|
|
49
|
+
self._motion_abort = threading.Event()
|
|
50
|
+
self._io_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
# -- transport hooks ---------------------------------------------------
|
|
53
|
+
def _open(self) -> None: raise NotImplementedError
|
|
54
|
+
def _close(self) -> None: raise NotImplementedError
|
|
55
|
+
def _cmd_reset(self) -> None: raise NotImplementedError
|
|
56
|
+
def _cmd_activate(self) -> None: raise NotImplementedError
|
|
57
|
+
def _cmd_goto(self, pos: int, speed: int, force: int) -> None: raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def _read_status(self) -> dict:
|
|
60
|
+
"""`sta` (3 = active), `obj` (0 = moving), `pos` (0 open .. 255 closed), `fault`."""
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def _fail(self, detail: str) -> None:
|
|
64
|
+
self._fails += 1
|
|
65
|
+
self._error = detail
|
|
66
|
+
|
|
67
|
+
def _ok(self) -> None:
|
|
68
|
+
self._fails = 0
|
|
69
|
+
self._error = None
|
|
70
|
+
|
|
71
|
+
# -- protocol ----------------------------------------------------------
|
|
72
|
+
def activate(self) -> None:
|
|
73
|
+
self._cmd_reset()
|
|
74
|
+
time.sleep(0.5)
|
|
75
|
+
self._cmd_activate()
|
|
76
|
+
deadline = time.time() + 10.0
|
|
77
|
+
last = None
|
|
78
|
+
while time.time() < deadline:
|
|
79
|
+
last = self._read_status()
|
|
80
|
+
if last["sta"] == _STA_ACTIVE and last["fault"] == 0:
|
|
81
|
+
return
|
|
82
|
+
time.sleep(0.2)
|
|
83
|
+
raise TimeoutError(f"gripper activation did not complete; last status={last}")
|
|
84
|
+
|
|
85
|
+
def _goto(self, pos: int, wait: bool = False, should_stop=None) -> None:
|
|
86
|
+
with self._cond:
|
|
87
|
+
if wait:
|
|
88
|
+
self._motion_abort.clear()
|
|
89
|
+
self._target = None
|
|
90
|
+
if should_stop is not None and should_stop():
|
|
91
|
+
raise RuntimeError("gripper_move_cancelled")
|
|
92
|
+
self._cmd_goto(pos, self.cfg.speed, self.cfg.force)
|
|
93
|
+
if not wait:
|
|
94
|
+
return
|
|
95
|
+
deadline = time.monotonic() + 5.0
|
|
96
|
+
while time.monotonic() < deadline:
|
|
97
|
+
if self._motion_abort.is_set() or (should_stop is not None and should_stop()):
|
|
98
|
+
self.hold()
|
|
99
|
+
raise RuntimeError("gripper_move_cancelled")
|
|
100
|
+
if self._read_status()["obj"] != 0: # reached the target or gripped something
|
|
101
|
+
return
|
|
102
|
+
time.sleep(0.05)
|
|
103
|
+
self.hold()
|
|
104
|
+
raise TimeoutError(f"gripper did not reach {pos} within 5s")
|
|
105
|
+
|
|
106
|
+
# -- Gripper -----------------------------------------------------------
|
|
107
|
+
@property
|
|
108
|
+
def live(self) -> bool:
|
|
109
|
+
return self._connected and self._fails < 3
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def last_error(self) -> str | None:
|
|
113
|
+
return self._error
|
|
114
|
+
|
|
115
|
+
def connect(self) -> None:
|
|
116
|
+
self._open()
|
|
117
|
+
self._connected = True
|
|
118
|
+
status = self._read_status()
|
|
119
|
+
if status["sta"] == _STA_ACTIVE and status["fault"] == 0x09:
|
|
120
|
+
# An idle-link watchdog can clear on resumed reads; avoid an unnecessary
|
|
121
|
+
# activation sweep before giving the already-active gripper another poll.
|
|
122
|
+
time.sleep(0.2)
|
|
123
|
+
status = self._read_status()
|
|
124
|
+
if status["sta"] != _STA_ACTIVE or status["fault"] != 0:
|
|
125
|
+
logger.info("gripper inactive/faulted (%s); activating", status)
|
|
126
|
+
self.activate()
|
|
127
|
+
self._last_raw = self._read_status()["pos"]
|
|
128
|
+
self._stop = False
|
|
129
|
+
self._thread = threading.Thread(target=self._run, name="robotiq", daemon=True)
|
|
130
|
+
self._thread.start()
|
|
131
|
+
|
|
132
|
+
def disconnect(self) -> None:
|
|
133
|
+
self._motion_abort.set()
|
|
134
|
+
with self._cond:
|
|
135
|
+
self._stop = True
|
|
136
|
+
self._target = self._last_sent = None
|
|
137
|
+
self._cond.notify()
|
|
138
|
+
if self._thread is not None:
|
|
139
|
+
self._thread.join(timeout=2.0)
|
|
140
|
+
self._thread = None
|
|
141
|
+
if self._connected:
|
|
142
|
+
try:
|
|
143
|
+
self._cmd_activate() # drop GTO: never leave the gripper chasing a target
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
try:
|
|
147
|
+
self._close()
|
|
148
|
+
finally:
|
|
149
|
+
self._connected = False
|
|
150
|
+
|
|
151
|
+
def get_ratio(self) -> float:
|
|
152
|
+
return ratio_from_raw(self._last_raw)
|
|
153
|
+
|
|
154
|
+
def set_ratio(self, ratio: float) -> None:
|
|
155
|
+
with self._cond:
|
|
156
|
+
self._target = raw_from_ratio(ratio)
|
|
157
|
+
self._cond.notify()
|
|
158
|
+
|
|
159
|
+
def hold(self) -> None:
|
|
160
|
+
"""Cancel queued and active GTO without releasing a held object."""
|
|
161
|
+
self._motion_abort.set()
|
|
162
|
+
with self._cond:
|
|
163
|
+
self._target = self._last_sent = None
|
|
164
|
+
if self._connected:
|
|
165
|
+
self._cmd_activate()
|
|
166
|
+
|
|
167
|
+
def move_blocking(self, ratio: float, should_stop=None) -> None:
|
|
168
|
+
target = raw_from_ratio(ratio)
|
|
169
|
+
self._goto(target, wait=True, should_stop=should_stop)
|
|
170
|
+
raw = self._read_status()["pos"]
|
|
171
|
+
with self._cond:
|
|
172
|
+
if self._motion_abort.is_set() or (should_stop is not None and should_stop()):
|
|
173
|
+
raise RuntimeError("gripper_move_cancelled")
|
|
174
|
+
self._last_sent = target
|
|
175
|
+
self._last_raw = raw
|
|
176
|
+
|
|
177
|
+
def recover(self) -> None:
|
|
178
|
+
"""Reopen the connection and activate the gripper if needed."""
|
|
179
|
+
try:
|
|
180
|
+
self.disconnect()
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
logger.warning("gripper disconnect during recover failed: %s", exc)
|
|
183
|
+
self._ok()
|
|
184
|
+
self.connect()
|
|
185
|
+
|
|
186
|
+
def _run(self) -> None:
|
|
187
|
+
"""Event-driven send of the newest target only, plus a deadline-paced status refresh.
|
|
188
|
+
No unconditional sleep, so a notify can never be lost behind one."""
|
|
189
|
+
next_status = time.monotonic()
|
|
190
|
+
while True:
|
|
191
|
+
with self._cond:
|
|
192
|
+
while not self._stop and self._target is None and time.monotonic() < next_status:
|
|
193
|
+
self._cond.wait(timeout=max(0.0, next_status - time.monotonic()))
|
|
194
|
+
if self._stop:
|
|
195
|
+
return
|
|
196
|
+
target, self._target = self._target, None
|
|
197
|
+
if target is not None and target != self._last_sent:
|
|
198
|
+
try:
|
|
199
|
+
self._goto(target)
|
|
200
|
+
self._last_sent = target
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.warning("gripper goto(%s) failed: %s", target, exc)
|
|
203
|
+
now = time.monotonic()
|
|
204
|
+
if now >= next_status:
|
|
205
|
+
try:
|
|
206
|
+
self._last_raw = self._read_status()["pos"]
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.debug("gripper status read failed: %s", exc)
|
|
209
|
+
next_status = now + 1.0 / self.cfg.rate_hz
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class RobotiqModbus(_RobotiqBase):
|
|
213
|
+
"""Modbus RTU over pyserial. Framing, CRC, the inter-frame gap and exception decoding are
|
|
214
|
+
minimalmodbus's job; only the retry policy stays ours, because RS-485 noise is normal and
|
|
215
|
+
the library gives up on the first bad frame."""
|
|
216
|
+
|
|
217
|
+
_WRITE_REG = 0x03E8 # 1000
|
|
218
|
+
_READ_REG = 0x07D0 # 2000
|
|
219
|
+
|
|
220
|
+
def __init__(self, cfg) -> None:
|
|
221
|
+
super().__init__(cfg)
|
|
222
|
+
|
|
223
|
+
def _open(self) -> None:
|
|
224
|
+
import minimalmodbus
|
|
225
|
+
import serial
|
|
226
|
+
|
|
227
|
+
inst = minimalmodbus.Instrument(self.cfg.port, self.cfg.slave_id, mode=minimalmodbus.MODE_RTU)
|
|
228
|
+
inst.serial.baudrate = self.cfg.baudrate
|
|
229
|
+
inst.serial.bytesize = serial.EIGHTBITS
|
|
230
|
+
inst.serial.parity = serial.PARITY_NONE
|
|
231
|
+
inst.serial.stopbits = serial.STOPBITS_ONE
|
|
232
|
+
inst.serial.timeout = self.cfg.timeout_s
|
|
233
|
+
inst.clear_buffers_before_each_transaction = True
|
|
234
|
+
inst.close_port_after_each_call = False
|
|
235
|
+
self._inst = inst
|
|
236
|
+
|
|
237
|
+
def _close(self) -> None:
|
|
238
|
+
self._inst.serial.close()
|
|
239
|
+
|
|
240
|
+
def _retry(self, fn, *args):
|
|
241
|
+
# minimalmodbus owns framing, but not cross-thread serialization. Keep the whole
|
|
242
|
+
# transaction (including buffer recovery and retry) atomic so the status poller and
|
|
243
|
+
# a blocking reset cannot consume bytes from each other's RTU response.
|
|
244
|
+
with self._io_lock:
|
|
245
|
+
last = None
|
|
246
|
+
for _ in range(2):
|
|
247
|
+
try:
|
|
248
|
+
out = fn(*args)
|
|
249
|
+
self._ok()
|
|
250
|
+
return out
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
last = exc
|
|
253
|
+
try:
|
|
254
|
+
self._inst.serial.reset_input_buffer()
|
|
255
|
+
self._inst.serial.reset_output_buffer()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
self._fail(f"modbus: {last}")
|
|
259
|
+
raise last
|
|
260
|
+
|
|
261
|
+
def _write(self, action: int, pos: int = 0, speed: int = 0, force: int = 0) -> None:
|
|
262
|
+
# Robotiq's 6-byte output block as three big-endian registers.
|
|
263
|
+
self._retry(self._inst.write_registers, self._WRITE_REG,
|
|
264
|
+
[(action << 8), pos, (speed << 8) | force])
|
|
265
|
+
|
|
266
|
+
def _cmd_reset(self) -> None: self._write(0x00)
|
|
267
|
+
def _cmd_activate(self) -> None: self._write(0x01) # rACT, GTO clear
|
|
268
|
+
def _cmd_goto(self, pos, speed, force) -> None: self._write(0x09, pos, speed, force)
|
|
269
|
+
|
|
270
|
+
def _read_status(self) -> dict:
|
|
271
|
+
# One read returns the whole status block -- unlike the URCap socket, which needs a
|
|
272
|
+
# round trip per variable.
|
|
273
|
+
regs = self._retry(self._inst.read_registers, self._READ_REG, 3, 4)
|
|
274
|
+
status, fault, pos = regs[0] >> 8, regs[1] >> 8, regs[2] >> 8
|
|
275
|
+
return {"sta": (status >> 4) & 0x03, "obj": (status >> 6) & 0x03, "pos": pos, "fault": fault}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class RobotiqURCap(_RobotiqBase):
|
|
279
|
+
"""The URCap socket on TCP 63352, served by the UR controller the gripper hangs off.
|
|
280
|
+
|
|
281
|
+
Text protocol: ``SET k v ...`` -> ack, ``GET k`` -> "k value". Status costs one round trip
|
|
282
|
+
per variable, so the whole block is read in one place and cached like the Modbus one.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def _open(self) -> None:
|
|
286
|
+
self._sock = socket.create_connection((self.cfg.ip, self.cfg.tcp_port), self.cfg.timeout_s)
|
|
287
|
+
self._sock.settimeout(self.cfg.timeout_s)
|
|
288
|
+
|
|
289
|
+
def _close(self) -> None:
|
|
290
|
+
self._sock.close()
|
|
291
|
+
|
|
292
|
+
def _txn(self, line: str) -> str:
|
|
293
|
+
try:
|
|
294
|
+
with self._io_lock:
|
|
295
|
+
deadline = time.monotonic() + self.cfg.timeout_s
|
|
296
|
+
self._sock.settimeout(self.cfg.timeout_s)
|
|
297
|
+
self._sock.sendall((line + "\n").encode())
|
|
298
|
+
self._sock.settimeout(max(0.001, deadline - time.monotonic()))
|
|
299
|
+
reply = bytearray(self._sock.recv(1024))
|
|
300
|
+
if not reply:
|
|
301
|
+
raise ConnectionError("URCap closed the connection")
|
|
302
|
+
# URCap replies need not end in newline. A read-only STA query gives
|
|
303
|
+
# numeric replies an unambiguous boundary, including split POS digits.
|
|
304
|
+
remaining = deadline - time.monotonic()
|
|
305
|
+
if remaining <= 0:
|
|
306
|
+
raise TimeoutError("URCap reply deadline exceeded")
|
|
307
|
+
self._sock.settimeout(remaining)
|
|
308
|
+
self._sock.sendall(b"GET STA\n")
|
|
309
|
+
primary = "ack" if line.startswith("SET ") else re.escape(line.split()[1]) + r" [0-9]{1,3}"
|
|
310
|
+
pattern = re.compile(r"\s*(" + primary + r")\s*STA [0-3]\s*")
|
|
311
|
+
while True:
|
|
312
|
+
match = pattern.fullmatch(reply.decode("ascii"))
|
|
313
|
+
if match:
|
|
314
|
+
resp = match.group(1)
|
|
315
|
+
break
|
|
316
|
+
remaining = deadline - time.monotonic()
|
|
317
|
+
if remaining <= 0 or len(reply) > 1024:
|
|
318
|
+
raise TimeoutError("incomplete or invalid URCap reply")
|
|
319
|
+
self._sock.settimeout(remaining)
|
|
320
|
+
chunk = self._sock.recv(1024)
|
|
321
|
+
if not chunk:
|
|
322
|
+
raise ConnectionError("URCap closed the connection")
|
|
323
|
+
reply.extend(chunk)
|
|
324
|
+
self._ok()
|
|
325
|
+
return resp
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
self._sock.close() # partial replies must never belong to a later transaction
|
|
328
|
+
self._connected = False
|
|
329
|
+
self._fail(f"urcap: {exc}")
|
|
330
|
+
raise
|
|
331
|
+
|
|
332
|
+
def _set(self, **kv: int) -> None:
|
|
333
|
+
self._txn("SET" + "".join(f" {k} {v}" for k, v in kv.items()))
|
|
334
|
+
|
|
335
|
+
def _get(self, var: str) -> int:
|
|
336
|
+
value = int(self._txn(f"GET {var}").split()[-1])
|
|
337
|
+
if not 0 <= value <= (3 if var in {"STA", "OBJ"} else 255):
|
|
338
|
+
self._fail("invalid URCap " + var + " value")
|
|
339
|
+
self._sock.close()
|
|
340
|
+
self._connected = False
|
|
341
|
+
raise ValueError(self.last_error)
|
|
342
|
+
return value
|
|
343
|
+
|
|
344
|
+
def _cmd_reset(self) -> None: self._set(ACT=0, ATR=0)
|
|
345
|
+
def _cmd_activate(self) -> None: self._set(ACT=1, GTO=0)
|
|
346
|
+
def _cmd_goto(self, pos, speed, force) -> None: self._set(POS=pos, SPE=speed, FOR=force, GTO=1)
|
|
347
|
+
|
|
348
|
+
def _read_status(self) -> dict:
|
|
349
|
+
# This protocol exposes no fault variable, so activation is driven off STA alone.
|
|
350
|
+
return {"sta": self._get("STA"), "obj": self._get("OBJ"), "pos": self._get("POS"), "fault": 0}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""UR arm assembly by controller generation."""
|
|
2
|
+
|
|
3
|
+
from .settings import CB3ArmConfig, ESeriesArmConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_arm(cfg):
|
|
7
|
+
if isinstance(cfg, CB3ArmConfig):
|
|
8
|
+
from .position import PositionArm
|
|
9
|
+
return PositionArm(cfg)
|
|
10
|
+
if isinstance(cfg, ESeriesArmConfig):
|
|
11
|
+
from .compliant import CompliantArm
|
|
12
|
+
return CompliantArm(cfg)
|
|
13
|
+
raise ValueError("UR requires a CB3 or e-Series configuration")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["create_arm"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Build the locked UR e-Series control runtime, entirely in the supplied directories."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import subprocess
|
|
8
|
+
import tarfile
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
import tyro
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(*args):
|
|
15
|
+
subprocess.run(args, check=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def source(name, record, root, archives):
|
|
19
|
+
"""Use the immutable GitHub commit tarball; record/check every source byte."""
|
|
20
|
+
archives.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
archive = archives / (name + ".tar.gz")
|
|
22
|
+
repository = record["repository"].removeprefix("https://github.com/").removesuffix(".git")
|
|
23
|
+
url = "https://api.github.com/repos/" + repository + "/tarball/" + record["ref"]
|
|
24
|
+
if not archive.exists():
|
|
25
|
+
request = urllib.request.Request(url, headers={"User-Agent": "roboapi-native-build"})
|
|
26
|
+
with urllib.request.urlopen(request) as response:
|
|
27
|
+
data = response.read()
|
|
28
|
+
archive.write_bytes(data)
|
|
29
|
+
path = root / (name + "-" + record["ref"] + "-archive")
|
|
30
|
+
hashes = {}
|
|
31
|
+
with tarfile.open(archive, "r:gz") as bundle:
|
|
32
|
+
for entry in bundle:
|
|
33
|
+
parts = PurePosixPath(entry.name).parts
|
|
34
|
+
if parts and not parts[0].endswith(record["ref"][:7]):
|
|
35
|
+
raise ValueError(name + " tarball is not for the locked GitHub commit")
|
|
36
|
+
if len(parts) < 2 or not entry.isfile():
|
|
37
|
+
continue
|
|
38
|
+
relative = PurePosixPath(*parts[1:])
|
|
39
|
+
if ".." in relative.parts or relative.is_absolute():
|
|
40
|
+
raise ValueError("invalid dependency archive path")
|
|
41
|
+
target = path.joinpath(*relative.parts)
|
|
42
|
+
data = bundle.extractfile(entry).read()
|
|
43
|
+
if target.is_symlink() or (target.exists() and target.read_bytes() != data):
|
|
44
|
+
raise ValueError(name + " source is modified: " + str(relative))
|
|
45
|
+
if not target.exists():
|
|
46
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
target.write_bytes(data)
|
|
48
|
+
target.chmod(entry.mode & 0o777)
|
|
49
|
+
hashes[str(relative)] = hashlib.sha256(data).hexdigest()
|
|
50
|
+
(root / (name + "-source.json")).write_text(json.dumps({
|
|
51
|
+
"ref": record["ref"], "url": url, "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
|
|
52
|
+
"files": hashes,
|
|
53
|
+
}, indent=2) + "\n")
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_runtime(build_dir: Path, install_dir: Path | None = None,
|
|
58
|
+
archive_dir: Path | None = None, jobs: int = 4) -> None:
|
|
59
|
+
"""Build and install the locked UR e-Series control runtime.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
build_dir: Build directory for sources, temporary files and CMake output.
|
|
63
|
+
install_dir: Installation prefix; defaults to BUILD_DIR/install.
|
|
64
|
+
archive_dir: Already downloaded locked URCL/Ruckig tarballs; defaults to BUILD_DIR/archives.
|
|
65
|
+
jobs: Number of parallel build jobs.
|
|
66
|
+
"""
|
|
67
|
+
native = Path(__file__).with_name("native")
|
|
68
|
+
build = build_dir.resolve()
|
|
69
|
+
scratch = build / "tmp"
|
|
70
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
os.environ["TMPDIR"] = str(scratch)
|
|
72
|
+
install = install_dir.resolve() if install_dir else build / "install"
|
|
73
|
+
sources = build / "sources"
|
|
74
|
+
sources.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
lock = json.loads((native / "dependencies.lock.json").read_text())
|
|
76
|
+
archives = archive_dir.resolve() if archive_dir else build / "archives"
|
|
77
|
+
deps = {name: source(name, lock[name], sources, archives) for name in ("urcl", "ruckig")}
|
|
78
|
+
run("cmake", "-S", str(native), "-B", str(build / "cmake"),
|
|
79
|
+
"-DCMAKE_BUILD_TYPE=Release", "-DBUILD_TESTING=OFF",
|
|
80
|
+
"-DCMAKE_EXPORT_NO_PACKAGE_REGISTRY=ON",
|
|
81
|
+
"-DURCL_SOURCE_DIR=" + str(deps["urcl"]), "-DRUCKIG_SOURCE_DIR=" + str(deps["ruckig"]),
|
|
82
|
+
"-DCMAKE_INSTALL_PREFIX=" + str(install))
|
|
83
|
+
run("cmake", "--build", str(build / "cmake"), "--parallel", str(jobs))
|
|
84
|
+
subprocess.run(["ctest", "--output-on-failure"], cwd=build / "cmake", check=True)
|
|
85
|
+
run("cmake", "--install", str(build / "cmake"))
|
|
86
|
+
print("Native UR executable:", install / "bin/roboapi-ur-loop")
|
|
87
|
+
print("Matching URScript:", install / "share/roboapi-ur-loop/external_control.urscript")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
tyro.cli(build_runtime, prog="python -m roboapi.hardware.ur.build",
|
|
92
|
+
config=(tyro.conf.DisallowNone,))
|