Desmond-Dong commited on
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 self._continue_conversation:
 
 
 
 
 
368
  self.send_messages([VoiceAssistantRequest(start=True)])
369
  self._is_streaming_audio = True
370
- _LOGGER.debug("Continuing conversation")
 
 
 
 
 
 
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. Shutdown motion executor
 
 
 
 
 
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()