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/utils/rate_limiter.py
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
"""Rate limiter utility for maintaining consistent execution rates."""
|
|
12
|
-
|
|
13
|
-
import sys
|
|
14
|
-
import time
|
|
15
|
-
from typing import Final
|
|
16
|
-
|
|
17
|
-
from loguru import logger
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class RateLimiter:
|
|
21
|
-
"""Class for limiting execution rate to a target frequency.
|
|
22
|
-
|
|
23
|
-
This class provides rate limiting functionality by sleeping between iterations to
|
|
24
|
-
maintain a desired execution frequency. It also tracks statistics about the achieved
|
|
25
|
-
rate and missed deadlines.
|
|
26
|
-
|
|
27
|
-
Attributes:
|
|
28
|
-
period_sec: Time period between iterations in seconds.
|
|
29
|
-
target_rate_hz: Desired execution rate in Hz.
|
|
30
|
-
window_size: Size of moving average window for rate calculations.
|
|
31
|
-
last_time_sec: Timestamp of the last iteration.
|
|
32
|
-
next_time_sec: Scheduled timestamp for the next iteration.
|
|
33
|
-
start_time_sec: Timestamp when the rate limiter was initialized or reset.
|
|
34
|
-
duration_buffer: List of recent iteration durations for rate calculation.
|
|
35
|
-
missed_deadlines: Counter for iterations that missed their scheduled time.
|
|
36
|
-
iterations: Total number of iterations since initialization or reset.
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
def __init__(self, rate_hz: float, window_size: int = 50) -> None:
|
|
40
|
-
"""Initializes rate limiter.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
rate_hz: Desired rate in Hz.
|
|
44
|
-
window_size: Size of moving average window for rate calculations.
|
|
45
|
-
|
|
46
|
-
Raises:
|
|
47
|
-
ValueError: If rate_hz is not positive.
|
|
48
|
-
"""
|
|
49
|
-
if rate_hz <= 0:
|
|
50
|
-
raise ValueError("Rate must be positive")
|
|
51
|
-
|
|
52
|
-
self.period_sec: Final[float] = 1.0 / rate_hz
|
|
53
|
-
self.target_rate_hz: Final[float] = rate_hz
|
|
54
|
-
self.window_size: Final[int] = max(1, window_size)
|
|
55
|
-
|
|
56
|
-
# Initialize timing variables
|
|
57
|
-
now_sec = time.monotonic()
|
|
58
|
-
self.last_time_sec: float = now_sec
|
|
59
|
-
self.next_time_sec: float = now_sec + self.period_sec
|
|
60
|
-
self.start_time_sec: float = now_sec
|
|
61
|
-
|
|
62
|
-
# Initialize statistics
|
|
63
|
-
self.duration_buffer: list[float] = []
|
|
64
|
-
self.missed_deadlines: int = 0
|
|
65
|
-
self.iterations: int = 0
|
|
66
|
-
self._MAX_ITERATIONS: Final[int] = sys.maxsize - 1000 # Leave some buffer
|
|
67
|
-
|
|
68
|
-
def sleep(self) -> None:
|
|
69
|
-
"""Sleeps to maintain desired rate.
|
|
70
|
-
|
|
71
|
-
Sleeps for the appropriate duration to maintain the target rate. If the next
|
|
72
|
-
scheduled time has already passed, increments the missed deadlines counter.
|
|
73
|
-
Uses monotonic time for reliable timing regardless of system clock changes.
|
|
74
|
-
"""
|
|
75
|
-
current_time_sec = time.monotonic()
|
|
76
|
-
sleep_time_sec = self.next_time_sec - current_time_sec
|
|
77
|
-
|
|
78
|
-
if sleep_time_sec > 0:
|
|
79
|
-
time.sleep(sleep_time_sec)
|
|
80
|
-
else:
|
|
81
|
-
self.missed_deadlines += 1
|
|
82
|
-
|
|
83
|
-
# Update timing and statistics
|
|
84
|
-
now_sec = time.monotonic()
|
|
85
|
-
|
|
86
|
-
# Reset iterations if approaching max value
|
|
87
|
-
if self.iterations >= self._MAX_ITERATIONS:
|
|
88
|
-
logger.warning(
|
|
89
|
-
"Iteration counter approaching max value, resetting statistics"
|
|
90
|
-
)
|
|
91
|
-
self.reset()
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
self.iterations += 1
|
|
95
|
-
|
|
96
|
-
if self.iterations > 1: # Skip first iteration
|
|
97
|
-
duration_sec = now_sec - self.last_time_sec
|
|
98
|
-
if len(self.duration_buffer) >= self.window_size:
|
|
99
|
-
self.duration_buffer.pop(0)
|
|
100
|
-
self.duration_buffer.append(duration_sec)
|
|
101
|
-
|
|
102
|
-
self.last_time_sec = now_sec
|
|
103
|
-
|
|
104
|
-
# More efficient way to advance next_time_sec when multiple periods behind
|
|
105
|
-
periods_behind = max(
|
|
106
|
-
0, int((now_sec - self.next_time_sec) / self.period_sec) + 1
|
|
107
|
-
)
|
|
108
|
-
self.next_time_sec += periods_behind * self.period_sec
|
|
109
|
-
|
|
110
|
-
def get_actual_rate(self) -> float:
|
|
111
|
-
"""Calculates actual achieved rate in Hz using moving average.
|
|
112
|
-
|
|
113
|
-
Returns:
|
|
114
|
-
Current execution rate based on recent iterations.
|
|
115
|
-
"""
|
|
116
|
-
if not self.duration_buffer:
|
|
117
|
-
return 0.0
|
|
118
|
-
avg_duration_sec = sum(self.duration_buffer) / len(self.duration_buffer)
|
|
119
|
-
return 0.0 if avg_duration_sec <= 0 else 1.0 / avg_duration_sec
|
|
120
|
-
|
|
121
|
-
def get_average_rate(self) -> float:
|
|
122
|
-
"""Calculates average rate over entire run.
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
Average execution rate since start or last reset.
|
|
126
|
-
"""
|
|
127
|
-
if self.iterations < 2:
|
|
128
|
-
return 0.0
|
|
129
|
-
total_time_sec = time.monotonic() - self.start_time_sec
|
|
130
|
-
return 0.0 if total_time_sec <= 0 else self.iterations / total_time_sec
|
|
131
|
-
|
|
132
|
-
def reset(self) -> None:
|
|
133
|
-
"""Resets the rate limiter state and statistics."""
|
|
134
|
-
now_sec = time.monotonic()
|
|
135
|
-
self.last_time_sec = now_sec
|
|
136
|
-
self.next_time_sec = now_sec + self.period_sec
|
|
137
|
-
self.start_time_sec = now_sec
|
|
138
|
-
self.duration_buffer.clear()
|
|
139
|
-
self.missed_deadlines = 0
|
|
140
|
-
self.iterations = 0
|
|
141
|
-
|
|
142
|
-
def get_stats(self) -> dict[str, float | int]:
|
|
143
|
-
"""Gets runtime statistics.
|
|
144
|
-
|
|
145
|
-
Returns:
|
|
146
|
-
Dictionary containing execution statistics including actual rate,
|
|
147
|
-
average rate, target rate, missed deadlines and iteration count.
|
|
148
|
-
"""
|
|
149
|
-
return {
|
|
150
|
-
"actual_rate": self.get_actual_rate(),
|
|
151
|
-
"average_rate": self.get_average_rate(),
|
|
152
|
-
"target_rate": self.target_rate_hz,
|
|
153
|
-
"missed_deadlines": self.missed_deadlines,
|
|
154
|
-
"iterations": self.iterations,
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if __name__ == "__main__":
|
|
159
|
-
rate_limiter = RateLimiter(100.0) # 100Hz
|
|
160
|
-
|
|
161
|
-
try:
|
|
162
|
-
while True:
|
|
163
|
-
rate_limiter.sleep()
|
|
164
|
-
actual_rate = rate_limiter.get_actual_rate()
|
|
165
|
-
logger.info(f"Rate: {actual_rate:.2f} Hz")
|
|
166
|
-
|
|
167
|
-
except KeyboardInterrupt:
|
|
168
|
-
stats = rate_limiter.get_stats()
|
|
169
|
-
logger.info("\nFinal stats:")
|
|
170
|
-
logger.info(f"Average rate: {stats['average_rate']:.2f} Hz")
|
|
171
|
-
logger.info(f"Final rate: {stats['actual_rate']:.2f} Hz")
|
|
172
|
-
logger.info(f"Missed deadlines: {stats['missed_deadlines']}")
|
dexcontrol/utils/rtc_utils.py
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
"""RTC utilities for dexcontrol.
|
|
12
|
-
|
|
13
|
-
This module provides utility functions for creating RTC subscribers
|
|
14
|
-
that first query Zenoh for connection information.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import zenoh
|
|
18
|
-
from loguru import logger
|
|
19
|
-
|
|
20
|
-
from dexcontrol.utils.subscribers.rtc import RTCSubscriber
|
|
21
|
-
from dexcontrol.utils.zenoh_utils import query_zenoh_json
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def query_rtc_info(
|
|
25
|
-
zenoh_session: zenoh.Session,
|
|
26
|
-
info_topic: str,
|
|
27
|
-
timeout: float = 2.0,
|
|
28
|
-
max_retries: int = 1,
|
|
29
|
-
retry_delay: float = 0.5,
|
|
30
|
-
) -> dict | None:
|
|
31
|
-
"""Query Zenoh for RTC connection information.
|
|
32
|
-
|
|
33
|
-
Args:
|
|
34
|
-
zenoh_session: Active Zenoh session for communication.
|
|
35
|
-
info_topic: Zenoh topic to query for RTC info.
|
|
36
|
-
timeout: Maximum time to wait for a response in seconds.
|
|
37
|
-
max_retries: Maximum number of retry attempts.
|
|
38
|
-
retry_delay: Initial delay between retries (doubles each retry).
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
Dictionary containing host and port information if successful, None otherwise.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
# Use the general Zenoh query function
|
|
45
|
-
info = query_zenoh_json(
|
|
46
|
-
zenoh_session=zenoh_session,
|
|
47
|
-
topic=info_topic,
|
|
48
|
-
timeout=timeout,
|
|
49
|
-
max_retries=max_retries,
|
|
50
|
-
retry_delay=retry_delay,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
return info
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def create_rtc_subscriber_from_zenoh(
|
|
57
|
-
zenoh_session: zenoh.Session,
|
|
58
|
-
info_topic: str,
|
|
59
|
-
name: str = "rtc_subscriber",
|
|
60
|
-
enable_fps_tracking: bool = True,
|
|
61
|
-
fps_log_interval: int = 100,
|
|
62
|
-
query_timeout: float = 2.0,
|
|
63
|
-
max_retries: int = 1,
|
|
64
|
-
) -> RTCSubscriber | None:
|
|
65
|
-
"""Create a RTC subscriber by first querying Zenoh for connection info.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
zenoh_session: Active Zenoh session for communication.
|
|
69
|
-
info_topic: Zenoh topic to query for RTC connection info.
|
|
70
|
-
name: Name for logging purposes.
|
|
71
|
-
enable_fps_tracking: Whether to track and log FPS metrics.
|
|
72
|
-
fps_log_interval: Number of frames between FPS calculations.
|
|
73
|
-
query_timeout: Maximum time to wait for Zenoh query response.
|
|
74
|
-
max_retries: Maximum number of retry attempts for Zenoh query.
|
|
75
|
-
|
|
76
|
-
Returns:
|
|
77
|
-
RTCSubscriber instance if successful, None otherwise.
|
|
78
|
-
"""
|
|
79
|
-
# Query Zenoh for RTC connection information
|
|
80
|
-
rtc_info = query_rtc_info(zenoh_session, info_topic, query_timeout, max_retries)
|
|
81
|
-
|
|
82
|
-
if rtc_info is None:
|
|
83
|
-
logger.error("Failed to get RTC connection info from Zenoh")
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
url = rtc_info.get("signaling_url")
|
|
87
|
-
|
|
88
|
-
if not url:
|
|
89
|
-
logger.error(f"Invalid RTC info: url={url}")
|
|
90
|
-
return None
|
|
91
|
-
|
|
92
|
-
# Construct WebSocket URL
|
|
93
|
-
ws_url = url
|
|
94
|
-
logger.info(f"Creating RTC subscriber with URL: {ws_url}")
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
# Create and return the RTC subscriber
|
|
98
|
-
subscriber = RTCSubscriber(
|
|
99
|
-
url=ws_url,
|
|
100
|
-
name=name,
|
|
101
|
-
enable_fps_tracking=enable_fps_tracking,
|
|
102
|
-
fps_log_interval=fps_log_interval,
|
|
103
|
-
)
|
|
104
|
-
return subscriber
|
|
105
|
-
except Exception as e:
|
|
106
|
-
logger.error(f"Failed to create RTC subscriber: {e}")
|
|
107
|
-
return None
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def create_rtc_subscriber_with_config(
|
|
111
|
-
zenoh_session: zenoh.Session,
|
|
112
|
-
config,
|
|
113
|
-
name: str = "rtc_subscriber",
|
|
114
|
-
enable_fps_tracking: bool = True,
|
|
115
|
-
fps_log_interval: int = 100,
|
|
116
|
-
) -> RTCSubscriber | None:
|
|
117
|
-
"""Create a RTC subscriber using configuration object.
|
|
118
|
-
|
|
119
|
-
Args:
|
|
120
|
-
zenoh_session: Active Zenoh session for communication.
|
|
121
|
-
config: Configuration object containing info_key.
|
|
122
|
-
name: Name for logging purposes.
|
|
123
|
-
enable_fps_tracking: Whether to track and log FPS metrics.
|
|
124
|
-
fps_log_interval: Number of frames between FPS calculations.
|
|
125
|
-
Returns:
|
|
126
|
-
RTCSubscriber instance if successful, None otherwise.
|
|
127
|
-
"""
|
|
128
|
-
if "info_key" not in config:
|
|
129
|
-
logger.error("Config subscriber_config missing info_key")
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
if not config["enable"]:
|
|
133
|
-
logger.info(f"Skipping {name} because it is disabled")
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
info_topic = config["info_key"]
|
|
137
|
-
|
|
138
|
-
return create_rtc_subscriber_from_zenoh(
|
|
139
|
-
zenoh_session=zenoh_session,
|
|
140
|
-
info_topic=info_topic,
|
|
141
|
-
name=name,
|
|
142
|
-
enable_fps_tracking=enable_fps_tracking,
|
|
143
|
-
fps_log_interval=fps_log_interval,
|
|
144
|
-
)
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
"""Zenoh subscriber utilities for dexcontrol.
|
|
12
|
-
|
|
13
|
-
This module provides a collection of subscriber classes and utilities for handling
|
|
14
|
-
Zenoh communication in a flexible and reusable way.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from .base import BaseZenohSubscriber, CustomDataHandler
|
|
18
|
-
from .camera import DepthCameraSubscriber, RGBCameraSubscriber, RGBDCameraSubscriber
|
|
19
|
-
from .decoders import (
|
|
20
|
-
DecoderFunction,
|
|
21
|
-
json_decoder,
|
|
22
|
-
protobuf_decoder,
|
|
23
|
-
raw_bytes_decoder,
|
|
24
|
-
string_decoder,
|
|
25
|
-
)
|
|
26
|
-
from .generic import GenericZenohSubscriber
|
|
27
|
-
from .imu import IMUSubscriber
|
|
28
|
-
from .lidar import LidarSubscriber
|
|
29
|
-
from .protobuf import ProtobufZenohSubscriber
|
|
30
|
-
from .rtc import RTCSubscriber
|
|
31
|
-
|
|
32
|
-
__all__ = [
|
|
33
|
-
"BaseZenohSubscriber",
|
|
34
|
-
"CustomDataHandler",
|
|
35
|
-
"GenericZenohSubscriber",
|
|
36
|
-
"ProtobufZenohSubscriber",
|
|
37
|
-
"DecoderFunction",
|
|
38
|
-
"protobuf_decoder",
|
|
39
|
-
"raw_bytes_decoder",
|
|
40
|
-
"json_decoder",
|
|
41
|
-
"string_decoder",
|
|
42
|
-
# Camera subscribers
|
|
43
|
-
"RGBCameraSubscriber",
|
|
44
|
-
"DepthCameraSubscriber",
|
|
45
|
-
"RGBDCameraSubscriber",
|
|
46
|
-
# Lidar subscriber
|
|
47
|
-
"LidarSubscriber",
|
|
48
|
-
# IMU subscriber
|
|
49
|
-
"IMUSubscriber",
|
|
50
|
-
# RTC subscriber
|
|
51
|
-
"RTCSubscriber",
|
|
52
|
-
]
|
|
@@ -1,281 +0,0 @@
|
|
|
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 Zenoh subscriber utilities.
|
|
12
|
-
|
|
13
|
-
This module provides the abstract base class for all Zenoh subscribers
|
|
14
|
-
and common utilities used across different subscriber implementations.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import threading
|
|
18
|
-
import time
|
|
19
|
-
from abc import ABC, abstractmethod
|
|
20
|
-
from collections.abc import Callable
|
|
21
|
-
from typing import Any, Final, TypeVar
|
|
22
|
-
|
|
23
|
-
import zenoh
|
|
24
|
-
from google.protobuf.message import Message
|
|
25
|
-
from loguru import logger
|
|
26
|
-
|
|
27
|
-
from dexcontrol.utils.os_utils import resolve_key_name
|
|
28
|
-
|
|
29
|
-
# Type variable for Message subclasses
|
|
30
|
-
M = TypeVar("M", bound=Message)
|
|
31
|
-
|
|
32
|
-
# Type alias for custom data handler functions
|
|
33
|
-
CustomDataHandler = Callable[[zenoh.Sample], None]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class BaseZenohSubscriber(ABC):
|
|
37
|
-
"""Base class for Zenoh subscribers with configurable data handling.
|
|
38
|
-
|
|
39
|
-
This class provides a common interface for subscribing to Zenoh topics
|
|
40
|
-
and processing incoming data through configurable decoder functions.
|
|
41
|
-
It handles the Zenoh communication setup and provides thread-safe access
|
|
42
|
-
to the latest data.
|
|
43
|
-
|
|
44
|
-
Attributes:
|
|
45
|
-
_active: Whether subscriber is receiving data updates.
|
|
46
|
-
_data_lock: Lock for thread-safe data access.
|
|
47
|
-
_zenoh_session: Active Zenoh session for communication.
|
|
48
|
-
_subscriber: Zenoh subscriber for data.
|
|
49
|
-
_topic: The resolved Zenoh topic name.
|
|
50
|
-
_enable_fps_tracking: Whether to track and log FPS metrics.
|
|
51
|
-
_frame_count: Counter for frames processed since last FPS calculation.
|
|
52
|
-
_fps: Most recently calculated frames per second value.
|
|
53
|
-
_last_fps_time: Timestamp of last FPS calculation.
|
|
54
|
-
_fps_log_interval: Number of frames between FPS calculations.
|
|
55
|
-
_name: Name for logging purposes.
|
|
56
|
-
_custom_data_handler: Optional custom data handler function.
|
|
57
|
-
_last_data_time: Timestamp of last data received.
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
def __init__(
|
|
61
|
-
self,
|
|
62
|
-
topic: str,
|
|
63
|
-
zenoh_session: zenoh.Session,
|
|
64
|
-
name: str = "subscriber",
|
|
65
|
-
enable_fps_tracking: bool = False,
|
|
66
|
-
fps_log_interval: int = 100,
|
|
67
|
-
custom_data_handler: CustomDataHandler | None = None,
|
|
68
|
-
) -> None:
|
|
69
|
-
"""Initialize the base Zenoh subscriber.
|
|
70
|
-
|
|
71
|
-
Args:
|
|
72
|
-
topic: Zenoh topic to subscribe to for data.
|
|
73
|
-
zenoh_session: Active Zenoh session for communication.
|
|
74
|
-
name: Name for logging purposes.
|
|
75
|
-
enable_fps_tracking: Whether to track and log FPS metrics.
|
|
76
|
-
fps_log_interval: Number of frames between FPS calculations.
|
|
77
|
-
custom_data_handler: Optional custom function to handle incoming data.
|
|
78
|
-
If provided, this will replace the default data
|
|
79
|
-
handling logic entirely.
|
|
80
|
-
"""
|
|
81
|
-
self._active: bool = False
|
|
82
|
-
self._data_lock: Final[threading.RLock] = threading.RLock()
|
|
83
|
-
self._zenoh_session: Final[zenoh.Session] = zenoh_session
|
|
84
|
-
self._name = name
|
|
85
|
-
self._custom_data_handler = custom_data_handler
|
|
86
|
-
|
|
87
|
-
# Data freshness tracking
|
|
88
|
-
self._last_data_time: float | None = None
|
|
89
|
-
|
|
90
|
-
# FPS tracking
|
|
91
|
-
self._enable_fps_tracking = enable_fps_tracking
|
|
92
|
-
self._fps_log_interval = fps_log_interval
|
|
93
|
-
self._frame_count = 0
|
|
94
|
-
self._fps = 0.0
|
|
95
|
-
self._last_fps_time = time.time()
|
|
96
|
-
|
|
97
|
-
# Setup Zenoh subscriber
|
|
98
|
-
self._topic: Final[str] = resolve_key_name(topic)
|
|
99
|
-
self._subscriber: Final[zenoh.Subscriber] = zenoh_session.declare_subscriber(
|
|
100
|
-
self._topic, self._data_handler_wrapper
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
logger.info(f"Initialized {self._name} subscriber for topic: {self._topic}")
|
|
104
|
-
|
|
105
|
-
def _data_handler_wrapper(self, sample: zenoh.Sample) -> None:
|
|
106
|
-
"""Wrapper for data handling that calls either custom or default handler.
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
sample: Zenoh sample containing data.
|
|
110
|
-
"""
|
|
111
|
-
# Update data freshness timestamp
|
|
112
|
-
with self._data_lock:
|
|
113
|
-
self._last_data_time = time.monotonic()
|
|
114
|
-
|
|
115
|
-
# Call custom data handler if provided, otherwise call default handler
|
|
116
|
-
if self._custom_data_handler is not None:
|
|
117
|
-
try:
|
|
118
|
-
self._custom_data_handler(sample)
|
|
119
|
-
except Exception as e:
|
|
120
|
-
logger.error(f"Custom data handler failed for {self._name}: {e}")
|
|
121
|
-
else:
|
|
122
|
-
# Call the default data handler
|
|
123
|
-
self._data_handler(sample)
|
|
124
|
-
|
|
125
|
-
@abstractmethod
|
|
126
|
-
def _data_handler(self, sample: zenoh.Sample) -> None:
|
|
127
|
-
"""Handle incoming data.
|
|
128
|
-
|
|
129
|
-
This method must be implemented by subclasses to process the specific
|
|
130
|
-
type of data they handle.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
sample: Zenoh sample containing data.
|
|
134
|
-
"""
|
|
135
|
-
pass
|
|
136
|
-
|
|
137
|
-
@abstractmethod
|
|
138
|
-
def get_latest_data(self) -> Any | None:
|
|
139
|
-
"""Get the latest data.
|
|
140
|
-
|
|
141
|
-
Returns:
|
|
142
|
-
Latest data if available, None otherwise.
|
|
143
|
-
"""
|
|
144
|
-
pass
|
|
145
|
-
|
|
146
|
-
def _update_fps_metrics(self) -> None:
|
|
147
|
-
"""Update FPS tracking metrics.
|
|
148
|
-
|
|
149
|
-
Increments frame counter and recalculates FPS at specified intervals.
|
|
150
|
-
Only has an effect if fps_tracking was enabled during initialization.
|
|
151
|
-
"""
|
|
152
|
-
if not self._enable_fps_tracking:
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
self._frame_count += 1
|
|
156
|
-
if self._frame_count >= self._fps_log_interval:
|
|
157
|
-
current_time = time.time()
|
|
158
|
-
elapsed = current_time - self._last_fps_time
|
|
159
|
-
self._fps = self._frame_count / elapsed
|
|
160
|
-
logger.info(f"{self._name} {self._topic} frequency: {self._fps:.2f} Hz")
|
|
161
|
-
self._frame_count = 0
|
|
162
|
-
self._last_fps_time = current_time
|
|
163
|
-
|
|
164
|
-
def wait_for_active(self, timeout: float = 5.0) -> bool:
|
|
165
|
-
"""Wait for the subscriber to start receiving data.
|
|
166
|
-
|
|
167
|
-
Args:
|
|
168
|
-
timeout: Maximum time to wait in seconds.
|
|
169
|
-
|
|
170
|
-
Returns:
|
|
171
|
-
True if subscriber becomes active, False if timeout is reached.
|
|
172
|
-
"""
|
|
173
|
-
start_time = time.monotonic()
|
|
174
|
-
check_interval = min(0.05, timeout / 10) # Check every 10ms or less
|
|
175
|
-
|
|
176
|
-
while True:
|
|
177
|
-
with self._data_lock:
|
|
178
|
-
if self._active:
|
|
179
|
-
return True
|
|
180
|
-
|
|
181
|
-
elapsed = time.monotonic() - start_time
|
|
182
|
-
if elapsed >= timeout:
|
|
183
|
-
logger.error(f"No data received from {self._topic} after {timeout}s")
|
|
184
|
-
return False
|
|
185
|
-
|
|
186
|
-
# Sleep for the shorter of: remaining time or check interval
|
|
187
|
-
sleep_time = min(check_interval, timeout - elapsed)
|
|
188
|
-
time.sleep(sleep_time)
|
|
189
|
-
|
|
190
|
-
def is_active(self) -> bool:
|
|
191
|
-
"""Check if the subscriber is actively receiving data.
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
True if subscriber is active, False otherwise.
|
|
195
|
-
"""
|
|
196
|
-
with self._data_lock:
|
|
197
|
-
return self._active
|
|
198
|
-
|
|
199
|
-
def shutdown(self) -> None:
|
|
200
|
-
"""Stop the subscriber and release resources."""
|
|
201
|
-
# Mark as inactive first to prevent further data processing
|
|
202
|
-
with self._data_lock:
|
|
203
|
-
self._active = False
|
|
204
|
-
|
|
205
|
-
# Small delay to allow any ongoing data processing to complete
|
|
206
|
-
time.sleep(0.05)
|
|
207
|
-
|
|
208
|
-
try:
|
|
209
|
-
if hasattr(self, "_subscriber") and self._subscriber:
|
|
210
|
-
self._subscriber.undeclare()
|
|
211
|
-
except Exception as e:
|
|
212
|
-
# Don't log "Undeclared subscriber" errors as warnings - they're expected during shutdown
|
|
213
|
-
error_msg = str(e).lower()
|
|
214
|
-
if not ("undeclared" in error_msg or "closed" in error_msg):
|
|
215
|
-
logger.warning(f"Error undeclaring subscriber for {self._topic}: {e}")
|
|
216
|
-
|
|
217
|
-
# Additional delay to allow Zenoh to process the undeclare operation
|
|
218
|
-
time.sleep(0.02)
|
|
219
|
-
|
|
220
|
-
@property
|
|
221
|
-
def topic(self) -> str:
|
|
222
|
-
"""Get the Zenoh topic name.
|
|
223
|
-
|
|
224
|
-
Returns:
|
|
225
|
-
The resolved Zenoh topic name.
|
|
226
|
-
"""
|
|
227
|
-
return self._topic
|
|
228
|
-
|
|
229
|
-
@property
|
|
230
|
-
def fps(self) -> float:
|
|
231
|
-
"""Get the current FPS measurement.
|
|
232
|
-
|
|
233
|
-
Returns:
|
|
234
|
-
Current frames per second measurement.
|
|
235
|
-
"""
|
|
236
|
-
return self._fps
|
|
237
|
-
|
|
238
|
-
@property
|
|
239
|
-
def name(self) -> str:
|
|
240
|
-
"""Get the subscriber name.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
The subscriber name.
|
|
244
|
-
"""
|
|
245
|
-
return self._name
|
|
246
|
-
|
|
247
|
-
def is_data_fresh(self, max_age_seconds: float) -> bool:
|
|
248
|
-
"""Check if the most recent data is fresh (received within the specified time).
|
|
249
|
-
|
|
250
|
-
This method checks if data has been received within the specified time window,
|
|
251
|
-
regardless of whether the data content has changed. This is useful for
|
|
252
|
-
detecting communication failures or stale data streams.
|
|
253
|
-
|
|
254
|
-
Args:
|
|
255
|
-
max_age_seconds: Maximum age in seconds for data to be considered fresh.
|
|
256
|
-
|
|
257
|
-
Returns:
|
|
258
|
-
True if fresh data was received within the time window, False otherwise.
|
|
259
|
-
Returns False if no data has ever been received.
|
|
260
|
-
"""
|
|
261
|
-
with self._data_lock:
|
|
262
|
-
if self._last_data_time is None:
|
|
263
|
-
return False
|
|
264
|
-
|
|
265
|
-
current_time = time.monotonic()
|
|
266
|
-
age = current_time - self._last_data_time
|
|
267
|
-
return age <= max_age_seconds
|
|
268
|
-
|
|
269
|
-
def get_time_since_last_data(self) -> float | None:
|
|
270
|
-
"""Get the time elapsed since the last data was received.
|
|
271
|
-
|
|
272
|
-
Returns:
|
|
273
|
-
Time in seconds since last data was received, or None if no data
|
|
274
|
-
has ever been received.
|
|
275
|
-
"""
|
|
276
|
-
with self._data_lock:
|
|
277
|
-
if self._last_data_time is None:
|
|
278
|
-
return None
|
|
279
|
-
|
|
280
|
-
current_time = time.monotonic()
|
|
281
|
-
return current_time - self._last_data_time
|