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/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.