roborouter 0.0.1__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.
roborouter/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """RoboRouter: run a robot on any model with a key and one call."""
2
+ from roborouter.client import Reply, RoboRouterError, Router, control_robot
3
+
4
+ __all__ = ["Reply", "RoboRouterError", "Router", "control_robot"]
roborouter/client.py ADDED
@@ -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)
@@ -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,5 @@
1
+ roborouter/__init__.py,sha256=MntJvGMaAhPmS21yo0EgIhVK36q_vl7_mtTHjkXPsxc,211
2
+ roborouter/client.py,sha256=XbpuqUv1yxXNs-d_aijAeZ1PhVfmuI_22QQCzl94cLg,8927
3
+ roborouter-0.0.1.dist-info/METADATA,sha256=RWx4rgygK_h6GCbsopMuQFTw-u1zI4xxcUNTbuK_3W0,1786
4
+ roborouter-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
5
+ roborouter-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any