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,128 @@
|
|
|
1
|
+
"""UR e-Series control using the native 500 Hz Cartesian admittance loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ... import geometry as geo
|
|
10
|
+
from ...errors import ControlNotReady
|
|
11
|
+
from .dashboard import Dashboard
|
|
12
|
+
from .feedback import URFeedbackReader
|
|
13
|
+
from .ipc import NativeLoop
|
|
14
|
+
|
|
15
|
+
class CompliantArm:
|
|
16
|
+
def __init__(self, cfg):
|
|
17
|
+
self.cfg = cfg
|
|
18
|
+
self._native = None
|
|
19
|
+
self._reader = None
|
|
20
|
+
self._error = None
|
|
21
|
+
self._dashboard = Dashboard(cfg.ip)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def live(self):
|
|
25
|
+
return bool(self._native and self._native.live)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def last_error(self):
|
|
29
|
+
return self._error or (self._native.last_error if self._native else None)
|
|
30
|
+
|
|
31
|
+
def connect(self):
|
|
32
|
+
self.disconnect()
|
|
33
|
+
self._error = None
|
|
34
|
+
try:
|
|
35
|
+
if self._dashboard.version()[0] not in (5, 10):
|
|
36
|
+
raise ValueError("e-Series compliant control requires a PolyScope 5 or 10 controller")
|
|
37
|
+
self.check_motion_ready()
|
|
38
|
+
# Check the installation TCP independently of the control RTDE recipe.
|
|
39
|
+
self._reader = URFeedbackReader(self.cfg.ip)
|
|
40
|
+
self._reader.connect()
|
|
41
|
+
self._native = NativeLoop(self.cfg)
|
|
42
|
+
self._native.start()
|
|
43
|
+
# Start holding the measured pose before accepting a target.
|
|
44
|
+
self._native.request(b"C", self._native.snapshot()["tcp"])
|
|
45
|
+
except BaseException:
|
|
46
|
+
self.disconnect()
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
def disconnect(self):
|
|
50
|
+
if self._native is not None:
|
|
51
|
+
self._native.close()
|
|
52
|
+
if self._reader is not None:
|
|
53
|
+
self._reader.disconnect()
|
|
54
|
+
self._native = self._reader = None
|
|
55
|
+
|
|
56
|
+
def _state(self):
|
|
57
|
+
if not self.live:
|
|
58
|
+
raise RuntimeError("not_alive: " + str(self.last_error or "UR control disconnected"))
|
|
59
|
+
return self._native.snapshot()
|
|
60
|
+
|
|
61
|
+
def get_joints(self):
|
|
62
|
+
return np.asarray(self._state()["q"])
|
|
63
|
+
|
|
64
|
+
def get_eef_pose(self):
|
|
65
|
+
return geo.rotvec6_to_pose7(np.asarray(self._state()["tcp"]))
|
|
66
|
+
|
|
67
|
+
def get_wrench(self):
|
|
68
|
+
return np.asarray(self._state()["wrench"])
|
|
69
|
+
|
|
70
|
+
def get_tcp_speed(self):
|
|
71
|
+
return np.asarray(self._state()["tcp_speed"])
|
|
72
|
+
|
|
73
|
+
def get_tcp_offset(self):
|
|
74
|
+
if self._reader is None:
|
|
75
|
+
raise RuntimeError("UR read-only feedback disconnected")
|
|
76
|
+
return np.asarray(self._reader.snapshot()["tcp_offset"])
|
|
77
|
+
|
|
78
|
+
def check_motion_ready(self):
|
|
79
|
+
self._dashboard.require_remote()
|
|
80
|
+
|
|
81
|
+
def servo_pose(self, pose7, twist=None, *, position_only=False):
|
|
82
|
+
del twist
|
|
83
|
+
self._state()
|
|
84
|
+
self._native.request(b"C" if position_only else b"P", geo.pose7_to_rotvec6(np.asarray(pose7)))
|
|
85
|
+
|
|
86
|
+
def stop_motion(self):
|
|
87
|
+
if self._native is not None:
|
|
88
|
+
self._native.request(b"H")
|
|
89
|
+
|
|
90
|
+
def hold(self):
|
|
91
|
+
self.stop_motion()
|
|
92
|
+
|
|
93
|
+
def zero_ft(self):
|
|
94
|
+
"""Request sensor zeroing once, without an extra settling wait."""
|
|
95
|
+
self._state()
|
|
96
|
+
self._native.request(b"Z")
|
|
97
|
+
|
|
98
|
+
def control_status(self):
|
|
99
|
+
return self._native.status() if self._native else {
|
|
100
|
+
"backend": "ur_eseries", "live": False, "mode": "DISCONNECTED",
|
|
101
|
+
"admittance_active": False, "configured_hz": self.cfg.fastloop_freq,
|
|
102
|
+
"last_error": self.last_error,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def recover(self, should_stop=None):
|
|
106
|
+
deadline = time.monotonic() + 30.0
|
|
107
|
+
detail = "dashboard_error"
|
|
108
|
+
while time.monotonic() < deadline:
|
|
109
|
+
if should_stop is not None and should_stop():
|
|
110
|
+
raise RuntimeError("UR recovery cancelled")
|
|
111
|
+
ready, detail = self._dashboard.step(should_stop=should_stop)
|
|
112
|
+
if detail == "motion_interrupted":
|
|
113
|
+
raise RuntimeError("UR recovery cancelled")
|
|
114
|
+
if ready:
|
|
115
|
+
break
|
|
116
|
+
if detail == "remote_control_required":
|
|
117
|
+
raise ControlNotReady("Switch PolyScope to Remote, then retry.")
|
|
118
|
+
if detail.startswith(("external_reset_required", "remote_control_required",
|
|
119
|
+
"confirm_safety_required", "unsupported_")):
|
|
120
|
+
self._error = detail
|
|
121
|
+
raise RuntimeError(detail + ": a person has to clear this at the robot")
|
|
122
|
+
time.sleep(2.0)
|
|
123
|
+
else:
|
|
124
|
+
self._error = detail
|
|
125
|
+
raise RuntimeError("recover timed out: " + detail)
|
|
126
|
+
if should_stop is not None and should_stop():
|
|
127
|
+
raise RuntimeError("UR recovery cancelled")
|
|
128
|
+
self.connect()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Shared UR Dashboard queries and controller recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
from ...errors import ControlNotReady
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dashboard_query(ip: str, command: str) -> str:
|
|
12
|
+
"""Read one Dashboard response; never issue controller state changes."""
|
|
13
|
+
if command not in {"get serial number", "is in remote control", "safetystatus", "running"}:
|
|
14
|
+
raise ValueError("only read-only Dashboard queries are allowed")
|
|
15
|
+
with socket.create_connection((ip, 29999), timeout=2) as dashboard:
|
|
16
|
+
with dashboard.makefile("rwb") as stream:
|
|
17
|
+
stream.readline(1024)
|
|
18
|
+
stream.write((command + "\n").encode())
|
|
19
|
+
stream.flush()
|
|
20
|
+
return stream.readline(1024).decode().strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Dashboard:
|
|
24
|
+
"""Controller version and explicit recovery through the Dashboard server."""
|
|
25
|
+
|
|
26
|
+
_EXTERNAL = {"RECOVERY", "SAFEGUARD_STOP", "SYSTEM_EMERGENCY_STOP", "ROBOT_EMERGENCY_STOP",
|
|
27
|
+
"AUTOMATIC_MODE_SAFEGUARD_STOP", "SYSTEM_THREE_POSITION_ENABLING_STOP"}
|
|
28
|
+
|
|
29
|
+
def __init__(self, ip: str, timeout: float = 3.0) -> None:
|
|
30
|
+
self._ip, self._timeout = ip, timeout
|
|
31
|
+
|
|
32
|
+
def _ask(self, sock, cmd: str) -> str:
|
|
33
|
+
sock.sendall((cmd + "\n").encode())
|
|
34
|
+
return sock.recv(1024).decode(errors="replace").strip()
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def parse_version(text: str) -> tuple[int, int]:
|
|
38
|
+
match = re.search(r"\b(\d+)\.(\d+)\.", text)
|
|
39
|
+
if match is None:
|
|
40
|
+
raise ValueError(f"unsupported controller version: {text}")
|
|
41
|
+
return int(match[1]), int(match[2])
|
|
42
|
+
|
|
43
|
+
def version(self) -> tuple[int, int]:
|
|
44
|
+
with socket.create_connection((self._ip, 29999), timeout=self._timeout) as sock:
|
|
45
|
+
sock.settimeout(self._timeout)
|
|
46
|
+
sock.recv(1024)
|
|
47
|
+
return self.parse_version(self._ask(sock, "PolyscopeVersion"))
|
|
48
|
+
|
|
49
|
+
def require_remote(self) -> None:
|
|
50
|
+
remote = dashboard_query(self._ip, "is in remote control").lower()
|
|
51
|
+
if remote == "false":
|
|
52
|
+
raise ControlNotReady("Switch PolyScope to Remote, then retry.")
|
|
53
|
+
if remote != "true":
|
|
54
|
+
raise RuntimeError("Could not read the PolyScope Remote control status.")
|
|
55
|
+
|
|
56
|
+
def step(self, should_stop=None) -> tuple[bool, str]:
|
|
57
|
+
"""One stage per call, non-blocking. (ready, detail)."""
|
|
58
|
+
try:
|
|
59
|
+
with socket.create_connection((self._ip, 29999), timeout=self._timeout) as sock:
|
|
60
|
+
sock.settimeout(self._timeout)
|
|
61
|
+
sock.recv(1024) # greeting
|
|
62
|
+
version = self.parse_version(self._ask(sock, "PolyscopeVersion"))
|
|
63
|
+
if version[0] not in (3, 5, 10):
|
|
64
|
+
return False, "unsupported_controller_version"
|
|
65
|
+
if version[0] in (5, 10) and "true" not in self._ask(sock, "is in remote control").lower():
|
|
66
|
+
return False, "remote_control_required"
|
|
67
|
+
if should_stop is not None and should_stop():
|
|
68
|
+
return False, "motion_interrupted"
|
|
69
|
+
# A URScript runtime error (servol past the arm's reach, say) kills the control
|
|
70
|
+
# program and leaves a popup on the pendant. The popup is not a safety state, so
|
|
71
|
+
# nothing below would clear it, and while it is up the program cannot be restarted.
|
|
72
|
+
self._ask(sock, "close popup")
|
|
73
|
+
safety = self._ask(sock, "safetystatus").split(":")[-1].strip().upper()
|
|
74
|
+
mode = self._ask(sock, "robotmode").split(":")[-1].strip().upper()
|
|
75
|
+
if should_stop is not None and should_stop():
|
|
76
|
+
return False, "motion_interrupted"
|
|
77
|
+
if safety in self._EXTERNAL:
|
|
78
|
+
return False, f"external_reset_required:{safety.lower()}"
|
|
79
|
+
if safety == "PROTECTIVE_STOP":
|
|
80
|
+
self._ask(sock, "unlock protective stop")
|
|
81
|
+
return False, "unlocking_protective_stop"
|
|
82
|
+
if safety in {"FAULT", "VIOLATION"}:
|
|
83
|
+
self._ask(sock, "close safety popup")
|
|
84
|
+
self._ask(sock, "restart safety")
|
|
85
|
+
return False, "restarting_safety"
|
|
86
|
+
if safety not in {"NORMAL", "REDUCED"}:
|
|
87
|
+
return False, f"unsupported_safety_status:{safety.lower()}"
|
|
88
|
+
if mode == "RUNNING":
|
|
89
|
+
return True, "dashboard_ready"
|
|
90
|
+
if mode == "POWER_OFF":
|
|
91
|
+
self._ask(sock, "close safety popup")
|
|
92
|
+
self._ask(sock, "power on")
|
|
93
|
+
return False, "powering_on"
|
|
94
|
+
if mode in {"POWER_ON", "IDLE"}:
|
|
95
|
+
self._ask(sock, "brake release")
|
|
96
|
+
return False, "releasing_brakes"
|
|
97
|
+
if mode == "BOOTING":
|
|
98
|
+
return False, "robot_booting"
|
|
99
|
+
if mode == "CONFIRM_SAFETY":
|
|
100
|
+
return False, "confirm_safety_required"
|
|
101
|
+
return False, f"unsupported_robot_mode:{mode.lower()}"
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
return False, f"dashboard_error:{exc or type(exc).__name__}"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""UR feedback from Primary read-only (30011), without control interfaces.
|
|
2
|
+
|
|
3
|
+
The 10 Hz stream contains measured joints, TCP pose, active TCP offset and robot
|
|
4
|
+
timestamps. A reader continuously drains it so idle sampling never leaves old
|
|
5
|
+
packets queued. No URScript, hold, recovery or gripper commands are sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import struct
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ... import geometry as geo
|
|
18
|
+
from .dashboard import dashboard_query
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_state(packet: bytes) -> dict | None:
|
|
22
|
+
"""Decode documented Robot State subpackages; ignore unrelated messages."""
|
|
23
|
+
if len(packet) < 5 or struct.unpack_from("!I", packet)[0] != len(packet):
|
|
24
|
+
raise ValueError("invalid UR packet length")
|
|
25
|
+
if packet[4] != 16:
|
|
26
|
+
return None
|
|
27
|
+
result = {}
|
|
28
|
+
offset = 5
|
|
29
|
+
while offset < len(packet):
|
|
30
|
+
if len(packet) - offset < 5:
|
|
31
|
+
raise ValueError("truncated UR subpackage")
|
|
32
|
+
size, kind = struct.unpack_from("!IB", packet, offset)
|
|
33
|
+
if size < 5 or offset + size > len(packet):
|
|
34
|
+
raise ValueError("invalid UR subpackage length")
|
|
35
|
+
body = packet[offset + 5:offset + size]
|
|
36
|
+
if kind == 0 and len(body) >= 8:
|
|
37
|
+
result["timestamp"] = struct.unpack_from("!Q", body)[0]
|
|
38
|
+
if len(body) >= 17:
|
|
39
|
+
values = struct.unpack_from("!7?BB", body, 8)
|
|
40
|
+
names = ("connected", "enabled", "powered", "emergency_stopped",
|
|
41
|
+
"protective_stopped", "program_running", "program_paused", "robot_mode", "control_mode")
|
|
42
|
+
result["robot_mode"] = dict(zip(names, values))
|
|
43
|
+
elif kind == 1 and len(body) >= 6 * 41:
|
|
44
|
+
result["joints"] = [struct.unpack_from("!d", body, i * 41)[0] for i in range(6)]
|
|
45
|
+
elif kind == 4 and len(body) >= 96:
|
|
46
|
+
values = struct.unpack_from("!12d", body)
|
|
47
|
+
result["pose"] = geo.rotvec6_to_pose7(np.asarray(values[:6])).tolist()
|
|
48
|
+
result["tcp_offset"] = list(values[6:])
|
|
49
|
+
offset += size
|
|
50
|
+
if not {"timestamp", "joints", "pose", "tcp_offset"} <= result.keys():
|
|
51
|
+
return None
|
|
52
|
+
if not all(np.all(np.isfinite(result[key])) for key in ("joints", "pose", "tcp_offset")):
|
|
53
|
+
raise ValueError("non-finite UR feedback")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class URFeedbackReader:
|
|
58
|
+
def __init__(self, ip: str):
|
|
59
|
+
self.ip = ip
|
|
60
|
+
self.serial = ""
|
|
61
|
+
self._socket = None
|
|
62
|
+
self._thread = None
|
|
63
|
+
self._stop = threading.Event()
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
self._state = None
|
|
66
|
+
self._updated = 0.0
|
|
67
|
+
self._error = None
|
|
68
|
+
|
|
69
|
+
def connect(self) -> None:
|
|
70
|
+
self.disconnect()
|
|
71
|
+
self._error = None
|
|
72
|
+
try:
|
|
73
|
+
serial = dashboard_query(self.ip, "get serial number")
|
|
74
|
+
if not serial.isdigit():
|
|
75
|
+
raise ValueError("Could not read the robot serial number.")
|
|
76
|
+
self.serial = serial
|
|
77
|
+
self._socket = socket.create_connection((self.ip, 30011), timeout=2)
|
|
78
|
+
self._socket.settimeout(1)
|
|
79
|
+
self._stop.clear()
|
|
80
|
+
self._thread = threading.Thread(target=self._read, name="ur-feedback", daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
deadline = time.monotonic() + 2
|
|
83
|
+
while not self.live:
|
|
84
|
+
if self._error or time.monotonic() >= deadline:
|
|
85
|
+
raise RuntimeError(self._error or "No live UR feedback has been received.")
|
|
86
|
+
time.sleep(0.02)
|
|
87
|
+
except Exception:
|
|
88
|
+
self.disconnect()
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
def _read(self) -> None:
|
|
92
|
+
buffer = bytearray()
|
|
93
|
+
try:
|
|
94
|
+
while not self._stop.is_set():
|
|
95
|
+
chunk = self._socket.recv(65536)
|
|
96
|
+
if not chunk:
|
|
97
|
+
raise RuntimeError("The read-only connection was closed.")
|
|
98
|
+
buffer.extend(chunk)
|
|
99
|
+
while len(buffer) >= 4:
|
|
100
|
+
size = struct.unpack_from("!I", buffer)[0]
|
|
101
|
+
if not 5 <= size <= 65536:
|
|
102
|
+
raise ValueError("invalid UR packet size")
|
|
103
|
+
if len(buffer) < size:
|
|
104
|
+
break
|
|
105
|
+
state = parse_state(bytes(buffer[:size]))
|
|
106
|
+
del buffer[:size]
|
|
107
|
+
if state is not None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
# Repeated robot timestamps never refresh the freshness clock.
|
|
110
|
+
if self._state is None or state["timestamp"] > self._state["timestamp"]:
|
|
111
|
+
self._state = state
|
|
112
|
+
self._updated = time.monotonic()
|
|
113
|
+
if len(buffer) > 65536:
|
|
114
|
+
raise ValueError("UR receive buffer exceeded limit")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
if not self._stop.is_set():
|
|
117
|
+
self._error = str(exc)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def live(self) -> bool:
|
|
121
|
+
with self._lock:
|
|
122
|
+
return (not self._stop.is_set() and self._state is not None and self._error is None
|
|
123
|
+
and time.monotonic() - self._updated <= 0.5)
|
|
124
|
+
|
|
125
|
+
def snapshot(self) -> dict:
|
|
126
|
+
with self._lock:
|
|
127
|
+
if self._state is None or self._error or time.monotonic() - self._updated > 0.5:
|
|
128
|
+
raise ValueError(self._error or "UR feedback is stale. Check the read-only connection.")
|
|
129
|
+
state = {key: value.copy() if isinstance(value, (list, dict)) else value for key, value in self._state.items()}
|
|
130
|
+
state["device"] = {"serial": self.serial, "tcp_offset": state["tcp_offset"].copy()}
|
|
131
|
+
self.validate_device(state["device"])
|
|
132
|
+
return state
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def last_error(self):
|
|
136
|
+
if self._stop.is_set():
|
|
137
|
+
return None
|
|
138
|
+
return self._error or ("UR read-only feedback is stale" if self._state is not None and not self.live else None)
|
|
139
|
+
|
|
140
|
+
def validate_device(self, device):
|
|
141
|
+
if set(device) != {"serial", "tcp_offset"} or not isinstance(device["serial"], str) or not device["serial"].isdigit():
|
|
142
|
+
raise ValueError("invalid UR calibration device information")
|
|
143
|
+
offset = np.asarray(device["tcp_offset"], dtype=float)
|
|
144
|
+
if offset.shape != (6,) or not np.all(np.isfinite(offset)) or np.max(np.abs(offset[:3])) > 1e-6:
|
|
145
|
+
raise ValueError("Select a flange-centre TCP with zero positional offset in PolyScope.")
|
|
146
|
+
|
|
147
|
+
def disconnect(self) -> None:
|
|
148
|
+
self._stop.set()
|
|
149
|
+
if self._socket is not None:
|
|
150
|
+
try:
|
|
151
|
+
self._socket.shutdown(socket.SHUT_RDWR)
|
|
152
|
+
except OSError:
|
|
153
|
+
pass
|
|
154
|
+
self._socket.close()
|
|
155
|
+
if self._thread is not None:
|
|
156
|
+
self._thread.join(timeout=2)
|
|
157
|
+
self._socket = self._thread = None
|
|
158
|
+
with self._lock:
|
|
159
|
+
self._state = None
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Process lifecycle and bounded IPC for the native e-Series controller."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
PARAMETERS = (
|
|
14
|
+
"fastloop_freq", "fastloop_rt_prio", "fastloop_mass",
|
|
15
|
+
"fastloop_stiffness", "fastloop_damping_ratio", "fastloop_hf_mass_ratio",
|
|
16
|
+
"fastloop_relaxation_hz", "fastloop_filter_tau", "fastloop_wrench_deadband",
|
|
17
|
+
"fastloop_wrench_notch", "fastloop_max_dt", "fastloop_max_vel", "fastloop_cmd_vel_limit",
|
|
18
|
+
"fastloop_cmd_acc_limit", "fastloop_pos_loop_kp", "fastloop_speedl_acc_limit",
|
|
19
|
+
"fastloop_speedl_jerk_limit",
|
|
20
|
+
)
|
|
21
|
+
MODES = {0: "IDLE", 1: "TRACK_POSE", 4: "SWITCHING", 5: "TRACK_POSITION"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def worker_command(cfg) -> list[str]:
|
|
25
|
+
binary = Path(cfg.fastloop_binary).expanduser().resolve()
|
|
26
|
+
script = Path(cfg.fastloop_script).expanduser().resolve()
|
|
27
|
+
command = [str(binary), "--ip", cfg.ip, "--script", str(script),
|
|
28
|
+
"--zero_ft_on_start", str(int(cfg.zero_ft_on_start))]
|
|
29
|
+
for name in PARAMETERS:
|
|
30
|
+
value = getattr(cfg, name)
|
|
31
|
+
if isinstance(value, bool):
|
|
32
|
+
value = int(value)
|
|
33
|
+
command += ["--" + name, ",".join(map(str, value)) if isinstance(value, list) else str(value)]
|
|
34
|
+
return command
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NativeLoop:
|
|
38
|
+
def __init__(self, cfg):
|
|
39
|
+
self.cfg = cfg
|
|
40
|
+
self.process = None
|
|
41
|
+
self.socket = None
|
|
42
|
+
self.error = None
|
|
43
|
+
self._rpc_lock = threading.Lock()
|
|
44
|
+
self._state_lock = threading.Lock()
|
|
45
|
+
self._state = {}
|
|
46
|
+
self._received_at = 0.0
|
|
47
|
+
self._stop = threading.Event()
|
|
48
|
+
self._poller = None
|
|
49
|
+
|
|
50
|
+
def _read(self, size: int, deadline: float) -> bytes:
|
|
51
|
+
result = bytearray()
|
|
52
|
+
while len(result) < size:
|
|
53
|
+
remaining = deadline - time.monotonic()
|
|
54
|
+
if remaining <= 0:
|
|
55
|
+
raise TimeoutError("UR native IPC deadline exceeded")
|
|
56
|
+
self.socket.settimeout(remaining)
|
|
57
|
+
chunk = self.socket.recv(size - len(result))
|
|
58
|
+
if not chunk:
|
|
59
|
+
raise RuntimeError("UR C++ control process closed IPC")
|
|
60
|
+
result.extend(chunk)
|
|
61
|
+
return bytes(result)
|
|
62
|
+
|
|
63
|
+
def _receive(self, deadline: float) -> dict:
|
|
64
|
+
values = struct.unpack("=43d", self._read(43 * 8, deadline))
|
|
65
|
+
error_size, = struct.unpack("=I", self._read(4, deadline))
|
|
66
|
+
if error_size > 65536 or not np.all(np.isfinite(values)):
|
|
67
|
+
raise RuntimeError("Invalid UR native state packet")
|
|
68
|
+
error = self._read(error_size, deadline).decode("utf-8", errors="replace")
|
|
69
|
+
state = {name: list(values[i * 6:(i + 1) * 6])
|
|
70
|
+
for i, name in enumerate(("q", "qd", "tcp", "tcp_speed", "wrench"))}
|
|
71
|
+
for i, name in enumerate(("mode", "fault", "safety_mode", "ticks", "dt_mean_ms",
|
|
72
|
+
"dt_std_ms", "dt_p99_ms", "dt_max_ms", "rtde_hz", "host_hz",
|
|
73
|
+
"program_running", "loop_alive", "loop_ok")):
|
|
74
|
+
state[name] = values[30 + i]
|
|
75
|
+
state["last_error"] = error
|
|
76
|
+
with self._state_lock:
|
|
77
|
+
advancing = not self._state or state["ticks"] > self._state.get("ticks", -1)
|
|
78
|
+
self._state = state
|
|
79
|
+
if advancing:
|
|
80
|
+
self._received_at = time.monotonic()
|
|
81
|
+
return state
|
|
82
|
+
|
|
83
|
+
def _abort_ipc(self, error):
|
|
84
|
+
self.error = str(error)
|
|
85
|
+
self._stop.set()
|
|
86
|
+
# EOF stops the worker even when a pending status request cannot answer STOP.
|
|
87
|
+
if self.socket is not None:
|
|
88
|
+
try:
|
|
89
|
+
self.socket.shutdown(socket.SHUT_RDWR)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def request(self, op: bytes, target=None) -> dict:
|
|
94
|
+
packet = op
|
|
95
|
+
if target is not None:
|
|
96
|
+
vector = np.asarray(target, dtype=np.float64)
|
|
97
|
+
if vector.shape != (6,) or not np.all(np.isfinite(vector)):
|
|
98
|
+
raise ValueError("UR native target must contain six finite values")
|
|
99
|
+
packet += struct.pack("=6d", *vector)
|
|
100
|
+
deadline = time.monotonic() + self.cfg.native_rpc_timeout_s
|
|
101
|
+
if not self._rpc_lock.acquire(timeout=self.cfg.native_rpc_timeout_s):
|
|
102
|
+
self._abort_ipc("UR native IPC lock deadline exceeded")
|
|
103
|
+
raise RuntimeError(self.error)
|
|
104
|
+
try:
|
|
105
|
+
if self.error or self.socket is None:
|
|
106
|
+
raise RuntimeError(self.error or "UR native IPC disconnected")
|
|
107
|
+
remaining = deadline - time.monotonic()
|
|
108
|
+
if remaining <= 0:
|
|
109
|
+
raise TimeoutError("UR native IPC deadline exceeded")
|
|
110
|
+
self.socket.settimeout(remaining)
|
|
111
|
+
self.socket.sendall(packet)
|
|
112
|
+
return self._receive(deadline)
|
|
113
|
+
except (OSError, RuntimeError) as exc:
|
|
114
|
+
self._abort_ipc(exc)
|
|
115
|
+
raise RuntimeError(self.error) from exc
|
|
116
|
+
finally:
|
|
117
|
+
self._rpc_lock.release()
|
|
118
|
+
|
|
119
|
+
def start(self):
|
|
120
|
+
command = worker_command(self.cfg)
|
|
121
|
+
for path in (command[0], command[4]):
|
|
122
|
+
if not Path(path).is_file():
|
|
123
|
+
raise FileNotFoundError("Build the pinned UR native runtime first: " + path)
|
|
124
|
+
log_dir = Path(self.cfg.fastloop_log_dir).expanduser().resolve()
|
|
125
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
self.socket, child = socket.socketpair()
|
|
127
|
+
try:
|
|
128
|
+
with (log_dir / "ur-cpp.log").open("ab") as log:
|
|
129
|
+
self.process = subprocess.Popen(
|
|
130
|
+
command + ["--ipc-fd", str(child.fileno())],
|
|
131
|
+
pass_fds=(child.fileno(),), stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
|
132
|
+
)
|
|
133
|
+
except BaseException:
|
|
134
|
+
self.close()
|
|
135
|
+
raise
|
|
136
|
+
finally:
|
|
137
|
+
child.close()
|
|
138
|
+
try:
|
|
139
|
+
state = self._receive(time.monotonic() + 15.0)
|
|
140
|
+
if not state["loop_alive"] or not state["loop_ok"]:
|
|
141
|
+
raise RuntimeError(state["last_error"] or "fastloop_start_failed")
|
|
142
|
+
self._stop.clear()
|
|
143
|
+
self._poller = threading.Thread(target=self._poll, name="ur-state", daemon=True)
|
|
144
|
+
self._poller.start()
|
|
145
|
+
except BaseException:
|
|
146
|
+
self.close()
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _poll(self):
|
|
150
|
+
while not self._stop.wait(1.0 / self.cfg.rate_hz):
|
|
151
|
+
try:
|
|
152
|
+
self.request(b"S")
|
|
153
|
+
except RuntimeError:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
def snapshot(self) -> dict:
|
|
157
|
+
with self._state_lock:
|
|
158
|
+
return {k: v.copy() if isinstance(v, list) else v for k, v in self._state.items()}
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def live(self) -> bool:
|
|
162
|
+
s = self.snapshot()
|
|
163
|
+
process = self.process
|
|
164
|
+
return bool(process and process.poll() is None and not self.error and self._fresh()
|
|
165
|
+
and s.get("loop_alive") and s.get("loop_ok") and s.get("program_running"))
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def last_error(self) -> str | None:
|
|
169
|
+
return self.error or self.snapshot().get("last_error") or (
|
|
170
|
+
"UR native feedback stale" if self._state and not self._fresh() else None)
|
|
171
|
+
|
|
172
|
+
def _fresh(self):
|
|
173
|
+
with self._state_lock:
|
|
174
|
+
return time.monotonic() - self._received_at <= self.cfg.native_state_stale_after_s
|
|
175
|
+
|
|
176
|
+
def close(self):
|
|
177
|
+
self._stop.set()
|
|
178
|
+
if self.socket is not None:
|
|
179
|
+
# EOF asks the worker to stop/join the control loop; it uploads no new program.
|
|
180
|
+
try:
|
|
181
|
+
self.socket.shutdown(socket.SHUT_RDWR)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
self.socket.close()
|
|
185
|
+
if self._poller is not None:
|
|
186
|
+
self._poller.join(timeout=3)
|
|
187
|
+
if self.process is not None:
|
|
188
|
+
try:
|
|
189
|
+
self.process.wait(timeout=3)
|
|
190
|
+
except subprocess.TimeoutExpired:
|
|
191
|
+
self.process.terminate()
|
|
192
|
+
try:
|
|
193
|
+
self.process.wait(timeout=3)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
self.process.kill()
|
|
196
|
+
self.process.wait()
|
|
197
|
+
self.socket = self.process = self._poller = None
|
|
198
|
+
|
|
199
|
+
def status(self) -> dict:
|
|
200
|
+
s = self.snapshot()
|
|
201
|
+
return {"backend": "ur_eseries", "live": self.live,
|
|
202
|
+
"mode": MODES.get(int(s.get("mode", 0)), "UNKNOWN"),
|
|
203
|
+
"admittance_active": self.live and s.get("mode") == 1,
|
|
204
|
+
"configured_hz": self.cfg.fastloop_freq,
|
|
205
|
+
"rtde_hz": s.get("rtde_hz"), "host_hz": s.get("host_hz"),
|
|
206
|
+
"dt_p99_ms": s.get("dt_p99_ms"), "last_error": self.last_error}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.16)
|
|
2
|
+
project(roboapi_ur_loop CXX)
|
|
3
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
4
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
5
|
+
find_package(Threads REQUIRED)
|
|
6
|
+
option(ROBOAPI_BUILD_CHECKS "Build the control-law checks" ON)
|
|
7
|
+
enable_testing()
|
|
8
|
+
if(NOT URCL_SOURCE_DIR OR NOT RUCKIG_SOURCE_DIR)
|
|
9
|
+
message(FATAL_ERROR "Provide the locked URCL and Ruckig source directories")
|
|
10
|
+
endif()
|
|
11
|
+
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
|
12
|
+
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
|
13
|
+
set(BUILD_PYTHON_MODULE OFF CACHE BOOL "" FORCE)
|
|
14
|
+
set(BUILD_CLOUD_CLIENT OFF CACHE BOOL "" FORCE)
|
|
15
|
+
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
|
16
|
+
add_subdirectory(${RUCKIG_SOURCE_DIR} ${CMAKE_BINARY_DIR}/ruckig EXCLUDE_FROM_ALL)
|
|
17
|
+
add_subdirectory(${URCL_SOURCE_DIR} ${CMAKE_BINARY_DIR}/urcl EXCLUDE_FROM_ALL)
|
|
18
|
+
add_executable(roboapi-ur-loop worker.cpp src/control_loop.cpp src/robot_io_urcl.cpp)
|
|
19
|
+
target_include_directories(roboapi-ur-loop PRIVATE include)
|
|
20
|
+
target_compile_options(roboapi-ur-loop PRIVATE -O2 -Wall -Wextra)
|
|
21
|
+
target_link_libraries(roboapi-ur-loop Threads::Threads ruckig::ruckig ur_client_library::urcl)
|
|
22
|
+
install(TARGETS roboapi-ur-loop DESTINATION bin)
|
|
23
|
+
install(FILES ${URCL_SOURCE_DIR}/resources/external_control.urscript DESTINATION share/roboapi-ur-loop)
|
|
24
|
+
if(ROBOAPI_BUILD_CHECKS)
|
|
25
|
+
foreach(check cartesian_speedl streaming_pose velocity_governor)
|
|
26
|
+
add_executable(check_${check} tests/${check}_unit.cpp)
|
|
27
|
+
target_include_directories(check_${check} PRIVATE include)
|
|
28
|
+
target_compile_options(check_${check} PRIVATE -UNDEBUG -Wall -Wextra)
|
|
29
|
+
target_link_libraries(check_${check} ruckig::ruckig)
|
|
30
|
+
add_test(NAME ${check} COMMAND check_${check})
|
|
31
|
+
endforeach()
|
|
32
|
+
add_executable(check_control_modes tests/control_modes_unit.cpp src/control_loop.cpp)
|
|
33
|
+
target_include_directories(check_control_modes PRIVATE include)
|
|
34
|
+
target_compile_options(check_control_modes PRIVATE -UNDEBUG -Wall -Wextra)
|
|
35
|
+
target_link_libraries(check_control_modes Threads::Threads ruckig::ruckig)
|
|
36
|
+
add_test(NAME control_modes COMMAND check_control_modes)
|
|
37
|
+
endif()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"urcl": {
|
|
4
|
+
"repository": "https://github.com/UniversalRobots/Universal_Robots_Client_Library.git",
|
|
5
|
+
"version": "2.14.1",
|
|
6
|
+
"ref": "a854de920d602300ce7c0fe3bd79655cb85bd678"
|
|
7
|
+
},
|
|
8
|
+
"ruckig": {
|
|
9
|
+
"repository": "https://github.com/pantor/ruckig.git",
|
|
10
|
+
"version": "0.15.3",
|
|
11
|
+
"ref": "37b6e7a4c60f5befd2506741d5f7d0ae7eefb3db"
|
|
12
|
+
}
|
|
13
|
+
}
|