dexcontrol 0.2.10__py3-none-any.whl → 0.2.12__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 (41) hide show
  1. dexcontrol/__init__.py +1 -0
  2. dexcontrol/config/core/arm.py +5 -1
  3. dexcontrol/config/core/hand.py +1 -1
  4. dexcontrol/config/core/head.py +7 -8
  5. dexcontrol/config/core/misc.py +14 -1
  6. dexcontrol/config/core/torso.py +8 -4
  7. dexcontrol/config/sensors/cameras/__init__.py +2 -1
  8. dexcontrol/config/sensors/cameras/luxonis_camera.py +51 -0
  9. dexcontrol/config/sensors/cameras/rgb_camera.py +1 -1
  10. dexcontrol/config/sensors/cameras/zed_camera.py +2 -2
  11. dexcontrol/config/sensors/vega_sensors.py +9 -1
  12. dexcontrol/config/vega.py +30 -2
  13. dexcontrol/core/arm.py +71 -46
  14. dexcontrol/core/component.py +41 -4
  15. dexcontrol/core/head.py +35 -23
  16. dexcontrol/core/misc.py +94 -13
  17. dexcontrol/core/torso.py +24 -6
  18. dexcontrol/proto/dexcontrol_msg_pb2.py +38 -36
  19. dexcontrol/proto/dexcontrol_msg_pb2.pyi +48 -20
  20. dexcontrol/proto/dexcontrol_query_pb2.py +7 -3
  21. dexcontrol/proto/dexcontrol_query_pb2.pyi +24 -0
  22. dexcontrol/robot.py +232 -68
  23. dexcontrol/sensors/__init__.py +2 -1
  24. dexcontrol/sensors/camera/__init__.py +2 -0
  25. dexcontrol/sensors/camera/luxonis_camera.py +169 -0
  26. dexcontrol/sensors/camera/zed_camera.py +17 -8
  27. dexcontrol/sensors/imu/chassis_imu.py +5 -1
  28. dexcontrol/sensors/imu/zed_imu.py +3 -2
  29. dexcontrol/sensors/lidar/rplidar.py +1 -0
  30. dexcontrol/sensors/manager.py +3 -0
  31. dexcontrol/utils/constants.py +3 -0
  32. dexcontrol/utils/error_code.py +236 -0
  33. dexcontrol/utils/subscribers/lidar.py +1 -0
  34. dexcontrol/utils/trajectory_utils.py +17 -5
  35. dexcontrol/utils/viz_utils.py +86 -11
  36. dexcontrol/utils/zenoh_utils.py +39 -0
  37. {dexcontrol-0.2.10.dist-info → dexcontrol-0.2.12.dist-info}/METADATA +4 -2
  38. dexcontrol-0.2.12.dist-info/RECORD +75 -0
  39. dexcontrol-0.2.10.dist-info/RECORD +0 -72
  40. {dexcontrol-0.2.10.dist-info → dexcontrol-0.2.12.dist-info}/WHEEL +0 -0
  41. {dexcontrol-0.2.10.dist-info → dexcontrol-0.2.12.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,169 @@
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
+ """Luxonis wrist camera sensor using RGB and depth Zenoh subscribers.
12
+
13
+ This sensor mirrors the high-level API of other camera sensors. It subscribes to
14
+ RGB (JPEG over Zenoh) and depth streams published by dexsensor's Luxonis camera
15
+ pipeline and exposes a simple interface for getting images.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Any, Dict, Optional, Union
22
+
23
+ import numpy as np
24
+ import zenoh
25
+
26
+ from dexcontrol.config.sensors.cameras.luxonis_camera import LuxonisCameraConfig
27
+ from dexcontrol.utils.subscribers.camera import (
28
+ DepthCameraSubscriber,
29
+ RGBCameraSubscriber,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class LuxonisCameraSensor:
36
+ """RGBD camera sensor wrapper for Luxonis/OAK wrist camera.
37
+
38
+ Provides access to RGB and depth frames via dedicated Zenoh subscribers.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ configs: LuxonisCameraConfig,
44
+ zenoh_session: zenoh.Session,
45
+ ) -> None:
46
+ self._name = configs.name
47
+ self._configs = configs
48
+ self._zenoh_session = zenoh_session
49
+
50
+ # Support left and right RGB streams + depth, mirroring ZED structure
51
+ self._subscribers: Dict[str, Optional[Union[RGBCameraSubscriber, DepthCameraSubscriber]]] = {}
52
+
53
+ subscriber_config = configs.subscriber_config
54
+
55
+ try:
56
+ # Create subscribers for left_rgb, right_rgb, depth (configurable enables)
57
+ stream_defs: Dict[str, Dict[str, Any]] = {
58
+ "left_rgb": subscriber_config.get("left_rgb", {}),
59
+ "right_rgb": subscriber_config.get("right_rgb", {}),
60
+ "depth": subscriber_config.get("depth", {}),
61
+ }
62
+
63
+ for stream_name, cfg in stream_defs.items():
64
+ if not cfg or not cfg.get("enable", False):
65
+ self._subscribers[stream_name] = None
66
+ continue
67
+ topic = cfg.get("topic")
68
+ if not topic:
69
+ logger.warning(f"'{self._name}': No topic configured for '{stream_name}'")
70
+ self._subscribers[stream_name] = None
71
+ continue
72
+
73
+ if stream_name == "depth":
74
+ self._subscribers[stream_name] = DepthCameraSubscriber(
75
+ topic=topic,
76
+ zenoh_session=self._zenoh_session,
77
+ name=f"{self._name}_{stream_name}_subscriber",
78
+ enable_fps_tracking=configs.enable_fps_tracking,
79
+ fps_log_interval=configs.fps_log_interval,
80
+ )
81
+ else:
82
+ self._subscribers[stream_name] = RGBCameraSubscriber(
83
+ topic=topic,
84
+ zenoh_session=self._zenoh_session,
85
+ name=f"{self._name}_{stream_name}_subscriber",
86
+ enable_fps_tracking=configs.enable_fps_tracking,
87
+ fps_log_interval=configs.fps_log_interval,
88
+ )
89
+
90
+ except Exception as e:
91
+ logger.error(f"Error creating Luxonis wrist camera subscribers: {e}")
92
+
93
+ # Lifecycle
94
+ def shutdown(self) -> None:
95
+ for sub in self._subscribers.values():
96
+ if sub:
97
+ sub.shutdown()
98
+ logger.info(f"'{self._name}' sensor shut down.")
99
+
100
+ # Status
101
+ def is_active(self) -> bool:
102
+ return any(sub.is_active() for sub in self._subscribers.values() if sub is not None)
103
+
104
+ def wait_for_active(self, timeout: float = 5.0, require_both: bool = False) -> bool:
105
+ subs = [s for s in self._subscribers.values() if s is not None]
106
+ if not subs:
107
+ return False
108
+ if require_both:
109
+ return all(s.wait_for_active(timeout) for s in subs)
110
+ return any(s.wait_for_active(timeout) for s in subs)
111
+
112
+ # Data access
113
+ def get_obs(
114
+ self, obs_keys: Optional[list[str]] = None, include_timestamp: bool = False
115
+ ) -> Dict[str, Optional[np.ndarray]]:
116
+ """Get latest images.
117
+
118
+ obs_keys can include any of: ["left_rgb", "right_rgb", "depth"]. If None, returns
119
+ all available. If include_timestamp is True and the underlying subscriber returns
120
+ (image, timestamp), that tuple is forwarded; otherwise only the image is returned.
121
+ """
122
+ keys_to_fetch = obs_keys or self.available_streams
123
+
124
+ out: Dict[str, Optional[np.ndarray]] = {}
125
+ for key in keys_to_fetch:
126
+ sub = self._subscribers.get(key)
127
+ data = sub.get_latest_data() if sub else None
128
+ is_tuple_or_list = isinstance(data, (tuple, list))
129
+ if include_timestamp:
130
+ if not is_tuple_or_list and data is not None:
131
+ logger.warning(f"Timestamp is not available yet for {key} stream.")
132
+ out[key] = data
133
+ else:
134
+ out[key] = data[0] if is_tuple_or_list else data
135
+ return out
136
+
137
+ def get_rgb(self) -> Optional[np.ndarray]:
138
+ # Backward-compat: return left_rgb if available else right_rgb
139
+ for key in ("left_rgb", "right_rgb"):
140
+ sub = self._subscribers.get(key)
141
+ if sub:
142
+ data = sub.get_latest_data()
143
+ return data[0] if isinstance(data, (tuple, list)) else data
144
+ return None
145
+
146
+ def get_depth(self) -> Optional[np.ndarray]:
147
+ sub = self._subscribers.get("depth")
148
+ if not sub:
149
+ return None
150
+ data = sub.get_latest_data()
151
+ return data[0] if isinstance(data, (tuple, list)) else data
152
+
153
+ # Properties
154
+ @property
155
+ def fps(self) -> Dict[str, float]:
156
+ return {name: sub.fps for name, sub in self._subscribers.items() if sub is not None}
157
+
158
+ @property
159
+ def name(self) -> str:
160
+ return self._name
161
+
162
+ @property
163
+ def available_streams(self) -> list:
164
+ return [name for name, sub in self._subscribers.items() if sub is not None]
165
+
166
+ @property
167
+ def active_streams(self) -> list:
168
+ return [name for name, sub in self._subscribers.items() if sub and sub.is_active()]
169
+
@@ -140,9 +140,6 @@ class ZedCameraSensor:
140
140
 
141
141
  def _query_camera_info(self) -> None:
142
142
  """Query Zenoh for camera metadata if using RTC."""
143
- if not self._configs.use_rtc:
144
- logger.info(f"'{self._name}': Skipping camera info query in non-RTC mode.")
145
- return
146
143
 
147
144
  enabled_rgb_streams = [
148
145
  s
@@ -171,7 +168,6 @@ class ZedCameraSensor:
171
168
 
172
169
  logger.info(f"'{self._name}': Querying for camera info at '{final_info_key}'.")
173
170
  self._camera_info = query_zenoh_json(self._zenoh_session, final_info_key)
174
-
175
171
  if self._camera_info:
176
172
  logger.info(f"'{self._name}': Successfully received camera info.")
177
173
  else:
@@ -249,7 +245,8 @@ class ZedCameraSensor:
249
245
  return False
250
246
 
251
247
  def get_obs(
252
- self, obs_keys: Optional[list[str]] = None
248
+ self, obs_keys: Optional[list[str]] = None,
249
+ include_timestamp: bool = False
253
250
  ) -> Dict[str, Optional[np.ndarray]]:
254
251
  """Get the latest observation data from specified camera streams.
255
252
 
@@ -257,17 +254,29 @@ class ZedCameraSensor:
257
254
  obs_keys: A list of stream names to retrieve data from (e.g.,
258
255
  ['left_rgb', 'depth']). If None, retrieves data from all
259
256
  enabled streams.
257
+ include_timestamp: If True, includes the timestamp in the observation data.
258
+ The timestamp data is not available for RTC streams.
260
259
 
261
260
  Returns:
262
- A dictionary mapping stream names to their latest image data. The
261
+ A dictionary mapping stream names to their latest image data with timestamp. The
263
262
  image is a numpy array (HxWxC for RGB, HxW for depth) or None if
264
- no data is available for that stream.
263
+ no data is available for that stream. If include_timestamp is True,
264
+ the value in the dictionary is a tuple with the image and timestamp.
265
265
  """
266
266
  keys_to_fetch = obs_keys or self.available_streams
267
267
  obs_out = {}
268
268
  for key in keys_to_fetch:
269
269
  subscriber = self._subscribers.get(key)
270
- obs_out[key] = subscriber.get_latest_data() if subscriber else None
270
+ data = subscriber.get_latest_data() if subscriber else None
271
+
272
+ is_tuple_or_list = isinstance(data, (tuple, list))
273
+
274
+ if include_timestamp:
275
+ if not is_tuple_or_list:
276
+ logger.warning(f"Timestamp is not available yet for {key} stream.")
277
+ obs_out[key] = data
278
+ else:
279
+ obs_out[key] = data[0] if is_tuple_or_list else data
271
280
  return obs_out
272
281
 
273
282
  def get_left_rgb(self) -> Optional[np.ndarray]:
@@ -76,7 +76,7 @@ class ChassisIMUSensor:
76
76
  """
77
77
  return self._subscriber.wait_for_active(timeout)
78
78
 
79
- def get_obs(self, obs_keys: list[Literal['ang_vel', 'acc', 'quat']] | None = None) -> dict[str, np.ndarray]:
79
+ def get_obs(self, obs_keys: list[Literal['ang_vel', 'acc', 'quat']] | None = None) -> dict[Literal['ang_vel', 'acc', 'quat', 'timestamp_ns'], np.ndarray]:
80
80
  """Get observation data for the ZED IMU sensor.
81
81
 
82
82
  Args:
@@ -89,6 +89,7 @@ class ChassisIMUSensor:
89
89
  - 'ang_vel': Angular velocity from 'angular_velocity'
90
90
  - 'acc': Linear acceleration from 'acceleration'
91
91
  - 'quat': Orientation quaternion from 'orientation', in wxyz convention
92
+ - 'timestamp_ns': Timestamp in nanoseconds
92
93
  """
93
94
  if obs_keys is None:
94
95
  obs_keys = ['ang_vel', 'acc', 'quat']
@@ -110,6 +111,9 @@ class ChassisIMUSensor:
110
111
  else:
111
112
  raise ValueError(f"Invalid observation key: {key}")
112
113
 
114
+ if hasattr(data, 'timestamp_ns'):
115
+ obs_out['timestamp_ns'] = data.timestamp_ns
116
+
113
117
  return obs_out
114
118
 
115
119
  def get_acceleration(self) -> np.ndarray:
@@ -75,7 +75,7 @@ class ZedIMUSensor:
75
75
 
76
76
  Args:
77
77
  obs_keys: List of observation keys to retrieve. If None, returns all available data.
78
- Valid keys: ['ang_vel', 'acc', 'quat']
78
+ Valid keys: ['ang_vel', 'acc', 'quat', 'timestamp']
79
79
 
80
80
  Returns:
81
81
  Dictionary with observation data including all IMU measurements.
@@ -83,6 +83,7 @@ class ZedIMUSensor:
83
83
  - 'ang_vel': Angular velocity from 'angular_velocity'
84
84
  - 'acc': Linear acceleration from 'acceleration'
85
85
  - 'quat': Orientation quaternion from 'orientation'
86
+ - 'timestamp_ns': Timestamp from 'timestamp'
86
87
  """
87
88
  if obs_keys is None:
88
89
  obs_keys = ['ang_vel', 'acc', 'quat']
@@ -91,7 +92,7 @@ class ZedIMUSensor:
91
92
  if data is None:
92
93
  return None
93
94
 
94
- obs_out = {}
95
+ obs_out = {'timestamp_ns': data['timestamp']}
95
96
 
96
97
  for key in obs_keys:
97
98
  if key == 'ang_vel':
@@ -87,6 +87,7 @@ class RPLidarSensor:
87
87
  - ranges: Array of range measurements in meters
88
88
  - angles: Array of corresponding angles in radians
89
89
  - qualities: Array of quality values (0-255) if available, None otherwise
90
+ - timestamp: Timestamp in nanoseconds (int)
90
91
  """
91
92
  return self._subscriber.get_latest_data()
92
93
 
@@ -26,6 +26,7 @@ from omegaconf import DictConfig, OmegaConf
26
26
  from dexcontrol.config.sensors.vega_sensors import VegaSensorsConfig
27
27
 
28
28
  if TYPE_CHECKING:
29
+ from dexcontrol.sensors.camera.luxonis_camera import LuxonisCameraSensor
29
30
  from dexcontrol.sensors.camera.rgb_camera import RGBCameraSensor
30
31
  from dexcontrol.sensors.camera.zed_camera import ZedCameraSensor
31
32
  from dexcontrol.sensors.imu.chassis_imu import ChassisIMUSensor
@@ -49,6 +50,8 @@ class Sensors:
49
50
  if TYPE_CHECKING:
50
51
  # Type annotations for dynamically created sensor attributes
51
52
  head_camera: ZedCameraSensor
53
+ left_wrist_camera: LuxonisCameraSensor
54
+ right_wrist_camera: LuxonisCameraSensor
52
55
  base_left_camera: RGBCameraSensor
53
56
  base_right_camera: RGBCameraSensor
54
57
  base_front_camera: RGBCameraSensor
@@ -20,3 +20,6 @@ COMM_CFG_PATH_ENV_VAR: Final[str] = "DEXMATE_COMM_CFG_PATH"
20
20
 
21
21
  # Environment variable to disable heartbeat monitoring
22
22
  DISABLE_HEARTBEAT_ENV_VAR: Final[str] = "DEXCONTROL_DISABLE_HEARTBEAT"
23
+
24
+ # Environment variable to disable estop checking
25
+ DISABLE_ESTOP_CHECKING_ENV_VAR: Final[str] = "DEXCONTROL_DISABLE_ESTOP_CHECKING"
@@ -0,0 +1,236 @@
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
+ """Error code interpretation utilities for robot components."""
12
+
13
+ from typing import Dict
14
+
15
+
16
+ class ErrorCodeInterpreter:
17
+ """Interprets error codes for different robot components."""
18
+
19
+ # Arm error codes (left_arm and right_arm)
20
+ ARM_ERROR_CODES = {
21
+ 0x00000020: "Overtemperature",
22
+ 0x00000080: "Encoder error",
23
+ 0x00000200: "Software error",
24
+ 0x00000400: "Temperature sensor error",
25
+ 0x00000800: "Position limit exceeded",
26
+ 0x00002000: "Position tracking error exceeded",
27
+ 0x00004000: "Current detection error",
28
+ 0x00008000: "Brake failure",
29
+ 0x00010000: "Position command limit exceeded",
30
+ }
31
+
32
+ # Head error codes
33
+ HEAD_ERROR_CODES = {
34
+ 0x00: "Disabled",
35
+ 0x04: "Enabled",
36
+ 0x20: "Overvoltage",
37
+ 0x24: "Undervoltage",
38
+ }
39
+
40
+ # Chassis wheel error codes (common)
41
+ CHASSIS_COMMON_CODES = {
42
+ 0x00000000: "No error",
43
+ 0x00000004: "Overvoltage",
44
+ 0x00000008: "Undervoltage",
45
+ }
46
+
47
+ # Left drive wheel motor specific
48
+ CHASSIS_LEFT_DRIVE_CODES = {
49
+ 0x00000010: "Overcurrent",
50
+ 0x00000020: "Overload",
51
+ 0x00000080: "Encoder deviation",
52
+ 0x00000200: "Reference voltage error",
53
+ 0x00000800: "Hall sensor error",
54
+ 0x00001000: "Motor overtemperature",
55
+ 0x00002000: "Encoder error",
56
+ }
57
+
58
+ # Right drive wheel motor specific
59
+ CHASSIS_RIGHT_DRIVE_CODES = {
60
+ 0x00100000: "Overcurrent",
61
+ 0x00200000: "Overload",
62
+ 0x00800000: "Encoder deviation",
63
+ 0x02000000: "Reference voltage error",
64
+ 0x08000000: "Hall sensor error",
65
+ 0x10000000: "Motor overtemperature",
66
+ 0x20000000: "Encoder error",
67
+ }
68
+
69
+ # Steering wheel error codes (left and right)
70
+ CHASSIS_STEERING_CODES = {
71
+ 0xFC00000C: "Undervoltage",
72
+ 0xFC000010: "Overvoltage",
73
+ }
74
+
75
+ # Torso motor error codes
76
+ TORSO_ERROR_CODES = {
77
+ 0x00000008: "Overvoltage",
78
+ 0x00000010: "Undervoltage",
79
+ 0x00000040: "Startup error",
80
+ 0x00000080: "Speed feedback error",
81
+ 0x00000100: "Overcurrent",
82
+ }
83
+
84
+ # BMS (Battery Management System) alarm codes
85
+ BMS_ERROR_CODES = {
86
+ 0x0004: "Single cell overvoltage",
87
+ 0x0008: "Single cell undervoltage",
88
+ 0x0010: "Total overvoltage",
89
+ 0x0020: "Total undervoltage",
90
+ 0x0040: "High temperature",
91
+ 0x0080: "Low temperature",
92
+ 0x0100: "Discharge overcurrent",
93
+ 0x0200: "Charge overcurrent",
94
+ # Bits 8-15 are reserved
95
+ }
96
+
97
+ @classmethod
98
+ def interpret_error(cls, component: str, error_code: int) -> str:
99
+ """
100
+ Interpret error code for a specific component.
101
+
102
+ Args:
103
+ component: Component name (e.g., 'left_arm', 'right_arm', 'head', etc.)
104
+ error_code: Error code to interpret
105
+
106
+ Returns:
107
+ Human-readable error description or hex code if unknown
108
+ """
109
+ component_lower = component.lower()
110
+
111
+ # Arms (left and right)
112
+ if "left_arm" in component_lower or "right_arm" in component_lower:
113
+ return cls._interpret_bitmask_errors(error_code, cls.ARM_ERROR_CODES)
114
+
115
+ # Hands (left and right) - same as arms
116
+ elif "left_hand" in component_lower or "right_hand" in component_lower:
117
+ return cls._interpret_bitmask_errors(error_code, cls.ARM_ERROR_CODES)
118
+
119
+ # Head
120
+ elif "head" in component_lower:
121
+ return cls.HEAD_ERROR_CODES.get(
122
+ error_code, f"Unknown error: 0x{error_code:02X}"
123
+ )
124
+
125
+ # Chassis (includes all wheel errors)
126
+ elif "chassis" in component_lower:
127
+ return cls._interpret_chassis_errors(error_code)
128
+
129
+ # Torso
130
+ elif "torso" in component_lower:
131
+ return cls._interpret_bitmask_errors(error_code, cls.TORSO_ERROR_CODES)
132
+
133
+ # BMS (Battery Management System)
134
+ elif "bms" in component_lower:
135
+ return cls._interpret_bitmask_errors(error_code, cls.BMS_ERROR_CODES)
136
+
137
+ else:
138
+ return f"Unknown component error: 0x{error_code:08X}"
139
+
140
+ @classmethod
141
+ def _interpret_bitmask_errors(
142
+ cls, error_code: int, error_dict: Dict[int, str]
143
+ ) -> str:
144
+ """
145
+ Interpret bitmask-style error codes.
146
+
147
+ Args:
148
+ error_code: Error code with multiple possible bits set
149
+ error_dict: Dictionary mapping bit masks to error descriptions
150
+
151
+ Returns:
152
+ Comma-separated list of active errors or hex code if none found
153
+ """
154
+ if error_code == 0:
155
+ return "No error"
156
+
157
+ errors = []
158
+ for mask, description in error_dict.items():
159
+ if error_code & mask:
160
+ errors.append(description)
161
+
162
+ if errors:
163
+ return ", ".join(errors)
164
+ else:
165
+ return f"Unknown error: 0x{error_code:08X}"
166
+
167
+ @classmethod
168
+ def _interpret_chassis_errors(cls, error_code: int) -> str:
169
+ """
170
+ Interpret chassis-specific error codes (includes all wheel errors).
171
+
172
+ Args:
173
+ error_code: Error code to interpret
174
+
175
+ Returns:
176
+ Human-readable error description
177
+ """
178
+ # Check steering wheel codes first (they have specific values)
179
+ if error_code in cls.CHASSIS_STEERING_CODES:
180
+ return cls.CHASSIS_STEERING_CODES[error_code]
181
+
182
+ # Check common codes
183
+ if error_code == 0:
184
+ return "No error"
185
+
186
+ errors = []
187
+
188
+ # Check common chassis errors
189
+ for mask, description in cls.CHASSIS_COMMON_CODES.items():
190
+ if mask != 0 and (error_code & mask):
191
+ errors.append(description)
192
+
193
+ # Check left drive wheel specific
194
+ for mask, description in cls.CHASSIS_LEFT_DRIVE_CODES.items():
195
+ if error_code & mask:
196
+ errors.append(f"Left drive: {description}")
197
+
198
+ # Check right drive wheel specific
199
+ for mask, description in cls.CHASSIS_RIGHT_DRIVE_CODES.items():
200
+ if error_code & mask:
201
+ errors.append(f"Right drive: {description}")
202
+
203
+ if errors:
204
+ return ", ".join(errors)
205
+ else:
206
+ return f"Unknown chassis error: 0x{error_code:08X}"
207
+
208
+
209
+ def get_error_description(component: str, error_code: int) -> str:
210
+ """
211
+ Convenience function to get error description.
212
+
213
+ Args:
214
+ component: Component name
215
+ error_code: Error code to interpret
216
+
217
+ Returns:
218
+ Human-readable error description
219
+ """
220
+ return ErrorCodeInterpreter.interpret_error(component, error_code)
221
+
222
+
223
+ def get_multiple_errors(components_errors: Dict[str, int]) -> Dict[str, str]:
224
+ """
225
+ Get error descriptions for multiple components.
226
+
227
+ Args:
228
+ components_errors: Dictionary mapping component names to error codes
229
+
230
+ Returns:
231
+ Dictionary mapping component names to error descriptions
232
+ """
233
+ return {
234
+ component: get_error_description(component, error_code)
235
+ for component, error_code in components_errors.items()
236
+ }
@@ -92,6 +92,7 @@ class LidarSubscriber(BaseZenohSubscriber):
92
92
  - ranges: Array of range measurements in meters
93
93
  - angles: Array of corresponding angles in radians
94
94
  - qualities: Array of quality values (0-255) if available, None otherwise
95
+ - timestamp: Timestamp in nanoseconds (int)
95
96
  """
96
97
  with self._data_lock:
97
98
  if self._latest_raw_data is None:
@@ -16,7 +16,7 @@ import numpy as np
16
16
  def generate_linear_trajectory(
17
17
  current_pos: np.ndarray,
18
18
  target_pos: np.ndarray,
19
- max_vel: float = 0.5,
19
+ max_vel: float | np.ndarray = 0.5,
20
20
  control_hz: float = 100,
21
21
  ) -> tuple[np.ndarray, int]:
22
22
  """Generate a linear trajectory between current and target positions.
@@ -24,7 +24,9 @@ def generate_linear_trajectory(
24
24
  Args:
25
25
  current_pos: Current position array.
26
26
  target_pos: Target position array.
27
- max_vel: Maximum velocity in units per second.
27
+ max_vel: Maximum velocity in units per second. Can be:
28
+ - float: Same velocity limit for all dimensions
29
+ - numpy array: Per-dimension velocity limits (same length as current_pos)
28
30
  control_hz: Control frequency in Hz.
29
31
 
30
32
  Returns:
@@ -32,9 +34,19 @@ def generate_linear_trajectory(
32
34
  - trajectory: Array of waypoints from current to target position.
33
35
  - num_steps: Number of steps in the trajectory.
34
36
  """
35
- # Calculate linear interpolation between current and target positions
36
- max_diff = np.max(np.abs(target_pos - current_pos))
37
- num_steps = int(max_diff / max_vel * control_hz)
37
+ # Calculate time needed for each dimension
38
+ pos_diff = np.abs(target_pos - current_pos)
39
+
40
+ if isinstance(max_vel, np.ndarray):
41
+ # Per-dimension velocity limits - find the dimension that takes longest
42
+ time_needed = pos_diff / max_vel
43
+ max_time = np.max(time_needed)
44
+ else:
45
+ # Single velocity limit for all dimensions
46
+ max_diff = np.max(pos_diff)
47
+ max_time = max_diff / max_vel
48
+
49
+ num_steps = int(max_time * control_hz)
38
50
 
39
51
  # Ensure at least one step
40
52
  num_steps = max(1, num_steps)