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/web/server.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Browser model API interaction, device status and installation endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
from ..errors import ControlNotReady, LoopRunning, PolicyUnavailable
|
|
17
|
+
from ..images import preview_jpeg
|
|
18
|
+
from ..config import DEFAULT_CONFIG
|
|
19
|
+
from .presets import Presets
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _asset(name):
|
|
25
|
+
path = PurePosixPath(unquote(name))
|
|
26
|
+
if path.is_absolute() or ".." in path.parts or "\\" in str(path) or "\x00" in str(path):
|
|
27
|
+
raise FileNotFoundError(name)
|
|
28
|
+
return files("roboapi.web").joinpath("static", *path.parts).read_bytes()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _public_policy_config(config: dict) -> dict:
|
|
32
|
+
"""Browser settings and connection state, never the token path or token value."""
|
|
33
|
+
return {
|
|
34
|
+
"url": config.get("url"),
|
|
35
|
+
"model": config.get("model"),
|
|
36
|
+
"api_key_configured": bool(config.get("api_key_configured")),
|
|
37
|
+
"policy_ready": bool(config.get("policy_ready")),
|
|
38
|
+
"inter_chunk_pause_s": config["inter_chunk_pause_s"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _handler(runner, presets):
|
|
43
|
+
class Handler(BaseHTTPRequestHandler):
|
|
44
|
+
protocol_version = "HTTP/1.1"
|
|
45
|
+
|
|
46
|
+
def handle(self) -> None:
|
|
47
|
+
try:
|
|
48
|
+
super().handle()
|
|
49
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
50
|
+
pass # Closing a browser tab can reset a keep-alive connection.
|
|
51
|
+
|
|
52
|
+
def log_message(self, *args) -> None: # keep stderr for the app's own logs
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def _send(self, body: bytes, ctype: str, status: int = 200) -> None:
|
|
56
|
+
self.send_response(status)
|
|
57
|
+
self.send_header("Content-Type", ctype)
|
|
58
|
+
self.send_header("Content-Length", str(len(body)))
|
|
59
|
+
self.send_header("Cache-Control", "no-store")
|
|
60
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
61
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(body)
|
|
64
|
+
|
|
65
|
+
def _json(self, value, status: int = 200) -> None:
|
|
66
|
+
self._send(json.dumps(value, allow_nan=False).encode(), "application/json", status)
|
|
67
|
+
|
|
68
|
+
def _camera_jpeg(self) -> bytes:
|
|
69
|
+
if not runner.emb.camera.live:
|
|
70
|
+
raise RuntimeError("Camera disconnected")
|
|
71
|
+
try:
|
|
72
|
+
image, age = runner.emb.get_camera()
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
raise RuntimeError("Could not read camera frame") from exc
|
|
75
|
+
if not 0 <= age <= runner.emb.cfg.camera.stale_after_s:
|
|
76
|
+
raise RuntimeError("Camera feed is stale")
|
|
77
|
+
try:
|
|
78
|
+
return preview_jpeg(image)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise RuntimeError("Could not encode camera frame") from exc
|
|
81
|
+
|
|
82
|
+
def _camera_stream(self) -> None:
|
|
83
|
+
try:
|
|
84
|
+
jpg = self._camera_jpeg()
|
|
85
|
+
except RuntimeError as exc:
|
|
86
|
+
logger.debug("camera preview unavailable", exc_info=True)
|
|
87
|
+
self._json({"error": str(exc)}, 503)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# An interrupted multipart response must end with EOF, not an idle keep-alive socket.
|
|
91
|
+
self.close_connection = True
|
|
92
|
+
self.connection.settimeout(3)
|
|
93
|
+
try:
|
|
94
|
+
self.send_response(200)
|
|
95
|
+
# Safari cancels fetch() responses using the image-only x-mixed-replace type.
|
|
96
|
+
self.send_header("Content-Type", "multipart/mixed; boundary=frame")
|
|
97
|
+
self.send_header("Cache-Control", "no-store")
|
|
98
|
+
self.send_header("Connection", "close")
|
|
99
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
100
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
101
|
+
self.end_headers()
|
|
102
|
+
while True:
|
|
103
|
+
started = time.monotonic()
|
|
104
|
+
self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
|
105
|
+
+ str(len(jpg)).encode() + b"\r\n\r\n" + jpg + b"\r\n")
|
|
106
|
+
self.wfile.flush()
|
|
107
|
+
time.sleep(max(0, 0.2 - (time.monotonic() - started)))
|
|
108
|
+
jpg = self._camera_jpeg()
|
|
109
|
+
except RuntimeError:
|
|
110
|
+
logger.debug("camera preview stream ended", exc_info=True)
|
|
111
|
+
except OSError:
|
|
112
|
+
pass # Disconnected or slow clients must not keep a preview worker alive.
|
|
113
|
+
|
|
114
|
+
def do_POST(self) -> None:
|
|
115
|
+
# Close even on validation errors: unread request bytes must not become requests.
|
|
116
|
+
self.close_connection = True
|
|
117
|
+
path = urlsplit(self.path).path
|
|
118
|
+
origin = self.headers.get("Origin")
|
|
119
|
+
try:
|
|
120
|
+
cross_origin = origin and (urlsplit(origin).scheme not in {"http", "https"}
|
|
121
|
+
or urlsplit(origin).netloc != self.headers.get("Host"))
|
|
122
|
+
except ValueError:
|
|
123
|
+
cross_origin = True
|
|
124
|
+
if cross_origin or self.headers.get("Sec-Fetch-Site") not in {None, "same-origin", "none"}:
|
|
125
|
+
self._json({"error": "Cross-origin requests are not allowed"}, 403)
|
|
126
|
+
return
|
|
127
|
+
if path == "/api/stop":
|
|
128
|
+
self._json(runner.stop("web stop"))
|
|
129
|
+
return
|
|
130
|
+
actions = {"acquire", "confirm_home", "confirm_plane_a", "confirm_plane_b", "confirm_plane_c", "return_home", "return_a", "save", "undo", "enable_control", "set_guiding_axis"}
|
|
131
|
+
instruction_paths = {"/api/instruction", "/api/policy/start"}
|
|
132
|
+
control_paths = {"/api/policy/pause", "/api/policy/resume", "/api/robot/reset",
|
|
133
|
+
"/api/robot/recover", "/api/errors/clear", "/api/devices/connect", "/api/devices/release"}
|
|
134
|
+
task_route = path.strip("/").split("/")
|
|
135
|
+
task_action = (task_route[3] if len(task_route) == 4 and task_route[:2] == ["api", "tasks"]
|
|
136
|
+
and task_route[3] in {"feedback", "delete"} else None)
|
|
137
|
+
calibration_action = path.rsplit("/", 1)[-1] if path.startswith("/api/calibration/") else None
|
|
138
|
+
if (path not in instruction_paths and path not in control_paths and
|
|
139
|
+
path not in {"/api/policy/config", "/api/workspace/config", "/api/presets", "/api/recording/config", "/api/storage/cleanup", "/api/calibration/check"}
|
|
140
|
+
and task_action is None and calibration_action not in actions):
|
|
141
|
+
self._json({"error": "Unknown action"}, 404)
|
|
142
|
+
return
|
|
143
|
+
if self.headers.get("Content-Type", "").split(";", 1)[0].strip() != "application/json":
|
|
144
|
+
self._json({"error": "Content-Type must be application/json"}, 415)
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
148
|
+
limit = 131072 if path in {"/api/presets", "/api/storage/cleanup"} else 16384
|
|
149
|
+
if length <= 0 or length > limit or self.headers.get("Transfer-Encoding"):
|
|
150
|
+
self._json({"error": f"A JSON body of 1–{limit} bytes is required"}, 413)
|
|
151
|
+
return
|
|
152
|
+
self.connection.settimeout(5)
|
|
153
|
+
body = json.loads(self.rfile.read(length))
|
|
154
|
+
if not isinstance(body, dict):
|
|
155
|
+
raise ValueError("JSON body must be an object")
|
|
156
|
+
if path == "/api/calibration/check":
|
|
157
|
+
if set(body) != {"direction"} or not isinstance(body["direction"], str):
|
|
158
|
+
raise ValueError("direction is required")
|
|
159
|
+
self._json(runner.check_direction(body["direction"]))
|
|
160
|
+
elif path == "/api/storage/cleanup":
|
|
161
|
+
ids = body.get("task_ids")
|
|
162
|
+
if (set(body) - {"kind", "task_ids"} or "kind" not in body
|
|
163
|
+
or ("task_ids" in body and (not isinstance(ids, list)
|
|
164
|
+
or not all(isinstance(item, str) for item in ids)))):
|
|
165
|
+
raise ValueError("kind and optional task_ids list are required")
|
|
166
|
+
self._json(runner.tasks.cleanup(body["kind"], ids))
|
|
167
|
+
elif task_action == "feedback":
|
|
168
|
+
if set(body) != {"rating", "note"} or body["rating"] not in (None, "up", "down") or not isinstance(body["note"], str):
|
|
169
|
+
raise ValueError("rating and note are required")
|
|
170
|
+
self._json(runner.tasks.feedback(task_route[2], body["rating"], body["note"]))
|
|
171
|
+
elif task_action == "delete":
|
|
172
|
+
if body:
|
|
173
|
+
raise ValueError("delete body must be empty")
|
|
174
|
+
runner.tasks.delete(task_route[2])
|
|
175
|
+
self._json({"ok": True})
|
|
176
|
+
elif path == "/api/recording/config":
|
|
177
|
+
if set(body) != {"enabled"} or not isinstance(body["enabled"], bool):
|
|
178
|
+
raise ValueError("enabled must be a boolean")
|
|
179
|
+
self._json(runner.tasks.settings(body["enabled"]))
|
|
180
|
+
elif path == "/api/presets":
|
|
181
|
+
if set(body) != {"instructions"}:
|
|
182
|
+
raise ValueError("instructions is required")
|
|
183
|
+
self._json({"instructions": presets.save(body["instructions"])})
|
|
184
|
+
elif path == "/api/workspace/config":
|
|
185
|
+
if set(body) != {"enabled", "min_xyz_m", "max_xyz_m"}:
|
|
186
|
+
raise ValueError("enabled, min_xyz_m and max_xyz_m are required")
|
|
187
|
+
self._json(runner.configure_workspace(**body))
|
|
188
|
+
elif path == "/api/policy/config":
|
|
189
|
+
required = {"url", "model", "inter_chunk_pause_s"}
|
|
190
|
+
if (set(body) not in (required, required | {"api_key"})
|
|
191
|
+
or not isinstance(body["url"], str) or not isinstance(body["model"], str)
|
|
192
|
+
or not 1 <= len(body["url"]) <= 2048 or not 1 <= len(body["model"]) <= 128
|
|
193
|
+
or ("api_key" in body and (not isinstance(body["api_key"], str)
|
|
194
|
+
or not 1 <= len(body["api_key"]) <= 4096))):
|
|
195
|
+
raise ValueError("url, model and inter_chunk_pause_s are required; api_key is optional")
|
|
196
|
+
result = runner.configure_policy(**body)
|
|
197
|
+
self._json({"ok": True, **_public_policy_config(result)})
|
|
198
|
+
elif path in {"/api/instruction", "/api/policy/start"}:
|
|
199
|
+
allowed = {"instruction", "new_task"} if path == "/api/policy/start" else {"instruction"}
|
|
200
|
+
if ("instruction" not in body or set(body) - allowed or not isinstance(body["instruction"], str)
|
|
201
|
+
or ("new_task" in body and not isinstance(body["new_task"], bool))):
|
|
202
|
+
raise ValueError("instruction must be a string")
|
|
203
|
+
instruction = body["instruction"].strip()
|
|
204
|
+
if not 1 <= len(instruction) <= 1000:
|
|
205
|
+
raise ValueError("instruction must contain 1–1000 characters")
|
|
206
|
+
hardware = runner.status()
|
|
207
|
+
if not hardware["arm_connected"]:
|
|
208
|
+
self._json({"error": "Robot disconnected"}, 409)
|
|
209
|
+
return
|
|
210
|
+
if path == "/api/policy/start" and not hardware["camera_connected"]:
|
|
211
|
+
self._json({"error": "Camera disconnected"}, 409)
|
|
212
|
+
return
|
|
213
|
+
options = {"new_task": body["new_task"]} if "new_task" in body else {}
|
|
214
|
+
self._json(runner.start(instruction, source="web", **options) if path == "/api/policy/start"
|
|
215
|
+
else runner.set_instruction(instruction, source="web"))
|
|
216
|
+
elif path in control_paths:
|
|
217
|
+
if body:
|
|
218
|
+
raise ValueError("control body must be empty")
|
|
219
|
+
state = runner.status()
|
|
220
|
+
if path == "/api/policy/pause" and state["mode"] != "running":
|
|
221
|
+
self._json({"error": "Policy is not running"}, 409)
|
|
222
|
+
return
|
|
223
|
+
if path == "/api/policy/resume" and state["mode"] != "paused":
|
|
224
|
+
self._json({"error": "Policy is not paused"}, 409)
|
|
225
|
+
return
|
|
226
|
+
if path == "/api/robot/reset" and not state["alive"]:
|
|
227
|
+
self._json({"error": "Robot unavailable; restore connection first"}, 409)
|
|
228
|
+
return
|
|
229
|
+
if path == "/api/errors/clear" and not state["alive"]:
|
|
230
|
+
self._json({"error": "Fault still active; recover the device first"}, 409)
|
|
231
|
+
return
|
|
232
|
+
if path == "/api/policy/pause":
|
|
233
|
+
result = runner.pause(source="web")
|
|
234
|
+
elif path == "/api/policy/resume":
|
|
235
|
+
result = runner.resume(source="web")
|
|
236
|
+
elif path == "/api/robot/reset":
|
|
237
|
+
result = {**runner.reset(), **runner.status()}
|
|
238
|
+
elif path == "/api/robot/recover":
|
|
239
|
+
result = {**runner.recover(), **runner.status()}
|
|
240
|
+
elif path == "/api/devices/connect":
|
|
241
|
+
result = runner.preview()
|
|
242
|
+
elif path == "/api/devices/release":
|
|
243
|
+
result = runner.release()
|
|
244
|
+
else:
|
|
245
|
+
result = runner.clear_monitor_errors()
|
|
246
|
+
self._json(result)
|
|
247
|
+
else:
|
|
248
|
+
result = runner.calibration.action(calibration_action, body)
|
|
249
|
+
messages = {"enable_control": "Control enabled",
|
|
250
|
+
"acquire": "Calibration started", "save": "Calibration saved", "undo": "Calibration record undone"}
|
|
251
|
+
if calibration_action in messages:
|
|
252
|
+
runner.records.event(messages[calibration_action])
|
|
253
|
+
self._json(result)
|
|
254
|
+
except KeyError:
|
|
255
|
+
self._json({"error": "Task not found"}, 404)
|
|
256
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
257
|
+
self._json({"error": str(exc)}, 400)
|
|
258
|
+
except PolicyUnavailable as exc:
|
|
259
|
+
detail = str(exc).lower()
|
|
260
|
+
if "authentication rejected" in detail:
|
|
261
|
+
message = "API key authentication failed"
|
|
262
|
+
elif "model not authorized" in detail:
|
|
263
|
+
message = "Current key cannot use this model"
|
|
264
|
+
elif "connect timeout" in detail:
|
|
265
|
+
message = "Model connection timed out"
|
|
266
|
+
elif "read timeout" in detail:
|
|
267
|
+
message = "Model response timed out"
|
|
268
|
+
else:
|
|
269
|
+
message = "Model connection failed; check Base URL, model, and API key"
|
|
270
|
+
self._json({"error": message}, 502)
|
|
271
|
+
except ControlNotReady as exc:
|
|
272
|
+
self._json({"error": str(exc), "kind": exc.error}, 409)
|
|
273
|
+
except LoopRunning as exc:
|
|
274
|
+
self._json({"error": str(exc)}, 409)
|
|
275
|
+
except RuntimeError as exc:
|
|
276
|
+
self._json({"error": "Model settings cannot be changed now" if path == "/api/policy/config"
|
|
277
|
+
else "Policy could not start; check device and model connections" if path == "/api/policy/start"
|
|
278
|
+
else "Robot recovery failed; check the current fault" if path == "/api/robot/recover"
|
|
279
|
+
else "Robot reset failed; check the current fault" if path == "/api/robot/reset"
|
|
280
|
+
else str(exc)}, 409)
|
|
281
|
+
except TimeoutError:
|
|
282
|
+
self._json({"error": "Request body timed out"}, 408)
|
|
283
|
+
except Exception:
|
|
284
|
+
logger.exception("monitor action failed")
|
|
285
|
+
self._json({"error": "Model settings failed; check service logs." if path == "/api/policy/config"
|
|
286
|
+
else "Could not save workspace settings; check service logs." if path == "/api/workspace/config"
|
|
287
|
+
else "Could not save instruction presets." if path == "/api/presets"
|
|
288
|
+
else "Could not save task data; check service logs." if task_action or path in {"/api/recording/config", "/api/storage/cleanup"}
|
|
289
|
+
else "Robot operation failed; check the current fault." if path in control_paths
|
|
290
|
+
else "Calibration operation failed; check service logs."}, 500)
|
|
291
|
+
|
|
292
|
+
def _video(self, path, task_id: str) -> None:
|
|
293
|
+
# Keep one file descriptor while a continuation atomically publishes a new MP4.
|
|
294
|
+
with path.open("rb") as video:
|
|
295
|
+
size = os.fstat(video.fileno()).st_size
|
|
296
|
+
start, end = 0, size - 1
|
|
297
|
+
requested = self.headers.get("Range")
|
|
298
|
+
if requested:
|
|
299
|
+
try:
|
|
300
|
+
unit, bounds = requested.split("=", 1)
|
|
301
|
+
first, last = bounds.split("-", 1)
|
|
302
|
+
if unit != "bytes" or "," in bounds:
|
|
303
|
+
raise ValueError
|
|
304
|
+
if first:
|
|
305
|
+
start = int(first)
|
|
306
|
+
end = min(int(last), size - 1) if last else size - 1
|
|
307
|
+
else:
|
|
308
|
+
suffix = int(last)
|
|
309
|
+
if suffix <= 0:
|
|
310
|
+
raise ValueError
|
|
311
|
+
start = max(0, size - suffix)
|
|
312
|
+
if not 0 <= start <= end < size:
|
|
313
|
+
raise ValueError
|
|
314
|
+
except ValueError:
|
|
315
|
+
self.send_response(416)
|
|
316
|
+
self.send_header("Content-Range", f"bytes */{size}")
|
|
317
|
+
self.send_header("Content-Length", "0")
|
|
318
|
+
self.end_headers()
|
|
319
|
+
return
|
|
320
|
+
self.send_response(206 if requested else 200)
|
|
321
|
+
self.send_header("Content-Type", "video/mp4")
|
|
322
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
323
|
+
self.send_header("Content-Length", str(end - start + 1))
|
|
324
|
+
self.send_header("Cache-Control", "no-store")
|
|
325
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
326
|
+
if requested:
|
|
327
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
328
|
+
if parse_qs(urlsplit(self.path).query).get("download") == ["1"]:
|
|
329
|
+
self.send_header("Content-Disposition", f'attachment; filename="task-{task_id}.mp4"')
|
|
330
|
+
self.end_headers()
|
|
331
|
+
video.seek(start)
|
|
332
|
+
remaining = end - start + 1
|
|
333
|
+
while remaining > 0:
|
|
334
|
+
chunk = video.read(min(65536, remaining))
|
|
335
|
+
if not chunk:
|
|
336
|
+
break
|
|
337
|
+
self.wfile.write(chunk)
|
|
338
|
+
remaining -= len(chunk)
|
|
339
|
+
|
|
340
|
+
def do_GET(self) -> None:
|
|
341
|
+
path = urlsplit(self.path).path
|
|
342
|
+
if path.startswith("/assets/"):
|
|
343
|
+
try:
|
|
344
|
+
asset = _asset(path.lstrip("/"))
|
|
345
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
346
|
+
self.send_error(404)
|
|
347
|
+
return
|
|
348
|
+
self._send(asset, mimetypes.guess_type(path)[0] or "application/octet-stream")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
if path == "/":
|
|
352
|
+
try:
|
|
353
|
+
page = _asset("index.html")
|
|
354
|
+
except FileNotFoundError:
|
|
355
|
+
self._json({"error": "Web assets missing. Install a release wheel or build frontend with npm run build."}, 503)
|
|
356
|
+
return
|
|
357
|
+
self._send(page, "text/html; charset=utf-8")
|
|
358
|
+
elif path == "/api/calibration/check":
|
|
359
|
+
self._json(runner.direction_check_status())
|
|
360
|
+
elif path == "/api/calibration":
|
|
361
|
+
try:
|
|
362
|
+
self._json(runner.calibration.status())
|
|
363
|
+
except Exception:
|
|
364
|
+
logger.exception("calibration status failed")
|
|
365
|
+
self._json({"error": "Could not load calibration status. Check the service logs."}, 500)
|
|
366
|
+
elif path == "/api/policy/config":
|
|
367
|
+
try:
|
|
368
|
+
self._json(_public_policy_config(runner.policy_config()))
|
|
369
|
+
except Exception:
|
|
370
|
+
logger.exception("policy settings read failed")
|
|
371
|
+
self._json({"error": "Could not load model settings"}, 500)
|
|
372
|
+
elif path == "/api/workspace/config":
|
|
373
|
+
self._json(runner.workspace_config())
|
|
374
|
+
elif path == "/api/status":
|
|
375
|
+
self._json(runner.status())
|
|
376
|
+
elif path == "/api/storage":
|
|
377
|
+
try:
|
|
378
|
+
self._json(runner.tasks.storage())
|
|
379
|
+
except OSError:
|
|
380
|
+
logger.exception("task storage read failed")
|
|
381
|
+
self._json({"error": "Could not read task storage"}, 500)
|
|
382
|
+
elif path == "/api/tasks" or path.startswith("/api/tasks/"):
|
|
383
|
+
try:
|
|
384
|
+
route = path.strip("/").split("/")
|
|
385
|
+
if len(route) == 2:
|
|
386
|
+
task = runner.tasks.current()
|
|
387
|
+
self._json({"tasks": runner.tasks.list(),
|
|
388
|
+
"current_task_id": task["id"] if task else None,
|
|
389
|
+
"recording_enabled": runner.tasks.settings()["enabled"]})
|
|
390
|
+
elif len(route) == 3:
|
|
391
|
+
self._json(runner.tasks.get(route[2]))
|
|
392
|
+
elif len(route) == 4 and route[3] == "video":
|
|
393
|
+
self._video(runner.tasks.video_path(route[2]), route[2])
|
|
394
|
+
else:
|
|
395
|
+
self._json({"error": "Unknown task action"}, 404)
|
|
396
|
+
except (KeyError, FileNotFoundError):
|
|
397
|
+
self._json({"error": "Task or video not found"}, 404)
|
|
398
|
+
elif path == "/api/logs":
|
|
399
|
+
self._json({"events": runner.records.activity()})
|
|
400
|
+
elif path == "/api/presets":
|
|
401
|
+
try:
|
|
402
|
+
self._json({"instructions": presets.read()})
|
|
403
|
+
except (OSError, ValueError, TypeError):
|
|
404
|
+
self._json({"error": "Could not load instruction presets"}, 500)
|
|
405
|
+
elif path == "/api/camera/stream":
|
|
406
|
+
self._camera_stream()
|
|
407
|
+
else:
|
|
408
|
+
self.send_error(404)
|
|
409
|
+
|
|
410
|
+
return Handler
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def start_web(runner, cfg) -> ThreadingHTTPServer:
|
|
414
|
+
presets = Presets(getattr(runner, "config_path", None) or DEFAULT_CONFIG)
|
|
415
|
+
server = ThreadingHTTPServer((cfg.host, cfg.port), _handler(runner, presets))
|
|
416
|
+
server.daemon_threads = True
|
|
417
|
+
threading.Thread(target=server.serve_forever, name="web", daemon=True).start()
|
|
418
|
+
logger.info("web interface at http://%s:%d", cfg.host, server.server_port)
|
|
419
|
+
return server
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Vite core license
|
|
2
|
+
Vite is released under the MIT license:
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2019-present, VoidZero Inc. and Vite contributors
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
|
25
|
+
|
|
26
|
+
# Rolldown runtime license
|
|
27
|
+
|
|
28
|
+
MIT License
|
|
29
|
+
|
|
30
|
+
Copyright (c) 2024-present VoidZero Inc. & Contributors
|
|
31
|
+
|
|
32
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
33
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
34
|
+
in the Software without restriction, including without limitation the rights
|
|
35
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
36
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
37
|
+
furnished to do so, subject to the following conditions:
|
|
38
|
+
|
|
39
|
+
The above copyright notice and this permission notice shall be included in all
|
|
40
|
+
copies or substantial portions of the Software.
|
|
41
|
+
|
|
42
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
43
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
44
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
45
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
46
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
47
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
48
|
+
SOFTWARE.
|