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/semantics.py ADDED
@@ -0,0 +1,6 @@
1
+ """The model contract shared by every supported embodiment."""
2
+
3
+ TRAINING_FRAME = "training"
4
+ HOME_POSE7 = (-0.13, -0.46, 0.49, 0.0, 0.0, 1.0, 0.0) # metres, quaternion wxyz
5
+ ACTION_HZ = 30
6
+ ACTION_DT = 1.0 / ACTION_HZ
roboapi/tasks.py ADDED
@@ -0,0 +1,337 @@
1
+ """Host-local task history, human feedback and camera recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ import queue
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ import uuid
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _write_json(path: Path, value: dict) -> None:
19
+ temporary = path.with_suffix(".tmp")
20
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
21
+ temporary.replace(path)
22
+
23
+
24
+ class _Recording:
25
+ """Sample the existing camera owner; encoding never runs on the control thread."""
26
+
27
+ def __init__(self, camera, path: Path):
28
+ self.camera, self.path = camera, path
29
+ self.stop = threading.Event()
30
+ self.error: str | None = None
31
+ self.thread = threading.Thread(target=self._capture, name="task-camera", daemon=True)
32
+ self.thread.start()
33
+
34
+ def _capture(self):
35
+ writer = None
36
+ temporary = self.path.with_name(self.path.stem + ".partial.mp4")
37
+ try:
38
+ import imageio_ffmpeg
39
+
40
+ deadline = time.monotonic()
41
+ while not self.stop.is_set():
42
+ frame, _ = self.camera.read()
43
+ if writer is None:
44
+ height, width = frame.shape[:2]
45
+ writer = imageio_ffmpeg.write_frames(
46
+ str(temporary), (width, height), fps=30, codec="libx264",
47
+ pix_fmt_in="rgb24", pix_fmt_out="yuv420p", macro_block_size=2,
48
+ output_params=["-preset", "ultrafast", "-crf", "23", "-threads", "2"],
49
+ ffmpeg_timeout=15,
50
+ )
51
+ writer.send(None)
52
+ writer.send(frame)
53
+ deadline = max(deadline + 1 / 30, time.monotonic())
54
+ self.stop.wait(max(0, deadline - time.monotonic()))
55
+ except Exception as exc:
56
+ self.error = str(exc)
57
+ finally:
58
+ if writer is not None:
59
+ try:
60
+ writer.close()
61
+ if temporary.exists() and temporary.stat().st_size:
62
+ temporary.replace(self.path)
63
+ except Exception as exc:
64
+ self.error = str(exc)
65
+ temporary.unlink(missing_ok=True)
66
+
67
+
68
+ class TaskArchive:
69
+ """One current task, many stopped runs, one rating and one published video.
70
+
71
+ A single publication queue preserves run order even if a task is continued
72
+ while the previous recording is still closing. Completed segments remain on
73
+ disk so continuing or restarting the host never re-encodes old video.
74
+ """
75
+
76
+ def __init__(self, root: str | Path):
77
+ self.root = Path(root).expanduser()
78
+ self.root.mkdir(parents=True, exist_ok=True)
79
+ self._lock = threading.RLock()
80
+ self._tasks: dict[str, dict] = {}
81
+ self._active: tuple[str, float, _Recording | None] | None = None
82
+ self._pending: dict[str, int] = {}
83
+ self._queue: queue.Queue = queue.Queue()
84
+ self._worker: threading.Thread | None = None
85
+ self._state_path = self.root / "settings.json"
86
+ self._state = {"enabled": True, "current_id": None}
87
+ if self._state_path.exists():
88
+ self._state.update(json.loads(self._state_path.read_text()))
89
+ for path in self.root.glob("*/metadata.json"):
90
+ task = json.loads(path.read_text())
91
+ # A process exit cannot leave a task pretending it is still running.
92
+ if task["active"] or task["recording"] in ("recording", "saving"):
93
+ task.update(active=False, stop_reason="interrupted",
94
+ recording="ready" if (path.parent / "video.mp4").exists() else "error",
95
+ recording_error="Recording interrupted by host restart")
96
+ _write_json(path, task)
97
+ self._tasks[task["id"]] = task
98
+
99
+ def _save(self, task: dict) -> None:
100
+ _write_json(self.root / task["id"] / "metadata.json", task)
101
+
102
+ def _snapshot(self, task: dict) -> dict:
103
+ result = dict(task)
104
+ result["video_available"] = (self.root / task["id"] / "video.mp4").is_file()
105
+ if self._active and self._active[0] == task["id"]:
106
+ _, started, recording = self._active
107
+ result["elapsed_s"] = round(task["elapsed_s"] + time.monotonic() - started, 2)
108
+ if recording and recording.error:
109
+ result.update(recording="error", recording_error=recording.error)
110
+ return result
111
+
112
+ def current(self) -> dict | None:
113
+ with self._lock:
114
+ task = self._tasks.get(self._state["current_id"])
115
+ return self._snapshot(task) if task else None
116
+
117
+ def list(self) -> list[dict]:
118
+ with self._lock:
119
+ return [self._snapshot(task) for task in
120
+ sorted(self._tasks.values(), key=lambda item: item["created_at"], reverse=True)]
121
+
122
+ def get(self, task_id: str) -> dict:
123
+ with self._lock:
124
+ return self._snapshot(self._tasks[task_id])
125
+
126
+ def settings(self, enabled: bool | None = None) -> dict:
127
+ """Recording preference applies when the next run starts."""
128
+ with self._lock:
129
+ if enabled is not None:
130
+ if not isinstance(enabled, bool):
131
+ raise ValueError("enabled must be a boolean")
132
+ self._state["enabled"] = enabled
133
+ _write_json(self._state_path, self._state)
134
+ return {"enabled": self._state["enabled"]}
135
+
136
+ def begin(self, instruction: str, model: str | None = None, source: str = "web",
137
+ camera=None, *, new_task: bool = False) -> dict:
138
+ instruction = instruction.strip()
139
+ with self._lock:
140
+ if self._active:
141
+ raise RuntimeError("a task is already running")
142
+ now = time.time()
143
+ task = self._tasks.get(self._state["current_id"])
144
+ if new_task or task is None or task["instruction"] != instruction:
145
+ task = {
146
+ "id": uuid.uuid4().hex, "instruction": instruction, "model": model,
147
+ "source": source, "created_at": now, "updated_at": now,
148
+ "elapsed_s": 0.0, "runs": 0, "rating": None, "note": "",
149
+ "stop_reason": None, "active": False, "recording": "off",
150
+ "recording_error": None, "infer_ms": None,
151
+ }
152
+ (self.root / task["id"] / ".segments").mkdir(parents=True)
153
+ else:
154
+ task = dict(task)
155
+ task.update(active=True, updated_at=now, model=model, source=source,
156
+ runs=task["runs"] + 1, stop_reason=None, recording_error=None)
157
+ record = self._state["enabled"] and camera is not None
158
+ task["recording"] = "recording" if record else "off"
159
+ state = {**self._state, "current_id": task["id"]}
160
+ # Persist before creating a recording thread: failed storage cannot leak one.
161
+ self._save(task)
162
+ _write_json(self._state_path, state)
163
+ self._tasks[task["id"]], self._state = task, state
164
+ recording = None
165
+ if record:
166
+ path = self.root / task["id"] / ".segments" / f'{task["runs"]:08d}.mp4'
167
+ try:
168
+ recording = _Recording(camera, path)
169
+ except Exception as exc:
170
+ task.update(recording="error", recording_error=str(exc))
171
+ self._active = (task["id"], time.monotonic(), recording)
172
+ return self._snapshot(task)
173
+
174
+ def finish(self, reason: str | None = None, infer_ms: float | None = None) -> dict | None:
175
+ """Stop sampling immediately; MP4 publication proceeds in the background."""
176
+ with self._lock:
177
+ if self._active is None:
178
+ return self.current()
179
+ task_id, started, recording = self._active
180
+ self._active = None
181
+ task = self._tasks[task_id]
182
+ task.update(active=False, updated_at=time.time(), stop_reason=reason,
183
+ elapsed_s=round(task["elapsed_s"] + time.monotonic() - started, 2),
184
+ infer_ms=infer_ms)
185
+ if recording:
186
+ recording.stop.set()
187
+ task["recording"] = "saving"
188
+ self._pending[task_id] = self._pending.get(task_id, 0) + 1
189
+ self._queue.put((task_id, recording))
190
+ if self._worker is None:
191
+ self._worker = threading.Thread(target=self._publish_runs, name="task-video", daemon=True)
192
+ self._worker.start()
193
+ self._save(task)
194
+ return self._snapshot(task)
195
+
196
+ def _publish_runs(self):
197
+ while True:
198
+ job = self._queue.get()
199
+ if job is None:
200
+ self._queue.task_done()
201
+ return
202
+ task_id, recording = job
203
+ recording.thread.join()
204
+ error = recording.error
205
+ try:
206
+ if recording.path.exists():
207
+ self._publish_video(task_id, recording.path)
208
+ except Exception as exc:
209
+ error = str(exc)
210
+ logger.warning("Task recording could not be saved: %s", exc)
211
+ with self._lock:
212
+ task = self._tasks[task_id]
213
+ self._pending[task_id] -= 1
214
+ if error and int(recording.path.stem) == task["runs"]:
215
+ task["recording_error"] = error
216
+ if not task["active"] and not self._pending[task_id]:
217
+ available = (self.root / task_id / "video.mp4").exists()
218
+ task["recording"] = "error" if task["recording_error"] else "ready" if available else "off"
219
+ try:
220
+ self._save(task)
221
+ except OSError:
222
+ logger.exception("Task recording metadata could not be saved")
223
+ self._queue.task_done()
224
+
225
+ def _publish_video(self, task_id: str, last_segment: Path) -> None:
226
+ import imageio_ffmpeg
227
+
228
+ directory = self.root / task_id
229
+ # Include only this and earlier runs, even when a later capture has finished.
230
+ segments = sorted(path for path in (directory / ".segments").glob("*.mp4")
231
+ if not path.name.endswith(".partial.mp4") and path.name <= last_segment.name)
232
+ manifest = directory / ".concat.txt"
233
+ manifest.write_text("".join(f"file '.segments/{path.name}'\n" for path in segments))
234
+ temporary = directory / ".video.mp4"
235
+ try:
236
+ result = subprocess.run(
237
+ [imageio_ffmpeg.get_ffmpeg_exe(), "-hide_banner", "-loglevel", "error",
238
+ "-y", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy",
239
+ "-movflags", "+faststart", str(temporary)],
240
+ capture_output=True, timeout=120,
241
+ )
242
+ if result.returncode:
243
+ raise RuntimeError(result.stderr.decode(errors="replace").strip())
244
+ temporary.replace(directory / "video.mp4")
245
+ finally:
246
+ manifest.unlink(missing_ok=True)
247
+ temporary.unlink(missing_ok=True)
248
+
249
+ def feedback(self, task_id: str, rating: str | None, note: str = "") -> dict:
250
+ if rating not in (None, "up", "down"):
251
+ raise ValueError("rating must be up, down or null")
252
+ if not isinstance(note, str):
253
+ raise ValueError("note must be text")
254
+ with self._lock:
255
+ task = self._tasks[task_id]
256
+ task.update(rating=rating, note=note, updated_at=time.time())
257
+ self._save(task)
258
+ return self._snapshot(task)
259
+
260
+ def delete(self, task_id: str) -> None:
261
+ with self._lock:
262
+ task = self._tasks[task_id]
263
+ if task["active"] or self._pending.get(task_id):
264
+ raise RuntimeError("stop the task and wait for its video before deleting it")
265
+ shutil.rmtree(self.root / task_id)
266
+ del self._tasks[task_id]
267
+ if self._state["current_id"] == task_id:
268
+ self._state["current_id"] = None
269
+ _write_json(self._state_path, self._state)
270
+
271
+ def storage(self) -> dict:
272
+ """Actual task-directory usage, including the source clips used for continuation."""
273
+ with self._lock:
274
+ tasks = []
275
+ for task in sorted(self._tasks.values(), key=lambda item: item["created_at"], reverse=True):
276
+ total = video = 0
277
+ for path in (self.root / task["id"]).rglob("*"):
278
+ if not path.is_file():
279
+ continue
280
+ try:
281
+ size = path.stat().st_size
282
+ except FileNotFoundError: # A recorder may publish a file during this scan.
283
+ continue
284
+ total += size
285
+ if path.suffix == ".mp4":
286
+ video += size
287
+ tasks.append({**self._snapshot(task), "bytes": total, "video_bytes": video,
288
+ "busy": task["active"] or bool(self._pending.get(task["id"]))})
289
+ return {"tasks": tasks, "total_bytes": sum(task["bytes"] for task in tasks),
290
+ "video_bytes": sum(task["video_bytes"] for task in tasks),
291
+ "recordings_count": sum(task["video_bytes"] > 0 for task in tasks),
292
+ "recording_enabled": self._state["enabled"]}
293
+
294
+ def cleanup(self, kind: str, task_ids: list[str] | None = None) -> dict:
295
+ """Clear selected or all archives, leaving live and finalizing runs untouched."""
296
+ if kind not in ("recordings", "tasks"):
297
+ raise ValueError("kind must be recordings or tasks")
298
+ with self._lock:
299
+ ids = list(self._tasks) if task_ids is None else list(dict.fromkeys(task_ids))
300
+ tasks = [self._tasks[task_id] for task_id in ids]
301
+ cleared = skipped = 0
302
+ for task in tasks:
303
+ task_id = task["id"]
304
+ if task["active"] or self._pending.get(task_id):
305
+ skipped += 1
306
+ continue
307
+ if kind == "tasks":
308
+ self.delete(task_id)
309
+ else:
310
+ directory = self.root / task_id
311
+ for path in directory.glob("*.mp4"):
312
+ path.unlink(missing_ok=True)
313
+ segments = directory / ".segments"
314
+ if segments.exists():
315
+ shutil.rmtree(segments)
316
+ segments.mkdir()
317
+ task.update(recording="off", recording_error=None)
318
+ self._save(task)
319
+ cleared += 1
320
+ return {"cleared": cleared, "skipped": skipped}
321
+
322
+ def video_path(self, task_id: str) -> Path:
323
+ with self._lock:
324
+ self._tasks[task_id] # Resolve membership before constructing any filesystem path.
325
+ path = self.root / task_id / "video.mp4"
326
+ if not path.is_file():
327
+ raise FileNotFoundError("this task has no recording")
328
+ return path
329
+
330
+ def close(self) -> None:
331
+ """Flush at application shutdown, never from Robot Stop."""
332
+ self.finish("shutdown")
333
+ self._queue.join()
334
+ if self._worker:
335
+ self._queue.put(None)
336
+ self._worker.join()
337
+ self._worker = None
@@ -0,0 +1,5 @@
1
+ """Browser interface for the shared runtime."""
2
+
3
+ from .server import start_web
4
+
5
+ __all__ = ["start_web"]
roboapi/web/presets.py ADDED
@@ -0,0 +1,44 @@
1
+ """Installation-local browser instruction presets, independent of policy execution."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import tempfile
7
+ import threading
8
+
9
+
10
+ class Presets:
11
+ def __init__(self, config_path):
12
+ self.path = Path(config_path).expanduser().resolve().with_name("instruction-presets.json")
13
+ self._lock = threading.Lock()
14
+
15
+ @staticmethod
16
+ def validate(instructions):
17
+ if not isinstance(instructions, list) or len(instructions) > 20:
18
+ raise ValueError("presets must be a list of at most 20 instructions")
19
+ if any(not isinstance(item, str) or not 1 <= len(item.strip()) <= 1000 for item in instructions):
20
+ raise ValueError("each preset must contain 1–1000 characters")
21
+ return [item.strip() for item in instructions]
22
+
23
+ def read(self):
24
+ with self._lock:
25
+ try:
26
+ return self.validate(json.loads(self.path.read_text()))
27
+ except FileNotFoundError:
28
+ return []
29
+
30
+ def save(self, instructions):
31
+ instructions = self.validate(instructions)
32
+ with self._lock:
33
+ self.path.parent.mkdir(parents=True, exist_ok=True)
34
+ temporary = None
35
+ try:
36
+ with tempfile.NamedTemporaryFile(mode="w", dir=self.path.parent, delete=False) as out:
37
+ temporary = out.name
38
+ json.dump(instructions, out, ensure_ascii=False, indent=2)
39
+ out.write("\n")
40
+ os.replace(temporary, self.path)
41
+ finally:
42
+ if temporary is not None:
43
+ Path(temporary).unlink(missing_ok=True)
44
+ return instructions