Desmond-Dong commited on
Commit
07e9e3c
·
1 Parent(s): 4467722

优化拆分代码

Browse files
reachy_mini_ha_voice/__init__.py CHANGED
@@ -12,7 +12,7 @@ Key features:
12
  """
13
 
14
  __version__ = "0.1.0"
15
- __author__ = "Pollen Robotics"
16
 
17
  # Don't import main module here to avoid runpy warning
18
  # The app is loaded via entry point: reachy_mini_ha_voice.main:ReachyMiniHAVoiceApp
 
12
  """
13
 
14
  __version__ = "0.1.0"
15
+ __author__ = "Desmond Dong"
16
 
17
  # Don't import main module here to avoid runpy warning
18
  # The app is loaded via entry point: reachy_mini_ha_voice.main:ReachyMiniHAVoiceApp
reachy_mini_ha_voice/entity_registry.py ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entity registry for ESPHome entities.
2
+
3
+ This module handles the registration and management of all ESPHome entities
4
+ for the Reachy Mini voice assistant.
5
+ """
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
9
+
10
+ from .entity import BinarySensorEntity, CameraEntity, MediaPlayerEntity, NumberEntity, TextSensorEntity
11
+ from .entity_extensions import SensorEntity, SwitchEntity, SelectEntity, ButtonEntity
12
+
13
+ if TYPE_CHECKING:
14
+ from .reachy_controller import ReachyController
15
+ from .camera_server import MJPEGCameraServer
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ # Fixed entity key mapping - ensures consistent keys across restarts
21
+ # Keys are based on object_id hash to ensure uniqueness and consistency
22
+ ENTITY_KEYS: Dict[str, int] = {
23
+ # Media player (key 0 reserved)
24
+ "reachy_mini_media_player": 0,
25
+ # Phase 1: Basic status and volume
26
+ "daemon_state": 100,
27
+ "backend_ready": 101,
28
+ "error_message": 102,
29
+ "speaker_volume": 103,
30
+ # Phase 2: Motor control
31
+ "motors_enabled": 200,
32
+ "motor_mode": 201,
33
+ "wake_up": 202,
34
+ "go_to_sleep": 203,
35
+ # Phase 3: Pose control
36
+ "head_x": 300,
37
+ "head_y": 301,
38
+ "head_z": 302,
39
+ "head_roll": 303,
40
+ "head_pitch": 304,
41
+ "head_yaw": 305,
42
+ "body_yaw": 306,
43
+ "antenna_left": 307,
44
+ "antenna_right": 308,
45
+ # Phase 4: Look at control
46
+ "look_at_x": 400,
47
+ "look_at_y": 401,
48
+ "look_at_z": 402,
49
+ # Phase 5: Audio sensors
50
+ "doa_angle": 500,
51
+ "speech_detected": 501,
52
+ # Phase 6: Diagnostic information
53
+ "control_loop_frequency": 600,
54
+ "sdk_version": 601,
55
+ "robot_name": 602,
56
+ "wireless_version": 603,
57
+ "simulation_mode": 604,
58
+ "wlan_ip": 605,
59
+ # Phase 7: IMU sensors
60
+ "imu_accel_x": 700,
61
+ "imu_accel_y": 701,
62
+ "imu_accel_z": 702,
63
+ "imu_gyro_x": 703,
64
+ "imu_gyro_y": 704,
65
+ "imu_gyro_z": 705,
66
+ "imu_temperature": 706,
67
+ # Phase 8: Emotion selector
68
+ "emotion": 800,
69
+ # Phase 9: Audio controls
70
+ "microphone_volume": 900,
71
+ # Phase 10: Camera
72
+ "camera_url": 1000, # Keep for backward compatibility
73
+ "camera": 1001, # New camera entity
74
+ # Phase 11: LED control (disabled - not visible)
75
+ # "led_brightness": 1100,
76
+ # "led_effect": 1101,
77
+ # "led_color_r": 1102,
78
+ # "led_color_g": 1103,
79
+ # "led_color_b": 1104,
80
+ # Phase 12: Audio processing
81
+ "agc_enabled": 1200,
82
+ "agc_max_gain": 1201,
83
+ "noise_suppression": 1202,
84
+ "echo_cancellation_converged": 1203,
85
+ # Phase 13: Robot joints (single JSON sensor)
86
+ "head_joints": 1300,
87
+ # Phase 14: Passive joints for 3D visualization
88
+ "passive_joints": 1400,
89
+ }
90
+
91
+
92
+ def get_entity_key(object_id: str) -> int:
93
+ """Get a consistent entity key for the given object_id."""
94
+ if object_id in ENTITY_KEYS:
95
+ return ENTITY_KEYS[object_id]
96
+ # Fallback: generate key from hash (should not happen if all entities are registered)
97
+ _LOGGER.warning(f"Entity key not found for {object_id}, generating from hash")
98
+ return abs(hash(object_id)) % 10000 + 2000
99
+
100
+
101
+ class EntityRegistry:
102
+ """Registry for managing ESPHome entities."""
103
+
104
+ def __init__(
105
+ self,
106
+ server,
107
+ reachy_controller: "ReachyController",
108
+ camera_server: Optional["MJPEGCameraServer"] = None,
109
+ play_emotion_callback: Optional[Callable[[str], None]] = None,
110
+ ):
111
+ """Initialize the entity registry.
112
+
113
+ Args:
114
+ server: The VoiceSatelliteProtocol server instance
115
+ reachy_controller: The ReachyController instance
116
+ camera_server: Optional camera server for camera entity
117
+ play_emotion_callback: Optional callback for playing emotions
118
+ """
119
+ self.server = server
120
+ self.reachy_controller = reachy_controller
121
+ self.camera_server = camera_server
122
+ self._play_emotion_callback = play_emotion_callback
123
+
124
+ # Entity references that need to be accessed externally
125
+ self.doa_angle_entity: Optional[SensorEntity] = None
126
+ self.speech_detected_entity: Optional[BinarySensorEntity] = None
127
+
128
+ # Emotion state
129
+ self._current_emotion = "None"
130
+ self._emotion_map = {
131
+ "None": None,
132
+ "Happy": "happy1",
133
+ "Sad": "sad1",
134
+ "Angry": "angry1",
135
+ "Fear": "fear1",
136
+ "Surprise": "surprise1",
137
+ "Disgust": "disgust1",
138
+ }
139
+
140
+ def setup_all_entities(self, entities: List) -> None:
141
+ """Setup all entity phases.
142
+
143
+ Args:
144
+ entities: The list to append entities to
145
+ """
146
+ self._setup_phase1_entities(entities)
147
+ self._setup_phase2_entities(entities)
148
+ self._setup_phase3_entities(entities)
149
+ self._setup_phase4_entities(entities)
150
+ self._setup_phase5_entities(entities)
151
+ self._setup_phase6_entities(entities)
152
+ self._setup_phase7_entities(entities)
153
+ self._setup_phase8_entities(entities)
154
+ self._setup_phase9_entities(entities)
155
+ self._setup_phase10_entities(entities)
156
+ # Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
157
+ self._setup_phase12_entities(entities)
158
+ self._setup_phase13_entities(entities)
159
+
160
+ _LOGGER.info("All entities registered: %d total", len(entities))
161
+
162
+ def _setup_phase1_entities(self, entities: List) -> None:
163
+ """Setup Phase 1 entities: Basic status and volume control."""
164
+ rc = self.reachy_controller
165
+
166
+ entities.append(TextSensorEntity(
167
+ server=self.server,
168
+ key=get_entity_key("daemon_state"),
169
+ name="Daemon State",
170
+ object_id="daemon_state",
171
+ icon="mdi:robot",
172
+ value_getter=rc.get_daemon_state,
173
+ ))
174
+
175
+ entities.append(BinarySensorEntity(
176
+ server=self.server,
177
+ key=get_entity_key("backend_ready"),
178
+ name="Backend Ready",
179
+ object_id="backend_ready",
180
+ icon="mdi:check-circle",
181
+ device_class="connectivity",
182
+ value_getter=rc.get_backend_ready,
183
+ ))
184
+
185
+ entities.append(TextSensorEntity(
186
+ server=self.server,
187
+ key=get_entity_key("error_message"),
188
+ name="Error Message",
189
+ object_id="error_message",
190
+ icon="mdi:alert-circle",
191
+ value_getter=rc.get_error_message,
192
+ ))
193
+
194
+ entities.append(NumberEntity(
195
+ server=self.server,
196
+ key=get_entity_key("speaker_volume"),
197
+ name="Speaker Volume",
198
+ object_id="speaker_volume",
199
+ min_value=0.0,
200
+ max_value=100.0,
201
+ step=1.0,
202
+ icon="mdi:volume-high",
203
+ unit_of_measurement="%",
204
+ mode=2, # Slider mode
205
+ entity_category=1, # config
206
+ value_getter=rc.get_speaker_volume,
207
+ value_setter=rc.set_speaker_volume,
208
+ ))
209
+
210
+ _LOGGER.debug("Phase 1 entities registered: daemon_state, backend_ready, error_message, speaker_volume")
211
+
212
+ def _setup_phase2_entities(self, entities: List) -> None:
213
+ """Setup Phase 2 entities: Motor control."""
214
+ rc = self.reachy_controller
215
+
216
+ entities.append(SwitchEntity(
217
+ server=self.server,
218
+ key=get_entity_key("motors_enabled"),
219
+ name="Motors Enabled",
220
+ object_id="motors_enabled",
221
+ icon="mdi:engine",
222
+ device_class="switch",
223
+ value_getter=rc.get_motors_enabled,
224
+ value_setter=rc.set_motors_enabled,
225
+ ))
226
+
227
+ entities.append(ButtonEntity(
228
+ server=self.server,
229
+ key=get_entity_key("wake_up"),
230
+ name="Wake Up",
231
+ object_id="wake_up",
232
+ icon="mdi:alarm",
233
+ device_class="restart",
234
+ on_press=rc.wake_up,
235
+ ))
236
+
237
+ entities.append(ButtonEntity(
238
+ server=self.server,
239
+ key=get_entity_key("go_to_sleep"),
240
+ name="Go to Sleep",
241
+ object_id="go_to_sleep",
242
+ icon="mdi:sleep",
243
+ device_class="restart",
244
+ on_press=rc.go_to_sleep,
245
+ ))
246
+
247
+ _LOGGER.debug("Phase 2 entities registered: motors_enabled, wake_up, go_to_sleep")
248
+
249
+ def _setup_phase3_entities(self, entities: List) -> None:
250
+ """Setup Phase 3 entities: Pose control."""
251
+ rc = self.reachy_controller
252
+
253
+ # Head position controls (X, Y, Z in mm)
254
+ entities.append(NumberEntity(
255
+ server=self.server,
256
+ key=get_entity_key("head_x"),
257
+ name="Head X Position",
258
+ object_id="head_x",
259
+ min_value=-50.0,
260
+ max_value=50.0,
261
+ step=1.0,
262
+ icon="mdi:axis-x-arrow",
263
+ unit_of_measurement="mm",
264
+ mode=2,
265
+ value_getter=rc.get_head_x,
266
+ value_setter=rc.set_head_x,
267
+ ))
268
+
269
+ entities.append(NumberEntity(
270
+ server=self.server,
271
+ key=get_entity_key("head_y"),
272
+ name="Head Y Position",
273
+ object_id="head_y",
274
+ min_value=-50.0,
275
+ max_value=50.0,
276
+ step=1.0,
277
+ icon="mdi:axis-y-arrow",
278
+ unit_of_measurement="mm",
279
+ mode=2,
280
+ value_getter=rc.get_head_y,
281
+ value_setter=rc.set_head_y,
282
+ ))
283
+
284
+ entities.append(NumberEntity(
285
+ server=self.server,
286
+ key=get_entity_key("head_z"),
287
+ name="Head Z Position",
288
+ object_id="head_z",
289
+ min_value=-50.0,
290
+ max_value=50.0,
291
+ step=1.0,
292
+ icon="mdi:axis-z-arrow",
293
+ unit_of_measurement="mm",
294
+ mode=2,
295
+ value_getter=rc.get_head_z,
296
+ value_setter=rc.set_head_z,
297
+ ))
298
+
299
+ # Head orientation controls (Roll, Pitch, Yaw in degrees)
300
+ entities.append(NumberEntity(
301
+ server=self.server,
302
+ key=get_entity_key("head_roll"),
303
+ name="Head Roll",
304
+ object_id="head_roll",
305
+ min_value=-40.0,
306
+ max_value=40.0,
307
+ step=1.0,
308
+ icon="mdi:rotate-3d-variant",
309
+ unit_of_measurement="°",
310
+ mode=2,
311
+ value_getter=rc.get_head_roll,
312
+ value_setter=rc.set_head_roll,
313
+ ))
314
+
315
+ entities.append(NumberEntity(
316
+ server=self.server,
317
+ key=get_entity_key("head_pitch"),
318
+ name="Head Pitch",
319
+ object_id="head_pitch",
320
+ min_value=-40.0,
321
+ max_value=40.0,
322
+ step=1.0,
323
+ icon="mdi:rotate-3d-variant",
324
+ unit_of_measurement="°",
325
+ mode=2,
326
+ value_getter=rc.get_head_pitch,
327
+ value_setter=rc.set_head_pitch,
328
+ ))
329
+
330
+ entities.append(NumberEntity(
331
+ server=self.server,
332
+ key=get_entity_key("head_yaw"),
333
+ name="Head Yaw",
334
+ object_id="head_yaw",
335
+ min_value=-180.0,
336
+ max_value=180.0,
337
+ step=1.0,
338
+ icon="mdi:rotate-3d-variant",
339
+ unit_of_measurement="°",
340
+ mode=2,
341
+ value_getter=rc.get_head_yaw,
342
+ value_setter=rc.set_head_yaw,
343
+ ))
344
+
345
+ # Body yaw control
346
+ entities.append(NumberEntity(
347
+ server=self.server,
348
+ key=get_entity_key("body_yaw"),
349
+ name="Body Yaw",
350
+ object_id="body_yaw",
351
+ min_value=-160.0,
352
+ max_value=160.0,
353
+ step=1.0,
354
+ icon="mdi:rotate-3d-variant",
355
+ unit_of_measurement="°",
356
+ mode=2,
357
+ value_getter=rc.get_body_yaw,
358
+ value_setter=rc.set_body_yaw,
359
+ ))
360
+
361
+ # Antenna controls
362
+ entities.append(NumberEntity(
363
+ server=self.server,
364
+ key=get_entity_key("antenna_left"),
365
+ name="Antenna(L)",
366
+ object_id="antenna_left",
367
+ min_value=-90.0,
368
+ max_value=90.0,
369
+ step=1.0,
370
+ icon="mdi:antenna",
371
+ unit_of_measurement="°",
372
+ mode=2,
373
+ value_getter=rc.get_antenna_left,
374
+ value_setter=rc.set_antenna_left,
375
+ ))
376
+
377
+ entities.append(NumberEntity(
378
+ server=self.server,
379
+ key=get_entity_key("antenna_right"),
380
+ name="Antenna(R)",
381
+ object_id="antenna_right",
382
+ min_value=-90.0,
383
+ max_value=90.0,
384
+ step=1.0,
385
+ icon="mdi:antenna",
386
+ unit_of_measurement="°",
387
+ mode=2,
388
+ value_getter=rc.get_antenna_right,
389
+ value_setter=rc.set_antenna_right,
390
+ ))
391
+
392
+ _LOGGER.debug("Phase 3 entities registered: head position/orientation, body_yaw, antennas")
393
+
394
+ def _setup_phase4_entities(self, entities: List) -> None:
395
+ """Setup Phase 4 entities: Look at control."""
396
+ rc = self.reachy_controller
397
+
398
+ entities.append(NumberEntity(
399
+ server=self.server,
400
+ key=get_entity_key("look_at_x"),
401
+ name="Look At X",
402
+ object_id="look_at_x",
403
+ min_value=-2.0,
404
+ max_value=2.0,
405
+ step=0.1,
406
+ icon="mdi:crosshairs-gps",
407
+ unit_of_measurement="m",
408
+ mode=1, # Box mode for precise input
409
+ value_getter=rc.get_look_at_x,
410
+ value_setter=rc.set_look_at_x,
411
+ ))
412
+
413
+ entities.append(NumberEntity(
414
+ server=self.server,
415
+ key=get_entity_key("look_at_y"),
416
+ name="Look At Y",
417
+ object_id="look_at_y",
418
+ min_value=-2.0,
419
+ max_value=2.0,
420
+ step=0.1,
421
+ icon="mdi:crosshairs-gps",
422
+ unit_of_measurement="m",
423
+ mode=1,
424
+ value_getter=rc.get_look_at_y,
425
+ value_setter=rc.set_look_at_y,
426
+ ))
427
+
428
+ entities.append(NumberEntity(
429
+ server=self.server,
430
+ key=get_entity_key("look_at_z"),
431
+ name="Look At Z",
432
+ object_id="look_at_z",
433
+ min_value=-2.0,
434
+ max_value=2.0,
435
+ step=0.1,
436
+ icon="mdi:crosshairs-gps",
437
+ unit_of_measurement="m",
438
+ mode=1,
439
+ value_getter=rc.get_look_at_z,
440
+ value_setter=rc.set_look_at_z,
441
+ ))
442
+
443
+ _LOGGER.debug("Phase 4 entities registered: look_at_x/y/z")
444
+
445
+ def _setup_phase5_entities(self, entities: List) -> None:
446
+ """Setup Phase 5 entities: Audio sensors."""
447
+ rc = self.reachy_controller
448
+
449
+ self.doa_angle_entity = SensorEntity(
450
+ server=self.server,
451
+ key=get_entity_key("doa_angle"),
452
+ name="DOA Angle",
453
+ object_id="doa_angle",
454
+ icon="mdi:compass",
455
+ unit_of_measurement="°",
456
+ accuracy_decimals=1,
457
+ state_class="measurement",
458
+ value_getter=rc.get_doa_angle,
459
+ )
460
+ entities.append(self.doa_angle_entity)
461
+
462
+ self.speech_detected_entity = BinarySensorEntity(
463
+ server=self.server,
464
+ key=get_entity_key("speech_detected"),
465
+ name="Speech Detected",
466
+ object_id="speech_detected",
467
+ icon="mdi:microphone",
468
+ device_class="sound",
469
+ value_getter=rc.get_speech_detected,
470
+ )
471
+ entities.append(self.speech_detected_entity)
472
+
473
+ _LOGGER.debug("Phase 5 entities registered: doa_angle, speech_detected")
474
+
475
+ def _setup_phase6_entities(self, entities: List) -> None:
476
+ """Setup Phase 6 entities: Diagnostic information."""
477
+ rc = self.reachy_controller
478
+
479
+ entities.append(SensorEntity(
480
+ server=self.server,
481
+ key=get_entity_key("control_loop_frequency"),
482
+ name="Control Loop Frequency",
483
+ object_id="control_loop_frequency",
484
+ icon="mdi:speedometer",
485
+ unit_of_measurement="Hz",
486
+ accuracy_decimals=1,
487
+ state_class="measurement",
488
+ value_getter=rc.get_control_loop_frequency,
489
+ ))
490
+
491
+ entities.append(TextSensorEntity(
492
+ server=self.server,
493
+ key=get_entity_key("sdk_version"),
494
+ name="SDK Version",
495
+ object_id="sdk_version",
496
+ icon="mdi:information",
497
+ value_getter=rc.get_sdk_version,
498
+ ))
499
+
500
+ entities.append(TextSensorEntity(
501
+ server=self.server,
502
+ key=get_entity_key("robot_name"),
503
+ name="Robot Name",
504
+ object_id="robot_name",
505
+ icon="mdi:robot",
506
+ value_getter=rc.get_robot_name,
507
+ ))
508
+
509
+ entities.append(BinarySensorEntity(
510
+ server=self.server,
511
+ key=get_entity_key("wireless_version"),
512
+ name="Wireless Version",
513
+ object_id="wireless_version",
514
+ icon="mdi:wifi",
515
+ device_class="connectivity",
516
+ value_getter=rc.get_wireless_version,
517
+ ))
518
+
519
+ entities.append(BinarySensorEntity(
520
+ server=self.server,
521
+ key=get_entity_key("simulation_mode"),
522
+ name="Simulation Mode",
523
+ object_id="simulation_mode",
524
+ icon="mdi:virtual-reality",
525
+ value_getter=rc.get_simulation_mode,
526
+ ))
527
+
528
+ entities.append(TextSensorEntity(
529
+ server=self.server,
530
+ key=get_entity_key("wlan_ip"),
531
+ name="WLAN IP",
532
+ object_id="wlan_ip",
533
+ icon="mdi:ip-network",
534
+ value_getter=rc.get_wlan_ip,
535
+ ))
536
+
537
+ _LOGGER.debug("Phase 6 entities registered: control_loop_frequency, sdk_version, robot_name, wireless_version, simulation_mode, wlan_ip")
538
+
539
+ def _setup_phase7_entities(self, entities: List) -> None:
540
+ """Setup Phase 7 entities: IMU sensors (wireless only)."""
541
+ rc = self.reachy_controller
542
+
543
+ # IMU Accelerometer
544
+ entities.append(SensorEntity(
545
+ server=self.server,
546
+ key=get_entity_key("imu_accel_x"),
547
+ name="IMU Accel X",
548
+ object_id="imu_accel_x",
549
+ icon="mdi:axis-x-arrow",
550
+ unit_of_measurement="m/s²",
551
+ accuracy_decimals=3,
552
+ state_class="measurement",
553
+ value_getter=rc.get_imu_accel_x,
554
+ ))
555
+
556
+ entities.append(SensorEntity(
557
+ server=self.server,
558
+ key=get_entity_key("imu_accel_y"),
559
+ name="IMU Accel Y",
560
+ object_id="imu_accel_y",
561
+ icon="mdi:axis-y-arrow",
562
+ unit_of_measurement="m/s²",
563
+ accuracy_decimals=3,
564
+ state_class="measurement",
565
+ value_getter=rc.get_imu_accel_y,
566
+ ))
567
+
568
+ entities.append(SensorEntity(
569
+ server=self.server,
570
+ key=get_entity_key("imu_accel_z"),
571
+ name="IMU Accel Z",
572
+ object_id="imu_accel_z",
573
+ icon="mdi:axis-z-arrow",
574
+ unit_of_measurement="m/s²",
575
+ accuracy_decimals=3,
576
+ state_class="measurement",
577
+ value_getter=rc.get_imu_accel_z,
578
+ ))
579
+
580
+ # IMU Gyroscope
581
+ entities.append(SensorEntity(
582
+ server=self.server,
583
+ key=get_entity_key("imu_gyro_x"),
584
+ name="IMU Gyro X",
585
+ object_id="imu_gyro_x",
586
+ icon="mdi:rotate-3d-variant",
587
+ unit_of_measurement="rad/s",
588
+ accuracy_decimals=3,
589
+ state_class="measurement",
590
+ value_getter=rc.get_imu_gyro_x,
591
+ ))
592
+
593
+ entities.append(SensorEntity(
594
+ server=self.server,
595
+ key=get_entity_key("imu_gyro_y"),
596
+ name="IMU Gyro Y",
597
+ object_id="imu_gyro_y",
598
+ icon="mdi:rotate-3d-variant",
599
+ unit_of_measurement="rad/s",
600
+ accuracy_decimals=3,
601
+ state_class="measurement",
602
+ value_getter=rc.get_imu_gyro_y,
603
+ ))
604
+
605
+ entities.append(SensorEntity(
606
+ server=self.server,
607
+ key=get_entity_key("imu_gyro_z"),
608
+ name="IMU Gyro Z",
609
+ object_id="imu_gyro_z",
610
+ icon="mdi:rotate-3d-variant",
611
+ unit_of_measurement="rad/s",
612
+ accuracy_decimals=3,
613
+ state_class="measurement",
614
+ value_getter=rc.get_imu_gyro_z,
615
+ ))
616
+
617
+ # IMU Temperature
618
+ entities.append(SensorEntity(
619
+ server=self.server,
620
+ key=get_entity_key("imu_temperature"),
621
+ name="IMU Temperature",
622
+ object_id="imu_temperature",
623
+ icon="mdi:thermometer",
624
+ unit_of_measurement="°C",
625
+ accuracy_decimals=1,
626
+ device_class="temperature",
627
+ state_class="measurement",
628
+ value_getter=rc.get_imu_temperature,
629
+ ))
630
+
631
+ _LOGGER.debug("Phase 7 entities registered: IMU accelerometer, gyroscope, temperature")
632
+
633
+ def _setup_phase8_entities(self, entities: List) -> None:
634
+ """Setup Phase 8 entities: Emotion selector."""
635
+
636
+ def get_emotion() -> str:
637
+ return self._current_emotion
638
+
639
+ def set_emotion(emotion: str) -> None:
640
+ self._current_emotion = emotion
641
+ emotion_name = self._emotion_map.get(emotion)
642
+ if emotion_name and self._play_emotion_callback:
643
+ self._play_emotion_callback(emotion_name)
644
+ # Reset to None after playing
645
+ self._current_emotion = "None"
646
+
647
+ entities.append(SelectEntity(
648
+ server=self.server,
649
+ key=get_entity_key("emotion"),
650
+ name="Emotion",
651
+ object_id="emotion",
652
+ options=list(self._emotion_map.keys()),
653
+ icon="mdi:emoticon",
654
+ value_getter=get_emotion,
655
+ value_setter=set_emotion,
656
+ ))
657
+
658
+ _LOGGER.debug("Phase 8 entities registered: emotion selector")
659
+
660
+ def _setup_phase9_entities(self, entities: List) -> None:
661
+ """Setup Phase 9 entities: Audio controls."""
662
+ rc = self.reachy_controller
663
+
664
+ entities.append(NumberEntity(
665
+ server=self.server,
666
+ key=get_entity_key("microphone_volume"),
667
+ name="Microphone Volume",
668
+ object_id="microphone_volume",
669
+ min_value=0.0,
670
+ max_value=100.0,
671
+ step=1.0,
672
+ icon="mdi:microphone",
673
+ unit_of_measurement="%",
674
+ mode=2, # Slider mode
675
+ entity_category=1, # config
676
+ value_getter=rc.get_microphone_volume,
677
+ value_setter=rc.set_microphone_volume,
678
+ ))
679
+
680
+ _LOGGER.debug("Phase 9 entities registered: microphone_volume")
681
+
682
+ def _setup_phase10_entities(self, entities: List) -> None:
683
+ """Setup Phase 10 entities: Camera for Home Assistant integration."""
684
+
685
+ def get_camera_image() -> Optional[bytes]:
686
+ """Get camera snapshot as JPEG bytes."""
687
+ if self.camera_server:
688
+ return self.camera_server.get_snapshot()
689
+ return None
690
+
691
+ entities.append(CameraEntity(
692
+ server=self.server,
693
+ key=get_entity_key("camera"),
694
+ name="Camera",
695
+ object_id="camera",
696
+ icon="mdi:camera",
697
+ image_getter=get_camera_image,
698
+ ))
699
+
700
+ _LOGGER.debug("Phase 10 entities registered: camera (ESPHome Camera entity)")
701
+
702
+ def _setup_phase12_entities(self, entities: List) -> None:
703
+ """Setup Phase 12 entities: Audio processing parameters (via local SDK)."""
704
+ rc = self.reachy_controller
705
+
706
+ entities.append(SwitchEntity(
707
+ server=self.server,
708
+ key=get_entity_key("agc_enabled"),
709
+ name="AGC Enabled",
710
+ object_id="agc_enabled",
711
+ icon="mdi:tune-vertical",
712
+ device_class="switch",
713
+ entity_category=1, # config
714
+ value_getter=rc.get_agc_enabled,
715
+ value_setter=rc.set_agc_enabled,
716
+ ))
717
+
718
+ entities.append(NumberEntity(
719
+ server=self.server,
720
+ key=get_entity_key("agc_max_gain"),
721
+ name="AGC Max Gain",
722
+ object_id="agc_max_gain",
723
+ min_value=0.0,
724
+ max_value=30.0,
725
+ step=1.0,
726
+ icon="mdi:volume-plus",
727
+ unit_of_measurement="dB",
728
+ mode=2,
729
+ entity_category=1, # config
730
+ value_getter=rc.get_agc_max_gain,
731
+ value_setter=rc.set_agc_max_gain,
732
+ ))
733
+
734
+ entities.append(NumberEntity(
735
+ server=self.server,
736
+ key=get_entity_key("noise_suppression"),
737
+ name="Noise Suppression",
738
+ object_id="noise_suppression",
739
+ min_value=0.0,
740
+ max_value=100.0,
741
+ step=1.0,
742
+ icon="mdi:volume-off",
743
+ unit_of_measurement="%",
744
+ mode=2,
745
+ entity_category=1, # config
746
+ value_getter=rc.get_noise_suppression,
747
+ value_setter=rc.set_noise_suppression,
748
+ ))
749
+
750
+ entities.append(BinarySensorEntity(
751
+ server=self.server,
752
+ key=get_entity_key("echo_cancellation_converged"),
753
+ name="Echo Cancellation Converged",
754
+ object_id="echo_cancellation_converged",
755
+ icon="mdi:waveform",
756
+ device_class="running",
757
+ entity_category=2, # diagnostic
758
+ value_getter=rc.get_echo_cancellation_converged,
759
+ ))
760
+
761
+ _LOGGER.debug("Phase 12 entities registered: agc_enabled, agc_max_gain, noise_suppression, echo_cancellation_converged")
762
+
763
+ def _setup_phase13_entities(self, entities: List) -> None:
764
+ """Setup Phase 13 entities: Robot joints as JSON sensor."""
765
+ rc = self.reachy_controller
766
+
767
+ entities.append(TextSensorEntity(
768
+ server=self.server,
769
+ key=get_entity_key("head_joints"),
770
+ name="Head Joints",
771
+ object_id="head_joints",
772
+ icon="mdi:robot",
773
+ value_getter=rc.get_head_joints_json,
774
+ ))
775
+
776
+ _LOGGER.debug("Phase 13 entities registered: head_joints")
777
+
778
+ # Phase 14: Passive joints for 3D visualization
779
+ entities.append(TextSensorEntity(
780
+ server=self.server,
781
+ key=get_entity_key("passive_joints"),
782
+ name="Passive Joints",
783
+ object_id="passive_joints",
784
+ value_getter=rc.get_passive_joints_json,
785
+ ))
786
+
787
+ _LOGGER.debug("Phase 14 entities registered: passive_joints")
788
+
789
+ def find_entity_references(self, entities: List) -> None:
790
+ """Find and store references to special entities from existing list.
791
+
792
+ Args:
793
+ entities: The list of existing entities to search
794
+ """
795
+ for entity in entities:
796
+ if hasattr(entity, 'object_id'):
797
+ if entity.object_id == 'doa_angle':
798
+ self.doa_angle_entity = entity
799
+ elif entity.object_id == 'speech_detected':
800
+ self.speech_detected_entity = entity
reachy_mini_ha_voice/satellite.py CHANGED
@@ -49,8 +49,8 @@ from pymicro_wakeword import MicroWakeWord
49
  from pyopen_wakeword import OpenWakeWord
50
 
51
  from .api_server import APIServer
52
- from .entity import BinarySensorEntity, CameraEntity, MediaPlayerEntity, NumberEntity, TextSensorEntity
53
- from .entity_extensions import SensorEntity, SwitchEntity, SelectEntity, ButtonEntity
54
  from .models import AvailableWakeWord, ServerState, WakeWordType
55
  from .util import call_all
56
  from .reachy_controller import ReachyController
@@ -61,85 +61,6 @@ _LOGGER = logging.getLogger(__name__)
61
  class VoiceSatelliteProtocol(APIServer):
62
  """Voice satellite protocol handler for ESPHome."""
63
 
64
- # Fixed entity key mapping - ensures consistent keys across restarts
65
- # Keys are based on object_id hash to ensure uniqueness and consistency
66
- ENTITY_KEYS = {
67
- # Media player (key 0 reserved)
68
- "reachy_mini_media_player": 0,
69
- # Phase 1: Basic status and volume
70
- "daemon_state": 100,
71
- "backend_ready": 101,
72
- "error_message": 102,
73
- "speaker_volume": 103,
74
- # Phase 2: Motor control
75
- "motors_enabled": 200,
76
- "motor_mode": 201,
77
- "wake_up": 202,
78
- "go_to_sleep": 203,
79
- # Phase 3: Pose control
80
- "head_x": 300,
81
- "head_y": 301,
82
- "head_z": 302,
83
- "head_roll": 303,
84
- "head_pitch": 304,
85
- "head_yaw": 305,
86
- "body_yaw": 306,
87
- "antenna_left": 307,
88
- "antenna_right": 308,
89
- # Phase 4: Look at control
90
- "look_at_x": 400,
91
- "look_at_y": 401,
92
- "look_at_z": 402,
93
- # Phase 5: Audio sensors
94
- "doa_angle": 500,
95
- "speech_detected": 501,
96
- # Phase 6: Diagnostic information
97
- "control_loop_frequency": 600,
98
- "sdk_version": 601,
99
- "robot_name": 602,
100
- "wireless_version": 603,
101
- "simulation_mode": 604,
102
- "wlan_ip": 605,
103
- # Phase 7: IMU sensors
104
- "imu_accel_x": 700,
105
- "imu_accel_y": 701,
106
- "imu_accel_z": 702,
107
- "imu_gyro_x": 703,
108
- "imu_gyro_y": 704,
109
- "imu_gyro_z": 705,
110
- "imu_temperature": 706,
111
- # Phase 8: Emotion selector
112
- "emotion": 800,
113
- # Phase 9: Audio controls
114
- "microphone_volume": 900,
115
- # Phase 10: Camera
116
- "camera_url": 1000, # Keep for backward compatibility
117
- "camera": 1001, # New camera entity
118
- # Phase 11: LED control (disabled - not visible)
119
- # "led_brightness": 1100,
120
- # "led_effect": 1101,
121
- # "led_color_r": 1102,
122
- # "led_color_g": 1103,
123
- # "led_color_b": 1104,
124
- # Phase 12: Audio processing
125
- "agc_enabled": 1200,
126
- "agc_max_gain": 1201,
127
- "noise_suppression": 1202,
128
- "echo_cancellation_converged": 1203,
129
- # Phase 13: Robot joints (single JSON sensor)
130
- "head_joints": 1300,
131
- # Phase 14: Passive joints for 3D visualization
132
- "passive_joints": 1400,
133
- }
134
-
135
- def _get_entity_key(self, object_id: str) -> int:
136
- """Get a consistent entity key for the given object_id."""
137
- if object_id in self.ENTITY_KEYS:
138
- return self.ENTITY_KEYS[object_id]
139
- # Fallback: generate key from hash (should not happen if all entities are registered)
140
- _LOGGER.warning(f"Entity key not found for {object_id}, generating from hash")
141
- return abs(hash(object_id)) % 10000 + 2000
142
-
143
  def __init__(self, state: ServerState, camera_server: Optional["MJPEGCameraServer"] = None) -> None:
144
  super().__init__(state.name)
145
  self.state = state
@@ -149,9 +70,13 @@ class VoiceSatelliteProtocol(APIServer):
149
  # Initialize Reachy controller
150
  self.reachy_controller = ReachyController(state.reachy_mini)
151
 
152
- # Initialize entity references (will be set in setup phases)
153
- self._doa_angle_entity: Optional[SensorEntity] = None
154
- self._speech_detected_entity: Optional[BinarySensorEntity] = None
 
 
 
 
155
 
156
  # Only setup entities once (check if already initialized)
157
  # This prevents duplicate entity registration on reconnection
@@ -159,7 +84,7 @@ class VoiceSatelliteProtocol(APIServer):
159
  if self.state.media_player_entity is None:
160
  self.state.media_player_entity = MediaPlayerEntity(
161
  server=self,
162
- key=self._get_entity_key("reachy_mini_media_player"),
163
  name="Media Player",
164
  object_id="reachy_mini_media_player",
165
  music_player=state.music_player,
@@ -167,20 +92,8 @@ class VoiceSatelliteProtocol(APIServer):
167
  )
168
  self.state.entities.append(self.state.media_player_entity)
169
 
170
- # Setup all entity phases
171
- self._setup_phase1_entities()
172
- self._setup_phase2_entities()
173
- self._setup_phase3_entities()
174
- self._setup_phase4_entities()
175
- self._setup_phase5_entities()
176
- self._setup_phase6_entities()
177
- self._setup_phase7_entities()
178
- self._setup_phase8_entities()
179
- self._setup_phase9_entities()
180
- self._setup_phase10_entities() # Camera
181
- # Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
182
- self._setup_phase12_entities() # Audio processing
183
- self._setup_phase13_entities() # Robot joints
184
 
185
  # Mark entities as initialized
186
  self.state._entities_initialized = True
@@ -191,12 +104,7 @@ class VoiceSatelliteProtocol(APIServer):
191
  for entity in self.state.entities:
192
  entity.server = self
193
  # Find and store references to DOA entities
194
- for entity in self.state.entities:
195
- if hasattr(entity, 'object_id'):
196
- if entity.object_id == 'doa_angle':
197
- self._doa_angle_entity = entity
198
- elif entity.object_id == 'speech_detected':
199
- self._speech_detected_entity = entity
200
 
201
  self._is_streaming_audio = False
202
  self._tts_url: Optional[str] = None
@@ -568,11 +476,11 @@ class VoiceSatelliteProtocol(APIServer):
568
  def _update_doa_entities(self) -> None:
569
  """Update DOA and speech detection entities in Home Assistant."""
570
  try:
571
- if self._doa_angle_entity is not None:
572
- self._doa_angle_entity.update_state()
573
  _LOGGER.debug("DOA angle entity updated")
574
- if self._speech_detected_entity is not None:
575
- self._speech_detected_entity.update_state()
576
  _LOGGER.debug("Speech detected entity updated")
577
  except Exception as e:
578
  _LOGGER.error("Error updating DOA entities: %s", e)
@@ -663,786 +571,3 @@ class VoiceSatelliteProtocol(APIServer):
663
 
664
  except Exception as e:
665
  _LOGGER.error(f"Error playing emotion {emotion_name}: {e}")
666
-
667
- # -------------------------------------------------------------------------
668
- # Entity Setup Methods
669
- # -------------------------------------------------------------------------
670
-
671
- def _setup_phase1_entities(self) -> None:
672
- """Setup Phase 1 entities: Basic status and volume control."""
673
-
674
- # Daemon state sensor
675
- daemon_state_sensor = TextSensorEntity(
676
- server=self,
677
- key=self._get_entity_key("daemon_state"),
678
- name="Daemon State",
679
- object_id="daemon_state",
680
- icon="mdi:robot",
681
- value_getter=self.reachy_controller.get_daemon_state,
682
- )
683
- self.state.entities.append(daemon_state_sensor)
684
-
685
- # Backend ready sensor
686
- backend_ready_sensor = BinarySensorEntity(
687
- server=self,
688
- key=self._get_entity_key("backend_ready"),
689
- name="Backend Ready",
690
- object_id="backend_ready",
691
- icon="mdi:check-circle",
692
- device_class="connectivity",
693
- value_getter=self.reachy_controller.get_backend_ready,
694
- )
695
- self.state.entities.append(backend_ready_sensor)
696
-
697
- # Error message sensor
698
- error_message_sensor = TextSensorEntity(
699
- server=self,
700
- key=self._get_entity_key("error_message"),
701
- name="Error Message",
702
- object_id="error_message",
703
- icon="mdi:alert-circle",
704
- value_getter=self.reachy_controller.get_error_message,
705
- )
706
- self.state.entities.append(error_message_sensor)
707
-
708
- # Speaker volume control
709
- speaker_volume = NumberEntity(
710
- server=self,
711
- key=self._get_entity_key("speaker_volume"),
712
- name="Speaker Volume",
713
- object_id="speaker_volume",
714
- min_value=0.0,
715
- max_value=100.0,
716
- step=1.0,
717
- icon="mdi:volume-high",
718
- unit_of_measurement="%",
719
- mode=2, # Slider mode
720
- entity_category=1, # config
721
- value_getter=self.reachy_controller.get_speaker_volume,
722
- value_setter=self.reachy_controller.set_speaker_volume,
723
- )
724
- self.state.entities.append(speaker_volume)
725
-
726
- _LOGGER.info("Phase 1 entities registered: daemon_state, backend_ready, error_message, speaker_volume")
727
-
728
- def _setup_phase2_entities(self) -> None:
729
- """Setup Phase 2 entities: Motor control."""
730
-
731
- # Motors enabled switch
732
- motors_enabled = SwitchEntity(
733
- server=self,
734
- key=self._get_entity_key("motors_enabled"),
735
- name="Motors Enabled",
736
- object_id="motors_enabled",
737
- icon="mdi:engine",
738
- device_class="switch",
739
- value_getter=self.reachy_controller.get_motors_enabled,
740
- value_setter=self.reachy_controller.set_motors_enabled,
741
- )
742
- self.state.entities.append(motors_enabled)
743
-
744
- # Wake up button
745
- wake_up_button = ButtonEntity(
746
- server=self,
747
- key=self._get_entity_key("wake_up"),
748
- name="Wake Up",
749
- object_id="wake_up",
750
- icon="mdi:alarm",
751
- device_class="restart",
752
- on_press=self.reachy_controller.wake_up,
753
- )
754
- self.state.entities.append(wake_up_button)
755
-
756
- # Go to sleep button
757
- sleep_button = ButtonEntity(
758
- server=self,
759
- key=self._get_entity_key("go_to_sleep"),
760
- name="Go to Sleep",
761
- object_id="go_to_sleep",
762
- icon="mdi:sleep",
763
- device_class="restart",
764
- on_press=self.reachy_controller.go_to_sleep,
765
- )
766
- self.state.entities.append(sleep_button)
767
-
768
- _LOGGER.info("Phase 2 entities registered: motors_enabled, wake_up, go_to_sleep")
769
-
770
- def _setup_phase3_entities(self) -> None:
771
- """Setup Phase 3 entities: Pose control."""
772
-
773
- # Head position controls (X, Y, Z in mm)
774
- head_x = NumberEntity(
775
- server=self,
776
- key=self._get_entity_key("head_x"),
777
- name="Head X Position",
778
- object_id="head_x",
779
- min_value=-50.0,
780
- max_value=50.0,
781
- step=1.0,
782
- icon="mdi:axis-x-arrow",
783
- unit_of_measurement="mm",
784
- mode=2, # Slider
785
- value_getter=self.reachy_controller.get_head_x,
786
- value_setter=self.reachy_controller.set_head_x,
787
- )
788
- self.state.entities.append(head_x)
789
-
790
- head_y = NumberEntity(
791
- server=self,
792
- key=self._get_entity_key("head_y"),
793
- name="Head Y Position",
794
- object_id="head_y",
795
- min_value=-50.0,
796
- max_value=50.0,
797
- step=1.0,
798
- icon="mdi:axis-y-arrow",
799
- unit_of_measurement="mm",
800
- mode=2,
801
- value_getter=self.reachy_controller.get_head_y,
802
- value_setter=self.reachy_controller.set_head_y,
803
- )
804
- self.state.entities.append(head_y)
805
-
806
- head_z = NumberEntity(
807
- server=self,
808
- key=self._get_entity_key("head_z"),
809
- name="Head Z Position",
810
- object_id="head_z",
811
- min_value=-50.0,
812
- max_value=50.0,
813
- step=1.0,
814
- icon="mdi:axis-z-arrow",
815
- unit_of_measurement="mm",
816
- mode=2,
817
- value_getter=self.reachy_controller.get_head_z,
818
- value_setter=self.reachy_controller.set_head_z,
819
- )
820
- self.state.entities.append(head_z)
821
-
822
- # Head orientation controls (Roll, Pitch, Yaw in degrees)
823
- head_roll = NumberEntity(
824
- server=self,
825
- key=self._get_entity_key("head_roll"),
826
- name="Head Roll",
827
- object_id="head_roll",
828
- min_value=-40.0,
829
- max_value=40.0,
830
- step=1.0,
831
- icon="mdi:rotate-3d-variant",
832
- unit_of_measurement="°",
833
- mode=2,
834
- value_getter=self.reachy_controller.get_head_roll,
835
- value_setter=self.reachy_controller.set_head_roll,
836
- )
837
- self.state.entities.append(head_roll)
838
-
839
- head_pitch = NumberEntity(
840
- server=self,
841
- key=self._get_entity_key("head_pitch"),
842
- name="Head Pitch",
843
- object_id="head_pitch",
844
- min_value=-40.0,
845
- max_value=40.0,
846
- step=1.0,
847
- icon="mdi:rotate-3d-variant",
848
- unit_of_measurement="°",
849
- mode=2,
850
- value_getter=self.reachy_controller.get_head_pitch,
851
- value_setter=self.reachy_controller.set_head_pitch,
852
- )
853
- self.state.entities.append(head_pitch)
854
-
855
- head_yaw = NumberEntity(
856
- server=self,
857
- key=self._get_entity_key("head_yaw"),
858
- name="Head Yaw",
859
- object_id="head_yaw",
860
- min_value=-180.0,
861
- max_value=180.0,
862
- step=1.0,
863
- icon="mdi:rotate-3d-variant",
864
- unit_of_measurement="°",
865
- mode=2,
866
- value_getter=self.reachy_controller.get_head_yaw,
867
- value_setter=self.reachy_controller.set_head_yaw,
868
- )
869
- self.state.entities.append(head_yaw)
870
-
871
- # Body yaw control
872
- body_yaw = NumberEntity(
873
- server=self,
874
- key=self._get_entity_key("body_yaw"),
875
- name="Body Yaw",
876
- object_id="body_yaw",
877
- min_value=-160.0,
878
- max_value=160.0,
879
- step=1.0,
880
- icon="mdi:rotate-3d-variant",
881
- unit_of_measurement="°",
882
- mode=2,
883
- value_getter=self.reachy_controller.get_body_yaw,
884
- value_setter=self.reachy_controller.set_body_yaw,
885
- )
886
- self.state.entities.append(body_yaw)
887
-
888
- # Antenna controls
889
- antenna_left = NumberEntity(
890
- server=self,
891
- key=self._get_entity_key("antenna_left"),
892
- name="Antenna(L)",
893
- object_id="antenna_left",
894
- min_value=-90.0,
895
- max_value=90.0,
896
- step=1.0,
897
- icon="mdi:antenna",
898
- unit_of_measurement="°",
899
- mode=2,
900
- value_getter=self.reachy_controller.get_antenna_left,
901
- value_setter=self.reachy_controller.set_antenna_left,
902
- )
903
- self.state.entities.append(antenna_left)
904
-
905
- antenna_right = NumberEntity(
906
- server=self,
907
- key=self._get_entity_key("antenna_right"),
908
- name="Antenna(R)",
909
- object_id="antenna_right",
910
- min_value=-90.0,
911
- max_value=90.0,
912
- step=1.0,
913
- icon="mdi:antenna",
914
- unit_of_measurement="°",
915
- mode=2,
916
- value_getter=self.reachy_controller.get_antenna_right,
917
- value_setter=self.reachy_controller.set_antenna_right,
918
- )
919
- self.state.entities.append(antenna_right)
920
-
921
- _LOGGER.info("Phase 3 entities registered: head position/orientation, body_yaw, antennas")
922
-
923
- def _setup_phase4_entities(self) -> None:
924
- """Setup Phase 4 entities: Look at control."""
925
-
926
- # Look at X coordinate
927
- look_at_x = NumberEntity(
928
- server=self,
929
- key=self._get_entity_key("look_at_x"),
930
- name="Look At X",
931
- object_id="look_at_x",
932
- min_value=-2.0,
933
- max_value=2.0,
934
- step=0.1,
935
- icon="mdi:crosshairs-gps",
936
- unit_of_measurement="m",
937
- mode=1, # Box mode for precise input
938
- value_getter=self.reachy_controller.get_look_at_x,
939
- value_setter=self.reachy_controller.set_look_at_x,
940
- )
941
- self.state.entities.append(look_at_x)
942
-
943
- # Look at Y coordinate
944
- look_at_y = NumberEntity(
945
- server=self,
946
- key=self._get_entity_key("look_at_y"),
947
- name="Look At Y",
948
- object_id="look_at_y",
949
- min_value=-2.0,
950
- max_value=2.0,
951
- step=0.1,
952
- icon="mdi:crosshairs-gps",
953
- unit_of_measurement="m",
954
- mode=1,
955
- value_getter=self.reachy_controller.get_look_at_y,
956
- value_setter=self.reachy_controller.set_look_at_y,
957
- )
958
- self.state.entities.append(look_at_y)
959
-
960
- # Look at Z coordinate
961
- look_at_z = NumberEntity(
962
- server=self,
963
- key=self._get_entity_key("look_at_z"),
964
- name="Look At Z",
965
- object_id="look_at_z",
966
- min_value=-2.0,
967
- max_value=2.0,
968
- step=0.1,
969
- icon="mdi:crosshairs-gps",
970
- unit_of_measurement="m",
971
- mode=1,
972
- value_getter=self.reachy_controller.get_look_at_z,
973
- value_setter=self.reachy_controller.set_look_at_z,
974
- )
975
- self.state.entities.append(look_at_z)
976
-
977
- _LOGGER.info("Phase 4 entities registered: look_at_x/y/z")
978
-
979
- def _setup_phase5_entities(self) -> None:
980
- """Setup Phase 5 entities: Audio sensors."""
981
-
982
- # DOA angle sensor
983
- self._doa_angle_entity = SensorEntity(
984
- server=self,
985
- key=self._get_entity_key("doa_angle"),
986
- name="DOA Angle",
987
- object_id="doa_angle",
988
- icon="mdi:compass",
989
- unit_of_measurement="°",
990
- accuracy_decimals=1,
991
- state_class="measurement",
992
- value_getter=self.reachy_controller.get_doa_angle,
993
- )
994
- self.state.entities.append(self._doa_angle_entity)
995
-
996
- # Speech detected sensor
997
- self._speech_detected_entity = BinarySensorEntity(
998
- server=self,
999
- key=self._get_entity_key("speech_detected"),
1000
- name="Speech Detected",
1001
- object_id="speech_detected",
1002
- icon="mdi:microphone",
1003
- device_class="sound",
1004
- value_getter=self.reachy_controller.get_speech_detected,
1005
- )
1006
- self.state.entities.append(self._speech_detected_entity)
1007
-
1008
- _LOGGER.info("Phase 5 entities registered: doa_angle, speech_detected")
1009
-
1010
- def _setup_phase6_entities(self) -> None:
1011
- """Setup Phase 6 entities: Diagnostic information."""
1012
-
1013
- # Control loop frequency
1014
- control_loop_freq = SensorEntity(
1015
- server=self,
1016
- key=self._get_entity_key("control_loop_frequency"),
1017
- name="Control Loop Frequency",
1018
- object_id="control_loop_frequency",
1019
- icon="mdi:speedometer",
1020
- unit_of_measurement="Hz",
1021
- accuracy_decimals=1,
1022
- state_class="measurement",
1023
- value_getter=self.reachy_controller.get_control_loop_frequency,
1024
- )
1025
- self.state.entities.append(control_loop_freq)
1026
-
1027
- # SDK version
1028
- sdk_version = TextSensorEntity(
1029
- server=self,
1030
- key=self._get_entity_key("sdk_version"),
1031
- name="SDK Version",
1032
- object_id="sdk_version",
1033
- icon="mdi:information",
1034
- value_getter=self.reachy_controller.get_sdk_version,
1035
- )
1036
- self.state.entities.append(sdk_version)
1037
-
1038
- # Robot name
1039
- robot_name = TextSensorEntity(
1040
- server=self,
1041
- key=self._get_entity_key("robot_name"),
1042
- name="Robot Name",
1043
- object_id="robot_name",
1044
- icon="mdi:robot",
1045
- value_getter=self.reachy_controller.get_robot_name,
1046
- )
1047
- self.state.entities.append(robot_name)
1048
-
1049
- # Wireless version
1050
- wireless_version = BinarySensorEntity(
1051
- server=self,
1052
- key=self._get_entity_key("wireless_version"),
1053
- name="Wireless Version",
1054
- object_id="wireless_version",
1055
- icon="mdi:wifi",
1056
- device_class="connectivity",
1057
- value_getter=self.reachy_controller.get_wireless_version,
1058
- )
1059
- self.state.entities.append(wireless_version)
1060
-
1061
- # Simulation mode
1062
- simulation_mode = BinarySensorEntity(
1063
- server=self,
1064
- key=self._get_entity_key("simulation_mode"),
1065
- name="Simulation Mode",
1066
- object_id="simulation_mode",
1067
- icon="mdi:virtual-reality",
1068
- value_getter=self.reachy_controller.get_simulation_mode,
1069
- )
1070
- self.state.entities.append(simulation_mode)
1071
-
1072
- # WLAN IP
1073
- wlan_ip = TextSensorEntity(
1074
- server=self,
1075
- key=self._get_entity_key("wlan_ip"),
1076
- name="WLAN IP",
1077
- object_id="wlan_ip",
1078
- icon="mdi:ip-network",
1079
- value_getter=self.reachy_controller.get_wlan_ip,
1080
- )
1081
- self.state.entities.append(wlan_ip)
1082
-
1083
- _LOGGER.info("Phase 6 entities registered: control_loop_frequency, sdk_version, robot_name, wireless_version, simulation_mode, wlan_ip")
1084
-
1085
- def _setup_phase7_entities(self) -> None:
1086
- """Setup Phase 7 entities: IMU sensors (wireless only)."""
1087
-
1088
- # IMU Accelerometer
1089
- imu_accel_x = SensorEntity(
1090
- server=self,
1091
- key=self._get_entity_key("imu_accel_x"),
1092
- name="IMU Accel X",
1093
- object_id="imu_accel_x",
1094
- icon="mdi:axis-x-arrow",
1095
- unit_of_measurement="m/s²",
1096
- accuracy_decimals=3,
1097
- state_class="measurement",
1098
- value_getter=self.reachy_controller.get_imu_accel_x,
1099
- )
1100
- self.state.entities.append(imu_accel_x)
1101
-
1102
- imu_accel_y = SensorEntity(
1103
- server=self,
1104
- key=self._get_entity_key("imu_accel_y"),
1105
- name="IMU Accel Y",
1106
- object_id="imu_accel_y",
1107
- icon="mdi:axis-y-arrow",
1108
- unit_of_measurement="m/s²",
1109
- accuracy_decimals=3,
1110
- state_class="measurement",
1111
- value_getter=self.reachy_controller.get_imu_accel_y,
1112
- )
1113
- self.state.entities.append(imu_accel_y)
1114
-
1115
- imu_accel_z = SensorEntity(
1116
- server=self,
1117
- key=self._get_entity_key("imu_accel_z"),
1118
- name="IMU Accel Z",
1119
- object_id="imu_accel_z",
1120
- icon="mdi:axis-z-arrow",
1121
- unit_of_measurement="m/s²",
1122
- accuracy_decimals=3,
1123
- state_class="measurement",
1124
- value_getter=self.reachy_controller.get_imu_accel_z,
1125
- )
1126
- self.state.entities.append(imu_accel_z)
1127
-
1128
- # IMU Gyroscope
1129
- imu_gyro_x = SensorEntity(
1130
- server=self,
1131
- key=self._get_entity_key("imu_gyro_x"),
1132
- name="IMU Gyro X",
1133
- object_id="imu_gyro_x",
1134
- icon="mdi:rotate-3d-variant",
1135
- unit_of_measurement="rad/s",
1136
- accuracy_decimals=3,
1137
- state_class="measurement",
1138
- value_getter=self.reachy_controller.get_imu_gyro_x,
1139
- )
1140
- self.state.entities.append(imu_gyro_x)
1141
-
1142
- imu_gyro_y = SensorEntity(
1143
- server=self,
1144
- key=self._get_entity_key("imu_gyro_y"),
1145
- name="IMU Gyro Y",
1146
- object_id="imu_gyro_y",
1147
- icon="mdi:rotate-3d-variant",
1148
- unit_of_measurement="rad/s",
1149
- accuracy_decimals=3,
1150
- state_class="measurement",
1151
- value_getter=self.reachy_controller.get_imu_gyro_y,
1152
- )
1153
- self.state.entities.append(imu_gyro_y)
1154
-
1155
- imu_gyro_z = SensorEntity(
1156
- server=self,
1157
- key=self._get_entity_key("imu_gyro_z"),
1158
- name="IMU Gyro Z",
1159
- object_id="imu_gyro_z",
1160
- icon="mdi:rotate-3d-variant",
1161
- unit_of_measurement="rad/s",
1162
- accuracy_decimals=3,
1163
- state_class="measurement",
1164
- value_getter=self.reachy_controller.get_imu_gyro_z,
1165
- )
1166
- self.state.entities.append(imu_gyro_z)
1167
-
1168
- # IMU Temperature
1169
- imu_temperature = SensorEntity(
1170
- server=self,
1171
- key=self._get_entity_key("imu_temperature"),
1172
- name="IMU Temperature",
1173
- object_id="imu_temperature",
1174
- icon="mdi:thermometer",
1175
- unit_of_measurement="°C",
1176
- accuracy_decimals=1,
1177
- device_class="temperature",
1178
- state_class="measurement",
1179
- value_getter=self.reachy_controller.get_imu_temperature,
1180
- )
1181
- self.state.entities.append(imu_temperature)
1182
-
1183
- _LOGGER.info("Phase 7 entities registered: IMU accelerometer, gyroscope, temperature")
1184
-
1185
- def _setup_phase8_entities(self) -> None:
1186
- """Setup Phase 8 entities: Emotion selector."""
1187
-
1188
- # Emotion options mapping
1189
- self._emotion_map = {
1190
- "None": None,
1191
- "Happy": "happy1",
1192
- "Sad": "sad1",
1193
- "Angry": "angry1",
1194
- "Fear": "fear1",
1195
- "Surprise": "surprise1",
1196
- "Disgust": "disgust1",
1197
- }
1198
-
1199
- def get_emotion() -> str:
1200
- return getattr(self, '_current_emotion', "None")
1201
-
1202
- def set_emotion(emotion: str) -> None:
1203
- self._current_emotion = emotion
1204
- emotion_name = self._emotion_map.get(emotion)
1205
- if emotion_name:
1206
- self._play_emotion(emotion_name)
1207
- # Reset to None after playing
1208
- self._current_emotion = "None"
1209
-
1210
- # Emotion selector
1211
- emotion_select = SelectEntity(
1212
- server=self,
1213
- key=self._get_entity_key("emotion"),
1214
- name="Emotion",
1215
- object_id="emotion",
1216
- options=list(self._emotion_map.keys()),
1217
- icon="mdi:emoticon",
1218
- value_getter=get_emotion,
1219
- value_setter=set_emotion,
1220
- )
1221
- self.state.entities.append(emotion_select)
1222
-
1223
- _LOGGER.info("Phase 8 entities registered: emotion selector")
1224
-
1225
- def _setup_phase9_entities(self) -> None:
1226
- """Setup Phase 9 entities: Audio controls."""
1227
-
1228
- # Microphone volume control
1229
- microphone_volume = NumberEntity(
1230
- server=self,
1231
- key=self._get_entity_key("microphone_volume"),
1232
- name="Microphone Volume",
1233
- object_id="microphone_volume",
1234
- min_value=0.0,
1235
- max_value=100.0,
1236
- step=1.0,
1237
- icon="mdi:microphone",
1238
- unit_of_measurement="%",
1239
- mode=2, # Slider mode
1240
- entity_category=1, # config
1241
- value_getter=self.reachy_controller.get_microphone_volume,
1242
- value_setter=self.reachy_controller.set_microphone_volume,
1243
- )
1244
- self.state.entities.append(microphone_volume)
1245
-
1246
- _LOGGER.info("Phase 9 entities registered: microphone_volume")
1247
-
1248
- def _setup_phase10_entities(self) -> None:
1249
- """Setup Phase 10 entities: Camera for Home Assistant integration."""
1250
-
1251
- # Camera image getter - returns JPEG bytes from camera server
1252
- def get_camera_image() -> Optional[bytes]:
1253
- """Get camera snapshot as JPEG bytes."""
1254
- if self.camera_server:
1255
- return self.camera_server.get_snapshot()
1256
- return None
1257
-
1258
- # Real camera entity - shows preview in Home Assistant
1259
- camera_entity = CameraEntity(
1260
- server=self,
1261
- key=self._get_entity_key("camera"), # Use new camera key
1262
- name="Camera",
1263
- object_id="camera",
1264
- icon="mdi:camera",
1265
- image_getter=get_camera_image,
1266
- )
1267
- self.state.entities.append(camera_entity)
1268
-
1269
- _LOGGER.info("Phase 10 entities registered: camera (ESPHome Camera entity)")
1270
-
1271
- def _setup_phase11_entities(self) -> None:
1272
- """Setup Phase 11 entities: LED control (via local SDK)."""
1273
-
1274
- # LED Brightness
1275
- led_brightness = NumberEntity(
1276
- server=self,
1277
- key=self._get_entity_key("led_brightness"),
1278
- name="LED Brightness",
1279
- object_id="led_brightness",
1280
- min_value=0.0,
1281
- max_value=100.0,
1282
- step=1.0,
1283
- icon="mdi:brightness-6",
1284
- unit_of_measurement="%",
1285
- mode=2, # Slider mode
1286
- value_getter=self.reachy_controller.get_led_brightness,
1287
- value_setter=self.reachy_controller.set_led_brightness,
1288
- )
1289
- self.state.entities.append(led_brightness)
1290
-
1291
- # LED Effect
1292
- led_effect = SelectEntity(
1293
- server=self,
1294
- key=self._get_entity_key("led_effect"),
1295
- name="LED Effect",
1296
- object_id="led_effect",
1297
- options=["off", "solid", "breathing", "rainbow", "doa"],
1298
- icon="mdi:led-on",
1299
- value_getter=self.reachy_controller.get_led_effect,
1300
- value_setter=self.reachy_controller.set_led_effect,
1301
- )
1302
- self.state.entities.append(led_effect)
1303
-
1304
- # LED Color R
1305
- led_color_r = NumberEntity(
1306
- server=self,
1307
- key=self._get_entity_key("led_color_r"),
1308
- name="LED Color Red",
1309
- object_id="led_color_r",
1310
- min_value=0.0,
1311
- max_value=255.0,
1312
- step=1.0,
1313
- icon="mdi:palette",
1314
- mode=2,
1315
- value_getter=self.reachy_controller.get_led_color_r,
1316
- value_setter=self.reachy_controller.set_led_color_r,
1317
- )
1318
- self.state.entities.append(led_color_r)
1319
-
1320
- # LED Color G
1321
- led_color_g = NumberEntity(
1322
- server=self,
1323
- key=self._get_entity_key("led_color_g"),
1324
- name="LED Color Green",
1325
- object_id="led_color_g",
1326
- min_value=0.0,
1327
- max_value=255.0,
1328
- step=1.0,
1329
- icon="mdi:palette",
1330
- mode=2,
1331
- value_getter=self.reachy_controller.get_led_color_g,
1332
- value_setter=self.reachy_controller.set_led_color_g,
1333
- )
1334
- self.state.entities.append(led_color_g)
1335
-
1336
- # LED Color B
1337
- led_color_b = NumberEntity(
1338
- server=self,
1339
- key=self._get_entity_key("led_color_b"),
1340
- name="LED Color Blue",
1341
- object_id="led_color_b",
1342
- min_value=0.0,
1343
- max_value=255.0,
1344
- step=1.0,
1345
- icon="mdi:palette",
1346
- mode=2,
1347
- value_getter=self.reachy_controller.get_led_color_b,
1348
- value_setter=self.reachy_controller.set_led_color_b,
1349
- )
1350
- self.state.entities.append(led_color_b)
1351
-
1352
- _LOGGER.info("Phase 11 entities registered: led_brightness, led_effect, led_color_r/g/b")
1353
-
1354
- def _setup_phase12_entities(self) -> None:
1355
- """Setup Phase 12 entities: Audio processing parameters (via local SDK)."""
1356
-
1357
- # AGC Enabled
1358
- agc_enabled = SwitchEntity(
1359
- server=self,
1360
- key=self._get_entity_key("agc_enabled"),
1361
- name="AGC Enabled",
1362
- object_id="agc_enabled",
1363
- icon="mdi:tune-vertical",
1364
- device_class="switch",
1365
- entity_category=1, # config
1366
- value_getter=self.reachy_controller.get_agc_enabled,
1367
- value_setter=self.reachy_controller.set_agc_enabled,
1368
- )
1369
- self.state.entities.append(agc_enabled)
1370
-
1371
- # AGC Max Gain
1372
- agc_max_gain = NumberEntity(
1373
- server=self,
1374
- key=self._get_entity_key("agc_max_gain"),
1375
- name="AGC Max Gain",
1376
- object_id="agc_max_gain",
1377
- min_value=0.0,
1378
- max_value=30.0,
1379
- step=1.0,
1380
- icon="mdi:volume-plus",
1381
- unit_of_measurement="dB",
1382
- mode=2,
1383
- entity_category=1, # config
1384
- value_getter=self.reachy_controller.get_agc_max_gain,
1385
- value_setter=self.reachy_controller.set_agc_max_gain,
1386
- )
1387
- self.state.entities.append(agc_max_gain)
1388
-
1389
- # Noise Suppression Level
1390
- noise_suppression = NumberEntity(
1391
- server=self,
1392
- key=self._get_entity_key("noise_suppression"),
1393
- name="Noise Suppression",
1394
- object_id="noise_suppression",
1395
- min_value=0.0,
1396
- max_value=100.0,
1397
- step=1.0,
1398
- icon="mdi:volume-off",
1399
- unit_of_measurement="%",
1400
- mode=2,
1401
- entity_category=1, # config
1402
- value_getter=self.reachy_controller.get_noise_suppression,
1403
- value_setter=self.reachy_controller.set_noise_suppression,
1404
- )
1405
- self.state.entities.append(noise_suppression)
1406
-
1407
- # Echo Cancellation Converged (read-only diagnostic status)
1408
- echo_cancellation_converged = BinarySensorEntity(
1409
- server=self,
1410
- key=self._get_entity_key("echo_cancellation_converged"),
1411
- name="Echo Cancellation Converged",
1412
- object_id="echo_cancellation_converged",
1413
- icon="mdi:waveform",
1414
- device_class="running",
1415
- entity_category=2, # diagnostic
1416
- value_getter=self.reachy_controller.get_echo_cancellation_converged,
1417
- )
1418
- self.state.entities.append(echo_cancellation_converged)
1419
-
1420
- _LOGGER.info("Phase 12 entities registered: agc_enabled, agc_max_gain, noise_suppression, echo_cancellation_converged")
1421
-
1422
- def _setup_phase13_entities(self) -> None:
1423
- """Setup Phase 13 entities: Robot joints as JSON sensor."""
1424
-
1425
- # Head joints sensor - returns JSON array of [yaw_body, stewart_1, ..., stewart_6]
1426
- head_joints_sensor = TextSensorEntity(
1427
- server=self,
1428
- key=self._get_entity_key("head_joints"),
1429
- name="Head Joints",
1430
- object_id="head_joints",
1431
- icon="mdi:robot",
1432
- value_getter=self.reachy_controller.get_head_joints_json,
1433
- )
1434
- self.state.entities.append(head_joints_sensor)
1435
-
1436
- _LOGGER.info("Phase 13 entities registered: head_joints")
1437
-
1438
- # Phase 14: Passive joints for 3D visualization
1439
- passive_joints_sensor = TextSensorEntity(
1440
- server=self,
1441
- key=self._get_entity_key("passive_joints"),
1442
- name="Passive Joints",
1443
- object_id="passive_joints",
1444
- value_getter=self.reachy_controller.get_passive_joints_json,
1445
- )
1446
- self.state.entities.append(passive_joints_sensor)
1447
-
1448
- _LOGGER.info("Phase 14 entities registered: passive_joints")
 
49
  from pyopen_wakeword import OpenWakeWord
50
 
51
  from .api_server import APIServer
52
+ from .entity import MediaPlayerEntity
53
+ from .entity_registry import EntityRegistry, get_entity_key
54
  from .models import AvailableWakeWord, ServerState, WakeWordType
55
  from .util import call_all
56
  from .reachy_controller import ReachyController
 
61
  class VoiceSatelliteProtocol(APIServer):
62
  """Voice satellite protocol handler for ESPHome."""
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def __init__(self, state: ServerState, camera_server: Optional["MJPEGCameraServer"] = None) -> None:
65
  super().__init__(state.name)
66
  self.state = state
 
70
  # Initialize Reachy controller
71
  self.reachy_controller = ReachyController(state.reachy_mini)
72
 
73
+ # Initialize entity registry
74
+ self._entity_registry = EntityRegistry(
75
+ server=self,
76
+ reachy_controller=self.reachy_controller,
77
+ camera_server=camera_server,
78
+ play_emotion_callback=self._play_emotion,
79
+ )
80
 
81
  # Only setup entities once (check if already initialized)
82
  # This prevents duplicate entity registration on reconnection
 
84
  if self.state.media_player_entity is None:
85
  self.state.media_player_entity = MediaPlayerEntity(
86
  server=self,
87
+ key=get_entity_key("reachy_mini_media_player"),
88
  name="Media Player",
89
  object_id="reachy_mini_media_player",
90
  music_player=state.music_player,
 
92
  )
93
  self.state.entities.append(self.state.media_player_entity)
94
 
95
+ # Setup all entities using the registry
96
+ self._entity_registry.setup_all_entities(self.state.entities)
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  # Mark entities as initialized
99
  self.state._entities_initialized = True
 
104
  for entity in self.state.entities:
105
  entity.server = self
106
  # Find and store references to DOA entities
107
+ self._entity_registry.find_entity_references(self.state.entities)
 
 
 
 
 
108
 
109
  self._is_streaming_audio = False
110
  self._tts_url: Optional[str] = None
 
476
  def _update_doa_entities(self) -> None:
477
  """Update DOA and speech detection entities in Home Assistant."""
478
  try:
479
+ if self._entity_registry.doa_angle_entity is not None:
480
+ self._entity_registry.doa_angle_entity.update_state()
481
  _LOGGER.debug("DOA angle entity updated")
482
+ if self._entity_registry.speech_detected_entity is not None:
483
+ self._entity_registry.speech_detected_entity.update_state()
484
  _LOGGER.debug("Speech detected entity updated")
485
  except Exception as e:
486
  _LOGGER.error("Error updating DOA entities: %s", e)
 
571
 
572
  except Exception as e:
573
  _LOGGER.error(f"Error playing emotion {emotion_name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -10,6 +10,7 @@ import json
10
  import logging
11
  import threading
12
  import time
 
13
  from pathlib import Path
14
  from queue import Queue
15
  from typing import Dict, List, Optional, Set, Union
@@ -34,6 +35,18 @@ _SOUNDS_DIR = _MODULE_DIR / "sounds"
34
  _LOCAL_DIR = _MODULE_DIR.parent / "local"
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  class VoiceAssistantService:
38
  """Voice assistant service that runs ESPHome protocol server."""
39
 
@@ -351,192 +364,51 @@ class VoiceAssistantService:
351
  return None
352
 
353
  def _process_audio(self) -> None:
354
- """Process audio from Reachy Mini's microphone."""
355
- from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
356
- from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
357
 
358
- wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
359
- micro_features: Optional[MicroWakeWordFeatures] = None
360
- micro_inputs: List[np.ndarray] = []
361
- oww_features: Optional[OpenWakeWordFeatures] = None
362
- oww_inputs: List[np.ndarray] = []
363
- has_oww = False
364
- last_active: Optional[float] = None
365
 
366
  try:
367
  _LOGGER.info("Starting audio processing...")
368
 
369
- # Use Reachy Mini's microphone if available
370
- use_reachy_audio = self.reachy_mini is not None
371
-
372
- if use_reachy_audio:
373
  _LOGGER.info("Using Reachy Mini's microphone")
374
- self._process_audio_reachy(
375
- wake_words, micro_features, micro_inputs,
376
- oww_features, oww_inputs, has_oww, last_active
377
- )
378
  else:
379
  _LOGGER.info("Using system microphone (fallback)")
380
- self._process_audio_fallback(
381
- wake_words, micro_features, micro_inputs,
382
- oww_features, oww_inputs, has_oww, last_active
383
- )
384
 
385
  except Exception:
386
  _LOGGER.exception("Error processing audio")
387
 
388
- def _process_audio_reachy(
389
- self,
390
- wake_words, micro_features, micro_inputs,
391
- oww_features, oww_inputs, has_oww, last_active
392
- ) -> None:
393
- """Process audio using Reachy Mini's microphone.
394
-
395
- Based on official SDK examples (sound_record.py):
396
- - get_audio_sample() returns np.ndarray with dtype=float32, shape=(samples, 2)
397
- - Data is already normalized to [-1.0, 1.0] range
398
- """
399
- from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
400
- from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
401
-
402
- # Initialize features once
403
- micro_features = MicroWakeWordFeatures()
404
-
405
  while self._running:
406
  try:
407
- # Skip if no satellite connection
408
- if self._state is None or self._state.satellite is None:
409
- time.sleep(0.1)
410
  continue
411
 
412
- # Update wake words list if changed
413
- if (not wake_words) or (self._state.wake_words_changed and self._state.wake_words):
414
- self._state.wake_words_changed = False
415
- wake_words.clear()
416
- wake_words.extend([
417
- ww for ww in self._state.wake_words.values()
418
- if ww.id in self._state.active_wake_words
419
- ])
420
-
421
- has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
422
- if has_oww and oww_features is None:
423
- oww_features = OpenWakeWordFeatures.from_builtin()
424
-
425
- _LOGGER.debug("Wake words updated: %s", [ww.id for ww in wake_words])
426
 
427
  # Get audio from Reachy Mini
428
- audio_data = self.reachy_mini.media.get_audio_sample()
429
-
430
- # Skip if no data
431
- if audio_data is None:
432
  time.sleep(0.01)
433
  continue
434
 
435
- # Validate data type
436
- if not isinstance(audio_data, np.ndarray):
437
- time.sleep(0.01)
438
- continue
439
-
440
- # Skip empty arrays
441
- if audio_data.size == 0:
442
- time.sleep(0.01)
443
- continue
444
-
445
- # Validate and convert dtype
446
- try:
447
- if audio_data.dtype.kind in ('S', 'U', 'O', 'V', 'b'):
448
- time.sleep(0.01)
449
- continue
450
- if audio_data.dtype != np.float32:
451
- audio_data = np.asarray(audio_data, dtype=np.float32)
452
- except (TypeError, ValueError):
453
- time.sleep(0.01)
454
- continue
455
-
456
- # Convert stereo to mono
457
- try:
458
- if audio_data.ndim == 2 and audio_data.shape[1] == 2:
459
- audio_chunk_array = audio_data.mean(axis=1)
460
- elif audio_data.ndim == 2:
461
- audio_chunk_array = audio_data[:, 0].copy()
462
- elif audio_data.ndim == 1:
463
- audio_chunk_array = audio_data
464
- else:
465
- time.sleep(0.01)
466
- continue
467
- except Exception:
468
- time.sleep(0.01)
469
- continue
470
-
471
- # Convert to 16-bit PCM bytes
472
- audio_chunk = (
473
- (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
474
- .astype("<i2")
475
- .tobytes()
476
- )
477
-
478
- # Stream audio to Home Assistant
479
- self._state.satellite.handle_audio(audio_chunk)
480
-
481
- # Process wake word features
482
- micro_inputs.clear()
483
- micro_inputs.extend(micro_features.process_streaming(audio_chunk))
484
-
485
- if has_oww and oww_features is not None:
486
- oww_inputs.clear()
487
- oww_inputs.extend(oww_features.process_streaming(audio_chunk))
488
-
489
- # Check each wake word
490
- for wake_word in wake_words:
491
- activated = False
492
-
493
- if isinstance(wake_word, MicroWakeWord):
494
- for micro_input in micro_inputs:
495
- if wake_word.process_streaming(micro_input):
496
- activated = True
497
- elif isinstance(wake_word, OpenWakeWord):
498
- for oww_input in oww_inputs:
499
- for prob in wake_word.process_streaming(oww_input):
500
- if prob > 0.5:
501
- activated = True
502
-
503
- if activated:
504
- now = time.monotonic()
505
- if (last_active is None) or ((now - last_active) > self._state.refractory_seconds):
506
- _LOGGER.info("Wake word detected: %s", wake_word.id)
507
- self._state.satellite.wakeup(wake_word)
508
- # Get DOA angle and turn to sound source
509
- doa_angle_deg = self._get_doa_angle_deg()
510
- self._motion.on_wakeup(doa_angle_deg)
511
- last_active = now
512
-
513
- # Process stop word
514
- if self._state.stop_word:
515
- stopped = False
516
- for micro_input in micro_inputs:
517
- if self._state.stop_word.process_streaming(micro_input):
518
- stopped = True
519
-
520
- if stopped and (self._state.stop_word.id in self._state.active_wake_words):
521
- _LOGGER.info("Stop word detected")
522
- self._state.satellite.stop()
523
 
524
  except Exception as e:
525
  _LOGGER.error("Error in Reachy audio processing: %s", e)
526
  time.sleep(0.1)
527
 
528
- def _process_audio_fallback(
529
- self,
530
- wake_words, micro_features, micro_inputs,
531
- oww_features, oww_inputs, has_oww, last_active
532
- ) -> None:
533
- """Process audio using system microphone (fallback)."""
534
  import sounddevice as sd
535
- from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
536
- from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
537
 
538
  block_size = 1024
539
- micro_features = MicroWakeWordFeatures()
540
 
541
  with sd.InputStream(
542
  samplerate=16000,
@@ -545,82 +417,163 @@ class VoiceAssistantService:
545
  dtype="float32",
546
  ) as stream:
547
  while self._running:
548
- # Skip if no satellite connection
549
- if self._state is None or self._state.satellite is None:
550
- time.sleep(0.1)
551
  continue
552
 
553
- # Update wake words list if changed
554
- if (not wake_words) or (self._state.wake_words_changed and self._state.wake_words):
555
- self._state.wake_words_changed = False
556
- wake_words.clear()
557
- wake_words.extend([
558
- ww for ww in self._state.wake_words.values()
559
- if ww.id in self._state.active_wake_words
560
- ])
561
-
562
- has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
563
- if has_oww and oww_features is None:
564
- oww_features = OpenWakeWordFeatures.from_builtin()
565
 
 
566
  audio_chunk_array, overflowed = stream.read(block_size)
567
  if overflowed:
568
  _LOGGER.warning("Audio buffer overflow")
569
 
570
  audio_chunk_array = audio_chunk_array.reshape(-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
- # Convert to 16-bit PCM bytes
573
- audio_chunk = (
574
- (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
575
- .astype("<i2")
576
- .tobytes()
577
- )
578
-
579
- # Stream audio to Home Assistant
580
- self._state.satellite.handle_audio(audio_chunk)
581
-
582
- # Process wake word features
583
- micro_inputs.clear()
584
- micro_inputs.extend(micro_features.process_streaming(audio_chunk))
585
-
586
- if has_oww and oww_features is not None:
587
- oww_inputs.clear()
588
- oww_inputs.extend(oww_features.process_streaming(audio_chunk))
589
-
590
- # Check each wake word
591
- for wake_word in wake_words:
592
- activated = False
593
-
594
- if isinstance(wake_word, MicroWakeWord):
595
- for micro_input in micro_inputs:
596
- if wake_word.process_streaming(micro_input):
597
- activated = True
598
- elif isinstance(wake_word, OpenWakeWord):
599
- for oww_input in oww_inputs:
600
- for prob in wake_word.process_streaming(oww_input):
601
- if prob > 0.5:
602
- activated = True
603
-
604
- if activated:
605
- now = time.monotonic()
606
- if (last_active is None) or ((now - last_active) > self._state.refractory_seconds):
607
- _LOGGER.info("Wake word detected: %s", wake_word.id)
608
- self._state.satellite.wakeup(wake_word)
609
- # Get DOA angle and turn to sound source
610
- doa_angle_deg = self._get_doa_angle_deg()
611
- self._motion.on_wakeup(doa_angle_deg)
612
- last_active = now
613
-
614
- # Process stop word
615
- if self._state.stop_word:
616
- stopped = False
617
- for micro_input in micro_inputs:
618
- if self._state.stop_word.process_streaming(micro_input):
619
- stopped = True
620
-
621
- if stopped and (self._state.stop_word.id in self._state.active_wake_words):
622
- _LOGGER.info("Stop word detected")
623
- self._state.satellite.stop()
624
 
625
  def _get_doa_angle_deg(self) -> Optional[float]:
626
  """Get DOA angle in degrees from Reachy Mini's microphone array.
 
10
  import logging
11
  import threading
12
  import time
13
+ from dataclasses import dataclass, field
14
  from pathlib import Path
15
  from queue import Queue
16
  from typing import Dict, List, Optional, Set, Union
 
35
  _LOCAL_DIR = _MODULE_DIR.parent / "local"
36
 
37
 
38
+ @dataclass
39
+ class AudioProcessingContext:
40
+ """Context for audio processing, holding mutable state."""
41
+ wake_words: List = field(default_factory=list)
42
+ micro_features: Optional[object] = None
43
+ micro_inputs: List = field(default_factory=list)
44
+ oww_features: Optional[object] = None
45
+ oww_inputs: List = field(default_factory=list)
46
+ has_oww: bool = False
47
+ last_active: Optional[float] = None
48
+
49
+
50
  class VoiceAssistantService:
51
  """Voice assistant service that runs ESPHome protocol server."""
52
 
 
364
  return None
365
 
366
  def _process_audio(self) -> None:
367
+ """Process audio from microphone (Reachy Mini or system fallback)."""
368
+ from pymicro_wakeword import MicroWakeWordFeatures
 
369
 
370
+ ctx = AudioProcessingContext()
371
+ ctx.micro_features = MicroWakeWordFeatures()
 
 
 
 
 
372
 
373
  try:
374
  _LOGGER.info("Starting audio processing...")
375
 
376
+ if self.reachy_mini is not None:
 
 
 
377
  _LOGGER.info("Using Reachy Mini's microphone")
378
+ self._audio_loop_reachy(ctx)
 
 
 
379
  else:
380
  _LOGGER.info("Using system microphone (fallback)")
381
+ self._audio_loop_fallback(ctx)
 
 
 
382
 
383
  except Exception:
384
  _LOGGER.exception("Error processing audio")
385
 
386
+ def _audio_loop_reachy(self, ctx: AudioProcessingContext) -> None:
387
+ """Audio loop using Reachy Mini's microphone."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  while self._running:
389
  try:
390
+ if not self._wait_for_satellite():
 
 
391
  continue
392
 
393
+ self._update_wake_words_list(ctx)
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  # Get audio from Reachy Mini
396
+ audio_chunk = self._get_reachy_audio_chunk()
397
+ if audio_chunk is None:
 
 
398
  time.sleep(0.01)
399
  continue
400
 
401
+ self._process_audio_chunk(ctx, audio_chunk)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
  except Exception as e:
404
  _LOGGER.error("Error in Reachy audio processing: %s", e)
405
  time.sleep(0.1)
406
 
407
+ def _audio_loop_fallback(self, ctx: AudioProcessingContext) -> None:
408
+ """Audio loop using system microphone (fallback)."""
 
 
 
 
409
  import sounddevice as sd
 
 
410
 
411
  block_size = 1024
 
412
 
413
  with sd.InputStream(
414
  samplerate=16000,
 
417
  dtype="float32",
418
  ) as stream:
419
  while self._running:
420
+ if not self._wait_for_satellite():
 
 
421
  continue
422
 
423
+ self._update_wake_words_list(ctx)
 
 
 
 
 
 
 
 
 
 
 
424
 
425
+ # Get audio from system microphone
426
  audio_chunk_array, overflowed = stream.read(block_size)
427
  if overflowed:
428
  _LOGGER.warning("Audio buffer overflow")
429
 
430
  audio_chunk_array = audio_chunk_array.reshape(-1)
431
+ audio_chunk = self._convert_to_pcm(audio_chunk_array)
432
+
433
+ self._process_audio_chunk(ctx, audio_chunk)
434
+
435
+ def _wait_for_satellite(self) -> bool:
436
+ """Wait for satellite connection. Returns True if connected."""
437
+ if self._state is None or self._state.satellite is None:
438
+ time.sleep(0.1)
439
+ return False
440
+ return True
441
+
442
+ def _update_wake_words_list(self, ctx: AudioProcessingContext) -> None:
443
+ """Update wake words list if changed."""
444
+ from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
445
+
446
+ if (not ctx.wake_words) or (self._state.wake_words_changed and self._state.wake_words):
447
+ self._state.wake_words_changed = False
448
+ ctx.wake_words.clear()
449
+ ctx.wake_words.extend([
450
+ ww for ww in self._state.wake_words.values()
451
+ if ww.id in self._state.active_wake_words
452
+ ])
453
+
454
+ ctx.has_oww = any(isinstance(ww, OpenWakeWord) for ww in ctx.wake_words)
455
+ if ctx.has_oww and ctx.oww_features is None:
456
+ ctx.oww_features = OpenWakeWordFeatures.from_builtin()
457
+
458
+ _LOGGER.debug("Wake words updated: %s", [ww.id for ww in ctx.wake_words])
459
+
460
+ def _get_reachy_audio_chunk(self) -> Optional[bytes]:
461
+ """Get audio chunk from Reachy Mini's microphone.
462
+
463
+ Returns:
464
+ PCM audio bytes, or None if no valid audio available.
465
+ """
466
+ audio_data = self.reachy_mini.media.get_audio_sample()
467
+
468
+ # Validate audio data
469
+ if audio_data is None:
470
+ return None
471
+ if not isinstance(audio_data, np.ndarray):
472
+ return None
473
+ if audio_data.size == 0:
474
+ return None
475
+
476
+ # Validate and convert dtype
477
+ try:
478
+ if audio_data.dtype.kind in ('S', 'U', 'O', 'V', 'b'):
479
+ return None
480
+ if audio_data.dtype != np.float32:
481
+ audio_data = np.asarray(audio_data, dtype=np.float32)
482
+ except (TypeError, ValueError):
483
+ return None
484
+
485
+ # Convert stereo to mono
486
+ try:
487
+ if audio_data.ndim == 2 and audio_data.shape[1] == 2:
488
+ audio_chunk_array = audio_data.mean(axis=1)
489
+ elif audio_data.ndim == 2:
490
+ audio_chunk_array = audio_data[:, 0].copy()
491
+ elif audio_data.ndim == 1:
492
+ audio_chunk_array = audio_data
493
+ else:
494
+ return None
495
+ except Exception:
496
+ return None
497
+
498
+ return self._convert_to_pcm(audio_chunk_array)
499
+
500
+ def _convert_to_pcm(self, audio_chunk_array: np.ndarray) -> bytes:
501
+ """Convert float32 audio array to 16-bit PCM bytes."""
502
+ return (
503
+ (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
504
+ .astype("<i2")
505
+ .tobytes()
506
+ )
507
+
508
+ def _process_audio_chunk(self, ctx: AudioProcessingContext, audio_chunk: bytes) -> None:
509
+ """Process an audio chunk for wake word detection.
510
+
511
+ Args:
512
+ ctx: Audio processing context
513
+ audio_chunk: PCM audio bytes
514
+ """
515
+ # Stream audio to Home Assistant
516
+ self._state.satellite.handle_audio(audio_chunk)
517
+
518
+ # Process wake word features
519
+ self._process_features(ctx, audio_chunk)
520
+
521
+ # Detect wake words
522
+ self._detect_wake_words(ctx)
523
+
524
+ # Detect stop word
525
+ self._detect_stop_word(ctx)
526
+
527
+ def _process_features(self, ctx: AudioProcessingContext, audio_chunk: bytes) -> None:
528
+ """Process audio features for wake word detection."""
529
+ ctx.micro_inputs.clear()
530
+ ctx.micro_inputs.extend(ctx.micro_features.process_streaming(audio_chunk))
531
+
532
+ if ctx.has_oww and ctx.oww_features is not None:
533
+ ctx.oww_inputs.clear()
534
+ ctx.oww_inputs.extend(ctx.oww_features.process_streaming(audio_chunk))
535
+
536
+ def _detect_wake_words(self, ctx: AudioProcessingContext) -> None:
537
+ """Detect wake words in the processed audio features."""
538
+ from pymicro_wakeword import MicroWakeWord
539
+ from pyopen_wakeword import OpenWakeWord
540
 
541
+ for wake_word in ctx.wake_words:
542
+ activated = False
543
+
544
+ if isinstance(wake_word, MicroWakeWord):
545
+ for micro_input in ctx.micro_inputs:
546
+ if wake_word.process_streaming(micro_input):
547
+ activated = True
548
+ elif isinstance(wake_word, OpenWakeWord):
549
+ for oww_input in ctx.oww_inputs:
550
+ for prob in wake_word.process_streaming(oww_input):
551
+ if prob > 0.5:
552
+ activated = True
553
+
554
+ if activated:
555
+ now = time.monotonic()
556
+ if (ctx.last_active is None) or ((now - ctx.last_active) > self._state.refractory_seconds):
557
+ _LOGGER.info("Wake word detected: %s", wake_word.id)
558
+ self._state.satellite.wakeup(wake_word)
559
+ # Get DOA angle and turn to sound source
560
+ doa_angle_deg = self._get_doa_angle_deg()
561
+ self._motion.on_wakeup(doa_angle_deg)
562
+ ctx.last_active = now
563
+
564
+ def _detect_stop_word(self, ctx: AudioProcessingContext) -> None:
565
+ """Detect stop word in the processed audio features."""
566
+ if not self._state.stop_word:
567
+ return
568
+
569
+ stopped = False
570
+ for micro_input in ctx.micro_inputs:
571
+ if self._state.stop_word.process_streaming(micro_input):
572
+ stopped = True
573
+
574
+ if stopped and (self._state.stop_word.id in self._state.active_wake_words):
575
+ _LOGGER.info("Stop word detected")
576
+ self._state.satellite.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
  def _get_doa_angle_deg(self) -> Optional[float]:
579
  """Get DOA angle in degrees from Reachy Mini's microphone array.