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
roboapi/mcp.py ADDED
@@ -0,0 +1,497 @@
1
+ """MCP instructions, schemas and tool registration.
2
+
3
+ Thin on purpose: every tool is a guarded pass-through to `Runtime`, which is where the
4
+ mode and the mutual exclusion actually live.
5
+
6
+ Two things are load-bearing about the docs here and neither is decoration:
7
+
8
+ * The workflow ("run a task with start_policy, poll get_observation") lives in the server
9
+ `instructions`, which the client MAY inject.
10
+ * Every expected failure carries a `hint` that is an imperative. That is what stops an LLM
11
+ from retrying blindly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import json
18
+ import time
19
+ from typing import Literal
20
+
21
+ import numpy as np
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+ from .errors import CalibrationError, PolicyUnavailable, RobotError
25
+ from .images import preview_jpeg
26
+ from .runtime import Runtime
27
+
28
+ INSTRUCTIONS = """
29
+ A single arm with a parallel gripper and a wrist fisheye RGB camera, using the specified model API.
30
+ All observations and Cartesian targets use the training frame. Positions are metres;
31
+ MCP roll/pitch/yaw use degrees. Relative translations follow training +X/+Y/+Z.
32
+ The control point is the flange centre. Training Home is xyz [-0.13,-0.46,0.49],
33
+ quaternion wxyz [0,0,1,0]. Model actions are absolute targets at a fixed 30 Hz.
34
+ Training +Z is world up; +X/+Y lie in the horizontal plane, regardless of base mounting.
35
+
36
+ If calibration is missing, use the roboapi-calibrate skill or Devices > Calibrate in the web interface.
37
+ The operator teaches
38
+ Home above a confirmed level reference surface, then points A, B and C through the native
39
+ robot interface (UR PolyScope or Franka's guiding buttons). Keep Home orientation, gripper opening and
40
+ the same fixed tool contact point for all three surface samples. A to B defines task +X;
41
+ C lies off line AB. The plane normal toward Home defines +Z; +Y = +Z cross +X.
42
+ Capturing C automatically returns the arm to recorded Home once; authorize this motion
43
+ before beginning. Fresh feedback verifies the return, then inspect the axes and save.
44
+ If an automatic return fails, use robot_calibration_return_home to retry after addressing
45
+ the reported cause. Retain the captured points when retrying the return.
46
+ Use robot_calibration_undo to remove the most recent record and return to its teaching step;
47
+ earlier records and saved calibration remain intact. Undo sends no motion targets.
48
+ Beginning teaching exits ordinary control. Franka prepares its load and native
49
+ guiding in Programming mode using Desk pose feedback. Select one native translation axis
50
+ after Home; these axes are not the training axes. FCI is used only for automatic returns.
51
+ Release the guiding buttons before capture or axis changes; Home capture initially selects Z.
52
+ Enable ordinary control separately after saving.
53
+
54
+ Use observations to judge task outcomes. A completed action chunk means its action periods
55
+ have ended; it does not guarantee target tracking or task success. Stop ends the current
56
+ run; robot_disconnect closes device and model connections while idle and discards unfinished teaching.
57
+ """
58
+
59
+
60
+ def _guard(runner: Runtime):
61
+ """The closed set of expected conditions returns a structured dict; everything else
62
+ raises and reaches the model as a tool error.
63
+
64
+ "Reaches the model" needs one step: the SDK forwards a `ToolError`'s text but scrubs any
65
+ other exception down to "Error executing tool <name>", which nobody can act on."""
66
+ from mcp.server.mcpserver.exceptions import ToolError
67
+
68
+ def decorate(fn):
69
+ @functools.wraps(fn)
70
+ def wrapper(*args, **kwargs):
71
+ _record(runner, fn.__name__, kwargs)
72
+ try:
73
+ return fn(*args, **kwargs)
74
+ except RobotError as exc:
75
+ _record(runner, fn.__name__, kwargs, error=exc.error)
76
+ return {"ok": False, "error": exc.error, "hint": exc.hint, "detail": str(exc)}
77
+ except PolicyUnavailable as exc:
78
+ _record(runner, fn.__name__, kwargs, error="policy_unavailable")
79
+ return {
80
+ "ok": False,
81
+ "error": "policy_unavailable",
82
+ "hint": (
83
+ "call robot_get_policy_config(); check the endpoint, model, and API key; "
84
+ "use robot_configure_policy() to update the connection or key"
85
+ ),
86
+ "detail": str(exc),
87
+ }
88
+ except Exception as exc:
89
+ detail = f"{type(exc).__name__}: {exc}"
90
+ _record(runner, fn.__name__, kwargs, error=detail)
91
+ raise ToolError(detail) from exc
92
+
93
+ return wrapper
94
+
95
+ return decorate
96
+
97
+ def _record(runner: Runtime, tool: str, kwargs: dict, error: str | None = None) -> None:
98
+ def safe(value):
99
+ if isinstance(value, BaseModel):
100
+ return safe(value.model_dump(exclude_none=True))
101
+ if isinstance(value, dict):
102
+ return {
103
+ str(key): (
104
+ "<redacted>"
105
+ if any(mark in str(key).lower() for mark in ("api_key", "token", "password", "secret"))
106
+ else safe(item)
107
+ )
108
+ for key, item in value.items()
109
+ }
110
+ if isinstance(value, (list, tuple)):
111
+ return [safe(item) for item in value]
112
+ if isinstance(value, str):
113
+ return value[:60]
114
+ return value
115
+
116
+ runner.records.call_log.append({
117
+ "t": __import__("time").time(),
118
+ "tool": tool,
119
+ "args": {
120
+ k: (
121
+ "<redacted>"
122
+ if any(mark in k.lower() for mark in ("api_key", "token", "password", "secret"))
123
+ else safe(v)
124
+ )
125
+ for k, v in kwargs.items()
126
+ },
127
+ "error": error,
128
+ })
129
+
130
+ class Waypoint(BaseModel):
131
+ """One point of a robot_move_waypoints path. The schema is the agent's contract: every
132
+ field is named with its unit, unknown fields are refused rather than ignored."""
133
+
134
+ model_config = ConfigDict(extra="forbid")
135
+ x: float | None = Field(None, description="metres, observation eef_frame")
136
+ y: float | None = Field(None, description="metres, observation eef_frame")
137
+ z: float | None = Field(None, description="metres, observation eef_frame")
138
+ roll: float | None = Field(None, description="degrees about training X")
139
+ pitch: float | None = Field(None, description="degrees about training Y")
140
+ yaw: float | None = Field(None, description="degrees about training Z")
141
+ gripper: float | None = Field(None, description="opening ratio, 0 closed .. 1 open, reached as this waypoint is reached")
142
+ relative: bool = Field(False, description="x/y/z/roll/pitch/yaw are offsets from the previous waypoint (the first: from the arm)")
143
+ blend: float | None = Field(None, description="metres. >0: round the corner and pass through without stopping, staying within this of the point (default 0.02). 0: reach the point exactly and stop there. Ignored on the last waypoint, which always stops")
144
+
145
+
146
+ def make_server(runner: Runtime):
147
+ from mcp.server.mcpserver import Image, MCPServer
148
+ from mcp.types import ToolAnnotations
149
+
150
+ def _observe(with_image: bool) -> list:
151
+ obs, image = runner.observation(with_image=with_image)
152
+ out: list = [json.dumps(obs, indent=2)]
153
+ if image is not None:
154
+ out.append(Image(data=preview_jpeg(image), format="jpeg"))
155
+ return out
156
+
157
+ def _after(result: dict, with_image: bool) -> list:
158
+ """Return the completed operation and a fresh observation."""
159
+ obs, image = runner.observation(with_image=with_image)
160
+ out: list = [json.dumps({**result, "observation": obs}, indent=2)]
161
+ if image is not None:
162
+ out.append(Image(data=preview_jpeg(image), format="jpeg"))
163
+ return out
164
+
165
+ def _calibrate(action, data=None):
166
+ try:
167
+ return runner.calibration.action(action, data)
168
+ except RobotError:
169
+ raise
170
+ except (ValueError, OSError, RuntimeError) as exc:
171
+ raise CalibrationError(str(exc)) from exc
172
+
173
+ def _calibration_result(result: dict, with_image: bool) -> list:
174
+ """Return the safe calibration state and, when available, the current wrist image."""
175
+ if not with_image:
176
+ return [json.dumps(result, indent=2)]
177
+ out_result = dict(result)
178
+ image = None
179
+ if with_image and runner.emb.camera.live:
180
+ try:
181
+ image, age = runner.emb.get_camera()
182
+ out_result["image_age_ms"] = round(age * 1e3, 1)
183
+ out_result["image_available"] = True
184
+ except Exception:
185
+ out_result["image_available"] = False
186
+ out_result["image_missing"] = True
187
+ else:
188
+ out_result["image_available"] = False
189
+ if with_image:
190
+ out_result["image_missing"] = True
191
+ out: list = [json.dumps(out_result, indent=2)]
192
+ if image is not None:
193
+ out.append(Image(data=preview_jpeg(image), format="jpeg"))
194
+ return out
195
+
196
+ server = MCPServer(name="roboapi", instructions=INSTRUCTIONS)
197
+ guard = _guard(runner)
198
+ moves = lambda title: ToolAnnotations(title=title, destructive_hint=True, idempotent_hint=False)
199
+ reads = lambda title: ToolAnnotations(title=title, read_only_hint=True)
200
+ holds = lambda title: ToolAnnotations(title=title, destructive_hint=False, idempotent_hint=True)
201
+ configures = lambda title: ToolAnnotations(title=title, destructive_hint=True, idempotent_hint=True)
202
+
203
+ @server.tool(annotations=reads("Inspect manual teaching calibration"))
204
+ @guard
205
+ def robot_calibration_status(with_image: bool = False) -> list:
206
+ """Inspect the teaching phase, axes, local profile path and control gate; does not connect hardware."""
207
+ return _calibration_result(runner.calibration.status(), with_image)
208
+
209
+ @server.tool(annotations=configures("Begin Cartesian teaching"))
210
+ @guard
211
+ def robot_calibration_begin(safety_confirmed: bool, with_image: bool = False) -> list:
212
+ """Begin teaching; Franka prepares native guiding in Programming mode. Authorize the automatic return after C."""
213
+ return _calibration_result(_calibrate("acquire", {"confirmed": safety_confirmed}), with_image)
214
+
215
+ @server.tool(annotations=configures("Capture Home"))
216
+ @guard
217
+ def robot_calibration_accept_home(with_image: bool = False) -> list:
218
+ """Capture Home above the level reference surface, using the orientation kept for A/B/C."""
219
+ return _calibration_result(_calibrate("confirm_home"), with_image)
220
+
221
+ @server.tool(annotations=configures("Select a native guiding axis"))
222
+ @guard
223
+ def robot_calibration_set_guiding_axis(axis: Literal["x", "y", "z"], with_image: bool = False) -> list:
224
+ """Franka A/B/C teaching: allow one native translation axis and lock all rotations.
225
+
226
+ Release the arm buttons before changing axes. These are native guiding axes,
227
+ not the task axes that calibration will determine. Does not send motion targets.
228
+ """
229
+ return _calibration_result(_calibrate("set_guiding_axis", {"axis": axis}), with_image)
230
+
231
+ @server.tool(annotations=configures("Capture reference-plane A"))
232
+ @guard
233
+ def robot_calibration_accept_plane_a(with_image: bool = False) -> list:
234
+ """Read the first surface point, with Home orientation and a fixed tool contact point. No motion."""
235
+ return _calibration_result(_calibrate("confirm_plane_a"), with_image)
236
+
237
+ @server.tool(annotations=configures("Capture reference-plane B"))
238
+ @guard
239
+ def robot_calibration_accept_plane_b(with_image: bool = False) -> list:
240
+ """Read B on the same level surface from A toward task +X, keeping orientation and contact point. No motion."""
241
+ return _calibration_result(_calibrate("confirm_plane_b"), with_image)
242
+
243
+ @server.tool(annotations=moves("Capture reference-plane C and return Home"))
244
+ @guard
245
+ def robot_calibration_accept_plane_c(with_image: bool = False) -> list:
246
+ """Read C off line AB on the same surface, then return Home and verify fresh feedback. MOVES THE ARM."""
247
+ return _calibration_result(_calibrate("confirm_plane_c"), with_image)
248
+
249
+ @server.tool(annotations=moves("Retry return Home"))
250
+ @guard
251
+ def robot_calibration_return_home(with_image: bool = False) -> list:
252
+ """Retry an interrupted automatic Home return. MOVES THE ARM; retains all plane samples."""
253
+ return _calibration_result(_calibrate("return_home"), with_image)
254
+
255
+ @server.tool(annotations=moves("Return to reference-plane A"))
256
+ @guard
257
+ def robot_calibration_return_a(with_image: bool = False) -> list:
258
+ """Return to captured A during B/C teaching and resume read-only sampling. MOVES THE ARM."""
259
+ return _calibration_result(_calibrate("return_a"), with_image)
260
+
261
+ @server.tool(annotations=configures("Save taught calibration"))
262
+ @guard
263
+ def robot_calibration_finish(save_confirmed: bool, with_image: bool = False) -> list:
264
+ """Save the calibration after a verified Home return. No motion or control connection."""
265
+ return _calibration_result(_calibrate("save", {"confirmed": save_confirmed}), with_image)
266
+
267
+ @server.tool(annotations=ToolAnnotations(title="Undo the last calibration record", destructive_hint=True, idempotent_hint=False))
268
+ @guard
269
+ def robot_calibration_undo(with_image: bool = False) -> list:
270
+ """Remove the last unsaved record and return to its teaching step without moving.
271
+
272
+ Retains earlier records and the saved calibration. After C or the Home return,
273
+ removes C and its draft so the operator can record C again.
274
+ """
275
+ return _calibration_result(_calibrate("undo"), with_image)
276
+
277
+ @server.tool(annotations=moves("Enable calibrated hardware control"))
278
+ @guard
279
+ def robot_calibration_enable_control(safety_confirmed: bool, initialization_confirmed: bool,
280
+ with_image: bool = True) -> list:
281
+ """Acquire real control; may upload scripts and initialize/open the gripper.
282
+
283
+ Requires separate explicit on-site authorization and a valid installation calibration.
284
+ Does not issue reset/motion targets, but acquisition itself can actuate hardware.
285
+ """
286
+ return _calibration_result(_calibrate("enable_control", {"confirmed": safety_confirmed, "initialization_confirmed": initialization_confirmed}), with_image)
287
+
288
+ @server.tool(annotations=reads("Inspect direction check readiness"))
289
+ @guard
290
+ def robot_get_direction_check() -> dict:
291
+ """Read proximity to calibrated Home and the active direction check. No motion."""
292
+ return runner.direction_check_status()
293
+
294
+ @server.tool(annotations=moves("Check calibrated movement directions"))
295
+ @guard
296
+ def robot_check_direction(direction: Literal["forward", "left", "up", "home"],
297
+ with_image: bool = True) -> list:
298
+ """From calibrated Home, translate 5 cm and rotate 30 degrees, then return.
299
+
300
+ Forward is task -Y with roll -30; left is +X with pitch -30; up is +Z
301
+ with yaw +30. Rotations are about fixed task axes at the flange centre.
302
+ Uses the policy full-pose execution and calibrated coordinate mapping.
303
+ The gripper opening is held throughout.
304
+ 'home' only returns to Home with the current gripper opening. Requires
305
+ enabled idle control. Stop abandons the remaining path, including return.
306
+ Motion completion is not a judgment that the observed direction is correct.
307
+ """
308
+ return _after(runner.check_direction(direction), with_image)
309
+
310
+ @server.tool(annotations=moves("Disconnect devices"))
311
+ @guard
312
+ def robot_disconnect() -> dict:
313
+ """Disconnect devices while idle and discard unfinished teaching. Retain saved calibration."""
314
+ return runner.release()
315
+
316
+ @server.tool(annotations=reads("Inspect policy configuration"))
317
+ @guard
318
+ def robot_get_policy_config() -> dict:
319
+ """Return the model connection, chunk wait in seconds and credential status.
320
+
321
+ These settings are shared by web and MCP runs. Never returns the API key.
322
+ """
323
+ return runner.policy_config()
324
+
325
+ @server.tool(annotations=configures("Configure the policy"))
326
+ @guard
327
+ def robot_configure_policy(url: str, model: str, inter_chunk_pause_s: float,
328
+ api_key: str | None = None) -> dict:
329
+ """Save the shared model connection and action-chunk wait while idle, without motion.
330
+
331
+ Read robot_get_policy_config first and retain values that should stay unchanged.
332
+ inter_chunk_pause_s is a non-negative wait before sampling the next observation;
333
+ the default is 0.5 seconds. Runs have no automatic time limit. Omit api_key to keep
334
+ the saved key. Connection is verified against /models before settings take effect.
335
+ A rejected update preserves previous settings and credentials. Use a trusted connection.
336
+ """
337
+ return runner.configure_policy(url=url, model=model, inter_chunk_pause_s=inter_chunk_pause_s,
338
+ api_key=api_key)
339
+
340
+ @server.tool(annotations=reads("Observe the robot"))
341
+ @guard
342
+ def robot_get_observation(with_image: bool = True) -> list:
343
+ """The robot's state and its wrist camera view, sampled at one instant.
344
+
345
+ Returns mode / alive / instruction / eef_pose / gripper_state / progress, plus the
346
+ current wrist image. Prefer this over any other way of checking progress; set
347
+ with_image=False only for a cheap liveness poll.
348
+ """
349
+ return _observe(with_image)
350
+
351
+ @server.tool(annotations=moves("Start the policy"))
352
+ @guard
353
+ def robot_start_policy(instruction: str) -> dict:
354
+ """Start the VLA policy on a task, e.g. "pick up the banana".
355
+
356
+ RETURNS IMMEDIATELY. The policy keeps running in the background until you call
357
+ robot_stop() or a fault ends the run. There is no automatic time limit. Watch it with
358
+ robot_get_observation().
359
+ """
360
+ return runner.start(instruction)
361
+
362
+ @server.tool(annotations=holds("Change the instruction"))
363
+ @guard
364
+ def robot_set_instruction(instruction: str) -> dict:
365
+ """Change what the RUNNING policy is trying to do, without stopping it.
366
+
367
+ Applies at the next inference, so allow roughly one chunk. For anything urgent use
368
+ robot_stop() instead. Meant for a loop that is already running: robot_start_policy
369
+ takes the task itself, so there is nothing to set beforehand.
370
+ """
371
+ return runner.set_instruction(instruction)
372
+
373
+ @server.tool(annotations=holds("Pause the policy"))
374
+ @guard
375
+ def robot_pause(with_image: bool = True) -> list:
376
+ """Hold position: stop sending new targets, keep the arm where it is.
377
+
378
+ Returns the observation from where it stopped.
379
+ """
380
+ return _after(runner.pause(), with_image)
381
+
382
+ @server.tool(annotations=holds("Resume the policy"))
383
+ @guard
384
+ def robot_resume() -> dict:
385
+ """Resume a paused policy. It re-observes before acting."""
386
+ return runner.resume()
387
+
388
+ @server.tool(annotations=holds("Stop the policy"))
389
+ @guard
390
+ def robot_stop(with_image: bool = True) -> list:
391
+ """Stop the policy loop. Takes effect within one frame; the arm holds position.
392
+
393
+ Returns the observation from where it stopped.
394
+ """
395
+ return _after(runner.stop(), with_image)
396
+
397
+ @server.tool(annotations=moves("Move to a pose"))
398
+ @guard
399
+ def robot_move_to_pose(x: float | None = None, y: float | None = None, z: float | None = None,
400
+ roll: float | None = None, pitch: float | None = None,
401
+ yaw: float | None = None, gripper: float | None = None,
402
+ relative: bool = False, with_image: bool = True) -> list:
403
+ """Move the gripper to a pose you choose. MOVES THE ARM.
404
+
405
+ Positions x/y/z are METRES in the training frame; roll/pitch/yaw are DEGREES about
406
+ the training X/Y/Z axes. Any axis you leave out keeps its current value, so you only pass
407
+ what you want to change.
408
+
409
+ relative=False (default): the values are absolute.
410
+ robot_move_to_pose(x=0.4, y=-0.3) -> go there, same height, same orientation
411
+ relative=True: the values are offsets from where the arm is now.
412
+ robot_move_to_pose(z=0.05, relative=True) -> lift 5 cm
413
+ robot_move_to_pose(yaw=15, relative=True) -> rotate 15 deg about training Z
414
+
415
+ `gripper` is an opening ratio: 0.0 closed, 1.0 open, omitted holds. It is ALWAYS
416
+ absolute -- `relative` applies to the pose only. The gripper is driven along the path
417
+ together with the pose, exactly as a policy chunk drives them, so it arrives as the
418
+ move arrives rather than after it:
419
+ robot_move_to_pose(x=0.4, z=0.2, gripper=1.0) -> travel there, opening on the way
420
+ robot_move_to_pose(gripper=0.0) -> close in place
421
+ A grasp is therefore two calls: reach the object open, then close. Do not close on the
422
+ approach to something you mean to grip -- the fingers meet it before the pose does.
423
+
424
+ The move follows a smooth Cartesian path at a fixed, conservative speed, and stays
425
+ compliant throughout, so it yields if it touches something. Speed is not adjustable.
426
+ Nothing checks that the pose is reachable or clear -- keep moves small. The result
427
+ carries `target` (planned) and `observation` (measured once the arm came to rest, with
428
+ the wrist image): compare them to see whether it arrived or was stopped short.
429
+ with_image=False skips the image on intermediate steps of a sequence. For a path of
430
+ several points use robot_move_waypoints instead. Only available while the policy loop
431
+ is stopped.
432
+ """
433
+ return _after(runner.move_to_pose(x=x, y=y, z=z, roll=roll, pitch=pitch, yaw=yaw,
434
+ gripper=gripper, relative=relative), with_image)
435
+
436
+ @server.tool(annotations=moves("Move along waypoints"))
437
+ @guard
438
+ def robot_move_waypoints(waypoints: list[Waypoint], with_image: bool = True) -> list:
439
+ """Drive the arm and gripper through several waypoints as ONE continuous motion.
440
+ MOVES THE ARM.
441
+
442
+ Use this instead of a series of robot_move_to_pose calls: one call, one path, no stop
443
+ between points. Same units and frame as robot_move_to_pose. In each waypoint an
444
+ omitted field holds the previous waypoint's value, and relative=True makes the pose
445
+ fields offsets from the PREVIOUS waypoint (the first: from where the arm is now), so a
446
+ path needs no arithmetic:
447
+ [{"z": 0.05, "relative": true}, -- lift 5 cm
448
+ {"x": 0.10, "relative": true}, -- 10 cm along +x
449
+ {"z": -0.05, "relative": true}, -- lower 5 cm
450
+ {"gripper": 0.0}] -- close, in place
451
+
452
+ `blend` (metres) says what happens AT a waypoint. > 0 (default 0.02): the path rounds
453
+ the corner and passes through without stopping, staying within `blend` of the point,
454
+ on the inside of the corner. 0: the arm reaches the point exactly and comes to rest.
455
+ The last waypoint always comes to rest, whatever its blend says. The path never bulges
456
+ outside the polyline you gave, so it cannot dip past a point; give a point you must
457
+ reach exactly, such as the last one before contact, blend=0.
458
+
459
+ The gripper value at a waypoint is reached as the arm reaches it, spread over the leg
460
+ before it. To move the gripper with the arm STILL -- a grasp or a release -- repeat the
461
+ point with the new gripper value, as in the example: a zero-length leg is a dwell.
462
+
463
+ Speed is fixed and conservative, the move stays compliant throughout, and nothing
464
+ checks reachability or clearance: keep legs short and the path clear. The result
465
+ carries each waypoint's planned pose and gripper, `reached` (how many were passed:
466
+ fewer than given means it was stopped), and `observation` from where the arm came to
467
+ rest. Only available while the policy loop is stopped.
468
+ """
469
+ return _after(runner.move_waypoints([w.model_dump(exclude_none=True) for w in waypoints]),
470
+ with_image)
471
+
472
+ @server.tool(annotations=moves("Reset to home"))
473
+ @guard
474
+ def robot_reset(with_image: bool = True) -> list:
475
+ """Open the gripper and return to the training Home pose.
476
+
477
+ UR e-Series requests force sensor zeroing on arrival, without an extra wait.
478
+ A zeroing failure is a warning and does not fail Home.
479
+
480
+ MOVES THE ARM. Use it before starting a task, or to recover from a bad pose. Returns
481
+ the observation from home.
482
+ """
483
+ return _after(runner.reset(), with_image)
484
+
485
+ @server.tool(annotations=moves("Recover the arm"))
486
+ @guard
487
+ def robot_recover(with_image: bool = True) -> list:
488
+ """Bring a non-live arm back: stop the loop, reconnect, return to the training Home pose.
489
+
490
+ MOVES THE ARM, and the user is asked to approve it. Call this when `alive` is false.
491
+ If it keeps failing, a person has to clear the robot physically (emergency stop,
492
+ safeguard, or an obstruction) -- say so rather than retrying. Returns the observation
493
+ from home.
494
+ """
495
+ return _after(runner.recover(), with_image)
496
+
497
+ return server
roboapi/motion.py ADDED
@@ -0,0 +1,125 @@
1
+ """Shared 30 Hz Cartesian dispatch for policy actions, manual moves and calibration."""
2
+
3
+ from contextlib import nullcontext
4
+ import time
5
+
6
+ import numpy as np
7
+
8
+ from . import geometry as geo
9
+ from .semantics import ACTION_DT
10
+
11
+
12
+ def workspace_targets(poses, workspace):
13
+ """Project position targets onto the box without changing orientation or input data."""
14
+ values = np.asarray(poses, dtype=np.float64)
15
+ if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
16
+ raise ValueError("poses must be an Nx7 finite array")
17
+ if workspace is None or not workspace.enabled:
18
+ return values
19
+ values = values.copy()
20
+ values[:, :3] = np.clip(values[:, :3], workspace.min_xyz_m, workspace.max_xyz_m)
21
+ return values
22
+
23
+
24
+ def pose_reached(target, measured):
25
+ position, angle = geo.pose_delta(target, measured)
26
+ return position <= 0.05 and angle <= np.deg2rad(20)
27
+
28
+
29
+ def wait_for_pose(arm, target, should_stop=None, *, name="Home"):
30
+ """Verify an explicit return using live Cartesian feedback."""
31
+ deadline = time.monotonic() + 10.0
32
+ while True:
33
+ if should_stop is not None and should_stop():
34
+ raise RuntimeError(f"Return {name} was stopped")
35
+ if not arm.live:
36
+ raise RuntimeError(arm.last_error or "Arm control is unavailable.")
37
+ pose = arm.get_eef_pose()
38
+ if pose_reached(target, pose):
39
+ return pose
40
+ if time.monotonic() >= deadline:
41
+ raise ValueError(f"{name} was not reached. Check the robot and retry Return {name}.")
42
+ wait_until(time.monotonic() + 0.02, should_stop)
43
+
44
+
45
+ def wait_until(deadline, should_stop=None) -> bool:
46
+ """Wait on a monotonic deadline, returning False when interrupted."""
47
+ while True:
48
+ if should_stop is not None and should_stop():
49
+ return False
50
+ remaining = deadline - time.monotonic()
51
+ if remaining <= 0:
52
+ return True
53
+ time.sleep(min(0.005, remaining))
54
+
55
+
56
+ def dispatch(arm, poses, grips=None, *, gripper=None, should_stop=None, on_executed=None,
57
+ command_lock=None, position_only=False, workspace=None) -> dict:
58
+ """Execute every target, including the final target's full action period.
59
+
60
+ Poses use the coordinate frame exposed by the supplied arm: the mapped arm
61
+ for ordinary motion, or the native arm for installation calibration.
62
+ """
63
+ dt = ACTION_DT
64
+ command_lock = command_lock if command_lock is not None else nullcontext()
65
+ poses = workspace_targets(poses, workspace)
66
+ twists = geo.pose_twists(poses, dt)
67
+ if workspace is not None and workspace.enabled:
68
+ linear = twists[:, :3]
69
+ # Central differences must not feed velocity through a box face.
70
+ outward = ((poses[:, :3] <= workspace.min_xyz_m) & (linear < 0)
71
+ | (poses[:, :3] >= workspace.max_xyz_m) & (linear > 0))
72
+ linear[outward] = 0.0
73
+ t0 = time.monotonic()
74
+ late = 0
75
+ executed = 0
76
+ last_sent_at = t0
77
+ n = len(poses)
78
+ for i in range(n):
79
+ if should_stop is not None and should_stop():
80
+ break
81
+ deadline = t0 + i * dt
82
+ now = time.monotonic()
83
+ if now < deadline:
84
+ if not wait_until(deadline, should_stop):
85
+ break
86
+ elif now > deadline + dt:
87
+ late += 1
88
+ if should_stop is not None and should_stop():
89
+ break
90
+ with command_lock:
91
+ if should_stop is not None and should_stop():
92
+ break
93
+ arm.servo_pose(poses[i], twists[i], position_only=position_only)
94
+ if grips is not None:
95
+ gripper.set_ratio(float(grips[i]))
96
+ executed += 1
97
+ if on_executed is not None:
98
+ on_executed(executed)
99
+ last_sent_at = time.monotonic()
100
+ aborted = executed < n
101
+ if n and not aborted:
102
+ # The final target owns a full action period too, including when dispatch ran
103
+ # late. Sending N targets spans only N-1 intervals; it is not completion.
104
+ deadline = max(t0 + n * dt, last_sent_at + dt)
105
+ aborted = not wait_until(deadline, should_stop)
106
+ dispatch_done = time.monotonic()
107
+ # A servo target is not an arrival guarantee, especially with contact or
108
+ # compliant control. Observe after the final commanded period, without
109
+ # requiring the measured pose to equal the policy's nominal target.
110
+ endpoint_error = {}
111
+ if n and not aborted:
112
+ position_error, orientation_error = geo.pose_delta(arm.get_eef_pose(), poses[-1])
113
+ endpoint_error = {
114
+ "endpoint_position_error_m": round(position_error, 5),
115
+ "endpoint_orientation_error_deg": round(float(np.degrees(orientation_error)), 2),
116
+ }
117
+ return {
118
+ "executed": executed,
119
+ "late_frames": late,
120
+ "elapsed_s": round(time.monotonic() - t0, 3),
121
+ "dispatch_s": round(dispatch_done - t0, 3),
122
+ "action_period_completed_at_monotonic": dispatch_done if not aborted else None,
123
+ **endpoint_error,
124
+ "aborted": aborted,
125
+ }
roboapi/naming.py ADDED
@@ -0,0 +1,7 @@
1
+ """Display names; technical namespaces are fixed by the package migration."""
2
+
3
+ import json
4
+ from importlib.resources import files
5
+
6
+ NAMES = json.loads(files("roboapi").joinpath("product-names.json").read_text(encoding="utf-8"))
7
+ PRODUCT_NAME = NAMES["product"]["name"]
roboapi/operations.py ADDED
@@ -0,0 +1,45 @@
1
+ """One interruptible writer shared by task and installation operations."""
2
+
3
+ import functools
4
+ import threading
5
+
6
+ from .errors import LoopRunning
7
+
8
+
9
+ class OperationGate:
10
+ """One non-blocking writer plus an abort signal that STOP sets without waiting."""
11
+
12
+ def __init__(self) -> None:
13
+ self._lock = threading.Lock()
14
+ self.abort = threading.Event()
15
+
16
+ def acquire(self) -> bool:
17
+ return self._lock.acquire(blocking=False)
18
+
19
+ def release(self) -> None:
20
+ self._lock.release()
21
+
22
+ def interrupt(self) -> None:
23
+ self.abort.set()
24
+
25
+ def reset_abort(self) -> None:
26
+ self.abort.clear()
27
+
28
+ @property
29
+ def busy(self) -> bool:
30
+ return self._lock.locked()
31
+
32
+
33
+ def exclusive(method):
34
+ """Runtime commands share one writer; STOP never waits for this gate."""
35
+ @functools.wraps(method)
36
+ def guarded(self, *args, **kwargs):
37
+ if not self.operations.acquire():
38
+ raise LoopRunning("another operation is in progress; stop it or wait for completion")
39
+ try:
40
+ return method(self, *args, **kwargs)
41
+ finally:
42
+ self.operations.release()
43
+ return guarded
44
+
45
+