Desmond-Dong commited on
Commit
5238074
·
1 Parent(s): 29fd1be

"feat(entities):add-17-ESPHome-entities-for-full-robot-control"

Browse files
.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
- - [ ] `daemon_state` - Daemon 状态传感器
211
- - [ ] `backend_ready` - 后端就绪状态
212
- - [ ] `error_message` - 错误信息
213
- - [ ] `speaker_volume` - 扬声器音量控制
214
-
215
- 2. **Phase 2 - 电机控制** (高优先级)
216
- - [ ] `motors_enabled` - 电机开关
217
- - [ ] `motor_mode` - 电机模式选择 (enabled/disabled/gravity_compensation)
218
- - [ ] `wake_up` / `go_to_sleep` - 唤醒/睡眠按钮
219
-
220
- 3. **Phase 3 - 姿态控制** (中优先级)
221
- - [ ] `head_x/y/z` - 头部位置控制
222
- - [ ] `head_roll/pitch/yaw` - 头部角度控制
223
- - [ ] `body_yaw` - 身体偏航角控制
224
- - [ ] `antenna_left/right` - 天线角度控制
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")