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,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,))