Desmond-Dong commited on
Commit
affc41b
·
1 Parent(s): 1b8e11b

fix: enable HA control of robot pose and fix continuous conversation

Browse files

1. Robot pose control from Home Assistant:
- Added set_target_pose() to MovementManager
- ReachyController now routes pose setters through MovementManager
- Head position, orientation, body yaw, antennas all controllable from HA

2. Fixed continuous conversation (tap-to-wake):
- Fixed _is_streaming_audio not being set correctly for continuation
- Fixed _reachy_on_idle() being called even when continuing
- Added _reachy_on_listening() call when continuing conversation
- Added logging for conversation continuation

3. Extended emotion list:
- Added 50+ emotions from robot's emotion library
- Includes basic emotions, extended emotions, actions, and special animations

reachy_mini_ha_voice/entity_registry.py CHANGED
@@ -124,22 +124,67 @@ class EntityRegistry:
124
  # Emotion state
125
  self._current_emotion = "None"
126
  # Map emotion names to available robot emotions
127
- # Available emotions from robot: fear1, surprised1, rage1, resigned1, go_away1, loving1,
128
- # impatient1/2, enthusiastic1/2, cheerful1, laughing1/2, irritated1/2, oops1/2, curious1,
129
- # electric1, contempt1, inquiring1/2/3, attentive1/2, frustrated1, dance1/2/3, no1, sad1/2,
130
- # understanding1/2, come1, calming1, exhausted1, scared1, downcast1, success1/2, disgusted1,
131
- # amazed1, displeased1/2, dying1, no_excited1, thoughtful1/2, lonely1, welcoming1/2,
132
- # no_sad1, reprimand1/2/3, boredom1/2, grateful1, uncertain1, furious1, anxiety1, yes_sad1,
133
- # proud1/2/3, shy1, indifferent1, tired1, serenity1, helpful1/2, incomprehensible2, relief1/2,
134
- # confused1, sleep1, yes1, uncomfortable1, lost1
135
  self._emotion_map = {
136
  "None": None,
 
137
  "Happy": "cheerful1",
138
  "Sad": "sad1",
139
  "Angry": "rage1",
140
  "Fear": "fear1",
141
  "Surprise": "surprised1",
142
  "Disgust": "disgusted1",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }
144
 
145
  def setup_all_entities(self, entities: List) -> None:
 
124
  # Emotion state
125
  self._current_emotion = "None"
126
  # Map emotion names to available robot emotions
127
+ # Full list of available emotions from robot
 
 
 
 
 
 
 
128
  self._emotion_map = {
129
  "None": None,
130
+ # Basic emotions
131
  "Happy": "cheerful1",
132
  "Sad": "sad1",
133
  "Angry": "rage1",
134
  "Fear": "fear1",
135
  "Surprise": "surprised1",
136
  "Disgust": "disgusted1",
137
+ # Extended emotions
138
+ "Laughing": "laughing1",
139
+ "Loving": "loving1",
140
+ "Proud": "proud1",
141
+ "Grateful": "grateful1",
142
+ "Enthusiastic": "enthusiastic1",
143
+ "Curious": "curious1",
144
+ "Amazed": "amazed1",
145
+ "Shy": "shy1",
146
+ "Confused": "confused1",
147
+ "Thoughtful": "thoughtful1",
148
+ "Anxious": "anxiety1",
149
+ "Scared": "scared1",
150
+ "Frustrated": "frustrated1",
151
+ "Irritated": "irritated1",
152
+ "Furious": "furious1",
153
+ "Contempt": "contempt1",
154
+ "Bored": "boredom1",
155
+ "Tired": "tired1",
156
+ "Exhausted": "exhausted1",
157
+ "Lonely": "lonely1",
158
+ "Downcast": "downcast1",
159
+ "Resigned": "resigned1",
160
+ "Uncertain": "uncertain1",
161
+ "Uncomfortable": "uncomfortable1",
162
+ "Lost": "lost1",
163
+ "Indifferent": "indifferent1",
164
+ # Positive actions
165
+ "Yes": "yes1",
166
+ "No": "no1",
167
+ "Welcoming": "welcoming1",
168
+ "Helpful": "helpful1",
169
+ "Attentive": "attentive1",
170
+ "Understanding": "understanding1",
171
+ "Calming": "calming1",
172
+ "Relief": "relief1",
173
+ "Success": "success1",
174
+ "Serenity": "serenity1",
175
+ # Negative actions
176
+ "Oops": "oops1",
177
+ "Displeased": "displeased1",
178
+ "Impatient": "impatient1",
179
+ "Reprimand": "reprimand1",
180
+ "GoAway": "go_away1",
181
+ # Special
182
+ "Come": "come1",
183
+ "Inquiring": "inquiring1",
184
+ "Sleep": "sleep1",
185
+ "Dance": "dance1",
186
+ "Electric": "electric1",
187
+ "Dying": "dying1",
188
  }
189
 
190
  def setup_all_entities(self, entities: List) -> None:
reachy_mini_ha_voice/movement_manager.py CHANGED
@@ -479,6 +479,41 @@ class MovementManager:
479
  with self._face_tracking_lock:
480
  self._face_tracking_offsets = offsets
481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  # =========================================================================
483
  # Internal: Command processing (runs in control loop)
484
  # =========================================================================
@@ -534,6 +569,28 @@ class MovementManager:
534
  amplitude_deg, duration = payload
535
  self._do_shake(amplitude_deg, duration)
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  def _start_action(self, action: PendingAction) -> None:
538
  """Start a new motion action."""
539
  self._pending_action = action
 
479
  with self._face_tracking_lock:
480
  self._face_tracking_offsets = offsets
481
 
482
+ def set_target_pose(
483
+ self,
484
+ x: Optional[float] = None,
485
+ y: Optional[float] = None,
486
+ z: Optional[float] = None,
487
+ roll: Optional[float] = None,
488
+ pitch: Optional[float] = None,
489
+ yaw: Optional[float] = None,
490
+ body_yaw: Optional[float] = None,
491
+ antenna_left: Optional[float] = None,
492
+ antenna_right: Optional[float] = None,
493
+ ) -> None:
494
+ """Thread-safe: Set target pose components.
495
+
496
+ Only provided values will be updated. Values are in meters for position
497
+ and radians for angles.
498
+
499
+ Args:
500
+ x, y, z: Head position in meters
501
+ roll, pitch, yaw: Head orientation in radians
502
+ body_yaw: Body yaw in radians
503
+ antenna_left, antenna_right: Antenna angles in radians
504
+ """
505
+ self._command_queue.put(("set_pose", {
506
+ "x": x,
507
+ "y": y,
508
+ "z": z,
509
+ "roll": roll,
510
+ "pitch": pitch,
511
+ "yaw": yaw,
512
+ "body_yaw": body_yaw,
513
+ "antenna_left": antenna_left,
514
+ "antenna_right": antenna_right,
515
+ }))
516
+
517
  # =========================================================================
518
  # Internal: Command processing (runs in control loop)
519
  # =========================================================================
 
569
  amplitude_deg, duration = payload
570
  self._do_shake(amplitude_deg, duration)
571
 
572
+ elif cmd == "set_pose":
573
+ # Update target pose from external control (e.g., Home Assistant)
574
+ if payload.get("x") is not None:
575
+ self.state.target_x = payload["x"]
576
+ if payload.get("y") is not None:
577
+ self.state.target_y = payload["y"]
578
+ if payload.get("z") is not None:
579
+ self.state.target_z = payload["z"]
580
+ if payload.get("roll") is not None:
581
+ self.state.target_roll = payload["roll"]
582
+ if payload.get("pitch") is not None:
583
+ self.state.target_pitch = payload["pitch"]
584
+ if payload.get("yaw") is not None:
585
+ self.state.target_yaw = payload["yaw"]
586
+ if payload.get("body_yaw") is not None:
587
+ self.state.target_body_yaw = payload["body_yaw"]
588
+ if payload.get("antenna_left") is not None:
589
+ self.state.target_antenna_left = payload["antenna_left"]
590
+ if payload.get("antenna_right") is not None:
591
+ self.state.target_antenna_right = payload["antenna_right"]
592
+ logger.debug("External pose update: %s", payload)
593
+
594
  def _start_action(self, action: PendingAction) -> None:
595
  """Start a new motion action."""
596
  self._pending_action = action
reachy_mini_ha_voice/reachy_controller.py CHANGED
@@ -47,6 +47,7 @@ class ReachyController:
47
  """
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
@@ -58,6 +59,15 @@ class ReachyController:
58
  # Thread lock for ReSpeaker USB access to prevent conflicts with GStreamer audio pipeline
59
  self._respeaker_lock = __import__('threading').Lock()
60
 
 
 
 
 
 
 
 
 
 
61
  @property
62
  def is_available(self) -> bool:
63
  """Check if robot is available."""
@@ -408,58 +418,74 @@ class ReachyController:
408
  return 0.0
409
 
410
  def _disabled_pose_setter(self, name: str) -> None:
411
- """Log warning for disabled pose setters."""
412
- logger.debug(f"set_{name} is disabled - MovementManager controls pose")
 
 
 
 
 
 
 
 
 
 
413
 
414
- # Head position getters (read-only, setters disabled for MovementManager)
415
  def get_head_x(self) -> float:
416
  """Get head X position in mm."""
417
  return self._get_head_pose_component('x')
418
 
419
  def set_head_x(self, x_mm: float) -> None:
420
- """Disabled - MovementManager controls head pose."""
421
- self._disabled_pose_setter('head_x')
 
422
 
423
  def get_head_y(self) -> float:
424
  """Get head Y position in mm."""
425
  return self._get_head_pose_component('y')
426
 
427
  def set_head_y(self, y_mm: float) -> None:
428
- """Disabled - MovementManager controls head pose."""
429
- self._disabled_pose_setter('head_y')
 
430
 
431
  def get_head_z(self) -> float:
432
  """Get head Z position in mm."""
433
  return self._get_head_pose_component('z')
434
 
435
  def set_head_z(self, z_mm: float) -> None:
436
- """Disabled - MovementManager controls head pose."""
437
- self._disabled_pose_setter('head_z')
 
438
 
439
- # Head orientation getters (read-only, setters disabled for MovementManager)
440
  def get_head_roll(self) -> float:
441
  """Get head roll angle in degrees."""
442
  return self._get_head_pose_component('roll')
443
 
444
  def set_head_roll(self, roll_deg: float) -> None:
445
- """Disabled - MovementManager controls head pose."""
446
- self._disabled_pose_setter('head_roll')
 
447
 
448
  def get_head_pitch(self) -> float:
449
  """Get head pitch angle in degrees."""
450
  return self._get_head_pose_component('pitch')
451
 
452
  def set_head_pitch(self, pitch_deg: float) -> None:
453
- """Disabled - MovementManager controls head pose."""
454
- self._disabled_pose_setter('head_pitch')
 
455
 
456
  def get_head_yaw(self) -> float:
457
  """Get head yaw angle in degrees."""
458
  return self._get_head_pose_component('yaw')
459
 
460
  def set_head_yaw(self, yaw_deg: float) -> None:
461
- """Disabled - MovementManager controls head pose."""
462
- self._disabled_pose_setter('head_yaw')
 
463
 
464
  def get_body_yaw(self) -> float:
465
  """Get body yaw angle in degrees."""
@@ -474,8 +500,9 @@ class ReachyController:
474
  return 0.0
475
 
476
  def set_body_yaw(self, yaw_deg: float) -> None:
477
- """Disabled - MovementManager controls body pose."""
478
- self._disabled_pose_setter('body_yaw')
 
479
 
480
  def get_antenna_left(self) -> float:
481
  """Get left antenna angle in degrees."""
@@ -490,8 +517,9 @@ class ReachyController:
490
  return 0.0
491
 
492
  def set_antenna_left(self, angle_deg: float) -> None:
493
- """Disabled - MovementManager controls antennas."""
494
- self._disabled_pose_setter('antenna_left')
 
495
 
496
  def get_antenna_right(self) -> float:
497
  """Get right antenna angle in degrees."""
@@ -506,8 +534,9 @@ class ReachyController:
506
  return 0.0
507
 
508
  def set_antenna_right(self, angle_deg: float) -> None:
509
- """Disabled - MovementManager controls antennas."""
510
- self._disabled_pose_setter('antenna_right')
 
511
 
512
  # ========== Phase 4: Look At Control ==========
513
 
 
47
  """
48
  self.reachy = reachy_mini
49
  self._speaker_volume = 100 # Default volume
50
+ self._movement_manager = None # Set later via set_movement_manager()
51
 
52
  # Status caching - only for get_status() which may trigger I/O
53
  # Note: get_current_head_pose() and get_current_joint_positions() are
 
59
  # Thread lock for ReSpeaker USB access to prevent conflicts with GStreamer audio pipeline
60
  self._respeaker_lock = __import__('threading').Lock()
61
 
62
+ def set_movement_manager(self, movement_manager) -> None:
63
+ """Set the MovementManager instance for pose control.
64
+
65
+ Args:
66
+ movement_manager: MovementManager instance
67
+ """
68
+ self._movement_manager = movement_manager
69
+ logger.info("MovementManager set for ReachyController")
70
+
71
  @property
72
  def is_available(self) -> bool:
73
  """Check if robot is available."""
 
418
  return 0.0
419
 
420
  def _disabled_pose_setter(self, name: str) -> None:
421
+ """Log warning when MovementManager is not available."""
422
+ logger.warning(f"set_{name} failed - MovementManager not set")
423
+
424
+ def _set_pose_via_manager(self, **kwargs) -> bool:
425
+ """Set pose via MovementManager if available.
426
+
427
+ Returns True if successful, False if MovementManager not available.
428
+ """
429
+ if self._movement_manager is None:
430
+ return False
431
+ self._movement_manager.set_target_pose(**kwargs)
432
+ return True
433
 
434
+ # Head position getters and setters
435
  def get_head_x(self) -> float:
436
  """Get head X position in mm."""
437
  return self._get_head_pose_component('x')
438
 
439
  def set_head_x(self, x_mm: float) -> None:
440
+ """Set head X position in mm via MovementManager."""
441
+ if not self._set_pose_via_manager(x=x_mm / 1000.0): # mm to m
442
+ self._disabled_pose_setter('head_x')
443
 
444
  def get_head_y(self) -> float:
445
  """Get head Y position in mm."""
446
  return self._get_head_pose_component('y')
447
 
448
  def set_head_y(self, y_mm: float) -> None:
449
+ """Set head Y position in mm via MovementManager."""
450
+ if not self._set_pose_via_manager(y=y_mm / 1000.0): # mm to m
451
+ self._disabled_pose_setter('head_y')
452
 
453
  def get_head_z(self) -> float:
454
  """Get head Z position in mm."""
455
  return self._get_head_pose_component('z')
456
 
457
  def set_head_z(self, z_mm: float) -> None:
458
+ """Set head Z position in mm via MovementManager."""
459
+ if not self._set_pose_via_manager(z=z_mm / 1000.0): # mm to m
460
+ self._disabled_pose_setter('head_z')
461
 
462
+ # Head orientation getters and setters
463
  def get_head_roll(self) -> float:
464
  """Get head roll angle in degrees."""
465
  return self._get_head_pose_component('roll')
466
 
467
  def set_head_roll(self, roll_deg: float) -> None:
468
+ """Set head roll angle in degrees via MovementManager."""
469
+ if not self._set_pose_via_manager(roll=math.radians(roll_deg)):
470
+ self._disabled_pose_setter('head_roll')
471
 
472
  def get_head_pitch(self) -> float:
473
  """Get head pitch angle in degrees."""
474
  return self._get_head_pose_component('pitch')
475
 
476
  def set_head_pitch(self, pitch_deg: float) -> None:
477
+ """Set head pitch angle in degrees via MovementManager."""
478
+ if not self._set_pose_via_manager(pitch=math.radians(pitch_deg)):
479
+ self._disabled_pose_setter('head_pitch')
480
 
481
  def get_head_yaw(self) -> float:
482
  """Get head yaw angle in degrees."""
483
  return self._get_head_pose_component('yaw')
484
 
485
  def set_head_yaw(self, yaw_deg: float) -> None:
486
+ """Set head yaw angle in degrees via MovementManager."""
487
+ if not self._set_pose_via_manager(yaw=math.radians(yaw_deg)):
488
+ self._disabled_pose_setter('head_yaw')
489
 
490
  def get_body_yaw(self) -> float:
491
  """Get body yaw angle in degrees."""
 
500
  return 0.0
501
 
502
  def set_body_yaw(self, yaw_deg: float) -> None:
503
+ """Set body yaw angle in degrees via MovementManager."""
504
+ if not self._set_pose_via_manager(body_yaw=math.radians(yaw_deg)):
505
+ self._disabled_pose_setter('body_yaw')
506
 
507
  def get_antenna_left(self) -> float:
508
  """Get left antenna angle in degrees."""
 
517
  return 0.0
518
 
519
  def set_antenna_left(self, angle_deg: float) -> None:
520
+ """Set left antenna angle in degrees via MovementManager."""
521
+ if not self._set_pose_via_manager(antenna_left=math.radians(angle_deg)):
522
+ self._disabled_pose_setter('antenna_left')
523
 
524
  def get_antenna_right(self) -> float:
525
  """Get right antenna angle in degrees."""
 
534
  return 0.0
535
 
536
  def set_antenna_right(self, angle_deg: float) -> None:
537
+ """Set right antenna angle in degrees via MovementManager."""
538
+ if not self._set_pose_via_manager(antenna_right=math.radians(angle_deg)):
539
+ self._disabled_pose_setter('antenna_right')
540
 
541
  # ========== Phase 4: Look At Control ==========
542
 
reachy_mini_ha_voice/satellite.py CHANGED
@@ -81,6 +81,10 @@ class VoiceSatelliteProtocol(APIServer):
81
 
82
  # Initialize Reachy controller
83
  self.reachy_controller = ReachyController(state.reachy_mini)
 
 
 
 
84
 
85
  # Initialize entity registry (tap_detector from state if available)
86
  tap_detector = getattr(state, 'tap_detector', None)
@@ -156,12 +160,12 @@ class VoiceSatelliteProtocol(APIServer):
156
  self.play_tts()
157
 
158
  elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
159
- self._is_streaming_audio = False
 
160
  if not self._tts_played:
161
  self._tts_finished()
162
  self._tts_played = False
163
- # Reachy Mini: Return to idle
164
- self._reachy_on_idle()
165
 
166
  def handle_timer_event(
167
  self,
@@ -420,16 +424,19 @@ class VoiceSatelliteProtocol(APIServer):
420
  should_continue = self._continue_conversation or self._tap_conversation_mode
421
 
422
  if should_continue:
 
 
423
  self.send_messages([VoiceAssistantRequest(start=True)])
424
  self._is_streaming_audio = True
425
 
426
  if self._tap_conversation_mode:
427
- _LOGGER.debug("Continuing tap conversation mode")
428
  # Provide feedback for continuous conversation mode
429
  self._tap_continue_feedback()
430
- else:
431
- _LOGGER.debug("Continuing conversation (HA requested)")
 
432
  else:
 
433
  self.unduck()
434
  _LOGGER.debug("TTS response finished")
435
  # Reachy Mini: Return to idle
 
81
 
82
  # Initialize Reachy controller
83
  self.reachy_controller = ReachyController(state.reachy_mini)
84
+
85
+ # Connect MovementManager to ReachyController for pose control from HA
86
+ if state.motion is not None and state.motion.movement_manager is not None:
87
+ self.reachy_controller.set_movement_manager(state.motion.movement_manager)
88
 
89
  # Initialize entity registry (tap_detector from state if available)
90
  tap_detector = getattr(state, 'tap_detector', None)
 
160
  self.play_tts()
161
 
162
  elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
163
+ # Note: Don't set _is_streaming_audio = False here
164
+ # _tts_finished() will handle it based on whether we continue conversation
165
  if not self._tts_played:
166
  self._tts_finished()
167
  self._tts_played = False
168
+ # Note: _reachy_on_idle() is called inside _tts_finished() if not continuing
 
169
 
170
  def handle_timer_event(
171
  self,
 
424
  should_continue = self._continue_conversation or self._tap_conversation_mode
425
 
426
  if should_continue:
427
+ _LOGGER.info("Continuing conversation (tap_mode=%s, ha_continue=%s)",
428
+ self._tap_conversation_mode, self._continue_conversation)
429
  self.send_messages([VoiceAssistantRequest(start=True)])
430
  self._is_streaming_audio = True
431
 
432
  if self._tap_conversation_mode:
 
433
  # Provide feedback for continuous conversation mode
434
  self._tap_continue_feedback()
435
+
436
+ # Stay in listening mode, don't go to idle
437
+ self._reachy_on_listening()
438
  else:
439
+ self._is_streaming_audio = False
440
  self.unduck()
441
  _LOGGER.debug("TTS response finished")
442
  # Reachy Mini: Return to idle