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,555 @@
1
+ """Franka Desk sessions, supported controller protocols and end-effector configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ from contextlib import suppress
10
+ from pathlib import Path
11
+ from urllib.parse import quote
12
+
13
+ import numpy as np
14
+
15
+ from .settings import STANDARD_TOOL
16
+ from ...errors import ControlNotReady
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DeskLeaseError(RuntimeError):
22
+ pass
23
+
24
+
25
+ def ensure_programming(desk):
26
+ """Prepare native guiding or tool configuration using an owned Desk session."""
27
+ if desk.get_fci_status() == "Active":
28
+ desk.disable_fci()
29
+ if getattr(desk.operating_mode, "value", None) != "Programming":
30
+ if desk.api == "v1":
31
+ desk.send_control_api_request(
32
+ "/api/system/operating-mode:change",
33
+ content={"desiredOperatingMode": "Programming"},
34
+ )
35
+ else:
36
+ desk.set_mode_programming()
37
+ deadline = time.monotonic() + 10
38
+ while (getattr(desk.operating_mode, "value", None) != "Programming"
39
+ or desk.get_fci_status() == "Active"):
40
+ if time.monotonic() >= deadline:
41
+ raise ControlNotReady("Desk did not enter Programming. Check the robot state and retry.")
42
+ time.sleep(0.05)
43
+
44
+
45
+ class _DeskLease:
46
+ def __init__(
47
+ self, desk, *, timeout: float = 15.0, poll_period: float = 0.5,
48
+ release_attempts: int = 3, api: str | None = None,
49
+ ) -> None:
50
+ self.desk = desk
51
+ self.timeout = timeout
52
+ self.poll_period = poll_period
53
+ self.release_attempts = release_attempts
54
+ api = api or desk.api
55
+ if api not in ("v1", "panda4", "fr3-legacy"):
56
+ raise ValueError("Unsupported Desk API")
57
+ self.api = api
58
+ self.opened = False
59
+ self.acquired = False
60
+ self.fault: str | None = None
61
+ self._stop = threading.Event()
62
+ self._thread: threading.Thread | None = None
63
+
64
+ def _owner(self) -> str | None:
65
+ if self.api == "fr3-legacy":
66
+ active = self.desk._control_status()["activeToken"]
67
+ return None if active is None else str(active.get("ownedBy") or "unknown owner")
68
+ path = "/admin/api/control-token" if self.api == "panda4" else "/api/system/control-token"
69
+ response = self.desk.send_api_request(path, method="GET", response_encoding="json")
70
+ if not isinstance(response, dict):
71
+ raise DeskLeaseError("invalid Desk control-token response")
72
+ if self.api == "panda4":
73
+ if "activeToken" not in response:
74
+ raise DeskLeaseError("missing Panda control-token status")
75
+ active = response["activeToken"]
76
+ if active is None:
77
+ return None
78
+ if not isinstance(active, dict) or "id" not in active:
79
+ raise DeskLeaseError("invalid Panda control-token owner")
80
+ return str(active.get("ownedBy") or "unknown owner")
81
+ # The v1 API returns {} when control is available.
82
+ if not response:
83
+ return None
84
+ if "owner" not in response:
85
+ raise DeskLeaseError("missing Desk control-token owner")
86
+ owner = response.get("owner")
87
+ return str(owner) if owner else None
88
+
89
+ def acquire(self, *, prepare=None, activate=True) -> None:
90
+ try:
91
+ self.desk.open(timeout=self.timeout)
92
+ self.opened = True
93
+ owner = self._owner()
94
+ if owner is not None:
95
+ raise ControlNotReady(f"Release Desk control held by {owner!r}, then retry.")
96
+ self.desk.take_control(wait_timeout=self.timeout, force=False)
97
+ self.acquired = True
98
+ if prepare is not None:
99
+ prepare(self.desk)
100
+ if not activate:
101
+ return
102
+ self.desk.set_mode_execution()
103
+ if "UNLOCKED" not in str(self.desk.brake_state).upper():
104
+ self.desk.unlock_brakes()
105
+ self.desk.enable_fci()
106
+ deadline = time.monotonic() + self.timeout
107
+ while self.desk.get_fci_status() != "Active" and time.monotonic() < deadline:
108
+ time.sleep(0.05)
109
+ self.verify()
110
+ self._stop.clear()
111
+ self._thread = threading.Thread(target=self._monitor, name="franka-desk", daemon=True)
112
+ self._thread.start()
113
+ except Exception:
114
+ with suppress(Exception):
115
+ self.release()
116
+ raise
117
+
118
+ def verify(self) -> None:
119
+ if not self.acquired or not bool(self.desk.has_control):
120
+ raise DeskLeaseError("Desk lease lost control ownership")
121
+ status = str(self.desk.get_fci_status())
122
+ if status != "Active":
123
+ raise DeskLeaseError(f"FCI left Active state while leased: {status}")
124
+
125
+ def check(self) -> None:
126
+ if self.fault is not None:
127
+ raise DeskLeaseError(self.fault)
128
+
129
+ def _monitor(self) -> None:
130
+ while not self._stop.wait(self.poll_period):
131
+ try:
132
+ self.verify()
133
+ except Exception as exc:
134
+ self.fault = str(exc)
135
+ return
136
+
137
+ def release(self) -> None:
138
+ self._stop.set()
139
+ thread, self._thread = self._thread, None
140
+ if thread is not None:
141
+ thread.join(timeout=self.timeout + 1.0)
142
+ if thread.is_alive():
143
+ raise DeskLeaseError("Desk monitor did not stop; token was not released")
144
+ errors = []
145
+ if self.acquired:
146
+ for attempt in range(1, self.release_attempts + 1):
147
+ try:
148
+ if not self.opened or not bool(getattr(self.desk, "is_open", False)):
149
+ self.desk.open(timeout=self.timeout)
150
+ self.opened = True
151
+ if self._owner() is None:
152
+ self.acquired = False
153
+ break
154
+ except Exception as exc:
155
+ errors.append(f"attempt {attempt} owner check: {type(exc).__name__}: {exc}")
156
+ try:
157
+ self.desk.release_control()
158
+ except Exception as exc:
159
+ errors.append(f"attempt {attempt} release: {type(exc).__name__}: {exc}")
160
+ try:
161
+ if self._owner() is None:
162
+ self.acquired = False
163
+ break
164
+ except Exception as owner_exc:
165
+ errors.append(
166
+ f"attempt {attempt} recheck: {type(owner_exc).__name__}: {owner_exc}"
167
+ )
168
+ else:
169
+ self.acquired = False
170
+ try:
171
+ owner = self._owner()
172
+ except Exception as exc:
173
+ logger.warning("Desk owner verification after release failed: %s", exc)
174
+ else:
175
+ if owner is not None:
176
+ errors.append(f"Desk control remained owned by {owner!r} after release")
177
+ break
178
+ if attempt < self.release_attempts:
179
+ time.sleep(0.2)
180
+ if self.opened and not self.acquired:
181
+ self.desk.close()
182
+ self.opened = False
183
+ if self.acquired:
184
+ raise DeskLeaseError(
185
+ "Desk token release failed: " + ("; ".join(errors) or "owner remained set")
186
+ )
187
+
188
+
189
+ class _LegacyOwnership:
190
+ def __init__(self, *args, **kwargs):
191
+ super().__init__(*args, **kwargs)
192
+ self._pending_control: tuple[str, str] | None = None
193
+
194
+ def _clear_pending_control(self):
195
+ if self._pending_control is None:
196
+ return
197
+ token_id, token = self._pending_control
198
+ for attempt in range(3):
199
+ try:
200
+ status = self._control_status()
201
+ active, pending = status["activeToken"], status.get("tokenRequest")
202
+ if active is not None and str(active["id"]) == token_id:
203
+ self.send_api_request("/admin/api/control-token", method="DELETE", content={"token": token})
204
+ elif pending is not None and str(pending["id"]) == token_id:
205
+ self.send_api_request(
206
+ "/admin/api/control-token/request/" + token_id,
207
+ method="DELETE", content={"token": token},
208
+ )
209
+ self._pending_control = None
210
+ return
211
+ except Exception:
212
+ if attempt == 2:
213
+ raise
214
+ time.sleep(0.05)
215
+
216
+ def close(self):
217
+ # Keep the request credential and session if cleanup fails, so close can be retried.
218
+ self._clear_pending_control()
219
+ super().close()
220
+
221
+ def _get_has_control(self):
222
+ active = self._control_status()["activeToken"]
223
+ return active is not None and str(active["id"]) == self.control_token_id
224
+
225
+ def _take_control(self, wait_timeout=30.0, force=False):
226
+ if force:
227
+ raise self._sdk.DeskError("Forced Desk control takeover is not supported")
228
+ status = self._control_status()
229
+ if status["activeToken"] is not None or status.get("tokenRequest") is not None:
230
+ raise self._sdk.DeskError("Desk control is already owned or requested by another session")
231
+ response = self.send_api_request(
232
+ "/admin/api/control-token/request", content={"requestedBy": self.username}, response_encoding="json",
233
+ )
234
+ if not isinstance(response, dict) or "id" not in response or not isinstance(response.get("token"), str) or not response["token"]:
235
+ raise self._sdk.DeskError("Invalid Desk control request response; inspect Desk ownership")
236
+ token_id, token = str(response["id"]), response["token"]
237
+ if not token_id.isdecimal():
238
+ raise self._sdk.DeskError("Invalid Desk control request identity; inspect Desk ownership")
239
+ # franky adopts the token only after _take_control returns successfully.
240
+ self._pending_control = token_id, token
241
+ deadline = time.monotonic() + wait_timeout
242
+ try:
243
+ while True:
244
+ active = self._control_status()["activeToken"]
245
+ if active is not None and str(active["id"]) == token_id:
246
+ return token_id, token
247
+ if time.monotonic() >= deadline:
248
+ raise self._sdk.TakeControlTimeoutError("Desk did not grant this control request")
249
+ time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
250
+ except BaseException as error:
251
+ try:
252
+ self._clear_pending_control()
253
+ except Exception:
254
+ raise self._sdk.DeskError("Desk control cleanup is still pending; retry closing the session") from error
255
+ raise
256
+
257
+ def get_fci_status(self):
258
+ return "Active" if self._control_status()["fciActive"] else "Inactive"
259
+
260
+ def enable_fci(self):
261
+ self.send_control_api_request("/admin/api/control-token/fci", content={"token": self.control_token})
262
+
263
+ def disable_fci(self):
264
+ self.send_control_api_request(
265
+ "/admin/api/control-token/fci", method="DELETE", content={"token": self.control_token},
266
+ )
267
+
268
+
269
+ class _System4Endpoints(_LegacyOwnership):
270
+ api = "panda4"
271
+
272
+ def _control_status(self):
273
+ status = self.send_api_request("/admin/api/control-token", method="GET", response_encoding="json")
274
+ if not isinstance(status, dict) or "activeToken" not in status or "fciActive" not in status:
275
+ raise self._sdk.DeskError("Invalid Panda control-token status")
276
+ for field in ("activeToken", "tokenRequest"):
277
+ token = status.get(field)
278
+ if token is not None and (not isinstance(token, dict) or "id" not in token):
279
+ raise self._sdk.DeskError("Invalid Panda control-token identity")
280
+ if not isinstance(status["fciActive"], bool):
281
+ raise self._sdk.DeskError("Invalid Panda FCI status")
282
+ return status
283
+
284
+ def _get_system_status(self):
285
+ # System 4.x can label JSON responses as text; do not infer the parser from MIME.
286
+ return self.send_api_request("/desk/api/system/status", method="GET", response_encoding="json")
287
+
288
+ def _get_brake_state(self):
289
+ brakes = self._get_system_status().get("brakesOpen")
290
+ if not isinstance(brakes, list) or len(brakes) != 7 or any(type(b) is not bool for b in brakes):
291
+ raise self._sdk.DeskError("Invalid Panda brake state")
292
+ if all(brakes):
293
+ return self._sdk.BrakeState.UNLOCKED
294
+ if not any(brakes):
295
+ return self._sdk.BrakeState.LOCKED
296
+ raise self._sdk.DeskError("Panda joints have inconsistent brake states")
297
+
298
+ def _get_operating_mode(self):
299
+ mode = self._get_system_status().get("operationalMode")
300
+ if mode == "EXEC":
301
+ return self._sdk.OperatingMode.EXECUTION
302
+ if mode in ("SS1", "SS2"):
303
+ return self._sdk.OperatingMode.PROGRAMMING
304
+ raise self._sdk.DeskError("Unrecognized Panda operational mode")
305
+
306
+ def set_mode_execution(self):
307
+ # On 4.x activating FCI selects EXEC, after brakes are opened. There is no
308
+ # separate operating-mode route. Check the physical stop without changing it.
309
+ status = self._get_system_status()
310
+ if status.get("x3Enabled") is not True or status.get("operationalMode") not in ("SS1", "SS2", "EXEC"):
311
+ raise self._sdk.DeskError("Panda physical stop/mode prevents execution; inspect on site")
312
+
313
+ def unlock_brakes(self):
314
+ self.send_control_api_request("/desk/api/robot/open-brakes", content={"force": False})
315
+
316
+ def lock_brakes(self):
317
+ self.send_control_api_request("/desk/api/robot/close-brakes")
318
+
319
+ def set_mode_programming(self):
320
+ # Releasing a token may disable FCI while leaving the controller in EXEC.
321
+ if self.get_fci_status() == "Active" or self.operating_mode == self._sdk.OperatingMode.EXECUTION:
322
+ self.disable_fci()
323
+
324
+ def execute_self_test(self):
325
+ raise self._sdk.DeskError("Panda system 4.x self-tests must be handled through Desk on site")
326
+
327
+
328
+ class _LegacyFR3Endpoints(_LegacyOwnership):
329
+ """Pre-v1 FR3 web protocol; retain the shared token request/cleanup lifecycle."""
330
+
331
+ api = "fr3-legacy"
332
+
333
+ def _get_system_status(self):
334
+ return self.send_api_request("/admin/api/system-status", method="GET", response_encoding="json")
335
+
336
+ def _control_status(self):
337
+ status = self._get_system_status().get("controlToken")
338
+ if (not isinstance(status, dict) or "activeToken" not in status
339
+ or not isinstance(status.get("fciActive"), bool)):
340
+ raise self._sdk.DeskError("Invalid legacy FR3 control-token status")
341
+ for field in ("activeToken", "tokenRequest"):
342
+ token = status.get(field)
343
+ if token is not None and (not isinstance(token, dict) or "id" not in token):
344
+ raise self._sdk.DeskError("Invalid legacy FR3 control-token identity")
345
+ return status
346
+
347
+ def _get_operating_mode(self):
348
+ return self._sdk.DeskWebSession._get_operating_mode(self)
349
+
350
+ def _get_brake_state(self):
351
+ return self._sdk.DeskWebSession._get_brake_state(self)
352
+
353
+ def set_mode_execution(self):
354
+ self._sdk.DeskWebSession.set_mode_execution(self)
355
+
356
+ def set_mode_programming(self):
357
+ if self.get_fci_status() == "Active":
358
+ self.disable_fci()
359
+ self._sdk.DeskWebSession.set_mode_programming(self)
360
+
361
+ def enable_fci(self):
362
+ try:
363
+ super().enable_fci()
364
+ except self._sdk.FrankaAPIError as exc:
365
+ if exc.http_code != 404:
366
+ raise
367
+ # Older clients expose these two activation routes. Only a missing
368
+ # endpoint selects the SDK's form-encoded route; other failures propagate.
369
+ self._sdk.DeskWebSession.enable_fci(self)
370
+
371
+ def unlock_brakes(self):
372
+ self._sdk.DeskWebSession.unlock_brakes(self)
373
+
374
+ def lock_brakes(self):
375
+ self._sdk.DeskWebSession.lock_brakes(self)
376
+
377
+
378
+ def _desk_type(api):
379
+ import franky
380
+
381
+ if api == "v1":
382
+ return franky.Desk
383
+ if api not in ("panda4", "fr3-legacy"):
384
+ raise ValueError("Unsupported Desk API")
385
+ import franky.desk as sdk
386
+
387
+ endpoints = _System4Endpoints if api == "panda4" else _LegacyFR3Endpoints
388
+
389
+ class LegacySession(endpoints, sdk.DeskWebSession):
390
+ _sdk = sdk
391
+
392
+ return LegacySession
393
+
394
+
395
+ def create_desk(cfg, password):
396
+ import httpx
397
+
398
+ if cfg.robot_model == "panda":
399
+ api = "panda4"
400
+ else:
401
+ # Probe a read-only endpoint. Authentication and network failures must not
402
+ # silently select another protocol, and probing never takes robot control.
403
+ response = httpx.get(
404
+ "https://" + cfg.host + "/api/system",
405
+ auth=(cfg.desk_user, password), verify=False, trust_env=False, timeout=10,
406
+ )
407
+ if response.status_code == 404:
408
+ api = "fr3-legacy"
409
+ else:
410
+ response.raise_for_status()
411
+ api = "v1"
412
+ desk = _desk_type(api)(cfg.host, cfg.desk_user, password)
413
+ desk.api = api
414
+ return desk
415
+
416
+
417
+ _PROFILES = "/admin/api/end-effector/profiles"
418
+ _INERTIA_ENTRIES = (("x11", 0, 0), ("x12", 0, 1), ("x13", 0, 2),
419
+ ("x22", 1, 1), ("x23", 1, 2), ("x33", 2, 2))
420
+
421
+
422
+ def tool_inertial():
423
+ """Encode the standard assembly as fresh Desk inertial fields."""
424
+ inertia = STANDARD_TOOL.inertia_kg_m2
425
+ return {
426
+ "mass": STANDARD_TOOL.mass_kg,
427
+ "centerOfMass": dict(zip("xyz", STANDARD_TOOL.com_flange_m)),
428
+ "inertia": {key: inertia[i][j] for key, i, j in _INERTIA_ENTRIES},
429
+ }
430
+
431
+
432
+ def _same_load(actual, expected):
433
+ try:
434
+ a = [actual["mass"], *[actual["centerOfMass"][k] for k in "xyz"],
435
+ *[actual["inertia"][k] for k in expected["inertia"]]]
436
+ b = [expected["mass"], *expected["centerOfMass"].values(), *expected["inertia"].values()]
437
+ return np.all(np.isfinite(a)) and np.allclose(a, b, rtol=1e-6, atol=1e-8)
438
+ except (KeyError, TypeError, ValueError):
439
+ return False
440
+
441
+
442
+ def tool_tcp():
443
+ """Desk TCP coincides with the flange in both position and orientation."""
444
+ return {"translation": dict.fromkeys("xyz", 0.0),
445
+ "rotation": dict.fromkeys(("roll", "pitch", "yaw"), 0.0)}
446
+
447
+
448
+ def _same_tcp(actual):
449
+ try:
450
+ values = [actual[group][axis] for group, axes in
451
+ (("translation", "xyz"), ("rotation", ("roll", "pitch", "yaw")))
452
+ for axis in axes]
453
+ return np.all(np.isfinite(values)) and np.allclose(values, 0.0, rtol=0, atol=1e-8)
454
+ except (KeyError, TypeError, ValueError):
455
+ return False
456
+
457
+
458
+ def apply_tool_configuration(cfg, control_desk):
459
+ """Apply the standard inertial data and flange-centre TCP before FCI starts."""
460
+ import franky
461
+ from .feedback import read_robot_status
462
+
463
+ expected = tool_inertial()
464
+ password = Path(cfg.desk_password_file).expanduser().read_text().strip()
465
+ # Profiles use the web admin login, including when control uses the v1 API.
466
+ admin = franky.DeskWebSession(cfg.host, cfg.desk_user, password)
467
+ headers = {"Content-Type": "application/json", "X-Control-Token": control_desk.control_token}
468
+ try:
469
+ admin.open(timeout=15.0)
470
+ if control_desk.api in ("fr3-legacy", "panda4"):
471
+ try:
472
+ actual = read_robot_status(cfg, admin)["endEffectorConfiguration"]
473
+ expected_flat = tool_configuration_data()
474
+ matches = all(
475
+ np.shape(actual[key]) == np.shape(expected_flat[key])
476
+ and np.all(np.isfinite(actual[key]))
477
+ and np.allclose(actual[key], expected_flat[key], rtol=1e-6, atol=1e-8)
478
+ for key in expected_flat
479
+ )
480
+ except (KeyError, TypeError, ValueError) as exc:
481
+ raise ValueError("Unsupported Desk end-effector feedback") from exc
482
+ if not matches:
483
+ raise ControlNotReady(
484
+ "Desk end-effector load or TCP does not match the standard configuration. Run "
485
+ "'roboapi export-franka-tool', import endeffector-config.json in "
486
+ "Desk's end-effector settings, then retry."
487
+ )
488
+ return
489
+
490
+ def request(path, method="GET", content=None):
491
+ return admin.send_api_request(path, method=method, content=content,
492
+ headers=headers, response_encoding="json")
493
+
494
+ active = request(_PROFILES + "/active")
495
+ if not isinstance(active, dict) or not all(k in active for k in ("id", "name", "deviceId", "tcp", "inertial")):
496
+ raise ValueError("Desk returned an invalid active end-effector profile.")
497
+ body = {"name": active["name"], "deviceId": active["deviceId"],
498
+ "tcp": tool_tcp(), "inertial": expected}
499
+ profile_id = active["id"]
500
+ needs_update = (profile_id == "ee-no-profile" or not _same_load(active["inertial"], expected)
501
+ or not _same_tcp(active["tcp"]))
502
+ if needs_update:
503
+ ensure_programming(control_desk)
504
+ if profile_id == "ee-no-profile":
505
+ body.update(name="roboapi", deviceId="ee-no-gripper")
506
+ profiles = request(_PROFILES)
507
+ if not isinstance(profiles, list) or not all(isinstance(p, dict) for p in profiles):
508
+ raise ValueError("Desk returned an invalid end-effector profile list.")
509
+ matches = [p for p in profiles if p.get("name") == "roboapi"]
510
+ if len(matches) > 1:
511
+ raise ValueError("Desk has multiple roboapi end-effector profiles.")
512
+ if matches:
513
+ profile_id = matches[0]["id"]
514
+ else:
515
+ try:
516
+ created = request(_PROFILES, "POST", body)
517
+ except Exception as exc:
518
+ # franky's transport treats HTTP 201 as an error, although creation succeeded.
519
+ if getattr(exc, "http_code", None) != 201:
520
+ raise
521
+ created = json.loads(exc.message)
522
+ profile_id = created["id"]
523
+ if not isinstance(profile_id, str) or not profile_id:
524
+ raise ValueError("Desk returned an invalid end-effector profile ID.")
525
+ if needs_update:
526
+ request(_PROFILES + "/" + quote(profile_id, safe=""), "PUT", body)
527
+ request(_PROFILES + "/active", "PUT", profile_id)
528
+ applied = request(_PROFILES + "/active")
529
+ if (not isinstance(applied, dict) or applied.get("id") != profile_id
530
+ or not _same_tcp(applied.get("tcp"))
531
+ or applied.get("deviceId") != body["deviceId"]
532
+ or not _same_load(applied.get("inertial"), expected)):
533
+ raise ValueError("Desk did not apply the standard end-effector load and flange-centre TCP.")
534
+ finally:
535
+ admin.close()
536
+
537
+
538
+ def tool_configuration_data():
539
+ """Legacy Desk import data for the standard load and identity flange-to-TCP transform."""
540
+ return {
541
+ "mass": STANDARD_TOOL.mass_kg,
542
+ "centerOfMass": list(STANDARD_TOOL.com_flange_m),
543
+ "inertia": np.asarray(STANDARD_TOOL.inertia_kg_m2).ravel(order="F").tolist(),
544
+ "transformation": np.eye(4).ravel(order="F").tolist(),
545
+ }
546
+
547
+
548
+ def export_tool_configuration(output):
549
+ """Export the complete standard end-effector configuration without connecting to Desk."""
550
+ data = tool_configuration_data()
551
+ target = Path(output).expanduser()
552
+ with target.open("x", encoding="utf-8") as stream:
553
+ json.dump(data, stream, indent=2, allow_nan=False)
554
+ stream.write("\n")
555
+ return target