Commit ·
3f1b696
1
Parent(s): 770da68
feat: add tap sensitivity slider entity for Home Assistant
Browse files
PROJECT_PLAN.md
CHANGED
|
@@ -70,6 +70,7 @@
|
|
| 70 |
- [x] ESPHome 协议服务器实现
|
| 71 |
- [x] mDNS 服务发现(自动被 Home Assistant 发现)
|
| 72 |
- [x] 本地唤醒词检测(microWakeWord)
|
|
|
|
| 73 |
- [x] 音频流传输到 Home Assistant
|
| 74 |
- [x] TTS 音频播放
|
| 75 |
- [x] 停止词检测
|
|
@@ -584,7 +585,7 @@ VAD_DB_OFF = -45 # 停止检测阈值
|
|
| 584 |
- ❌ 用户说 "记住这个" → 保存动作
|
| 585 |
- ❌ 用户说 "做刚才的动作" → 播放录制的动作
|
| 586 |
|
| 587 |
-
### Phase 20 - 环境感知响应 (
|
| 588 |
|
| 589 |
**目标**: 利用 IMU 传感器感知环境变化并做出响应。
|
| 590 |
|
|
@@ -592,11 +593,49 @@ VAD_DB_OFF = -45 # 停止检测阈值
|
|
| 592 |
- ✅ `mini.imu["accelerometer"]` - 加速度计 (Phase 7 已实现为实体)
|
| 593 |
- ✅ `mini.imu["gyroscope"]` - 陀螺仪 (Phase 7 已实现为实体)
|
| 594 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
**未实现功能**:
|
| 596 |
|
| 597 |
| 检测事件 | 响应动作 | 实现状态 |
|
| 598 |
|---------|---------|---------|
|
| 599 |
-
| 被拍打/敲击 | 播放惊讶动作 + 语音 "哎呀!" | ❌ 未实现 |
|
| 600 |
| 被摇晃 | 播放晕眩动作 + 语音 "别晃我~" | ❌ 未实现 |
|
| 601 |
| 倾斜/倒下 | 播放求助动作 + 语音 "我倒了,帮帮我" | ❌ 未实现 |
|
| 602 |
| 长时间静止 | 进入休眠动画 | ❌ 未实现 |
|
|
|
|
| 70 |
- [x] ESPHome 协议服务器实现
|
| 71 |
- [x] mDNS 服务发现(自动被 Home Assistant 发现)
|
| 72 |
- [x] 本地唤醒词检测(microWakeWord)
|
| 73 |
+
- [x] 拍一拍唤醒(IMU 加速度检测,仅无线版本)
|
| 74 |
- [x] 音频流传输到 Home Assistant
|
| 75 |
- [x] TTS 音频播放
|
| 76 |
- [x] 停止词检测
|
|
|
|
| 585 |
- ❌ 用户说 "记住这个" → 保存动作
|
| 586 |
- ❌ 用户说 "做刚才的动作" → 播放录制的动作
|
| 587 |
|
| 588 |
+
### Phase 20 - 环境感知响应 (部分实现) 🟡
|
| 589 |
|
| 590 |
**目标**: 利用 IMU 传感器感知环境变化并做出响应。
|
| 591 |
|
|
|
|
| 593 |
- ✅ `mini.imu["accelerometer"]` - 加速度计 (Phase 7 已实现为实体)
|
| 594 |
- ✅ `mini.imu["gyroscope"]` - 陀螺仪 (Phase 7 已实现为实体)
|
| 595 |
|
| 596 |
+
**已实现功能**:
|
| 597 |
+
|
| 598 |
+
| 检测事件 | 响应动作 | 实现状态 |
|
| 599 |
+
|---------|---------|---------|
|
| 600 |
+
| 拍一拍唤醒 | 进入持续对话模式 | ✅ 已实现 |
|
| 601 |
+
| 再次拍一拍 | 退出持续对话模式 | ✅ 已实现 |
|
| 602 |
+
|
| 603 |
+
**拍一拍唤醒 vs 语音唤醒**:
|
| 604 |
+
|
| 605 |
+
| 唤醒方式 | 对话模式 | 说明 |
|
| 606 |
+
|---------|---------|------|
|
| 607 |
+
| 语音唤醒 (Okay Nabu) | 单次对话 | 每次对话需要重新说唤醒词 |
|
| 608 |
+
| 拍一拍唤醒 | 持续对话 | TTS 结束后自动继续监听,再拍一次退出 |
|
| 609 |
+
|
| 610 |
+
**技术实现**:
|
| 611 |
+
- `tap_detector.py` - IMU 加速度突变检测
|
| 612 |
+
- `satellite.py:_tap_conversation_mode` - 持续对话模式标志
|
| 613 |
+
- 阈值: 2.0g (可配置)
|
| 614 |
+
- 冷却时间: 1.0s (防止重复触发)
|
| 615 |
+
- 仅限无线版本 (Wireless) 可用
|
| 616 |
+
|
| 617 |
+
```python
|
| 618 |
+
# satellite.py - 持续对话模式
|
| 619 |
+
def wakeup_from_tap(self):
|
| 620 |
+
if self._tap_conversation_mode:
|
| 621 |
+
# 第二次拍 - 退出持续对话
|
| 622 |
+
self._tap_conversation_mode = False
|
| 623 |
+
self._reachy_on_idle()
|
| 624 |
+
else:
|
| 625 |
+
# 第一次拍 - 进入持续对话
|
| 626 |
+
self._tap_conversation_mode = True
|
| 627 |
+
self.send_messages([VoiceAssistantRequest(start=True)])
|
| 628 |
+
|
| 629 |
+
def _tts_finished(self):
|
| 630 |
+
if self._tap_conversation_mode:
|
| 631 |
+
# 持续对话模式:自动继续监听
|
| 632 |
+
self.send_messages([VoiceAssistantRequest(start=True)])
|
| 633 |
+
```
|
| 634 |
+
|
| 635 |
**未实现功能**:
|
| 636 |
|
| 637 |
| 检测事件 | 响应动作 | 实现状态 |
|
| 638 |
|---------|---------|---------|
|
|
|
|
| 639 |
| 被摇晃 | 播放晕眩动作 + 语音 "别晃我~" | ❌ 未实现 |
|
| 640 |
| 倾斜/倒下 | 播放求助动作 + 语音 "我倒了,帮帮我" | ❌ 未实现 |
|
| 641 |
| 长时间静止 | 进入休眠动画 | ❌ 未实现 |
|
reachy_mini_ha_voice/entity_registry.py
CHANGED
|
@@ -13,6 +13,7 @@ from .entity_extensions import SensorEntity, SwitchEntity, SelectEntity, ButtonE
|
|
| 13 |
if TYPE_CHECKING:
|
| 14 |
from .reachy_controller import ReachyController
|
| 15 |
from .camera_server import MJPEGCameraServer
|
|
|
|
| 16 |
|
| 17 |
_LOGGER = logging.getLogger(__name__)
|
| 18 |
|
|
@@ -79,6 +80,8 @@ ENTITY_KEYS: Dict[str, int] = {
|
|
| 79 |
"agc_max_gain": 1201,
|
| 80 |
"noise_suppression": 1202,
|
| 81 |
"echo_cancellation_converged": 1203,
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
|
|
@@ -100,6 +103,7 @@ class EntityRegistry:
|
|
| 100 |
reachy_controller: "ReachyController",
|
| 101 |
camera_server: Optional["MJPEGCameraServer"] = None,
|
| 102 |
play_emotion_callback: Optional[Callable[[str], None]] = None,
|
|
|
|
| 103 |
):
|
| 104 |
"""Initialize the entity registry.
|
| 105 |
|
|
@@ -108,11 +112,13 @@ class EntityRegistry:
|
|
| 108 |
reachy_controller: The ReachyController instance
|
| 109 |
camera_server: Optional camera server for camera entity
|
| 110 |
play_emotion_callback: Optional callback for playing emotions
|
|
|
|
| 111 |
"""
|
| 112 |
self.server = server
|
| 113 |
self.reachy_controller = reachy_controller
|
| 114 |
self.camera_server = camera_server
|
| 115 |
self._play_emotion_callback = play_emotion_callback
|
|
|
|
| 116 |
|
| 117 |
# Emotion state
|
| 118 |
self._current_emotion = "None"
|
|
@@ -145,6 +151,7 @@ class EntityRegistry:
|
|
| 145 |
# Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
|
| 146 |
self._setup_phase12_entities(entities)
|
| 147 |
# Phase 13-14 (head_joints, passive_joints) removed - not needed
|
|
|
|
| 148 |
|
| 149 |
_LOGGER.info("All entities registered: %d total", len(entities))
|
| 150 |
|
|
@@ -726,6 +733,40 @@ class EntityRegistry:
|
|
| 726 |
|
| 727 |
_LOGGER.debug("Phase 12 entities registered: agc_enabled, agc_max_gain, noise_suppression, echo_cancellation_converged")
|
| 728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
def find_entity_references(self, entities: List) -> None:
|
| 730 |
"""Find and store references to special entities from existing list.
|
| 731 |
|
|
|
|
| 13 |
if TYPE_CHECKING:
|
| 14 |
from .reachy_controller import ReachyController
|
| 15 |
from .camera_server import MJPEGCameraServer
|
| 16 |
+
from .tap_detector import TapDetector
|
| 17 |
|
| 18 |
_LOGGER = logging.getLogger(__name__)
|
| 19 |
|
|
|
|
| 80 |
"agc_max_gain": 1201,
|
| 81 |
"noise_suppression": 1202,
|
| 82 |
"echo_cancellation_converged": 1203,
|
| 83 |
+
# Phase 20: Tap detection
|
| 84 |
+
"tap_sensitivity": 1300,
|
| 85 |
}
|
| 86 |
|
| 87 |
|
|
|
|
| 103 |
reachy_controller: "ReachyController",
|
| 104 |
camera_server: Optional["MJPEGCameraServer"] = None,
|
| 105 |
play_emotion_callback: Optional[Callable[[str], None]] = None,
|
| 106 |
+
tap_detector: Optional["TapDetector"] = None,
|
| 107 |
):
|
| 108 |
"""Initialize the entity registry.
|
| 109 |
|
|
|
|
| 112 |
reachy_controller: The ReachyController instance
|
| 113 |
camera_server: Optional camera server for camera entity
|
| 114 |
play_emotion_callback: Optional callback for playing emotions
|
| 115 |
+
tap_detector: Optional tap detector for sensitivity control
|
| 116 |
"""
|
| 117 |
self.server = server
|
| 118 |
self.reachy_controller = reachy_controller
|
| 119 |
self.camera_server = camera_server
|
| 120 |
self._play_emotion_callback = play_emotion_callback
|
| 121 |
+
self.tap_detector = tap_detector
|
| 122 |
|
| 123 |
# Emotion state
|
| 124 |
self._current_emotion = "None"
|
|
|
|
| 151 |
# Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
|
| 152 |
self._setup_phase12_entities(entities)
|
| 153 |
# Phase 13-14 (head_joints, passive_joints) removed - not needed
|
| 154 |
+
self._setup_phase20_entities(entities)
|
| 155 |
|
| 156 |
_LOGGER.info("All entities registered: %d total", len(entities))
|
| 157 |
|
|
|
|
| 733 |
|
| 734 |
_LOGGER.debug("Phase 12 entities registered: agc_enabled, agc_max_gain, noise_suppression, echo_cancellation_converged")
|
| 735 |
|
| 736 |
+
def _setup_phase20_entities(self, entities: List) -> None:
|
| 737 |
+
"""Setup Phase 20 entities: Tap detection settings (Wireless only)."""
|
| 738 |
+
from .tap_detector import TAP_THRESHOLD_G_MIN, TAP_THRESHOLD_G_MAX, TAP_THRESHOLD_G_DEFAULT
|
| 739 |
+
|
| 740 |
+
def get_tap_sensitivity() -> float:
|
| 741 |
+
"""Get current tap sensitivity threshold in g."""
|
| 742 |
+
if self.tap_detector:
|
| 743 |
+
return self.tap_detector.threshold_g
|
| 744 |
+
return TAP_THRESHOLD_G_DEFAULT
|
| 745 |
+
|
| 746 |
+
def set_tap_sensitivity(value: float) -> None:
|
| 747 |
+
"""Set tap sensitivity threshold in g."""
|
| 748 |
+
if self.tap_detector:
|
| 749 |
+
self.tap_detector.threshold_g = value
|
| 750 |
+
_LOGGER.info("Tap sensitivity set to %.2fg", value)
|
| 751 |
+
|
| 752 |
+
entities.append(NumberEntity(
|
| 753 |
+
server=self.server,
|
| 754 |
+
key=get_entity_key("tap_sensitivity"),
|
| 755 |
+
name="Tap Sensitivity",
|
| 756 |
+
object_id="tap_sensitivity",
|
| 757 |
+
min_value=TAP_THRESHOLD_G_MIN,
|
| 758 |
+
max_value=TAP_THRESHOLD_G_MAX,
|
| 759 |
+
step=0.1,
|
| 760 |
+
icon="mdi:gesture-tap",
|
| 761 |
+
unit_of_measurement="g",
|
| 762 |
+
mode=2, # Slider mode
|
| 763 |
+
entity_category=1, # config
|
| 764 |
+
value_getter=get_tap_sensitivity,
|
| 765 |
+
value_setter=set_tap_sensitivity,
|
| 766 |
+
))
|
| 767 |
+
|
| 768 |
+
_LOGGER.debug("Phase 20 entities registered: tap_sensitivity")
|
| 769 |
+
|
| 770 |
def find_entity_references(self, entities: List) -> None:
|
| 771 |
"""Find and store references to special entities from existing list.
|
| 772 |
|
reachy_mini_ha_voice/models.py
CHANGED
|
@@ -73,6 +73,7 @@ class ServerState:
|
|
| 73 |
reachy_mini: Optional[object] = None
|
| 74 |
motion_enabled: bool = True
|
| 75 |
motion: Optional[object] = None # ReachyMiniMotion instance
|
|
|
|
| 76 |
|
| 77 |
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
| 78 |
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
|
|
|
| 73 |
reachy_mini: Optional[object] = None
|
| 74 |
motion_enabled: bool = True
|
| 75 |
motion: Optional[object] = None # ReachyMiniMotion instance
|
| 76 |
+
tap_detector: Optional[object] = None # TapDetector instance (Wireless only)
|
| 77 |
|
| 78 |
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
| 79 |
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
reachy_mini_ha_voice/motion.py
CHANGED
|
@@ -112,6 +112,26 @@ class ReachyMiniMotion:
|
|
| 112 |
self._movement_manager.set_state(RobotState.LISTENING)
|
| 113 |
_LOGGER.debug("Reachy Mini: Listening pose")
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
def on_thinking(self):
|
| 116 |
"""Called when processing speech - thinking pose.
|
| 117 |
|
|
|
|
| 112 |
self._movement_manager.set_state(RobotState.LISTENING)
|
| 113 |
_LOGGER.debug("Reachy Mini: Listening pose")
|
| 114 |
|
| 115 |
+
def on_continue_listening(self):
|
| 116 |
+
"""Called when continuing to listen in tap conversation mode.
|
| 117 |
+
|
| 118 |
+
Performs a small nod to indicate ready for next input.
|
| 119 |
+
Non-blocking: command sent to MovementManager.
|
| 120 |
+
"""
|
| 121 |
+
if self._movement_manager is None:
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
self._movement_manager.set_state(RobotState.LISTENING)
|
| 125 |
+
|
| 126 |
+
# Small nod to indicate ready
|
| 127 |
+
action = PendingAction(
|
| 128 |
+
name="continue_nod",
|
| 129 |
+
target_pitch=math.radians(8), # Small nod down
|
| 130 |
+
duration=0.25,
|
| 131 |
+
)
|
| 132 |
+
self._movement_manager.queue_action(action)
|
| 133 |
+
_LOGGER.debug("Reachy Mini: Continue listening (nod)")
|
| 134 |
+
|
| 135 |
def on_thinking(self):
|
| 136 |
"""Called when processing speech - thinking pose.
|
| 137 |
|
reachy_mini_ha_voice/satellite.py
CHANGED
|
@@ -75,16 +75,21 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 75 |
self._continue_conversation = False
|
| 76 |
self._timer_finished = False
|
| 77 |
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
# Initialize Reachy controller
|
| 80 |
self.reachy_controller = ReachyController(state.reachy_mini)
|
| 81 |
|
| 82 |
-
# Initialize entity registry
|
|
|
|
| 83 |
self._entity_registry = EntityRegistry(
|
| 84 |
server=self,
|
| 85 |
reachy_controller=self.reachy_controller,
|
| 86 |
camera_server=camera_server,
|
| 87 |
play_emotion_callback=self._play_emotion,
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
# Only setup entities once (check if already initialized)
|
|
@@ -330,6 +335,44 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 330 |
self._is_streaming_audio = True
|
| 331 |
self.state.tts_player.play(self.state.wakeup_sound)
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
def stop(self) -> None:
|
| 334 |
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 335 |
self.state.tts_player.stop()
|
|
@@ -364,10 +407,21 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 364 |
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 365 |
self.send_messages([VoiceAssistantAnnounceFinished()])
|
| 366 |
|
| 367 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
self.send_messages([VoiceAssistantRequest(start=True)])
|
| 369 |
self._is_streaming_audio = True
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
else:
|
| 372 |
self.unduck()
|
| 373 |
_LOGGER.debug("TTS response finished")
|
|
@@ -511,6 +565,23 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 511 |
except Exception as e:
|
| 512 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
def _reachy_on_timer_finished(self) -> None:
|
| 515 |
"""Called when a timer finishes."""
|
| 516 |
if not self.state.motion_enabled or not self.state.reachy_mini:
|
|
|
|
| 75 |
self._continue_conversation = False
|
| 76 |
self._timer_finished = False
|
| 77 |
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
| 78 |
+
|
| 79 |
+
# Tap-to-talk continuous conversation mode
|
| 80 |
+
self._tap_conversation_mode = False # When True, auto-continue after TTS
|
| 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)
|
| 87 |
self._entity_registry = EntityRegistry(
|
| 88 |
server=self,
|
| 89 |
reachy_controller=self.reachy_controller,
|
| 90 |
camera_server=camera_server,
|
| 91 |
play_emotion_callback=self._play_emotion,
|
| 92 |
+
tap_detector=tap_detector,
|
| 93 |
)
|
| 94 |
|
| 95 |
# Only setup entities once (check if already initialized)
|
|
|
|
| 335 |
self._is_streaming_audio = True
|
| 336 |
self.state.tts_player.play(self.state.wakeup_sound)
|
| 337 |
|
| 338 |
+
def wakeup_from_tap(self) -> None:
|
| 339 |
+
"""Trigger wake-up from tap detection (no wake word).
|
| 340 |
+
|
| 341 |
+
First tap: Enter continuous conversation mode
|
| 342 |
+
Second tap (while in conversation): Exit continuous conversation mode
|
| 343 |
+
"""
|
| 344 |
+
# If already in tap conversation mode, exit it
|
| 345 |
+
if self._tap_conversation_mode:
|
| 346 |
+
_LOGGER.info("Tap detected - exiting continuous conversation mode")
|
| 347 |
+
self._tap_conversation_mode = False
|
| 348 |
+
self._is_streaming_audio = False
|
| 349 |
+
# Stop any ongoing TTS
|
| 350 |
+
self.state.tts_player.stop()
|
| 351 |
+
self.unduck()
|
| 352 |
+
self._reachy_on_idle()
|
| 353 |
+
return
|
| 354 |
+
|
| 355 |
+
if self._timer_finished:
|
| 356 |
+
# Stop timer instead
|
| 357 |
+
self._timer_finished = False
|
| 358 |
+
self.state.tts_player.stop()
|
| 359 |
+
_LOGGER.debug("Stopping timer finished sound")
|
| 360 |
+
return
|
| 361 |
+
|
| 362 |
+
_LOGGER.info("Tap detected - entering continuous conversation mode")
|
| 363 |
+
self._tap_conversation_mode = True
|
| 364 |
+
|
| 365 |
+
self.send_messages(
|
| 366 |
+
[VoiceAssistantRequest(start=True, wake_word_phrase="tap")]
|
| 367 |
+
)
|
| 368 |
+
self.duck()
|
| 369 |
+
self._is_streaming_audio = True
|
| 370 |
+
self.state.tts_player.play(self.state.wakeup_sound)
|
| 371 |
+
|
| 372 |
+
def is_tap_conversation_active(self) -> bool:
|
| 373 |
+
"""Check if tap-triggered continuous conversation is active."""
|
| 374 |
+
return self._tap_conversation_mode
|
| 375 |
+
|
| 376 |
def stop(self) -> None:
|
| 377 |
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 378 |
self.state.tts_player.stop()
|
|
|
|
| 407 |
self.state.active_wake_words.discard(self.state.stop_word.id)
|
| 408 |
self.send_messages([VoiceAssistantAnnounceFinished()])
|
| 409 |
|
| 410 |
+
# Check if should continue conversation
|
| 411 |
+
# 1. HA requested continue (intent not matched)
|
| 412 |
+
# 2. Tap conversation mode is active
|
| 413 |
+
should_continue = self._continue_conversation or self._tap_conversation_mode
|
| 414 |
+
|
| 415 |
+
if should_continue:
|
| 416 |
self.send_messages([VoiceAssistantRequest(start=True)])
|
| 417 |
self._is_streaming_audio = True
|
| 418 |
+
|
| 419 |
+
if self._tap_conversation_mode:
|
| 420 |
+
_LOGGER.debug("Continuing tap conversation mode")
|
| 421 |
+
# Provide feedback for continuous conversation mode
|
| 422 |
+
self._tap_continue_feedback()
|
| 423 |
+
else:
|
| 424 |
+
_LOGGER.debug("Continuing conversation (HA requested)")
|
| 425 |
else:
|
| 426 |
self.unduck()
|
| 427 |
_LOGGER.debug("TTS response finished")
|
|
|
|
| 565 |
except Exception as e:
|
| 566 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 567 |
|
| 568 |
+
def _tap_continue_feedback(self) -> None:
|
| 569 |
+
"""Provide feedback when continuing conversation in tap mode.
|
| 570 |
+
|
| 571 |
+
Plays a short sound and triggers a nod to indicate ready for next input.
|
| 572 |
+
"""
|
| 573 |
+
try:
|
| 574 |
+
# Play the wakeup sound (short beep) to indicate listening
|
| 575 |
+
self.state.tts_player.play(self.state.wakeup_sound)
|
| 576 |
+
_LOGGER.debug("Tap continue feedback: sound played")
|
| 577 |
+
|
| 578 |
+
# Trigger a small nod to indicate ready for input
|
| 579 |
+
if self.state.motion_enabled and self.state.motion:
|
| 580 |
+
self.state.motion.on_continue_listening()
|
| 581 |
+
_LOGGER.debug("Tap continue feedback: continue listening pose")
|
| 582 |
+
except Exception as e:
|
| 583 |
+
_LOGGER.error("Tap continue feedback error: %s", e)
|
| 584 |
+
|
| 585 |
def _reachy_on_timer_finished(self) -> None:
|
| 586 |
"""Called when a timer finishes."""
|
| 587 |
if not self.state.motion_enabled or not self.state.reachy_mini:
|
reachy_mini_ha_voice/tap_detector.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tap detection using IMU accelerometer data.
|
| 2 |
+
|
| 3 |
+
This module provides tap/knock detection for Reachy Mini (Wireless version only).
|
| 4 |
+
When a tap is detected, it can trigger the voice assistant wake-up.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import math
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from typing import Callable, Optional, TYPE_CHECKING
|
| 12 |
+
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from reachy_mini import ReachyMini
|
| 15 |
+
|
| 16 |
+
_LOGGER = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Tap detection parameters
|
| 19 |
+
TAP_THRESHOLD_G_DEFAULT = 2.0 # Default acceleration threshold in g
|
| 20 |
+
TAP_THRESHOLD_G_MIN = 0.5 # Minimum threshold (very sensitive)
|
| 21 |
+
TAP_THRESHOLD_G_MAX = 5.0 # Maximum threshold (less sensitive)
|
| 22 |
+
TAP_COOLDOWN_SECONDS = 1.0 # Minimum time between tap detections
|
| 23 |
+
TAP_DETECTION_RATE_HZ = 50 # IMU polling rate
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TapDetector:
|
| 27 |
+
"""Detects taps/knocks on Reachy Mini using IMU accelerometer."""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
reachy_mini: Optional["ReachyMini"] = None,
|
| 32 |
+
on_tap_callback: Optional[Callable[[], None]] = None,
|
| 33 |
+
threshold_g: float = TAP_THRESHOLD_G_DEFAULT,
|
| 34 |
+
cooldown_seconds: float = TAP_COOLDOWN_SECONDS,
|
| 35 |
+
):
|
| 36 |
+
"""Initialize tap detector.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
reachy_mini: Reachy Mini robot instance
|
| 40 |
+
on_tap_callback: Callback function when tap is detected
|
| 41 |
+
threshold_g: Acceleration threshold in g units (0.5-5.0)
|
| 42 |
+
cooldown_seconds: Minimum time between tap detections
|
| 43 |
+
"""
|
| 44 |
+
self.reachy_mini = reachy_mini
|
| 45 |
+
self._on_tap_callback = on_tap_callback
|
| 46 |
+
self._threshold_g = threshold_g
|
| 47 |
+
self._threshold_ms2 = threshold_g * 9.81
|
| 48 |
+
self._cooldown_seconds = cooldown_seconds
|
| 49 |
+
|
| 50 |
+
self._running = False
|
| 51 |
+
self._thread: Optional[threading.Thread] = None
|
| 52 |
+
self._last_tap_time: float = 0.0
|
| 53 |
+
self._enabled = True
|
| 54 |
+
self._baseline_magnitude: float = 9.81
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def threshold_g(self) -> float:
|
| 58 |
+
"""Get current threshold in g units."""
|
| 59 |
+
return self._threshold_g
|
| 60 |
+
|
| 61 |
+
@threshold_g.setter
|
| 62 |
+
def threshold_g(self, value: float) -> None:
|
| 63 |
+
"""Set threshold in g units (clamped to valid range)."""
|
| 64 |
+
value = max(TAP_THRESHOLD_G_MIN, min(TAP_THRESHOLD_G_MAX, value))
|
| 65 |
+
self._threshold_g = value
|
| 66 |
+
self._threshold_ms2 = value * 9.81
|
| 67 |
+
_LOGGER.info("Tap threshold set to %.2fg", value)
|
| 68 |
+
|
| 69 |
+
def set_reachy_mini(self, reachy_mini: "ReachyMini") -> None:
|
| 70 |
+
"""Set the Reachy Mini instance."""
|
| 71 |
+
self.reachy_mini = reachy_mini
|
| 72 |
+
|
| 73 |
+
def set_callback(self, callback: Callable[[], None]) -> None:
|
| 74 |
+
"""Set the tap detection callback."""
|
| 75 |
+
self._on_tap_callback = callback
|
| 76 |
+
|
| 77 |
+
def set_enabled(self, enabled: bool) -> None:
|
| 78 |
+
"""Enable or disable tap detection."""
|
| 79 |
+
self._enabled = enabled
|
| 80 |
+
_LOGGER.info("Tap detection %s", "enabled" if enabled else "disabled")
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def is_running(self) -> bool:
|
| 84 |
+
"""Check if tap detector is running."""
|
| 85 |
+
return self._running
|
| 86 |
+
|
| 87 |
+
def start(self) -> None:
|
| 88 |
+
"""Start tap detection thread."""
|
| 89 |
+
if self._running:
|
| 90 |
+
_LOGGER.warning("TapDetector already running")
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
if self.reachy_mini is None:
|
| 94 |
+
_LOGGER.warning("Cannot start TapDetector: no Reachy Mini instance")
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
# Check if IMU is available (Wireless version only)
|
| 98 |
+
try:
|
| 99 |
+
imu_data = self.reachy_mini.imu
|
| 100 |
+
if imu_data is None:
|
| 101 |
+
_LOGGER.warning(
|
| 102 |
+
"IMU not available - tap detection disabled "
|
| 103 |
+
"(only available on Wireless version)"
|
| 104 |
+
)
|
| 105 |
+
return
|
| 106 |
+
except Exception as e:
|
| 107 |
+
_LOGGER.warning("Failed to check IMU availability: %s", e)
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
self._running = True
|
| 111 |
+
self._thread = threading.Thread(
|
| 112 |
+
target=self._detection_loop,
|
| 113 |
+
daemon=True,
|
| 114 |
+
name="tap-detector"
|
| 115 |
+
)
|
| 116 |
+
self._thread.start()
|
| 117 |
+
_LOGGER.info("TapDetector started (threshold=%.1fg)", self._threshold_ms2 / 9.81)
|
| 118 |
+
|
| 119 |
+
def stop(self) -> None:
|
| 120 |
+
"""Stop tap detection thread."""
|
| 121 |
+
self._running = False
|
| 122 |
+
if self._thread is not None:
|
| 123 |
+
self._thread.join(timeout=2.0)
|
| 124 |
+
self._thread = None
|
| 125 |
+
_LOGGER.info("TapDetector stopped")
|
| 126 |
+
|
| 127 |
+
def _calibrate_baseline(self) -> None:
|
| 128 |
+
"""Calibrate baseline acceleration (gravity)."""
|
| 129 |
+
samples = []
|
| 130 |
+
for _ in range(20):
|
| 131 |
+
if not self._running or self.reachy_mini is None:
|
| 132 |
+
break
|
| 133 |
+
try:
|
| 134 |
+
imu = self.reachy_mini.imu
|
| 135 |
+
if imu and "accelerometer" in imu:
|
| 136 |
+
ax, ay, az = imu["accelerometer"]
|
| 137 |
+
samples.append(math.sqrt(ax*ax + ay*ay + az*az))
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
time.sleep(0.02)
|
| 141 |
+
|
| 142 |
+
if samples:
|
| 143 |
+
self._baseline_magnitude = sum(samples) / len(samples)
|
| 144 |
+
_LOGGER.info("IMU baseline calibrated: %.2f m/s²", self._baseline_magnitude)
|
| 145 |
+
|
| 146 |
+
def _detection_loop(self) -> None:
|
| 147 |
+
"""Main detection loop running in background thread."""
|
| 148 |
+
_LOGGER.debug("Tap detection loop started")
|
| 149 |
+
|
| 150 |
+
# Calibration phase
|
| 151 |
+
self._calibrate_baseline()
|
| 152 |
+
|
| 153 |
+
interval = 1.0 / TAP_DETECTION_RATE_HZ
|
| 154 |
+
|
| 155 |
+
while self._running:
|
| 156 |
+
try:
|
| 157 |
+
if not self._enabled or self.reachy_mini is None:
|
| 158 |
+
time.sleep(interval)
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
imu = self.reachy_mini.imu
|
| 162 |
+
if not imu or "accelerometer" not in imu:
|
| 163 |
+
time.sleep(interval)
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
# Get accelerometer data
|
| 167 |
+
ax, ay, az = imu["accelerometer"]
|
| 168 |
+
magnitude = math.sqrt(ax*ax + ay*ay + az*az)
|
| 169 |
+
|
| 170 |
+
# Detect tap: sudden spike above baseline + threshold
|
| 171 |
+
delta = abs(magnitude - self._baseline_magnitude)
|
| 172 |
+
|
| 173 |
+
if delta > self._threshold_ms2:
|
| 174 |
+
now = time.time()
|
| 175 |
+
if now - self._last_tap_time > self._cooldown_seconds:
|
| 176 |
+
self._last_tap_time = now
|
| 177 |
+
_LOGGER.info("Tap detected! (delta=%.2f m/s²)", delta)
|
| 178 |
+
|
| 179 |
+
if self._on_tap_callback:
|
| 180 |
+
try:
|
| 181 |
+
self._on_tap_callback()
|
| 182 |
+
except Exception as e:
|
| 183 |
+
_LOGGER.error("Tap callback error: %s", e)
|
| 184 |
+
|
| 185 |
+
time.sleep(interval)
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
_LOGGER.debug("Tap detection error: %s", e)
|
| 189 |
+
time.sleep(0.1)
|
| 190 |
+
|
| 191 |
+
_LOGGER.debug("Tap detection loop stopped")
|
reachy_mini_ha_voice/voice_assistant.py
CHANGED
|
@@ -26,6 +26,7 @@ from .util import get_mac
|
|
| 26 |
from .zeroconf import HomeAssistantZeroconf
|
| 27 |
from .motion import ReachyMiniMotion
|
| 28 |
from .camera_server import MJPEGCameraServer
|
|
|
|
| 29 |
|
| 30 |
_LOGGER = logging.getLogger(__name__)
|
| 31 |
|
|
@@ -75,6 +76,8 @@ class VoiceAssistantService:
|
|
| 75 |
self._state: Optional[ServerState] = None
|
| 76 |
self._motion = ReachyMiniMotion(reachy_mini)
|
| 77 |
self._camera_server: Optional[MJPEGCameraServer] = None
|
|
|
|
|
|
|
| 78 |
|
| 79 |
async def start(self) -> None:
|
| 80 |
"""Start the voice assistant service."""
|
|
@@ -165,6 +168,18 @@ class VoiceAssistantService:
|
|
| 165 |
if self._motion is not None:
|
| 166 |
self._motion.start()
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# Start audio processing thread (non-daemon for proper cleanup)
|
| 169 |
self._running = True
|
| 170 |
self._audio_thread = threading.Thread(
|
|
@@ -247,7 +262,12 @@ class VoiceAssistantService:
|
|
| 247 |
await self._camera_server.stop()
|
| 248 |
self._camera_server = None
|
| 249 |
|
| 250 |
-
# 8.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
if self._motion:
|
| 252 |
self._motion.shutdown()
|
| 253 |
|
|
@@ -619,3 +639,35 @@ class VoiceAssistantService:
|
|
| 619 |
if stopped and (self._state.stop_word.id in self._state.active_wake_words):
|
| 620 |
_LOGGER.info("Stop word detected")
|
| 621 |
self._state.satellite.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
from .zeroconf import HomeAssistantZeroconf
|
| 27 |
from .motion import ReachyMiniMotion
|
| 28 |
from .camera_server import MJPEGCameraServer
|
| 29 |
+
from .tap_detector import TapDetector
|
| 30 |
|
| 31 |
_LOGGER = logging.getLogger(__name__)
|
| 32 |
|
|
|
|
| 76 |
self._state: Optional[ServerState] = None
|
| 77 |
self._motion = ReachyMiniMotion(reachy_mini)
|
| 78 |
self._camera_server: Optional[MJPEGCameraServer] = None
|
| 79 |
+
self._tap_detector: Optional[TapDetector] = None
|
| 80 |
+
self._last_tap_wakeup: float = 0.0 # For refractory period
|
| 81 |
|
| 82 |
async def start(self) -> None:
|
| 83 |
"""Start the voice assistant service."""
|
|
|
|
| 168 |
if self._motion is not None:
|
| 169 |
self._motion.start()
|
| 170 |
|
| 171 |
+
# Start tap detector for "tap to wake" (Wireless version only)
|
| 172 |
+
if self.reachy_mini is not None:
|
| 173 |
+
self._tap_detector = TapDetector(
|
| 174 |
+
reachy_mini=self.reachy_mini,
|
| 175 |
+
on_tap_callback=self._on_tap_detected,
|
| 176 |
+
threshold_g=2.0,
|
| 177 |
+
cooldown_seconds=1.0,
|
| 178 |
+
)
|
| 179 |
+
self._tap_detector.start()
|
| 180 |
+
# Store tap_detector in state for entity registry access
|
| 181 |
+
self._state.tap_detector = self._tap_detector
|
| 182 |
+
|
| 183 |
# Start audio processing thread (non-daemon for proper cleanup)
|
| 184 |
self._running = True
|
| 185 |
self._audio_thread = threading.Thread(
|
|
|
|
| 262 |
await self._camera_server.stop()
|
| 263 |
self._camera_server = None
|
| 264 |
|
| 265 |
+
# 8. Stop tap detector
|
| 266 |
+
if self._tap_detector:
|
| 267 |
+
self._tap_detector.stop()
|
| 268 |
+
self._tap_detector = None
|
| 269 |
+
|
| 270 |
+
# 9. Shutdown motion executor
|
| 271 |
if self._motion:
|
| 272 |
self._motion.shutdown()
|
| 273 |
|
|
|
|
| 639 |
if stopped and (self._state.stop_word.id in self._state.active_wake_words):
|
| 640 |
_LOGGER.info("Stop word detected")
|
| 641 |
self._state.satellite.stop()
|
| 642 |
+
|
| 643 |
+
def _on_tap_detected(self) -> None:
|
| 644 |
+
"""Callback when tap is detected on the robot.
|
| 645 |
+
|
| 646 |
+
First tap: Enter continuous conversation mode
|
| 647 |
+
Second tap: Exit continuous conversation mode
|
| 648 |
+
"""
|
| 649 |
+
if self._state is None or self._state.satellite is None:
|
| 650 |
+
return
|
| 651 |
+
|
| 652 |
+
# Check refractory period
|
| 653 |
+
now = time.monotonic()
|
| 654 |
+
if now - self._last_tap_wakeup < self._state.refractory_seconds:
|
| 655 |
+
_LOGGER.debug("Tap ignored (refractory period)")
|
| 656 |
+
return
|
| 657 |
+
|
| 658 |
+
self._last_tap_wakeup = now
|
| 659 |
+
|
| 660 |
+
# Check if we're exiting conversation mode
|
| 661 |
+
is_exiting = self._state.satellite.is_tap_conversation_active()
|
| 662 |
+
|
| 663 |
+
# Trigger tap handling in satellite (handles mode toggle)
|
| 664 |
+
self._state.satellite.wakeup_from_tap()
|
| 665 |
+
|
| 666 |
+
# Trigger motion feedback
|
| 667 |
+
if self._motion is not None:
|
| 668 |
+
if is_exiting:
|
| 669 |
+
# Exiting conversation - return to idle
|
| 670 |
+
self._motion.on_idle()
|
| 671 |
+
else:
|
| 672 |
+
# Starting conversation
|
| 673 |
+
self._motion.on_wakeup()
|