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/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
+ ]
@@ -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)
@@ -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")