roborouter 0.0.1__tar.gz
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.
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: roborouter
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Run a robot on any model with a key and one call.
|
|
5
|
+
Author: Momentum
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: models,momentum,robotics
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: httpx>=0.28
|
|
10
|
+
Requires-Dist: msgpack>=1.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=9.1; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.16; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# RoboRouter
|
|
17
|
+
|
|
18
|
+
Run a robot, in a simulator or the real world, on any model with a key and one call.
|
|
19
|
+
|
|
20
|
+
**Not ready for use yet.** The service isn't open to new users, and the only model so far is a test script.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install roborouter
|
|
24
|
+
export MOMENTUM_API_KEY=fw_cli_... # Settings, Workspace API keys
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import roborouter
|
|
29
|
+
|
|
30
|
+
config = {"model": "momentum/scripted"}
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
state = {"joints": read_my_joints(), "images": {"top": read_my_camera_jpeg()}}
|
|
34
|
+
reply = roborouter.control_robot(config, state, "pick up the fork")
|
|
35
|
+
if reply.finished:
|
|
36
|
+
break
|
|
37
|
+
move_my_robot(reply.joints)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`joints` is a map of your joint names to their positions, for any number of arms. `images` is optional, and each frame is JPEG bytes. `momentum/scripted` nudges every joint a little for a few moves and then finishes, so you can check the loop with no model provider involved.
|
|
41
|
+
|
|
42
|
+
**With no details from you, nothing checks the targets.** A bad reply moves the arm as far as your robot's own limits allow. `control_robot` raises `RoboRouterError` when it can't get an answer, so hold your robot on an exception.
|
|
43
|
+
|
|
44
|
+
`control_robot` drives one robot per process. For several robots, give each its own `roborouter.Router()` and call `router.control_robot(...)`.
|
|
45
|
+
|
|
46
|
+
`reply.usage` has tokens and cost. `reply.timings` has the time each stage took, in milliseconds.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# RoboRouter
|
|
2
|
+
|
|
3
|
+
Run a robot, in a simulator or the real world, on any model with a key and one call.
|
|
4
|
+
|
|
5
|
+
**Not ready for use yet.** The service isn't open to new users, and the only model so far is a test script.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install roborouter
|
|
9
|
+
export MOMENTUM_API_KEY=fw_cli_... # Settings, Workspace API keys
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import roborouter
|
|
14
|
+
|
|
15
|
+
config = {"model": "momentum/scripted"}
|
|
16
|
+
|
|
17
|
+
while True:
|
|
18
|
+
state = {"joints": read_my_joints(), "images": {"top": read_my_camera_jpeg()}}
|
|
19
|
+
reply = roborouter.control_robot(config, state, "pick up the fork")
|
|
20
|
+
if reply.finished:
|
|
21
|
+
break
|
|
22
|
+
move_my_robot(reply.joints)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`joints` is a map of your joint names to their positions, for any number of arms. `images` is optional, and each frame is JPEG bytes. `momentum/scripted` nudges every joint a little for a few moves and then finishes, so you can check the loop with no model provider involved.
|
|
26
|
+
|
|
27
|
+
**With no details from you, nothing checks the targets.** A bad reply moves the arm as far as your robot's own limits allow. `control_robot` raises `RoboRouterError` when it can't get an answer, so hold your robot on an exception.
|
|
28
|
+
|
|
29
|
+
`control_robot` drives one robot per process. For several robots, give each its own `roborouter.Router()` and call `router.control_robot(...)`.
|
|
30
|
+
|
|
31
|
+
`reply.usage` has tokens and cost. `reply.timings` has the time each stage took, in milliseconds.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "roborouter"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Run a robot on any model with a key and one call."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
authors = [{ name = "Momentum" }]
|
|
9
|
+
keywords = ["momentum", "robotics", "models"]
|
|
10
|
+
# The install stays small: no torch, numpy or robot libraries.
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.28",
|
|
13
|
+
"msgpack>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=9.1", "ruff>=0.16"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/roborouter"]
|
|
25
|
+
|
|
26
|
+
# Only the package itself goes public; tests, examples and benchmarks stay in this repo.
|
|
27
|
+
[tool.hatch.build.targets.sdist]
|
|
28
|
+
include = ["src/roborouter"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``control_robot``: send one move to a model and get the next joint targets back.
|
|
2
|
+
|
|
3
|
+
This makes a run ID the first time an instruction is sent, then passes it with a step number on every
|
|
4
|
+
call, and the server looks up the rest. A call only carries this move's state, as msgpack with camera
|
|
5
|
+
frames as raw bytes, because encoding what comes off the robot is one of the biggest latency costs we
|
|
6
|
+
control. One kept-alive connection serves every call, so a call doesn't pay for a new TLS handshake.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import msgpack
|
|
19
|
+
|
|
20
|
+
DEFAULT_API_URL = "https://api-aws.momentumbots.io/api"
|
|
21
|
+
TURNS_PATH = "/v1/router/turns"
|
|
22
|
+
MEDIA_TYPE = "application/msgpack"
|
|
23
|
+
# How long a model may take to answer. Assumed for now; real models may need it changed.
|
|
24
|
+
CALL_TIMEOUT_SECONDS = 120
|
|
25
|
+
RETRY_DELAYS_SECONDS = (0.5, 1.0, 2.0)
|
|
26
|
+
_RETRY_STATUSES = {502, 503, 504}
|
|
27
|
+
# The server won't take another step on these runs, so the next call starts a new one.
|
|
28
|
+
_REFUSED_RUN_CODES = {"out_of_order", "run_finished", "run_mismatch"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RoboRouterError(Exception):
|
|
32
|
+
"""No answer for this move. Hold the robot."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code: str, message: str, param: str | None = None) -> None:
|
|
35
|
+
super().__init__(f"{code}: {message}")
|
|
36
|
+
self.code, self.message, self.param = code, message, param
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Reply:
|
|
41
|
+
joints: dict[str, float] | None
|
|
42
|
+
finished: bool
|
|
43
|
+
reason: str | None
|
|
44
|
+
message: str | None
|
|
45
|
+
answered_by: str
|
|
46
|
+
usage: dict
|
|
47
|
+
timings: dict
|
|
48
|
+
run_id: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _Run:
|
|
53
|
+
id: str
|
|
54
|
+
step: int = 0
|
|
55
|
+
in_flight: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _model_of(config: object) -> str:
|
|
59
|
+
model = config.get("model") if isinstance(config, dict) else None
|
|
60
|
+
if not isinstance(model, str) or not model:
|
|
61
|
+
raise RoboRouterError("invalid_config", 'config needs a model, as {"model": "momentum/scripted"}.', "model")
|
|
62
|
+
return model
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _joints_of(state: object) -> dict[str, float]:
|
|
66
|
+
joints = state.get("joints") if isinstance(state, dict) else None
|
|
67
|
+
if not isinstance(joints, dict) or not joints:
|
|
68
|
+
raise RoboRouterError("invalid_state", "state needs joints, a map of joint names to positions.", "joints")
|
|
69
|
+
positions = {}
|
|
70
|
+
for name, value in joints.items():
|
|
71
|
+
try:
|
|
72
|
+
position = float(value)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
raise RoboRouterError("invalid_state", f"Joint {name} is not a number: {value!r}.", f"joints.{name}")
|
|
75
|
+
if not math.isfinite(position):
|
|
76
|
+
raise RoboRouterError("invalid_state", f"Joint {name} is {position}.", f"joints.{name}")
|
|
77
|
+
positions[name] = position
|
|
78
|
+
return positions
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _images_of(state: dict) -> dict | None:
|
|
82
|
+
images = state.get("images")
|
|
83
|
+
if images is None:
|
|
84
|
+
return None
|
|
85
|
+
if not isinstance(images, dict):
|
|
86
|
+
raise RoboRouterError("invalid_state", "images must be a map of camera names to JPEG bytes.", "images")
|
|
87
|
+
for name, frame in images.items():
|
|
88
|
+
if not isinstance(frame, (bytes, bytearray, memoryview)):
|
|
89
|
+
raise RoboRouterError("invalid_state", f"Camera {name} needs JPEG bytes, not {type(frame).__name__}.",
|
|
90
|
+
f"images.{name}")
|
|
91
|
+
return images
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _api_error(response: httpx.Response) -> RoboRouterError:
|
|
95
|
+
try:
|
|
96
|
+
error = response.json()["error"]
|
|
97
|
+
return RoboRouterError(error["code"], error["message"], error.get("param"))
|
|
98
|
+
except (ValueError, KeyError, TypeError):
|
|
99
|
+
return RoboRouterError("http_error", f"The API answered with HTTP {response.status_code}.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _reply_of(response: httpx.Response, run_id: str, extra_timings: dict) -> Reply:
|
|
103
|
+
try:
|
|
104
|
+
answer = msgpack.unpackb(response.content, raw=False)
|
|
105
|
+
return Reply(joints=answer["joints"], finished=answer["finished"], reason=answer["reason"],
|
|
106
|
+
message=answer["message"], answered_by=answer["answered_by"], usage=answer["usage"],
|
|
107
|
+
timings={**answer["timings"], **extra_timings}, run_id=run_id)
|
|
108
|
+
except (ValueError, TypeError, KeyError, msgpack.UnpackException):
|
|
109
|
+
raise RoboRouterError("bad_reply", "The API's reply couldn't be read. Hold the robot.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Router:
|
|
113
|
+
"""Holds the connection and the runs in progress. ``control_robot`` at module level uses a shared
|
|
114
|
+
one, which drives one robot. A process driving several robots gives each its own Router."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None,
|
|
117
|
+
http: httpx.Client | None = None, sleep=time.sleep) -> None:
|
|
118
|
+
self._api_key, self._base_url, self._http, self._sleep = api_key, base_url, http, sleep
|
|
119
|
+
self._runs: dict[tuple[str, str], _Run] = {}
|
|
120
|
+
self._lock = threading.Lock()
|
|
121
|
+
|
|
122
|
+
def control_robot(self, config: dict, state: dict, instructions: str) -> Reply:
|
|
123
|
+
api_key = self._api_key or os.environ.get("MOMENTUM_API_KEY")
|
|
124
|
+
if not api_key:
|
|
125
|
+
raise RoboRouterError("no_api_key", "Set MOMENTUM_API_KEY to a workspace API key (fw_cli_…).")
|
|
126
|
+
started = time.perf_counter()
|
|
127
|
+
model = _model_of(config)
|
|
128
|
+
joints = _joints_of(state)
|
|
129
|
+
images = _images_of(state)
|
|
130
|
+
if not isinstance(instructions, str) or not instructions:
|
|
131
|
+
raise RoboRouterError("invalid_instructions", "instructions must be a non-empty string.", "instructions")
|
|
132
|
+
|
|
133
|
+
run_key = (model, instructions)
|
|
134
|
+
with self._lock:
|
|
135
|
+
run = self._runs.setdefault(run_key, _Run(id=uuid.uuid4().hex))
|
|
136
|
+
if run.in_flight:
|
|
137
|
+
raise RoboRouterError("run_busy", "Another call is already moving this run. To drive two robots "
|
|
138
|
+
"in one process, give each its own roborouter.Router().")
|
|
139
|
+
run.in_flight = True
|
|
140
|
+
try:
|
|
141
|
+
reply = self._move(run, model, joints, images, instructions, api_key, started)
|
|
142
|
+
except RoboRouterError as error:
|
|
143
|
+
if error.code in _REFUSED_RUN_CODES:
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._runs.pop(run_key, None)
|
|
146
|
+
raise
|
|
147
|
+
else:
|
|
148
|
+
with self._lock:
|
|
149
|
+
if reply.finished:
|
|
150
|
+
self._runs.pop(run_key, None)
|
|
151
|
+
else:
|
|
152
|
+
run.step += 1
|
|
153
|
+
return reply
|
|
154
|
+
finally:
|
|
155
|
+
run.in_flight = False
|
|
156
|
+
|
|
157
|
+
def _move(self, run: _Run, model: str, joints: dict, images: dict | None, instructions: str, api_key: str,
|
|
158
|
+
started: float) -> Reply:
|
|
159
|
+
body = {"run_id": run.id, "step": run.step, "model": model, "joints": joints}
|
|
160
|
+
if images:
|
|
161
|
+
body["images"] = images
|
|
162
|
+
if run.step == 0:
|
|
163
|
+
body["instructions"] = instructions
|
|
164
|
+
payload = msgpack.packb(body, use_bin_type=True)
|
|
165
|
+
encode_ms = (time.perf_counter() - started) * 1000
|
|
166
|
+
|
|
167
|
+
sent = time.perf_counter()
|
|
168
|
+
response = self._post(payload, api_key)
|
|
169
|
+
round_trip_ms = (time.perf_counter() - sent) * 1000
|
|
170
|
+
if response.status_code != 200:
|
|
171
|
+
raise _api_error(response)
|
|
172
|
+
return _reply_of(response, run.id, {"encode_ms": encode_ms, "round_trip_ms": round_trip_ms})
|
|
173
|
+
|
|
174
|
+
def _post(self, payload: bytes, api_key: str) -> httpx.Response:
|
|
175
|
+
"""Post, retrying the same step when the API can't be reached. The server returns its stored
|
|
176
|
+
reply for a step it already answered, so a retry never asks the model twice."""
|
|
177
|
+
base_url = self._base_url or os.environ.get("MOMENTUM_API_URL") or DEFAULT_API_URL
|
|
178
|
+
url = base_url.rstrip("/") + TURNS_PATH
|
|
179
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": MEDIA_TYPE}
|
|
180
|
+
if self._http is None:
|
|
181
|
+
self._http = httpx.Client(timeout=CALL_TIMEOUT_SECONDS)
|
|
182
|
+
problem = "no answer"
|
|
183
|
+
for delay in (*RETRY_DELAYS_SECONDS, None):
|
|
184
|
+
try:
|
|
185
|
+
response = self._http.post(url, content=payload, headers=headers)
|
|
186
|
+
if response.status_code not in _RETRY_STATUSES:
|
|
187
|
+
return response
|
|
188
|
+
problem = f"HTTP {response.status_code}"
|
|
189
|
+
except httpx.TransportError as exc:
|
|
190
|
+
problem = type(exc).__name__
|
|
191
|
+
if delay is not None:
|
|
192
|
+
self._sleep(delay)
|
|
193
|
+
raise RoboRouterError("unreachable", f"Could not reach {base_url}: {problem}. Hold the robot.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_default_router = Router()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def control_robot(config: dict, state: dict, instructions: str) -> Reply:
|
|
200
|
+
"""Send this move to the model in ``config`` and get the robot's next joint targets.
|
|
201
|
+
|
|
202
|
+
``config`` needs ``{"model": ...}``. ``state`` needs ``{"joints": {name: position}}`` and may have
|
|
203
|
+
``{"images": {camera: jpeg_bytes}}``. Raises :class:`RoboRouterError` when there's no answer, so
|
|
204
|
+
hold the robot on an exception.
|
|
205
|
+
"""
|
|
206
|
+
return _default_router.control_robot(config, state, instructions)
|