roboapi 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roboapi/__init__.py +2 -0
- roboapi/calibration/__init__.py +6 -0
- roboapi/calibration/profile.py +139 -0
- roboapi/calibration/session.py +350 -0
- roboapi/cli.py +215 -0
- roboapi/config/__init__.py +22 -0
- roboapi/config/loader.py +119 -0
- roboapi/config/models.py +191 -0
- roboapi/embodiment.py +251 -0
- roboapi/errors.py +37 -0
- roboapi/geometry.py +337 -0
- roboapi/hardware/__init__.py +5 -0
- roboapi/hardware/base.py +113 -0
- roboapi/hardware/camera.py +167 -0
- roboapi/hardware/franka/__init__.py +5 -0
- roboapi/hardware/franka/arm.py +476 -0
- roboapi/hardware/franka/desk.py +555 -0
- roboapi/hardware/franka/feedback.py +196 -0
- roboapi/hardware/franka/guiding.py +104 -0
- roboapi/hardware/franka/ik.py +543 -0
- roboapi/hardware/franka/limits.py +110 -0
- roboapi/hardware/franka/settings.py +116 -0
- roboapi/hardware/robotiq.py +350 -0
- roboapi/hardware/ur/__init__.py +16 -0
- roboapi/hardware/ur/build.py +92 -0
- roboapi/hardware/ur/compliant.py +128 -0
- roboapi/hardware/ur/dashboard.py +103 -0
- roboapi/hardware/ur/feedback.py +159 -0
- roboapi/hardware/ur/ipc.py +206 -0
- roboapi/hardware/ur/native/CMakeLists.txt +37 -0
- roboapi/hardware/ur/native/dependencies.lock.json +13 -0
- roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
- roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
- roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
- roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
- roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
- roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
- roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
- roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
- roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
- roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
- roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
- roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
- roboapi/hardware/ur/native/worker.cpp +147 -0
- roboapi/hardware/ur/position.py +253 -0
- roboapi/hardware/ur/settings.py +95 -0
- roboapi/images.py +16 -0
- roboapi/initialize.py +137 -0
- roboapi/mcp.py +497 -0
- roboapi/motion.py +125 -0
- roboapi/naming.py +7 -0
- roboapi/operations.py +45 -0
- roboapi/policy/__init__.py +7 -0
- roboapi/policy/client.py +277 -0
- roboapi/policy/protocol.py +166 -0
- roboapi/policy/settings.py +79 -0
- roboapi/product-names.json +12 -0
- roboapi/records.py +149 -0
- roboapi/runtime.py +911 -0
- roboapi/semantics.py +6 -0
- roboapi/tasks.py +337 -0
- roboapi/web/__init__.py +5 -0
- roboapi/web/presets.py +44 -0
- roboapi/web/server.py +419 -0
- roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
- roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
- roboapi/web/static/assets/Inter-OFL.txt +93 -0
- roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
- roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
- roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
- roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
- roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi/web/static/assets/React-LICENSE.txt +23 -0
- roboapi/web/static/assets/Three-LICENSE.txt +23 -0
- roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
- roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
- roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
- roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
- roboapi/web/static/index.html +15 -0
- roboapi-0.4.2.dist-info/METADATA +205 -0
- roboapi-0.4.2.dist-info/RECORD +94 -0
- roboapi-0.4.2.dist-info/WHEEL +5 -0
- roboapi-0.4.2.dist-info/entry_points.txt +2 -0
- roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
- roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/top_level.txt +1 -0
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
|
+
|