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/cli.py ADDED
@@ -0,0 +1,292 @@
1
+ """Command line interface for the Sim2Bot Python package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.metadata
7
+ import json
8
+ import os
9
+ import socket
10
+ import sys
11
+ import webbrowser
12
+
13
+ from websockets.exceptions import ConnectionClosed
14
+ from websockets.sync.client import connect as ws_connect
15
+
16
+ from .bridge import (
17
+ DEFAULT_CONTROL_URL,
18
+ DEFAULT_TCP_PORT,
19
+ DEFAULT_UDP_PORT,
20
+ bridge_info,
21
+ health_url,
22
+ is_bridge_running,
23
+ video_url,
24
+ )
25
+
26
+ # The hosted app is the right default for an installed package. Sim2Bot's own
27
+ # developers point this at their dev server with SIM2BOT_APP_URL.
28
+ DEFAULT_APP_URL = os.environ.get("SIM2BOT_APP_URL", "https://app.sim2bot.com")
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = build_parser()
33
+ args = parser.parse_args(argv)
34
+ if not args.command:
35
+ parser.print_help()
36
+ return 0
37
+ return int(args.func(args) or 0)
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(prog="sim2bot", description="Sim2Bot SDK tools")
42
+ sub = parser.add_subparsers(dest="command")
43
+
44
+ bridge = sub.add_parser("bridge", help="run the local Sim2Bot bridge")
45
+ bridge.add_argument("--host", default=os.environ.get("BRIDGE_HOST", "127.0.0.1"))
46
+ bridge.add_argument("--port", type=int, default=int(os.environ.get("BRIDGE_PORT", "8765")))
47
+ bridge.add_argument(
48
+ "--tcp-port", type=int, default=int(os.environ.get("BRIDGE_TCP_PORT", DEFAULT_TCP_PORT))
49
+ )
50
+ bridge.add_argument(
51
+ "--udp-port", type=int, default=int(os.environ.get("BRIDGE_UDP_PORT", DEFAULT_UDP_PORT))
52
+ )
53
+ bridge.add_argument("--log-level", default="info")
54
+ bridge.set_defaults(func=run_bridge)
55
+
56
+ doctor = sub.add_parser("doctor", help="check bridge, browser, and SDK connectivity")
57
+ doctor.add_argument("--url", default=DEFAULT_CONTROL_URL)
58
+ doctor.add_argument("--room", default=os.environ.get("SIM2BOT_BRIDGE_ROOM"))
59
+ doctor.add_argument("--tcp-port", type=int, default=DEFAULT_TCP_PORT)
60
+ doctor.add_argument("--udp-port", type=int, default=DEFAULT_UDP_PORT)
61
+ doctor.add_argument("--timeout", type=float, default=2.0)
62
+ doctor.set_defaults(func=run_doctor)
63
+
64
+ list_robots = sub.add_parser("list-robots", help="list robots announced by the browser sim")
65
+ list_robots.add_argument("--url", default=DEFAULT_CONTROL_URL)
66
+ list_robots.add_argument("--room", default=os.environ.get("SIM2BOT_BRIDGE_ROOM"))
67
+ list_robots.add_argument("--timeout", type=float, default=30.0)
68
+ list_robots.add_argument("--json", action="store_true", help="print machine-readable JSON")
69
+ list_robots.set_defaults(func=run_list_robots)
70
+
71
+ open_cmd = sub.add_parser("open", help="open the Sim2Bot web app")
72
+ open_cmd.add_argument("--url", default=DEFAULT_APP_URL)
73
+ open_cmd.set_defaults(func=run_open)
74
+
75
+ return parser
76
+
77
+
78
+ def run_bridge(args: argparse.Namespace) -> int:
79
+ os.environ["BRIDGE_TCP_PORT"] = str(args.tcp_port)
80
+ os.environ["BRIDGE_UDP_PORT"] = str(args.udp_port)
81
+ os.environ["BRIDGE_RAW_HOST"] = args.host
82
+
83
+ import uvicorn
84
+
85
+ url = f"ws://{_display_host(args.host)}:{args.port}/ws"
86
+ print("Sim2Bot bridge running")
87
+ print(f"Control WS: {url}")
88
+ print(f"Video WS: {video_url(url)}")
89
+ print(f"TCP: {_display_host(args.host)}:{args.tcp_port}")
90
+ print(f"UDP: {_display_host(args.host)}:{args.udp_port}")
91
+ if args.host not in {"127.0.0.1", "localhost", "::1"} and not (
92
+ os.environ.get("SIM2BOT_BRIDGE_TOKEN")
93
+ or os.environ.get("BRIDGE_AUTH_TOKEN")
94
+ or os.environ.get("SIM2BOT_API_KEY")
95
+ ):
96
+ print("LAN clients need SIM2BOT_BRIDGE_TOKEN or BRIDGE_AUTH_TOKEN.")
97
+ print("Waiting for browser simulator...")
98
+ uvicorn.run(
99
+ "sim2bot.bridge_server:app",
100
+ host=args.host,
101
+ port=args.port,
102
+ log_level=args.log_level,
103
+ )
104
+ return 0
105
+
106
+
107
+ def run_doctor(args: argparse.Namespace) -> int:
108
+ info = bridge_info(args.url, tcp_port=args.tcp_port, udp_port=args.udp_port)
109
+ print("Sim2Bot doctor")
110
+ print("")
111
+ print(f"SDK: {_version()}")
112
+ print(f"Control: {info.url}")
113
+ print(f"Video: {info.video_url}")
114
+ print(f"Health: {info.health_url}")
115
+
116
+ if not is_bridge_running(args.url, timeout=args.timeout):
117
+ print("Bridge: not running")
118
+ print("")
119
+ print("Start it with `sim2bot bridge`, or use `Robot(auto_bridge=True)`.")
120
+ return 1
121
+
122
+ print("Bridge: running")
123
+ _check_scene(args)
124
+ _check_video(args)
125
+ _check_tcp(info, args.timeout, args.room)
126
+ _check_udp(info, args.timeout, args.room)
127
+ _check_cv2()
128
+ return 0
129
+
130
+
131
+ def run_list_robots(args: argparse.Namespace) -> int:
132
+ from .client import Robot
133
+
134
+ try:
135
+ with Robot(
136
+ url=args.url,
137
+ auto_bridge=True,
138
+ wait_for_sim=True,
139
+ wait_for_sim_timeout=args.timeout,
140
+ room=args.room,
141
+ ) as robot:
142
+ robots = robot.describe()
143
+ except TimeoutError:
144
+ print("No robots found.")
145
+ print("Open the Sim2Bot web app, then Tools -> Bridge -> Connect.")
146
+ return 1
147
+
148
+ if args.json:
149
+ print(json.dumps([_robot_info_dict(robot) for robot in robots], indent=2))
150
+ return 0
151
+
152
+ print(f"Robots: {len(robots)}")
153
+ for robot in robots:
154
+ _print_robot_info(robot)
155
+ return 0
156
+
157
+
158
+ def run_open(args: argparse.Namespace) -> int:
159
+ print(f"Opening {args.url}")
160
+ ok = webbrowser.open(args.url)
161
+ if not ok:
162
+ print("Could not open a browser automatically.")
163
+ return 1
164
+ return 0
165
+
166
+
167
+ def _check_scene(args: argparse.Namespace) -> None:
168
+ try:
169
+ with ws_connect(args.url, open_timeout=args.timeout) as ws:
170
+ ws.send(json.dumps(_with_room({"type": "hello", "role": "controller"}, args.room)))
171
+ ws.send(json.dumps(_with_room({"type": "describe"}, args.room)))
172
+ scene = _recv_type(ws, "scene", args.timeout)
173
+ except Exception as exc:
174
+ print(f"Browser: control WS failed ({exc})")
175
+ return
176
+
177
+ robots = scene.get("robots", [])
178
+ cameras = scene.get("cameras", [])
179
+ if robots:
180
+ print(f"Browser: connected ({len(robots)} robot(s))")
181
+ for robot in robots:
182
+ print(
183
+ " "
184
+ f"[{robot.get('index', 0)}] {robot.get('name', '')}, "
185
+ f"id={robot.get('id') or '(none)'}, "
186
+ f"dof={robot.get('dof', '?')}, "
187
+ f"gripper={'yes' if robot.get('hasGripper') else 'no'}"
188
+ )
189
+ else:
190
+ print("Browser: no simulator scene announced yet")
191
+ print(f"Cameras: {len(cameras)} available")
192
+ for camera in cameras:
193
+ print(f" {camera.get('id')} ({camera.get('label', camera.get('kind', 'camera'))})")
194
+
195
+
196
+ def _check_video(args: argparse.Namespace) -> None:
197
+ try:
198
+ with ws_connect(video_url(args.url), open_timeout=args.timeout, max_size=None) as ws:
199
+ ws.send(json.dumps(_with_room({"type": "hello", "role": "controller-video"}, args.room)))
200
+ print("Video: reachable")
201
+ except Exception as exc:
202
+ print(f"Video: failed ({exc})")
203
+
204
+
205
+ def _check_tcp(info, timeout: float, room: str | None = None) -> None:
206
+ try:
207
+ with socket.create_connection((info.host, info.tcp_port), timeout=timeout) as sock:
208
+ sock.settimeout(timeout)
209
+ sock.sendall(json.dumps(_with_room({"type": "describe"}, room)).encode() + b"\n")
210
+ sock.recv(65535)
211
+ print("TCP: reachable")
212
+ except OSError as exc:
213
+ print(f"TCP: failed ({exc})")
214
+
215
+
216
+ def _check_udp(info, timeout: float, room: str | None = None) -> None:
217
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
218
+ sock.settimeout(timeout)
219
+ try:
220
+ sock.sendto(json.dumps(_with_room({"type": "describe"}, room)).encode(), (info.host, info.udp_port))
221
+ sock.recvfrom(65535)
222
+ print("UDP: reachable")
223
+ except OSError as exc:
224
+ print(f"UDP: failed ({exc})")
225
+ finally:
226
+ sock.close()
227
+
228
+
229
+ def _check_cv2() -> None:
230
+ try:
231
+ import cv2 # type: ignore # noqa: F401
232
+ import numpy # type: ignore # noqa: F401
233
+ except ImportError:
234
+ print("OpenCV: not installed (install sim2bot[cv2] for frame.image())")
235
+ return
236
+ print("OpenCV: installed")
237
+
238
+
239
+ def _recv_type(ws, want_type: str, timeout: float) -> dict:
240
+ while True:
241
+ try:
242
+ raw = ws.recv(timeout=timeout)
243
+ except (TimeoutError, ConnectionClosed) as exc:
244
+ raise RuntimeError(f"no {want_type!r} response") from exc
245
+ message = json.loads(raw)
246
+ if message.get("type") == want_type:
247
+ return message
248
+
249
+
250
+ def _with_room(message: dict, room: str | None) -> dict:
251
+ return {**message, "room": room} if room else message
252
+
253
+
254
+ def _display_host(host: str) -> str:
255
+ return "localhost" if host in {"0.0.0.0", "::"} else host
256
+
257
+
258
+ def _print_robot_info(robot) -> None:
259
+ print(f"[{robot.index}] {robot.name}")
260
+ print(f" id: {robot.id or '(not announced)'}")
261
+ print(f" dof: {robot.dof}")
262
+ print(f" joints: {robot.joint_names or '(not announced)'}")
263
+ print(f" home: {robot.home or '(not announced)'}")
264
+ print(f" gripper: {robot.has_gripper}")
265
+ print(f" locomotion: {robot.locomotion}")
266
+ print(f" base_dof: {robot.base_dof}")
267
+
268
+
269
+ def _robot_info_dict(robot) -> dict:
270
+ return {
271
+ "index": robot.index,
272
+ "id": robot.id,
273
+ "name": robot.name,
274
+ "dof": robot.dof,
275
+ "joint_names": robot.joint_names,
276
+ "joint_limits": robot.joint_limits,
277
+ "home": robot.home,
278
+ "has_gripper": robot.has_gripper,
279
+ "locomotion": robot.locomotion,
280
+ "base_dof": robot.base_dof,
281
+ }
282
+
283
+
284
+ def _version() -> str:
285
+ try:
286
+ return importlib.metadata.version("sim2bot")
287
+ except importlib.metadata.PackageNotFoundError:
288
+ return "editable/local"
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main(sys.argv[1:]))