dexcontrol 0.2.12__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 (59) hide show
  1. dexcontrol/__init__.py +18 -8
  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/core/chassis.py +9 -4
  8. dexcontrol/config/core/hand.py +1 -0
  9. dexcontrol/config/sensors/cameras/__init__.py +1 -2
  10. dexcontrol/config/sensors/cameras/zed_camera.py +2 -2
  11. dexcontrol/config/sensors/vega_sensors.py +12 -18
  12. dexcontrol/config/vega.py +4 -1
  13. dexcontrol/core/arm.py +61 -37
  14. dexcontrol/core/chassis.py +141 -119
  15. dexcontrol/core/component.py +110 -59
  16. dexcontrol/core/hand.py +118 -85
  17. dexcontrol/core/head.py +18 -29
  18. dexcontrol/core/misc.py +327 -155
  19. dexcontrol/core/robot_query_interface.py +463 -0
  20. dexcontrol/core/torso.py +4 -8
  21. dexcontrol/proto/dexcontrol_msg_pb2.py +27 -39
  22. dexcontrol/proto/dexcontrol_msg_pb2.pyi +75 -118
  23. dexcontrol/proto/dexcontrol_query_pb2.py +39 -39
  24. dexcontrol/proto/dexcontrol_query_pb2.pyi +17 -4
  25. dexcontrol/robot.py +245 -574
  26. dexcontrol/sensors/__init__.py +1 -2
  27. dexcontrol/sensors/camera/__init__.py +0 -2
  28. dexcontrol/sensors/camera/base_camera.py +144 -0
  29. dexcontrol/sensors/camera/rgb_camera.py +67 -63
  30. dexcontrol/sensors/camera/zed_camera.py +89 -147
  31. dexcontrol/sensors/imu/chassis_imu.py +76 -56
  32. dexcontrol/sensors/imu/zed_imu.py +54 -43
  33. dexcontrol/sensors/lidar/rplidar.py +16 -20
  34. dexcontrol/sensors/manager.py +4 -11
  35. dexcontrol/sensors/ultrasonic.py +14 -27
  36. dexcontrol/utils/__init__.py +0 -11
  37. dexcontrol/utils/comm_helper.py +111 -0
  38. dexcontrol/utils/constants.py +1 -1
  39. dexcontrol/utils/os_utils.py +169 -1
  40. dexcontrol/utils/pb_utils.py +0 -22
  41. {dexcontrol-0.2.12.dist-info → dexcontrol-0.3.1.dist-info}/METADATA +13 -1
  42. dexcontrol-0.3.1.dist-info/RECORD +68 -0
  43. dexcontrol/config/sensors/cameras/luxonis_camera.py +0 -51
  44. dexcontrol/sensors/camera/luxonis_camera.py +0 -169
  45. dexcontrol/utils/rate_limiter.py +0 -172
  46. dexcontrol/utils/rtc_utils.py +0 -144
  47. dexcontrol/utils/subscribers/__init__.py +0 -52
  48. dexcontrol/utils/subscribers/base.py +0 -281
  49. dexcontrol/utils/subscribers/camera.py +0 -332
  50. dexcontrol/utils/subscribers/decoders.py +0 -88
  51. dexcontrol/utils/subscribers/generic.py +0 -110
  52. dexcontrol/utils/subscribers/imu.py +0 -175
  53. dexcontrol/utils/subscribers/lidar.py +0 -172
  54. dexcontrol/utils/subscribers/protobuf.py +0 -111
  55. dexcontrol/utils/subscribers/rtc.py +0 -316
  56. dexcontrol/utils/zenoh_utils.py +0 -122
  57. dexcontrol-0.2.12.dist-info/RECORD +0 -75
  58. {dexcontrol-0.2.12.dist-info → dexcontrol-0.3.1.dist-info}/WHEEL +0 -0
  59. {dexcontrol-0.2.12.dist-info → dexcontrol-0.3.1.dist-info}/licenses/LICENSE +0 -0
dexcontrol/__init__.py CHANGED
@@ -30,27 +30,37 @@ from dexcontrol.robot import Robot
30
30
  from dexcontrol.utils.constants import COMM_CFG_PATH_ENV_VAR
31
31
 
32
32
  # Package-level constants
33
+ __version__: str = "0.3.0" # Current library version
33
34
  LIB_PATH: Final[Path] = Path(__file__).resolve().parent
34
35
  CFG_PATH: Final[Path] = LIB_PATH / "config"
35
- MIN_SOC_SOFTWARE_VERSION: int = 233
36
+ MIN_SOC_SOFTWARE_VERSION: int = 286
36
37
 
37
- logger.configure(handlers=[{"sink": RichHandler(markup=True), "format": "{message}"}])
38
+ logger.configure(
39
+ handlers=[
40
+ {"sink": RichHandler(markup=True), "format": "{message}", "level": "INFO"}
41
+ ]
42
+ )
38
43
 
39
44
 
40
- def get_comm_cfg_path() -> Path:
45
+ def get_comm_cfg_path() -> Path | None:
41
46
  default_path = list(
42
47
  Path("~/.dexmate/comm/zenoh/").expanduser().glob("**/zenoh_peer_config.json5")
43
48
  )
44
49
  if len(default_path) == 0:
45
- raise FileNotFoundError(
46
- "No zenoh_peer_config.json5 file found in ~/.dexmate/comm/zenoh/"
50
+ logger.debug(
51
+ "No zenoh_peer_config.json5 file found in ~/.dexmate/comm/zenoh/ - will use DexComm defaults"
47
52
  )
53
+ return None
48
54
  return default_path[0]
49
55
 
50
56
 
51
- COMM_CFG_PATH: Final[Path] = Path(
52
- os.getenv(COMM_CFG_PATH_ENV_VAR, get_comm_cfg_path())
53
- ).expanduser()
57
+ # Try to get comm config path, but allow None
58
+ _comm_cfg = os.getenv(COMM_CFG_PATH_ENV_VAR)
59
+ if _comm_cfg:
60
+ COMM_CFG_PATH: Final[Path] = Path(_comm_cfg).expanduser()
61
+ else:
62
+ _default = get_comm_cfg_path()
63
+ COMM_CFG_PATH: Final[Path] = _default if _default else Path("/tmp/no_config")
54
64
 
55
65
  ROBOT_CFG_PATH: Final[Path] = CFG_PATH
56
66
 
@@ -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
+ ]
@@ -0,0 +1,421 @@
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
+ """Base classes for DexControl communication using DexComm.
12
+
13
+ Provides abstract base classes and common functionality for all
14
+ communication components in DexControl.
15
+ """
16
+
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar
22
+
23
+ from dexcomm import (
24
+ BufferedSubscriber,
25
+ Publisher,
26
+ Subscriber,
27
+ SubscriberManager,
28
+ ZenohConfig,
29
+ )
30
+ from loguru import logger
31
+
32
+ from dexcontrol.utils.os_utils import resolve_key_name
33
+
34
+ T = TypeVar("T")
35
+
36
+
37
+ class DataFormat(Enum):
38
+ """Supported data formats for communication."""
39
+
40
+ RAW = "raw"
41
+ JSON = "json"
42
+ NUMPY = "numpy"
43
+ IMAGE_RGB = "image_rgb"
44
+ IMAGE_DEPTH = "image_depth"
45
+ IMU = "imu"
46
+ LIDAR_2D = "lidar_2d"
47
+ PROTOBUF = "protobuf"
48
+ CUSTOM = "custom"
49
+
50
+
51
+ @dataclass
52
+ class SubscriberConfig:
53
+ """Configuration for a subscriber."""
54
+
55
+ topic: str
56
+ format: DataFormat = DataFormat.RAW
57
+ buffer_size: int = 1
58
+ enable_buffering: bool = False
59
+ callback: Optional[Callable[[Any], None]] = None
60
+ namespace: Optional[str] = None
61
+ config_path: Optional[Path] = None
62
+ qos: Dict[str, Any] = field(default_factory=dict)
63
+
64
+
65
+ class SensorSubscriber(ABC, Generic[T]):
66
+ """Abstract base class for sensor subscribers.
67
+
68
+ Provides a clean interface for subscribing to sensor data with
69
+ automatic deserialization and type safety.
70
+ """
71
+
72
+ def __init__(self, config: SubscriberConfig):
73
+ """Initialize the sensor subscriber.
74
+
75
+ Args:
76
+ config: Subscriber configuration
77
+ """
78
+ self.config = config
79
+ self._subscriber: Optional[Subscriber] = None
80
+ self._setup_subscriber()
81
+
82
+ def _setup_subscriber(self) -> None:
83
+ """Setup the DexComm subscriber."""
84
+ topic = self._resolve_topic()
85
+
86
+ # Choose subscriber type based on configuration
87
+ subscriber_class = (
88
+ BufferedSubscriber if self.config.enable_buffering else Subscriber
89
+ )
90
+
91
+ self._subscriber = subscriber_class(
92
+ topic=topic,
93
+ callback=self._wrapped_callback,
94
+ deserializer=self._get_deserializer(),
95
+ config=self._get_zenoh_config(),
96
+ buffer_size=self.config.buffer_size,
97
+ qos=self.config.qos,
98
+ )
99
+
100
+ logger.info(f"Created {self.__class__.__name__} for topic: {topic}")
101
+
102
+ def _resolve_topic(self) -> str:
103
+ """Resolve the full topic name including namespace."""
104
+ topic = self.config.topic
105
+ if self.config.namespace:
106
+ topic = f"{self.config.namespace}/{topic}"
107
+ return resolve_key_name(topic)
108
+
109
+ def _wrapped_callback(self, data: Any) -> None:
110
+ """Wrapper for the user callback with error handling."""
111
+ try:
112
+ # Process data through subclass implementation
113
+ processed = self.process_data(data)
114
+
115
+ # Call user callback if provided
116
+ if self.config.callback:
117
+ self.config.callback(processed)
118
+ except Exception as e:
119
+ logger.error(f"Error in {self.__class__.__name__} callback: {e}")
120
+
121
+ def _get_zenoh_config(self) -> Optional[ZenohConfig]:
122
+ """Get Zenoh configuration."""
123
+ if self.config.config_path:
124
+ return ZenohConfig.from_file(self.config.config_path)
125
+ return None
126
+
127
+ @abstractmethod
128
+ def _get_deserializer(self) -> Optional[Callable[[bytes], Any]]:
129
+ """Get the appropriate deserializer for this sensor type.
130
+
131
+ Returns:
132
+ Deserializer function or None for raw bytes
133
+ """
134
+ pass
135
+
136
+ @abstractmethod
137
+ def process_data(self, data: Any) -> T:
138
+ """Process raw data into the appropriate type.
139
+
140
+ Args:
141
+ data: Deserialized data from subscriber
142
+
143
+ Returns:
144
+ Processed data of type T
145
+ """
146
+ pass
147
+
148
+ def get_latest(self) -> Optional[T]:
149
+ """Get the latest data from the sensor.
150
+
151
+ Returns:
152
+ Latest processed data or None if no data available
153
+ """
154
+ if not self._subscriber:
155
+ return None
156
+
157
+ raw_data = self._subscriber.get_latest()
158
+ if raw_data is not None:
159
+ return self.process_data(raw_data)
160
+ return None
161
+
162
+ def wait_for_data(self, timeout: float = 5.0) -> Optional[T]:
163
+ """Wait for data from the sensor.
164
+
165
+ Args:
166
+ timeout: Maximum time to wait in seconds
167
+
168
+ Returns:
169
+ Received data or None if timeout
170
+ """
171
+ if not self._subscriber:
172
+ return None
173
+
174
+ raw_data = self._subscriber.wait_for_message(timeout)
175
+ if raw_data is not None:
176
+ return self.process_data(raw_data)
177
+ return None
178
+
179
+ def is_active(self) -> bool:
180
+ """Check if the sensor is actively receiving data.
181
+
182
+ Returns:
183
+ True if receiving data, False otherwise
184
+ """
185
+ return self.get_latest() is not None
186
+
187
+ def get_stats(self) -> Dict[str, Any]:
188
+ """Get subscriber statistics.
189
+
190
+ Returns:
191
+ Dictionary with subscriber statistics
192
+ """
193
+ if not self._subscriber:
194
+ return {}
195
+ return self._subscriber.get_stats()
196
+
197
+ def shutdown(self) -> None:
198
+ """Shutdown the subscriber and release resources."""
199
+ if self._subscriber:
200
+ self._subscriber.shutdown()
201
+ logger.debug(
202
+ f"{self.__class__.__name__} shutdown for topic: {self.config.topic}"
203
+ )
204
+
205
+
206
+ class StreamSubscriber(SensorSubscriber[T]):
207
+ """Base class for high-frequency streaming data subscribers.
208
+
209
+ Adds rate monitoring and buffering capabilities for streams.
210
+ """
211
+
212
+ def __init__(self, config: SubscriberConfig):
213
+ """Initialize the stream subscriber."""
214
+ super().__init__(config)
215
+ self._rate_monitor = RateMonitor(window_size=100)
216
+
217
+ def _wrapped_callback(self, data: Any) -> None:
218
+ """Extended callback with rate monitoring."""
219
+ self._rate_monitor.update()
220
+ super()._wrapped_callback(data)
221
+
222
+ def get_rate(self) -> float:
223
+ """Get the current data rate in Hz.
224
+
225
+ Returns:
226
+ Current rate in Hz
227
+ """
228
+ return self._rate_monitor.get_rate()
229
+
230
+ def get_buffer(self) -> List[T]:
231
+ """Get buffered data (only if buffering is enabled).
232
+
233
+ Returns:
234
+ List of buffered data
235
+ """
236
+ if not isinstance(self._subscriber, BufferedSubscriber):
237
+ return []
238
+
239
+ raw_buffer = self._subscriber.get_buffer()
240
+ return [self.process_data(data) for data in raw_buffer]
241
+
242
+ def clear_buffer(self) -> None:
243
+ """Clear the data buffer."""
244
+ if isinstance(self._subscriber, BufferedSubscriber):
245
+ self._subscriber.clear_buffer()
246
+
247
+
248
+ class TopicManager:
249
+ """Manages multiple topics dynamically using DexComm's manager pattern.
250
+
251
+ Useful for applications that need to subscribe/unsubscribe to topics
252
+ at runtime, such as data loggers or monitoring systems.
253
+ """
254
+
255
+ def __init__(self, namespace: Optional[str] = None):
256
+ """Initialize the topic manager.
257
+
258
+ Args:
259
+ namespace: Optional namespace for all topics
260
+ """
261
+ self.namespace = namespace
262
+ self._subscribers = SubscriberManager()
263
+ self._publishers = {}
264
+ self._callbacks: Dict[str, List[Callable]] = {}
265
+
266
+ def add_subscriber(
267
+ self,
268
+ topic: str,
269
+ callback: Optional[Callable[[Any], None]] = None,
270
+ format: DataFormat = DataFormat.RAW,
271
+ buffer_size: int = 1,
272
+ ) -> None:
273
+ """Add a new subscriber dynamically.
274
+
275
+ Args:
276
+ topic: Topic to subscribe to
277
+ callback: Optional callback function
278
+ format: Data format for deserialization
279
+ buffer_size: Number of messages to buffer
280
+ """
281
+ full_topic = self._resolve_topic(topic)
282
+
283
+ # Store callback
284
+ if callback:
285
+ if topic not in self._callbacks:
286
+ self._callbacks[topic] = []
287
+ self._callbacks[topic].append(callback)
288
+
289
+ # Create wrapper callback
290
+ def wrapper(msg):
291
+ for cb in self._callbacks.get(topic, []):
292
+ try:
293
+ cb(msg)
294
+ except Exception as e:
295
+ logger.error(f"Error in callback for {topic}: {e}")
296
+
297
+ # Add to manager
298
+ self._subscribers.add(
299
+ full_topic,
300
+ callback=wrapper if callback else None,
301
+ buffer_size=buffer_size,
302
+ )
303
+
304
+ logger.info(f"Added subscriber for topic: {full_topic}")
305
+
306
+ def remove_subscriber(self, topic: str) -> None:
307
+ """Remove a subscriber.
308
+
309
+ Args:
310
+ topic: Topic to unsubscribe from
311
+ """
312
+ full_topic = self._resolve_topic(topic)
313
+ self._subscribers.remove(full_topic)
314
+
315
+ # Clear callbacks
316
+ if topic in self._callbacks:
317
+ del self._callbacks[topic]
318
+
319
+ logger.info(f"Removed subscriber for topic: {full_topic}")
320
+
321
+ def add_publisher(
322
+ self, topic: str, format: DataFormat = DataFormat.RAW
323
+ ) -> Publisher:
324
+ """Add a new publisher dynamically.
325
+
326
+ Args:
327
+ topic: Topic to publish to
328
+ format: Data format for serialization
329
+
330
+ Returns:
331
+ Publisher instance
332
+ """
333
+ full_topic = self._resolve_topic(topic)
334
+
335
+ if topic not in self._publishers:
336
+ self._publishers[topic] = Publisher(full_topic)
337
+ logger.info(f"Added publisher for topic: {full_topic}")
338
+
339
+ return self._publishers[topic]
340
+
341
+ def publish(self, topic: str, data: Any) -> None:
342
+ """Publish data to a topic.
343
+
344
+ Args:
345
+ topic: Topic to publish to
346
+ data: Data to publish
347
+ """
348
+ if topic not in self._publishers:
349
+ self.add_publisher(topic)
350
+
351
+ self._publishers[topic].publish(data)
352
+
353
+ def get_latest(self, topic: str) -> Optional[Any]:
354
+ """Get latest message from a topic.
355
+
356
+ Args:
357
+ topic: Topic to get data from
358
+
359
+ Returns:
360
+ Latest message or None
361
+ """
362
+ full_topic = self._resolve_topic(topic)
363
+ return self._subscribers.get_latest(full_topic)
364
+
365
+ def get_all_latest(self) -> Dict[str, Any]:
366
+ """Get latest messages from all subscribed topics.
367
+
368
+ Returns:
369
+ Dictionary mapping topics to their latest messages
370
+ """
371
+ return self._subscribers.get_all_latest()
372
+
373
+ def _resolve_topic(self, topic: str) -> str:
374
+ """Resolve topic with namespace."""
375
+ if self.namespace:
376
+ topic = f"{self.namespace}/{topic}"
377
+ return resolve_key_name(topic)
378
+
379
+ def shutdown(self) -> None:
380
+ """Shutdown all subscribers and publishers."""
381
+ self._subscribers.shutdown_all()
382
+ for pub in self._publishers.values():
383
+ pub.shutdown()
384
+ logger.info("TopicManager shutdown complete")
385
+
386
+
387
+ class RateMonitor:
388
+ """Monitor data rate for streaming subscribers."""
389
+
390
+ def __init__(self, window_size: int = 100):
391
+ """Initialize rate monitor.
392
+
393
+ Args:
394
+ window_size: Number of samples for rate calculation
395
+ """
396
+ self.window_size = window_size
397
+ self.timestamps: List[float] = []
398
+
399
+ def update(self) -> None:
400
+ """Update with new data point."""
401
+ import time
402
+
403
+ self.timestamps.append(time.time())
404
+
405
+ # Keep only window_size samples
406
+ if len(self.timestamps) > self.window_size:
407
+ self.timestamps.pop(0)
408
+
409
+ def get_rate(self) -> float:
410
+ """Calculate current rate in Hz.
411
+
412
+ Returns:
413
+ Rate in Hz or 0 if insufficient data
414
+ """
415
+ if len(self.timestamps) < 2:
416
+ return 0.0
417
+
418
+ time_span = self.timestamps[-1] - self.timestamps[0]
419
+ if time_span > 0:
420
+ return (len(self.timestamps) - 1) / time_span
421
+ return 0.0