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/semantics.py
ADDED
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
|
roboapi/web/__init__.py
ADDED
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
|