dexcontrol 0.3.0__py3-none-any.whl → 0.3.2__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.
- dexcontrol/__init__.py +16 -7
- dexcontrol/apps/dualsense_teleop_base.py +1 -1
- dexcontrol/comm/__init__.py +51 -0
- dexcontrol/comm/base.py +421 -0
- dexcontrol/comm/rtc.py +400 -0
- dexcontrol/comm/subscribers.py +329 -0
- dexcontrol/config/sensors/cameras/__init__.py +1 -2
- dexcontrol/config/sensors/cameras/zed_camera.py +2 -2
- dexcontrol/config/sensors/vega_sensors.py +12 -18
- dexcontrol/core/arm.py +29 -25
- dexcontrol/core/chassis.py +3 -12
- dexcontrol/core/component.py +68 -43
- dexcontrol/core/hand.py +50 -52
- dexcontrol/core/head.py +14 -26
- dexcontrol/core/misc.py +188 -166
- dexcontrol/core/robot_query_interface.py +140 -117
- dexcontrol/core/torso.py +0 -4
- dexcontrol/robot.py +15 -37
- dexcontrol/sensors/__init__.py +1 -2
- dexcontrol/sensors/camera/__init__.py +0 -2
- dexcontrol/sensors/camera/base_camera.py +144 -0
- dexcontrol/sensors/camera/rgb_camera.py +67 -63
- dexcontrol/sensors/camera/zed_camera.py +89 -147
- dexcontrol/sensors/imu/chassis_imu.py +76 -56
- dexcontrol/sensors/imu/zed_imu.py +54 -43
- dexcontrol/sensors/lidar/rplidar.py +16 -20
- dexcontrol/sensors/manager.py +4 -11
- dexcontrol/sensors/ultrasonic.py +14 -27
- dexcontrol/utils/__init__.py +0 -11
- dexcontrol/utils/comm_helper.py +111 -0
- dexcontrol/utils/constants.py +1 -1
- dexcontrol/utils/os_utils.py +8 -22
- {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.2.dist-info}/METADATA +2 -1
- dexcontrol-0.3.2.dist-info/RECORD +68 -0
- dexcontrol/config/sensors/cameras/luxonis_camera.py +0 -51
- dexcontrol/sensors/camera/luxonis_camera.py +0 -169
- dexcontrol/utils/rate_limiter.py +0 -172
- dexcontrol/utils/rtc_utils.py +0 -144
- dexcontrol/utils/subscribers/__init__.py +0 -52
- dexcontrol/utils/subscribers/base.py +0 -281
- dexcontrol/utils/subscribers/camera.py +0 -332
- dexcontrol/utils/subscribers/decoders.py +0 -88
- dexcontrol/utils/subscribers/generic.py +0 -110
- dexcontrol/utils/subscribers/imu.py +0 -175
- dexcontrol/utils/subscribers/lidar.py +0 -172
- dexcontrol/utils/subscribers/protobuf.py +0 -111
- dexcontrol/utils/subscribers/rtc.py +0 -316
- dexcontrol/utils/zenoh_utils.py +0 -369
- dexcontrol-0.3.0.dist-info/RECORD +0 -76
- {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.2.dist-info}/WHEEL +0 -0
- {dexcontrol-0.3.0.dist-info → dexcontrol-0.3.2.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
|
+
)
|