Desmond-Dong commited on
Commit
80662a9
·
1 Parent(s): dadb1e9

"fix-duplicate-entity-registration-and-camera-integration"

Browse files
PROJECT_PLAN.md CHANGED
@@ -48,7 +48,7 @@
48
  │ Home Assistant │
49
  │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
50
  │ │ STT Engine │ │ Intent │ │ TTS Engine │ │
51
- │ │ (Whisper) │ │ Processing │ │ (Piper/Cloud) │ │
52
  │ └─────────────┘ └─────────────┘ └─────────────────────┘ │
53
  └─────────────────────────────────────────────────────────────┘
54
  ```
@@ -87,11 +87,12 @@ reachy_mini_ha_voice/
87
  │ ├── voice_assistant.py # 语音助手服务
88
  │ ├── satellite.py # ESPHome 协议处理
89
  │ ├── audio_player.py # 音频播放器
 
90
  │ ├── motion.py # 运动控制
91
  │ ├── models.py # 数据模型
92
  │ ├── entity.py # ESPHome 基础实体
93
- │ ├── entity_extensions.py # 扩展实体类型 (NEW)
94
- │ ├── reachy_controller.py # Reachy Mini 控制器包装 (NEW)
95
  │ ├── api_server.py # API 服务器
96
  │ ├── zeroconf.py # mDNS 发现
97
  │ └── util.py # 工具函数
@@ -107,7 +108,6 @@ reachy_mini_ha_voice/
107
  │ └── timer_finished.flac
108
  ├── pyproject.toml # 项目配置
109
  ├── README.md # 说明文档
110
- ├── ENTITIES.md # 实体使用文档 (NEW)
111
  └── PROJECT_PLAN.md # 项目计划
112
  ```
113
 
@@ -222,6 +222,23 @@ dependencies = [
222
  | `Sensor` | `imu_gyro_z` | `mini.imu["gyroscope"][2]` | Z 轴角速度 (rad/s) |
223
  | `Sensor` | `imu_temperature` | `mini.imu["temperature"]` | IMU 温度 (°C) |
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  > **注意**: 头部位置 (x/y/z) 和角度 (roll/pitch/yaw)、身体偏航角、天线角度都是**可控制**的实体,
226
  > 使用 `Number` 类型实现双向控制。设置新值时调用 `goto_target()`,读取当前值时调用 `get_current_head_pose()` 等。
227
 
@@ -264,18 +281,43 @@ dependencies = [
264
  - [x] `imu_gyro_x/y/z` - 陀螺仪
265
  - [x] `imu_temperature` - IMU 温度
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  ---
268
 
269
  ## 🎉 所有实体已完成!
270
 
271
- **总计:30+ 个实体**
272
- - Phase 1: 4 个实体
273
- - Phase 2: 4 个实体
274
- - Phase 3: 9 个实体
275
- - Phase 4: 3 个实体
276
- - Phase 5: 2 个实体
277
- - Phase 6: 6 个实体
278
- - Phase 7: 7 个实体
 
 
 
 
 
279
 
280
  ### SDK 数据结构参考
281
 
 
48
  │ Home Assistant │
49
  │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
50
  │ │ STT Engine │ │ Intent │ │ TTS Engine │ │
51
+ │ │ │ │ Processing │ │ │ │
52
  │ └─────────────┘ └─────────────┘ └─────────────────────┘ │
53
  └─────────────────────────────────────────────────────────────┘
54
  ```
 
87
  │ ├── voice_assistant.py # 语音助手服务
88
  │ ├── satellite.py # ESPHome 协议处理
89
  │ ├── audio_player.py # 音频播放器
90
+ │ ├── camera_server.py # MJPEG 摄像头流服务器
91
  │ ├── motion.py # 运动控制
92
  │ ├── models.py # 数据模型
93
  │ ├── entity.py # ESPHome 基础实体
94
+ │ ├── entity_extensions.py # 扩展实体类型
95
+ │ ├── reachy_controller.py # Reachy Mini 控制器包装
96
  │ ├── api_server.py # API 服务器
97
  │ ├── zeroconf.py # mDNS 发现
98
  │ └── util.py # 工具函数
 
108
  │ └── timer_finished.flac
109
  ├── pyproject.toml # 项目配置
110
  ├── README.md # 说明文档
 
111
  └── PROJECT_PLAN.md # 项目计划
112
  ```
113
 
 
222
  | `Sensor` | `imu_gyro_z` | `mini.imu["gyroscope"][2]` | Z 轴角速度 (rad/s) |
223
  | `Sensor` | `imu_temperature` | `mini.imu["temperature"]` | IMU 温度 (°C) |
224
 
225
+ #### Phase 8-12: 扩展功能
226
+
227
+ | ESPHome 实体类型 | 名称 | 说明 |
228
+ |-----------------|------|------|
229
+ | `Select` | `emotion` | 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust) |
230
+ | `Number` | `microphone_volume` | 麦克风音量 (0-100%) |
231
+ | `Camera` | `camera` | ESPHome Camera 实体(实时预览) |
232
+ | `Number` | `led_brightness` | LED 亮度 (0-100%) |
233
+ | `Select` | `led_effect` | LED 效果 (off/solid/breathing/rainbow/doa) |
234
+ | `Number` | `led_color_r` | LED 红色分量 (0-255) |
235
+ | `Number` | `led_color_g` | LED 绿色分量 (0-255) |
236
+ | `Number` | `led_color_b` | LED 蓝色分量 (0-255) |
237
+ | `Switch` | `agc_enabled` | 自动增益控制开关 |
238
+ | `Number` | `agc_max_gain` | AGC 最大增益 (0-30 dB) |
239
+ | `Number` | `noise_suppression` | 噪声抑制级别 (0-100%) |
240
+ | `Binary Sensor` | `echo_cancellation_converged` | 回声消除收敛状态 |
241
+
242
  > **注意**: 头部位置 (x/y/z) 和角度 (roll/pitch/yaw)、身体偏航角、天线角度都是**可控制**的实体,
243
  > 使用 `Number` 类型实现双向控制。设置新值时调用 `goto_target()`,读取当前值时调用 `get_current_head_pose()` 等。
244
 
 
281
  - [x] `imu_gyro_x/y/z` - 陀螺仪
282
  - [x] `imu_temperature` - IMU 温度
283
 
284
+ 8. **Phase 8 - 表情控制** ✅ **已完成**
285
+ - [x] `emotion` - 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust)
286
+
287
+ 9. **Phase 9 - 音频控制** ✅ **已完成**
288
+ - [x] `microphone_volume` - 麦克风音量控制 (0-100%)
289
+
290
+ 10. **Phase 10 - 摄像头集成** ✅ **已完成**
291
+ - [x] `camera` - ESPHome Camera 实体(实时预览)
292
+
293
+ 11. **Phase 11 - LED 控制** ✅ **已完成**
294
+ - [x] `led_brightness` - LED 亮度 (0-100%)
295
+ - [x] `led_effect` - LED 效果 (off/solid/breathing/rainbow/doa)
296
+ - [x] `led_color_r/g/b` - LED RGB 颜色 (0-255)
297
+
298
+ 12. **Phase 12 - 音频处理参数** ✅ **已完成**
299
+ - [x] `agc_enabled` - 自动增益控制开关
300
+ - [x] `agc_max_gain` - AGC 最大增益 (0-30 dB)
301
+ - [x] `noise_suppression` - 噪声抑制级别 (0-100%)
302
+ - [x] `echo_cancellation_converged` - 回声消除收敛状态(只读)
303
+
304
  ---
305
 
306
  ## 🎉 所有实体已完成!
307
 
308
+ **总计:45+ 个实体**
309
+ - Phase 1: 4 个实体 (基础状态与音量)
310
+ - Phase 2: 4 个实体 (电机控制)
311
+ - Phase 3: 9 个实体 (姿态控制)
312
+ - Phase 4: 3 个实体 (注视控制)
313
+ - Phase 5: 2 个实体 (音频传感器)
314
+ - Phase 6: 6 个实体 (诊断信息)
315
+ - Phase 7: 7 个实体 (IMU 传感器)
316
+ - Phase 8: 1 个实体 (表情控制)
317
+ - Phase 9: 1 个实体 (麦克风音���)
318
+ - Phase 10: 1 个实体 (摄像头)
319
+ - Phase 11: 5 个实体 (LED 控制)
320
+ - Phase 12: 4 个实体 (音频处理参数)
321
 
322
  ### SDK 数据结构参考
323
 
README.md CHANGED
@@ -78,7 +78,7 @@ Additional wake words can be configured through Home Assistant.
78
 
79
  ## ESPHome Entities
80
 
81
- This application exposes 30+ entities to Home Assistant for complete robot control:
82
 
83
  ### Status & Control (Phase 1)
84
  - **Daemon State** - Monitor robot daemon status
@@ -118,7 +118,25 @@ This application exposes 30+ entities to Home Assistant for complete robot contr
118
  - **Gyroscope** - X/Y/Z angular velocity (rad/s)
119
  - **Temperature** - IMU temperature (°C)
120
 
121
- 📖 **[View Complete Entity Documentation](ENTITIES.md)** - Includes usage examples, automations, and Lovelace dashboard configurations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  ## How It Works
124
 
@@ -145,6 +163,7 @@ This application exposes 30+ entities to Home Assistant for complete robot contr
145
  reachy_mini_ha_voice/
146
  ├── reachy_mini_ha_voice/
147
  │ ├── __init__.py
 
148
  │ ├── main.py # App entry point
149
  │ ├── voice_assistant.py # Voice assistant service
150
  │ ├── camera_server.py # MJPEG camera streaming server
@@ -152,7 +171,9 @@ reachy_mini_ha_voice/
152
  │ ├── audio_player.py # Audio playback
153
  │ ├── motion.py # Motion control
154
  │ ├── models.py # Data models
155
- │ ├── entity.py # ESPHome entities
 
 
156
  │ ├── api_server.py # API server
157
  │ ├── zeroconf.py # mDNS discovery
158
  │ └── util.py # Utilities
 
78
 
79
  ## ESPHome Entities
80
 
81
+ This application exposes 45+ entities to Home Assistant for complete robot control:
82
 
83
  ### Status & Control (Phase 1)
84
  - **Daemon State** - Monitor robot daemon status
 
118
  - **Gyroscope** - X/Y/Z angular velocity (rad/s)
119
  - **Temperature** - IMU temperature (°C)
120
 
121
+ ### Emotion Control (Phase 8)
122
+ - **Emotion** - Select emotion (Happy/Sad/Angry/Fear/Surprise/Disgust)
123
+
124
+ ### Audio Control (Phase 9)
125
+ - **Microphone Volume** - Control microphone input level (0-100%)
126
+
127
+ ### Camera (Phase 10)
128
+ - **Camera** - ESPHome Camera entity with live preview in Home Assistant
129
+
130
+ ### LED Control (Phase 11)
131
+ - **LED Brightness** - Control LED brightness (0-100%)
132
+ - **LED Effect** - Select LED effect (off/solid/breathing/rainbow/doa)
133
+ - **LED Color R/G/B** - Control LED color (0-255 per channel)
134
+
135
+ ### Audio Processing (Phase 12)
136
+ - **AGC Enabled** - Toggle automatic gain control
137
+ - **AGC Max Gain** - Set maximum AGC gain (0-30 dB)
138
+ - **Noise Suppression** - Set noise suppression level (0-100%)
139
+ - **Echo Cancellation Converged** - Monitor echo cancellation status
140
 
141
  ## How It Works
142
 
 
163
  reachy_mini_ha_voice/
164
  ├── reachy_mini_ha_voice/
165
  │ ├── __init__.py
166
+ │ ├── __main__.py # CLI entry point
167
  │ ├── main.py # App entry point
168
  │ ├── voice_assistant.py # Voice assistant service
169
  │ ├── camera_server.py # MJPEG camera streaming server
 
171
  │ ├── audio_player.py # Audio playback
172
  │ ├── motion.py # Motion control
173
  │ ├── models.py # Data models
174
+ │ ├── entity.py # ESPHome base entities
175
+ │ ├── entity_extensions.py # Extended entity types
176
+ │ ├── reachy_controller.py # Reachy Mini controller wrapper
177
  │ ├── api_server.py # API server
178
  │ ├── zeroconf.py # mDNS discovery
179
  │ └── util.py # Utilities
old_entity_extensions.py DELETED
@@ -1,283 +0,0 @@
1
- """Extended ESPHome entity types for Reachy Mini control."""
2
-
3
- from collections.abc import Iterable
4
- from typing import Callable, List, Optional
5
- import logging
6
-
7
- from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
8
- ListEntitiesButtonResponse,
9
- ListEntitiesRequest,
10
- ListEntitiesSelectResponse,
11
- ListEntitiesSensorResponse,
12
- ListEntitiesSwitchResponse,
13
- ButtonCommandRequest,
14
- SelectCommandRequest,
15
- SelectStateResponse,
16
- SensorStateResponse,
17
- SubscribeHomeAssistantStatesRequest,
18
- SubscribeStatesRequest,
19
- SwitchCommandRequest,
20
- SwitchStateResponse,
21
- )
22
- from google.protobuf import message
23
-
24
- from .api_server import APIServer
25
- from .entity import ESPHomeEntity
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
-
30
- class SensorStateClass:
31
- """ESPHome SensorStateClass enum values."""
32
- NONE = 0
33
- MEASUREMENT = 1
34
- TOTAL_INCREASING = 2
35
- TOTAL = 3
36
-
37
-
38
- class SensorEntity(ESPHomeEntity):
39
- """Sensor entity for ESPHome (read-only numeric values)."""
40
-
41
- def __init__(
42
- self,
43
- server: APIServer,
44
- key: int,
45
- name: str,
46
- object_id: str,
47
- icon: str = "",
48
- unit_of_measurement: str = "",
49
- accuracy_decimals: int = 2,
50
- device_class: str = "",
51
- state_class: int = SensorStateClass.NONE,
52
- value_getter: Optional[Callable[[], float]] = None,
53
- ) -> None:
54
- ESPHomeEntity.__init__(self, server)
55
- self.key = key
56
- self.name = name
57
- self.object_id = object_id
58
- self.icon = icon
59
- self.unit_of_measurement = unit_of_measurement
60
- self.accuracy_decimals = accuracy_decimals
61
- self.device_class = device_class
62
- # Convert string state_class to int if needed (for backward compatibility)
63
- if isinstance(state_class, str):
64
- state_class_map = {
65
- "": SensorStateClass.NONE,
66
- "measurement": SensorStateClass.MEASUREMENT,
67
- "total_increasing": SensorStateClass.TOTAL_INCREASING,
68
- "total": SensorStateClass.TOTAL,
69
- }
70
- self.state_class = state_class_map.get(state_class.lower(), SensorStateClass.NONE)
71
- else:
72
- self.state_class = state_class
73
- self._value_getter = value_getter
74
- self._value = 0.0
75
-
76
- @property
77
- def value(self) -> float:
78
- if self._value_getter:
79
- return self._value_getter()
80
- return self._value
81
-
82
- @value.setter
83
- def value(self, new_value: float) -> None:
84
- self._value = new_value
85
-
86
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
87
- if isinstance(msg, ListEntitiesRequest):
88
- yield ListEntitiesSensorResponse(
89
- object_id=self.object_id,
90
- key=self.key,
91
- name=self.name,
92
- icon=self.icon,
93
- unit_of_measurement=self.unit_of_measurement,
94
- accuracy_decimals=self.accuracy_decimals,
95
- device_class=self.device_class,
96
- state_class=self.state_class,
97
- )
98
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
99
- yield self._get_state_message()
100
-
101
- def _get_state_message(self) -> SensorStateResponse:
102
- return SensorStateResponse(
103
- key=self.key,
104
- state=self.value,
105
- missing_state=False,
106
- )
107
-
108
- def update_state(self) -> None:
109
- """Send state update to Home Assistant."""
110
- self.server.send_messages([self._get_state_message()])
111
-
112
-
113
- class SwitchEntity(ESPHomeEntity):
114
- """Switch entity for ESPHome (read-write boolean values)."""
115
-
116
- def __init__(
117
- self,
118
- server: APIServer,
119
- key: int,
120
- name: str,
121
- object_id: str,
122
- icon: str = "",
123
- device_class: str = "",
124
- entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
125
- value_getter: Optional[Callable[[], bool]] = None,
126
- value_setter: Optional[Callable[[bool], None]] = None,
127
- ) -> None:
128
- ESPHomeEntity.__init__(self, server)
129
- self.key = key
130
- self.name = name
131
- self.object_id = object_id
132
- self.icon = icon
133
- self.device_class = device_class
134
- self.entity_category = entity_category
135
- self._value_getter = value_getter
136
- self._value_setter = value_setter
137
- self._value = False
138
-
139
- @property
140
- def value(self) -> bool:
141
- if self._value_getter:
142
- return self._value_getter()
143
- return self._value
144
-
145
- @value.setter
146
- def value(self, new_value: bool) -> None:
147
- if self._value_setter:
148
- self._value_setter(new_value)
149
- self._value = new_value
150
-
151
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
152
- if isinstance(msg, ListEntitiesRequest):
153
- yield ListEntitiesSwitchResponse(
154
- object_id=self.object_id,
155
- key=self.key,
156
- name=self.name,
157
- icon=self.icon,
158
- device_class=self.device_class,
159
- entity_category=self.entity_category,
160
- )
161
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
162
- yield self._get_state_message()
163
- elif isinstance(msg, SwitchCommandRequest) and msg.key == self.key:
164
- self.value = msg.state
165
- yield self._get_state_message()
166
-
167
- def _get_state_message(self) -> SwitchStateResponse:
168
- return SwitchStateResponse(
169
- key=self.key,
170
- state=self.value,
171
- )
172
-
173
- def update_state(self) -> None:
174
- """Send state update to Home Assistant."""
175
- self.server.send_messages([self._get_state_message()])
176
-
177
-
178
- class SelectEntity(ESPHomeEntity):
179
- """Select entity for ESPHome (read-write string selection)."""
180
-
181
- def __init__(
182
- self,
183
- server: APIServer,
184
- key: int,
185
- name: str,
186
- object_id: str,
187
- options: List[str],
188
- icon: str = "",
189
- value_getter: Optional[Callable[[], str]] = None,
190
- value_setter: Optional[Callable[[str], None]] = None,
191
- ) -> None:
192
- ESPHomeEntity.__init__(self, server)
193
- self.key = key
194
- self.name = name
195
- self.object_id = object_id
196
- self.options = options
197
- self.icon = icon
198
- self._value_getter = value_getter
199
- self._value_setter = value_setter
200
- self._value = options[0] if options else ""
201
-
202
- @property
203
- def value(self) -> str:
204
- if self._value_getter:
205
- return self._value_getter()
206
- return self._value
207
-
208
- @value.setter
209
- def value(self, new_value: str) -> None:
210
- if new_value in self.options:
211
- if self._value_setter:
212
- self._value_setter(new_value)
213
- self._value = new_value
214
- else:
215
- logger.warning(f"Invalid option '{new_value}' for {self.name}")
216
-
217
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
218
- if isinstance(msg, ListEntitiesRequest):
219
- yield ListEntitiesSelectResponse(
220
- object_id=self.object_id,
221
- key=self.key,
222
- name=self.name,
223
- icon=self.icon,
224
- options=self.options,
225
- )
226
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
227
- yield self._get_state_message()
228
- elif isinstance(msg, SelectCommandRequest) and msg.key == self.key:
229
- self.value = msg.state
230
- yield self._get_state_message()
231
-
232
- def _get_state_message(self) -> SelectStateResponse:
233
- return SelectStateResponse(
234
- key=self.key,
235
- state=self.value,
236
- missing_state=False,
237
- )
238
-
239
- def update_state(self) -> None:
240
- """Send state update to Home Assistant."""
241
- self.server.send_messages([self._get_state_message()])
242
-
243
-
244
- class ButtonEntity(ESPHomeEntity):
245
- """Button entity for ESPHome (trigger actions)."""
246
-
247
- def __init__(
248
- self,
249
- server: APIServer,
250
- key: int,
251
- name: str,
252
- object_id: str,
253
- icon: str = "",
254
- device_class: str = "",
255
- on_press: Optional[Callable[[], None]] = None,
256
- ) -> None:
257
- ESPHomeEntity.__init__(self, server)
258
- self.key = key
259
- self.name = name
260
- self.object_id = object_id
261
- self.icon = icon
262
- self.device_class = device_class
263
- self._on_press = on_press
264
-
265
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
266
- if isinstance(msg, ListEntitiesRequest):
267
- yield ListEntitiesButtonResponse(
268
- object_id=self.object_id,
269
- key=self.key,
270
- name=self.name,
271
- icon=self.icon,
272
- device_class=self.device_class,
273
- )
274
- elif isinstance(msg, ButtonCommandRequest) and msg.key == self.key:
275
- if self._on_press:
276
- try:
277
- self._on_press()
278
- logger.info(f"Button '{self.name}' pressed")
279
- except Exception as e:
280
- logger.error(f"Error executing button '{self.name}': {e}")
281
- # Buttons don't have state responses
282
- return
283
- yield # Make this a generator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reachy_mini_ha_voice/satellite.py CHANGED
@@ -149,30 +149,49 @@ class VoiceSatelliteProtocol(APIServer):
149
  self._doa_angle_entity: Optional[SensorEntity] = None
150
  self._speech_detected_entity: Optional[BinarySensorEntity] = None
151
 
152
- if self.state.media_player_entity is None:
153
- self.state.media_player_entity = MediaPlayerEntity(
154
- server=self,
155
- key=self._get_entity_key("reachy_mini_media_player"),
156
- name="Media Player",
157
- object_id="reachy_mini_media_player",
158
- music_player=state.music_player,
159
- announce_player=state.tts_player,
160
- )
161
- self.state.entities.append(self.state.media_player_entity)
162
-
163
- # Setup all entity phases
164
- self._setup_phase1_entities()
165
- self._setup_phase2_entities()
166
- self._setup_phase3_entities()
167
- self._setup_phase4_entities()
168
- self._setup_phase5_entities()
169
- self._setup_phase6_entities()
170
- self._setup_phase7_entities()
171
- self._setup_phase8_entities()
172
- self._setup_phase9_entities()
173
- self._setup_phase10_entities() # Camera
174
- # Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
175
- self._setup_phase12_entities() # Audio processing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  self._is_streaming_audio = False
178
  self._tts_url: Optional[str] = None
@@ -463,6 +482,11 @@ class VoiceSatelliteProtocol(APIServer):
463
  def connection_lost(self, exc):
464
  super().connection_lost(exc)
465
  _LOGGER.info("Disconnected from Home Assistant")
 
 
 
 
 
466
 
467
  def _download_external_wake_word(
468
  self, external_wake_word: VoiceAssistantExternalWakeWord
 
149
  self._doa_angle_entity: Optional[SensorEntity] = None
150
  self._speech_detected_entity: Optional[BinarySensorEntity] = None
151
 
152
+ # Only setup entities once (check if already initialized)
153
+ # This prevents duplicate entity registration on reconnection
154
+ if not getattr(self.state, '_entities_initialized', False):
155
+ if self.state.media_player_entity is None:
156
+ self.state.media_player_entity = MediaPlayerEntity(
157
+ server=self,
158
+ key=self._get_entity_key("reachy_mini_media_player"),
159
+ name="Media Player",
160
+ object_id="reachy_mini_media_player",
161
+ music_player=state.music_player,
162
+ announce_player=state.tts_player,
163
+ )
164
+ self.state.entities.append(self.state.media_player_entity)
165
+
166
+ # Setup all entity phases
167
+ self._setup_phase1_entities()
168
+ self._setup_phase2_entities()
169
+ self._setup_phase3_entities()
170
+ self._setup_phase4_entities()
171
+ self._setup_phase5_entities()
172
+ self._setup_phase6_entities()
173
+ self._setup_phase7_entities()
174
+ self._setup_phase8_entities()
175
+ self._setup_phase9_entities()
176
+ self._setup_phase10_entities() # Camera
177
+ # Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
178
+ self._setup_phase12_entities() # Audio processing
179
+
180
+ # Mark entities as initialized
181
+ self.state._entities_initialized = True
182
+ _LOGGER.info("Entities initialized: %d total", len(self.state.entities))
183
+ else:
184
+ _LOGGER.debug("Entities already initialized, skipping setup")
185
+ # Update server reference in existing entities
186
+ for entity in self.state.entities:
187
+ entity.server = self
188
+ # Find and store references to DOA entities
189
+ for entity in self.state.entities:
190
+ if hasattr(entity, 'object_id'):
191
+ if entity.object_id == 'doa_angle':
192
+ self._doa_angle_entity = entity
193
+ elif entity.object_id == 'speech_detected':
194
+ self._speech_detected_entity = entity
195
 
196
  self._is_streaming_audio = False
197
  self._tts_url: Optional[str] = None
 
482
  def connection_lost(self, exc):
483
  super().connection_lost(exc)
484
  _LOGGER.info("Disconnected from Home Assistant")
485
+ # Clear streaming state on disconnect
486
+ self._is_streaming_audio = False
487
+ self._tts_url = None
488
+ self._tts_played = False
489
+ self._continue_conversation = False
490
 
491
  def _download_external_wake_word(
492
  self, external_wake_word: VoiceAssistantExternalWakeWord
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -137,19 +137,7 @@ class VoiceAssistantService:
137
  )
138
  self._audio_thread.start()
139
 
140
- # Create ESPHome server
141
- loop = asyncio.get_running_loop()
142
- self._server = await loop.create_server(
143
- lambda: VoiceSatelliteProtocol(self._state),
144
- host=self.host,
145
- port=self.port,
146
- )
147
-
148
- # Start mDNS discovery
149
- self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
150
- await self._discovery.register_server()
151
-
152
- # Start camera server if enabled
153
  if self.camera_enabled:
154
  self._camera_server = MJPEGCameraServer(
155
  reachy_mini=self.reachy_mini,
@@ -160,6 +148,19 @@ class VoiceAssistantService:
160
  )
161
  await self._camera_server.start()
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
164
 
165
  async def stop(self) -> None:
 
137
  )
138
  self._audio_thread.start()
139
 
140
+ # Start camera server if enabled (must be before ESPHome server)
 
 
 
 
 
 
 
 
 
 
 
 
141
  if self.camera_enabled:
142
  self._camera_server = MJPEGCameraServer(
143
  reachy_mini=self.reachy_mini,
 
148
  )
149
  await self._camera_server.start()
150
 
151
+ # Create ESPHome server (pass camera_server for camera entity)
152
+ loop = asyncio.get_running_loop()
153
+ camera_server = self._camera_server # Capture for lambda
154
+ self._server = await loop.create_server(
155
+ lambda: VoiceSatelliteProtocol(self._state, camera_server=camera_server),
156
+ host=self.host,
157
+ port=self.port,
158
+ )
159
+
160
+ # Start mDNS discovery
161
+ self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
162
+ await self._discovery.register_server()
163
+
164
  _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
165
 
166
  async def stop(self) -> None: