Commit ·
5238074
1
Parent(s): 29fd1be
"feat(entities):add-17-ESPHome-entities-for-full-robot-control"
Browse files- .claude/settings.local.json +2 -1
- ENTITIES.md +488 -0
- PROJECT_PLAN.md +16 -16
- README.md +30 -0
- reachy_mini_ha_voice/entity.py +211 -1
- reachy_mini_ha_voice/entity_extensions.py +262 -0
- reachy_mini_ha_voice/main.py +5 -1
- reachy_mini_ha_voice/reachy_controller.py +383 -0
- reachy_mini_ha_voice/satellite.py +279 -1
.claude/settings.local.json
CHANGED
|
@@ -34,7 +34,8 @@
|
|
| 34 |
"mcp__web-reader__webReader",
|
| 35 |
"mcp__zread__get_repo_structure",
|
| 36 |
"mcp__zread__read_file",
|
| 37 |
-
"Bash(dir:*)"
|
|
|
|
| 38 |
],
|
| 39 |
"deny": [],
|
| 40 |
"ask": []
|
|
|
|
| 34 |
"mcp__web-reader__webReader",
|
| 35 |
"mcp__zread__get_repo_structure",
|
| 36 |
"mcp__zread__read_file",
|
| 37 |
+
"Bash(dir:*)",
|
| 38 |
+
"Bash(C::*)"
|
| 39 |
],
|
| 40 |
"deny": [],
|
| 41 |
"ask": []
|
ENTITIES.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Mini Home Assistant 实体使用指南
|
| 2 |
+
|
| 3 |
+
本文档介绍如何在 Home Assistant 中使用 Reachy Mini 暴露的 ESPHome 实体来控制机器人。
|
| 4 |
+
|
| 5 |
+
## 概述
|
| 6 |
+
|
| 7 |
+
Reachy Mini HA Voice 应用通过 ESPHome 协议向 Home Assistant 暴露了多个实体,允许你完全控制机器人的运动、电机状态和系统信息。
|
| 8 |
+
|
| 9 |
+
## 实体列表
|
| 10 |
+
|
| 11 |
+
### 📊 Phase 1: 基础状态与音量控制
|
| 12 |
+
|
| 13 |
+
#### 1. Daemon State (文本传感器)
|
| 14 |
+
- **实体 ID**: `sensor.reachy_mini_daemon_state`
|
| 15 |
+
- **类型**: 只读文本传感器
|
| 16 |
+
- **说明**: 显示 Reachy Mini Daemon 的当前状态
|
| 17 |
+
- **可能的值**:
|
| 18 |
+
- `not_initialized` - 未初始化
|
| 19 |
+
- `starting` - 启动中
|
| 20 |
+
- `running` - 运行中
|
| 21 |
+
- `stopping` - 停止中
|
| 22 |
+
- `stopped` - 已停止
|
| 23 |
+
- `error` - 错误状态
|
| 24 |
+
- `not_available` - 机器人不可用(独立模式)
|
| 25 |
+
|
| 26 |
+
#### 2. Backend Ready (二进制传感器)
|
| 27 |
+
- **实体 ID**: `binary_sensor.reachy_mini_backend_ready`
|
| 28 |
+
- **类型**: 只读布尔传感器
|
| 29 |
+
- **说明**: 指示后端服务是否就绪
|
| 30 |
+
- **值**: `on` (就绪) / `off` (未就绪)
|
| 31 |
+
|
| 32 |
+
#### 3. Error Message (文本传感器)
|
| 33 |
+
- **实体 ID**: `sensor.reachy_mini_error_message`
|
| 34 |
+
- **类型**: 只读文本传感器
|
| 35 |
+
- **说明**: 显示当前的错误信息(如果有)
|
| 36 |
+
|
| 37 |
+
#### 4. Speaker Volume (数字控制)
|
| 38 |
+
- **实体 ID**: `number.reachy_mini_speaker_volume`
|
| 39 |
+
- **类型**: 可读写数字控制
|
| 40 |
+
- **范围**: 0-100%
|
| 41 |
+
- **说明**: 控制扬声器音量
|
| 42 |
+
- **使用示例**:
|
| 43 |
+
```yaml
|
| 44 |
+
# 设置音量为 80%
|
| 45 |
+
service: number.set_value
|
| 46 |
+
target:
|
| 47 |
+
entity_id: number.reachy_mini_speaker_volume
|
| 48 |
+
data:
|
| 49 |
+
value: 80
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### ⚙️ Phase 2: 电机控制
|
| 55 |
+
|
| 56 |
+
#### 5. Motors Enabled (开关)
|
| 57 |
+
- **实体 ID**: `switch.reachy_mini_motors_enabled`
|
| 58 |
+
- **类型**: 可读写开关
|
| 59 |
+
- **说明**: 启用或禁用所有电机的扭矩
|
| 60 |
+
- **使用示例**:
|
| 61 |
+
```yaml
|
| 62 |
+
# 启用电机
|
| 63 |
+
service: switch.turn_on
|
| 64 |
+
target:
|
| 65 |
+
entity_id: switch.reachy_mini_motors_enabled
|
| 66 |
+
|
| 67 |
+
# 禁用电机
|
| 68 |
+
service: switch.turn_off
|
| 69 |
+
target:
|
| 70 |
+
entity_id: switch.reachy_mini_motors_enabled
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
#### 6. Motor Mode (选择器)
|
| 74 |
+
- **实体 ID**: `select.reachy_mini_motor_mode`
|
| 75 |
+
- **类型**: 可读写选择器
|
| 76 |
+
- **选项**:
|
| 77 |
+
- `enabled` - 电机启用,位置控制
|
| 78 |
+
- `disabled` - 电机禁用,无扭矩
|
| 79 |
+
- `gravity_compensation` - 重力补偿模式
|
| 80 |
+
- **使用示例**:
|
| 81 |
+
```yaml
|
| 82 |
+
# 设置为重力补偿模式
|
| 83 |
+
service: select.select_option
|
| 84 |
+
target:
|
| 85 |
+
entity_id: select.reachy_mini_motor_mode
|
| 86 |
+
data:
|
| 87 |
+
option: gravity_compensation
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
#### 7. Wake Up (按钮)
|
| 91 |
+
- **实体 ID**: `button.reachy_mini_wake_up`
|
| 92 |
+
- **类型**: 按钮
|
| 93 |
+
- **说明**: 执行唤醒动画
|
| 94 |
+
- **使用示例**:
|
| 95 |
+
```yaml
|
| 96 |
+
service: button.press
|
| 97 |
+
target:
|
| 98 |
+
entity_id: button.reachy_mini_wake_up
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
#### 8. Go to Sleep (按钮)
|
| 102 |
+
- **实体 ID**: `button.reachy_mini_go_to_sleep`
|
| 103 |
+
- **类型**: 按钮
|
| 104 |
+
- **说明**: 执行睡眠动画
|
| 105 |
+
- **使用示例**:
|
| 106 |
+
```yaml
|
| 107 |
+
service: button.press
|
| 108 |
+
target:
|
| 109 |
+
entity_id: button.reachy_mini_go_to_sleep
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
### 🎯 Phase 3: 姿态控制
|
| 115 |
+
|
| 116 |
+
#### 头部位置控制 (X, Y, Z)
|
| 117 |
+
|
| 118 |
+
##### 9. Head X Position (数字控制)
|
| 119 |
+
- **实体 ID**: `number.reachy_mini_head_x`
|
| 120 |
+
- **范围**: -50mm ~ +50mm
|
| 121 |
+
- **说明**: 控制头部在 X 轴的位置
|
| 122 |
+
|
| 123 |
+
##### 10. Head Y Position (数字控制)
|
| 124 |
+
- **实体 ID**: `number.reachy_mini_head_y`
|
| 125 |
+
- **范围**: -50mm ~ +50mm
|
| 126 |
+
- **说明**: 控制头部在 Y 轴的位置
|
| 127 |
+
|
| 128 |
+
##### 11. Head Z Position (数字控制)
|
| 129 |
+
- **实体 ID**: `number.reachy_mini_head_z`
|
| 130 |
+
- **范围**: -50mm ~ +50mm
|
| 131 |
+
- **说明**: 控制头部在 Z 轴的位置
|
| 132 |
+
|
| 133 |
+
**使用示例**:
|
| 134 |
+
```yaml
|
| 135 |
+
# 移动头部到指定位置
|
| 136 |
+
service: number.set_value
|
| 137 |
+
target:
|
| 138 |
+
entity_id:
|
| 139 |
+
- number.reachy_mini_head_x
|
| 140 |
+
- number.reachy_mini_head_y
|
| 141 |
+
- number.reachy_mini_head_z
|
| 142 |
+
data:
|
| 143 |
+
value: 10 # 每个轴移动 10mm
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
#### 头部角度控制 (Roll, Pitch, Yaw)
|
| 147 |
+
|
| 148 |
+
##### 12. Head Roll (数字控制)
|
| 149 |
+
- **实体 ID**: `number.reachy_mini_head_roll`
|
| 150 |
+
- **范围**: -40° ~ +40°
|
| 151 |
+
- **说明**: 控制头部翻滚角(左右倾斜)
|
| 152 |
+
|
| 153 |
+
##### 13. Head Pitch (数字控制)
|
| 154 |
+
- **实体 ID**: `number.reachy_mini_head_pitch`
|
| 155 |
+
- **范围**: -40° ~ +40°
|
| 156 |
+
- **说明**: 控制头部俯仰角(上下点头)
|
| 157 |
+
|
| 158 |
+
##### 14. Head Yaw (数字控制)
|
| 159 |
+
- **实体 ID**: `number.reachy_mini_head_yaw`
|
| 160 |
+
- **范围**: -180° ~ +180°
|
| 161 |
+
- **说明**: 控制头部偏航角(左右转头)
|
| 162 |
+
|
| 163 |
+
**使用示例**:
|
| 164 |
+
```yaml
|
| 165 |
+
# 让机器人点头(pitch = -20°)
|
| 166 |
+
service: number.set_value
|
| 167 |
+
target:
|
| 168 |
+
entity_id: number.reachy_mini_head_pitch
|
| 169 |
+
data:
|
| 170 |
+
value: -20
|
| 171 |
+
|
| 172 |
+
# 让机器人摇头(yaw 左右摆动)
|
| 173 |
+
service: number.set_value
|
| 174 |
+
target:
|
| 175 |
+
entity_id: number.reachy_mini_head_yaw
|
| 176 |
+
data:
|
| 177 |
+
value: 30
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
#### 身体控制
|
| 181 |
+
|
| 182 |
+
##### 15. Body Yaw (数字控制)
|
| 183 |
+
- **实体 ID**: `number.reachy_mini_body_yaw`
|
| 184 |
+
- **范围**: -160° ~ +160°
|
| 185 |
+
- **说明**: 控制身体的偏航角(旋转)
|
| 186 |
+
|
| 187 |
+
**使用示例**:
|
| 188 |
+
```yaml
|
| 189 |
+
# 旋转身体 45 度
|
| 190 |
+
service: number.set_value
|
| 191 |
+
target:
|
| 192 |
+
entity_id: number.reachy_mini_body_yaw
|
| 193 |
+
data:
|
| 194 |
+
value: 45
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
#### 天线控制
|
| 198 |
+
|
| 199 |
+
##### 16. Left Antenna (数字控制)
|
| 200 |
+
- **实体 ID**: `number.reachy_mini_antenna_left`
|
| 201 |
+
- **范围**: -90° ~ +90°
|
| 202 |
+
- **说明**: 控制左天线角度
|
| 203 |
+
|
| 204 |
+
##### 17. Right Antenna (数字控制)
|
| 205 |
+
- **实体 ID**: `number.reachy_mini_antenna_right`
|
| 206 |
+
- **范围**: -90° ~ +90°
|
| 207 |
+
- **说明**: 控制右天线角度
|
| 208 |
+
|
| 209 |
+
**使用示例**:
|
| 210 |
+
```yaml
|
| 211 |
+
# 让天线竖起来表示兴奋
|
| 212 |
+
service: number.set_value
|
| 213 |
+
target:
|
| 214 |
+
entity_id:
|
| 215 |
+
- number.reachy_mini_antenna_left
|
| 216 |
+
- number.reachy_mini_antenna_right
|
| 217 |
+
data:
|
| 218 |
+
value: 45
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## 自动化示例
|
| 224 |
+
|
| 225 |
+
### 示例 1: 早晨唤醒机器人
|
| 226 |
+
|
| 227 |
+
```yaml
|
| 228 |
+
automation:
|
| 229 |
+
- alias: "早晨唤醒 Reachy Mini"
|
| 230 |
+
trigger:
|
| 231 |
+
- platform: time
|
| 232 |
+
at: "08:00:00"
|
| 233 |
+
action:
|
| 234 |
+
- service: button.press
|
| 235 |
+
target:
|
| 236 |
+
entity_id: button.reachy_mini_wake_up
|
| 237 |
+
- service: number.set_value
|
| 238 |
+
target:
|
| 239 |
+
entity_id: number.reachy_mini_speaker_volume
|
| 240 |
+
data:
|
| 241 |
+
value: 70
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
### 示例 2: 晚上让机器人睡觉
|
| 245 |
+
|
| 246 |
+
```yaml
|
| 247 |
+
automation:
|
| 248 |
+
- alias: "晚上 Reachy Mini 睡觉"
|
| 249 |
+
trigger:
|
| 250 |
+
- platform: time
|
| 251 |
+
at: "22:00:00"
|
| 252 |
+
action:
|
| 253 |
+
- service: button.press
|
| 254 |
+
target:
|
| 255 |
+
entity_id: button.reachy_mini_go_to_sleep
|
| 256 |
+
- service: switch.turn_off
|
| 257 |
+
target:
|
| 258 |
+
entity_id: switch.reachy_mini_motors_enabled
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
### 示例 3: 有人回家时打招呼
|
| 262 |
+
|
| 263 |
+
```yaml
|
| 264 |
+
automation:
|
| 265 |
+
- alias: "Reachy Mini 打招呼"
|
| 266 |
+
trigger:
|
| 267 |
+
- platform: state
|
| 268 |
+
entity_id: binary_sensor.front_door
|
| 269 |
+
to: "on"
|
| 270 |
+
condition:
|
| 271 |
+
- condition: state
|
| 272 |
+
entity_id: binary_sensor.reachy_mini_backend_ready
|
| 273 |
+
state: "on"
|
| 274 |
+
action:
|
| 275 |
+
# 点头
|
| 276 |
+
- service: number.set_value
|
| 277 |
+
target:
|
| 278 |
+
entity_id: number.reachy_mini_head_pitch
|
| 279 |
+
data:
|
| 280 |
+
value: -20
|
| 281 |
+
- delay: "00:00:01"
|
| 282 |
+
- service: number.set_value
|
| 283 |
+
target:
|
| 284 |
+
entity_id: number.reachy_mini_head_pitch
|
| 285 |
+
data:
|
| 286 |
+
value: 0
|
| 287 |
+
# 天线摆动
|
| 288 |
+
- service: number.set_value
|
| 289 |
+
target:
|
| 290 |
+
entity_id:
|
| 291 |
+
- number.reachy_mini_antenna_left
|
| 292 |
+
- number.reachy_mini_antenna_right
|
| 293 |
+
data:
|
| 294 |
+
value: 45
|
| 295 |
+
- delay: "00:00:01"
|
| 296 |
+
- service: number.set_value
|
| 297 |
+
target:
|
| 298 |
+
entity_id:
|
| 299 |
+
- number.reachy_mini_antenna_left
|
| 300 |
+
- number.reachy_mini_antenna_right
|
| 301 |
+
data:
|
| 302 |
+
value: 0
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
### 示例 4: 根据后端状态显示通知
|
| 306 |
+
|
| 307 |
+
```yaml
|
| 308 |
+
automation:
|
| 309 |
+
- alias: "Reachy Mini 错误通知"
|
| 310 |
+
trigger:
|
| 311 |
+
- platform: state
|
| 312 |
+
entity_id: sensor.reachy_mini_daemon_state
|
| 313 |
+
to: "error"
|
| 314 |
+
action:
|
| 315 |
+
- service: notify.mobile_app
|
| 316 |
+
data:
|
| 317 |
+
title: "Reachy Mini 错误"
|
| 318 |
+
message: "{{ states('sensor.reachy_mini_error_message') }}"
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
### 示例 5: 创建自定义动作序列
|
| 322 |
+
|
| 323 |
+
```yaml
|
| 324 |
+
script:
|
| 325 |
+
reachy_mini_dance:
|
| 326 |
+
alias: "Reachy Mini 跳舞"
|
| 327 |
+
sequence:
|
| 328 |
+
# 启用电机
|
| 329 |
+
- service: switch.turn_on
|
| 330 |
+
target:
|
| 331 |
+
entity_id: switch.reachy_mini_motors_enabled
|
| 332 |
+
# 左右摇头
|
| 333 |
+
- repeat:
|
| 334 |
+
count: 3
|
| 335 |
+
sequence:
|
| 336 |
+
- service: number.set_value
|
| 337 |
+
target:
|
| 338 |
+
entity_id: number.reachy_mini_head_yaw
|
| 339 |
+
data:
|
| 340 |
+
value: 30
|
| 341 |
+
- delay: "00:00:00.5"
|
| 342 |
+
- service: number.set_value
|
| 343 |
+
target:
|
| 344 |
+
entity_id: number.reachy_mini_head_yaw
|
| 345 |
+
data:
|
| 346 |
+
value: -30
|
| 347 |
+
- delay: "00:00:00.5"
|
| 348 |
+
# 回到中心
|
| 349 |
+
- service: number.set_value
|
| 350 |
+
target:
|
| 351 |
+
entity_id: number.reachy_mini_head_yaw
|
| 352 |
+
data:
|
| 353 |
+
value: 0
|
| 354 |
+
# 天线摆动
|
| 355 |
+
- service: number.set_value
|
| 356 |
+
target:
|
| 357 |
+
entity_id:
|
| 358 |
+
- number.reachy_mini_antenna_left
|
| 359 |
+
- number.reachy_mini_antenna_right
|
| 360 |
+
data:
|
| 361 |
+
value: 60
|
| 362 |
+
- delay: "00:00:01"
|
| 363 |
+
- service: number.set_value
|
| 364 |
+
target:
|
| 365 |
+
entity_id:
|
| 366 |
+
- number.reachy_mini_antenna_left
|
| 367 |
+
- number.reachy_mini_antenna_right
|
| 368 |
+
data:
|
| 369 |
+
value: 0
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## Lovelace 仪表板示例
|
| 375 |
+
|
| 376 |
+
### 基础控制卡片
|
| 377 |
+
|
| 378 |
+
```yaml
|
| 379 |
+
type: vertical-stack
|
| 380 |
+
cards:
|
| 381 |
+
- type: entities
|
| 382 |
+
title: Reachy Mini 状态
|
| 383 |
+
entities:
|
| 384 |
+
- entity: sensor.reachy_mini_daemon_state
|
| 385 |
+
name: Daemon 状态
|
| 386 |
+
- entity: binary_sensor.reachy_mini_backend_ready
|
| 387 |
+
name: 后端就绪
|
| 388 |
+
- entity: sensor.reachy_mini_error_message
|
| 389 |
+
name: 错误信息
|
| 390 |
+
|
| 391 |
+
- type: entities
|
| 392 |
+
title: 电机控制
|
| 393 |
+
entities:
|
| 394 |
+
- entity: switch.reachy_mini_motors_enabled
|
| 395 |
+
name: 电机开关
|
| 396 |
+
- entity: select.reachy_mini_motor_mode
|
| 397 |
+
name: 电机模式
|
| 398 |
+
- entity: button.reachy_mini_wake_up
|
| 399 |
+
name: 唤醒
|
| 400 |
+
- entity: button.reachy_mini_go_to_sleep
|
| 401 |
+
name: 睡眠
|
| 402 |
+
|
| 403 |
+
- type: entities
|
| 404 |
+
title: 音量控制
|
| 405 |
+
entities:
|
| 406 |
+
- entity: number.reachy_mini_speaker_volume
|
| 407 |
+
name: 扬声器音量
|
| 408 |
+
```
|
| 409 |
+
|
| 410 |
+
### 头部控制卡片
|
| 411 |
+
|
| 412 |
+
```yaml
|
| 413 |
+
type: vertical-stack
|
| 414 |
+
cards:
|
| 415 |
+
- type: entities
|
| 416 |
+
title: 头部位置 (mm)
|
| 417 |
+
entities:
|
| 418 |
+
- entity: number.reachy_mini_head_x
|
| 419 |
+
name: X 轴
|
| 420 |
+
- entity: number.reachy_mini_head_y
|
| 421 |
+
name: Y 轴
|
| 422 |
+
- entity: number.reachy_mini_head_z
|
| 423 |
+
name: Z 轴
|
| 424 |
+
|
| 425 |
+
- type: entities
|
| 426 |
+
title: 头部角度 (°)
|
| 427 |
+
entities:
|
| 428 |
+
- entity: number.reachy_mini_head_roll
|
| 429 |
+
name: 翻滚 (Roll)
|
| 430 |
+
- entity: number.reachy_mini_head_pitch
|
| 431 |
+
name: 俯仰 (Pitch)
|
| 432 |
+
- entity: number.reachy_mini_head_yaw
|
| 433 |
+
name: 偏航 (Yaw)
|
| 434 |
+
|
| 435 |
+
- type: entities
|
| 436 |
+
title: 身体与天线
|
| 437 |
+
entities:
|
| 438 |
+
- entity: number.reachy_mini_body_yaw
|
| 439 |
+
name: 身体偏航
|
| 440 |
+
- entity: number.reachy_mini_antenna_left
|
| 441 |
+
name: 左天线
|
| 442 |
+
- entity: number.reachy_mini_antenna_right
|
| 443 |
+
name: 右天线
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
---
|
| 447 |
+
|
| 448 |
+
## 注意事项
|
| 449 |
+
|
| 450 |
+
1. **电机安全**: 在控制姿态之前,确保电机已启用 (`switch.reachy_mini_motors_enabled` 为 `on`)
|
| 451 |
+
|
| 452 |
+
2. **角度限制**: 所有角度控制都有安全限制,超出范围的值会被自动限制在有效范围内
|
| 453 |
+
|
| 454 |
+
3. **独立模式**: 如果机器人不可用(独立模式),控制命令不会产生错误,但也不会执行任何动作
|
| 455 |
+
|
| 456 |
+
4. **平滑运动**: 快速连续的控制命令可能导致不平滑的运动,建议在命令之间添加适当的延迟
|
| 457 |
+
|
| 458 |
+
5. **状态更新**: 实体状态会实时更新,但某些传感器可能有轻微延迟
|
| 459 |
+
|
| 460 |
+
---
|
| 461 |
+
|
| 462 |
+
## 故障排除
|
| 463 |
+
|
| 464 |
+
### 问题: 实体不显示在 Home Assistant 中
|
| 465 |
+
**解决方案**:
|
| 466 |
+
- 确认 Reachy Mini HA Voice 应用正在运行
|
| 467 |
+
- 检查 ESPHome 集成是否正确配置
|
| 468 |
+
- 重启 Home Assistant 或重新加载 ESPHome 集成
|
| 469 |
+
|
| 470 |
+
### 问题: 控制命令无响应
|
| 471 |
+
**解决方案**:
|
| 472 |
+
- 检查 `binary_sensor.reachy_mini_backend_ready` 是否为 `on`
|
| 473 |
+
- 查看 `sensor.reachy_mini_error_message` 是否有错误信息
|
| 474 |
+
- 确认电机已启用(对于运动控制)
|
| 475 |
+
|
| 476 |
+
### 问题: Daemon 状态显示 "error"
|
| 477 |
+
**解决方案**:
|
| 478 |
+
- 查看 `sensor.reachy_mini_error_message` 获取详细错误信息
|
| 479 |
+
- 检查 Reachy Mini 硬件连接
|
| 480 |
+
- 重启 Reachy Mini HA Voice 应用
|
| 481 |
+
|
| 482 |
+
---
|
| 483 |
+
|
| 484 |
+
## 更多信息
|
| 485 |
+
|
| 486 |
+
- [项目 GitHub](https://github.com/yourusername/reachy_mini_ha_voice)
|
| 487 |
+
- [Reachy Mini SDK 文档](https://github.com/pollen-robotics/reachy_mini)
|
| 488 |
+
- [Home Assistant ESPHome 集成](https://www.home-assistant.io/integrations/esphome/)
|
PROJECT_PLAN.md
CHANGED
|
@@ -206,22 +206,22 @@ dependencies = [
|
|
| 206 |
|
| 207 |
### 实现优先级
|
| 208 |
|
| 209 |
-
1. **Phase 1 - 基础状态与音量** (高优先级)
|
| 210 |
-
- [
|
| 211 |
-
- [
|
| 212 |
-
- [
|
| 213 |
-
- [
|
| 214 |
-
|
| 215 |
-
2. **Phase 2 - 电机控制** (高优先级)
|
| 216 |
-
- [
|
| 217 |
-
- [
|
| 218 |
-
- [
|
| 219 |
-
|
| 220 |
-
3. **Phase 3 - 姿态控制** (中优先级)
|
| 221 |
-
- [
|
| 222 |
-
- [
|
| 223 |
-
- [
|
| 224 |
-
- [
|
| 225 |
|
| 226 |
4. **Phase 4 - 注视控制** (中优先级)
|
| 227 |
- [ ] `look_at_x/y/z` - 注视点坐标控制
|
|
|
|
| 206 |
|
| 207 |
### 实现优先级
|
| 208 |
|
| 209 |
+
1. **Phase 1 - 基础状态与音量** (高优先级) ✅ **已完成**
|
| 210 |
+
- [x] `daemon_state` - Daemon 状态传感器
|
| 211 |
+
- [x] `backend_ready` - 后端就绪状态
|
| 212 |
+
- [x] `error_message` - 错误信息
|
| 213 |
+
- [x] `speaker_volume` - 扬声器音量控制
|
| 214 |
+
|
| 215 |
+
2. **Phase 2 - 电机控制** (高优先级) ✅ **已完成**
|
| 216 |
+
- [x] `motors_enabled` - 电机开关
|
| 217 |
+
- [x] `motor_mode` - 电机模式选择 (enabled/disabled/gravity_compensation)
|
| 218 |
+
- [x] `wake_up` / `go_to_sleep` - 唤醒/睡眠按钮
|
| 219 |
+
|
| 220 |
+
3. **Phase 3 - 姿态控制** (中优先级) ✅ **已完成**
|
| 221 |
+
- [x] `head_x/y/z` - 头部位置控制
|
| 222 |
+
- [x] `head_roll/pitch/yaw` - 头部角度控制
|
| 223 |
+
- [x] `body_yaw` - 身体偏航角控制
|
| 224 |
+
- [x] `antenna_left/right` - 天线角度控制
|
| 225 |
|
| 226 |
4. **Phase 4 - 注视控制** (中优先级)
|
| 227 |
- [ ] `look_at_x/y/z` - 注视点坐标控制
|
README.md
CHANGED
|
@@ -21,6 +21,12 @@ A voice assistant application for **Reachy Mini robot** that integrates with Hom
|
|
| 21 |
- **ESPHome Integration**: Seamlessly connects to Home Assistant
|
| 22 |
- **Motion Control**: Head movements and antenna animations during voice interaction
|
| 23 |
- **Zero Configuration**: Install and run - all settings are managed in Home Assistant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
## Requirements
|
| 26 |
|
|
@@ -52,6 +58,30 @@ Default wake word: **"Okay Nabu"**
|
|
| 52 |
|
| 53 |
Additional wake words can be configured through Home Assistant.
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
## How It Works
|
| 56 |
|
| 57 |
```
|
|
|
|
| 21 |
- **ESPHome Integration**: Seamlessly connects to Home Assistant
|
| 22 |
- **Motion Control**: Head movements and antenna animations during voice interaction
|
| 23 |
- **Zero Configuration**: Install and run - all settings are managed in Home Assistant
|
| 24 |
+
- **Full Robot Control**: Expose 17+ entities to Home Assistant for complete robot control
|
| 25 |
+
- Motor control (enable/disable, mode selection)
|
| 26 |
+
- Head position and orientation control
|
| 27 |
+
- Body rotation control
|
| 28 |
+
- Antenna animation control
|
| 29 |
+
- System status monitoring
|
| 30 |
|
| 31 |
## Requirements
|
| 32 |
|
|
|
|
| 58 |
|
| 59 |
Additional wake words can be configured through Home Assistant.
|
| 60 |
|
| 61 |
+
## ESPHome Entities
|
| 62 |
+
|
| 63 |
+
This application exposes 17+ entities to Home Assistant for complete robot control:
|
| 64 |
+
|
| 65 |
+
### Status & Control
|
| 66 |
+
- **Daemon State** - Monitor robot daemon status
|
| 67 |
+
- **Backend Ready** - Check if backend is ready
|
| 68 |
+
- **Error Message** - View current error messages
|
| 69 |
+
- **Speaker Volume** - Control audio volume (0-100%)
|
| 70 |
+
|
| 71 |
+
### Motor Control
|
| 72 |
+
- **Motors Enabled** - Enable/disable motor torque
|
| 73 |
+
- **Motor Mode** - Select motor mode (enabled/disabled/gravity_compensation)
|
| 74 |
+
- **Wake Up** - Execute wake up animation
|
| 75 |
+
- **Go to Sleep** - Execute sleep animation
|
| 76 |
+
|
| 77 |
+
### Pose Control
|
| 78 |
+
- **Head Position** - Control X/Y/Z position (±50mm)
|
| 79 |
+
- **Head Orientation** - Control roll/pitch/yaw angles
|
| 80 |
+
- **Body Yaw** - Rotate body (±160°)
|
| 81 |
+
- **Antennas** - Control left/right antenna angles (±90°)
|
| 82 |
+
|
| 83 |
+
📖 **[View Complete Entity Documentation](ENTITIES.md)** - Includes usage examples, automations, and Lovelace dashboard configurations
|
| 84 |
+
|
| 85 |
## How It Works
|
| 86 |
|
| 87 |
```
|
reachy_mini_ha_voice/entity.py
CHANGED
|
@@ -2,15 +2,34 @@
|
|
| 2 |
|
| 3 |
from abc import abstractmethod
|
| 4 |
from collections.abc import Iterable
|
| 5 |
-
from typing import Callable, List, Optional, Union
|
|
|
|
| 6 |
|
| 7 |
# pylint: disable=no-name-in-module
|
| 8 |
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 9 |
ListEntitiesMediaPlayerResponse,
|
|
|
|
| 10 |
ListEntitiesRequest,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
MediaPlayerCommandRequest,
|
| 12 |
MediaPlayerStateResponse,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
SubscribeHomeAssistantStatesRequest,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
)
|
| 15 |
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
| 16 |
from google.protobuf import message
|
|
@@ -19,6 +38,11 @@ from .api_server import APIServer
|
|
| 19 |
from .audio_player import AudioPlayer
|
| 20 |
from .util import call_all
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
class ESPHomeEntity:
|
| 24 |
"""Base class for ESPHome entities."""
|
|
@@ -133,3 +157,189 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|
| 133 |
volume=self.volume,
|
| 134 |
muted=self.muted,
|
| 135 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from abc import abstractmethod
|
| 4 |
from collections.abc import Iterable
|
| 5 |
+
from typing import Callable, List, Optional, Union, TYPE_CHECKING
|
| 6 |
+
import logging
|
| 7 |
|
| 8 |
# pylint: disable=no-name-in-module
|
| 9 |
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 10 |
+
ListEntitiesBinarySensorResponse,
|
| 11 |
+
ListEntitiesButtonResponse,
|
| 12 |
ListEntitiesMediaPlayerResponse,
|
| 13 |
+
ListEntitiesNumberResponse,
|
| 14 |
ListEntitiesRequest,
|
| 15 |
+
ListEntitiesSelectResponse,
|
| 16 |
+
ListEntitiesSensorResponse,
|
| 17 |
+
ListEntitiesSwitchResponse,
|
| 18 |
+
ListEntitiesTextSensorResponse,
|
| 19 |
+
BinarySensorStateResponse,
|
| 20 |
+
ButtonCommandRequest,
|
| 21 |
MediaPlayerCommandRequest,
|
| 22 |
MediaPlayerStateResponse,
|
| 23 |
+
NumberCommandRequest,
|
| 24 |
+
NumberStateResponse,
|
| 25 |
+
SelectCommandRequest,
|
| 26 |
+
SelectStateResponse,
|
| 27 |
+
SensorStateResponse,
|
| 28 |
SubscribeHomeAssistantStatesRequest,
|
| 29 |
+
SubscribeStatesRequest,
|
| 30 |
+
SwitchCommandRequest,
|
| 31 |
+
SwitchStateResponse,
|
| 32 |
+
TextSensorStateResponse,
|
| 33 |
)
|
| 34 |
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
| 35 |
from google.protobuf import message
|
|
|
|
| 38 |
from .audio_player import AudioPlayer
|
| 39 |
from .util import call_all
|
| 40 |
|
| 41 |
+
if TYPE_CHECKING:
|
| 42 |
+
from reachy_mini import ReachyMini
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
|
| 47 |
class ESPHomeEntity:
|
| 48 |
"""Base class for ESPHome entities."""
|
|
|
|
| 157 |
volume=self.volume,
|
| 158 |
muted=self.muted,
|
| 159 |
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class TextSensorEntity(ESPHomeEntity):
|
| 163 |
+
"""Text sensor entity for ESPHome (read-only string values)."""
|
| 164 |
+
|
| 165 |
+
def __init__(
|
| 166 |
+
self,
|
| 167 |
+
server: APIServer,
|
| 168 |
+
key: int,
|
| 169 |
+
name: str,
|
| 170 |
+
object_id: str,
|
| 171 |
+
icon: str = "",
|
| 172 |
+
value_getter: Optional[Callable[[], str]] = None,
|
| 173 |
+
) -> None:
|
| 174 |
+
ESPHomeEntity.__init__(self, server)
|
| 175 |
+
self.key = key
|
| 176 |
+
self.name = name
|
| 177 |
+
self.object_id = object_id
|
| 178 |
+
self.icon = icon
|
| 179 |
+
self._value_getter = value_getter
|
| 180 |
+
self._value = ""
|
| 181 |
+
|
| 182 |
+
@property
|
| 183 |
+
def value(self) -> str:
|
| 184 |
+
if self._value_getter:
|
| 185 |
+
return self._value_getter()
|
| 186 |
+
return self._value
|
| 187 |
+
|
| 188 |
+
@value.setter
|
| 189 |
+
def value(self, new_value: str) -> None:
|
| 190 |
+
self._value = new_value
|
| 191 |
+
|
| 192 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 193 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 194 |
+
yield ListEntitiesTextSensorResponse(
|
| 195 |
+
object_id=self.object_id,
|
| 196 |
+
key=self.key,
|
| 197 |
+
name=self.name,
|
| 198 |
+
icon=self.icon,
|
| 199 |
+
)
|
| 200 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 201 |
+
yield self._get_state_message()
|
| 202 |
+
|
| 203 |
+
def _get_state_message(self) -> TextSensorStateResponse:
|
| 204 |
+
return TextSensorStateResponse(
|
| 205 |
+
key=self.key,
|
| 206 |
+
state=self.value,
|
| 207 |
+
missing_state=False,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def update_state(self) -> None:
|
| 211 |
+
"""Send state update to Home Assistant."""
|
| 212 |
+
self.server.send_messages([self._get_state_message()])
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class BinarySensorEntity(ESPHomeEntity):
|
| 216 |
+
"""Binary sensor entity for ESPHome (read-only boolean values)."""
|
| 217 |
+
|
| 218 |
+
def __init__(
|
| 219 |
+
self,
|
| 220 |
+
server: APIServer,
|
| 221 |
+
key: int,
|
| 222 |
+
name: str,
|
| 223 |
+
object_id: str,
|
| 224 |
+
icon: str = "",
|
| 225 |
+
device_class: str = "",
|
| 226 |
+
value_getter: Optional[Callable[[], bool]] = None,
|
| 227 |
+
) -> None:
|
| 228 |
+
ESPHomeEntity.__init__(self, server)
|
| 229 |
+
self.key = key
|
| 230 |
+
self.name = name
|
| 231 |
+
self.object_id = object_id
|
| 232 |
+
self.icon = icon
|
| 233 |
+
self.device_class = device_class
|
| 234 |
+
self._value_getter = value_getter
|
| 235 |
+
self._value = False
|
| 236 |
+
|
| 237 |
+
@property
|
| 238 |
+
def value(self) -> bool:
|
| 239 |
+
if self._value_getter:
|
| 240 |
+
return self._value_getter()
|
| 241 |
+
return self._value
|
| 242 |
+
|
| 243 |
+
@value.setter
|
| 244 |
+
def value(self, new_value: bool) -> None:
|
| 245 |
+
self._value = new_value
|
| 246 |
+
|
| 247 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 248 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 249 |
+
yield ListEntitiesBinarySensorResponse(
|
| 250 |
+
object_id=self.object_id,
|
| 251 |
+
key=self.key,
|
| 252 |
+
name=self.name,
|
| 253 |
+
icon=self.icon,
|
| 254 |
+
device_class=self.device_class,
|
| 255 |
+
)
|
| 256 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 257 |
+
yield self._get_state_message()
|
| 258 |
+
|
| 259 |
+
def _get_state_message(self) -> BinarySensorStateResponse:
|
| 260 |
+
return BinarySensorStateResponse(
|
| 261 |
+
key=self.key,
|
| 262 |
+
state=self.value,
|
| 263 |
+
missing_state=False,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
def update_state(self) -> None:
|
| 267 |
+
"""Send state update to Home Assistant."""
|
| 268 |
+
self.server.send_messages([self._get_state_message()])
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
class NumberEntity(ESPHomeEntity):
|
| 272 |
+
"""Number entity for ESPHome (read-write numeric values)."""
|
| 273 |
+
|
| 274 |
+
def __init__(
|
| 275 |
+
self,
|
| 276 |
+
server: APIServer,
|
| 277 |
+
key: int,
|
| 278 |
+
name: str,
|
| 279 |
+
object_id: str,
|
| 280 |
+
min_value: float = 0.0,
|
| 281 |
+
max_value: float = 100.0,
|
| 282 |
+
step: float = 1.0,
|
| 283 |
+
icon: str = "",
|
| 284 |
+
unit_of_measurement: str = "",
|
| 285 |
+
mode: int = 0, # 0 = auto, 1 = box, 2 = slider
|
| 286 |
+
value_getter: Optional[Callable[[], float]] = None,
|
| 287 |
+
value_setter: Optional[Callable[[float], None]] = None,
|
| 288 |
+
) -> None:
|
| 289 |
+
ESPHomeEntity.__init__(self, server)
|
| 290 |
+
self.key = key
|
| 291 |
+
self.name = name
|
| 292 |
+
self.object_id = object_id
|
| 293 |
+
self.min_value = min_value
|
| 294 |
+
self.max_value = max_value
|
| 295 |
+
self.step = step
|
| 296 |
+
self.icon = icon
|
| 297 |
+
self.unit_of_measurement = unit_of_measurement
|
| 298 |
+
self.mode = mode
|
| 299 |
+
self._value_getter = value_getter
|
| 300 |
+
self._value_setter = value_setter
|
| 301 |
+
self._value = min_value
|
| 302 |
+
|
| 303 |
+
@property
|
| 304 |
+
def value(self) -> float:
|
| 305 |
+
if self._value_getter:
|
| 306 |
+
return self._value_getter()
|
| 307 |
+
return self._value
|
| 308 |
+
|
| 309 |
+
@value.setter
|
| 310 |
+
def value(self, new_value: float) -> None:
|
| 311 |
+
# Clamp value to valid range
|
| 312 |
+
new_value = max(self.min_value, min(self.max_value, new_value))
|
| 313 |
+
if self._value_setter:
|
| 314 |
+
self._value_setter(new_value)
|
| 315 |
+
self._value = new_value
|
| 316 |
+
|
| 317 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 318 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 319 |
+
yield ListEntitiesNumberResponse(
|
| 320 |
+
object_id=self.object_id,
|
| 321 |
+
key=self.key,
|
| 322 |
+
name=self.name,
|
| 323 |
+
icon=self.icon,
|
| 324 |
+
min_value=self.min_value,
|
| 325 |
+
max_value=self.max_value,
|
| 326 |
+
step=self.step,
|
| 327 |
+
unit_of_measurement=self.unit_of_measurement,
|
| 328 |
+
mode=self.mode,
|
| 329 |
+
)
|
| 330 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 331 |
+
yield self._get_state_message()
|
| 332 |
+
elif isinstance(msg, NumberCommandRequest) and msg.key == self.key:
|
| 333 |
+
self.value = msg.state
|
| 334 |
+
yield self._get_state_message()
|
| 335 |
+
|
| 336 |
+
def _get_state_message(self) -> NumberStateResponse:
|
| 337 |
+
return NumberStateResponse(
|
| 338 |
+
key=self.key,
|
| 339 |
+
state=self.value,
|
| 340 |
+
missing_state=False,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
def update_state(self) -> None:
|
| 344 |
+
"""Send state update to Home Assistant."""
|
| 345 |
+
self.server.send_messages([self._get_state_message()])
|
reachy_mini_ha_voice/entity_extensions.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 SensorEntity(ESPHomeEntity):
|
| 31 |
+
"""Sensor entity for ESPHome (read-only numeric values)."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
server: APIServer,
|
| 36 |
+
key: int,
|
| 37 |
+
name: str,
|
| 38 |
+
object_id: str,
|
| 39 |
+
icon: str = "",
|
| 40 |
+
unit_of_measurement: str = "",
|
| 41 |
+
accuracy_decimals: int = 2,
|
| 42 |
+
device_class: str = "",
|
| 43 |
+
state_class: str = "",
|
| 44 |
+
value_getter: Optional[Callable[[], float]] = None,
|
| 45 |
+
) -> None:
|
| 46 |
+
ESPHomeEntity.__init__(self, server)
|
| 47 |
+
self.key = key
|
| 48 |
+
self.name = name
|
| 49 |
+
self.object_id = object_id
|
| 50 |
+
self.icon = icon
|
| 51 |
+
self.unit_of_measurement = unit_of_measurement
|
| 52 |
+
self.accuracy_decimals = accuracy_decimals
|
| 53 |
+
self.device_class = device_class
|
| 54 |
+
self.state_class = state_class
|
| 55 |
+
self._value_getter = value_getter
|
| 56 |
+
self._value = 0.0
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def value(self) -> float:
|
| 60 |
+
if self._value_getter:
|
| 61 |
+
return self._value_getter()
|
| 62 |
+
return self._value
|
| 63 |
+
|
| 64 |
+
@value.setter
|
| 65 |
+
def value(self, new_value: float) -> None:
|
| 66 |
+
self._value = new_value
|
| 67 |
+
|
| 68 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 69 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 70 |
+
yield ListEntitiesSensorResponse(
|
| 71 |
+
object_id=self.object_id,
|
| 72 |
+
key=self.key,
|
| 73 |
+
name=self.name,
|
| 74 |
+
icon=self.icon,
|
| 75 |
+
unit_of_measurement=self.unit_of_measurement,
|
| 76 |
+
accuracy_decimals=self.accuracy_decimals,
|
| 77 |
+
device_class=self.device_class,
|
| 78 |
+
state_class=self.state_class,
|
| 79 |
+
)
|
| 80 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 81 |
+
yield self._get_state_message()
|
| 82 |
+
|
| 83 |
+
def _get_state_message(self) -> SensorStateResponse:
|
| 84 |
+
return SensorStateResponse(
|
| 85 |
+
key=self.key,
|
| 86 |
+
state=self.value,
|
| 87 |
+
missing_state=False,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def update_state(self) -> None:
|
| 91 |
+
"""Send state update to Home Assistant."""
|
| 92 |
+
self.server.send_messages([self._get_state_message()])
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class SwitchEntity(ESPHomeEntity):
|
| 96 |
+
"""Switch entity for ESPHome (read-write boolean values)."""
|
| 97 |
+
|
| 98 |
+
def __init__(
|
| 99 |
+
self,
|
| 100 |
+
server: APIServer,
|
| 101 |
+
key: int,
|
| 102 |
+
name: str,
|
| 103 |
+
object_id: str,
|
| 104 |
+
icon: str = "",
|
| 105 |
+
device_class: str = "",
|
| 106 |
+
value_getter: Optional[Callable[[], bool]] = None,
|
| 107 |
+
value_setter: Optional[Callable[[bool], None]] = None,
|
| 108 |
+
) -> None:
|
| 109 |
+
ESPHomeEntity.__init__(self, server)
|
| 110 |
+
self.key = key
|
| 111 |
+
self.name = name
|
| 112 |
+
self.object_id = object_id
|
| 113 |
+
self.icon = icon
|
| 114 |
+
self.device_class = device_class
|
| 115 |
+
self._value_getter = value_getter
|
| 116 |
+
self._value_setter = value_setter
|
| 117 |
+
self._value = False
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def value(self) -> bool:
|
| 121 |
+
if self._value_getter:
|
| 122 |
+
return self._value_getter()
|
| 123 |
+
return self._value
|
| 124 |
+
|
| 125 |
+
@value.setter
|
| 126 |
+
def value(self, new_value: bool) -> None:
|
| 127 |
+
if self._value_setter:
|
| 128 |
+
self._value_setter(new_value)
|
| 129 |
+
self._value = new_value
|
| 130 |
+
|
| 131 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 132 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 133 |
+
yield ListEntitiesSwitchResponse(
|
| 134 |
+
object_id=self.object_id,
|
| 135 |
+
key=self.key,
|
| 136 |
+
name=self.name,
|
| 137 |
+
icon=self.icon,
|
| 138 |
+
device_class=self.device_class,
|
| 139 |
+
)
|
| 140 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 141 |
+
yield self._get_state_message()
|
| 142 |
+
elif isinstance(msg, SwitchCommandRequest) and msg.key == self.key:
|
| 143 |
+
self.value = msg.state
|
| 144 |
+
yield self._get_state_message()
|
| 145 |
+
|
| 146 |
+
def _get_state_message(self) -> SwitchStateResponse:
|
| 147 |
+
return SwitchStateResponse(
|
| 148 |
+
key=self.key,
|
| 149 |
+
state=self.value,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def update_state(self) -> None:
|
| 153 |
+
"""Send state update to Home Assistant."""
|
| 154 |
+
self.server.send_messages([self._get_state_message()])
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class SelectEntity(ESPHomeEntity):
|
| 158 |
+
"""Select entity for ESPHome (read-write string selection)."""
|
| 159 |
+
|
| 160 |
+
def __init__(
|
| 161 |
+
self,
|
| 162 |
+
server: APIServer,
|
| 163 |
+
key: int,
|
| 164 |
+
name: str,
|
| 165 |
+
object_id: str,
|
| 166 |
+
options: List[str],
|
| 167 |
+
icon: str = "",
|
| 168 |
+
value_getter: Optional[Callable[[], str]] = None,
|
| 169 |
+
value_setter: Optional[Callable[[str], None]] = None,
|
| 170 |
+
) -> None:
|
| 171 |
+
ESPHomeEntity.__init__(self, server)
|
| 172 |
+
self.key = key
|
| 173 |
+
self.name = name
|
| 174 |
+
self.object_id = object_id
|
| 175 |
+
self.options = options
|
| 176 |
+
self.icon = icon
|
| 177 |
+
self._value_getter = value_getter
|
| 178 |
+
self._value_setter = value_setter
|
| 179 |
+
self._value = options[0] if options else ""
|
| 180 |
+
|
| 181 |
+
@property
|
| 182 |
+
def value(self) -> str:
|
| 183 |
+
if self._value_getter:
|
| 184 |
+
return self._value_getter()
|
| 185 |
+
return self._value
|
| 186 |
+
|
| 187 |
+
@value.setter
|
| 188 |
+
def value(self, new_value: str) -> None:
|
| 189 |
+
if new_value in self.options:
|
| 190 |
+
if self._value_setter:
|
| 191 |
+
self._value_setter(new_value)
|
| 192 |
+
self._value = new_value
|
| 193 |
+
else:
|
| 194 |
+
logger.warning(f"Invalid option '{new_value}' for {self.name}")
|
| 195 |
+
|
| 196 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 197 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 198 |
+
yield ListEntitiesSelectResponse(
|
| 199 |
+
object_id=self.object_id,
|
| 200 |
+
key=self.key,
|
| 201 |
+
name=self.name,
|
| 202 |
+
icon=self.icon,
|
| 203 |
+
options=self.options,
|
| 204 |
+
)
|
| 205 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 206 |
+
yield self._get_state_message()
|
| 207 |
+
elif isinstance(msg, SelectCommandRequest) and msg.key == self.key:
|
| 208 |
+
self.value = msg.state
|
| 209 |
+
yield self._get_state_message()
|
| 210 |
+
|
| 211 |
+
def _get_state_message(self) -> SelectStateResponse:
|
| 212 |
+
return SelectStateResponse(
|
| 213 |
+
key=self.key,
|
| 214 |
+
state=self.value,
|
| 215 |
+
missing_state=False,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
def update_state(self) -> None:
|
| 219 |
+
"""Send state update to Home Assistant."""
|
| 220 |
+
self.server.send_messages([self._get_state_message()])
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class ButtonEntity(ESPHomeEntity):
|
| 224 |
+
"""Button entity for ESPHome (trigger actions)."""
|
| 225 |
+
|
| 226 |
+
def __init__(
|
| 227 |
+
self,
|
| 228 |
+
server: APIServer,
|
| 229 |
+
key: int,
|
| 230 |
+
name: str,
|
| 231 |
+
object_id: str,
|
| 232 |
+
icon: str = "",
|
| 233 |
+
device_class: str = "",
|
| 234 |
+
on_press: Optional[Callable[[], None]] = None,
|
| 235 |
+
) -> None:
|
| 236 |
+
ESPHomeEntity.__init__(self, server)
|
| 237 |
+
self.key = key
|
| 238 |
+
self.name = name
|
| 239 |
+
self.object_id = object_id
|
| 240 |
+
self.icon = icon
|
| 241 |
+
self.device_class = device_class
|
| 242 |
+
self._on_press = on_press
|
| 243 |
+
|
| 244 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 245 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 246 |
+
yield ListEntitiesButtonResponse(
|
| 247 |
+
object_id=self.object_id,
|
| 248 |
+
key=self.key,
|
| 249 |
+
name=self.name,
|
| 250 |
+
icon=self.icon,
|
| 251 |
+
device_class=self.device_class,
|
| 252 |
+
)
|
| 253 |
+
elif isinstance(msg, ButtonCommandRequest) and msg.key == self.key:
|
| 254 |
+
if self._on_press:
|
| 255 |
+
try:
|
| 256 |
+
self._on_press()
|
| 257 |
+
logger.info(f"Button '{self.name}' pressed")
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Error executing button '{self.name}': {e}")
|
| 260 |
+
# Buttons don't have state responses
|
| 261 |
+
return
|
| 262 |
+
yield # Make this a generator
|
reachy_mini_ha_voice/main.py
CHANGED
|
@@ -88,9 +88,13 @@ class ReachyMiniHAVoiceApp(ReachyMiniApp):
|
|
| 88 |
try:
|
| 89 |
logger.info("Attempting to connect to Reachy Mini...")
|
| 90 |
super().wrapped_run(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
except Exception as e:
|
| 92 |
error_str = str(e)
|
| 93 |
-
if "Unable to connect" in error_str or "ZError" in error_str:
|
| 94 |
logger.warning(f"Failed to connect to Reachy Mini: {e}")
|
| 95 |
logger.info("Falling back to standalone mode")
|
| 96 |
self._run_standalone()
|
|
|
|
| 88 |
try:
|
| 89 |
logger.info("Attempting to connect to Reachy Mini...")
|
| 90 |
super().wrapped_run(*args, **kwargs)
|
| 91 |
+
except TimeoutError as e:
|
| 92 |
+
logger.warning(f"Timeout connecting to Reachy Mini: {e}")
|
| 93 |
+
logger.info("Falling back to standalone mode")
|
| 94 |
+
self._run_standalone()
|
| 95 |
except Exception as e:
|
| 96 |
error_str = str(e)
|
| 97 |
+
if "Unable to connect" in error_str or "ZError" in error_str or "Timeout" in error_str:
|
| 98 |
logger.warning(f"Failed to connect to Reachy Mini: {e}")
|
| 99 |
logger.info("Falling back to standalone mode")
|
| 100 |
self._run_standalone()
|
reachy_mini_ha_voice/reachy_controller.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reachy Mini controller wrapper for ESPHome entities."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional, TYPE_CHECKING
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from reachy_mini import ReachyMini
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ReachyController:
|
| 14 |
+
"""
|
| 15 |
+
Wrapper class for Reachy Mini control operations.
|
| 16 |
+
|
| 17 |
+
Provides safe access to Reachy Mini SDK functions with error handling
|
| 18 |
+
and fallback for standalone mode (when robot is not available).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, reachy_mini: Optional["ReachyMini"] = None):
|
| 22 |
+
"""
|
| 23 |
+
Initialize the controller.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
reachy_mini: ReachyMini instance, or None for standalone mode
|
| 27 |
+
"""
|
| 28 |
+
self.reachy = reachy_mini
|
| 29 |
+
self._speaker_volume = 100 # Default volume
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def is_available(self) -> bool:
|
| 33 |
+
"""Check if robot is available."""
|
| 34 |
+
return self.reachy is not None
|
| 35 |
+
|
| 36 |
+
# ========== Phase 1: Basic Status & Volume ==========
|
| 37 |
+
|
| 38 |
+
def get_daemon_state(self) -> str:
|
| 39 |
+
"""Get daemon state."""
|
| 40 |
+
if not self.is_available:
|
| 41 |
+
return "not_available"
|
| 42 |
+
try:
|
| 43 |
+
status = self.reachy.get_daemon_status()
|
| 44 |
+
return status.state.value if hasattr(status, 'state') else "unknown"
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"Error getting daemon state: {e}")
|
| 47 |
+
return "error"
|
| 48 |
+
|
| 49 |
+
def get_backend_ready(self) -> bool:
|
| 50 |
+
"""Check if backend is ready."""
|
| 51 |
+
if not self.is_available:
|
| 52 |
+
return False
|
| 53 |
+
try:
|
| 54 |
+
status = self.reachy.get_backend_status()
|
| 55 |
+
return status.ready if hasattr(status, 'ready') else False
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Error getting backend status: {e}")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
def get_error_message(self) -> str:
|
| 61 |
+
"""Get current error message."""
|
| 62 |
+
if not self.is_available:
|
| 63 |
+
return "Robot not available"
|
| 64 |
+
try:
|
| 65 |
+
status = self.reachy.get_daemon_status()
|
| 66 |
+
return status.error if hasattr(status, 'error') else ""
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Error getting error message: {e}")
|
| 69 |
+
return str(e)
|
| 70 |
+
|
| 71 |
+
def get_speaker_volume(self) -> float:
|
| 72 |
+
"""Get speaker volume (0-100)."""
|
| 73 |
+
return self._speaker_volume
|
| 74 |
+
|
| 75 |
+
def set_speaker_volume(self, volume: float) -> None:
|
| 76 |
+
"""
|
| 77 |
+
Set speaker volume (0-100).
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
volume: Volume level 0-100
|
| 81 |
+
"""
|
| 82 |
+
self._speaker_volume = max(0.0, min(100.0, volume))
|
| 83 |
+
logger.info(f"Speaker volume set to {self._speaker_volume}%")
|
| 84 |
+
# Note: Actual volume control is handled by AudioPlayer
|
| 85 |
+
|
| 86 |
+
# ========== Phase 2: Motor Control ==========
|
| 87 |
+
|
| 88 |
+
def get_motors_enabled(self) -> bool:
|
| 89 |
+
"""Check if motors are enabled."""
|
| 90 |
+
if not self.is_available:
|
| 91 |
+
return False
|
| 92 |
+
try:
|
| 93 |
+
state = self.reachy.get_full_state()
|
| 94 |
+
return state.control_mode.value == "enabled"
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"Error getting motor state: {e}")
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
def set_motors_enabled(self, enabled: bool) -> None:
|
| 100 |
+
"""
|
| 101 |
+
Enable or disable motors.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
enabled: True to enable, False to disable
|
| 105 |
+
"""
|
| 106 |
+
if not self.is_available:
|
| 107 |
+
logger.warning("Cannot control motors: robot not available")
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
if enabled:
|
| 112 |
+
self.reachy.enable_motors()
|
| 113 |
+
logger.info("Motors enabled")
|
| 114 |
+
else:
|
| 115 |
+
self.reachy.disable_motors()
|
| 116 |
+
logger.info("Motors disabled")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Error setting motor state: {e}")
|
| 119 |
+
|
| 120 |
+
def get_motor_mode(self) -> str:
|
| 121 |
+
"""Get current motor control mode."""
|
| 122 |
+
if not self.is_available:
|
| 123 |
+
return "disabled"
|
| 124 |
+
try:
|
| 125 |
+
state = self.reachy.get_full_state()
|
| 126 |
+
return state.control_mode.value
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Error getting motor mode: {e}")
|
| 129 |
+
return "error"
|
| 130 |
+
|
| 131 |
+
def set_motor_mode(self, mode: str) -> None:
|
| 132 |
+
"""
|
| 133 |
+
Set motor control mode.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
mode: One of "enabled", "disabled", "gravity_compensation"
|
| 137 |
+
"""
|
| 138 |
+
if not self.is_available:
|
| 139 |
+
logger.warning("Cannot set motor mode: robot not available")
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
if mode == "enabled":
|
| 144 |
+
self.reachy.enable_motors()
|
| 145 |
+
elif mode == "disabled":
|
| 146 |
+
self.reachy.disable_motors()
|
| 147 |
+
elif mode == "gravity_compensation":
|
| 148 |
+
self.reachy.set_motor_control_mode("gravity_compensation")
|
| 149 |
+
else:
|
| 150 |
+
logger.warning(f"Invalid motor mode: {mode}")
|
| 151 |
+
return
|
| 152 |
+
logger.info(f"Motor mode set to {mode}")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"Error setting motor mode: {e}")
|
| 155 |
+
|
| 156 |
+
def wake_up(self) -> None:
|
| 157 |
+
"""Execute wake up animation."""
|
| 158 |
+
if not self.is_available:
|
| 159 |
+
logger.warning("Cannot wake up: robot not available")
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
self.reachy.wake_up()
|
| 164 |
+
logger.info("Wake up animation executed")
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.error(f"Error executing wake up: {e}")
|
| 167 |
+
|
| 168 |
+
def go_to_sleep(self) -> None:
|
| 169 |
+
"""Execute sleep animation."""
|
| 170 |
+
if not self.is_available:
|
| 171 |
+
logger.warning("Cannot sleep: robot not available")
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
self.reachy.goto_sleep()
|
| 176 |
+
logger.info("Sleep animation executed")
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"Error executing sleep: {e}")
|
| 179 |
+
|
| 180 |
+
# ========== Phase 3: Pose Control ==========
|
| 181 |
+
|
| 182 |
+
def get_head_x(self) -> float:
|
| 183 |
+
"""Get head X position in mm."""
|
| 184 |
+
if not self.is_available:
|
| 185 |
+
return 0.0
|
| 186 |
+
try:
|
| 187 |
+
pose = self.reachy.get_current_head_pose()
|
| 188 |
+
return pose.x * 1000 # Convert m to mm
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"Error getting head X: {e}")
|
| 191 |
+
return 0.0
|
| 192 |
+
|
| 193 |
+
def set_head_x(self, x_mm: float) -> None:
|
| 194 |
+
"""Set head X position in mm."""
|
| 195 |
+
if not self.is_available:
|
| 196 |
+
return
|
| 197 |
+
try:
|
| 198 |
+
current = self.reachy.get_current_head_pose()
|
| 199 |
+
self.reachy.goto_target(
|
| 200 |
+
head=(x_mm / 1000, current.y, current.z, current.roll, current.pitch, current.yaw)
|
| 201 |
+
)
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"Error setting head X: {e}")
|
| 204 |
+
|
| 205 |
+
def get_head_y(self) -> float:
|
| 206 |
+
"""Get head Y position in mm."""
|
| 207 |
+
if not self.is_available:
|
| 208 |
+
return 0.0
|
| 209 |
+
try:
|
| 210 |
+
pose = self.reachy.get_current_head_pose()
|
| 211 |
+
return pose.y * 1000
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.error(f"Error getting head Y: {e}")
|
| 214 |
+
return 0.0
|
| 215 |
+
|
| 216 |
+
def set_head_y(self, y_mm: float) -> None:
|
| 217 |
+
"""Set head Y position in mm."""
|
| 218 |
+
if not self.is_available:
|
| 219 |
+
return
|
| 220 |
+
try:
|
| 221 |
+
current = self.reachy.get_current_head_pose()
|
| 222 |
+
self.reachy.goto_target(
|
| 223 |
+
head=(current.x, y_mm / 1000, current.z, current.roll, current.pitch, current.yaw)
|
| 224 |
+
)
|
| 225 |
+
except Exception as e:
|
| 226 |
+
logger.error(f"Error setting head Y: {e}")
|
| 227 |
+
|
| 228 |
+
def get_head_z(self) -> float:
|
| 229 |
+
"""Get head Z position in mm."""
|
| 230 |
+
if not self.is_available:
|
| 231 |
+
return 0.0
|
| 232 |
+
try:
|
| 233 |
+
pose = self.reachy.get_current_head_pose()
|
| 234 |
+
return pose.z * 1000
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error getting head Z: {e}")
|
| 237 |
+
return 0.0
|
| 238 |
+
|
| 239 |
+
def set_head_z(self, z_mm: float) -> None:
|
| 240 |
+
"""Set head Z position in mm."""
|
| 241 |
+
if not self.is_available:
|
| 242 |
+
return
|
| 243 |
+
try:
|
| 244 |
+
current = self.reachy.get_current_head_pose()
|
| 245 |
+
self.reachy.goto_target(
|
| 246 |
+
head=(current.x, current.y, z_mm / 1000, current.roll, current.pitch, current.yaw)
|
| 247 |
+
)
|
| 248 |
+
except Exception as e:
|
| 249 |
+
logger.error(f"Error setting head Z: {e}")
|
| 250 |
+
|
| 251 |
+
def get_head_roll(self) -> float:
|
| 252 |
+
"""Get head roll angle in degrees."""
|
| 253 |
+
if not self.is_available:
|
| 254 |
+
return 0.0
|
| 255 |
+
try:
|
| 256 |
+
pose = self.reachy.get_current_head_pose()
|
| 257 |
+
return math.degrees(pose.roll)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Error getting head roll: {e}")
|
| 260 |
+
return 0.0
|
| 261 |
+
|
| 262 |
+
def set_head_roll(self, roll_deg: float) -> None:
|
| 263 |
+
"""Set head roll angle in degrees."""
|
| 264 |
+
if not self.is_available:
|
| 265 |
+
return
|
| 266 |
+
try:
|
| 267 |
+
current = self.reachy.get_current_head_pose()
|
| 268 |
+
self.reachy.goto_target(
|
| 269 |
+
head=(current.x, current.y, current.z, math.radians(roll_deg), current.pitch, current.yaw)
|
| 270 |
+
)
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"Error setting head roll: {e}")
|
| 273 |
+
|
| 274 |
+
def get_head_pitch(self) -> float:
|
| 275 |
+
"""Get head pitch angle in degrees."""
|
| 276 |
+
if not self.is_available:
|
| 277 |
+
return 0.0
|
| 278 |
+
try:
|
| 279 |
+
pose = self.reachy.get_current_head_pose()
|
| 280 |
+
return math.degrees(pose.pitch)
|
| 281 |
+
except Exception as e:
|
| 282 |
+
logger.error(f"Error getting head pitch: {e}")
|
| 283 |
+
return 0.0
|
| 284 |
+
|
| 285 |
+
def set_head_pitch(self, pitch_deg: float) -> None:
|
| 286 |
+
"""Set head pitch angle in degrees."""
|
| 287 |
+
if not self.is_available:
|
| 288 |
+
return
|
| 289 |
+
try:
|
| 290 |
+
current = self.reachy.get_current_head_pose()
|
| 291 |
+
self.reachy.goto_target(
|
| 292 |
+
head=(current.x, current.y, current.z, current.roll, math.radians(pitch_deg), current.yaw)
|
| 293 |
+
)
|
| 294 |
+
except Exception as e:
|
| 295 |
+
logger.error(f"Error setting head pitch: {e}")
|
| 296 |
+
|
| 297 |
+
def get_head_yaw(self) -> float:
|
| 298 |
+
"""Get head yaw angle in degrees."""
|
| 299 |
+
if not self.is_available:
|
| 300 |
+
return 0.0
|
| 301 |
+
try:
|
| 302 |
+
pose = self.reachy.get_current_head_pose()
|
| 303 |
+
return math.degrees(pose.yaw)
|
| 304 |
+
except Exception as e:
|
| 305 |
+
logger.error(f"Error getting head yaw: {e}")
|
| 306 |
+
return 0.0
|
| 307 |
+
|
| 308 |
+
def set_head_yaw(self, yaw_deg: float) -> None:
|
| 309 |
+
"""Set head yaw angle in degrees."""
|
| 310 |
+
if not self.is_available:
|
| 311 |
+
return
|
| 312 |
+
try:
|
| 313 |
+
current = self.reachy.get_current_head_pose()
|
| 314 |
+
self.reachy.goto_target(
|
| 315 |
+
head=(current.x, current.y, current.z, current.roll, current.pitch, math.radians(yaw_deg))
|
| 316 |
+
)
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.error(f"Error setting head yaw: {e}")
|
| 319 |
+
|
| 320 |
+
def get_body_yaw(self) -> float:
|
| 321 |
+
"""Get body yaw angle in degrees."""
|
| 322 |
+
if not self.is_available:
|
| 323 |
+
return 0.0
|
| 324 |
+
try:
|
| 325 |
+
state = self.reachy.get_full_state()
|
| 326 |
+
return math.degrees(state.body_yaw)
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"Error getting body yaw: {e}")
|
| 329 |
+
return 0.0
|
| 330 |
+
|
| 331 |
+
def set_body_yaw(self, yaw_deg: float) -> None:
|
| 332 |
+
"""Set body yaw angle in degrees."""
|
| 333 |
+
if not self.is_available:
|
| 334 |
+
return
|
| 335 |
+
try:
|
| 336 |
+
self.reachy.goto_target(body_yaw=math.radians(yaw_deg))
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logger.error(f"Error setting body yaw: {e}")
|
| 339 |
+
|
| 340 |
+
def get_antenna_left(self) -> float:
|
| 341 |
+
"""Get left antenna angle in degrees."""
|
| 342 |
+
if not self.is_available:
|
| 343 |
+
return 0.0
|
| 344 |
+
try:
|
| 345 |
+
state = self.reachy.get_full_state()
|
| 346 |
+
# antennas_position is [right, left]
|
| 347 |
+
return math.degrees(state.antennas_position[1])
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logger.error(f"Error getting left antenna: {e}")
|
| 350 |
+
return 0.0
|
| 351 |
+
|
| 352 |
+
def set_antenna_left(self, angle_deg: float) -> None:
|
| 353 |
+
"""Set left antenna angle in degrees."""
|
| 354 |
+
if not self.is_available:
|
| 355 |
+
return
|
| 356 |
+
try:
|
| 357 |
+
state = self.reachy.get_full_state()
|
| 358 |
+
right = state.antennas_position[0]
|
| 359 |
+
self.reachy.goto_target(antennas=(right, math.radians(angle_deg)))
|
| 360 |
+
except Exception as e:
|
| 361 |
+
logger.error(f"Error setting left antenna: {e}")
|
| 362 |
+
|
| 363 |
+
def get_antenna_right(self) -> float:
|
| 364 |
+
"""Get right antenna angle in degrees."""
|
| 365 |
+
if not self.is_available:
|
| 366 |
+
return 0.0
|
| 367 |
+
try:
|
| 368 |
+
state = self.reachy.get_full_state()
|
| 369 |
+
return math.degrees(state.antennas_position[0])
|
| 370 |
+
except Exception as e:
|
| 371 |
+
logger.error(f"Error getting right antenna: {e}")
|
| 372 |
+
return 0.0
|
| 373 |
+
|
| 374 |
+
def set_antenna_right(self, angle_deg: float) -> None:
|
| 375 |
+
"""Set right antenna angle in degrees."""
|
| 376 |
+
if not self.is_available:
|
| 377 |
+
return
|
| 378 |
+
try:
|
| 379 |
+
state = self.reachy.get_full_state()
|
| 380 |
+
left = state.antennas_position[1]
|
| 381 |
+
self.reachy.goto_target(antennas=(math.radians(angle_deg), left))
|
| 382 |
+
except Exception as e:
|
| 383 |
+
logger.error(f"Error setting right antenna: {e}")
|
reachy_mini_ha_voice/satellite.py
CHANGED
|
@@ -40,9 +40,11 @@ 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 |
from .util import call_all
|
|
|
|
| 46 |
|
| 47 |
_LOGGER = logging.getLogger(__name__)
|
| 48 |
|
|
@@ -55,6 +57,9 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 55 |
self.state = state
|
| 56 |
self.state.satellite = self
|
| 57 |
|
|
|
|
|
|
|
|
|
|
| 58 |
if self.state.media_player_entity is None:
|
| 59 |
self.state.media_player_entity = MediaPlayerEntity(
|
| 60 |
server=self,
|
|
@@ -66,6 +71,11 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 66 |
)
|
| 67 |
self.state.entities.append(self.state.media_player_entity)
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
self._is_streaming_audio = False
|
| 70 |
self._tts_url: Optional[str] = None
|
| 71 |
self._tts_played = False
|
|
@@ -474,3 +484,271 @@ class VoiceSatelliteProtocol(APIServer):
|
|
| 474 |
_LOGGER.debug("Reachy Mini: Timer finished animation")
|
| 475 |
except Exception as e:
|
| 476 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
from pyopen_wakeword import OpenWakeWord
|
| 41 |
|
| 42 |
from .api_server import APIServer
|
| 43 |
+
from .entity import BinarySensorEntity, MediaPlayerEntity, NumberEntity, TextSensorEntity
|
| 44 |
+
from .entity_extensions import SensorEntity, SwitchEntity, SelectEntity, ButtonEntity
|
| 45 |
from .models import AvailableWakeWord, ServerState, WakeWordType
|
| 46 |
from .util import call_all
|
| 47 |
+
from .reachy_controller import ReachyController
|
| 48 |
|
| 49 |
_LOGGER = logging.getLogger(__name__)
|
| 50 |
|
|
|
|
| 57 |
self.state = state
|
| 58 |
self.state.satellite = self
|
| 59 |
|
| 60 |
+
# Initialize Reachy controller
|
| 61 |
+
self.reachy_controller = ReachyController(state.reachy_mini)
|
| 62 |
+
|
| 63 |
if self.state.media_player_entity is None:
|
| 64 |
self.state.media_player_entity = MediaPlayerEntity(
|
| 65 |
server=self,
|
|
|
|
| 71 |
)
|
| 72 |
self.state.entities.append(self.state.media_player_entity)
|
| 73 |
|
| 74 |
+
# Setup all entity phases
|
| 75 |
+
self._setup_phase1_entities()
|
| 76 |
+
self._setup_phase2_entities()
|
| 77 |
+
self._setup_phase3_entities()
|
| 78 |
+
|
| 79 |
self._is_streaming_audio = False
|
| 80 |
self._tts_url: Optional[str] = None
|
| 81 |
self._tts_played = False
|
|
|
|
| 484 |
_LOGGER.debug("Reachy Mini: Timer finished animation")
|
| 485 |
except Exception as e:
|
| 486 |
_LOGGER.error("Reachy Mini motion error: %s", e)
|
| 487 |
+
|
| 488 |
+
# -------------------------------------------------------------------------
|
| 489 |
+
# Entity Setup Methods
|
| 490 |
+
# -------------------------------------------------------------------------
|
| 491 |
+
|
| 492 |
+
def _setup_phase1_entities(self) -> None:
|
| 493 |
+
"""Setup Phase 1 entities: Basic status and volume control."""
|
| 494 |
+
|
| 495 |
+
# Daemon state sensor
|
| 496 |
+
daemon_state_sensor = TextSensorEntity(
|
| 497 |
+
server=self,
|
| 498 |
+
key=len(self.state.entities),
|
| 499 |
+
name="Daemon State",
|
| 500 |
+
object_id="daemon_state",
|
| 501 |
+
icon="mdi:robot",
|
| 502 |
+
value_getter=self.reachy_controller.get_daemon_state,
|
| 503 |
+
)
|
| 504 |
+
self.state.entities.append(daemon_state_sensor)
|
| 505 |
+
|
| 506 |
+
# Backend ready sensor
|
| 507 |
+
backend_ready_sensor = BinarySensorEntity(
|
| 508 |
+
server=self,
|
| 509 |
+
key=len(self.state.entities),
|
| 510 |
+
name="Backend Ready",
|
| 511 |
+
object_id="backend_ready",
|
| 512 |
+
icon="mdi:check-circle",
|
| 513 |
+
device_class="connectivity",
|
| 514 |
+
value_getter=self.reachy_controller.get_backend_ready,
|
| 515 |
+
)
|
| 516 |
+
self.state.entities.append(backend_ready_sensor)
|
| 517 |
+
|
| 518 |
+
# Error message sensor
|
| 519 |
+
error_message_sensor = TextSensorEntity(
|
| 520 |
+
server=self,
|
| 521 |
+
key=len(self.state.entities),
|
| 522 |
+
name="Error Message",
|
| 523 |
+
object_id="error_message",
|
| 524 |
+
icon="mdi:alert-circle",
|
| 525 |
+
value_getter=self.reachy_controller.get_error_message,
|
| 526 |
+
)
|
| 527 |
+
self.state.entities.append(error_message_sensor)
|
| 528 |
+
|
| 529 |
+
# Speaker volume control
|
| 530 |
+
speaker_volume = NumberEntity(
|
| 531 |
+
server=self,
|
| 532 |
+
key=len(self.state.entities),
|
| 533 |
+
name="Speaker Volume",
|
| 534 |
+
object_id="speaker_volume",
|
| 535 |
+
min_value=0.0,
|
| 536 |
+
max_value=100.0,
|
| 537 |
+
step=1.0,
|
| 538 |
+
icon="mdi:volume-high",
|
| 539 |
+
unit_of_measurement="%",
|
| 540 |
+
mode=2, # Slider mode
|
| 541 |
+
value_getter=self.reachy_controller.get_speaker_volume,
|
| 542 |
+
value_setter=self.reachy_controller.set_speaker_volume,
|
| 543 |
+
)
|
| 544 |
+
self.state.entities.append(speaker_volume)
|
| 545 |
+
|
| 546 |
+
_LOGGER.info("Phase 1 entities registered: daemon_state, backend_ready, error_message, speaker_volume")
|
| 547 |
+
|
| 548 |
+
def _setup_phase2_entities(self) -> None:
|
| 549 |
+
"""Setup Phase 2 entities: Motor control."""
|
| 550 |
+
|
| 551 |
+
# Motors enabled switch
|
| 552 |
+
motors_enabled = SwitchEntity(
|
| 553 |
+
server=self,
|
| 554 |
+
key=len(self.state.entities),
|
| 555 |
+
name="Motors Enabled",
|
| 556 |
+
object_id="motors_enabled",
|
| 557 |
+
icon="mdi:engine",
|
| 558 |
+
device_class="switch",
|
| 559 |
+
value_getter=self.reachy_controller.get_motors_enabled,
|
| 560 |
+
value_setter=self.reachy_controller.set_motors_enabled,
|
| 561 |
+
)
|
| 562 |
+
self.state.entities.append(motors_enabled)
|
| 563 |
+
|
| 564 |
+
# Motor mode select
|
| 565 |
+
motor_mode = SelectEntity(
|
| 566 |
+
server=self,
|
| 567 |
+
key=len(self.state.entities),
|
| 568 |
+
name="Motor Mode",
|
| 569 |
+
object_id="motor_mode",
|
| 570 |
+
options=["enabled", "disabled", "gravity_compensation"],
|
| 571 |
+
icon="mdi:cog",
|
| 572 |
+
value_getter=self.reachy_controller.get_motor_mode,
|
| 573 |
+
value_setter=self.reachy_controller.set_motor_mode,
|
| 574 |
+
)
|
| 575 |
+
self.state.entities.append(motor_mode)
|
| 576 |
+
|
| 577 |
+
# Wake up button
|
| 578 |
+
wake_up_button = ButtonEntity(
|
| 579 |
+
server=self,
|
| 580 |
+
key=len(self.state.entities),
|
| 581 |
+
name="Wake Up",
|
| 582 |
+
object_id="wake_up",
|
| 583 |
+
icon="mdi:alarm",
|
| 584 |
+
device_class="restart",
|
| 585 |
+
on_press=self.reachy_controller.wake_up,
|
| 586 |
+
)
|
| 587 |
+
self.state.entities.append(wake_up_button)
|
| 588 |
+
|
| 589 |
+
# Go to sleep button
|
| 590 |
+
sleep_button = ButtonEntity(
|
| 591 |
+
server=self,
|
| 592 |
+
key=len(self.state.entities),
|
| 593 |
+
name="Go to Sleep",
|
| 594 |
+
object_id="go_to_sleep",
|
| 595 |
+
icon="mdi:sleep",
|
| 596 |
+
device_class="restart",
|
| 597 |
+
on_press=self.reachy_controller.go_to_sleep,
|
| 598 |
+
)
|
| 599 |
+
self.state.entities.append(sleep_button)
|
| 600 |
+
|
| 601 |
+
_LOGGER.info("Phase 2 entities registered: motors_enabled, motor_mode, wake_up, go_to_sleep")
|
| 602 |
+
|
| 603 |
+
def _setup_phase3_entities(self) -> None:
|
| 604 |
+
"""Setup Phase 3 entities: Pose control."""
|
| 605 |
+
|
| 606 |
+
# Head position controls (X, Y, Z in mm)
|
| 607 |
+
head_x = NumberEntity(
|
| 608 |
+
server=self,
|
| 609 |
+
key=len(self.state.entities),
|
| 610 |
+
name="Head X Position",
|
| 611 |
+
object_id="head_x",
|
| 612 |
+
min_value=-50.0,
|
| 613 |
+
max_value=50.0,
|
| 614 |
+
step=1.0,
|
| 615 |
+
icon="mdi:axis-x-arrow",
|
| 616 |
+
unit_of_measurement="mm",
|
| 617 |
+
mode=2, # Slider
|
| 618 |
+
value_getter=self.reachy_controller.get_head_x,
|
| 619 |
+
value_setter=self.reachy_controller.set_head_x,
|
| 620 |
+
)
|
| 621 |
+
self.state.entities.append(head_x)
|
| 622 |
+
|
| 623 |
+
head_y = NumberEntity(
|
| 624 |
+
server=self,
|
| 625 |
+
key=len(self.state.entities),
|
| 626 |
+
name="Head Y Position",
|
| 627 |
+
object_id="head_y",
|
| 628 |
+
min_value=-50.0,
|
| 629 |
+
max_value=50.0,
|
| 630 |
+
step=1.0,
|
| 631 |
+
icon="mdi:axis-y-arrow",
|
| 632 |
+
unit_of_measurement="mm",
|
| 633 |
+
mode=2,
|
| 634 |
+
value_getter=self.reachy_controller.get_head_y,
|
| 635 |
+
value_setter=self.reachy_controller.set_head_y,
|
| 636 |
+
)
|
| 637 |
+
self.state.entities.append(head_y)
|
| 638 |
+
|
| 639 |
+
head_z = NumberEntity(
|
| 640 |
+
server=self,
|
| 641 |
+
key=len(self.state.entities),
|
| 642 |
+
name="Head Z Position",
|
| 643 |
+
object_id="head_z",
|
| 644 |
+
min_value=-50.0,
|
| 645 |
+
max_value=50.0,
|
| 646 |
+
step=1.0,
|
| 647 |
+
icon="mdi:axis-z-arrow",
|
| 648 |
+
unit_of_measurement="mm",
|
| 649 |
+
mode=2,
|
| 650 |
+
value_getter=self.reachy_controller.get_head_z,
|
| 651 |
+
value_setter=self.reachy_controller.set_head_z,
|
| 652 |
+
)
|
| 653 |
+
self.state.entities.append(head_z)
|
| 654 |
+
|
| 655 |
+
# Head orientation controls (Roll, Pitch, Yaw in degrees)
|
| 656 |
+
head_roll = NumberEntity(
|
| 657 |
+
server=self,
|
| 658 |
+
key=len(self.state.entities),
|
| 659 |
+
name="Head Roll",
|
| 660 |
+
object_id="head_roll",
|
| 661 |
+
min_value=-40.0,
|
| 662 |
+
max_value=40.0,
|
| 663 |
+
step=1.0,
|
| 664 |
+
icon="mdi:rotate-3d-variant",
|
| 665 |
+
unit_of_measurement="°",
|
| 666 |
+
mode=2,
|
| 667 |
+
value_getter=self.reachy_controller.get_head_roll,
|
| 668 |
+
value_setter=self.reachy_controller.set_head_roll,
|
| 669 |
+
)
|
| 670 |
+
self.state.entities.append(head_roll)
|
| 671 |
+
|
| 672 |
+
head_pitch = NumberEntity(
|
| 673 |
+
server=self,
|
| 674 |
+
key=len(self.state.entities),
|
| 675 |
+
name="Head Pitch",
|
| 676 |
+
object_id="head_pitch",
|
| 677 |
+
min_value=-40.0,
|
| 678 |
+
max_value=40.0,
|
| 679 |
+
step=1.0,
|
| 680 |
+
icon="mdi:rotate-3d-variant",
|
| 681 |
+
unit_of_measurement="°",
|
| 682 |
+
mode=2,
|
| 683 |
+
value_getter=self.reachy_controller.get_head_pitch,
|
| 684 |
+
value_setter=self.reachy_controller.set_head_pitch,
|
| 685 |
+
)
|
| 686 |
+
self.state.entities.append(head_pitch)
|
| 687 |
+
|
| 688 |
+
head_yaw = NumberEntity(
|
| 689 |
+
server=self,
|
| 690 |
+
key=len(self.state.entities),
|
| 691 |
+
name="Head Yaw",
|
| 692 |
+
object_id="head_yaw",
|
| 693 |
+
min_value=-180.0,
|
| 694 |
+
max_value=180.0,
|
| 695 |
+
step=1.0,
|
| 696 |
+
icon="mdi:rotate-3d-variant",
|
| 697 |
+
unit_of_measurement="°",
|
| 698 |
+
mode=2,
|
| 699 |
+
value_getter=self.reachy_controller.get_head_yaw,
|
| 700 |
+
value_setter=self.reachy_controller.set_head_yaw,
|
| 701 |
+
)
|
| 702 |
+
self.state.entities.append(head_yaw)
|
| 703 |
+
|
| 704 |
+
# Body yaw control
|
| 705 |
+
body_yaw = NumberEntity(
|
| 706 |
+
server=self,
|
| 707 |
+
key=len(self.state.entities),
|
| 708 |
+
name="Body Yaw",
|
| 709 |
+
object_id="body_yaw",
|
| 710 |
+
min_value=-160.0,
|
| 711 |
+
max_value=160.0,
|
| 712 |
+
step=1.0,
|
| 713 |
+
icon="mdi:rotate-3d-variant",
|
| 714 |
+
unit_of_measurement="°",
|
| 715 |
+
mode=2,
|
| 716 |
+
value_getter=self.reachy_controller.get_body_yaw,
|
| 717 |
+
value_setter=self.reachy_controller.set_body_yaw,
|
| 718 |
+
)
|
| 719 |
+
self.state.entities.append(body_yaw)
|
| 720 |
+
|
| 721 |
+
# Antenna controls
|
| 722 |
+
antenna_left = NumberEntity(
|
| 723 |
+
server=self,
|
| 724 |
+
key=len(self.state.entities),
|
| 725 |
+
name="Left Antenna",
|
| 726 |
+
object_id="antenna_left",
|
| 727 |
+
min_value=-90.0,
|
| 728 |
+
max_value=90.0,
|
| 729 |
+
step=1.0,
|
| 730 |
+
icon="mdi:antenna",
|
| 731 |
+
unit_of_measurement="°",
|
| 732 |
+
mode=2,
|
| 733 |
+
value_getter=self.reachy_controller.get_antenna_left,
|
| 734 |
+
value_setter=self.reachy_controller.set_antenna_left,
|
| 735 |
+
)
|
| 736 |
+
self.state.entities.append(antenna_left)
|
| 737 |
+
|
| 738 |
+
antenna_right = NumberEntity(
|
| 739 |
+
server=self,
|
| 740 |
+
key=len(self.state.entities),
|
| 741 |
+
name="Right Antenna",
|
| 742 |
+
object_id="antenna_right",
|
| 743 |
+
min_value=-90.0,
|
| 744 |
+
max_value=90.0,
|
| 745 |
+
step=1.0,
|
| 746 |
+
icon="mdi:antenna",
|
| 747 |
+
unit_of_measurement="°",
|
| 748 |
+
mode=2,
|
| 749 |
+
value_getter=self.reachy_controller.get_antenna_right,
|
| 750 |
+
value_setter=self.reachy_controller.set_antenna_right,
|
| 751 |
+
)
|
| 752 |
+
self.state.entities.append(antenna_right)
|
| 753 |
+
|
| 754 |
+
_LOGGER.info("Phase 3 entities registered: head position/orientation, body_yaw, antennas")
|