sim2bot 0.1.0__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.
sim2bot/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Python client for the Sim2Bot browser robot simulator.
2
+
3
+ Starts (or connects to) the local bridge and exposes a robot endpoint:
4
+ control commands, telemetry, scene discovery, and camera feeds — the same way
5
+ you'd talk to a real robot.
6
+
7
+ from sim2bot import Robot
8
+
9
+ with Robot() as robot:
10
+ info = robot.describe() # what's loaded (dof, home, cameras, ...)
11
+ robot.move_to(info[0].home) # command joint positions
12
+ print(robot.state().q) # read telemetry
13
+ for frame in robot.camera("model:0", fps=30):
14
+ img = frame.image() # numpy BGR (needs the [cv2] extra)
15
+ """
16
+
17
+ from .client import (
18
+ CameraFrame,
19
+ CameraStream,
20
+ Robot,
21
+ RobotInfo,
22
+ RobotState,
23
+ RoomDeviceInfo,
24
+ )
25
+ from .bridge import BridgeInfo, ensure_bridge, is_bridge_running, shutdown_managed_bridges
26
+
27
+ __all__ = [
28
+ "Robot",
29
+ "RobotState",
30
+ "RobotInfo",
31
+ "RoomDeviceInfo",
32
+ "CameraFrame",
33
+ "CameraStream",
34
+ "BridgeInfo",
35
+ "ensure_bridge",
36
+ "is_bridge_running",
37
+ "shutdown_managed_bridges",
38
+ ]
sim2bot/bridge.py ADDED
@@ -0,0 +1,232 @@
1
+ """Helpers for starting and checking the local Sim2Bot bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+ from urllib.parse import urlparse
15
+
16
+ DEFAULT_CONTROL_URL = "ws://localhost:8765/ws"
17
+ DEFAULT_TCP_PORT = 8770
18
+ DEFAULT_UDP_PORT = 8771
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BridgeInfo:
23
+ """Connection details for a local Sim2Bot bridge.
24
+
25
+ Attributes:
26
+ url: JSON control and telemetry WebSocket URL.
27
+ video_url: Binary camera-frame WebSocket URL.
28
+ health_url: HTTP health-check URL.
29
+ host: Parsed bridge host.
30
+ port: WebSocket/HTTP port.
31
+ tcp_port: Newline-delimited JSON TCP port.
32
+ udp_port: Datagram control/telemetry port.
33
+ started_by_sdk: Whether [`ensure_bridge`][sim2bot.bridge.ensure_bridge]
34
+ started this process.
35
+ pid: Managed bridge process ID when available.
36
+ """
37
+
38
+ url: str
39
+ video_url: str
40
+ health_url: str
41
+ host: str
42
+ port: int
43
+ tcp_port: int
44
+ udp_port: int
45
+ started_by_sdk: bool = False
46
+ pid: Optional[int] = None
47
+
48
+
49
+ _managed_processes: list[subprocess.Popen] = []
50
+
51
+
52
+ def video_url(control_url: str = DEFAULT_CONTROL_URL) -> str:
53
+ """Return the binary video WebSocket URL for a control WebSocket URL."""
54
+ if control_url.endswith("/ws"):
55
+ return control_url[: -len("/ws")] + "/video"
56
+ return control_url.rstrip("/") + "/video"
57
+
58
+
59
+ def health_url(control_url: str = DEFAULT_CONTROL_URL) -> str:
60
+ parsed = urlparse(control_url)
61
+ scheme = "https" if parsed.scheme == "wss" else "http"
62
+ host = parsed.hostname or "localhost"
63
+ port = parsed.port or (443 if parsed.scheme == "wss" else 80)
64
+ return f"{scheme}://{host}:{port}/health"
65
+
66
+
67
+ def bridge_info(
68
+ url: str = DEFAULT_CONTROL_URL,
69
+ tcp_port: int = DEFAULT_TCP_PORT,
70
+ udp_port: int = DEFAULT_UDP_PORT,
71
+ started_by_sdk: bool = False,
72
+ pid: Optional[int] = None,
73
+ ) -> BridgeInfo:
74
+ parsed = urlparse(url)
75
+ host = parsed.hostname or "localhost"
76
+ port = parsed.port or (443 if parsed.scheme == "wss" else 80)
77
+ return BridgeInfo(
78
+ url=url,
79
+ video_url=video_url(url),
80
+ health_url=health_url(url),
81
+ host=host,
82
+ port=port,
83
+ tcp_port=tcp_port,
84
+ udp_port=udp_port,
85
+ started_by_sdk=started_by_sdk,
86
+ pid=pid,
87
+ )
88
+
89
+
90
+ def is_bridge_running(url: str = DEFAULT_CONTROL_URL, timeout: float = 0.5) -> bool:
91
+ """Check whether the bridge health endpoint responds successfully.
92
+
93
+ Args:
94
+ url: Bridge control WebSocket URL used to derive the health URL.
95
+ timeout: HTTP timeout in seconds.
96
+
97
+ Returns:
98
+ ``True`` only when the endpoint responds with HTTP 200.
99
+ """
100
+ try:
101
+ with urllib.request.urlopen(health_url(url), timeout=timeout) as response:
102
+ return response.status == 200
103
+ except (OSError, urllib.error.URLError):
104
+ return False
105
+
106
+
107
+ def wait_for_bridge(url: str = DEFAULT_CONTROL_URL, timeout: float = 10.0) -> bool:
108
+ """Poll until the bridge becomes healthy or timeout expires."""
109
+ deadline = time.time() + timeout
110
+ while time.time() < deadline:
111
+ if is_bridge_running(url, timeout=0.3):
112
+ return True
113
+ time.sleep(0.1)
114
+ return is_bridge_running(url, timeout=0.3)
115
+
116
+
117
+ def ensure_bridge(
118
+ url: str = DEFAULT_CONTROL_URL,
119
+ *,
120
+ host: Optional[str] = None,
121
+ tcp_port: int = DEFAULT_TCP_PORT,
122
+ udp_port: int = DEFAULT_UDP_PORT,
123
+ timeout: float = 10.0,
124
+ ) -> BridgeInfo:
125
+ """Reuse a healthy local bridge or start one in the background.
126
+
127
+ Args:
128
+ url: Desired control WebSocket URL.
129
+ host: Bind host for a new process. Defaults to loopback unless configured
130
+ with ``BRIDGE_HOST``.
131
+ tcp_port: Newline-delimited JSON TCP port.
132
+ udp_port: Datagram control/telemetry port.
133
+ timeout: Seconds to wait for a new bridge to become healthy.
134
+
135
+ Returns:
136
+ Connection and ownership details as
137
+ [`BridgeInfo`][sim2bot.bridge.BridgeInfo].
138
+
139
+ Raises:
140
+ RuntimeError: If a new bridge exits early or does not become healthy.
141
+
142
+ Notes:
143
+ The subprocess uses the current Python interpreter and is terminated at
144
+ interpreter exit. Use the ``sim2bot bridge`` CLI for a manually managed,
145
+ long-running process. The default loopback bind is deliberate; LAN access
146
+ requires explicit host and authentication configuration.
147
+ """
148
+ if is_bridge_running(url):
149
+ return bridge_info(url, tcp_port=tcp_port, udp_port=udp_port)
150
+
151
+ parsed = urlparse(url)
152
+ bind_host = host or os.environ.get("BRIDGE_HOST") or "127.0.0.1"
153
+ port = parsed.port or 8765
154
+ env = os.environ.copy()
155
+ env["BRIDGE_TCP_PORT"] = str(tcp_port)
156
+ env["BRIDGE_UDP_PORT"] = str(udp_port)
157
+ env["BRIDGE_RAW_HOST"] = bind_host
158
+ env.setdefault("PYTHONUNBUFFERED", "1")
159
+
160
+ cmd = [
161
+ sys.executable,
162
+ "-m",
163
+ "uvicorn",
164
+ "sim2bot.bridge_server:app",
165
+ "--host",
166
+ bind_host,
167
+ "--port",
168
+ str(port),
169
+ "--log-level",
170
+ "warning",
171
+ ]
172
+ process = subprocess.Popen(
173
+ cmd,
174
+ env=env,
175
+ stdout=subprocess.DEVNULL,
176
+ stderr=subprocess.DEVNULL,
177
+ start_new_session=True,
178
+ )
179
+ _managed_processes.append(process)
180
+ _register_cleanup()
181
+
182
+ if wait_for_bridge(url, timeout=timeout):
183
+ return bridge_info(
184
+ url,
185
+ tcp_port=tcp_port,
186
+ udp_port=udp_port,
187
+ started_by_sdk=True,
188
+ pid=process.pid,
189
+ )
190
+
191
+ exit_code = process.poll()
192
+ if is_bridge_running(url):
193
+ return bridge_info(url, tcp_port=tcp_port, udp_port=udp_port)
194
+ if exit_code is None:
195
+ _terminate_process(process)
196
+ raise RuntimeError(f"Sim2Bot bridge did not become healthy within {timeout:.1f}s")
197
+ raise RuntimeError(
198
+ f"Sim2Bot bridge failed to start (exit code {exit_code}); "
199
+ "run `sim2bot bridge` to see server logs"
200
+ )
201
+
202
+
203
+ def shutdown_managed_bridges() -> None:
204
+ """Terminate bridge processes started by
205
+ [`ensure_bridge`][sim2bot.bridge.ensure_bridge] in this process.
206
+
207
+ Bridges that were already running and merely discovered are not stopped.
208
+
209
+ Returns:
210
+ Already-running bridges not owned by this process remain active.
211
+ """
212
+ for process in list(_managed_processes):
213
+ _terminate_process(process)
214
+ _managed_processes.clear()
215
+
216
+
217
+ def _register_cleanup() -> None:
218
+ if getattr(_register_cleanup, "_registered", False):
219
+ return
220
+ atexit.register(shutdown_managed_bridges)
221
+ setattr(_register_cleanup, "_registered", True)
222
+
223
+
224
+ def _terminate_process(process: subprocess.Popen) -> None:
225
+ if process.poll() is not None:
226
+ return
227
+ process.terminate()
228
+ try:
229
+ process.wait(timeout=2.0)
230
+ except subprocess.TimeoutExpired:
231
+ process.kill()
232
+ process.wait(timeout=2.0)