dexcontrol 0.3.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of dexcontrol might be problematic. Click here for more details.

Files changed (51) hide show
  1. dexcontrol/__init__.py +16 -7
  2. dexcontrol/apps/dualsense_teleop_base.py +1 -1
  3. dexcontrol/comm/__init__.py +51 -0
  4. dexcontrol/comm/base.py +421 -0
  5. dexcontrol/comm/rtc.py +400 -0
  6. dexcontrol/comm/subscribers.py +329 -0
  7. dexcontrol/config/sensors/cameras/__init__.py +1 -2
  8. dexcontrol/config/sensors/cameras/zed_camera.py +2 -2
  9. dexcontrol/config/sensors/vega_sensors.py +12 -18
  10. dexcontrol/core/arm.py +29 -25
  11. dexcontrol/core/chassis.py +3 -12
  12. dexcontrol/core/component.py +68 -43
  13. dexcontrol/core/hand.py +50 -52
  14. dexcontrol/core/head.py +14 -26
  15. dexcontrol/core/misc.py +188 -166
  16. dexcontrol/core/robot_query_interface.py +137 -114
  17. dexcontrol/core/torso.py +0 -4
  18. dexcontrol/robot.py +15 -37
  19. dexcontrol/sensors/__init__.py +1 -2
  20. dexcontrol/sensors/camera/__init__.py +0 -2
  21. dexcontrol/sensors/camera/base_camera.py +144 -0
  22. dexcontrol/sensors/camera/rgb_camera.py +67 -63
  23. dexcontrol/sensors/camera/zed_camera.py +89 -147
  24. dexcontrol/sensors/imu/chassis_imu.py +76 -56
  25. dexcontrol/sensors/imu/zed_imu.py +54 -43
  26. dexcontrol/sensors/lidar/rplidar.py +16 -20
  27. dexcontrol/sensors/manager.py +4 -11
  28. dexcontrol/sensors/ultrasonic.py +14 -27
  29. dexcontrol/utils/__init__.py +0 -11
  30. dexcontrol/utils/comm_helper.py +111 -0
  31. dexcontrol/utils/constants.py +1 -1
  32. dexcontrol/utils/os_utils.py +8 -22
  33. {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.1.dist-info}/METADATA +2 -1
  34. dexcontrol-0.3.1.dist-info/RECORD +68 -0
  35. dexcontrol/config/sensors/cameras/luxonis_camera.py +0 -51
  36. dexcontrol/sensors/camera/luxonis_camera.py +0 -169
  37. dexcontrol/utils/rate_limiter.py +0 -172
  38. dexcontrol/utils/rtc_utils.py +0 -144
  39. dexcontrol/utils/subscribers/__init__.py +0 -52
  40. dexcontrol/utils/subscribers/base.py +0 -281
  41. dexcontrol/utils/subscribers/camera.py +0 -332
  42. dexcontrol/utils/subscribers/decoders.py +0 -88
  43. dexcontrol/utils/subscribers/generic.py +0 -110
  44. dexcontrol/utils/subscribers/imu.py +0 -175
  45. dexcontrol/utils/subscribers/lidar.py +0 -172
  46. dexcontrol/utils/subscribers/protobuf.py +0 -111
  47. dexcontrol/utils/subscribers/rtc.py +0 -316
  48. dexcontrol/utils/zenoh_utils.py +0 -369
  49. dexcontrol-0.3.0.dist-info/RECORD +0 -76
  50. {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.1.dist-info}/WHEEL +0 -0
  51. {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.1.dist-info}/licenses/LICENSE +0 -0
dexcontrol/comm/rtc.py ADDED
@@ -0,0 +1,400 @@
1
+ # Copyright (C) 2025 Dexmate Inc.
2
+ #
3
+ # This software is dual-licensed:
4
+ #
5
+ # 1. GNU Affero General Public License v3.0 (AGPL-3.0)
6
+ # See LICENSE-AGPL for details
7
+ #
8
+ # 2. Commercial License
9
+ # For commercial licensing terms, contact: contact@dexmate.ai
10
+
11
+ """WebRTC subscriber implementation with DexComm-compatible API.
12
+
13
+ Clean, modular implementation of WebRTC video streaming that provides
14
+ the exact same interface as DexComm subscribers.
15
+ """
16
+
17
+ import asyncio
18
+ import json
19
+ import threading
20
+ import time
21
+ from typing import Any, Callable, Dict, List, Optional
22
+
23
+ import numpy as np
24
+ from loguru import logger
25
+
26
+ from dexcontrol.utils.comm_helper import query_json_service
27
+ from dexcontrol.utils.os_utils import resolve_key_name
28
+
29
+ # WebRTC dependencies
30
+ try:
31
+ import websockets
32
+
33
+ WEBRTC_AVAILABLE = True
34
+ except ImportError:
35
+ logger.warning(
36
+ "WebRTC dependencies not available. Install: pip install aiortc websockets"
37
+ )
38
+ websockets = None
39
+ WEBRTC_AVAILABLE = False
40
+
41
+ # Performance optimization
42
+ try:
43
+ import uvloop
44
+
45
+ UVLOOP_AVAILABLE = True
46
+ except ImportError:
47
+ uvloop = None
48
+ UVLOOP_AVAILABLE = False
49
+
50
+
51
+ class RTCSubscriber:
52
+ """WebRTC video subscriber with DexComm-compatible API.
53
+
54
+ Receives video streams via WebRTC and provides the same interface
55
+ as DexComm's Subscriber class for seamless integration.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ signaling_url: Optional[str] = None,
61
+ info_topic: Optional[str] = None,
62
+ callback: Optional[Callable[[np.ndarray], None]] = None,
63
+ buffer_size: int = 1,
64
+ name: Optional[str] = None,
65
+ ):
66
+ """Initialize WebRTC subscriber.
67
+
68
+ Args:
69
+ signaling_url: Direct WebRTC signaling URL (preferred)
70
+ info_topic: Topic to query for WebRTC connection info (fallback)
71
+ callback: Optional callback for incoming frames
72
+ buffer_size: Number of frames to buffer
73
+ name: Optional name for logging
74
+ """
75
+ self.signaling_url = signaling_url
76
+ self.topic = info_topic
77
+ self.callback = callback
78
+ self.buffer_size = buffer_size
79
+ self.name = name or f"rtc_{(info_topic or 'direct').replace('/', '_')}"
80
+
81
+ # Data storage
82
+ self._buffer: List[np.ndarray] = []
83
+ self._buffer_lock = threading.Lock()
84
+ self._latest_frame: Optional[np.ndarray] = None
85
+ self._data_lock = threading.Lock()
86
+
87
+ # Statistics
88
+ self._frame_count = 0
89
+ self._last_receive_time_ns: Optional[int] = None
90
+
91
+ # Connection state
92
+ self._active = False
93
+ self._connected = False
94
+ self._rtc_info: Optional[Dict] = None
95
+
96
+ # Threading
97
+ self._stop_event = threading.Event()
98
+ self._thread: Optional[threading.Thread] = None
99
+
100
+ # WebRTC objects
101
+ self._pc: Optional[Any] = None
102
+ self._websocket: Optional[Any] = None
103
+ self._async_stop: Optional[Any] = None
104
+
105
+ # Initialize connection
106
+ self._initialize()
107
+
108
+ def _initialize(self) -> None:
109
+ """Initialize WebRTC connection."""
110
+ if not WEBRTC_AVAILABLE:
111
+ logger.error(f"{self.name}: WebRTC dependencies not available")
112
+ return
113
+
114
+ # Use direct signaling URL if provided, otherwise query for it
115
+ if self.signaling_url:
116
+ self._rtc_info = {"signaling_url": self.signaling_url}
117
+ logger.debug(
118
+ f"{self.name}: Using direct signaling URL: {self.signaling_url}"
119
+ )
120
+ else:
121
+ # Query for connection info
122
+ self._rtc_info = self._query_connection_info()
123
+ if not self._rtc_info:
124
+ logger.warning(f"{self.name}: Failed to get connection info")
125
+ return
126
+
127
+ # Start WebRTC connection
128
+ self._start_connection()
129
+
130
+ def _query_connection_info(self) -> Optional[Dict[str, Any]]:
131
+ """Query for WebRTC connection information."""
132
+ full_topic = resolve_key_name(self.topic)
133
+ logger.debug(f"{self.name}: Querying {full_topic}")
134
+
135
+ info = query_json_service(topic=full_topic, timeout=2.0)
136
+
137
+ if info and "signaling_url" in info:
138
+ logger.info(f"{self.name}: Got connection info")
139
+ return info
140
+
141
+ return None
142
+
143
+ def _start_connection(self) -> None:
144
+ """Start WebRTC connection in background thread."""
145
+ url = self._rtc_info.get("signaling_url")
146
+ if not url:
147
+ logger.error(f"{self.name}: No signaling URL")
148
+ return
149
+
150
+ self._url = url
151
+ self._thread = threading.Thread(
152
+ target=self._run_async_loop, daemon=True, name=f"{self.name}_thread"
153
+ )
154
+ self._thread.start()
155
+
156
+ def _run_async_loop(self) -> None:
157
+ """Run async event loop in thread."""
158
+ try:
159
+ if UVLOOP_AVAILABLE and uvloop:
160
+ loop = uvloop.new_event_loop()
161
+ asyncio.set_event_loop(loop)
162
+ loop.run_until_complete(self._connect_and_receive())
163
+ loop.close()
164
+ else:
165
+ asyncio.run(self._connect_and_receive())
166
+ except Exception as e:
167
+ logger.error(f"{self.name}: Event loop error: {e}")
168
+ finally:
169
+ self._cleanup_state()
170
+
171
+ async def _connect_and_receive(self) -> None:
172
+ """Main async function for WebRTC connection."""
173
+ self._async_stop = asyncio.Event()
174
+ monitor = asyncio.create_task(self._monitor_stop())
175
+
176
+ try:
177
+ # Create peer connection with certificate workaround
178
+ # Fix for pyOpenSSL compatibility issue
179
+ import ssl
180
+
181
+ from aiortc import RTCConfiguration, RTCPeerConnection
182
+
183
+ ssl._create_default_https_context = ssl._create_unverified_context
184
+
185
+ # Create configuration without ICE servers to avoid cert issues
186
+ config = RTCConfiguration(iceServers=[])
187
+ self._pc = RTCPeerConnection(configuration=config)
188
+
189
+ # Setup track handler
190
+ @self._pc.on("track")
191
+ async def on_track(track):
192
+ logger.info(f"{self.name}: Received {track.kind} track")
193
+ if track.kind == "video":
194
+ # Start receiving frames as a background task
195
+ asyncio.create_task(self._receive_frames(track))
196
+
197
+ # Connect to signaling server
198
+ await self._establish_connection()
199
+
200
+ # Wait until stopped
201
+ await self._async_stop.wait()
202
+
203
+ except Exception as e:
204
+ if not self._stop_event.is_set():
205
+ logger.error(f"{self.name}: Connection error: {e}")
206
+ finally:
207
+ monitor.cancel()
208
+ await self._cleanup_async()
209
+
210
+ async def _establish_connection(self) -> None:
211
+ """Establish WebRTC connection via signaling."""
212
+ try:
213
+ self._websocket = await websockets.connect(self._url)
214
+ logger.debug(f"{self.name}: WebSocket connected to {self._url}")
215
+
216
+ # Create offer
217
+ self._pc.addTransceiver("video", direction="recvonly")
218
+ offer = await self._pc.createOffer()
219
+ await self._pc.setLocalDescription(offer)
220
+
221
+ # Send offer
222
+ await self._websocket.send(
223
+ json.dumps(
224
+ {
225
+ "sdp": self._pc.localDescription.sdp,
226
+ "type": self._pc.localDescription.type,
227
+ }
228
+ )
229
+ )
230
+
231
+ # Receive answer
232
+ response = json.loads(await self._websocket.recv())
233
+ if response["type"] == "answer":
234
+ from aiortc import RTCSessionDescription
235
+
236
+ await self._pc.setRemoteDescription(
237
+ RTCSessionDescription(response["sdp"], response["type"])
238
+ )
239
+ self._connected = True
240
+ else:
241
+ raise ValueError(f"Unexpected response: {response['type']}")
242
+ except Exception as e:
243
+ logger.error(f"{self.name}: Failed to establish connection: {e}")
244
+ raise
245
+
246
+ async def _receive_frames(self, track) -> None:
247
+ """Receive and process video frames."""
248
+ while not self._async_stop.is_set():
249
+ try:
250
+ frame = await asyncio.wait_for(track.recv(), timeout=1.0)
251
+ img = frame.to_ndarray(format="rgb24")
252
+ self._process_frame(img)
253
+
254
+ if not self._active:
255
+ self._active = True
256
+
257
+ except asyncio.TimeoutError:
258
+ # This is normal during waiting, only log if too frequent
259
+ continue
260
+ except Exception as e:
261
+ if not self._async_stop.is_set():
262
+ logger.error(f"{self.name}: Frame receive error: {e}")
263
+ break
264
+ logger.debug(f"{self.name}: Frame receiver stopped")
265
+
266
+ def _process_frame(self, frame: np.ndarray) -> None:
267
+ """Process incoming video frame."""
268
+ # Update statistics
269
+ with self._data_lock:
270
+ self._frame_count += 1
271
+ self._last_receive_time_ns = time.time_ns()
272
+ self._latest_frame = frame
273
+
274
+ # Update buffer
275
+ with self._buffer_lock:
276
+ self._buffer.append(frame)
277
+ if len(self._buffer) > self.buffer_size:
278
+ self._buffer.pop(0)
279
+
280
+ # Call callback
281
+ if self.callback:
282
+ try:
283
+ self.callback(frame)
284
+ except Exception as e:
285
+ logger.error(f"{self.name}: Callback error: {e}")
286
+
287
+ async def _monitor_stop(self) -> None:
288
+ """Monitor stop event from main thread."""
289
+ while not self._stop_event.is_set():
290
+ await asyncio.sleep(0.1)
291
+ self._async_stop.set()
292
+
293
+ async def _cleanup_async(self) -> None:
294
+ """Clean up async resources."""
295
+ if self._websocket:
296
+ try:
297
+ await self._websocket.close()
298
+ except Exception:
299
+ pass
300
+
301
+ if self._pc and self._pc.connectionState != "closed":
302
+ try:
303
+ await self._pc.close()
304
+ except Exception:
305
+ pass
306
+
307
+ def _cleanup_state(self) -> None:
308
+ """Clean up connection state."""
309
+ with self._data_lock:
310
+ self._active = False
311
+ self._connected = False
312
+
313
+ # DexComm-compatible API
314
+
315
+ def get_latest(self) -> Optional[np.ndarray]:
316
+ """Get the latest received frame."""
317
+ with self._data_lock:
318
+ return self._latest_frame.copy() if self._latest_frame is not None else None
319
+
320
+ def get_buffer(self) -> List[np.ndarray]:
321
+ """Get all buffered frames."""
322
+ with self._buffer_lock:
323
+ return [f.copy() for f in self._buffer]
324
+
325
+ def wait_for_message(self, timeout: float = 5.0) -> Optional[np.ndarray]:
326
+ """Wait for a frame to be received."""
327
+ start = time.time()
328
+ initial_count = self._frame_count
329
+
330
+ while time.time() - start < timeout:
331
+ if self._frame_count > initial_count:
332
+ return self.get_latest()
333
+ time.sleep(0.05)
334
+
335
+ return None
336
+
337
+ def get_stats(self) -> Dict[str, Any]:
338
+ """Get subscriber statistics."""
339
+ with self._data_lock:
340
+ return {
341
+ "topic": self.topic,
342
+ "receive_count": self._frame_count,
343
+ "last_receive_time_ns": self._last_receive_time_ns,
344
+ "buffer_size": len(self._buffer),
345
+ "active": self._active,
346
+ "connected": self._connected,
347
+ }
348
+
349
+ def is_active(self) -> bool:
350
+ """Check if actively receiving frames."""
351
+ return self._active
352
+
353
+ def shutdown(self) -> None:
354
+ """Shutdown the subscriber."""
355
+ self._stop_event.set()
356
+
357
+ if self._thread and self._thread.is_alive():
358
+ self._thread.join(timeout=5.0)
359
+ if self._thread.is_alive():
360
+ logger.warning(f"{self.name}: Thread didn't stop cleanly")
361
+
362
+ self._cleanup_state()
363
+ logger.debug(f"{self.name}: Shutdown complete")
364
+
365
+ def __enter__(self):
366
+ return self
367
+
368
+ def __exit__(self, *args):
369
+ self.shutdown()
370
+
371
+ def __del__(self):
372
+ self.shutdown()
373
+
374
+
375
+ def create_rtc_camera_subscriber(
376
+ signaling_url: Optional[str] = None,
377
+ info_topic: Optional[str] = None,
378
+ callback: Optional[Callable[[np.ndarray], None]] = None,
379
+ buffer_size: int = 1,
380
+ ) -> RTCSubscriber:
381
+ """Create an RTC subscriber for camera streams.
382
+
383
+ Args:
384
+ signaling_url: Direct WebRTC signaling URL (preferred)
385
+ info_topic: Topic to query for connection info (fallback)
386
+ callback: Optional callback for frames
387
+ buffer_size: Number of frames to buffer
388
+
389
+ Returns:
390
+ RTCSubscriber instance with DexComm-compatible API
391
+ """
392
+ if not signaling_url and not info_topic:
393
+ raise ValueError("Either signaling_url or info_topic must be provided")
394
+
395
+ return RTCSubscriber(
396
+ signaling_url=signaling_url,
397
+ info_topic=info_topic,
398
+ callback=callback,
399
+ buffer_size=buffer_size,
400
+ )