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/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Command-line boot, serving and shutdown lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
import tyro
|
|
18
|
+
from tyro.extras import SubcommandApp
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
from .runtime import Runtime, build
|
|
22
|
+
from .mcp import make_server
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
app = SubcommandApp()
|
|
26
|
+
_shutdown = lambda: None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@tyro.conf.configure(tyro.conf.OmitArgPrefixes, tyro.conf.DisallowNone)
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Options:
|
|
32
|
+
config: Annotated[Path | None, tyro.conf.arg(
|
|
33
|
+
aliases=["-c"], metavar="PATH", help="Installation YAML.",
|
|
34
|
+
help_behavior_hint=f"(default: {config.DEFAULT_CONFIG})",
|
|
35
|
+
)] = None
|
|
36
|
+
verbose: Annotated[bool, tyro.conf.arg(aliases=["-v"])] = False
|
|
37
|
+
"""Enable debug logging."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _configure(options: Options) -> None:
|
|
41
|
+
logging.basicConfig(
|
|
42
|
+
level=logging.DEBUG if options.verbose else logging.INFO,
|
|
43
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
44
|
+
stream=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
sys.setswitchinterval(0.0005)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _boot(options: Options) -> Runtime:
|
|
50
|
+
global _shutdown
|
|
51
|
+
_configure(options)
|
|
52
|
+
try:
|
|
53
|
+
cfg = config.load(options.config)
|
|
54
|
+
runner = build(cfg, config_path=options.config or config.DEFAULT_CONFIG)
|
|
55
|
+
except (OSError, ValueError, TypeError, RuntimeError) as exc:
|
|
56
|
+
raise SystemExit(f"Startup failed: {exc}") from None
|
|
57
|
+
done = threading.Event()
|
|
58
|
+
|
|
59
|
+
def shutdown(*_):
|
|
60
|
+
if done.is_set():
|
|
61
|
+
return
|
|
62
|
+
done.set()
|
|
63
|
+
runner.close()
|
|
64
|
+
|
|
65
|
+
_shutdown = shutdown
|
|
66
|
+
atexit.register(shutdown)
|
|
67
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
68
|
+
signal.signal(sig, lambda *_: (shutdown(), sys.exit(0)))
|
|
69
|
+
state = runner.preview()
|
|
70
|
+
logger.info("read-only preview: arm=%s, camera=%s; enable control explicitly after calibration",
|
|
71
|
+
"connected" if state["arm_connected"] else "unavailable",
|
|
72
|
+
"connected" if state["camera_connected"] else "unavailable")
|
|
73
|
+
return runner
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def _runtime(options: Options, *, control: bool = False):
|
|
78
|
+
runner = _boot(options)
|
|
79
|
+
try:
|
|
80
|
+
if control:
|
|
81
|
+
runner.calibration.action("enable_control", {
|
|
82
|
+
"confirmed": True, "initialization_confirmed": True,
|
|
83
|
+
})
|
|
84
|
+
yield runner
|
|
85
|
+
except (OSError, ValueError, TypeError, RuntimeError) as exc:
|
|
86
|
+
raise SystemExit(f"Operation failed: {exc}") from None
|
|
87
|
+
finally:
|
|
88
|
+
_shutdown()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command
|
|
92
|
+
def init(common: Options = Options()) -> int:
|
|
93
|
+
"""Create local installation configuration interactively."""
|
|
94
|
+
from .initialize import initialize
|
|
95
|
+
|
|
96
|
+
_configure(common)
|
|
97
|
+
try:
|
|
98
|
+
return initialize(common.config or config.DEFAULT_CONFIG)
|
|
99
|
+
except (EOFError, KeyboardInterrupt):
|
|
100
|
+
print("\nInitialization cancelled.", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
except (OSError, ValueError) as exc:
|
|
103
|
+
raise SystemExit(f"Initialization failed: {exc}") from None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command(is_default=True)
|
|
107
|
+
def serve(common: Options = Options(),
|
|
108
|
+
transport: Literal["stdio", "streamable-http", "sse"] = "stdio") -> int:
|
|
109
|
+
"""Run the MCP server."""
|
|
110
|
+
return _serve(common, transport)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command
|
|
114
|
+
def export_franka_tool(output: Path = Path("endeffector-config.json")) -> int:
|
|
115
|
+
"""Export the standard Franka load and flange-centre TCP for import in legacy Desk."""
|
|
116
|
+
from .hardware.franka.desk import export_tool_configuration
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
print(export_tool_configuration(output))
|
|
120
|
+
except (OSError, ValueError, TypeError, RuntimeError) as exc:
|
|
121
|
+
raise SystemExit(f"Tool export failed: {exc}") from None
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command
|
|
126
|
+
def http(common: Options = Options(), with_mcp: bool = False,
|
|
127
|
+
transport: Literal["streamable-http", "sse"] = "streamable-http") -> int:
|
|
128
|
+
"""Run the browser interface and HTTP API.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
with_mcp: Also expose MCP on the same runtime.
|
|
132
|
+
transport: Network MCP transport when --with-mcp is enabled.
|
|
133
|
+
"""
|
|
134
|
+
return _serve(common, transport if with_mcp else None, browser=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command
|
|
138
|
+
def state(common: Options = Options()) -> int:
|
|
139
|
+
"""Print installation and runtime status without enabling control."""
|
|
140
|
+
with _runtime(common) as runner:
|
|
141
|
+
observation, _ = runner.observation(with_image=False)
|
|
142
|
+
print(json.dumps(observation, indent=2))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command
|
|
147
|
+
def reset(common: Options = Options()) -> int:
|
|
148
|
+
"""Connect control, open the gripper, return Home and disconnect devices."""
|
|
149
|
+
with _runtime(common, control=True) as runner:
|
|
150
|
+
print(json.dumps(runner.reset(), indent=2))
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command
|
|
155
|
+
def smoke(common: Options = Options()) -> int:
|
|
156
|
+
"""Run a real-hardware smoke test; stay next to the robot."""
|
|
157
|
+
with _runtime(common, control=True) as runner:
|
|
158
|
+
emb = runner.emb
|
|
159
|
+
emb.require_control()
|
|
160
|
+
print("state :", json.dumps(emb.get_state(), indent=2))
|
|
161
|
+
image, age = emb.get_camera()
|
|
162
|
+
print(f"camera : {image.shape} age={age * 1e3:.0f}ms")
|
|
163
|
+
print("gripper : closing to 0.2 ...")
|
|
164
|
+
emb.gripper.move_blocking(0.2)
|
|
165
|
+
print(f" -> {emb.gripper.get_ratio():.3f}")
|
|
166
|
+
print("gripper : opening to 1.0 ...")
|
|
167
|
+
emb.gripper.move_blocking(1.0)
|
|
168
|
+
print(f" -> {emb.gripper.get_ratio():.3f}")
|
|
169
|
+
print("reset :", json.dumps(runner.reset(), indent=2))
|
|
170
|
+
print("alive :", emb.alive, emb.last_error or "")
|
|
171
|
+
return 0 if emb.alive else 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _serve(options: Options, transport: str | None, *, browser: bool = False) -> int:
|
|
175
|
+
runner = _boot(options)
|
|
176
|
+
cfg = runner.emb.cfg
|
|
177
|
+
web = None
|
|
178
|
+
try:
|
|
179
|
+
if cfg.web.enabled or browser:
|
|
180
|
+
from .web import start_web
|
|
181
|
+
web = start_web(runner, cfg.web)
|
|
182
|
+
if transport is None:
|
|
183
|
+
while True:
|
|
184
|
+
time.sleep(0.5)
|
|
185
|
+
else:
|
|
186
|
+
server = make_server(runner)
|
|
187
|
+
net = {} if transport == "stdio" else {"host": cfg.server.host, "port": cfg.server.port}
|
|
188
|
+
if transport != "stdio" and cfg.server.allowed_hosts:
|
|
189
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
190
|
+
|
|
191
|
+
net["transport_security"] = TransportSecuritySettings(
|
|
192
|
+
enable_dns_rebinding_protection=True,
|
|
193
|
+
allowed_hosts=cfg.server.allowed_hosts,
|
|
194
|
+
)
|
|
195
|
+
thread = threading.Thread(
|
|
196
|
+
target=server.run,
|
|
197
|
+
kwargs={"transport": transport, **net},
|
|
198
|
+
name="mcp-serve",
|
|
199
|
+
daemon=True,
|
|
200
|
+
)
|
|
201
|
+
thread.start()
|
|
202
|
+
while thread.is_alive():
|
|
203
|
+
thread.join(0.5)
|
|
204
|
+
except (OSError, ValueError, TypeError, RuntimeError) as exc:
|
|
205
|
+
raise SystemExit(f"Server failed: {exc}") from None
|
|
206
|
+
finally:
|
|
207
|
+
_shutdown()
|
|
208
|
+
if web is not None:
|
|
209
|
+
web.shutdown()
|
|
210
|
+
web.server_close()
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
raise SystemExit(app.cli())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Public configuration API."""
|
|
2
|
+
|
|
3
|
+
from .loader import DEFAULT_CONFIG, load, save
|
|
4
|
+
from .models import (
|
|
5
|
+
ArmConfig,
|
|
6
|
+
CalibrationConfig,
|
|
7
|
+
CameraConfig,
|
|
8
|
+
Config,
|
|
9
|
+
GripperConfig,
|
|
10
|
+
PolicyConfig,
|
|
11
|
+
RuntimeConfig,
|
|
12
|
+
ServerConfig,
|
|
13
|
+
WebConfig,
|
|
14
|
+
WorkspaceConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ArmConfig", "CalibrationConfig", "CameraConfig", "Config",
|
|
19
|
+
"GripperConfig",
|
|
20
|
+
"PolicyConfig", "RuntimeConfig", "ServerConfig",
|
|
21
|
+
"WebConfig", "WorkspaceConfig", "DEFAULT_CONFIG", "load", "save",
|
|
22
|
+
]
|
roboapi/config/loader.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Load and save the local installation configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shlex
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any, get_type_hints
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .models import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG = "~/.config/roboapi/config.yaml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build(cls: type, data: Any) -> Any:
|
|
21
|
+
"""Construct nested dataclasses; their constructors reject unknown keys."""
|
|
22
|
+
if not is_dataclass(cls) or data is None:
|
|
23
|
+
return data
|
|
24
|
+
if not isinstance(data, dict):
|
|
25
|
+
raise TypeError(f"{cls.__name__} expects a mapping, got {type(data).__name__}")
|
|
26
|
+
kinds = get_type_hints(cls)
|
|
27
|
+
kwargs = {}
|
|
28
|
+
for key, value in data.items():
|
|
29
|
+
if key not in kinds:
|
|
30
|
+
raise TypeError(f"{cls.__name__} got an unexpected key {key!r}")
|
|
31
|
+
target = kinds[key]
|
|
32
|
+
kwargs[key] = _build(target, value) if is_dataclass(target) else value
|
|
33
|
+
return cls(**kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load(path: str | Path | None = None) -> Config:
|
|
37
|
+
"""Load the local installation, using the standard path when none is supplied."""
|
|
38
|
+
target = Path(path if path is not None else DEFAULT_CONFIG).expanduser()
|
|
39
|
+
try:
|
|
40
|
+
raw = yaml.safe_load(target.read_text())
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
command = "roboapi init" if path is None else f"roboapi init --config {shlex.quote(str(target))}"
|
|
43
|
+
raise FileNotFoundError(f"Installation configuration not found: {target}. Run {command}.") from None
|
|
44
|
+
except yaml.YAMLError as exc:
|
|
45
|
+
raise ValueError(f"Invalid installation configuration at {target}: {exc}") from exc
|
|
46
|
+
if not isinstance(raw, dict):
|
|
47
|
+
raise TypeError("installation configuration must be a mapping")
|
|
48
|
+
arm_data = raw.get("arm", {})
|
|
49
|
+
if not isinstance(arm_data, dict):
|
|
50
|
+
raise TypeError(f"arm expects a mapping, got {type(arm_data).__name__}")
|
|
51
|
+
arm_type = arm_data.get("type")
|
|
52
|
+
if arm_type == "ur":
|
|
53
|
+
from ..hardware.ur.settings import SERIES
|
|
54
|
+
|
|
55
|
+
arm_cls = SERIES.get(arm_data.get("series"))
|
|
56
|
+
if arm_cls is None:
|
|
57
|
+
raise ValueError("arm.series must be cb3|e")
|
|
58
|
+
elif arm_type == "franka":
|
|
59
|
+
from ..hardware.franka.settings import FrankaArmConfig, ROBOT_MODELS
|
|
60
|
+
|
|
61
|
+
if arm_data.get("robot_model") not in ROBOT_MODELS:
|
|
62
|
+
raise ValueError("arm.robot_model must be panda|fr3")
|
|
63
|
+
arm_cls = FrankaArmConfig
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f"arm.type must be ur|franka, got {arm_type!r}")
|
|
66
|
+
raw = dict(raw)
|
|
67
|
+
raw.pop("arm", None)
|
|
68
|
+
cfg = _build(Config, raw)
|
|
69
|
+
cfg.arm = _build(arm_cls, arm_data)
|
|
70
|
+
cfg.validate()
|
|
71
|
+
return cfg
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _differences(values, defaults):
|
|
75
|
+
result = {}
|
|
76
|
+
for key, value in values.items():
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
value = _differences(value, defaults[key])
|
|
79
|
+
if value:
|
|
80
|
+
result[key] = value
|
|
81
|
+
elif value != defaults[key]:
|
|
82
|
+
result[key] = value
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _compact(cfg):
|
|
87
|
+
"""Save installation choices; shared hardware defaults stay in their typed models."""
|
|
88
|
+
result = {}
|
|
89
|
+
for field in fields(cfg):
|
|
90
|
+
section = getattr(cfg, field.name)
|
|
91
|
+
changed = _differences(asdict(section), asdict(type(section)()))
|
|
92
|
+
if field.name == "arm":
|
|
93
|
+
changed["type"] = section.type
|
|
94
|
+
if section.type == "ur":
|
|
95
|
+
changed["series"] = section.series
|
|
96
|
+
else:
|
|
97
|
+
changed["robot_model"] = section.robot_model
|
|
98
|
+
if field.name == "runtime":
|
|
99
|
+
changed["inter_chunk_pause_s"] = section.inter_chunk_pause_s
|
|
100
|
+
if changed:
|
|
101
|
+
result[field.name] = changed
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save(cfg: Config, path: str | Path) -> None:
|
|
106
|
+
"""Atomically replace the installation file, keeping credentials in separate files."""
|
|
107
|
+
cfg.validate()
|
|
108
|
+
target = Path(path).expanduser()
|
|
109
|
+
content = yaml.safe_dump(_compact(cfg), sort_keys=False)
|
|
110
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
111
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
114
|
+
stream.write(content)
|
|
115
|
+
stream.flush()
|
|
116
|
+
os.fsync(stream.fileno())
|
|
117
|
+
os.replace(temporary, target)
|
|
118
|
+
finally:
|
|
119
|
+
Path(temporary).unlink(missing_ok=True)
|
roboapi/config/models.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Typed application and robot configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from ..semantics import HOME_POSE7
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ArmConfig:
|
|
13
|
+
"""Motion settings shared by every arm implementation."""
|
|
14
|
+
|
|
15
|
+
type: str = ""
|
|
16
|
+
home_gripper: float = 1.0 # 1.0 = open. Reset opens the gripper before returning Home.
|
|
17
|
+
# Peak speed of an agent-commanded Cartesian move. Franka also uses the angular value as
|
|
18
|
+
# a hard cap on pose-feedback rotation, so a newly replaced direct target cannot bypass it.
|
|
19
|
+
cartesian_speed: float = 0.05 # m/s
|
|
20
|
+
cartesian_angular_speed: float = 0.4 # rad/s
|
|
21
|
+
# Corner radius of a waypoint path (robot_move_waypoints) when the waypoint does not say:
|
|
22
|
+
# the path cuts inside each corner by at most this and keeps moving through it.
|
|
23
|
+
blend_radius: float = 0.02 # m
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class GripperConfig:
|
|
28
|
+
type: str = "modbus" # modbus | urcap
|
|
29
|
+
# modbus: host serial device via USB/RS-485
|
|
30
|
+
port: str = "/dev/ttyUSB0"
|
|
31
|
+
slave_id: int = 9
|
|
32
|
+
baudrate: int = 115200
|
|
33
|
+
# urcap: UR controller socket; an empty ip reuses arm.ip for UR arms.
|
|
34
|
+
ip: str = ""
|
|
35
|
+
tcp_port: int = 63352
|
|
36
|
+
timeout_s: float = 1.0
|
|
37
|
+
speed: int = 255
|
|
38
|
+
force: int = 150
|
|
39
|
+
rate_hz: float = 100.0
|
|
40
|
+
# *** CALIBRATE ON REAL HARDWARE *** -- full-stroke travel time at `speed`. An
|
|
41
|
+
# agent-commanded gripper change is spread over a path at least this long, so the call does
|
|
42
|
+
# not return while the fingers are still moving.
|
|
43
|
+
full_travel_s: float = 1.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CameraConfig:
|
|
48
|
+
type: str = "v4l2"
|
|
49
|
+
device: str = "/dev/video0"
|
|
50
|
+
stale_after_s: float = 0.5 # a frozen camera makes `alive` false: the VLA would eat a stale frame
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PolicyConfig:
|
|
55
|
+
type: str = "openai"
|
|
56
|
+
url: str = ""
|
|
57
|
+
# Bearer token, read from a file so it never lands in the config -- or in git.
|
|
58
|
+
token_file: str = ""
|
|
59
|
+
model: str = "" # required only by the OpenAI-compatible transport
|
|
60
|
+
timeout_s: float = 30.0
|
|
61
|
+
use_env_proxy: bool = True # false bypasses HTTP(S)_PROXY and other environment proxy settings
|
|
62
|
+
# Resize before sending: preserve aspect ratio, set only the height, never crop or pad.
|
|
63
|
+
# Set to null only when the deployment owns preprocessing.
|
|
64
|
+
image_height: int | None = 360
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class RuntimeConfig:
|
|
69
|
+
# Execute every returned action; chunk length is determined by the policy.
|
|
70
|
+
inter_chunk_pause_s: float = 0.5 # hold after a completed chunk, then sample fresh input
|
|
71
|
+
log_dir: str = "./logs"
|
|
72
|
+
save_images: bool = False
|
|
73
|
+
|
|
74
|
+
def validate(self) -> None:
|
|
75
|
+
pause = self.inter_chunk_pause_s
|
|
76
|
+
if isinstance(pause, bool) or not isinstance(pause, (int, float)) or not math.isfinite(pause) or pause < 0:
|
|
77
|
+
raise ValueError("runtime.inter_chunk_pause_s must be a non-negative finite value")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ServerConfig:
|
|
82
|
+
host: str = "127.0.0.1" # the MCP socket. The stdio transport has none and ignores this.
|
|
83
|
+
port: int = 8000
|
|
84
|
+
# Explicit public Host headers for a loopback server behind a LAN proxy.
|
|
85
|
+
# Empty keeps the SDK defaults; this is not a client-IP access list.
|
|
86
|
+
allowed_hosts: list[str] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class WebConfig:
|
|
91
|
+
enabled: bool = True
|
|
92
|
+
host: str = "127.0.0.1"
|
|
93
|
+
port: int = 8080
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class CalibrationConfig:
|
|
98
|
+
"""Startup frame calibration profile used by the policy/control boundary."""
|
|
99
|
+
|
|
100
|
+
profile_file: str = "~/.config/roboapi/calibration.json"
|
|
101
|
+
tool_id: str = "default"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class WorkspaceConfig:
|
|
106
|
+
"""Soft limits for flange-centre targets in the training frame, in metres."""
|
|
107
|
+
|
|
108
|
+
enabled: bool = True
|
|
109
|
+
min_xyz_m: list[float] = field(default_factory=lambda: [-0.73, -1.06, -0.11])
|
|
110
|
+
max_xyz_m: list[float] = field(default_factory=lambda: [0.47, 0.14, 1.09])
|
|
111
|
+
|
|
112
|
+
def validate(self) -> None:
|
|
113
|
+
if not isinstance(self.enabled, bool):
|
|
114
|
+
raise ValueError("workspace.enabled must be a boolean")
|
|
115
|
+
for name in ("min_xyz_m", "max_xyz_m"):
|
|
116
|
+
values = getattr(self, name)
|
|
117
|
+
if (not isinstance(values, (list, tuple)) or len(values) != 3
|
|
118
|
+
or any(isinstance(v, bool) or not isinstance(v, (int, float))
|
|
119
|
+
or not math.isfinite(v) for v in values)):
|
|
120
|
+
raise ValueError(f"workspace.{name} must contain three finite numbers")
|
|
121
|
+
if any(lo >= hi for lo, hi in zip(self.min_xyz_m, self.max_xyz_m)):
|
|
122
|
+
raise ValueError("Workspace minimum must be below maximum on every axis")
|
|
123
|
+
if not self.contains(HOME_POSE7[:3]):
|
|
124
|
+
raise ValueError("Workspace must contain Home [-0.13, -0.46, 0.49] m")
|
|
125
|
+
|
|
126
|
+
def contains(self, xyz, *, atol=0.0) -> bool:
|
|
127
|
+
return all(lo - atol <= value <= hi + atol
|
|
128
|
+
for lo, value, hi in zip(self.min_xyz_m, xyz, self.max_xyz_m))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Config:
|
|
133
|
+
arm: ArmConfig = field(default_factory=ArmConfig)
|
|
134
|
+
gripper: GripperConfig = field(default_factory=GripperConfig)
|
|
135
|
+
camera: CameraConfig = field(default_factory=CameraConfig)
|
|
136
|
+
policy: PolicyConfig = field(default_factory=PolicyConfig)
|
|
137
|
+
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
|
138
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
139
|
+
web: WebConfig = field(default_factory=WebConfig)
|
|
140
|
+
calibration: CalibrationConfig = field(default_factory=CalibrationConfig)
|
|
141
|
+
workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
|
|
142
|
+
|
|
143
|
+
def validate(self) -> None:
|
|
144
|
+
arm = self.arm
|
|
145
|
+
if arm.type not in ("ur", "franka"):
|
|
146
|
+
raise ValueError(f"arm.type must be ur|franka, got {arm.type!r}")
|
|
147
|
+
for name, value in (
|
|
148
|
+
("cartesian_speed", arm.cartesian_speed),
|
|
149
|
+
("cartesian_angular_speed", arm.cartesian_angular_speed),
|
|
150
|
+
):
|
|
151
|
+
if not isinstance(value, (int, float)) or not 0.0 < value < float("inf"):
|
|
152
|
+
raise ValueError(f"arm.{name} must be a positive finite value")
|
|
153
|
+
if arm.type == "franka":
|
|
154
|
+
from ..hardware.franka.settings import FrankaArmConfig
|
|
155
|
+
|
|
156
|
+
if not isinstance(arm, FrankaArmConfig):
|
|
157
|
+
raise ValueError("arm.type=franka requires FrankaArmConfig")
|
|
158
|
+
arm.validate()
|
|
159
|
+
if arm.type == "ur":
|
|
160
|
+
from ..hardware.ur.settings import CB3ArmConfig, ESeriesArmConfig
|
|
161
|
+
|
|
162
|
+
if not isinstance(arm, (CB3ArmConfig, ESeriesArmConfig)):
|
|
163
|
+
raise ValueError("UR requires a CB3 or e-Series configuration")
|
|
164
|
+
arm.validate()
|
|
165
|
+
if self.gripper.type not in ("modbus", "urcap"):
|
|
166
|
+
raise ValueError(f"gripper.type must be modbus|urcap, got {self.gripper.type!r}")
|
|
167
|
+
if (self.gripper.type == "urcap" and not self.gripper.ip
|
|
168
|
+
and not (arm.type == "ur" and arm.ip)):
|
|
169
|
+
raise ValueError("gripper.type=urcap requires a UR controller address")
|
|
170
|
+
if self.camera.type != "v4l2":
|
|
171
|
+
raise ValueError(f"camera.type must be v4l2, got {self.camera.type!r}")
|
|
172
|
+
if self.policy.type != "openai":
|
|
173
|
+
raise ValueError(f"policy.type must be openai, got {self.policy.type!r}")
|
|
174
|
+
if self.policy.type == "openai":
|
|
175
|
+
if not self.policy.url:
|
|
176
|
+
raise ValueError(f"policy.type={self.policy.type} requires policy.url")
|
|
177
|
+
if not self.policy.token_file:
|
|
178
|
+
raise ValueError(f"policy.type={self.policy.type} requires policy.token_file")
|
|
179
|
+
if self.policy.type == "openai" and not self.policy.model:
|
|
180
|
+
raise ValueError("policy.type=openai requires policy.model")
|
|
181
|
+
if not isinstance(self.policy.use_env_proxy, bool):
|
|
182
|
+
raise ValueError("policy.use_env_proxy must be a boolean")
|
|
183
|
+
if self.policy.image_height is not None and self.policy.image_height < 1:
|
|
184
|
+
raise ValueError("policy.image_height must be >= 1 or null")
|
|
185
|
+
self.runtime.validate()
|
|
186
|
+
self.workspace.validate()
|
|
187
|
+
calibration = self.calibration
|
|
188
|
+
if not isinstance(calibration.profile_file, str) or not calibration.profile_file.strip():
|
|
189
|
+
raise ValueError("calibration.profile_file is required")
|
|
190
|
+
if not isinstance(calibration.tool_id, str) or not calibration.tool_id.strip():
|
|
191
|
+
raise ValueError("calibration.tool_id is required")
|