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