dexcontrol 0.2.10__py3-none-any.whl → 0.3.0__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 (47) hide show
  1. dexcontrol/__init__.py +2 -0
  2. dexcontrol/config/core/arm.py +5 -1
  3. dexcontrol/config/core/chassis.py +9 -4
  4. dexcontrol/config/core/hand.py +2 -1
  5. dexcontrol/config/core/head.py +7 -8
  6. dexcontrol/config/core/misc.py +14 -1
  7. dexcontrol/config/core/torso.py +8 -4
  8. dexcontrol/config/sensors/cameras/__init__.py +2 -1
  9. dexcontrol/config/sensors/cameras/luxonis_camera.py +51 -0
  10. dexcontrol/config/sensors/cameras/rgb_camera.py +1 -1
  11. dexcontrol/config/sensors/cameras/zed_camera.py +2 -2
  12. dexcontrol/config/sensors/vega_sensors.py +9 -1
  13. dexcontrol/config/vega.py +34 -3
  14. dexcontrol/core/arm.py +103 -58
  15. dexcontrol/core/chassis.py +146 -115
  16. dexcontrol/core/component.py +83 -20
  17. dexcontrol/core/hand.py +74 -39
  18. dexcontrol/core/head.py +41 -28
  19. dexcontrol/core/misc.py +256 -25
  20. dexcontrol/core/robot_query_interface.py +440 -0
  21. dexcontrol/core/torso.py +28 -10
  22. dexcontrol/proto/dexcontrol_msg_pb2.py +27 -37
  23. dexcontrol/proto/dexcontrol_msg_pb2.pyi +111 -126
  24. dexcontrol/proto/dexcontrol_query_pb2.py +39 -35
  25. dexcontrol/proto/dexcontrol_query_pb2.pyi +41 -4
  26. dexcontrol/robot.py +266 -409
  27. dexcontrol/sensors/__init__.py +2 -1
  28. dexcontrol/sensors/camera/__init__.py +2 -0
  29. dexcontrol/sensors/camera/luxonis_camera.py +169 -0
  30. dexcontrol/sensors/camera/zed_camera.py +17 -8
  31. dexcontrol/sensors/imu/chassis_imu.py +5 -1
  32. dexcontrol/sensors/imu/zed_imu.py +3 -2
  33. dexcontrol/sensors/lidar/rplidar.py +1 -0
  34. dexcontrol/sensors/manager.py +3 -0
  35. dexcontrol/utils/constants.py +3 -0
  36. dexcontrol/utils/error_code.py +236 -0
  37. dexcontrol/utils/os_utils.py +183 -1
  38. dexcontrol/utils/pb_utils.py +0 -22
  39. dexcontrol/utils/subscribers/lidar.py +1 -0
  40. dexcontrol/utils/trajectory_utils.py +17 -5
  41. dexcontrol/utils/viz_utils.py +86 -11
  42. dexcontrol/utils/zenoh_utils.py +288 -2
  43. {dexcontrol-0.2.10.dist-info → dexcontrol-0.3.0.dist-info}/METADATA +15 -2
  44. dexcontrol-0.3.0.dist-info/RECORD +76 -0
  45. dexcontrol-0.2.10.dist-info/RECORD +0 -72
  46. {dexcontrol-0.2.10.dist-info → dexcontrol-0.3.0.dist-info}/WHEEL +0 -0
  47. {dexcontrol-0.2.10.dist-info → dexcontrol-0.3.0.dist-info}/licenses/LICENSE +0 -0
dexcontrol/robot.py CHANGED
@@ -24,16 +24,11 @@ from __future__ import annotations
24
24
 
25
25
  import os
26
26
  import signal
27
+ import sys
27
28
  import threading
28
29
  import time
29
30
  import weakref
30
- from typing import (
31
- TYPE_CHECKING,
32
- Any,
33
- Final,
34
- Literal,
35
- cast,
36
- )
31
+ from typing import TYPE_CHECKING, Any, Final, Literal, cast
37
32
 
38
33
  import hydra.utils
39
34
  import numpy as np
@@ -46,19 +41,19 @@ from rich.table import Table
46
41
  import dexcontrol
47
42
  from dexcontrol.config.vega import VegaConfig, get_vega_config
48
43
  from dexcontrol.core.component import RobotComponent
49
- from dexcontrol.proto import dexcontrol_query_pb2
44
+ from dexcontrol.core.hand import HandType
45
+ from dexcontrol.core.misc import ServerLogSubscriber
46
+ from dexcontrol.core.robot_query_interface import RobotQueryInterface
50
47
  from dexcontrol.sensors import Sensors
51
48
  from dexcontrol.utils.constants import ROBOT_NAME_ENV_VAR
52
- from dexcontrol.utils.os_utils import get_robot_model, resolve_key_name
53
- from dexcontrol.utils.pb_utils import (
54
- TYPE_SOFTWARE_VERSION,
55
- ComponentStatus,
56
- software_version_to_dict,
57
- status_to_dict,
58
- )
49
+ from dexcontrol.utils.os_utils import check_version_compatibility, get_robot_model
59
50
  from dexcontrol.utils.rate_limiter import RateLimiter
60
51
  from dexcontrol.utils.trajectory_utils import generate_linear_trajectory
61
- from dexcontrol.utils.viz_utils import show_component_status, show_software_version
52
+ from dexcontrol.utils.zenoh_utils import (
53
+ close_zenoh_session_with_timeout,
54
+ create_zenoh_session,
55
+ wait_for_zenoh_cleanup,
56
+ )
62
57
 
63
58
  if TYPE_CHECKING:
64
59
  from dexcontrol.core.arm import Arm
@@ -74,55 +69,25 @@ _active_robots: weakref.WeakSet[Robot] = weakref.WeakSet()
74
69
  _signal_handlers_registered: bool = False
75
70
 
76
71
 
77
- def _signal_handler(signum: int, frame: Any) -> None:
78
- """Signal handler to shutdown all active Robot instances.
79
-
80
- Args:
81
- signum: Signal number received (e.g., SIGINT, SIGTERM).
82
- frame: Current stack frame (unused).
83
- """
84
- logger.info(f"Received signal {signum}, shutting down all active robots...")
85
- # Create a list copy to avoid modification during iteration
86
- robots_to_shutdown = list(_active_robots)
87
- for robot in robots_to_shutdown:
88
- logger.info(f"Shutting down robot: {robot}")
89
- try:
90
- robot.shutdown()
91
- except Exception as e: # pylint: disable=broad-except
92
- logger.error(f"Error during robot shutdown: {e}", exc_info=True)
93
- logger.info("All robots shutdown complete")
94
- os._exit(1)
95
-
96
-
97
72
  def _register_signal_handlers() -> None:
98
- """Register signal handlers for graceful shutdown.
99
-
100
- This function ensures signal handlers are registered only once and sets up
101
- handlers for SIGINT (Ctrl+C), SIGTERM, and SIGHUP (on Unix systems).
102
- """
73
+ """Register signal handlers for graceful shutdown."""
103
74
  global _signal_handlers_registered
104
75
  if _signal_handlers_registered:
105
76
  return
106
77
 
107
- # Register handlers for common termination signals
108
- signal.signal(signal.SIGINT, _signal_handler) # Ctrl+C
109
- signal.signal(signal.SIGTERM, _signal_handler) # Termination signal
78
+ def signal_handler(signum: int, frame: Any) -> None:
79
+ sys.exit(0)
80
+
81
+ signal.signal(signal.SIGINT, signal_handler)
82
+ signal.signal(signal.SIGTERM, signal_handler)
110
83
 
111
- # On Unix systems, also handle SIGHUP
112
84
  if hasattr(signal, "SIGHUP"):
113
- signal.signal(signal.SIGHUP, _signal_handler)
85
+ signal.signal(signal.SIGHUP, signal_handler)
114
86
 
115
87
  _signal_handlers_registered = True
116
88
 
117
89
 
118
- class ComponentConfig(omegaconf.DictConfig):
119
- """Type hints for component configuration."""
120
-
121
- _target_: str
122
- configs: dict[str, Any]
123
-
124
-
125
- class Robot:
90
+ class Robot(RobotQueryInterface):
126
91
  """Main interface class for robot control and monitoring.
127
92
 
128
93
  This class serves as the primary interface for interacting with a robot system.
@@ -131,33 +96,32 @@ class Robot:
131
96
  system-wide operations like status monitoring, trajectory execution, and component
132
97
  control.
133
98
 
134
- The Robot class supports context manager usage and automatic cleanup on program
135
- interruption through signal handlers.
136
-
137
99
  Example usage:
138
100
  # Using context manager (recommended)
139
101
  with Robot() as robot:
140
102
  robot.set_joint_pos({"left_arm": [0, 0, 0, 0, 0, 0, 0]})
103
+ version_info = robot.get_version_info()
141
104
 
142
105
  # Manual usage with explicit shutdown
143
106
  robot = Robot()
144
107
  try:
145
108
  robot.set_joint_pos({"left_arm": [0, 0, 0, 0, 0, 0, 0]})
109
+ hand_types = robot.query_hand_type()
146
110
  finally:
147
111
  robot.shutdown()
148
112
 
149
113
  Attributes:
150
- left_arm: Left arm component interface.
151
- right_arm: Right arm component interface.
152
- left_hand: Left hand component interface.
153
- right_hand: Right hand component interface.
154
- head: Head component interface.
155
- chassis: Chassis component interface.
156
- torso: Torso component interface.
114
+ left_arm: Left arm component interface (7-DOF manipulator).
115
+ right_arm: Right arm component interface (7-DOF manipulator).
116
+ left_hand: Left hand component interface (conditional, based on hardware).
117
+ right_hand: Right hand component interface (conditional, based on hardware).
118
+ head: Head component interface (3-DOF pan-tilt-roll).
119
+ chassis: Chassis component interface (mobile base).
120
+ torso: Torso component interface (1-DOF pitch).
157
121
  battery: Battery monitoring interface.
158
122
  estop: Emergency stop interface.
159
123
  heartbeat: Heartbeat monitoring interface.
160
- sensors: Sensor systems interface.
124
+ sensors: Sensor systems interface (cameras, IMU, lidar, etc.).
161
125
  """
162
126
 
163
127
  # Type annotations for dynamically created attributes
@@ -204,25 +168,20 @@ class Robot:
204
168
  robot_model = get_robot_model()
205
169
  self._robot_model: Final[str] = robot_model
206
170
 
207
- try:
208
- self._configs: Final[VegaConfig] = configs or get_vega_config(robot_model)
209
- except Exception as e:
210
- raise ValueError(f"Failed to load robot configuration: {e}") from e
171
+ # Load configuration and initialize zenoh session
172
+ self._configs: Final[VegaConfig] = configs or get_vega_config(robot_model)
173
+ self._zenoh_session: zenoh.Session = create_zenoh_session(zenoh_config_file)
211
174
 
212
- try:
213
- self._zenoh_session: Final[zenoh.Session] = self._init_zenoh_session(
214
- zenoh_config_file
215
- )
216
- except Exception as e:
217
- raise RuntimeError(f"Failed to initialize zenoh session: {e}") from e
175
+ # Initialize ZenohQueryable parent class
176
+ super().__init__(self._zenoh_session, self._configs)
218
177
 
219
178
  self._robot_name: Final[str] = os.getenv(ROBOT_NAME_ENV_VAR, "robot")
220
- self._pv_components: Final[list[str]] = [
221
- "left_hand",
222
- "right_hand",
179
+ self._pv_components: list[str] = [
223
180
  "head",
224
181
  "torso",
225
182
  ]
183
+ self._log_subscriber = ServerLogSubscriber(self._zenoh_session)
184
+ self._hand_types: dict[str, HandType] = {}
226
185
 
227
186
  # Register for automatic shutdown on signals if enabled
228
187
  if auto_shutdown:
@@ -231,36 +190,11 @@ class Robot:
231
190
 
232
191
  self._print_initialization_info(robot_model)
233
192
 
234
- # Initialize robot body components dynamically
235
- try:
236
- config_dict = omegaconf.OmegaConf.to_container(self._configs, resolve=True)
237
- if not isinstance(config_dict, dict):
238
- raise ValueError("Invalid configuration format")
239
- self._init_components(cast(dict[str, Any], config_dict))
240
- except Exception as e:
241
- self.shutdown() # Clean up on initialization failure
242
- raise RuntimeError(f"Failed to initialize robot components: {e}") from e
193
+ # Initialize robot components with safe error handling
194
+ self._safe_initialize_components()
243
195
 
244
- # Ensure all components are active
245
- try:
246
- self._wait_for_components()
247
- except Exception as e:
248
- self.shutdown() # Clean up on initialization failure
249
- raise RuntimeError(f"Failed to activate components: {e}") from e
250
-
251
- try:
252
- self.sensors = Sensors(self._configs.sensors, self._zenoh_session)
253
- self.sensors.wait_for_all_active()
254
- except Exception as e:
255
- self.shutdown() # Clean up on initialization failure
256
- raise RuntimeError(f"Failed to initialize sensors: {e}") from e
257
-
258
- # Set default modes
259
- try:
260
- self._set_default_modes()
261
- except Exception as e:
262
- self.shutdown() # Clean up on initialization failure
263
- raise RuntimeError(f"Failed to set default modes: {e}") from e
196
+ # Check version compatibility using new JSON interface
197
+ self._check_version_compatibility()
264
198
 
265
199
  @property
266
200
  def robot_model(self) -> str:
@@ -280,33 +214,26 @@ class Robot:
280
214
  """
281
215
  return self._robot_name
282
216
 
283
- def __enter__(self) -> Robot:
284
- """Context manager entry.
285
-
286
- Returns:
287
- Self reference for context management.
288
- """
217
+ def __enter__(self) -> "Robot":
218
+ """Enter context manager."""
289
219
  return self
290
220
 
291
- def __exit__(
292
- self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any
293
- ) -> None:
294
- """Context manager exit with automatic shutdown.
295
-
296
- Args:
297
- exc_type: Type of the exception that occurred, if any.
298
- exc_val: Exception instance that occurred, if any.
299
- exc_tb: Traceback of the exception that occurred, if any.
300
- """
221
+ def __exit__(self, exc_type, exc_val, exc_tb):
222
+ """Exit context manager and clean up resources."""
301
223
  self.shutdown()
302
224
 
303
225
  def __del__(self) -> None:
304
226
  """Destructor to ensure cleanup."""
305
227
  if not self._shutdown_called:
306
- logger.warning(
307
- "Robot instance being destroyed without explicit shutdown call"
308
- )
309
- self.shutdown()
228
+ # Only log if the logger is still available (process might be terminating)
229
+ try:
230
+ logger.warning(
231
+ "Robot instance being destroyed without explicit shutdown call"
232
+ )
233
+ self.shutdown()
234
+ except Exception: # pylint: disable=broad-except
235
+ # During interpreter shutdown, some modules might not be available
236
+ pass
310
237
 
311
238
  def _print_initialization_info(self, robot_model: str | None) -> None:
312
239
  """Print initialization information.
@@ -326,20 +253,65 @@ class Robot:
326
253
 
327
254
  console.print(table)
328
255
 
329
- def _init_components(self, config_dict: dict[str, Any]) -> None:
330
- """Initialize robot components from configuration.
256
+ def _safe_initialize_components(self) -> None:
257
+ """Safely initialize all robot components with consolidated error handling.
331
258
 
332
- Args:
333
- config_dict: Configuration dictionary for components.
259
+ This method consolidates the initialization of components, sensors, and
260
+ default modes into a single method with unified error handling.
334
261
 
335
262
  Raises:
336
- RuntimeError: If component initialization fails.
263
+ RuntimeError: If any critical initialization step fails.
337
264
  """
265
+ initialization_steps = [
266
+ ("robot components", self._initialize_robot_components),
267
+ ("component activation", self._wait_for_components),
268
+ ("sensors", self._initialize_sensors),
269
+ ("default state", self._set_default_state),
270
+ ]
271
+
272
+ for step_name, step_function in initialization_steps:
273
+ try:
274
+ step_function()
275
+ except Exception as e:
276
+ self.shutdown()
277
+ raise RuntimeError(
278
+ f"Robot initialization failed at {step_name}: {e}"
279
+ ) from e
280
+
281
+ def _initialize_sensors(self) -> None:
282
+ """Initialize sensors and wait for activation."""
283
+ self.sensors = Sensors(self._configs.sensors, self._zenoh_session)
284
+ self.sensors.wait_for_all_active()
285
+
286
+ def _initialize_robot_components(self) -> None:
287
+ """Initialize robot components from configuration."""
288
+ config_dict = omegaconf.OmegaConf.to_container(self._configs, resolve=True)
289
+ config_dict = cast(dict[str, Any], config_dict)
290
+
291
+ initialized_components = []
292
+ failed_components = []
293
+
338
294
  for component_name, component_config in config_dict.items():
339
295
  if component_name == "sensors":
340
296
  continue
341
297
 
342
298
  try:
299
+ # Skip hand initialization if the hand is not present on hardware or unknown
300
+ if (
301
+ component_name in ["left_hand", "right_hand"]
302
+ and self._hand_types == {}
303
+ ):
304
+ self._hand_types = self.query_hand_type()
305
+ if (
306
+ component_name in ["left_hand", "right_hand"]
307
+ and self._hand_types.get(component_name.split("_")[0])
308
+ == HandType.UNKNOWN
309
+ ):
310
+ logger.info(
311
+ f"Skipping {component_name} initialization, no known hand detected."
312
+ )
313
+ continue
314
+
343
315
  component_config = getattr(self._configs, str(component_name))
344
316
  if (
345
317
  not hasattr(component_config, "_target_")
@@ -347,6 +319,7 @@ class Robot:
347
319
  ):
348
320
  continue
349
321
 
322
+ # Create component configuration
350
323
  temp_config = omegaconf.OmegaConf.create(
351
324
  {
352
325
  "_target_": component_config._target_,
@@ -355,16 +328,41 @@ class Robot:
355
328
  },
356
329
  }
357
330
  )
331
+
332
+ # Handle different hand types
333
+ if component_name in ["left_hand", "right_hand"]:
334
+ hand_type = self._hand_types.get(component_name.split("_")[0])
335
+ temp_config["hand_type"] = hand_type
336
+
337
+ # Instantiate component with error handling
358
338
  component_instance = hydra.utils.instantiate(
359
339
  temp_config, zenoh_session=self._zenoh_session
360
340
  )
341
+
342
+ # Store component instance both as attribute and in tracking dictionaries
361
343
  setattr(self, str(component_name), component_instance)
344
+ initialized_components.append(component_name)
345
+
362
346
  except Exception as e:
363
- raise RuntimeError(
364
- f"Failed to initialize component {component_name}: {e}"
365
- ) from e
347
+ logger.error(f"Failed to initialize component {component_name}: {e}")
348
+ failed_components.append(component_name)
349
+ # Continue with other components rather than failing completely
350
+
351
+ # Report initialization summary
352
+ if failed_components:
353
+ logger.warning(
354
+ f"Failed to initialize components: {', '.join(failed_components)}"
355
+ )
356
+
357
+ # Raise error only if no critical components were initialized
358
+ critical_components = ["left_arm", "right_arm", "head", "torso", "chassis"]
359
+ initialized_critical = [
360
+ c for c in critical_components if c in initialized_components
361
+ ]
362
+ if not initialized_critical:
363
+ raise RuntimeError("Failed to initialize any critical components")
366
364
 
367
- def _set_default_modes(self) -> None:
365
+ def _set_default_state(self) -> None:
368
366
  """Set default control modes for robot components.
369
367
 
370
368
  Raises:
@@ -372,21 +370,13 @@ class Robot:
372
370
  """
373
371
  for arm in ["left_arm", "right_arm"]:
374
372
  if component := getattr(self, arm, None):
375
- try:
376
- component.set_mode("position")
377
- except Exception as e:
378
- raise RuntimeError(
379
- f"Failed to set default mode for {arm}: {e}"
380
- ) from e
373
+ component.set_modes(["position"] * 7)
381
374
 
382
375
  if head := getattr(self, "head", None):
383
- try:
384
- head.set_mode("enable")
385
- home_pos = head.get_predefined_pose("home")
386
- home_pos = self.compensate_torso_pitch(home_pos, "head")
387
- head.set_joint_pos(home_pos)
388
- except Exception as e:
389
- raise RuntimeError(f"Failed to set default mode for head: {e}") from e
376
+ head.set_mode("enable")
377
+ home_pos = head.get_predefined_pose("home")
378
+ home_pos = self.compensate_torso_pitch(home_pos, "head")
379
+ head.set_joint_pos(home_pos)
390
380
 
391
381
  def _wait_for_components(self) -> None:
392
382
  """Waits for all critical components to become active.
@@ -401,20 +391,25 @@ class Robot:
401
391
  component_names: Final[list[str]] = [
402
392
  "left_arm",
403
393
  "right_arm",
404
- "left_hand",
405
- "right_hand",
406
394
  "head",
407
395
  "chassis",
408
396
  "torso",
409
397
  "battery",
410
398
  "estop",
411
399
  ]
400
+
401
+ # Only add hands to component_names if they were actually initialized
402
+ if hasattr(self, "left_hand"):
403
+ component_names.append("left_hand")
404
+ if hasattr(self, "right_hand"):
405
+ component_names.append("right_hand")
406
+
412
407
  if self._configs.heartbeat.enabled:
413
408
  component_names.append("heartbeat")
414
409
 
415
410
  console = Console()
416
411
  actives: list[bool] = []
417
- timeout_sec: Final[int] = 8
412
+ timeout_sec: Final[float] = 5.0
418
413
  check_interval: Final[float] = 0.1 # Check every 100ms
419
414
 
420
415
  status = console.status(
@@ -445,7 +440,6 @@ class Robot:
445
440
 
446
441
  # Check if we've exceeded timeout
447
442
  if time.monotonic() - start_time >= timeout_sec:
448
- console.log(f"{name} failed to become active")
449
443
  actives.append(False)
450
444
  break
451
445
 
@@ -454,51 +448,110 @@ class Robot:
454
448
  finally:
455
449
  status.stop()
456
450
 
457
- if not all(actives):
451
+ if not any(actives):
458
452
  self.shutdown()
453
+ raise RuntimeError(f"No components activated within {timeout_sec}s")
454
+
455
+ if not all(actives):
459
456
  inactive = [
460
457
  name for name, active in zip(component_names, actives) if not active
461
458
  ]
462
- raise RuntimeError(
463
- f"Components failed to activate within {timeout_sec}s: {', '.join(inactive)}"
459
+ logger.error(
460
+ f"Components failed to activate within {timeout_sec}s: {', '.join(inactive)}.\n"
461
+ f"Other components may work, but some features, e.g. collision avoidance, may not work correctly."
462
+ f"Please check the robot status immediately."
464
463
  )
464
+ else:
465
+ logger.info("All motor components are active")
465
466
 
466
- logger.info("All components activated successfully")
467
+ def get_component_map(self) -> dict[str, Any]:
468
+ """Get the component mapping dictionary.
467
469
 
468
- def _init_zenoh_session(self, zenoh_config_file: str | None) -> zenoh.Session:
469
- """Initializes Zenoh communication session.
470
+ Returns:
471
+ Dictionary mapping component names to component instances.
472
+ """
473
+ component_map = {
474
+ "left_arm": getattr(self, "left_arm", None),
475
+ "right_arm": getattr(self, "right_arm", None),
476
+ "torso": getattr(self, "torso", None),
477
+ "head": getattr(self, "head", None),
478
+ }
470
479
 
471
- Args:
472
- zenoh_config_file: Path to zenoh configuration file.
480
+ # Only add hands if they were initialized
481
+ if hasattr(self, "left_hand"):
482
+ component_map["left_hand"] = self.left_hand
483
+ if hasattr(self, "right_hand"):
484
+ component_map["right_hand"] = self.right_hand
473
485
 
474
- Returns:
475
- Initialized zenoh session.
486
+ # Remove None values
487
+ return {k: v for k, v in component_map.items() if v is not None}
488
+
489
+ def validate_component_names(self, joint_pos: dict[str, Any]) -> None:
490
+ """Validate that all component names are valid and initialized.
491
+
492
+ Args:
493
+ joint_pos: Joint position dictionary to validate.
476
494
 
477
495
  Raises:
478
- RuntimeError: If zenoh session initialization fails.
496
+ ValueError: If invalid component names are found with detailed guidance.
479
497
  """
480
- try:
481
- config_path = zenoh_config_file or self._get_default_zenoh_config()
482
- if config_path is None:
483
- logger.warning("Using default zenoh config settings")
484
- return zenoh.open(zenoh.Config())
485
- return zenoh.open(zenoh.Config.from_file(config_path))
486
- except Exception as e:
487
- raise RuntimeError(f"Failed to initialize zenoh session: {e}") from e
498
+ if not joint_pos:
499
+ raise ValueError("Joint position dictionary cannot be empty")
488
500
 
489
- @staticmethod
490
- def _get_default_zenoh_config() -> str | None:
491
- """Gets the default zenoh configuration file path.
501
+ component_map = self.get_component_map()
502
+ valid_components = set(component_map.keys())
503
+ provided_components = set(joint_pos.keys())
504
+ invalid_components = provided_components - valid_components
492
505
 
493
- Returns:
494
- Path to default config file if it exists, None otherwise.
506
+ if invalid_components:
507
+ available_msg = (
508
+ f"Available components: {', '.join(sorted(valid_components))}"
509
+ )
510
+ invalid_msg = (
511
+ f"Invalid component names: {', '.join(sorted(invalid_components))}"
512
+ )
513
+
514
+ # Provide helpful suggestions for common mistakes
515
+ suggestions = []
516
+ for invalid in invalid_components:
517
+ if invalid in ["left_hand", "right_hand"]:
518
+ suggestions.append(f"'{invalid}' may not be connected or detected")
519
+ elif invalid.replace("_", "") in [
520
+ c.replace("_", "") for c in valid_components
521
+ ]:
522
+ close_match = next(
523
+ (
524
+ c
525
+ for c in valid_components
526
+ if c.replace("_", "") == invalid.replace("_", "")
527
+ ),
528
+ None,
529
+ )
530
+ if close_match:
531
+ suggestions.append(
532
+ f"Did you mean '{close_match}' instead of '{invalid}'?"
533
+ )
534
+
535
+ error_msg = f"{invalid_msg}. {available_msg}."
536
+ if suggestions:
537
+ error_msg += f" Suggestions: {' '.join(suggestions)}"
538
+
539
+ raise ValueError(error_msg)
540
+
541
+ def _check_version_compatibility(self) -> None:
542
+ """Check version compatibility between client and server.
543
+
544
+ This method uses the new JSON-based version interface to:
545
+ 1. Compare client library version with server's minimum required version
546
+ 2. Check server component versions for compatibility
547
+ 3. Provide clear guidance for version mismatches
495
548
  """
496
- default_path = dexcontrol.COMM_CFG_PATH
497
- if not default_path.exists():
498
- logger.warning(f"Zenoh config file not found at {default_path}")
499
- logger.warning("Please use dextop to set up the zenoh config file")
500
- return None
501
- return str(default_path)
549
+ try:
550
+ version_info = self.get_version_info(show=False)
551
+ check_version_compatibility(version_info)
552
+ except Exception as e:
553
+ # Log error but don't fail initialization for version check issues
554
+ logger.warning(f"Version compatibility check failed: {e}")
502
555
 
503
556
  def shutdown(self) -> None:
504
557
  """Cleans up and closes all component connections.
@@ -511,7 +564,7 @@ class Robot:
511
564
  logger.warning("Shutdown already called, skipping")
512
565
  return
513
566
 
514
- logger.info("Shutting down robot...")
567
+ logger.info("Shutting down robot components...")
515
568
  self._shutdown_called = True
516
569
 
517
570
  # Remove from active robots registry
@@ -533,9 +586,6 @@ class Robot:
533
586
  f"Error stopping component {component.__class__.__name__}: {e}"
534
587
  )
535
588
 
536
- # Brief delay to ensure all stop operations complete
537
- time.sleep(0.1)
538
-
539
589
  # Shutdown sensors first (they may have background threads)
540
590
  try:
541
591
  if hasattr(self, "sensors") and self.sensors is not None:
@@ -558,40 +608,29 @@ class Robot:
558
608
  # Brief delay to allow component shutdown to complete
559
609
  time.sleep(0.1)
560
610
 
561
- # Enhanced Zenoh session close with better synchronization
562
- zenoh_close_success = False
563
-
564
- def _close_zenoh_session():
565
- """Close zenoh session in a separate thread."""
566
- nonlocal zenoh_close_success
567
- try:
568
- # Brief delay for Zenoh cleanup
569
- time.sleep(0.1)
570
- self._zenoh_session.close()
571
- zenoh_close_success = True
572
- except Exception as e: # pylint: disable=broad-except
573
- logger.warning(f"Zenoh session close error: {e}")
574
-
575
- # Use non-daemon thread for proper cleanup
576
- close_thread = threading.Thread(target=_close_zenoh_session, daemon=False)
577
- close_thread.start()
578
-
579
- # Wait for close with reasonable timeout
580
- close_thread.join(timeout=3.0)
581
-
582
- if close_thread.is_alive():
583
- logger.warning(
584
- "Zenoh session close timed out after 1s, continuing shutdown"
585
- )
586
- # Thread will be left to finish in background, but won't block shutdown
587
- elif zenoh_close_success:
588
- logger.debug("Zenoh session closed cleanly")
589
-
590
- # Brief final delay for cleanup
591
- time.sleep(0.5)
611
+ # Clean up log subscriber before closing zenoh session
612
+ try:
613
+ self._log_subscriber.shutdown()
614
+ except Exception as e: # pylint: disable=broad-except
615
+ logger.debug(f"Error shutting down log subscriber: {e}")
592
616
 
617
+ # Close Zenoh session
618
+ close_zenoh_session_with_timeout(self._zenoh_session)
619
+ lingering_threads = wait_for_zenoh_cleanup()
620
+ if lingering_threads:
621
+ self._assist_clean_exit_if_needed()
593
622
  logger.info("Robot shutdown complete")
594
623
 
624
+ def _assist_clean_exit_if_needed(self) -> None:
625
+ """Assist with clean exit if this is the last robot and we're exiting."""
626
+ if (
627
+ not _active_robots # No more active robots
628
+ and threading.current_thread() is threading.main_thread() # In main thread
629
+ and not hasattr(sys, "ps1") # Not in interactive mode
630
+ ):
631
+ logger.debug("Assisting clean exit due to lingering Zenoh threads")
632
+ sys.exit(0)
633
+
595
634
  def is_shutdown(self) -> bool:
596
635
  """Check if the robot has been shutdown.
597
636
 
@@ -600,143 +639,6 @@ class Robot:
600
639
  """
601
640
  return self._shutdown_called
602
641
 
603
- def get_software_version(
604
- self, show: bool = True
605
- ) -> dict[str, TYPE_SOFTWARE_VERSION]:
606
- """Retrieve software version information for all components.
607
-
608
- Args:
609
- show: Whether to display the version information.
610
-
611
- Returns:
612
- Dictionary containing version information for all components.
613
-
614
- Raises:
615
- RuntimeError: If version information cannot be retrieved.
616
- """
617
- try:
618
- replies = self._zenoh_session.get(
619
- resolve_key_name(self._configs.version_info_name)
620
- )
621
- version_dict = {}
622
- for reply in replies:
623
- if reply.ok and reply.ok.payload:
624
- version_bytes = reply.ok.payload.to_bytes()
625
- version_msg = cast(
626
- dexcontrol_query_pb2.SoftwareVersion,
627
- dexcontrol_query_pb2.SoftwareVersion.FromString(version_bytes),
628
- )
629
- version_dict = software_version_to_dict(version_msg)
630
- break
631
-
632
- if show:
633
- show_software_version(version_dict)
634
- return version_dict
635
- except Exception as e:
636
- raise RuntimeError(f"Failed to retrieve software versions: {e}") from e
637
-
638
- def get_component_status(
639
- self, show: bool = True
640
- ) -> dict[str, dict[str, bool | ComponentStatus]]:
641
- """Retrieve status information for all components.
642
-
643
- Args:
644
- show: Whether to display the status information.
645
-
646
- Returns:
647
- Dictionary containing status information for all components.
648
-
649
- Raises:
650
- RuntimeError: If status information cannot be retrieved.
651
- """
652
- try:
653
- replies = self._zenoh_session.get(
654
- resolve_key_name(self._configs.status_info_name)
655
- )
656
- status_dict = {}
657
- for reply in replies:
658
- if reply.ok and reply.ok.payload:
659
- status_bytes = reply.ok.payload.to_bytes()
660
- status_msg = cast(
661
- dexcontrol_query_pb2.ComponentStates,
662
- dexcontrol_query_pb2.ComponentStates.FromString(status_bytes),
663
- )
664
- status_dict = status_to_dict(status_msg)
665
- break
666
-
667
- if show:
668
- show_component_status(status_dict)
669
- return status_dict
670
- except Exception as e:
671
- raise RuntimeError(f"Failed to retrieve component status: {e}") from e
672
-
673
- def reboot_component(self, part: Literal["arm", "chassis", "torso"]) -> None:
674
- """Reboot a specific robot component.
675
-
676
- Args:
677
- part: Component to reboot ("arm", "chassis", or "torso").
678
-
679
- Raises:
680
- ValueError: If the specified component is invalid.
681
- RuntimeError: If the reboot operation fails.
682
- """
683
- component_map = {
684
- "arm": dexcontrol_query_pb2.RebootComponent.Component.ARM,
685
- "chassis": dexcontrol_query_pb2.RebootComponent.Component.CHASSIS,
686
- "torso": dexcontrol_query_pb2.RebootComponent.Component.TORSO,
687
- }
688
-
689
- if part not in component_map:
690
- raise ValueError(f"Invalid component: {part}")
691
-
692
- try:
693
- query_msg = dexcontrol_query_pb2.RebootComponent(
694
- component=component_map[part]
695
- )
696
- self._zenoh_session.get(
697
- resolve_key_name(self._configs.reboot_query_name),
698
- payload=query_msg.SerializeToString(),
699
- )
700
- logger.info(f"Rebooting component: {part}")
701
- except Exception as e:
702
- raise RuntimeError(f"Failed to reboot component {part}: {e}") from e
703
-
704
- def clear_error(
705
- self, part: Literal["left_arm", "right_arm", "chassis", "head"] | str
706
- ) -> None:
707
- """Clear error state for a specific component.
708
-
709
- Args:
710
- part: Component to clear error state for.
711
-
712
- Raises:
713
- ValueError: If the specified component is invalid.
714
- RuntimeError: If the error clearing operation fails.
715
- """
716
- component_map = {
717
- "left_arm": dexcontrol_query_pb2.ClearError.Component.LEFT_ARM,
718
- "right_arm": dexcontrol_query_pb2.ClearError.Component.RIGHT_ARM,
719
- "chassis": dexcontrol_query_pb2.ClearError.Component.CHASSIS,
720
- "head": dexcontrol_query_pb2.ClearError.Component.HEAD,
721
- }
722
-
723
- if part not in component_map:
724
- raise ValueError(f"Invalid component: {part}")
725
-
726
- try:
727
- query_msg = dexcontrol_query_pb2.ClearError(component=component_map[part])
728
- self._zenoh_session.get(
729
- resolve_key_name(self._configs.clear_error_query_name),
730
- handler=lambda reply: logger.info(
731
- f"Cleared error of {part}: {reply.ok}"
732
- ),
733
- payload=query_msg.SerializeToString(),
734
- )
735
- except Exception as e:
736
- raise RuntimeError(
737
- f"Failed to clear error for component {part}: {e}"
738
- ) from e
739
-
740
642
  def get_joint_pos_dict(
741
643
  self,
742
644
  component: Literal[
@@ -760,14 +662,7 @@ class Robot:
760
662
  KeyError: If an invalid component name is provided.
761
663
  RuntimeError: If joint position retrieval fails.
762
664
  """
763
- component_map = {
764
- "left_arm": self.left_arm,
765
- "right_arm": self.right_arm,
766
- "torso": self.torso,
767
- "head": self.head,
768
- "left_hand": self.left_hand,
769
- "right_hand": self.right_hand,
770
- }
665
+ component_map = self.get_component_map()
771
666
 
772
667
  try:
773
668
  if isinstance(component, str):
@@ -857,10 +752,10 @@ class Robot:
857
752
 
858
753
  try:
859
754
  start_time = time.time()
860
- component_map = self._get_component_map()
755
+ component_map = self.get_component_map()
861
756
 
862
757
  # Validate component names
863
- self._validate_component_names(joint_pos, component_map)
758
+ self.validate_component_names(joint_pos)
864
759
 
865
760
  # Separate position-velocity controlled components from others
866
761
  pv_components = [c for c in joint_pos if c in self._pv_components]
@@ -986,6 +881,10 @@ class Robot:
986
881
 
987
882
  return adjusted_positions
988
883
 
884
+ def have_hand(self, side: Literal["left", "right"]) -> bool:
885
+ """Check if the robot has a hand."""
886
+ return self._hand_types.get(side) != HandType.UNKNOWN
887
+
989
888
  def _process_trajectory(
990
889
  self, trajectory: dict[str, np.ndarray | dict[str, np.ndarray]]
991
890
  ) -> dict[str, dict[str, np.ndarray]]:
@@ -1051,14 +950,7 @@ class Robot:
1051
950
  ValueError: If invalid component is specified.
1052
951
  """
1053
952
  rate_limiter = RateLimiter(control_hz)
1054
- component_map = {
1055
- "left_arm": self.left_arm,
1056
- "right_arm": self.right_arm,
1057
- "torso": self.torso,
1058
- "head": self.head,
1059
- "left_hand": self.left_hand,
1060
- "right_hand": self.right_hand,
1061
- }
953
+ component_map = self.get_component_map()
1062
954
 
1063
955
  first_component = next(iter(processed_trajectory))
1064
956
  trajectory_length = len(processed_trajectory[first_component]["position"])
@@ -1080,41 +972,6 @@ class Robot:
1080
972
  )
1081
973
  rate_limiter.sleep()
1082
974
 
1083
- def _get_component_map(self) -> dict[str, Any]:
1084
- """Get the component mapping dictionary.
1085
-
1086
- Returns:
1087
- Dictionary mapping component names to component instances.
1088
- """
1089
- return {
1090
- "left_arm": self.left_arm,
1091
- "right_arm": self.right_arm,
1092
- "torso": self.torso,
1093
- "head": self.head,
1094
- "left_hand": self.left_hand,
1095
- "right_hand": self.right_hand,
1096
- }
1097
-
1098
- def _validate_component_names(
1099
- self,
1100
- joint_pos: dict[str, list[float] | np.ndarray],
1101
- component_map: dict[str, Any],
1102
- ) -> None:
1103
- """Validate that all component names are valid.
1104
-
1105
- Args:
1106
- joint_pos: Joint position dictionary.
1107
- component_map: Component mapping dictionary.
1108
-
1109
- Raises:
1110
- ValueError: If invalid component names are found.
1111
- """
1112
- invalid_components = set(joint_pos.keys()) - set(component_map.keys())
1113
- if invalid_components:
1114
- raise ValueError(
1115
- f"Invalid component names: {', '.join(invalid_components)}"
1116
- )
1117
-
1118
975
  def _set_pv_components(
1119
976
  self,
1120
977
  pv_components: list[str],
@@ -1177,7 +1034,7 @@ class Robot:
1177
1034
  exit_on_reach_kwargs: Optional parameters for exit when the joint positions are reached.
1178
1035
  """
1179
1036
  control_hz = wait_kwargs.get("control_hz", self.left_arm._default_control_hz)
1180
- max_vel = wait_kwargs.get("max_vel", self.left_arm._default_max_vel)
1037
+ max_vel = wait_kwargs.get("max_vel", self.left_arm._joint_vel_limit)
1181
1038
 
1182
1039
  # Generate trajectories for smooth motion during wait_time
1183
1040
  rate_limiter = RateLimiter(control_hz)