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.
@@ -0,0 +1,488 @@
1
+ """Packaged Sim2Bot local bridge server.
2
+
3
+ This is the same FastAPI relay used by the development bridge, but kept inside
4
+ the Python package so `sim2bot bridge` and `Robot(auto_bridge=True)` can start it
5
+ without relying on the repository layout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import ipaddress
12
+ import json
13
+ import os
14
+ import secrets
15
+ import string
16
+ from urllib.parse import urlparse
17
+
18
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
19
+
20
+ app = FastAPI(title="Sim2Bot Simulated Robot Bridge")
21
+
22
+ simulators: set[WebSocket] = set()
23
+ controllers: set[WebSocket] = set()
24
+ websocket_rooms: dict[WebSocket, str] = {}
25
+ latest_simulator_by_room: dict[str, WebSocket] = {}
26
+
27
+ tcp_clients: dict[asyncio.StreamWriter, str] = {}
28
+ udp_clients: dict[tuple[str, int], str] = {}
29
+ udp_transport: asyncio.DatagramTransport | None = None
30
+
31
+ COMMAND_TYPES = {
32
+ "joint_position",
33
+ "joint_velocity",
34
+ "tcp_pose",
35
+ "gripper",
36
+ "room_opening",
37
+ "base_velocity",
38
+ "camera_subscribe",
39
+ "camera_unsubscribe",
40
+ "marker",
41
+ "marker_delete",
42
+ "marker_clear",
43
+ "reset",
44
+ "stop",
45
+ "joint_trajectory",
46
+ "joint_trajectory_stop",
47
+ }
48
+
49
+ DEFAULT_ROOM = "default"
50
+ MAX_ROOM_LENGTH = 80
51
+ ROOM_CHARS = set(string.ascii_letters + string.digits + "._-")
52
+
53
+ scene_by_room: dict[str, dict[str, list]] = {}
54
+
55
+ video_simulators: set[WebSocket] = set()
56
+ video_controllers: set[WebSocket] = set()
57
+ video_rooms: dict[WebSocket, str] = {}
58
+
59
+
60
+ def normalize_room(value: object | None) -> str:
61
+ if not isinstance(value, str):
62
+ return DEFAULT_ROOM
63
+ room = value.strip()
64
+ if not room or len(room) > MAX_ROOM_LENGTH:
65
+ return DEFAULT_ROOM
66
+ if any(ch not in ROOM_CHARS for ch in room):
67
+ return DEFAULT_ROOM
68
+ return room
69
+
70
+
71
+ def message_room(message: dict, fallback: str = DEFAULT_ROOM) -> str:
72
+ return normalize_room(message.get("room") or fallback)
73
+
74
+
75
+ def websocket_room(websocket: WebSocket, fallback: str = DEFAULT_ROOM) -> str:
76
+ return normalize_room(websocket.query_params.get("room") or fallback)
77
+
78
+
79
+ def scene_message(room: str = DEFAULT_ROOM) -> dict:
80
+ scene = scene_by_room.get(room, {"robots": [], "cameras": [], "devices": []})
81
+ return {
82
+ "type": "scene",
83
+ "room": room,
84
+ "robots": scene["robots"],
85
+ "cameras": scene["cameras"],
86
+ "devices": scene.get("devices", []),
87
+ }
88
+
89
+
90
+ def tcp_port() -> int:
91
+ return int(os.environ.get("BRIDGE_TCP_PORT", "8770"))
92
+
93
+
94
+ def udp_port() -> int:
95
+ return int(os.environ.get("BRIDGE_UDP_PORT", "8771"))
96
+
97
+
98
+ def raw_host() -> str:
99
+ return os.environ.get("BRIDGE_RAW_HOST") or os.environ.get("BRIDGE_HOST") or "127.0.0.1"
100
+
101
+
102
+ @app.get("/health")
103
+ async def health() -> dict[str, str]:
104
+ return {"status": "ok"}
105
+
106
+
107
+ def bridge_token() -> str | None:
108
+ return (
109
+ os.environ.get("SIM2BOT_BRIDGE_TOKEN")
110
+ or os.environ.get("BRIDGE_AUTH_TOKEN")
111
+ or os.environ.get("SIM2BOT_API_KEY")
112
+ )
113
+
114
+
115
+ def is_loopback_host(host: str | None) -> bool:
116
+ if not host:
117
+ return False
118
+ if host == "localhost":
119
+ return True
120
+ try:
121
+ return ipaddress.ip_address(host).is_loopback
122
+ except ValueError:
123
+ return False
124
+
125
+
126
+ def is_loopback_client(websocket: WebSocket) -> bool:
127
+ return is_loopback_host(websocket.client.host if websocket.client else None)
128
+
129
+
130
+ def allowed_origin(websocket: WebSocket) -> bool:
131
+ origin = websocket.headers.get("origin")
132
+ if not origin:
133
+ return True
134
+
135
+ allowed = {
136
+ item.strip()
137
+ for item in os.environ.get("BRIDGE_ALLOWED_ORIGINS", "").split(",")
138
+ if item.strip()
139
+ }
140
+ if origin in allowed:
141
+ return True
142
+
143
+ parsed = urlparse(origin)
144
+ return is_loopback_host(parsed.hostname)
145
+
146
+
147
+ def websocket_token(websocket: WebSocket) -> str | None:
148
+ for key in ("token", "api_key", "authToken"):
149
+ value = websocket.query_params.get(key)
150
+ if value:
151
+ return value
152
+ auth = websocket.headers.get("authorization", "")
153
+ if auth.lower().startswith("bearer "):
154
+ return auth[7:].strip()
155
+ return websocket.headers.get("x-sim2bot-token")
156
+
157
+
158
+ def token_matches(value: str | None) -> bool:
159
+ expected = bridge_token()
160
+ return bool(expected and value and secrets.compare_digest(value, expected))
161
+
162
+
163
+ async def authorize_websocket(websocket: WebSocket) -> bool:
164
+ if not allowed_origin(websocket):
165
+ await websocket.close(code=1008, reason="Origin is not allowed")
166
+ return False
167
+ if is_loopback_client(websocket):
168
+ return True
169
+ if token_matches(websocket_token(websocket)):
170
+ return True
171
+ await websocket.close(code=1008, reason="Remote bridge access requires a token")
172
+ return False
173
+
174
+
175
+ def sanitize_message(message: dict) -> dict:
176
+ if not any(key in message for key in ("authToken", "apiKey", "api_key", "token")):
177
+ return message
178
+ cleaned = dict(message)
179
+ for key in ("authToken", "apiKey", "api_key", "token"):
180
+ cleaned.pop(key, None)
181
+ return cleaned
182
+
183
+
184
+ def message_token(message: dict) -> str | None:
185
+ for key in ("authToken", "apiKey", "api_key", "token"):
186
+ value = message.get(key)
187
+ if isinstance(value, str) and value:
188
+ return value
189
+ return None
190
+
191
+
192
+ def raw_client_authorized(host: str | None, message: dict) -> bool:
193
+ return is_loopback_host(host) or token_matches(message_token(message))
194
+
195
+
196
+ async def broadcast(targets, message: dict) -> None:
197
+ dead: list[WebSocket] = []
198
+ for ws in list(targets):
199
+ try:
200
+ await ws.send_json(message)
201
+ except Exception:
202
+ dead.append(ws)
203
+ for ws in dead:
204
+ targets.discard(ws)
205
+
206
+
207
+ def sockets_in_room(sockets: set[WebSocket], rooms: dict[WebSocket, str], room: str) -> set[WebSocket]:
208
+ return {ws for ws in sockets if rooms.get(ws, DEFAULT_ROOM) == room}
209
+
210
+
211
+ def simulator_targets(room: str) -> set[WebSocket]:
212
+ latest = latest_simulator_by_room.get(room)
213
+ if latest in simulators and websocket_rooms.get(latest, DEFAULT_ROOM) == room:
214
+ return {latest}
215
+ return sockets_in_room(simulators, websocket_rooms, room)
216
+
217
+
218
+ def controller_targets(room: str) -> set[WebSocket]:
219
+ return sockets_in_room(controllers, websocket_rooms, room)
220
+
221
+
222
+ def register_websocket(websocket: WebSocket, role: str, room: str) -> None:
223
+ old_room = websocket_rooms.get(websocket)
224
+ simulators.discard(websocket)
225
+ controllers.discard(websocket)
226
+ websocket_rooms[websocket] = room
227
+ if role == "simulator":
228
+ simulators.add(websocket)
229
+ latest_simulator_by_room[room] = websocket
230
+ else:
231
+ controllers.add(websocket)
232
+ if old_room and old_room != room and latest_simulator_by_room.get(old_room) is websocket:
233
+ promote_latest_simulator(old_room)
234
+
235
+
236
+ def promote_latest_simulator(room: str) -> None:
237
+ for ws in list(simulators):
238
+ if websocket_rooms.get(ws, DEFAULT_ROOM) == room:
239
+ latest_simulator_by_room[room] = ws
240
+ return
241
+ latest_simulator_by_room.pop(room, None)
242
+
243
+
244
+ def forward_to_simulators(message: dict, room: str | None = None) -> None:
245
+ if message.get("type") in COMMAND_TYPES:
246
+ asyncio.create_task(broadcast(simulator_targets(room or message_room(message)), message))
247
+
248
+
249
+ def forward_telemetry_to_raw(message: dict, room: str) -> None:
250
+ line = (json.dumps(message) + "\n").encode()
251
+ for writer, client_room in list(tcp_clients.items()):
252
+ if client_room != room:
253
+ continue
254
+ try:
255
+ writer.write(line)
256
+ except Exception:
257
+ tcp_clients.pop(writer, None)
258
+ if udp_transport is not None:
259
+ datagram = json.dumps(message).encode()
260
+ for addr, client_room in list(udp_clients.items()):
261
+ if client_room != room:
262
+ continue
263
+ try:
264
+ udp_transport.sendto(datagram, addr)
265
+ except Exception:
266
+ udp_clients.pop(addr, None)
267
+
268
+
269
+ async def broadcast_status(room: str = DEFAULT_ROOM) -> None:
270
+ await broadcast(
271
+ sockets_in_room(simulators | controllers, websocket_rooms, room),
272
+ {
273
+ "type": "bridge_status",
274
+ "room": room,
275
+ "simulators": len(sockets_in_room(simulators, websocket_rooms, room)),
276
+ "controllers": (
277
+ len(sockets_in_room(controllers, websocket_rooms, room))
278
+ + sum(1 for client_room in tcp_clients.values() if client_room == room)
279
+ + sum(1 for client_room in udp_clients.values() if client_room == room)
280
+ ),
281
+ },
282
+ )
283
+
284
+
285
+ @app.websocket("/ws")
286
+ async def websocket_endpoint(websocket: WebSocket) -> None:
287
+ if not await authorize_websocket(websocket):
288
+ return
289
+ await websocket.accept()
290
+ role: str | None = None
291
+ room = websocket_room(websocket)
292
+
293
+ try:
294
+ while True:
295
+ message = await websocket.receive_json()
296
+ if not isinstance(message, dict):
297
+ continue
298
+ message = sanitize_message(message)
299
+ msg_type = message.get("type")
300
+
301
+ if msg_type == "hello":
302
+ role = "simulator" if message.get("role") == "simulator" else "controller"
303
+ old_room = room
304
+ room = message_room(message, room)
305
+ register_websocket(websocket, role, room)
306
+ if role == "simulator" and message.get("robots"):
307
+ scene_by_room[room] = {
308
+ "robots": message.get("robots") or [],
309
+ "cameras": message.get("cameras") or [],
310
+ "devices": message.get("devices") or [],
311
+ }
312
+ if old_room != room:
313
+ await broadcast_status(old_room)
314
+ await broadcast_status(room)
315
+ continue
316
+
317
+ if msg_type == "describe":
318
+ await websocket.send_json(scene_message(message_room(message, room)))
319
+ continue
320
+
321
+ if role is None:
322
+ role = "simulator" if msg_type == "telemetry" else "controller"
323
+ room = message_room(message, room)
324
+ register_websocket(websocket, role, room)
325
+ await broadcast_status(room)
326
+
327
+ if role == "controller" and msg_type in COMMAND_TYPES:
328
+ await broadcast(simulator_targets(message_room(message, room)), message)
329
+ elif role == "simulator" and msg_type == "telemetry":
330
+ await broadcast(controller_targets(room), message)
331
+ forward_telemetry_to_raw(message, room)
332
+ except WebSocketDisconnect:
333
+ pass
334
+ finally:
335
+ room = websocket_rooms.pop(websocket, room)
336
+ simulators.discard(websocket)
337
+ controllers.discard(websocket)
338
+ if latest_simulator_by_room.get(room) is websocket:
339
+ promote_latest_simulator(room)
340
+ if not sockets_in_room(simulators, websocket_rooms, room):
341
+ scene_by_room.pop(room, None)
342
+ await broadcast_status(room)
343
+
344
+
345
+ @app.websocket("/video")
346
+ async def video_endpoint(websocket: WebSocket) -> None:
347
+ if not await authorize_websocket(websocket):
348
+ return
349
+ await websocket.accept()
350
+ role: str | None = None
351
+ room = websocket_room(websocket)
352
+ try:
353
+ while True:
354
+ message = await websocket.receive()
355
+ if message["type"] == "websocket.disconnect":
356
+ break
357
+
358
+ text = message.get("text")
359
+ if text is not None:
360
+ try:
361
+ data = json.loads(text)
362
+ except ValueError:
363
+ continue
364
+ if isinstance(data, dict) and data.get("type") == "hello":
365
+ room = message_room(data, room)
366
+ role = (
367
+ "simulator-video"
368
+ if data.get("role") == "simulator-video"
369
+ else "controller-video"
370
+ )
371
+ (video_simulators if role == "simulator-video" else video_controllers).add(
372
+ websocket
373
+ )
374
+ video_rooms[websocket] = room
375
+ continue
376
+
377
+ frame = message.get("bytes")
378
+ if frame is None:
379
+ continue
380
+ if role is None:
381
+ role = "simulator-video"
382
+ video_simulators.add(websocket)
383
+ video_rooms[websocket] = room
384
+ if role == "simulator-video":
385
+ dead: list[WebSocket] = []
386
+ for ws in list(video_controllers):
387
+ if video_rooms.get(ws, DEFAULT_ROOM) != room:
388
+ continue
389
+ try:
390
+ await ws.send_bytes(frame)
391
+ except Exception:
392
+ dead.append(ws)
393
+ for ws in dead:
394
+ video_controllers.discard(ws)
395
+ except WebSocketDisconnect:
396
+ pass
397
+ finally:
398
+ video_rooms.pop(websocket, None)
399
+ video_simulators.discard(websocket)
400
+ video_controllers.discard(websocket)
401
+
402
+
403
+ async def handle_tcp_client(
404
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
405
+ ) -> None:
406
+ peer = writer.get_extra_info("peername")
407
+ peer_host = peer[0] if isinstance(peer, tuple) and peer else None
408
+ authorized = is_loopback_host(peer_host)
409
+ tcp_clients[writer] = DEFAULT_ROOM
410
+ await broadcast_status(DEFAULT_ROOM)
411
+ try:
412
+ while True:
413
+ line = await reader.readline()
414
+ if not line:
415
+ break
416
+ try:
417
+ message = json.loads(line.decode())
418
+ except (ValueError, UnicodeDecodeError):
419
+ continue
420
+ if isinstance(message, dict):
421
+ old_room = tcp_clients.get(writer, DEFAULT_ROOM)
422
+ room = message_room(message, old_room)
423
+ if room != old_room:
424
+ tcp_clients[writer] = room
425
+ await broadcast_status(old_room)
426
+ await broadcast_status(room)
427
+ if not authorized:
428
+ authorized = token_matches(message_token(message))
429
+ if not authorized:
430
+ writer.write(b'{"type":"error","error":"unauthorized"}\n')
431
+ await writer.drain()
432
+ break
433
+ message = sanitize_message(message)
434
+ if message.get("type") == "describe":
435
+ writer.write((json.dumps(scene_message(room)) + "\n").encode())
436
+ else:
437
+ forward_to_simulators(message, room)
438
+ except (ConnectionError, asyncio.IncompleteReadError):
439
+ pass
440
+ finally:
441
+ room = tcp_clients.pop(writer, DEFAULT_ROOM)
442
+ try:
443
+ writer.close()
444
+ except Exception:
445
+ pass
446
+ await broadcast_status(room)
447
+
448
+
449
+ class CommandUDPProtocol(asyncio.DatagramProtocol):
450
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
451
+ try:
452
+ message = json.loads(data.decode())
453
+ except (ValueError, UnicodeDecodeError):
454
+ return
455
+ if not isinstance(message, dict):
456
+ return
457
+ if not raw_client_authorized(addr[0], message):
458
+ return
459
+ room = message_room(message, udp_clients.get(addr, DEFAULT_ROOM))
460
+ message = sanitize_message(message)
461
+ udp_clients[addr] = room
462
+ if message.get("type") == "describe":
463
+ if udp_transport is not None:
464
+ udp_transport.sendto(json.dumps(scene_message(room)).encode(), addr)
465
+ return
466
+ forward_to_simulators(message, room)
467
+
468
+
469
+ @app.on_event("startup")
470
+ async def start_raw_endpoints() -> None:
471
+ global udp_transport
472
+ loop = asyncio.get_running_loop()
473
+
474
+ tcp_server = await asyncio.start_server(handle_tcp_client, raw_host(), tcp_port())
475
+ app.state.tcp_server = tcp_server
476
+
477
+ udp_transport, _ = await loop.create_datagram_endpoint(
478
+ CommandUDPProtocol, local_addr=(raw_host(), udp_port())
479
+ )
480
+
481
+
482
+ @app.on_event("shutdown")
483
+ async def stop_raw_endpoints() -> None:
484
+ server = getattr(app.state, "tcp_server", None)
485
+ if server is not None:
486
+ server.close()
487
+ if udp_transport is not None:
488
+ udp_transport.close()