dexcontrol 0.2.12__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.

dexcontrol/robot.py CHANGED
@@ -24,6 +24,7 @@ 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
@@ -40,24 +41,19 @@ from rich.table import Table
40
41
  import dexcontrol
41
42
  from dexcontrol.config.vega import VegaConfig, get_vega_config
42
43
  from dexcontrol.core.component import RobotComponent
43
- 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
44
47
  from dexcontrol.sensors import Sensors
45
48
  from dexcontrol.utils.constants import ROBOT_NAME_ENV_VAR
46
- from dexcontrol.utils.os_utils import get_robot_model, resolve_key_name
47
- from dexcontrol.utils.pb_utils import (
48
- TYPE_SOFTWARE_VERSION,
49
- ComponentStatus,
50
- software_version_to_dict,
51
- status_to_dict,
52
- )
49
+ from dexcontrol.utils.os_utils import check_version_compatibility, get_robot_model
53
50
  from dexcontrol.utils.rate_limiter import RateLimiter
54
51
  from dexcontrol.utils.trajectory_utils import generate_linear_trajectory
55
- from dexcontrol.utils.viz_utils import (
56
- show_component_status,
57
- show_ntp_stats,
58
- 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,
59
56
  )
60
- from dexcontrol.utils.zenoh_utils import compute_ntp_stats
61
57
 
62
58
  if TYPE_CHECKING:
63
59
  from dexcontrol.core.arm import Arm
@@ -73,66 +69,25 @@ _active_robots: weakref.WeakSet[Robot] = weakref.WeakSet()
73
69
  _signal_handlers_registered: bool = False
74
70
 
75
71
 
76
- def _signal_handler(signum: int, frame: Any) -> None:
77
- """Signal handler to shutdown all active Robot instances.
78
-
79
- Args:
80
- signum: Signal number received (e.g., SIGINT, SIGTERM).
81
- frame: Current stack frame (unused).
82
- """
83
- logger.info(f"Received signal {signum}, shutting down all active robots...")
84
- # Create a list copy to avoid modification during iteration
85
- robots_to_shutdown = list(_active_robots)
86
-
87
- # Set a total timeout for all shutdowns
88
- shutdown_start = time.time()
89
- max_shutdown_time = 5.0 # Maximum 5 seconds for all shutdowns
90
-
91
- for robot in robots_to_shutdown:
92
- if time.time() - shutdown_start > max_shutdown_time:
93
- logger.warning("Shutdown timeout reached, forcing exit")
94
- break
95
-
96
- logger.info(f"Shutting down robot: {robot}")
97
- try:
98
- robot.shutdown()
99
- except Exception as e: # pylint: disable=broad-except
100
- logger.error(f"Error during robot shutdown: {e}", exc_info=True)
101
-
102
- logger.info("All robots shutdown complete")
103
- # Force exit to ensure the process terminates
104
- os._exit(0)
105
-
106
-
107
72
  def _register_signal_handlers() -> None:
108
- """Register signal handlers for graceful shutdown.
109
-
110
- This function ensures signal handlers are registered only once and sets up
111
- handlers for SIGINT (Ctrl+C), SIGTERM, and SIGHUP (on Unix systems).
112
- """
73
+ """Register signal handlers for graceful shutdown."""
113
74
  global _signal_handlers_registered
114
75
  if _signal_handlers_registered:
115
76
  return
116
77
 
117
- # Register handlers for common termination signals
118
- signal.signal(signal.SIGINT, _signal_handler) # Ctrl+C
119
- 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)
120
83
 
121
- # On Unix systems, also handle SIGHUP
122
84
  if hasattr(signal, "SIGHUP"):
123
- signal.signal(signal.SIGHUP, _signal_handler)
85
+ signal.signal(signal.SIGHUP, signal_handler)
124
86
 
125
87
  _signal_handlers_registered = True
126
88
 
127
89
 
128
- class ComponentConfig(omegaconf.DictConfig):
129
- """Type hints for component configuration."""
130
-
131
- _target_: str
132
- configs: dict[str, Any]
133
-
134
-
135
- class Robot:
90
+ class Robot(RobotQueryInterface):
136
91
  """Main interface class for robot control and monitoring.
137
92
 
138
93
  This class serves as the primary interface for interacting with a robot system.
@@ -141,33 +96,32 @@ class Robot:
141
96
  system-wide operations like status monitoring, trajectory execution, and component
142
97
  control.
143
98
 
144
- The Robot class supports context manager usage and automatic cleanup on program
145
- interruption through signal handlers.
146
-
147
99
  Example usage:
148
100
  # Using context manager (recommended)
149
101
  with Robot() as robot:
150
102
  robot.set_joint_pos({"left_arm": [0, 0, 0, 0, 0, 0, 0]})
103
+ version_info = robot.get_version_info()
151
104
 
152
105
  # Manual usage with explicit shutdown
153
106
  robot = Robot()
154
107
  try:
155
108
  robot.set_joint_pos({"left_arm": [0, 0, 0, 0, 0, 0, 0]})
109
+ hand_types = robot.query_hand_type()
156
110
  finally:
157
111
  robot.shutdown()
158
112
 
159
113
  Attributes:
160
- left_arm: Left arm component interface.
161
- right_arm: Right arm component interface.
162
- left_hand: Left hand component interface.
163
- right_hand: Right hand component interface.
164
- head: Head component interface.
165
- chassis: Chassis component interface.
166
- 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).
167
121
  battery: Battery monitoring interface.
168
122
  estop: Emergency stop interface.
169
123
  heartbeat: Heartbeat monitoring interface.
170
- sensors: Sensor systems interface.
124
+ sensors: Sensor systems interface (cameras, IMU, lidar, etc.).
171
125
  """
172
126
 
173
127
  # Type annotations for dynamically created attributes
@@ -214,25 +168,20 @@ class Robot:
214
168
  robot_model = get_robot_model()
215
169
  self._robot_model: Final[str] = robot_model
216
170
 
217
- try:
218
- self._configs: Final[VegaConfig] = configs or get_vega_config(robot_model)
219
- except Exception as e:
220
- 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)
221
174
 
222
- try:
223
- self._zenoh_session: Final[zenoh.Session] = self._init_zenoh_session(
224
- zenoh_config_file
225
- )
226
- except Exception as e:
227
- raise RuntimeError(f"Failed to initialize zenoh session: {e}") from e
175
+ # Initialize ZenohQueryable parent class
176
+ super().__init__(self._zenoh_session, self._configs)
228
177
 
229
178
  self._robot_name: Final[str] = os.getenv(ROBOT_NAME_ENV_VAR, "robot")
230
- self._pv_components: Final[list[str]] = [
231
- "left_hand",
232
- "right_hand",
179
+ self._pv_components: list[str] = [
233
180
  "head",
234
181
  "torso",
235
182
  ]
183
+ self._log_subscriber = ServerLogSubscriber(self._zenoh_session)
184
+ self._hand_types: dict[str, HandType] = {}
236
185
 
237
186
  # Register for automatic shutdown on signals if enabled
238
187
  if auto_shutdown:
@@ -241,53 +190,11 @@ class Robot:
241
190
 
242
191
  self._print_initialization_info(robot_model)
243
192
 
244
- # Initialize robot body components dynamically
245
- try:
246
- config_dict = omegaconf.OmegaConf.to_container(self._configs, resolve=True)
247
- if not isinstance(config_dict, dict):
248
- raise ValueError("Invalid configuration format")
249
- self._init_components(cast(dict[str, Any], config_dict))
250
- except Exception as e:
251
- self.shutdown() # Clean up on initialization failure
252
- raise RuntimeError(f"Failed to initialize robot components: {e}") from e
253
-
254
- # Ensure all components are active
255
- try:
256
- self._wait_for_components()
257
- except Exception as e:
258
- self.shutdown() # Clean up on initialization failure
259
- raise RuntimeError(f"Failed to activate components: {e}") from e
193
+ # Initialize robot components with safe error handling
194
+ self._safe_initialize_components()
260
195
 
261
- try:
262
- self.sensors = Sensors(self._configs.sensors, self._zenoh_session)
263
- self.sensors.wait_for_all_active()
264
- except Exception as e:
265
- self.shutdown() # Clean up on initialization failure
266
- raise RuntimeError(f"Failed to initialize sensors: {e}") from e
267
-
268
- # Check soc software version before proceeding
269
- version_dict = self.get_software_version(show=False)
270
- min_soc_version = dexcontrol.MIN_SOC_SOFTWARE_VERSION
271
- soc_version = version_dict.get("soc", {}).get("software_version")
272
- if soc_version is None:
273
- logger.warning(
274
- "Could not determine the software version of the remote driver ('soc'). "
275
- "Please ensure the remote server is running and reachable."
276
- )
277
- elif soc_version < min_soc_version:
278
- logger.warning(
279
- f"The remote driver ('soc') software version is too old: {soc_version}. "
280
- f"Minimum required version is {min_soc_version}.\n"
281
- f"Please update the remote server or downgrade this project code to match the server version. "
282
- f"Otherwise, some features may not work correctly or may encounter errors."
283
- )
284
-
285
- # Set default modes
286
- try:
287
- self._set_default_modes()
288
- except Exception as e:
289
- self.shutdown() # Clean up on initialization failure
290
- raise RuntimeError(f"Failed to set default modes: {e}") from e
196
+ # Check version compatibility using new JSON interface
197
+ self._check_version_compatibility()
291
198
 
292
199
  @property
293
200
  def robot_model(self) -> str:
@@ -307,24 +214,12 @@ class Robot:
307
214
  """
308
215
  return self._robot_name
309
216
 
310
- def __enter__(self) -> Robot:
311
- """Context manager entry.
312
-
313
- Returns:
314
- Self reference for context management.
315
- """
217
+ def __enter__(self) -> "Robot":
218
+ """Enter context manager."""
316
219
  return self
317
220
 
318
- def __exit__(
319
- self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any
320
- ) -> None:
321
- """Context manager exit with automatic shutdown.
322
-
323
- Args:
324
- exc_type: Type of the exception that occurred, if any.
325
- exc_val: Exception instance that occurred, if any.
326
- exc_tb: Traceback of the exception that occurred, if any.
327
- """
221
+ def __exit__(self, exc_type, exc_val, exc_tb):
222
+ """Exit context manager and clean up resources."""
328
223
  self.shutdown()
329
224
 
330
225
  def __del__(self) -> None:
@@ -340,41 +235,6 @@ class Robot:
340
235
  # During interpreter shutdown, some modules might not be available
341
236
  pass
342
237
 
343
- @staticmethod
344
- def cleanup_all() -> None:
345
- """Force cleanup of all Zenoh resources and threads.
346
-
347
- This method can be called between Robot instances to ensure a clean state.
348
- It's especially useful when creating multiple Robot instances in the same script.
349
-
350
- Example:
351
- robot1 = Robot()
352
- robot1.shutdown()
353
- Robot.cleanup_all() # Ensure clean state
354
-
355
- robot2 = Robot() # Safe to create new instance
356
- robot2.shutdown()
357
- """
358
- import gc
359
-
360
- # Force garbage collection to clean up any remaining resources
361
- gc.collect()
362
-
363
- # Give threads time to terminate
364
- time.sleep(1.0)
365
-
366
- # Log any remaining pyo3-closure threads
367
- lingering = []
368
- for thread in threading.enumerate():
369
- if "pyo3-closure" in thread.name and thread.is_alive():
370
- lingering.append(thread.name)
371
-
372
- if lingering:
373
- logger.debug(
374
- f"Zenoh threads still active after cleanup: {lingering}. "
375
- "This is normal and shouldn't affect new Robot instances."
376
- )
377
-
378
238
  def _print_initialization_info(self, robot_model: str | None) -> None:
379
239
  """Print initialization information.
380
240
 
@@ -393,40 +253,116 @@ class Robot:
393
253
 
394
254
  console.print(table)
395
255
 
396
- def _init_components(self, config_dict: dict[str, Any]) -> None:
397
- """Initialize robot components from configuration.
256
+ def _safe_initialize_components(self) -> None:
257
+ """Safely initialize all robot components with consolidated error handling.
398
258
 
399
- Args:
400
- 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.
401
261
 
402
262
  Raises:
403
- RuntimeError: If component initialization fails.
263
+ RuntimeError: If any critical initialization step fails.
404
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
+
405
294
  for component_name, component_config in config_dict.items():
406
295
  if component_name == "sensors":
407
296
  continue
408
297
 
409
- component_config = getattr(self._configs, str(component_name))
410
- if (
411
- not hasattr(component_config, "_target_")
412
- or not component_config._target_
413
- ):
414
- continue
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
+
315
+ component_config = getattr(self._configs, str(component_name))
316
+ if (
317
+ not hasattr(component_config, "_target_")
318
+ or not component_config._target_
319
+ ):
320
+ continue
321
+
322
+ # Create component configuration
323
+ temp_config = omegaconf.OmegaConf.create(
324
+ {
325
+ "_target_": component_config._target_,
326
+ "configs": {
327
+ k: v for k, v in component_config.items() if k != "_target_"
328
+ },
329
+ }
330
+ )
415
331
 
416
- temp_config = omegaconf.OmegaConf.create(
417
- {
418
- "_target_": component_config._target_,
419
- "configs": {
420
- k: v for k, v in component_config.items() if k != "_target_"
421
- },
422
- }
423
- )
424
- component_instance = hydra.utils.instantiate(
425
- temp_config, zenoh_session=self._zenoh_session
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
338
+ component_instance = hydra.utils.instantiate(
339
+ temp_config, zenoh_session=self._zenoh_session
340
+ )
341
+
342
+ # Store component instance both as attribute and in tracking dictionaries
343
+ setattr(self, str(component_name), component_instance)
344
+ initialized_components.append(component_name)
345
+
346
+ except Exception as 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)}"
426
355
  )
427
- setattr(self, str(component_name), component_instance)
428
356
 
429
- def _set_default_modes(self) -> None:
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")
364
+
365
+ def _set_default_state(self) -> None:
430
366
  """Set default control modes for robot components.
431
367
 
432
368
  Raises:
@@ -434,7 +370,7 @@ class Robot:
434
370
  """
435
371
  for arm in ["left_arm", "right_arm"]:
436
372
  if component := getattr(self, arm, None):
437
- component.set_mode("position")
373
+ component.set_modes(["position"] * 7)
438
374
 
439
375
  if head := getattr(self, "head", None):
440
376
  head.set_mode("enable")
@@ -455,14 +391,19 @@ class Robot:
455
391
  component_names: Final[list[str]] = [
456
392
  "left_arm",
457
393
  "right_arm",
458
- "left_hand",
459
- "right_hand",
460
394
  "head",
461
395
  "chassis",
462
396
  "torso",
463
397
  "battery",
464
398
  "estop",
465
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
+
466
407
  if self._configs.heartbeat.enabled:
467
408
  component_names.append("heartbeat")
468
409
 
@@ -520,43 +461,97 @@ class Robot:
520
461
  f"Other components may work, but some features, e.g. collision avoidance, may not work correctly."
521
462
  f"Please check the robot status immediately."
522
463
  )
464
+ else:
465
+ logger.info("All motor components are active")
523
466
 
524
- logger.info("All components activated successfully")
467
+ def get_component_map(self) -> dict[str, Any]:
468
+ """Get the component mapping dictionary.
525
469
 
526
- def _init_zenoh_session(self, zenoh_config_file: str | None) -> zenoh.Session:
527
- """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
+ }
528
479
 
529
- Args:
530
- 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
531
485
 
532
- Returns:
533
- 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.
534
494
 
535
495
  Raises:
536
- RuntimeError: If zenoh session initialization fails.
496
+ ValueError: If invalid component names are found with detailed guidance.
537
497
  """
538
- try:
539
- config_path = zenoh_config_file or self._get_default_zenoh_config()
540
- if config_path is None:
541
- logger.warning("Using default zenoh config settings")
542
- return zenoh.open(zenoh.Config())
543
- return zenoh.open(zenoh.Config.from_file(config_path))
544
- except Exception as e:
545
- 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")
546
500
 
547
- @staticmethod
548
- def _get_default_zenoh_config() -> str | None:
549
- """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
550
505
 
551
- Returns:
552
- 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
553
548
  """
554
- default_path = dexcontrol.COMM_CFG_PATH
555
- if not default_path.exists():
556
- logger.warning(f"Zenoh config file not found at {default_path}")
557
- logger.warning("Please use dextop to set up the zenoh config file")
558
- return None
559
- 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}")
560
555
 
561
556
  def shutdown(self) -> None:
562
557
  """Cleans up and closes all component connections.
@@ -569,7 +564,7 @@ class Robot:
569
564
  logger.warning("Shutdown already called, skipping")
570
565
  return
571
566
 
572
- logger.info("Shutting down robot...")
567
+ logger.info("Shutting down robot components...")
573
568
  self._shutdown_called = True
574
569
 
575
570
  # Remove from active robots registry
@@ -591,9 +586,6 @@ class Robot:
591
586
  f"Error stopping component {component.__class__.__name__}: {e}"
592
587
  )
593
588
 
594
- # Brief delay to ensure all stop operations complete
595
- time.sleep(0.1)
596
-
597
589
  # Shutdown sensors first (they may have background threads)
598
590
  try:
599
591
  if hasattr(self, "sensors") and self.sensors is not None:
@@ -616,80 +608,29 @@ class Robot:
616
608
  # Brief delay to allow component shutdown to complete
617
609
  time.sleep(0.1)
618
610
 
619
- # Enhanced Zenoh session close with better synchronization
620
- zenoh_close_success = False
621
- zenoh_close_exception = None
622
-
623
- def _close_zenoh_session():
624
- """Close zenoh session in a separate thread."""
625
- nonlocal zenoh_close_success, zenoh_close_exception
626
- try:
627
- # Brief delay for Zenoh cleanup
628
- time.sleep(0.1)
629
- self._zenoh_session.close()
630
- zenoh_close_success = True
631
- except Exception as e: # pylint: disable=broad-except
632
- zenoh_close_exception = e
633
- logger.warning(f"Zenoh session close error: {e}")
634
-
635
- # Try to close zenoh session with timeout
636
- close_thread = threading.Thread(target=_close_zenoh_session, daemon=True)
637
- close_thread.start()
638
-
639
- # Wait for close with reasonable timeout
640
- close_timeout = 2.0
641
- close_thread.join(timeout=close_timeout)
642
-
643
- if close_thread.is_alive():
644
- logger.warning(f"Zenoh session close timed out after {close_timeout}s")
645
- # The thread is daemon, so it won't block the main thread
646
- elif zenoh_close_success:
647
- logger.debug("Zenoh session closed cleanly")
648
- elif zenoh_close_exception:
649
- logger.debug(
650
- f"Zenoh session close completed with error: {zenoh_close_exception}"
651
- )
652
-
653
- # Give Zenoh threads more time to clean up properly
654
- # This helps ensure threads terminate naturally
655
- time.sleep(0.8)
656
-
657
- # Check for lingering pyo3-closure threads
658
- # These are internal Zenoh library threads
659
- lingering_threads = []
660
- for thread in threading.enumerate():
661
- if (
662
- "pyo3-closure" in thread.name
663
- and thread.is_alive()
664
- and not thread.daemon
665
- ):
666
- lingering_threads.append(thread.name)
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}")
667
616
 
617
+ # Close Zenoh session
618
+ close_zenoh_session_with_timeout(self._zenoh_session)
619
+ lingering_threads = wait_for_zenoh_cleanup()
668
620
  if lingering_threads:
669
- logger.debug(
670
- f"Note: Zenoh library threads still active: {lingering_threads}. "
671
- "These are internal library threads that should not prevent creating new Robot instances."
672
- )
673
-
674
- # If this is the last robot being shutdown and we're in the main module,
675
- # help the script exit cleanly
676
- import sys
677
-
678
- if (
679
- not _active_robots
680
- and threading.current_thread() is threading.main_thread()
681
- ):
682
- # Check if we're likely at the end of the main script
683
- frame = sys._getframe()
684
- while frame.f_back:
685
- frame = frame.f_back
686
- # If we're at the top level of a script (not in interactive mode)
687
- if frame.f_code.co_name == "<module>" and not hasattr(sys, "ps1"):
688
- logger.debug("Main script ending, forcing clean exit")
689
- os._exit(0)
690
-
621
+ self._assist_clean_exit_if_needed()
691
622
  logger.info("Robot shutdown complete")
692
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
+
693
634
  def is_shutdown(self) -> bool:
694
635
  """Check if the robot has been shutdown.
695
636
 
@@ -698,209 +639,6 @@ class Robot:
698
639
  """
699
640
  return self._shutdown_called
700
641
 
701
- def query_ntp(
702
- self,
703
- sample_count: int = 30,
704
- show: bool = False,
705
- timeout: float = 1.0,
706
- device: Literal["soc", "jetson"] = "soc",
707
- ) -> dict[Literal["success", "offset", "rtt"], bool | float]:
708
- """Query the NTP server via zenoh for time synchronization and compute robust statistics.
709
-
710
- Args:
711
- sample_count: Number of NTP samples to request (default: 50).
712
- show: Whether to print summary statistics using a rich table.
713
- timeout: Timeout for the zenoh querier in seconds (default: 2.0).
714
- device: Which device to query for NTP ("soc" or "jetson").
715
-
716
- Returns:
717
- Dictionary with keys:
718
- - "success": True if any replies were received, False otherwise.
719
- - "offset": Mean offset (in seconds) after removing RTT outliers.
720
- - "rtt": Mean round-trip time (in seconds) after removing RTT outliers.
721
- """
722
- if device == "soc":
723
- ntp_key = resolve_key_name(self._configs.soc_ntp_query_name)
724
- elif device == "jetson":
725
- raise NotImplementedError("Jetson NTP query is not implemented yet")
726
- time_offset = []
727
- time_rtt = []
728
-
729
- querier = self._zenoh_session.declare_querier(ntp_key, timeout=timeout)
730
- time.sleep(0.1)
731
-
732
- reply_count = 0
733
- for i in range(sample_count):
734
- request = dexcontrol_query_pb2.NTPRequest()
735
- request.client_send_time_ns = time.time_ns()
736
- request.sample_count = sample_count
737
- request.sample_index = i
738
- replies = querier.get(payload=request.SerializeToString())
739
-
740
- for reply in replies:
741
- reply_count += 1
742
- if reply.ok and reply.ok.payload:
743
- client_receive_time_ns = time.time_ns()
744
- response = dexcontrol_query_pb2.NTPResponse()
745
- response.ParseFromString(reply.ok.payload.to_bytes())
746
- t0 = request.client_send_time_ns
747
- t1 = response.server_receive_time_ns
748
- t2 = response.server_send_time_ns
749
- t3 = client_receive_time_ns
750
- offset = ((t1 - t0) + (t2 - t3)) // 2 / 1e9
751
- rtt = (t3 - t0) / 1e9
752
- time_offset.append(offset)
753
- time_rtt.append(rtt)
754
- if i < sample_count - 1:
755
- time.sleep(0.01)
756
-
757
- querier.undeclare()
758
- if reply_count == 0:
759
- return {"success": False, "offset": 0, "rtt": 0}
760
-
761
- stats = compute_ntp_stats(time_offset, time_rtt)
762
- offset = stats["offset (mean)"]
763
- rtt = stats["round_trip_time (mean)"]
764
- if show:
765
- show_ntp_stats(stats)
766
-
767
- return {"success": True, "offset": offset, "rtt": rtt}
768
-
769
- def get_software_version(
770
- self, show: bool = True
771
- ) -> dict[str, TYPE_SOFTWARE_VERSION]:
772
- """Retrieve software version information for all components.
773
-
774
- Args:
775
- show: Whether to display the version information.
776
-
777
- Returns:
778
- Dictionary containing version information for all components.
779
-
780
- Raises:
781
- RuntimeError: If version information cannot be retrieved.
782
- """
783
- try:
784
- replies = self._zenoh_session.get(
785
- resolve_key_name(self._configs.version_info_name)
786
- )
787
- version_dict = {}
788
- for reply in replies:
789
- if reply.ok and reply.ok.payload:
790
- version_bytes = reply.ok.payload.to_bytes()
791
- version_msg = cast(
792
- dexcontrol_query_pb2.SoftwareVersion,
793
- dexcontrol_query_pb2.SoftwareVersion.FromString(version_bytes),
794
- )
795
- version_dict = software_version_to_dict(version_msg)
796
- break
797
-
798
- if show:
799
- show_software_version(version_dict)
800
- return version_dict
801
- except Exception as e:
802
- raise RuntimeError(f"Failed to retrieve software versions: {e}") from e
803
-
804
- def get_component_status(
805
- self, show: bool = True
806
- ) -> dict[str, dict[str, bool | ComponentStatus]]:
807
- """Retrieve status information for all components.
808
-
809
- Args:
810
- show: Whether to display the status information.
811
-
812
- Returns:
813
- Dictionary containing status information for all components.
814
-
815
- Raises:
816
- RuntimeError: If status information cannot be retrieved.
817
- """
818
- try:
819
- replies = self._zenoh_session.get(
820
- resolve_key_name(self._configs.status_info_name)
821
- )
822
- status_dict = {}
823
- for reply in replies:
824
- if reply.ok and reply.ok.payload:
825
- status_bytes = reply.ok.payload.to_bytes()
826
- status_msg = cast(
827
- dexcontrol_query_pb2.ComponentStates,
828
- dexcontrol_query_pb2.ComponentStates.FromString(status_bytes),
829
- )
830
- status_dict = status_to_dict(status_msg)
831
- break
832
-
833
- if show:
834
- show_component_status(status_dict)
835
- return status_dict
836
- except Exception as e:
837
- raise RuntimeError(f"Failed to retrieve component status: {e}") from e
838
-
839
- def reboot_component(self, part: Literal["arm", "chassis", "torso"]) -> None:
840
- """Reboot a specific robot component.
841
-
842
- Args:
843
- part: Component to reboot ("arm", "chassis", or "torso").
844
-
845
- Raises:
846
- ValueError: If the specified component is invalid.
847
- RuntimeError: If the reboot operation fails.
848
- """
849
- component_map = {
850
- "arm": dexcontrol_query_pb2.RebootComponent.Component.ARM,
851
- "chassis": dexcontrol_query_pb2.RebootComponent.Component.CHASSIS,
852
- "torso": dexcontrol_query_pb2.RebootComponent.Component.TORSO,
853
- }
854
-
855
- if part not in component_map:
856
- raise ValueError(f"Invalid component: {part}")
857
-
858
- try:
859
- query_msg = dexcontrol_query_pb2.RebootComponent(
860
- component=component_map[part]
861
- )
862
- self._zenoh_session.get(
863
- resolve_key_name(self._configs.reboot_query_name),
864
- payload=query_msg.SerializeToString(),
865
- )
866
- logger.info(f"Rebooting component: {part}")
867
- except Exception as e:
868
- raise RuntimeError(f"Failed to reboot component {part}: {e}") from e
869
-
870
- def clear_error(
871
- self, part: Literal["left_arm", "right_arm", "chassis", "head"] | str
872
- ) -> None:
873
- """Clear error state for a specific component.
874
-
875
- Args:
876
- part: Component to clear error state for.
877
-
878
- Raises:
879
- ValueError: If the specified component is invalid.
880
- RuntimeError: If the error clearing operation fails.
881
- """
882
- component_map = {
883
- "left_arm": dexcontrol_query_pb2.ClearError.Component.LEFT_ARM,
884
- "right_arm": dexcontrol_query_pb2.ClearError.Component.RIGHT_ARM,
885
- "chassis": dexcontrol_query_pb2.ClearError.Component.CHASSIS,
886
- "head": dexcontrol_query_pb2.ClearError.Component.HEAD,
887
- }
888
-
889
- if part not in component_map:
890
- raise ValueError(f"Invalid component: {part}")
891
-
892
- try:
893
- query_msg = dexcontrol_query_pb2.ClearError(component=component_map[part])
894
- self._zenoh_session.get(
895
- resolve_key_name(self._configs.clear_error_query_name),
896
- handler=lambda reply: logger.info(f"Cleared error of {part}"),
897
- payload=query_msg.SerializeToString(),
898
- )
899
- except Exception as e:
900
- raise RuntimeError(
901
- f"Failed to clear error for component {part}: {e}"
902
- ) from e
903
-
904
642
  def get_joint_pos_dict(
905
643
  self,
906
644
  component: Literal[
@@ -924,14 +662,7 @@ class Robot:
924
662
  KeyError: If an invalid component name is provided.
925
663
  RuntimeError: If joint position retrieval fails.
926
664
  """
927
- component_map = {
928
- "left_arm": self.left_arm,
929
- "right_arm": self.right_arm,
930
- "torso": self.torso,
931
- "head": self.head,
932
- "left_hand": self.left_hand,
933
- "right_hand": self.right_hand,
934
- }
665
+ component_map = self.get_component_map()
935
666
 
936
667
  try:
937
668
  if isinstance(component, str):
@@ -1021,10 +752,10 @@ class Robot:
1021
752
 
1022
753
  try:
1023
754
  start_time = time.time()
1024
- component_map = self._get_component_map()
755
+ component_map = self.get_component_map()
1025
756
 
1026
757
  # Validate component names
1027
- self._validate_component_names(joint_pos, component_map)
758
+ self.validate_component_names(joint_pos)
1028
759
 
1029
760
  # Separate position-velocity controlled components from others
1030
761
  pv_components = [c for c in joint_pos if c in self._pv_components]
@@ -1150,6 +881,10 @@ class Robot:
1150
881
 
1151
882
  return adjusted_positions
1152
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
+
1153
888
  def _process_trajectory(
1154
889
  self, trajectory: dict[str, np.ndarray | dict[str, np.ndarray]]
1155
890
  ) -> dict[str, dict[str, np.ndarray]]:
@@ -1215,14 +950,7 @@ class Robot:
1215
950
  ValueError: If invalid component is specified.
1216
951
  """
1217
952
  rate_limiter = RateLimiter(control_hz)
1218
- component_map = {
1219
- "left_arm": self.left_arm,
1220
- "right_arm": self.right_arm,
1221
- "torso": self.torso,
1222
- "head": self.head,
1223
- "left_hand": self.left_hand,
1224
- "right_hand": self.right_hand,
1225
- }
953
+ component_map = self.get_component_map()
1226
954
 
1227
955
  first_component = next(iter(processed_trajectory))
1228
956
  trajectory_length = len(processed_trajectory[first_component]["position"])
@@ -1244,41 +972,6 @@ class Robot:
1244
972
  )
1245
973
  rate_limiter.sleep()
1246
974
 
1247
- def _get_component_map(self) -> dict[str, Any]:
1248
- """Get the component mapping dictionary.
1249
-
1250
- Returns:
1251
- Dictionary mapping component names to component instances.
1252
- """
1253
- return {
1254
- "left_arm": self.left_arm,
1255
- "right_arm": self.right_arm,
1256
- "torso": self.torso,
1257
- "head": self.head,
1258
- "left_hand": self.left_hand,
1259
- "right_hand": self.right_hand,
1260
- }
1261
-
1262
- def _validate_component_names(
1263
- self,
1264
- joint_pos: dict[str, list[float] | np.ndarray],
1265
- component_map: dict[str, Any],
1266
- ) -> None:
1267
- """Validate that all component names are valid.
1268
-
1269
- Args:
1270
- joint_pos: Joint position dictionary.
1271
- component_map: Component mapping dictionary.
1272
-
1273
- Raises:
1274
- ValueError: If invalid component names are found.
1275
- """
1276
- invalid_components = set(joint_pos.keys()) - set(component_map.keys())
1277
- if invalid_components:
1278
- raise ValueError(
1279
- f"Invalid component names: {', '.join(invalid_components)}"
1280
- )
1281
-
1282
975
  def _set_pv_components(
1283
976
  self,
1284
977
  pv_components: list[str],