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.
Files changed (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. 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
+ }