Commit ·
f9facd8
1
Parent(s): 12915bc
refactor: optimize based on SDK analysis and conversation_app reference
Browse filesKey improvements:
1. Remove unnecessary caching in reachy_controller.py
- SDK's get_current_head_pose() and get_current_joint_positions() are
non-blocking (return cached Zenoh data), no extra caching needed
- Keep status caching only for get_status() which may trigger I/O
2. Increase control loop frequency from 5Hz to 20Hz
- Safe because SDK state queries are non-blocking
- Better responsiveness for motion feedback
3. Reduce pose change threshold from 0.01 to 0.002 rad
- Smoother motion transitions
- 0.002 rad 0.11 degrees
Reference: reachy_mini_conversation_app uses 100Hz with similar architecture
reachy_mini_ha_voice/movement_manager.py
CHANGED
|
@@ -37,7 +37,10 @@ logger = logging.getLogger(__name__)
|
|
| 37 |
# Constants (borrowed from conversation_app)
|
| 38 |
# =============================================================================
|
| 39 |
|
| 40 |
-
CONTROL_LOOP_FREQUENCY_HZ =
|
|
|
|
|
|
|
|
|
|
| 41 |
TARGET_PERIOD = 1.0 / CONTROL_LOOP_FREQUENCY_HZ
|
| 42 |
|
| 43 |
# Speech sway parameters (from conversation_app SwayRollRT)
|
|
@@ -348,11 +351,11 @@ class MovementManager:
|
|
| 348 |
self._audio_loudness_db: float = -100.0
|
| 349 |
self._audio_lock = threading.Lock()
|
| 350 |
|
| 351 |
-
# Pose change detection
|
|
|
|
|
|
|
| 352 |
self._last_sent_pose: Optional[Dict[str, float]] = None
|
| 353 |
-
|
| 354 |
-
# 0.01 rad ≈ 0.57 degrees, prevents micro-movements from triggering commands
|
| 355 |
-
self._pose_change_threshold = 0.01
|
| 356 |
|
| 357 |
# Face tracking offsets (from camera worker)
|
| 358 |
self._face_tracking_offsets: Tuple[float, float, float, float, float, float] = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
|
|
|
| 37 |
# Constants (borrowed from conversation_app)
|
| 38 |
# =============================================================================
|
| 39 |
|
| 40 |
+
CONTROL_LOOP_FREQUENCY_HZ = 20 # 20Hz control loop (increased from 5Hz based on SDK analysis)
|
| 41 |
+
# SDK's get_current_head_pose() and get_current_joint_positions() are non-blocking
|
| 42 |
+
# (they return cached Zenoh data), so higher frequency is safe.
|
| 43 |
+
# Using 20Hz as a balance between responsiveness and stability.
|
| 44 |
TARGET_PERIOD = 1.0 / CONTROL_LOOP_FREQUENCY_HZ
|
| 45 |
|
| 46 |
# Speech sway parameters (from conversation_app SwayRollRT)
|
|
|
|
| 351 |
self._audio_loudness_db: float = -100.0
|
| 352 |
self._audio_lock = threading.Lock()
|
| 353 |
|
| 354 |
+
# Pose change detection threshold
|
| 355 |
+
# 0.002 rad ≈ 0.11 degrees - small enough for smooth motion
|
| 356 |
+
# SDK's set_target() is the only method that sends Zenoh messages
|
| 357 |
self._last_sent_pose: Optional[Dict[str, float]] = None
|
| 358 |
+
self._pose_change_threshold = 0.002
|
|
|
|
|
|
|
| 359 |
|
| 360 |
# Face tracking offsets (from camera worker)
|
| 361 |
self._face_tracking_offsets: Tuple[float, float, float, float, float, float] = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
reachy_mini_ha_voice/reachy_controller.py
CHANGED
|
@@ -48,19 +48,12 @@ class ReachyController:
|
|
| 48 |
self.reachy = reachy_mini
|
| 49 |
self._speaker_volume = 100 # Default volume
|
| 50 |
|
| 51 |
-
#
|
| 52 |
-
#
|
| 53 |
-
#
|
| 54 |
self._state_cache: Dict[str, Any] = {}
|
| 55 |
-
self._cache_ttl = 1.0 # 1 second cache TTL
|
| 56 |
self._last_status_query = 0.0
|
| 57 |
-
self._last_pose_query = 0.0
|
| 58 |
-
self._last_joints_query = 0.0
|
| 59 |
-
|
| 60 |
-
# Request throttling to prevent daemon overload
|
| 61 |
-
self._min_request_interval = 0.1 # Minimum 100ms between SDK requests
|
| 62 |
-
self._last_sdk_request = 0.0
|
| 63 |
-
self._request_lock = __import__('threading').Lock()
|
| 64 |
|
| 65 |
# Thread lock for ReSpeaker USB access to prevent conflicts with GStreamer audio pipeline
|
| 66 |
self._respeaker_lock = __import__('threading').Lock()
|
|
@@ -73,7 +66,12 @@ class ReachyController:
|
|
| 73 |
# ========== Phase 1: Basic Status & Volume ==========
|
| 74 |
|
| 75 |
def _get_cached_status(self) -> Optional[Dict]:
|
| 76 |
-
"""Get cached daemon status to reduce query frequency.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
now = time.time()
|
| 78 |
if now - self._last_status_query < self._cache_ttl:
|
| 79 |
return self._state_cache.get('status')
|
|
@@ -81,13 +79,6 @@ class ReachyController:
|
|
| 81 |
if not self.is_available:
|
| 82 |
return None
|
| 83 |
|
| 84 |
-
# Throttle SDK requests to prevent daemon overload
|
| 85 |
-
with self._request_lock:
|
| 86 |
-
if now - self._last_sdk_request < self._min_request_interval:
|
| 87 |
-
# Return cached value if we're requesting too fast
|
| 88 |
-
return self._state_cache.get('status')
|
| 89 |
-
self._last_sdk_request = now
|
| 90 |
-
|
| 91 |
try:
|
| 92 |
status = self.reachy.client.get_status(wait=False)
|
| 93 |
self._state_cache['status'] = status
|
|
@@ -336,53 +327,35 @@ class ReachyController:
|
|
| 336 |
|
| 337 |
# ========== Phase 3: Pose Control ==========
|
| 338 |
|
| 339 |
-
def
|
| 340 |
-
"""Get
|
| 341 |
-
now = time.time()
|
| 342 |
-
if now - self._last_pose_query < self._cache_ttl:
|
| 343 |
-
return self._state_cache.get('head_pose')
|
| 344 |
|
|
|
|
|
|
|
|
|
|
| 345 |
if not self.is_available:
|
| 346 |
return None
|
| 347 |
|
| 348 |
-
# Throttle SDK requests to prevent daemon overload
|
| 349 |
-
with self._request_lock:
|
| 350 |
-
if now - self._last_sdk_request < self._min_request_interval:
|
| 351 |
-
return self._state_cache.get('head_pose')
|
| 352 |
-
self._last_sdk_request = now
|
| 353 |
-
|
| 354 |
try:
|
| 355 |
-
|
| 356 |
-
self._state_cache['head_pose'] = pose
|
| 357 |
-
self._last_pose_query = now
|
| 358 |
-
return pose
|
| 359 |
except Exception as e:
|
| 360 |
logger.error(f"Error getting head pose: {e}")
|
| 361 |
-
return
|
| 362 |
|
| 363 |
-
def
|
| 364 |
-
"""Get
|
| 365 |
-
now = time.time()
|
| 366 |
-
if now - self._last_joints_query < self._cache_ttl:
|
| 367 |
-
return self._state_cache.get('joint_positions')
|
| 368 |
|
|
|
|
|
|
|
|
|
|
| 369 |
if not self.is_available:
|
| 370 |
return None
|
| 371 |
|
| 372 |
-
# Throttle SDK requests to prevent daemon overload
|
| 373 |
-
with self._request_lock:
|
| 374 |
-
if now - self._last_sdk_request < self._min_request_interval:
|
| 375 |
-
return self._state_cache.get('joint_positions')
|
| 376 |
-
self._last_sdk_request = now
|
| 377 |
-
|
| 378 |
try:
|
| 379 |
-
|
| 380 |
-
self._state_cache['joint_positions'] = joints
|
| 381 |
-
self._last_joints_query = now
|
| 382 |
-
return joints
|
| 383 |
except Exception as e:
|
| 384 |
logger.error(f"Error getting joint positions: {e}")
|
| 385 |
-
return
|
| 386 |
|
| 387 |
def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
|
| 388 |
"""
|
|
@@ -409,7 +382,7 @@ class ReachyController:
|
|
| 409 |
|
| 410 |
def get_head_x(self) -> float:
|
| 411 |
"""Get head X position in mm with caching."""
|
| 412 |
-
pose = self.
|
| 413 |
if pose is None:
|
| 414 |
return 0.0
|
| 415 |
try:
|
|
@@ -439,7 +412,7 @@ class ReachyController:
|
|
| 439 |
|
| 440 |
def get_head_y(self) -> float:
|
| 441 |
"""Get head Y position in mm with caching."""
|
| 442 |
-
pose = self.
|
| 443 |
if pose is None:
|
| 444 |
return 0.0
|
| 445 |
try:
|
|
@@ -467,7 +440,7 @@ class ReachyController:
|
|
| 467 |
|
| 468 |
def get_head_z(self) -> float:
|
| 469 |
"""Get head Z position in mm with caching."""
|
| 470 |
-
pose = self.
|
| 471 |
if pose is None:
|
| 472 |
return 0.0
|
| 473 |
try:
|
|
@@ -495,7 +468,7 @@ class ReachyController:
|
|
| 495 |
|
| 496 |
def get_head_roll(self) -> float:
|
| 497 |
"""Get head roll angle in degrees with caching."""
|
| 498 |
-
pose = self.
|
| 499 |
if pose is None:
|
| 500 |
return 0.0
|
| 501 |
try:
|
|
@@ -526,7 +499,7 @@ class ReachyController:
|
|
| 526 |
|
| 527 |
def get_head_pitch(self) -> float:
|
| 528 |
"""Get head pitch angle in degrees with caching."""
|
| 529 |
-
pose = self.
|
| 530 |
if pose is None:
|
| 531 |
return 0.0
|
| 532 |
try:
|
|
@@ -556,7 +529,7 @@ class ReachyController:
|
|
| 556 |
|
| 557 |
def get_head_yaw(self) -> float:
|
| 558 |
"""Get head yaw angle in degrees with caching."""
|
| 559 |
-
pose = self.
|
| 560 |
if pose is None:
|
| 561 |
return 0.0
|
| 562 |
try:
|
|
@@ -586,7 +559,7 @@ class ReachyController:
|
|
| 586 |
|
| 587 |
def get_body_yaw(self) -> float:
|
| 588 |
"""Get body yaw angle in degrees with caching."""
|
| 589 |
-
joints = self.
|
| 590 |
if joints is None:
|
| 591 |
return 0.0
|
| 592 |
try:
|
|
@@ -611,7 +584,7 @@ class ReachyController:
|
|
| 611 |
|
| 612 |
def get_antenna_left(self) -> float:
|
| 613 |
"""Get left antenna angle in degrees with caching."""
|
| 614 |
-
joints = self.
|
| 615 |
if joints is None:
|
| 616 |
return 0.0
|
| 617 |
try:
|
|
@@ -638,7 +611,7 @@ class ReachyController:
|
|
| 638 |
|
| 639 |
def get_antenna_right(self) -> float:
|
| 640 |
"""Get right antenna angle in degrees with caching."""
|
| 641 |
-
joints = self.
|
| 642 |
if joints is None:
|
| 643 |
return 0.0
|
| 644 |
try:
|
|
@@ -886,7 +859,7 @@ class ReachyController:
|
|
| 886 |
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 887 |
|
| 888 |
# ========== Phase 11: LED Control (DISABLED - LEDs are inside the robot and not visible) ==========
|
| 889 |
-
# According to PROJECT_PLAN.md principle 8: "LED都被隐藏在了机器人内部,所有的LED控制全部都忽
|
| 890 |
# The following LED methods are kept but commented out for reference.
|
| 891 |
# They are not registered as entities in entity_registry.py.
|
| 892 |
|
|
|
|
| 48 |
self.reachy = reachy_mini
|
| 49 |
self._speaker_volume = 100 # Default volume
|
| 50 |
|
| 51 |
+
# Status caching - only for get_status() which may trigger I/O
|
| 52 |
+
# Note: get_current_head_pose() and get_current_joint_positions() are
|
| 53 |
+
# non-blocking in the SDK (they return cached Zenoh data), so no caching needed
|
| 54 |
self._state_cache: Dict[str, Any] = {}
|
| 55 |
+
self._cache_ttl = 1.0 # 1 second cache TTL for status queries
|
| 56 |
self._last_status_query = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
# Thread lock for ReSpeaker USB access to prevent conflicts with GStreamer audio pipeline
|
| 59 |
self._respeaker_lock = __import__('threading').Lock()
|
|
|
|
| 66 |
# ========== Phase 1: Basic Status & Volume ==========
|
| 67 |
|
| 68 |
def _get_cached_status(self) -> Optional[Dict]:
|
| 69 |
+
"""Get cached daemon status to reduce query frequency.
|
| 70 |
+
|
| 71 |
+
Note: get_status() may trigger I/O, so we cache it.
|
| 72 |
+
Unlike get_current_head_pose() and get_current_joint_positions()
|
| 73 |
+
which are non-blocking in the SDK.
|
| 74 |
+
"""
|
| 75 |
now = time.time()
|
| 76 |
if now - self._last_status_query < self._cache_ttl:
|
| 77 |
return self._state_cache.get('status')
|
|
|
|
| 79 |
if not self.is_available:
|
| 80 |
return None
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
try:
|
| 83 |
status = self.reachy.client.get_status(wait=False)
|
| 84 |
self._state_cache['status'] = status
|
|
|
|
| 327 |
|
| 328 |
# ========== Phase 3: Pose Control ==========
|
| 329 |
|
| 330 |
+
def _get_head_pose(self) -> Optional[np.ndarray]:
|
| 331 |
+
"""Get current head pose from SDK.
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
+
Note: SDK's get_current_head_pose() is non-blocking - it returns
|
| 334 |
+
cached data from Zenoh subscriptions, so no throttling needed.
|
| 335 |
+
"""
|
| 336 |
if not self.is_available:
|
| 337 |
return None
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
try:
|
| 340 |
+
return self.reachy.get_current_head_pose()
|
|
|
|
|
|
|
|
|
|
| 341 |
except Exception as e:
|
| 342 |
logger.error(f"Error getting head pose: {e}")
|
| 343 |
+
return None
|
| 344 |
|
| 345 |
+
def _get_joint_positions(self) -> Optional[tuple]:
|
| 346 |
+
"""Get current joint positions from SDK.
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
+
Note: SDK's get_current_joint_positions() is non-blocking - it returns
|
| 349 |
+
cached data from Zenoh subscriptions, so no throttling needed.
|
| 350 |
+
"""
|
| 351 |
if not self.is_available:
|
| 352 |
return None
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
try:
|
| 355 |
+
return self.reachy.get_current_joint_positions()
|
|
|
|
|
|
|
|
|
|
| 356 |
except Exception as e:
|
| 357 |
logger.error(f"Error getting joint positions: {e}")
|
| 358 |
+
return None
|
| 359 |
|
| 360 |
def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
|
| 361 |
"""
|
|
|
|
| 382 |
|
| 383 |
def get_head_x(self) -> float:
|
| 384 |
"""Get head X position in mm with caching."""
|
| 385 |
+
pose = self._get_head_pose()
|
| 386 |
if pose is None:
|
| 387 |
return 0.0
|
| 388 |
try:
|
|
|
|
| 412 |
|
| 413 |
def get_head_y(self) -> float:
|
| 414 |
"""Get head Y position in mm with caching."""
|
| 415 |
+
pose = self._get_head_pose()
|
| 416 |
if pose is None:
|
| 417 |
return 0.0
|
| 418 |
try:
|
|
|
|
| 440 |
|
| 441 |
def get_head_z(self) -> float:
|
| 442 |
"""Get head Z position in mm with caching."""
|
| 443 |
+
pose = self._get_head_pose()
|
| 444 |
if pose is None:
|
| 445 |
return 0.0
|
| 446 |
try:
|
|
|
|
| 468 |
|
| 469 |
def get_head_roll(self) -> float:
|
| 470 |
"""Get head roll angle in degrees with caching."""
|
| 471 |
+
pose = self._get_head_pose()
|
| 472 |
if pose is None:
|
| 473 |
return 0.0
|
| 474 |
try:
|
|
|
|
| 499 |
|
| 500 |
def get_head_pitch(self) -> float:
|
| 501 |
"""Get head pitch angle in degrees with caching."""
|
| 502 |
+
pose = self._get_head_pose()
|
| 503 |
if pose is None:
|
| 504 |
return 0.0
|
| 505 |
try:
|
|
|
|
| 529 |
|
| 530 |
def get_head_yaw(self) -> float:
|
| 531 |
"""Get head yaw angle in degrees with caching."""
|
| 532 |
+
pose = self._get_head_pose()
|
| 533 |
if pose is None:
|
| 534 |
return 0.0
|
| 535 |
try:
|
|
|
|
| 559 |
|
| 560 |
def get_body_yaw(self) -> float:
|
| 561 |
"""Get body yaw angle in degrees with caching."""
|
| 562 |
+
joints = self._get_joint_positions()
|
| 563 |
if joints is None:
|
| 564 |
return 0.0
|
| 565 |
try:
|
|
|
|
| 584 |
|
| 585 |
def get_antenna_left(self) -> float:
|
| 586 |
"""Get left antenna angle in degrees with caching."""
|
| 587 |
+
joints = self._get_joint_positions()
|
| 588 |
if joints is None:
|
| 589 |
return 0.0
|
| 590 |
try:
|
|
|
|
| 611 |
|
| 612 |
def get_antenna_right(self) -> float:
|
| 613 |
"""Get right antenna angle in degrees with caching."""
|
| 614 |
+
joints = self._get_joint_positions()
|
| 615 |
if joints is None:
|
| 616 |
return 0.0
|
| 617 |
try:
|
|
|
|
| 859 |
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 860 |
|
| 861 |
# ========== Phase 11: LED Control (DISABLED - LEDs are inside the robot and not visible) ==========
|
| 862 |
+
# According to PROJECT_PLAN.md principle 8: "LED都被隐藏在了机器人内部,所有的LED控制全部都忽�?
|
| 863 |
# The following LED methods are kept but commented out for reference.
|
| 864 |
# They are not registered as entities in entity_registry.py.
|
| 865 |
|