Commit ·
80662a9
1
Parent(s): dadb1e9
"fix-duplicate-entity-registration-and-camera-integration"
Browse files- PROJECT_PLAN.md +54 -12
- README.md +24 -3
- old_entity_extensions.py +0 -283
- reachy_mini_ha_voice/satellite.py +48 -24
- reachy_mini_ha_voice/voice_assistant.py +14 -13
PROJECT_PLAN.md
CHANGED
|
@@ -48,7 +48,7 @@
|
|
| 48 |
│ Home Assistant │
|
| 49 |
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
| 50 |
│ │ STT Engine │ │ Intent │ │ TTS Engine │ │
|
| 51 |
-
│ │
|
| 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 # 扩展实体类型
|
| 94 |
-
│ ├── reachy_controller.py # Reachy Mini 控制器包装
|
| 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 |
-
**总计:
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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:
|