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.
- dexcontrol/__init__.py +18 -8
- 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/core/chassis.py +9 -4
- dexcontrol/config/core/hand.py +1 -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/config/vega.py +4 -1
- dexcontrol/core/arm.py +61 -37
- dexcontrol/core/chassis.py +141 -119
- dexcontrol/core/component.py +110 -59
- dexcontrol/core/hand.py +118 -85
- dexcontrol/core/head.py +18 -29
- dexcontrol/core/misc.py +327 -155
- dexcontrol/core/robot_query_interface.py +463 -0
- dexcontrol/core/torso.py +4 -8
- dexcontrol/proto/dexcontrol_msg_pb2.py +27 -39
- dexcontrol/proto/dexcontrol_msg_pb2.pyi +75 -118
- dexcontrol/proto/dexcontrol_query_pb2.py +39 -39
- dexcontrol/proto/dexcontrol_query_pb2.pyi +17 -4
- dexcontrol/robot.py +245 -574
- 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 +169 -1
- dexcontrol/utils/pb_utils.py +0 -22
- {dexcontrol-0.2.12.dist-info → dexcontrol-0.3.1.dist-info}/METADATA +13 -1
- dexcontrol-0.3.1.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 -122
- dexcontrol-0.2.12.dist-info/RECORD +0 -75
- {dexcontrol-0.2.12.dist-info → dexcontrol-0.3.1.dist-info}/WHEEL +0 -0
- {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 =
|
|
36
|
+
MIN_SOC_SOFTWARE_VERSION: int = 286
|
|
36
37
|
|
|
37
|
-
logger.configure(
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
]
|
dexcontrol/comm/base.py
ADDED
|
@@ -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
|