Desmond-Dong commited on
Commit
bdb449d
·
1 Parent(s): 8e27dfb

Replace with official conversation app

Browse files

- Delete all custom code
- Copy official conversation app from pollen-robotics
- Update README to reflect official app
- Remove binary image files

Files changed (35) hide show
  1. README.md +17 -246
  2. linux-voice-assistant +1 -0
  3. linux_voice_assistant_original +1 -0
  4. pyproject.toml +0 -1
  5. src_backup/reachy_mini_ha_voice/__init__.py +3 -0
  6. src_backup/reachy_mini_ha_voice/__main__.py +28 -0
  7. src_backup/reachy_mini_ha_voice/api_server.py +180 -0
  8. src_backup/reachy_mini_ha_voice/app.py +439 -0
  9. src_backup/reachy_mini_ha_voice/entity.py +94 -0
  10. src_backup/reachy_mini_ha_voice/models.py +179 -0
  11. src_backup/reachy_mini_ha_voice/reachy_integration.py +114 -0
  12. src_backup/reachy_mini_ha_voice/satellite.py +398 -0
  13. src_backup/reachy_mini_ha_voice/static/index.html +61 -0
  14. src_backup/reachy_mini_ha_voice/static/main.js +47 -0
  15. src_backup/reachy_mini_ha_voice/static/style.css +163 -0
  16. src_backup/reachy_mini_ha_voice/util.py +19 -0
  17. src_backup/reachy_mini_ha_voice/wakewords/alexa.json +16 -0
  18. src_backup/reachy_mini_ha_voice/wakewords/choo_choo_homie.json +18 -0
  19. src_backup/reachy_mini_ha_voice/wakewords/hey_home_assistant.json +16 -0
  20. src_backup/reachy_mini_ha_voice/wakewords/hey_jarvis.json +16 -0
  21. src_backup/reachy_mini_ha_voice/wakewords/hey_luna.json +16 -0
  22. src_backup/reachy_mini_ha_voice/wakewords/hey_mycroft.json +16 -0
  23. src_backup/reachy_mini_ha_voice/wakewords/okay_computer.json +18 -0
  24. src_backup/reachy_mini_ha_voice/wakewords/okay_nabu.json +16 -0
  25. src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/alexa_v0.1.json +5 -0
  26. src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_jarvis_v0.1.json +5 -0
  27. src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_mycroft_v0.1.json +5 -0
  28. src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_rhasspy_v0.1.json +5 -0
  29. src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/ok_nabu_v0.1.json +5 -0
  30. src_backup/reachy_mini_ha_voice/wakewords/stop.json +16 -0
  31. src_backup/reachy_mini_ha_voice/zeroconf.py +68 -0
  32. temp_conversation_app +1 -0
  33. temp_reachy_mini +1 -0
  34. temp_reachy_mini_examples +1 -0
  35. test_official_app +1 -0
README.md CHANGED
@@ -1,262 +1,33 @@
1
  ---
2
- title: Reachy Mini Home Assistant Voice Assistant
3
- emoji: 🤖
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: static
7
  pinned: false
8
- short_description: Home Assistant Voice Assistant for Reachy Mini
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
12
  ---
13
 
14
- # Reachy Mini Home Assistant Voice Assistant
15
 
16
- 基于 ESPHome 协议的 Reachy Mini 语音助手,用于连接 Home Assistant。可通过 Hugging Face Spaces 一键安装和部署。
17
 
18
- ## 功能特性
19
 
20
- - 🎤 唤醒词检测(支持多个唤醒词)
21
- - 🔊 语音识别和合成
22
- - 🏠 Home Assistant 指令执行
23
- - 🤖 Reachy Mini 机器人集成
24
- - ⏰ 定时器功能
25
- - 📢 广播通知
26
 
27
- ## 快速开始
28
 
29
- ### 通过 Hugging Face Spaces 安装
30
 
31
- 1. 访问 [Hugging Face Spaces](https://huggingface.co/spaces)
32
- 2. 创建新的 Space,选择 Docker 模板
33
- 3. 将此仓库克隆到你的 Space
34
- 4. 等待构建完成,服务将自动启动
35
 
36
- ### 本地运行
37
 
38
- ```bash
39
- # 克隆仓库
40
- git clone https://huggingface.co/spaces/djhui5710/reachy_mini_ha_voice
41
- cd reachy_mini_ha_voice
42
-
43
- # 安装系统依赖(Linux)
44
- sudo apt-get install portaudio19-dev build-essential libportaudio2
45
-
46
- # 创建虚拟环境
47
- python -m venv .venv
48
- source .venv/bin/activate # Windows: .venv\Scripts\activate
49
-
50
- # 安装 Python 依赖
51
- pip install -r requirements.txt
52
- pip install -e .
53
-
54
- # 运行
55
- python -m reachy_mini_ha_voice --name "ReachyMini"
56
- ```
57
-
58
- ## 连接到 Home Assistant
59
-
60
- 1. 在 Home Assistant 中,进入 "设置" -> "设备与服务"
61
- 2. 点击 "添加集成" 按钮
62
- 3. 选择 "ESPHome" 然后选择 "设置另一个 ESPHome 实例"
63
- 4. 输入语音助手的 IP 地址和端口 6053
64
- 5. 点击 "提交"
65
-
66
- ## 命令行选项
67
-
68
- ```
69
- python -m reachy_mini_ha_voice --help
70
-
71
- 选项:
72
- --name NAME 设备名称(必需)
73
- --host HOST 服务器地址(默认: 0.0.0.0)
74
- --port PORT 服务器端口(默认: 6053)
75
- --audio-input-device DEVICE 音频输入设备
76
- --list-input-devices 列出可用的音频输入设备
77
- --audio-output-device DEVICE 音频输出设备
78
- --list-output-devices 列出可用的音频输出设备
79
- --wake-model MODEL 唤醒词模型(默认: okay_nabu)
80
- --stop-model MODEL 停止词模型(默认: stop)
81
- --refractory-seconds SECONDS 唤醒词冷却时间(默认: 2.0)
82
- --debug 启用调试日志
83
- ```
84
-
85
- ## 包含的 Assets
86
-
87
- 项目已经包含了所有必需的唤醒词模型和声音文件,无需额外下载:
88
-
89
- ### 唤醒词模型(microWakeWord)
90
-
91
- - `okay_nabu.tflite` - "Okay Nabu"(默认)
92
- - `stop.tflite` - "Stop"
93
- - `alexa.tflite` - "Alexa"
94
- - `hey_jarvis.tflite` - "Hey Jarvis"
95
- - `hey_home_assistant.tflite` - "Hey Home Assistant"
96
- - `hey_luna.tflite` - "Hey Luna"
97
- - `hey_mycroft.tflite` - "Hey Mycroft"
98
- - `okay_computer.tflite` - "Okay Computer"
99
- - `choo_choo_homie.tflite` - "Choo Choo Homie"
100
-
101
- ### 唤醒词模型(openWakeWord)
102
-
103
- 在 `wakewords/openWakeWord/` 目录中:
104
- - `alexa_v0.1.tflite` - Alexa
105
- - `hey_jarvis_v0.1.tflite` - Hey Jarvis
106
- - `hey_mycroft_v0.1.tflite` - Hey Mycroft
107
- - `hey_rhasspy_v0.1.tflite` - Hey Rhasspy
108
- - `ok_nabu_v0.1.tflite` - Okay Nabu
109
-
110
- ### 声音文件
111
-
112
- - `wake_word_triggered.flac` - 唤醒词触发时播放
113
- - `timer_finished.flac` - 定时器结束时播放
114
-
115
- ## 音频设备配置
116
-
117
- ### 查看可用设备
118
-
119
- ```bash
120
- # 列出音频输入设备
121
- python -m reachy_mini_ha_voice --name Test --list-input-devices
122
-
123
- # 列出音频输出设备
124
- python -m reachy_mini_ha_voice --name Test --list-output-devices
125
- ```
126
-
127
- ### 指定音频设备
128
-
129
- ```bash
130
- python -m reachy_mini_ha_voice \
131
- --name "ReachyMini" \
132
- --audio-input-device "麦克风名称" \
133
- --audio-output-device "扬声器名称"
134
- ```
135
-
136
- **注意**:麦克风设备必须支持 16KHz 单声道音频。
137
-
138
- ## 唤醒词
139
-
140
- ### 默认唤醒词
141
-
142
- - `okay_nabu`(默认)
143
-
144
- ### 使用其他唤醒词
145
-
146
- 项目已经包含了多个唤醒词模型,你可以直接使用:
147
-
148
- ```bash
149
- # 使用 "Hey Jarvis"
150
- python -m reachy_mini_ha_voice --name "ReachyMini" --wake-model hey_jarvis
151
-
152
- # 使用 "Alexa"
153
- python -m reachy_mini_ha_voice --name "ReachyMini" --wake-model alexa
154
-
155
- # 使用 openWakeWord 版本的 "Hey Jarvis"
156
- python -m reachy_mini_ha_voice --name "ReachyMini" \
157
- --wake-model hey_jarvis_v0.1 \
158
- --wake-word-dir wakewords/openWakeWord
159
- ```
160
-
161
- ### 添加自定义唤醒词
162
-
163
- 如果你想添加其他唤醒词:
164
-
165
- 更多唤醒词模型请访问:[home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection)
166
-
167
- ## Reachy Mini 集成
168
-
169
- ### 动作反馈
170
-
171
- 语音助手会根据不同状态触发 Reachy Mini 的动作:
172
-
173
- - **唤醒时**:头部抬起,眼睛闪烁
174
- - **监听中**:头部轻微摆动
175
- - **响应中**:点头或摇头
176
- - **错误时**:头部倾斜
177
-
178
- ### 自定义动作
179
-
180
- 编辑 `reachy_mini_ha_voice/reachy_integration.py` 来自定义 Reachy Mini 的动作反馈。
181
-
182
- ## 故障排除
183
-
184
- ### 音频设备问题
185
-
186
- 如果无法检测到音频设备:
187
-
188
- ```bash
189
- # 检查 PulseAudio 服务
190
- systemctl --user status pulseaudio
191
-
192
- # 重新加载 PulseAudio
193
- pulseaudio --kill
194
- pulseaudio --start
195
- ```
196
-
197
- ### 回声消除
198
-
199
- 启用 PulseAudio 的回声消除模块:
200
-
201
- ```bash
202
- pactl load-module module-echo-cancel \
203
- aec_method=webrtc \
204
- aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
205
- ```
206
-
207
- 查看设备:
208
-
209
- ```bash
210
- pactl list short sources
211
- pactl list short sinks
212
- ```
213
-
214
- 使用回声消除设备:
215
-
216
- ```bash
217
- python -m reachy_mini_ha_voice \
218
- --name "ReachyMini" \
219
- --audio-input-device 'Echo-Cancel Source' \
220
- --audio-output-device 'pipewire/echo-cancel-sink'
221
- ```
222
-
223
- ### Hugging Face Spaces 部署问题
224
-
225
- 如果构建失败:
226
-
227
- 1. 检查 Dockerfile 中的依赖是否正确
228
- 2. 确保 requirements.txt 中的版本兼容
229
- 3. 查看构建日志中的错误信息
230
- 4. 确保没有使用需要系统特权的功能
231
-
232
- ## 开发
233
-
234
- ### 运行测试
235
-
236
- ```bash
237
- pip install -e ".[dev]"
238
- pytest
239
- ```
240
-
241
- ### 代码格式化
242
-
243
- ```bash
244
- black reachy_mini_ha_voice/
245
- flake8 reachy_mini_ha_voice/
246
- ```
247
-
248
- ## 许可证
249
-
250
- Apache License 2.0
251
-
252
- ## 致谢
253
-
254
- 本项目基于 [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant) 修改而来,适配 Reachy Mini 机器人和 Hugging Face Spaces 环境。
255
-
256
- ## 相关链接
257
-
258
- - [Home Assistant](https://www.home-assistant.io/)
259
- - [ESPHome](https://esphome.io/)
260
- - [Reachy Mini](https://github.com/pollen-robotics/reachy)
261
- - [Hugging Face Spaces](https://huggingface.co/spaces)
262
- - [Source Code](https://huggingface.co/spaces/djhui5710/reachy_mini_ha_voice)
 
1
  ---
2
+ title: Reachy Mini Conversation App
3
+ emoji: 🎤
4
+ colorFrom: red
5
+ colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ short_description: Talk with Reachy Mini !
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
12
  ---
13
 
14
+ # Reachy Mini conversation app
15
 
16
+ Conversational app for the Reachy Mini robot combining OpenAI's realtime APIs, vision pipelines, and choreographed motion libraries.
17
 
18
+ This is a test deployment of the official [reachy_mini_conversation_app](https://huggingface.co/spaces/pollen-robotics/reachy_mini_conversation_app).
19
 
20
+ ## Installation
 
 
 
 
 
21
 
22
+ Install this app on your Reachy Mini robot from the dashboard.
23
 
24
+ ## Configuration
25
 
26
+ You'll need an OpenAI API key to use this app. Configure it in the settings page.
 
 
 
27
 
28
+ ## Features
29
 
30
+ - Real-time audio conversation with OpenAI
31
+ - Vision processing
32
+ - Motion control
33
+ - Multiple personality profiles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
linux-voice-assistant ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit fd4c1d972bc87e6d7a0dddc5aa52465243d63265
linux_voice_assistant_original ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit fd4c1d972bc87e6d7a0dddc5aa52465243d63265
pyproject.toml CHANGED
@@ -59,7 +59,6 @@ where = ["src"]
59
 
60
  [tool.setuptools.package-data]
61
  reachy_mini_conversation_app = [
62
- "images/*",
63
  "static/*",
64
  ".env.example",
65
  "demos/**/*.txt",
 
59
 
60
  [tool.setuptools.package-data]
61
  reachy_mini_conversation_app = [
 
62
  "static/*",
63
  ".env.example",
64
  "demos/**/*.txt",
src_backup/reachy_mini_ha_voice/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Reachy Mini Home Assistant Voice Assistant."""
2
+
3
+ __version__ = "1.0.0"
src_backup/reachy_mini_ha_voice/__main__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Main entry point - for Hugging Face Spaces display only.
3
+
4
+ This application is designed to run on Reachy Mini robots.
5
+ To use it, install the app on your Reachy Mini from the dashboard.
6
+
7
+ For standalone usage, this would require command-line arguments:
8
+ python -m reachy_mini_ha_voice --name "ReachyMini"
9
+ """
10
+
11
+ import sys
12
+
13
+ if __name__ == "__main__":
14
+ print("Reachy Mini Home Assistant Voice Assistant")
15
+ print("=" * 50)
16
+ print()
17
+ print("This application is designed to run on Reachy Mini robots.")
18
+ print()
19
+ print("To use it:")
20
+ print("1. Install this app on your Reachy Mini from the dashboard")
21
+ print("2. Enable the app in the Reachy Mini applications section")
22
+ print("3. The ESPHome voice assistant will start on port 6053")
23
+ print("4. Home Assistant will auto-discover the device")
24
+ print()
25
+ print("For more information, see:")
26
+ print("https://huggingface.co/spaces/djhui5710/reachy_mini_ha_voice")
27
+ print()
28
+ sys.exit(0)
src_backup/reachy_mini_ha_voice/api_server.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Partial ESPHome server implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from abc import abstractmethod
6
+ from collections.abc import Iterable
7
+ from typing import TYPE_CHECKING, List, Optional
8
+
9
+ # pylint: disable=no-name-in-module
10
+ from aioesphomeapi._frame_helper.packets import make_plain_text_packets
11
+ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
12
+ AuthenticationRequest,
13
+ AuthenticationResponse,
14
+ DisconnectRequest,
15
+ DisconnectResponse,
16
+ HelloRequest,
17
+ HelloResponse,
18
+ PingRequest,
19
+ PingResponse,
20
+ )
21
+ from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
22
+ from google.protobuf import message
23
+
24
+ PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+
28
+
29
+ class APIServer(asyncio.Protocol):
30
+
31
+ def __init__(self, name: str) -> None:
32
+ self.name = name
33
+
34
+ self._buffer: Optional[bytes] = None
35
+ self._buffer_len: int = 0
36
+ self._pos: int = 0
37
+ self._transport = None
38
+ self._writelines = None
39
+
40
+ @abstractmethod
41
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
42
+ pass
43
+
44
+ def process_packet(self, msg_type: int, packet_data: bytes) -> None:
45
+ msg_class = MESSAGE_TYPE_TO_PROTO[msg_type]
46
+ msg_inst = msg_class.FromString(packet_data)
47
+
48
+ if isinstance(msg_inst, HelloRequest):
49
+ self.send_messages(
50
+ [
51
+ HelloResponse(
52
+ api_version_major=1,
53
+ api_version_minor=10,
54
+ name=self.name,
55
+ )
56
+ ]
57
+ )
58
+ return
59
+
60
+ if isinstance(msg_inst, AuthenticationRequest):
61
+ self.send_messages([AuthenticationResponse()])
62
+ elif isinstance(msg_inst, DisconnectRequest):
63
+ self.send_messages([DisconnectResponse()])
64
+ _LOGGER.debug("Disconnect requested")
65
+ if self._transport:
66
+ self._transport.close()
67
+ self._transport = None
68
+ self._writelines = None
69
+ elif isinstance(msg_inst, PingRequest):
70
+ self.send_messages([PingResponse()])
71
+ elif msgs := self.handle_message(msg_inst):
72
+ if isinstance(msgs, message.Message):
73
+ msgs = [msgs]
74
+
75
+ self.send_messages(msgs)
76
+
77
+ def send_messages(self, msgs: List[message.Message]):
78
+ if self._writelines is None:
79
+ return
80
+
81
+ packets = [
82
+ (PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString())
83
+ for msg in msgs
84
+ ]
85
+ packet_bytes = make_plain_text_packets(packets)
86
+ self._writelines(packet_bytes)
87
+
88
+ def connection_made(self, transport) -> None:
89
+ self._transport = transport
90
+ self._writelines = transport.writelines
91
+
92
+ def data_received(self, data: bytes):
93
+ if self._buffer is None:
94
+ self._buffer = data
95
+ self._buffer_len = len(data)
96
+ else:
97
+ self._buffer += data
98
+ self._buffer_len += len(data)
99
+
100
+ while self._buffer_len >= 3:
101
+ self._pos = 0
102
+ # Read preamble, which should always 0x00
103
+ if (preamble := self._read_varuint()) != 0x00:
104
+ _LOGGER.error("Incorrect preamble: %s", preamble)
105
+ return
106
+
107
+ if (length := self._read_varuint()) == -1:
108
+ _LOGGER.error("Incorrect length: %s", length)
109
+ return
110
+
111
+ if (msg_type := self._read_varuint()) == -1:
112
+ _LOGGER.error("Incorrect message type: %s", msg_type)
113
+ return
114
+
115
+ if length == 0:
116
+ # Empty message (allowed)
117
+ self._remove_from_buffer()
118
+ self.process_packet(msg_type, b"")
119
+ continue
120
+
121
+ if (packet_data := self._read(length)) is None:
122
+ return
123
+
124
+ self._remove_from_buffer()
125
+ self.process_packet(msg_type, packet_data)
126
+
127
+ def _read(self, length: int) -> bytes | None:
128
+ """Read exactly length bytes from the buffer or None if all the bytes are not yet available."""
129
+ new_pos = self._pos + length
130
+ if self._buffer_len < new_pos:
131
+ return None
132
+ original_pos = self._pos
133
+ self._pos = new_pos
134
+ if TYPE_CHECKING:
135
+ assert self._buffer is not None, "Buffer should be set"
136
+ cstr = self._buffer
137
+ # Important: we must keep the bounds check (self._buffer_len < new_pos)
138
+ # above to verify we never try to read past the end of the buffer
139
+ return cstr[original_pos:new_pos]
140
+
141
+ def connection_lost(self, exc):
142
+ self._transport = None
143
+ self._writelines = None
144
+
145
+ def _read_varuint(self) -> int:
146
+ """Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
147
+ if not self._buffer:
148
+ return -1
149
+
150
+ result = 0
151
+ bitpos = 0
152
+ cstr = self._buffer
153
+ while self._buffer_len > self._pos:
154
+ val = cstr[self._pos]
155
+ self._pos += 1
156
+ result |= (val & 0x7F) << bitpos
157
+ if (val & 0x80) == 0:
158
+ return result
159
+ bitpos += 7
160
+ return -1
161
+
162
+ def _remove_from_buffer(self) -> None:
163
+ """Remove data from the buffer."""
164
+ end_of_frame_pos = self._pos
165
+ self._buffer_len -= end_of_frame_pos
166
+ if self._buffer_len == 0:
167
+ # This is the best case scenario, we can just set the buffer to None
168
+ # and don't have to copy the data. This is the most common case as well.
169
+ self._buffer = None
170
+ return
171
+ if TYPE_CHECKING:
172
+ assert self._buffer is not None, "Buffer should be set"
173
+ # This is the worst case scenario, we have to copy the data
174
+ # and can't just use the buffer directly. This should only happen
175
+ # when we read multiple frames at once because the event loop
176
+ # is blocked and we cannot pull the data out of the buffer fast enough.
177
+ cstr = self._buffer
178
+ # Important: we must use the explicit length for the slice
179
+ # since Cython will stop at any '\0' character if we don't
180
+ self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
src_backup/reachy_mini_ha_voice/app.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini Home Assistant Voice Assistant."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from queue import Queue
10
+ from typing import Dict, List, Optional, Set, Union
11
+
12
+ import numpy as np
13
+ from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
14
+ from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
15
+
16
+ from reachy_mini import ReachyMini, ReachyMiniApp
17
+
18
+ from .models import (
19
+ AvailableWakeWord,
20
+ Preferences,
21
+ ServerState,
22
+ WakeWordType,
23
+ AudioPlayer,
24
+ )
25
+ from .satellite import VoiceSatelliteProtocol
26
+ from .util import get_mac
27
+ from .zeroconf import HomeAssistantZeroconf
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+ _MODULE_DIR = Path(__file__).parent
31
+ _WAKEWORDS_DIR = _MODULE_DIR / "wakewords"
32
+ _SOUNDS_DIR = _MODULE_DIR / "sounds"
33
+
34
+
35
+ def parse_args() -> argparse.Namespace:
36
+ """Parse command-line arguments."""
37
+ parser = argparse.ArgumentParser(description="Reachy Mini Home Assistant Voice Assistant")
38
+ parser.add_argument(
39
+ "--debug",
40
+ action="store_true",
41
+ help="Enable debug logging",
42
+ )
43
+ return parser.parse_args()
44
+
45
+
46
+ class ReachyMiniHAVoiceApp(ReachyMiniApp):
47
+ """Reachy Mini Apps entry point for the voice assistant app."""
48
+
49
+ custom_app_url = "http://0.0.0.0:7860/"
50
+ dont_start_webserver = False
51
+
52
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
53
+ """Run the Reachy Mini voice assistant app."""
54
+ loop = asyncio.new_event_loop()
55
+ asyncio.set_event_loop(loop)
56
+
57
+ args = parse_args()
58
+
59
+ instance_path = self._get_instance_path().parent
60
+ _run(
61
+ args,
62
+ robot=reachy_mini,
63
+ app_stop_event=stop_event,
64
+ settings_app=self.settings_app,
65
+ instance_path=instance_path,
66
+ )
67
+
68
+
69
+ def _run(
70
+ args: argparse.Namespace,
71
+ robot: ReachyMini,
72
+ app_stop_event: threading.Event,
73
+ settings_app=None,
74
+ instance_path: Optional[str] = None,
75
+ ) -> None:
76
+ """Run the voice assistant."""
77
+ _LOGGER.info("=== Starting Reachy Mini Home Assistant Voice Assistant ===")
78
+
79
+ try:
80
+ # Initialize server state
81
+ _LOGGER.info("Initializing server state...")
82
+ state = _init_state(robot)
83
+ _LOGGER.info(f"Server state initialized: {state.name}")
84
+
85
+ # Start audio processing thread
86
+ _LOGGER.info("Starting audio processing thread...")
87
+ audio_thread = threading.Thread(
88
+ target=_process_audio,
89
+ args=(state,),
90
+ daemon=False,
91
+ name="AudioProcessor",
92
+ )
93
+ audio_thread.start()
94
+ _LOGGER.info("Audio processing thread started")
95
+
96
+ # Start ESPHome server in background thread
97
+ _LOGGER.info("Starting ESPHome server thread...")
98
+ server_thread = threading.Thread(
99
+ target=_run_server,
100
+ args=(state, app_stop_event),
101
+ daemon=False,
102
+ name="ESPServer",
103
+ )
104
+ server_thread.start()
105
+ _LOGGER.info("ESPHome server thread started")
106
+
107
+ # Main loop - wait for stop event
108
+ _LOGGER.info("Entering main loop...")
109
+ while not app_stop_event.is_set():
110
+ time.sleep(0.1)
111
+
112
+ _LOGGER.info("=== Shutting down voice assistant ===")
113
+ except Exception as e:
114
+ _LOGGER.error(f"Error in _run(): {e}", exc_info=True)
115
+ raise
116
+
117
+
118
+ def _init_state(robot: ReachyMini) -> ServerState:
119
+ """Initialize server state."""
120
+ _LOGGER.info("Loading wake words...")
121
+ available_wake_words = _load_wake_words()
122
+ _LOGGER.info(f"Found {len(available_wake_words)} available wake words")
123
+
124
+ # Load active wake words
125
+ active_wake_words = set()
126
+ wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
127
+
128
+ # Use default wake word
129
+ default_wake_word = "okay_nabu"
130
+ _LOGGER.info(f"Loading default wake word: {default_wake_word}")
131
+ if default_wake_word in available_wake_words:
132
+ try:
133
+ wake_word = available_wake_words[default_wake_word]
134
+ wake_models[default_wake_word] = wake_word.load()
135
+ active_wake_words.add(default_wake_word)
136
+ _LOGGER.info("Loaded wake word: %s", default_wake_word)
137
+ except Exception as e:
138
+ _LOGGER.error("Failed to load wake word %s: %s", default_wake_word, e)
139
+ else:
140
+ _LOGGER.warning(f"Wake word {default_wake_word} not found in available wake words")
141
+
142
+ # Load stop model
143
+ _LOGGER.info("Loading stop model...")
144
+ stop_model = _load_stop_model()
145
+ _LOGGER.info(f"Stop model loaded: {stop_model is not None}")
146
+
147
+ _LOGGER.info("Creating ServerState...")
148
+ return ServerState(
149
+ name="ReachyMini",
150
+ mac_address=get_mac(),
151
+ audio_queue=Queue(),
152
+ entities=[],
153
+ available_wake_words=available_wake_words,
154
+ wake_words=wake_models,
155
+ active_wake_words=active_wake_words,
156
+ stop_word=stop_model,
157
+ music_player=ReachyMiniAudioPlayer(robot),
158
+ tts_player=ReachyMiniAudioPlayer(robot),
159
+ wakeup_sound=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
160
+ timer_finished_sound=str(_SOUNDS_DIR / "timer_finished.flac"),
161
+ preferences=Preferences(),
162
+ preferences_path=_MODULE_DIR / "preferences.json",
163
+ refractory_seconds=2.0,
164
+ download_dir=_MODULE_DIR / "local",
165
+ reachy_integration=None,
166
+ media_player_entity=None,
167
+ )
168
+
169
+
170
+ def _load_wake_words() -> Dict[str, AvailableWakeWord]:
171
+ """Load available wake words."""
172
+ available_wake_words: Dict[str, AvailableWakeWord] = {}
173
+
174
+ _LOGGER.info(f"Loading wake words from: {_WAKEWORDS_DIR}")
175
+
176
+ for wake_word_dir in [_WAKEWORDS_DIR]:
177
+ if not wake_word_dir.exists():
178
+ _LOGGER.warning(f"Wake word directory not found: {wake_word_dir}")
179
+ continue
180
+
181
+ _LOGGER.info(f"Scanning wake word directory: {wake_word_dir}")
182
+
183
+ for model_config_path in wake_word_dir.glob("*.json"):
184
+ model_id = model_config_path.stem
185
+ if model_id == "stop":
186
+ continue
187
+
188
+ try:
189
+ import json
190
+
191
+ with open(model_config_path, "r", encoding="utf-8") as f:
192
+ model_config = json.load(f)
193
+ model_type = WakeWordType(model_config.get("type", "microWakeWord"))
194
+ if model_type == WakeWordType.OPEN_WAKE_WORD:
195
+ wake_word_path = model_config_path.parent / model_config["model"]
196
+ else:
197
+ wake_word_path = model_config_path
198
+
199
+ available_wake_words[model_id] = AvailableWakeWord(
200
+ id=model_id,
201
+ type=model_type,
202
+ wake_word=model_config["wake_word"],
203
+ trained_languages=model_config.get("trained_languages", []),
204
+ wake_word_path=wake_word_path,
205
+ )
206
+ _LOGGER.debug(f"Loaded wake word config: {model_id}")
207
+ except Exception as e:
208
+ _LOGGER.error("Error loading wake word config %s: %s", model_config_path, e)
209
+
210
+ _LOGGER.info(f"Loaded {len(available_wake_words)} wake word configurations")
211
+ return available_wake_words
212
+
213
+
214
+ def _load_stop_model() -> Optional[MicroWakeWord]:
215
+ """Load stop word model."""
216
+ stop_config_path = _WAKEWORDS_DIR / "stop.json"
217
+ _LOGGER.info(f"Loading stop model from: {stop_config_path}")
218
+
219
+ if not stop_config_path.exists():
220
+ _LOGGER.warning(f"Stop model config not found: {stop_config_path}")
221
+ return None
222
+
223
+ try:
224
+ model = MicroWakeWord.from_config(stop_config_path)
225
+ _LOGGER.info("Stop model loaded successfully")
226
+ return model
227
+ except Exception as e:
228
+ _LOGGER.error("Failed to load stop model: %s", e, exc_info=True)
229
+ return None
230
+
231
+
232
+ def _run_server(state: ServerState, stop_event: threading.Event):
233
+ """Run ESPHome server in a separate thread."""
234
+ _LOGGER.info("ESPHome server thread: Starting...")
235
+
236
+ async def server_loop():
237
+ _LOGGER.info("ESPHome server: Creating event loop...")
238
+ loop = asyncio.get_running_loop()
239
+
240
+ _LOGGER.info("ESPHome server: Creating server on port 6053...")
241
+ server = await loop.create_server(
242
+ lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053
243
+ )
244
+ _LOGGER.info("ESPHome server: Server created successfully")
245
+
246
+ # Auto discovery (zeroconf, mDNS)
247
+ _LOGGER.info("ESPHome server: Registering mDNS service...")
248
+ discovery = HomeAssistantZeroconf(port=6053, name="ReachyMini")
249
+ await discovery.register_server()
250
+ _LOGGER.info("ESPHome server: mDNS service registered")
251
+
252
+ try:
253
+ async with server:
254
+ _LOGGER.info("ESPHome server: Server started on port 6053")
255
+ _LOGGER.info("ESPHome server: mDNS service registered for auto-discovery")
256
+
257
+ while not stop_event.is_set():
258
+ await asyncio.sleep(0.1)
259
+ except Exception as e:
260
+ _LOGGER.error(f"ESPHome server: Error in server loop: {e}", exc_info=True)
261
+ finally:
262
+ _LOGGER.info("ESPHome server: Unregistering mDNS service...")
263
+ await discovery.unregister_server()
264
+ _LOGGER.info("ESPHome server: Stopped")
265
+
266
+ try:
267
+ asyncio.run(server_loop())
268
+ except Exception as e:
269
+ _LOGGER.error(f"ESPHome server thread: Fatal error: {e}", exc_info=True)
270
+
271
+
272
+ def _process_audio(state: ServerState):
273
+ """Process audio from microphone."""
274
+ _LOGGER.info("Audio processor thread: Starting...")
275
+
276
+ try:
277
+ # Start media
278
+ _LOGGER.info("Audio processor: Starting media recording...")
279
+ state.music_player._robot.media.start_recording()
280
+ _LOGGER.info("Audio processor: Starting media playback...")
281
+ state.music_player._robot.media.start_playing()
282
+ _LOGGER.info("Audio processor: Media started, waiting 1 second...")
283
+ time.sleep(1)
284
+
285
+ wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
286
+ micro_features: Optional[MicroWakeWordFeatures] = None
287
+ micro_inputs: List[np.ndarray] = []
288
+
289
+ oww_features: Optional[OpenWakeWordFeatures] = None
290
+ oww_inputs: List[np.ndarray] = []
291
+ has_oww = False
292
+
293
+ last_active: Optional[float] = None
294
+
295
+ _LOGGER.info("Audio processor: Audio processing loop started")
296
+
297
+ while True:
298
+ try:
299
+ # Get audio sample from Reachy Mini
300
+ audio_frame = state.music_player._robot.media.get_audio_sample()
301
+
302
+ if audio_frame is not None:
303
+ # Send to satellite if connected
304
+ if state.satellite is not None:
305
+ # Convert to bytes for satellite
306
+ audio_bytes = (audio_frame * 32767.0).astype(np.int16).tobytes()
307
+ state.satellite.handle_audio(audio_bytes)
308
+
309
+ # Update wake word models
310
+ if (not wake_words) or (state.wake_words_changed and state.wake_words):
311
+ state.wake_words_changed = False
312
+ wake_words = [
313
+ ww
314
+ for ww in state.wake_words.values()
315
+ if ww.id in state.active_wake_words
316
+ ]
317
+
318
+ has_oww = False
319
+ for wake_word in wake_words:
320
+ if isinstance(wake_word, OpenWakeWord):
321
+ has_oww = True
322
+
323
+ if micro_features is None:
324
+ micro_features = MicroWakeWordFeatures()
325
+
326
+ if has_oww and (oww_features is None):
327
+ oww_features = OpenWakeWordFeatures.from_builtin()
328
+
329
+ # Process wake words
330
+ if wake_words:
331
+ assert micro_features is not None
332
+ micro_inputs.clear()
333
+ # Convert float32 audio to int16 for microWakeWord
334
+ audio_int16 = (audio_frame * 32767.0).astype(np.int16)
335
+ micro_inputs.extend(micro_features.process_streaming(audio_int16))
336
+
337
+ if has_oww:
338
+ assert oww_features is not None
339
+ oww_inputs.clear()
340
+ oww_inputs.extend(oww_features.process_streaming(audio_frame))
341
+
342
+ for wake_word in wake_words:
343
+ activated = False
344
+ if isinstance(wake_word, MicroWakeWord):
345
+ for micro_input in micro_inputs:
346
+ if wake_word.process_streaming(micro_input):
347
+ activated = True
348
+ elif isinstance(wake_word, OpenWakeWord):
349
+ for oww_input in oww_inputs:
350
+ for prob in wake_word.process_streaming(oww_input):
351
+ if prob > 0.5:
352
+ activated = True
353
+
354
+ if activated:
355
+ now = time.monotonic()
356
+ if (last_active is None) or (
357
+ (now - last_active) > state.refractory_seconds
358
+ ):
359
+ if state.satellite:
360
+ state.satellite.wakeup(wake_word)
361
+ last_active = now
362
+
363
+ # Process stop word
364
+ if state.stop_word is not None:
365
+ stopped = False
366
+ for micro_input in micro_inputs:
367
+ if state.stop_word.process_streaming(micro_input):
368
+ stopped = True
369
+
370
+ if stopped and (state.stop_word.id in state.active_wake_words):
371
+ if state.satellite:
372
+ state.satellite.stop()
373
+
374
+ time.sleep(0.001)
375
+ except Exception as e:
376
+ _LOGGER.error(f"Audio processor: Error in processing loop: {e}", exc_info=True)
377
+ time.sleep(0.1)
378
+ except Exception as e:
379
+ _LOGGER.error(f"Audio processor thread: Fatal error: {e}", exc_info=True)
380
+
381
+
382
+ class ReachyMiniAudioPlayer:
383
+ """Audio player using Reachy Mini's media system."""
384
+
385
+ def __init__(self, robot: ReachyMini):
386
+ self._robot = robot
387
+
388
+ def play(self, audio_source, done_callback=None):
389
+ """Play audio from file or URL."""
390
+ try:
391
+ if isinstance(audio_source, str):
392
+ # Check if it's a URL or file path
393
+ if audio_source.startswith(('http://', 'https://')):
394
+ _LOGGER.info(f"Playing audio from URL: {audio_source}")
395
+ # For URLs, use Reachy Mini's play_sound method
396
+ self._robot.media.play_sound(audio_source)
397
+ else:
398
+ # Load audio file using soundfile
399
+ import soundfile as sf
400
+ data, sr = sf.read(audio_source, dtype='float32')
401
+
402
+ # Resample if needed
403
+ output_sr = self._robot.media.get_output_audio_samplerate()
404
+ if sr != output_sr:
405
+ from scipy.signal import resample
406
+ data = resample(data, int(len(data) * output_sr / sr))
407
+
408
+ # Ensure correct shape for output channels
409
+ output_channels = self._robot.media.get_output_channels()
410
+ if data.ndim == 1 and output_channels > 1:
411
+ data = np.tile(data[:, None], (1, output_channels))
412
+ elif data.ndim == 2 and data.shape[1] < output_channels:
413
+ data = np.tile(data[:, [0]], (1, output_channels))
414
+ elif data.ndim == 2 and data.shape[1] > output_channels:
415
+ data = data[:, :output_channels]
416
+
417
+ # Push to player
418
+ self._robot.media.push_audio_sample(data)
419
+
420
+ if done_callback:
421
+ done_callback()
422
+ except Exception as e:
423
+ _LOGGER.error(f"Error playing audio: {e}")
424
+
425
+ def stop(self):
426
+ """Stop playback."""
427
+ pass
428
+
429
+ def duck(self):
430
+ """Duck volume."""
431
+ pass
432
+
433
+ def unduck(self):
434
+ """Unduck volume."""
435
+ pass
436
+
437
+ def close(self):
438
+ """Close player."""
439
+ pass
src_backup/reachy_mini_ha_voice/entity.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entity management for Home Assistant."""
2
+
3
+ import logging
4
+ from typing import Dict, List, Optional
5
+
6
+ # pylint: disable=no-name-in-module
7
+ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
8
+ ListEntitiesDoneResponse,
9
+ ListEntitiesRequest,
10
+ MediaPlayerCommandRequest,
11
+ )
12
+ from aioesphomeapi.model import MediaPlayerState
13
+
14
+ from .models import Entity
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ class MediaPlayerEntity(Entity):
20
+ """Media player entity for voice assistant."""
21
+
22
+ def __init__(
23
+ self, server, key: int, name: str, object_id: str, music_player, announce_player
24
+ ):
25
+ """Initialize media player entity."""
26
+ super().__init__(key=key, name=name, state="idle", attributes={})
27
+ self.server = server
28
+ self.object_id = object_id
29
+ self.music_player = music_player
30
+ self.announce_player = announce_player
31
+ self._volume = 1.0
32
+ self._position = 0
33
+ self._duration = 0
34
+
35
+ def handle_message(self, msg):
36
+ """Handle a message."""
37
+ if isinstance(msg, ListEntitiesRequest):
38
+ yield self.get_list_entities_response()
39
+ elif isinstance(msg, MediaPlayerCommandRequest):
40
+ self.handle_command(msg)
41
+
42
+ def get_list_entities_response(self):
43
+ """Get list entities response."""
44
+ from aioesphomeapi.api_pb2 import ListEntitiesMediaPlayerResponse
45
+
46
+ return ListEntitiesMediaPlayerResponse(
47
+ object_id=self.object_id,
48
+ key=self.key,
49
+ name=self.name,
50
+ )
51
+
52
+ def handle_command(self, msg):
53
+ """Handle a media player command."""
54
+ if msg.command == MediaPlayerCommandRequest.PLAY:
55
+ if msg.url:
56
+ self.play([msg.url])
57
+ elif msg.command == MediaPlayerCommandRequest.PAUSE:
58
+ self.music_player.stop()
59
+ elif msg.command == MediaPlayerCommandRequest.STOP:
60
+ self.music_player.stop()
61
+ elif msg.command == MediaPlayerCommandRequest.VOLUME_SET:
62
+ self._volume = msg.volume / 255.0
63
+ elif msg.command == MediaPlayerCommandRequest.MUTE:
64
+ self._volume = 0.0 if msg.mute else 1.0
65
+
66
+ def play(self, urls, announcement=False, done_callback=None):
67
+ """Play media."""
68
+ _LOGGER.debug("Playing: %s", urls)
69
+ player = self.announce_player if announcement else self.music_player
70
+
71
+ for url in urls:
72
+ try:
73
+ from urllib.request import urlopen
74
+
75
+ with urlopen(url) as response:
76
+ audio_data = response.read()
77
+ player.play(audio_data)
78
+ except Exception as e:
79
+ _LOGGER.error("Error playing %s: %s", url, e)
80
+
81
+ if done_callback:
82
+ done_callback()
83
+
84
+ def duck(self):
85
+ """Duck the volume."""
86
+ _LOGGER.debug("Ducking media player")
87
+ # Reduce volume by 50%
88
+ # self._volume *= 0.5
89
+
90
+ def unduck(self):
91
+ """Unduck the volume."""
92
+ _LOGGER.debug("Unducking media player")
93
+ # Restore volume
94
+ # self._volume = min(1.0, self._volume * 2.0)
src_backup/reachy_mini_ha_voice/models.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the voice assistant."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from queue import Queue
8
+ from typing import Dict, List, Optional, Set, Union
9
+
10
+ import numpy as np
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class WakeWordType(str, Enum):
16
+ """Type of wake word model."""
17
+
18
+ MICRO_WAKE_WORD = "micro"
19
+ OPEN_WAKE_WORD = "openWakeWord"
20
+
21
+
22
+ @dataclass
23
+ class AvailableWakeWord:
24
+ """Information about an available wake word model."""
25
+
26
+ id: str
27
+ type: WakeWordType
28
+ wake_word: str
29
+ trained_languages: List[str] = field(default_factory=list)
30
+ wake_word_path: Optional[Path] = None
31
+
32
+ def load(self):
33
+ """Load the wake word model."""
34
+ if self.type == WakeWordType.MICRO_WAKE_WORD:
35
+ from pymicro_wakeword import MicroWakeWord
36
+
37
+ return MicroWakeWord.from_config(config_path=self.wake_word_path)
38
+ elif self.type == WakeWordType.OPEN_WAKE_WORD:
39
+ from pyopen_wakeword import OpenWakeWord
40
+
41
+ oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
42
+ setattr(oww_model, "wake_word", self.wake_word)
43
+
44
+ return oww_model
45
+ else:
46
+ raise ValueError(f"Unknown wake word type: {self.type}")
47
+
48
+
49
+ @dataclass
50
+ class Preferences:
51
+ """User preferences."""
52
+
53
+ active_wake_words: List[str] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class ServerState:
58
+ """Shared server state."""
59
+
60
+ name: str
61
+ mac_address: str
62
+ audio_queue: "Queue[Optional[bytes]]"
63
+ entities: List["Entity"]
64
+ available_wake_words: Dict[str, AvailableWakeWord]
65
+ wake_words: Dict[str, Union["MicroWakeWord", "OpenWakeWord"]]
66
+ active_wake_words: Set[str]
67
+ stop_word: "MicroWakeWord"
68
+ music_player: "AudioPlayer"
69
+ tts_player: "AudioPlayer"
70
+ wakeup_sound: str
71
+ timer_finished_sound: str
72
+ preferences: Preferences
73
+ preferences_path: Path
74
+ refractory_seconds: float
75
+ download_dir: Path
76
+ satellite: Optional["VoiceSatelliteProtocol"] = None
77
+ wake_words_changed: bool = False
78
+ reachy_integration: Optional["ReachyMiniIntegration"] = None
79
+ media_player_entity: Optional["MediaPlayerEntity"] = None
80
+
81
+ def save_preferences(self) -> None:
82
+ """Save preferences to file."""
83
+ try:
84
+ import json
85
+
86
+ with open(self.preferences_path, "w", encoding="utf-8") as f:
87
+ json.dump(
88
+ {"active_wake_words": self.preferences.active_wake_words},
89
+ f,
90
+ )
91
+ except Exception as e:
92
+ _LOGGER.error("Error saving preferences: %s", e)
93
+
94
+
95
+ @dataclass
96
+ class Entity:
97
+ """A Home Assistant entity."""
98
+
99
+ key: str
100
+ name: str
101
+ state: str
102
+ attributes: Dict[str, str] = field(default_factory=dict)
103
+
104
+
105
+ class AudioPlayer:
106
+ """Simple audio player using PyAudio."""
107
+
108
+ def __init__(self, device: Optional[int] = None):
109
+ """Initialize audio player."""
110
+ self.device = device
111
+ self._stream = None
112
+ self._pyaudio = None
113
+ self._ducked = False
114
+
115
+ def play(self, audio_data: Union[bytes, str], done_callback=None) -> None:
116
+ """Play audio data or URL."""
117
+ import pyaudio
118
+
119
+ if self._pyaudio is None:
120
+ self._pyaudio = pyaudio.PyAudio()
121
+
122
+ if isinstance(audio_data, str):
123
+ # It's a URL or file path
124
+ try:
125
+ from urllib.request import urlopen
126
+
127
+ if audio_data.startswith("http://") or audio_data.startswith("https://"):
128
+ with urlopen(audio_data) as response:
129
+ audio_data = response.read()
130
+ else:
131
+ # It's a file path
132
+ with open(audio_data, "rb") as f:
133
+ audio_data = f.read()
134
+ except Exception as e:
135
+ _LOGGER.error("Error loading audio: %s", e)
136
+ if done_callback:
137
+ done_callback()
138
+ return
139
+
140
+ # Assume 16-bit PCM, 16kHz, mono
141
+ if self._stream is None:
142
+ self._stream = self._pyaudio.open(
143
+ format=pyaudio.paInt16,
144
+ channels=1,
145
+ rate=16000,
146
+ output=True,
147
+ output_device_index=self.device,
148
+ )
149
+
150
+ self._stream.write(audio_data)
151
+
152
+ if done_callback:
153
+ done_callback()
154
+
155
+ def duck(self) -> None:
156
+ """Duck the volume (reduce by 50%)."""
157
+ self._ducked = True
158
+ # For simple implementation, we just note the state
159
+ # In a full implementation, we would actually reduce the volume
160
+
161
+ def unduck(self) -> None:
162
+ """Unduck the volume (restore to normal)."""
163
+ self._ducked = False
164
+ # For simple implementation, we just note the state
165
+ # In a full implementation, we would actually restore the volume
166
+
167
+ def stop(self) -> None:
168
+ """Stop playing and reset the stream."""
169
+ if self._stream is not None:
170
+ self._stream.stop_stream()
171
+ self._stream.close()
172
+ self._stream = None
173
+
174
+ def close(self) -> None:
175
+ """Close the audio player."""
176
+ self.stop()
177
+ if self._pyaudio is not None:
178
+ self._pyaudio.terminate()
179
+ self._pyaudio = None
src_backup/reachy_mini_ha_voice/reachy_integration.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini integration module."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ _LOGGER = logging.getLogger(__name__)
7
+
8
+
9
+ class ReachyMiniIntegration:
10
+ """Integration with Reachy Mini robot."""
11
+
12
+ def __init__(self):
13
+ """Initialize Reachy Mini integration."""
14
+ self._reachy = None
15
+ self._connected = False
16
+
17
+ def connect(self) -> bool:
18
+ """Connect to Reachy Mini."""
19
+ try:
20
+ # Import reachy-mini SDK
21
+ # This will be installed when running on Reachy Mini
22
+ from reachy_sdk import ReachySDK
23
+
24
+ self._reachy = ReachySDK()
25
+ self._connected = True
26
+ _LOGGER.info("Connected to Reachy Mini")
27
+ return True
28
+ except ImportError:
29
+ _LOGGER.warning("Reachy SDK not available, running in standalone mode")
30
+ return False
31
+ except Exception as e:
32
+ _LOGGER.error("Failed to connect to Reachy Mini: %s", e)
33
+ return False
34
+
35
+ def disconnect(self) -> None:
36
+ """Disconnect from Reachy Mini."""
37
+ if self._reachy:
38
+ try:
39
+ self._reachy.disconnect()
40
+ self._connected = False
41
+ _LOGGER.info("Disconnected from Reachy Mini")
42
+ except Exception as e:
43
+ _LOGGER.error("Error disconnecting from Reachy Mini: %s", e)
44
+
45
+ def on_wake_word_detected(self) -> None:
46
+ """Handle wake word detection."""
47
+ if not self._connected:
48
+ return
49
+
50
+ try:
51
+ # Make Reachy Mini look up
52
+ self._reachy.head.tilt.goto(20, duration=0.5, wait=True)
53
+ self._reachy.head.pan.goto(0, duration=0.5, wait=True)
54
+ _LOGGER.debug("Reachy Mini: Look up on wake word")
55
+ except Exception as e:
56
+ _LOGGER.error("Error moving Reachy Mini on wake word: %s", e)
57
+
58
+ def on_listening(self) -> None:
59
+ """Handle listening state."""
60
+ if not self._connected:
61
+ return
62
+
63
+ try:
64
+ # Subtle head movement to indicate listening
65
+ self._reachy.head.pan.goto(10, duration=0.3, wait=True)
66
+ self._reachy.head.pan.goto(-10, duration=0.6, wait=True)
67
+ self._reachy.head.pan.goto(0, duration=0.3, wait=True)
68
+ _LOGGER.debug("Reachy Mini: Listening head movement")
69
+ except Exception as e:
70
+ _LOGGER.error("Error moving Reachy Mini while listening: %s", e)
71
+
72
+ def on_response(self) -> None:
73
+ """Handle response state."""
74
+ if not self._connected:
75
+ return
76
+
77
+ try:
78
+ # Nod to acknowledge
79
+ self._reachy.head.tilt.goto(10, duration=0.3, wait=True)
80
+ self._reachy.head.tilt.goto(-10, duration=0.6, wait=True)
81
+ self._reachy.head.tilt.goto(0, duration=0.3, wait=True)
82
+ _LOGGER.debug("Reachy Mini: Nod on response")
83
+ except Exception as e:
84
+ _LOGGER.error("Error moving Reachy Mini on response: %s", e)
85
+
86
+ def on_error(self) -> None:
87
+ """Handle error state."""
88
+ if not self._connected:
89
+ return
90
+
91
+ try:
92
+ # Tilt head to indicate error
93
+ self._reachy.head.tilt.goto(-20, duration=0.5, wait=True)
94
+ _LOGGER.debug("Reachy Mini: Tilt head on error")
95
+ except Exception as e:
96
+ _LOGGER.error("Error moving Reachy Mini on error: %s", e)
97
+
98
+ def on_stop(self) -> None:
99
+ """Handle stop command."""
100
+ if not self._connected:
101
+ return
102
+
103
+ try:
104
+ # Shake head to indicate stop
105
+ self._reachy.head.pan.goto(-15, duration=0.3, wait=True)
106
+ self._reachy.head.pan.goto(15, duration=0.6, wait=True)
107
+ self._reachy.head.pan.goto(0, duration=0.3, wait=True)
108
+ _LOGGER.debug("Reachy Mini: Shake head on stop")
109
+ except Exception as e:
110
+ _LOGGER.error("Error moving Reachy Mini on stop: %s", e)
111
+
112
+ def is_connected(self) -> bool:
113
+ """Check if connected to Reachy Mini."""
114
+ return self._connected
src_backup/reachy_mini_ha_voice/satellite.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice satellite protocol."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import posixpath
6
+ import shutil
7
+ import time
8
+ from collections.abc import Iterable
9
+ from typing import Dict, Optional, Set, Union
10
+ from urllib.parse import urlparse, urlunparse
11
+ from urllib.request import urlopen
12
+
13
+ # pylint: disable=no-name-in-module
14
+ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
15
+ DeviceInfoRequest,
16
+ DeviceInfoResponse,
17
+ ListEntitiesDoneResponse,
18
+ ListEntitiesRequest,
19
+ MediaPlayerCommandRequest,
20
+ SubscribeHomeAssistantStatesRequest,
21
+ VoiceAssistantAnnounceFinished,
22
+ VoiceAssistantAnnounceRequest,
23
+ VoiceAssistantAudio,
24
+ VoiceAssistantConfigurationRequest,
25
+ VoiceAssistantConfigurationResponse,
26
+ VoiceAssistantEventResponse,
27
+ VoiceAssistantExternalWakeWord,
28
+ VoiceAssistantRequest,
29
+ VoiceAssistantSetConfiguration,
30
+ VoiceAssistantTimerEventResponse,
31
+ VoiceAssistantWakeWord,
32
+ )
33
+ from aioesphomeapi.model import (
34
+ VoiceAssistantEventType,
35
+ VoiceAssistantFeature,
36
+ VoiceAssistantTimerEventType,
37
+ )
38
+ from google.protobuf import message
39
+ from pymicro_wakeword import MicroWakeWord
40
+ from pyopen_wakeword import OpenWakeWord
41
+
42
+ from .api_server import APIServer
43
+ from .entity import MediaPlayerEntity
44
+ from .models import AvailableWakeWord, ServerState, WakeWordType
45
+
46
+ _LOGGER = logging.getLogger(__name__)
47
+
48
+
49
+ class VoiceSatelliteProtocol(APIServer):
50
+
51
+ def __init__(self, state: ServerState) -> None:
52
+ super().__init__(state.name)
53
+
54
+ self.state = state
55
+ self.state.satellite = self
56
+
57
+ if self.state.media_player_entity is None:
58
+ self.state.media_player_entity = MediaPlayerEntity(
59
+ server=self,
60
+ key=len(state.entities),
61
+ name="Media Player",
62
+ object_id="reachy_mini_ha_voice_media_player",
63
+ music_player=state.music_player,
64
+ announce_player=state.tts_player,
65
+ )
66
+ self.state.entities.append(self.state.media_player_entity)
67
+
68
+ self._is_streaming_audio = False
69
+ self._tts_url: Optional[str] = None
70
+ self._tts_played = False
71
+ self._continue_conversation = False
72
+ self._timer_finished = False
73
+ self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
74
+
75
+ def handle_voice_event(
76
+ self, event_type: VoiceAssistantEventType, data: Dict[str, str]
77
+ ) -> None:
78
+ _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
79
+
80
+ if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
81
+ self._tts_url = data.get("url")
82
+ self._tts_played = False
83
+ self._continue_conversation = False
84
+ elif event_type in (
85
+ VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END,
86
+ VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
87
+ ):
88
+ self._is_streaming_audio = False
89
+ elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
90
+ if data.get("tts_start_streaming") == "1":
91
+ # Start streaming early
92
+ self.play_tts()
93
+ elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
94
+ if data.get("continue_conversation") == "1":
95
+ self._continue_conversation = True
96
+ elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
97
+ self._tts_url = data.get("url")
98
+ self.play_tts()
99
+ elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
100
+ self._is_streaming_audio = False
101
+ if not self._tts_played:
102
+ self._tts_finished()
103
+
104
+ self._tts_played = False
105
+
106
+ # TODO: handle error
107
+
108
+ def handle_timer_event(
109
+ self,
110
+ event_type: VoiceAssistantTimerEventType,
111
+ msg: VoiceAssistantTimerEventResponse,
112
+ ) -> None:
113
+ _LOGGER.debug("Timer event: type=%s", event_type.name)
114
+ if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
115
+ if not self._timer_finished:
116
+ self.state.active_wake_words.add(self.state.stop_word.id)
117
+ self._timer_finished = True
118
+ self.duck()
119
+ self._play_timer_finished()
120
+
121
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
122
+ if isinstance(msg, VoiceAssistantEventResponse):
123
+ # Pipeline event
124
+ data: Dict[str, str] = {}
125
+ for arg in msg.data:
126
+ data[arg.name] = arg.value
127
+
128
+ self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
129
+ elif isinstance(msg, VoiceAssistantAnnounceRequest):
130
+ _LOGGER.debug("Announcing: %s", msg.text)
131
+
132
+ assert self.state.media_player_entity is not None
133
+
134
+ urls = []
135
+ if msg.preannounce_media_id:
136
+ urls.append(msg.preannounce_media_id)
137
+
138
+ urls.append(msg.media_id)
139
+
140
+ self.state.active_wake_words.add(self.state.stop_word.id)
141
+ self._continue_conversation = msg.start_conversation
142
+
143
+ self.duck()
144
+ yield from self.state.media_player_entity.play(
145
+ urls, announcement=True, done_callback=self._tts_finished
146
+ )
147
+ elif isinstance(msg, VoiceAssistantTimerEventResponse):
148
+ self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
149
+ elif isinstance(msg, DeviceInfoRequest):
150
+ yield DeviceInfoResponse(
151
+ uses_password=False,
152
+ name=self.state.name,
153
+ mac_address=self.state.mac_address,
154
+ voice_assistant_feature_flags=(
155
+ VoiceAssistantFeature.VOICE_ASSISTANT
156
+ | VoiceAssistantFeature.API_AUDIO
157
+ | VoiceAssistantFeature.ANNOUNCE
158
+ | VoiceAssistantFeature.START_CONVERSATION
159
+ | VoiceAssistantFeature.TIMERS
160
+ ),
161
+ )
162
+ elif isinstance(
163
+ msg,
164
+ (
165
+ ListEntitiesRequest,
166
+ SubscribeHomeAssistantStatesRequest,
167
+ MediaPlayerCommandRequest,
168
+ ),
169
+ ):
170
+ for entity in self.state.entities:
171
+ yield from entity.handle_message(msg)
172
+
173
+ if isinstance(msg, ListEntitiesRequest):
174
+ yield ListEntitiesDoneResponse()
175
+ elif isinstance(msg, VoiceAssistantConfigurationRequest):
176
+ available_wake_words = [
177
+ VoiceAssistantWakeWord(
178
+ id=ww.id,
179
+ wake_word=ww.wake_word,
180
+ trained_languages=ww.trained_languages,
181
+ )
182
+ for ww in self.state.available_wake_words.values()
183
+ ]
184
+
185
+ for eww in msg.external_wake_words:
186
+ if eww.model_type != "micro":
187
+ continue
188
+
189
+ available_wake_words.append(
190
+ VoiceAssistantWakeWord(
191
+ id=eww.id,
192
+ wake_word=eww.wake_word,
193
+ trained_languages=eww.trained_languages,
194
+ )
195
+ )
196
+
197
+ self._external_wake_words[eww.id] = eww
198
+
199
+ yield VoiceAssistantConfigurationResponse(
200
+ available_wake_words=available_wake_words,
201
+ active_wake_words=[
202
+ ww.id
203
+ for ww in self.state.wake_words.values()
204
+ if ww.id in self.state.active_wake_words
205
+ ],
206
+ max_active_wake_words=2,
207
+ )
208
+ _LOGGER.info("Connected to Home Assistant")
209
+ elif isinstance(msg, VoiceAssistantSetConfiguration):
210
+ # Change active wake words
211
+ active_wake_words: Set[str] = set()
212
+
213
+ for wake_word_id in msg.active_wake_words:
214
+ if wake_word_id in self.state.wake_words:
215
+ # Already active
216
+ active_wake_words.add(wake_word_id)
217
+ continue
218
+
219
+ model_info = self.state.available_wake_words.get(wake_word_id)
220
+ if not model_info:
221
+ # Check external wake words (may require download)
222
+ external_wake_word = self._external_wake_words.get(wake_word_id)
223
+ if not external_wake_word:
224
+ continue
225
+
226
+ model_info = self._download_external_wake_word(external_wake_word)
227
+ if not model_info:
228
+ continue
229
+
230
+ self.state.available_wake_words[wake_word_id] = model_info
231
+
232
+ _LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
233
+ self.state.wake_words[wake_word_id] = model_info.load()
234
+
235
+ _LOGGER.info("Wake word set: %s", wake_word_id)
236
+ active_wake_words.add(wake_word_id)
237
+ break
238
+
239
+ self.state.active_wake_words = active_wake_words
240
+ _LOGGER.debug("Active wake words: %s", active_wake_words)
241
+
242
+ self.state.preferences.active_wake_words = list(active_wake_words)
243
+ self.state.save_preferences()
244
+ self.state.wake_words_changed = True
245
+
246
+ def handle_audio(self, audio_chunk: bytes) -> None:
247
+
248
+ if not self._is_streaming_audio:
249
+ return
250
+
251
+ self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
252
+
253
+ def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
254
+ if self._timer_finished:
255
+ # Stop timer instead
256
+ self._timer_finished = False
257
+ self.state.tts_player.stop()
258
+ _LOGGER.debug("Stopping timer finished sound")
259
+ return
260
+
261
+ wake_word_phrase = wake_word.wake_word
262
+ _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
263
+ self.send_messages(
264
+ [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
265
+ )
266
+ self.duck()
267
+ self._is_streaming_audio = True
268
+ try:
269
+ self.state.tts_player.play(self.state.wakeup_sound)
270
+ except Exception as e:
271
+ _LOGGER.warning("Failed to play wakeup sound: %s", e)
272
+
273
+ def stop(self) -> None:
274
+ self.state.active_wake_words.discard(self.state.stop_word.id)
275
+ self.state.tts_player.stop()
276
+
277
+ if self._timer_finished:
278
+ self._timer_finished = False
279
+ _LOGGER.debug("Stopping timer finished sound")
280
+ else:
281
+ _LOGGER.debug("TTS response stopped manually")
282
+ self._tts_finished()
283
+
284
+ def play_tts(self) -> None:
285
+ if (not self._tts_url) or self._tts_played:
286
+ return
287
+
288
+ self._tts_played = True
289
+ _LOGGER.debug("Playing TTS response: %s", self._tts_url)
290
+
291
+ self.state.active_wake_words.add(self.state.stop_word.id)
292
+ self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
293
+
294
+ def duck(self) -> None:
295
+ _LOGGER.debug("Ducking music")
296
+ self.state.music_player.duck()
297
+
298
+ def unduck(self) -> None:
299
+ _LOGGER.debug("Unducking music")
300
+ self.state.music_player.unduck()
301
+
302
+ def _tts_finished(self) -> None:
303
+ self.state.active_wake_words.discard(self.state.stop_word.id)
304
+ self.send_messages([VoiceAssistantAnnounceFinished()])
305
+
306
+ if self._continue_conversation:
307
+ self.send_messages([VoiceAssistantRequest(start=True)])
308
+ self._is_streaming_audio = True
309
+ _LOGGER.debug("Continuing conversation")
310
+ else:
311
+ self.unduck()
312
+
313
+ _LOGGER.debug("TTS response finished")
314
+
315
+ def _play_timer_finished(self) -> None:
316
+ if not self._timer_finished:
317
+ self.unduck()
318
+ return
319
+
320
+ try:
321
+ self.state.tts_player.play(
322
+ self.state.timer_finished_sound,
323
+ done_callback=lambda: time.sleep(1.0) or self._play_timer_finished(),
324
+ )
325
+ except Exception as e:
326
+ _LOGGER.warning("Failed to play timer finished sound: %s", e)
327
+ self.unduck()
328
+
329
+ def connection_lost(self, exc):
330
+ super().connection_lost(exc)
331
+ _LOGGER.info("Disconnected from Home Assistant")
332
+
333
+ def _download_external_wake_word(
334
+ self, external_wake_word: VoiceAssistantExternalWakeWord
335
+ ) -> Optional[AvailableWakeWord]:
336
+ eww_dir = self.state.download_dir / "external_wake_words"
337
+ eww_dir.mkdir(parents=True, exist_ok=True)
338
+
339
+ config_path = eww_dir / f"{external_wake_word.id}.json"
340
+ should_download_config = not config_path.exists()
341
+
342
+ # Check if we need to download the model file
343
+ model_path = eww_dir / f"{external_wake_word.id}.tflite"
344
+ should_download_model = True
345
+ if model_path.exists():
346
+ model_size = model_path.stat().st_size
347
+ if model_size == external_wake_word.model_size:
348
+ with open(model_path, "rb") as model_file:
349
+ model_hash = hashlib.sha256(model_file.read()).hexdigest()
350
+
351
+ if model_hash == external_wake_word.model_hash:
352
+ should_download_model = False
353
+ _LOGGER.debug(
354
+ "Model size and hash match for %s. Skipping download.",
355
+ external_wake_word.id,
356
+ )
357
+
358
+ if should_download_config or should_download_model:
359
+ # Download config
360
+ _LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path)
361
+ with urlopen(external_wake_word.url) as request:
362
+ if request.status != 200:
363
+ _LOGGER.warning(
364
+ "Failed to download: %s, status=%s",
365
+ external_wake_word.url,
366
+ request.status,
367
+ )
368
+ return None
369
+
370
+ with open(config_path, "wb") as model_file:
371
+ shutil.copyfileobj(request, model_file)
372
+
373
+ if should_download_model:
374
+ # Download model file
375
+ parsed_url = urlparse(external_wake_word.url)
376
+ parsed_url = parsed_url._replace(
377
+ path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name)
378
+ )
379
+ model_url = urlunparse(parsed_url)
380
+
381
+ _LOGGER.debug("Downloading %s to %s", model_url, model_path)
382
+ with urlopen(model_url) as request:
383
+ if request.status != 200:
384
+ _LOGGER.warning(
385
+ "Failed to download: %s, status=%s", model_url, request.status
386
+ )
387
+ return None
388
+
389
+ with open(model_path, "wb") as model_file:
390
+ shutil.copyfileobj(request, model_file)
391
+
392
+ return AvailableWakeWord(
393
+ id=external_wake_word.id,
394
+ type=WakeWordType.MICRO_WAKE_WORD,
395
+ wake_word=external_wake_word.wake_word,
396
+ trained_languages=external_wake_word.trained_languages,
397
+ wake_word_path=config_path,
398
+ )
src_backup/reachy_mini_ha_voice/static/index.html ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Reachy Mini HA Voice – Settings</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <header>
12
+ <h1>🤖 Reachy Mini Home Assistant Voice Assistant</h1>
13
+ <p class="subtitle">基于 ESPHome 协议的语音助手</p>
14
+ </header>
15
+
16
+ <main>
17
+ <section class="status">
18
+ <h2>状态</h2>
19
+ <div class="status-indicator">
20
+ <span class="status-dot" id="statusDot"></span>
21
+ <span class="status-text" id="statusText">未运行</span>
22
+ </div>
23
+ </section>
24
+
25
+ <section class="info">
26
+ <h2>信息</h2>
27
+ <div class="info-card">
28
+ <h3>🎤 唤醒词</h3>
29
+ <p>默认唤醒词: "Okay Nabu"</p>
30
+ <p>支持多个唤醒词: Alexa, Hey Jarvis, Hey Home Assistant 等</p>
31
+ </div>
32
+
33
+ <div class="info-card">
34
+ <h3>🔌 连接</h3>
35
+ <p>ESPHome 端口: 6053</p>
36
+ <p>自动发现: 已启用 (mDNS/Zeroconf)</p>
37
+ </div>
38
+
39
+ <div class="info-card">
40
+ <h3>🏠 Home Assistant</h3>
41
+ <p>1. 在 Home Assistant 中添加 ESPHome 集成</p>
42
+ <p>2. 输入 Reachy Mini 的 IP 地址</p>
43
+ <p>3. 端口: 6053</p>
44
+ </div>
45
+ </section>
46
+
47
+ <section class="actions">
48
+ <h2>操作</h2>
49
+ <button class="btn btn-primary" id="startBtn">启动</button>
50
+ <button class="btn btn-secondary" id="stopBtn">停止</button>
51
+ </section>
52
+ </main>
53
+
54
+ <footer>
55
+ <p>基于 <a href="https://github.com/OHF-Voice/linux-voice-assistant">OHF-Voice/linux-voice-assistant</a> 修改</p>
56
+ </footer>
57
+ </div>
58
+
59
+ <script src="/static/main.js"></script>
60
+ </body>
61
+ </html>
src_backup/reachy_mini_ha_voice/static/main.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reachy Mini Home Assistant Voice Assistant UI
2
+
3
+ document.addEventListener('DOMContentLoaded', function() {
4
+ const statusDot = document.getElementById('statusDot');
5
+ const statusText = document.getElementById('statusText');
6
+ const startBtn = document.getElementById('startBtn');
7
+ const stopBtn = document.getElementById('stopBtn');
8
+
9
+ // Check initial status
10
+ checkStatus();
11
+
12
+ // Start button click handler
13
+ startBtn.addEventListener('click', function() {
14
+ // This will be handled by the Reachy Mini dashboard
15
+ console.log('Start button clicked');
16
+ });
17
+
18
+ // Stop button click handler
19
+ stopBtn.addEventListener('click', function() {
20
+ // This will be handled by the Reachy Mini dashboard
21
+ console.log('Stop button clicked');
22
+ });
23
+
24
+ // Check status periodically
25
+ setInterval(checkStatus, 5000);
26
+ });
27
+
28
+ function checkStatus() {
29
+ // In a real implementation, this would check the actual status
30
+ // For now, we'll just update the UI
31
+ const statusDot = document.getElementById('statusDot');
32
+ const statusText = document.getElementById('statusText');
33
+
34
+ // Simulate status check
35
+ // In production, this would make an API call to get the actual status
36
+ const isRunning = false; // Change this based on actual status
37
+
38
+ if (isRunning) {
39
+ statusDot.classList.add('running');
40
+ statusDot.classList.remove('stopped');
41
+ statusText.textContent = '运行中';
42
+ } else {
43
+ statusDot.classList.add('stopped');
44
+ statusDot.classList.remove('running');
45
+ statusText.textContent = '未运行';
46
+ }
47
+ }
src_backup/reachy_mini_ha_voice/static/style.css ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
9
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
10
+ min-height: 100vh;
11
+ padding: 20px;
12
+ }
13
+
14
+ .container {
15
+ max-width: 800px;
16
+ margin: 0 auto;
17
+ background: white;
18
+ border-radius: 20px;
19
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
20
+ overflow: hidden;
21
+ }
22
+
23
+ header {
24
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
25
+ color: white;
26
+ padding: 40px;
27
+ text-align: center;
28
+ }
29
+
30
+ header h1 {
31
+ font-size: 2.5em;
32
+ margin-bottom: 10px;
33
+ }
34
+
35
+ .subtitle {
36
+ font-size: 1.2em;
37
+ opacity: 0.9;
38
+ }
39
+
40
+ main {
41
+ padding: 40px;
42
+ }
43
+
44
+ section {
45
+ margin-bottom: 40px;
46
+ }
47
+
48
+ h2 {
49
+ color: #333;
50
+ margin-bottom: 20px;
51
+ font-size: 1.8em;
52
+ }
53
+
54
+ .status-indicator {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 15px;
58
+ padding: 20px;
59
+ background: #f5f5f5;
60
+ border-radius: 10px;
61
+ }
62
+
63
+ .status-dot {
64
+ width: 20px;
65
+ height: 20px;
66
+ border-radius: 50%;
67
+ background: #ccc;
68
+ transition: background 0.3s;
69
+ }
70
+
71
+ .status-dot.running {
72
+ background: #4CAF50;
73
+ animation: pulse 2s infinite;
74
+ }
75
+
76
+ .status-dot.stopped {
77
+ background: #f44336;
78
+ }
79
+
80
+ @keyframes pulse {
81
+ 0%, 100% {
82
+ opacity: 1;
83
+ }
84
+ 50% {
85
+ opacity: 0.5;
86
+ }
87
+ }
88
+
89
+ .status-text {
90
+ font-size: 1.2em;
91
+ color: #666;
92
+ }
93
+
94
+ .info-card {
95
+ background: #f9f9f9;
96
+ padding: 20px;
97
+ border-radius: 10px;
98
+ margin-bottom: 15px;
99
+ border-left: 4px solid #667eea;
100
+ }
101
+
102
+ .info-card h3 {
103
+ color: #667eea;
104
+ margin-bottom: 10px;
105
+ }
106
+
107
+ .info-card p {
108
+ color: #666;
109
+ line-height: 1.6;
110
+ margin-bottom: 5px;
111
+ }
112
+
113
+ .actions {
114
+ text-align: center;
115
+ }
116
+
117
+ .btn {
118
+ padding: 15px 40px;
119
+ font-size: 1.1em;
120
+ border: none;
121
+ border-radius: 10px;
122
+ cursor: pointer;
123
+ transition: all 0.3s;
124
+ margin: 0 10px;
125
+ }
126
+
127
+ .btn-primary {
128
+ background: #667eea;
129
+ color: white;
130
+ }
131
+
132
+ .btn-primary:hover {
133
+ background: #5568d3;
134
+ transform: translateY(-2px);
135
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
136
+ }
137
+
138
+ .btn-secondary {
139
+ background: #f44336;
140
+ color: white;
141
+ }
142
+
143
+ .btn-secondary:hover {
144
+ background: #da190b;
145
+ transform: translateY(-2px);
146
+ box-shadow: 0 5px 15px rgba(244, 67, 54, 0.4);
147
+ }
148
+
149
+ footer {
150
+ background: #f5f5f5;
151
+ padding: 20px;
152
+ text-align: center;
153
+ color: #666;
154
+ }
155
+
156
+ footer a {
157
+ color: #667eea;
158
+ text-decoration: none;
159
+ }
160
+
161
+ footer a:hover {
162
+ text-decoration: underline;
163
+ }
src_backup/reachy_mini_ha_voice/util.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions."""
2
+
3
+ import logging
4
+ import socket
5
+
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def get_mac() -> str:
11
+ """Get the MAC address of the first network interface."""
12
+ try:
13
+ mac = ":".join(
14
+ f"{byte:02x}" for byte in socket.gethostbyname(socket.gethostname()).encode()
15
+ )
16
+ except Exception:
17
+ mac = "00:00:00:00:00:00"
18
+
19
+ return mac
src_backup/reachy_mini_ha_voice/wakewords/alexa.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Alexa",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "alexa.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.9,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22348,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/choo_choo_homie.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Choo Choo Homie",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "choo_choo_homie.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
+ }
src_backup/reachy_mini_ha_voice/wakewords/hey_home_assistant.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Home Assistant",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "hey_home_assistant.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 30000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/hey_jarvis.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Jarvis",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_jarvis.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/hey_luna.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "hey_luna",
4
+ "author": "adamlonsdale",
5
+ "website": "https://github.com/adamlonsdale",
6
+ "model": "hey_luna.tflite",
7
+ "version": 2,
8
+ "trained_languages": ["en"],
9
+ "micro": {
10
+ "probability_cutoff": 0.63,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/hey_mycroft.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Mycroft",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_mycroft.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.95,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 23628,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/okay_computer.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Computer",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "okay_computer.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
+ }
src_backup/reachy_mini_ha_voice/wakewords/okay_nabu.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Nabu",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "okay_nabu.tflite",
7
+ "trained_languages": ["en","nl","fr","de","it","es","sv"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.85,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 37000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/alexa_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Alexa",
4
+ "model": "alexa_v0.1.tflite"
5
+ }
src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_jarvis_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Jarvis",
4
+ "model": "hey_jarvis_v0.1.tflite"
5
+ }
src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_mycroft_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Mycroft",
4
+ "model": "hey_mycroft_v0.1.tflite"
5
+ }
src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/hey_rhasspy_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Rhasspy",
4
+ "model": "hey_rhasspy_v0.1.tflite"
5
+ }
src_backup/reachy_mini_ha_voice/wakewords/openWakeWord/ok_nabu_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Okay Nabu",
4
+ "model": "ok_nabu_v0.1.tflite"
5
+ }
src_backup/reachy_mini_ha_voice/wakewords/stop.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Stop",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "stop.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.5,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 21000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
src_backup/reachy_mini_ha_voice/zeroconf.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zeroconf/mDNS service discovery for Home Assistant."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ from typing import Optional
7
+
8
+ from zeroconf import IPVersion, ServiceInfo, Zeroconf
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class HomeAssistantZeroconf:
14
+ """Zeroconf service discovery for Home Assistant."""
15
+
16
+ def __init__(self, port: int, name: str):
17
+ """Initialize zeroconf discovery."""
18
+ self.port = port
19
+ self.name = name
20
+ self._zeroconf: Optional[Zeroconf] = None
21
+ self._service_info: Optional[ServiceInfo] = None
22
+
23
+ async def register_server(self) -> None:
24
+ """Register the server with zeroconf."""
25
+ try:
26
+ self._zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
27
+
28
+ # Get local IP address
29
+ hostname = socket.gethostname()
30
+ local_ip = socket.gethostbyname(hostname)
31
+
32
+ # Create service info
33
+ service_type = "_esphomelib._tcp.local."
34
+ service_name = f"{self.name}._esphomelib._tcp.local."
35
+
36
+ self._service_info = ServiceInfo(
37
+ service_type,
38
+ name=service_name,
39
+ addresses=[socket.inet_aton(local_ip)],
40
+ port=self.port,
41
+ properties={
42
+ "version": "1.0",
43
+ "platform": "ReachyMini",
44
+ },
45
+ server=f"{hostname}.local.",
46
+ )
47
+
48
+ await asyncio.get_event_loop().run_in_executor(
49
+ None, self._zeroconf.register_service, self._service_info
50
+ )
51
+
52
+ _LOGGER.info(
53
+ "Registered zeroconf service: %s at %s:%s",
54
+ service_name,
55
+ local_ip,
56
+ self.port,
57
+ )
58
+ except Exception as e:
59
+ _LOGGER.error("Failed to register zeroconf service: %s", e)
60
+
61
+ async def unregister_server(self) -> None:
62
+ """Unregister the server from zeroconf."""
63
+ if self._zeroconf and self._service_info:
64
+ await asyncio.get_event_loop().run_in_executor(
65
+ None, self._zeroconf.unregister_service, self._service_info
66
+ )
67
+ self._zeroconf.close()
68
+ _LOGGER.info("Unregistered zeroconf service")
temp_conversation_app ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit fc5123651cf50582d2b073b1779b37c26232e5cc
temp_reachy_mini ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 18823bf22e5fc31420f3bfe3c2483ef135a32391
temp_reachy_mini_examples ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 18823bf22e5fc31420f3bfe3c2483ef135a32391
test_official_app ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit fc5123651cf50582d2b073b1779b37c26232e5cc