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

Update app

Browse files
Files changed (41) hide show
  1. .claude/settings.local.json +0 -20
  2. .gitattributes +0 -4
  3. .gitignore +1 -74
  4. .serena/.gitignore +0 -1
  5. .serena/project.yml +0 -84
  6. PROJECT_PLAN.md +406 -406
  7. README.md +199 -203
  8. app.py +50 -50
  9. index.html +40 -0
  10. linux-voice-assistant +0 -1
  11. pyproject.toml +49 -49
  12. reachy_mini_conversation_app +0 -1
  13. reachy_mini_ha_voice/__init__.py +22 -22
  14. reachy_mini_ha_voice/camera_server.py +365 -365
  15. reachy_mini_ha_voice/entity_extensions.py +292 -292
  16. reachy_mini_ha_voice/main.py +175 -175
  17. reachy_mini_ha_voice/reachy_controller.py +1008 -1008
  18. reachy_mini_ha_voice/satellite.py +0 -0
  19. reachy_mini_ha_voice/sounds/.gitkeep +1 -1
  20. reachy_mini_ha_voice/sounds/LICENSE.md +1 -1
  21. reachy_mini_ha_voice/sounds/README.md +9 -9
  22. reachy_mini_ha_voice/static/index.html +27 -0
  23. reachy_mini_ha_voice/static/main.js +47 -0
  24. reachy_mini_ha_voice/static/style.css +25 -0
  25. reachy_mini_ha_voice/voice_assistant.py +671 -671
  26. reachy_mini_ha_voice/wakewords/README.md +11 -11
  27. reachy_mini_ha_voice/wakewords/alexa.json +16 -16
  28. reachy_mini_ha_voice/wakewords/choo_choo_homie.json +17 -17
  29. reachy_mini_ha_voice/wakewords/hey_home_assistant.json +16 -16
  30. reachy_mini_ha_voice/wakewords/hey_jarvis.json +16 -16
  31. reachy_mini_ha_voice/wakewords/hey_luna.json +16 -16
  32. reachy_mini_ha_voice/wakewords/hey_mycroft.json +16 -16
  33. reachy_mini_ha_voice/wakewords/okay_computer.json +18 -18
  34. reachy_mini_ha_voice/wakewords/okay_nabu.json +16 -16
  35. reachy_mini_ha_voice/wakewords/openWakeWord/alexa_v0.1.json +5 -5
  36. reachy_mini_ha_voice/wakewords/openWakeWord/hey_jarvis_v0.1.json +5 -5
  37. reachy_mini_ha_voice/wakewords/openWakeWord/hey_mycroft_v0.1.json +5 -5
  38. reachy_mini_ha_voice/wakewords/openWakeWord/hey_rhasspy_v0.1.json +5 -5
  39. reachy_mini_ha_voice/wakewords/openWakeWord/ok_nabu_v0.1.json +5 -5
  40. reachy_mini_ha_voice/wakewords/stop.json +16 -16
  41. style.css +411 -0
.claude/settings.local.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
- "includeCoAuthoredBy": false,
4
- "permissions": {
5
- "allow": [
6
- "SlashCommand(/zcf:git-commit)"
7
- ],
8
- "deny": [],
9
- "ask": []
10
- },
11
- "hooks": {},
12
- "alwaysThinkingEnabled": true,
13
- "outputStyle": "default",
14
- "statusLine": {
15
- "type": "command",
16
- "command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe",
17
- "padding": 0
18
- },
19
- "model": "opus"
20
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitattributes DELETED
@@ -1,4 +0,0 @@
1
- reachy_mini_ha_voice/wakewords/**/*.tflite filter=lfs diff=lfs merge=lfs -text
2
- reachy_mini_ha_voice/sounds/**/*.flac filter=lfs diff=lfs merge=lfs -text
3
- "reachy_mini_ha_voice/wakewords/**/*.tflite filter=lfs diff=lfs merge=lfs -text
4
- reachy_mini_ha_voice/sounds/**/*.flac" filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
.gitignore CHANGED
@@ -1,76 +1,3 @@
1
- # Python
2
  __pycache__/
3
- *.py[cod]
4
- *$py.class
5
- *.so
6
- .Python
7
- build/
8
- develop-eggs/
9
- dist/
10
- downloads/
11
- eggs/
12
- .eggs/
13
- lib/
14
- lib64/
15
- parts/
16
- sdist/
17
- var/
18
- wheels/
19
- pip-wheel-metadata/
20
- share/python-wheels/
21
  *.egg-info/
22
- .installed.cfg
23
- *.egg
24
- MANIFEST
25
-
26
- # Virtual Environment
27
- .venv/
28
- venv/
29
- ENV/
30
- env/
31
-
32
- # IDE
33
- .vscode/
34
- .idea/
35
- *.swp
36
- *.swo
37
- .claude/*
38
- .serena/*
39
- .spec-workflow/
40
- .playwright-mcp/
41
- *~
42
-
43
- # Configuration
44
- config.json
45
- .env
46
- *.log
47
-
48
- # Cache
49
- .cache/
50
- *.cache
51
- .DS_Store
52
-
53
- # Testing
54
- .pytest_cache/
55
- .coverage
56
- htmlcov/
57
- .tox/
58
-
59
- # Audio (exclude package bundled files)
60
- *.wav
61
- *.mp3
62
- # *.flac - bundled in package
63
- !reachy_mini_ha_voice/sounds/*.flac
64
-
65
- # Models (exclude package bundled files)
66
- models/
67
- # *.tflite - bundled in package
68
- !reachy_mini_ha_voice/wakewords/*.tflite
69
- !reachy_mini_ha_voice/wakewords/**/*.tflite
70
- *.onnx
71
-
72
- # SDK Reference (local development only)
73
- reachy_mini/
74
- reachy_mini_conversation_app/
75
- linux-voice-assistant/
76
- reachy-mini-desktop-app/
 
 
1
  __pycache__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.egg-info/
3
+ build/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.serena/.gitignore DELETED
@@ -1 +0,0 @@
1
- /cache
 
 
.serena/project.yml DELETED
@@ -1,84 +0,0 @@
1
- # list of languages for which language servers are started; choose from:
2
- # al bash clojure cpp csharp csharp_omnisharp
3
- # dart elixir elm erlang fortran go
4
- # haskell java julia kotlin lua markdown
5
- # nix perl php python python_jedi r
6
- # rego ruby ruby_solargraph rust scala swift
7
- # terraform typescript typescript_vts yaml zig
8
- # Note:
9
- # - For C, use cpp
10
- # - For JavaScript, use typescript
11
- # Special requirements:
12
- # - csharp: Requires the presence of a .sln file in the project folder.
13
- # When using multiple languages, the first language server that supports a given file will be used for that file.
14
- # The first language is the default language and the respective language server will be used as a fallback.
15
- # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
16
- languages:
17
- - python
18
-
19
- # the encoding used by text files in the project
20
- # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
21
- encoding: "utf-8"
22
-
23
- # whether to use the project's gitignore file to ignore files
24
- # Added on 2025-04-07
25
- ignore_all_files_in_gitignore: true
26
-
27
- # list of additional paths to ignore
28
- # same syntax as gitignore, so you can use * and **
29
- # Was previously called `ignored_dirs`, please update your config if you are using that.
30
- # Added (renamed) on 2025-04-07
31
- ignored_paths: []
32
-
33
- # whether the project is in read-only mode
34
- # If set to true, all editing tools will be disabled and attempts to use them will result in an error
35
- # Added on 2025-04-18
36
- read_only: false
37
-
38
- # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
39
- # Below is the complete list of tools for convenience.
40
- # To make sure you have the latest list of tools, and to view their descriptions,
41
- # execute `uv run scripts/print_tool_overview.py`.
42
- #
43
- # * `activate_project`: Activates a project by name.
44
- # * `check_onboarding_performed`: Checks whether project onboarding was already performed.
45
- # * `create_text_file`: Creates/overwrites a file in the project directory.
46
- # * `delete_lines`: Deletes a range of lines within a file.
47
- # * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
48
- # * `execute_shell_command`: Executes a shell command.
49
- # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
50
- # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
51
- # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
52
- # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
53
- # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
54
- # * `initial_instructions`: Gets the initial instructions for the current project.
55
- # Should only be used in settings where the system prompt cannot be set,
56
- # e.g. in clients you have no control over, like Claude Desktop.
57
- # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
58
- # * `insert_at_line`: Inserts content at a given line in a file.
59
- # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
60
- # * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
61
- # * `list_memories`: Lists memories in Serena's project-specific memory store.
62
- # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
63
- # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
64
- # * `read_file`: Reads a file within the project directory.
65
- # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
66
- # * `remove_project`: Removes a project from the Serena configuration.
67
- # * `replace_lines`: Replaces a range of lines within a file with new content.
68
- # * `replace_symbol_body`: Replaces the full definition of a symbol.
69
- # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
70
- # * `search_for_pattern`: Performs a search for a pattern in the project.
71
- # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
72
- # * `switch_modes`: Activates modes by providing a list of their names
73
- # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
74
- # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
75
- # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
76
- # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
77
- excluded_tools: []
78
-
79
- # initial prompt for the project. It will always be given to the LLM upon activating the project
80
- # (contrary to the memories, which are loaded on demand).
81
- initial_prompt: ""
82
-
83
- project_name: "reachy_mini_ha_voice"
84
- included_optional_tools: []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PROJECT_PLAN.md CHANGED
@@ -1,406 +1,406 @@
1
- # Reachy Mini Home Assistant Voice Assistant - 项目计划
2
-
3
- ## 项目概述
4
-
5
- 将 Home Assistant 语音助手功能集成到 Reachy Mini 机器人,通过 ESPHome 协议与 Home Assistant 通信。
6
-
7
- ## 本地项目目录参考 (禁止修改参考目录内任何文件)
8
- 1. [linux-voice-assistant](linux-voice-assistant)
9
- 2. [Reachy Mini SDK](reachy_mini)
10
- 3. [reachy_mini_conversation_app](reachy_mini_conversation_app)
11
- 4. [reachy-mini-desktop-app](reachy-mini-desktop-app)
12
-
13
- ## 核心设计原则
14
-
15
- 1. **零配置安装** - 用户只需安装应用,无需手动配置
16
- 2. **使用 Reachy Mini 原生硬件** - 使用机器人自带的麦克风和扬声器
17
- 3. **Home Assistant 集中管理** - 所有配置在 Home Assistant 端完成
18
- 4. **运动反馈** - 语音交互时提供头部运动和天线动画反馈
19
-
20
- ## 技术架构
21
-
22
- ```
23
- ┌─────────────────────────────────────────────────────────────┐
24
- │ Reachy Mini │
25
- │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
26
- │ │ Microphone │→ │ Wake Word │→ │ ESPHome Protocol │ │
27
- │ │ (ReSpeaker) │ │ Detection │ │ Server (Port 6053) │ │
28
- │ └─────────────┘ └─────────────┘ └──────────┬──────────┘ │
29
- │ │ │
30
- │ ┌─────────────┐ ┌─────────────┐ │ │
31
- │ │ Speaker │← │ Audio │←────────────┘ │
32
- │ │ (ReSpeaker) │ │ Player │ │
33
- │ └─────────────┘ └─────────────┘ │
34
- │ │
35
- │ ┌─────────────────────────────────────────────────────┐ │
36
- │ │ Motion Controller (Head + Antennas) │ │
37
- │ │ - on_wakeup: 点头确认 │ │
38
- │ │ - on_listening: 注视用户 │ │
39
- │ │ - on_thinking: 抬头思考 │ │
40
- │ │ - on_speaking: 说话时微动 │ │
41
- │ │ - on_idle: 返回中立位置 │ │
42
- │ └─────────────────────────────────────────────────────┘ │
43
- └─────────────────────────────────────────────────────────────┘
44
-
45
- │ ESPHome Protocol
46
-
47
- ┌─────────────────────────────────────────────────────────────┐
48
- │ Home Assistant │
49
- │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
50
- │ │ STT Engine │ │ Intent │ │ TTS Engine │ │
51
- │ │ │ │ Processing │ │ │ │
52
- │ └─────────────┘ └─────────────┘ └─────────────────────┘ │
53
- └─────────────────────────────────────────────────────────────┘
54
- ```
55
-
56
- ## 已完成功能
57
-
58
- ### 核心功能
59
- - [x] ESPHome 协议服务器实现
60
- - [x] mDNS 服务发现(自动被 Home Assistant 发现)
61
- - [x] 本地唤醒词检测(microWakeWord)
62
- - [x] 音频流传输到 Home Assistant
63
- - [x] TTS 音频播放
64
- - [x] 停止词检测
65
-
66
- ### Reachy Mini 集成
67
- - [x] 使用 Reachy Mini SDK 的麦克风输入
68
- - [x] 使用 Reachy Mini SDK 的扬声器输出
69
- - [x] 头部运动控制(点头、摇头、注视)
70
- - [x] 天线动画控制
71
- - [x] 语音状态反馈动作
72
-
73
- ### 应用架构
74
- - [x] 符合 Reachy Mini App 架构
75
- - [x] 自动下载唤醒词模型
76
- - [x] 自动下载音效文件
77
- - [x] 无需 .env 配置文件
78
-
79
- ## 文件清单
80
-
81
- ```
82
- reachy_mini_ha_voice/
83
- ├── reachy_mini_ha_voice/
84
- │ ├── __init__.py # 包初始化
85
- │ ├── __main__.py # 命令行入口
86
- │ ├── main.py # ReachyMiniApp 入口
87
- │ ├── voice_assistant.py # 语音助手服务
88
- │ ├── satellite.py # ESPHome 协议处理
89
- │ ├── audio_player.py # 音频播放器
90
- │ ├── camera_server.py # MJPEG 摄像头流服务器
91
- │ ├── motion.py # 运动控制
92
- │ ├── models.py # 数据模型
93
- │ ├── entity.py # ESPHome 基础实体
94
- │ ├── entity_extensions.py # 扩展实体类型
95
- │ ├── reachy_controller.py # Reachy Mini 控制器包装
96
- │ ├── api_server.py # API 服务器
97
- │ ├── zeroconf.py # mDNS 发现
98
- │ └── util.py # 工具函数
99
- ├── wakewords/ # 唤醒词模型(自动下载)
100
- │ ├── okay_nabu.json
101
- │ ├── okay_nabu.tflite
102
- │ ├── hey_jarvis.json
103
- │ ├── hey_jarvis.tflite
104
- │ ├── stop.json
105
- │ └── stop.tflite
106
- ├── sounds/ # 音效文件(自动下载)
107
- │ ├── wake_word_triggered.flac
108
- │ └── timer_finished.flac
109
- ├── pyproject.toml # 项目配置
110
- ├── README.md # 说明文档
111
- └── PROJECT_PLAN.md # 项目计划
112
- ```
113
-
114
- ## 依赖项
115
-
116
- ```toml
117
- dependencies = [
118
- "reachy-mini", # Reachy Mini SDK
119
- "sounddevice>=0.4.6", # 音频处理(备用)
120
- "soundfile>=0.12.0", # 音频文件读取
121
- "numpy>=1.24.0", # 数值计算
122
- "pymicro-wakeword>=2.0.0,<3.0.0", # 唤醒词检测
123
- "pyopen-wakeword>=1.0.0,<2.0.0", # 备用唤醒词
124
- "aioesphomeapi>=42.0.0", # ESPHome 协议
125
- "zeroconf>=0.100.0", # mDNS 发现
126
- "scipy>=1.10.0", # 运动控制
127
- "pydantic>=2.0.0", # 数据验证
128
- ]
129
- ```
130
-
131
- ## 使用流程
132
-
133
- 1. **安装应用**
134
- - 从 Reachy Mini App Store 安装
135
- - 或 `pip install reachy-mini-ha-voice`
136
-
137
- 2. **启动应用**
138
- - 应用自动启动 ESPHome 服务器(端口 6053)
139
- - 自动下载所需模型和音效
140
-
141
- 3. **连接 Home Assistant**
142
- - Home Assistant 自动发现设备(mDNS)
143
- - 或手动添加:设置 → 设备与服务 → 添加集成 → ESPHome
144
-
145
- 4. **使用语音助手**
146
- - 说 "Okay Nabu" 唤醒
147
- - 说出命令
148
- - Reachy Mini 会做出运动反馈
149
-
150
- ## ESPHome 实体规划
151
-
152
- 基于 Reachy Mini SDK 深入分析,以下实体已暴露给 Home Assistant:
153
-
154
- ### 已实现实体
155
-
156
- | 实体类型 | 名称 | 说明 |
157
- |---------|------|------|
158
- | Media Player | `media_player` | 音频播放控制 |
159
- | Voice Assistant | `voice_assistant` | 语音助手管道 |
160
-
161
- ### 已实现的控制实体 (Controls) - 可读写
162
-
163
- #### Phase 1-3: 基础控制与姿态
164
-
165
- | ESPHome 实体类型 | 名称 | SDK API | 范围/选项 | 说明 |
166
- |-----------------|------|---------|----------|------|
167
- | `Number` | `speaker_volume` | `AudioPlayer.set_volume()` | 0-100 | 扬声器音量 |
168
- | `Select` | `motor_mode` | `set_motor_control_mode()` | enabled/disabled/gravity_compensation | 电机模式选择 |
169
- | `Switch` | `motors_enabled` | `enable_motors()` / `disable_motors()` | on/off | 电机扭矩开关 |
170
- | `Button` | `wake_up` | `mini.wake_up()` | - | 唤醒机器人动作 |
171
- | `Button` | `go_to_sleep` | `mini.goto_sleep()` | - | 睡眠机器人动作 |
172
- | `Number` | `head_x` | `goto_target(head=...)` | ±50mm | 头部 X 位置控制 |
173
- | `Number` | `head_y` | `goto_target(head=...)` | ±50mm | 头部 Y 位置控制 |
174
- | `Number` | `head_z` | `goto_target(head=...)` | ±50mm | 头部 Z 位置控制 |
175
- | `Number` | `head_roll` | `goto_target(head=...)` | -40° ~ +40° | 头部翻滚角控制 |
176
- | `Number` | `head_pitch` | `goto_target(head=...)` | -40° ~ +40° | 头部俯仰角控制 |
177
- | `Number` | `head_yaw` | `goto_target(head=...)` | -180° ~ +180° | 头部偏航角控制 |
178
- | `Number` | `body_yaw` | `goto_target(body_yaw=...)` | -160° ~ +160° | 身体偏航角控制 |
179
- | `Number` | `antenna_left` | `goto_target(antennas=...)` | -90° ~ +90° | 左天线角度控制 |
180
- | `Number` | `antenna_right` | `goto_target(antennas=...)` | -90° ~ +90° | 右天线角度控制 |
181
-
182
- #### Phase 4: 注视控制
183
-
184
- | ESPHome 实体类型 | 名称 | SDK API | 范围/选项 | 说明 |
185
- |-----------------|------|---------|----------|------|
186
- | `Number` | `look_at_x` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 X 坐标 |
187
- | `Number` | `look_at_y` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 Y 坐标 |
188
- | `Number` | `look_at_z` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 Z 坐标 |
189
-
190
- ### 已实现的传感器实体 (Sensors) - 只读
191
-
192
- #### Phase 1 & 5: 基础状态与音频传感器
193
-
194
- | ESPHome 实体类型 | 名称 | SDK API | 说明 |
195
- |-----------------|------|---------|------|
196
- | `Text Sensor` | `daemon_state` | `DaemonStatus.state` | Daemon 状态 |
197
- | `Binary Sensor` | `backend_ready` | `backend_status.ready` | 后端是否就绪 |
198
- | `Text Sensor` | `error_message` | `DaemonStatus.error` | 当前错误信息 |
199
- | `Sensor` | `doa_angle` | `DoAInfo.angle` | 声源方向角度 (°) |
200
- | `Binary Sensor` | `speech_detected` | `DoAInfo.speech_detected` | 是否检测到语音 |
201
-
202
- #### Phase 6: 诊断信息
203
-
204
- | ESPHome 实体类型 | 名称 | SDK API | 说明 |
205
- |-----------------|------|---------|------|
206
- | `Sensor` | `control_loop_frequency` | `control_loop_stats` | 控制循环频率 (Hz) |
207
- | `Text Sensor` | `sdk_version` | `DaemonStatus.version` | SDK 版本号 |
208
- | `Text Sensor` | `robot_name` | `DaemonStatus.robot_name` | 机器人名称 |
209
- | `Binary Sensor` | `wireless_version` | `DaemonStatus.wireless_version` | 是否为无线版本 |
210
- | `Binary Sensor` | `simulation_mode` | `DaemonStatus.simulation_enabled` | 是否在仿真模式 |
211
- | `Text Sensor` | `wlan_ip` | `DaemonStatus.wlan_ip` | 无线网络 IP |
212
-
213
- #### Phase 7: IMU 传感器 (仅无线版本)
214
-
215
- | ESPHome 实体类型 | 名称 | SDK API | 说明 |
216
- |-----------------|------|---------|------|
217
- | `Sensor` | `imu_accel_x` | `mini.imu["accelerometer"][0]` | X 轴加速度 (m/s²) |
218
- | `Sensor` | `imu_accel_y` | `mini.imu["accelerometer"][1]` | Y 轴加速度 (m/s²) |
219
- | `Sensor` | `imu_accel_z` | `mini.imu["accelerometer"][2]` | Z 轴加速度 (m/s²) |
220
- | `Sensor` | `imu_gyro_x` | `mini.imu["gyroscope"][0]` | X 轴角速度 (rad/s) |
221
- | `Sensor` | `imu_gyro_y` | `mini.imu["gyroscope"][1]` | Y 轴角速度 (rad/s) |
222
- | `Sensor` | `imu_gyro_z` | `mini.imu["gyroscope"][2]` | Z 轴角速度 (rad/s) |
223
- | `Sensor` | `imu_temperature` | `mini.imu["temperature"]` | IMU 温度 (°C) |
224
-
225
- #### Phase 8-12: 扩展功能
226
-
227
- | ESPHome 实体类型 | 名称 | 说明 |
228
- |-----------------|------|------|
229
- | `Select` | `emotion` | 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust) |
230
- | `Number` | `microphone_volume` | 麦克风音量 (0-100%) |
231
- | `Camera` | `camera` | ESPHome Camera 实体(实时预览) |
232
- | `Number` | `led_brightness` | LED 亮度 (0-100%) |
233
- | `Select` | `led_effect` | LED 效果 (off/solid/breathing/rainbow/doa) |
234
- | `Number` | `led_color_r` | LED 红色分量 (0-255) |
235
- | `Number` | `led_color_g` | LED 绿色分量 (0-255) |
236
- | `Number` | `led_color_b` | LED 蓝色分量 (0-255) |
237
- | `Switch` | `agc_enabled` | 自动增益控制开关 |
238
- | `Number` | `agc_max_gain` | AGC 最大增益 (0-30 dB) |
239
- | `Number` | `noise_suppression` | 噪声抑制级别 (0-100%) |
240
- | `Binary Sensor` | `echo_cancellation_converged` | 回声消除收敛状态 |
241
-
242
- > **注意**: 头部位置 (x/y/z) 和角度 (roll/pitch/yaw)、身体偏航角、天线角度都是**可控制**的实体,
243
- > 使用 `Number` 类型实现双向控制。设置新值时调用 `goto_target()`,读取当前值时调用 `get_current_head_pose()` 等。
244
-
245
- ### 实现优先级
246
-
247
- 1. **Phase 1 - 基础状态与音量** (高优先级) ✅ **已完成**
248
- - [x] `daemon_state` - Daemon 状态传感器
249
- - [x] `backend_ready` - 后端就绪状态
250
- - [x] `error_message` - 错误信息
251
- - [x] `speaker_volume` - 扬声器音量控制
252
-
253
- 2. **Phase 2 - 电机控制** (高优先级) ✅ **已完成**
254
- - [x] `motors_enabled` - 电机开关
255
- - [x] `motor_mode` - 电机模式选择 (enabled/disabled/gravity_compensation)
256
- - [x] `wake_up` / `go_to_sleep` - 唤醒/睡眠按钮
257
-
258
- 3. **Phase 3 - 姿态控制** (中优先级) ✅ **已完成**
259
- - [x] `head_x/y/z` - 头部位置控制
260
- - [x] `head_roll/pitch/yaw` - 头部角度控制
261
- - [x] `body_yaw` - 身体偏航角控制
262
- - [x] `antenna_left/right` - 天线角度控制
263
-
264
- 4. **Phase 4 - 注视控制** (中优先级) ✅ **已完成**
265
- - [x] `look_at_x/y/z` - 注视点坐标控制
266
-
267
- 5. **Phase 5 - 音频传感器** (低优先级) ✅ **已完成**
268
- - [x] `doa_angle` - 声源方向
269
- - [x] `speech_detected` - 语音检测
270
-
271
- 6. **Phase 6 - 诊断信息** (低优先级) ✅ **已完成**
272
- - [x] `control_loop_frequency` - 控制循环频率
273
- - [x] `sdk_version` - SDK 版本
274
- - [x] `robot_name` - 机器人名称
275
- - [x] `wireless_version` - 无线版本标识
276
- - [x] `simulation_mode` - 仿真模式标识
277
- - [x] `wlan_ip` - 无线 IP 地址
278
-
279
- 7. **Phase 7 - IMU 传感器** (可选,仅无线版本) ✅ **已完成**
280
- - [x] `imu_accel_x/y/z` - 加速度计
281
- - [x] `imu_gyro_x/y/z` - 陀螺仪
282
- - [x] `imu_temperature` - IMU 温度
283
-
284
- 8. **Phase 8 - 表情控制** ✅ **已完成**
285
- - [x] `emotion` - 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust)
286
-
287
- 9. **Phase 9 - 音频控制** ✅ **已完成**
288
- - [x] `microphone_volume` - 麦克风音量控制 (0-100%)
289
-
290
- 10. **Phase 10 - 摄像头集成** ✅ **已完成**
291
- - [x] `camera` - ESPHome Camera 实体(实时预览)
292
-
293
- 11. **Phase 11 - LED 控制** ✅ **已完成**
294
- - [x] `led_brightness` - LED 亮度 (0-100%)
295
- - [x] `led_effect` - LED 效果 (off/solid/breathing/rainbow/doa)
296
- - [x] `led_color_r/g/b` - LED RGB 颜色 (0-255)
297
-
298
- 12. **Phase 12 - 音频处理参数** ✅ **已完成**
299
- - [x] `agc_enabled` - 自动增益控制开关
300
- - [x] `agc_max_gain` - AGC 最大增益 (0-30 dB)
301
- - [x] `noise_suppression` - 噪声抑制级别 (0-100%)
302
- - [x] `echo_cancellation_converged` - 回声消除收敛状态(只读)
303
-
304
- ---
305
-
306
- ## 🎉 所有实体已完成!
307
-
308
- **总计:45+ 个实体**
309
- - Phase 1: 4 个实体 (基础状态与音量)
310
- - Phase 2: 4 个实体 (电机控制)
311
- - Phase 3: 9 个实体 (姿态控制)
312
- - Phase 4: 3 个实体 (注视控制)
313
- - Phase 5: 2 个实体 (音频传感器)
314
- - Phase 6: 6 个实体 (诊断信息)
315
- - Phase 7: 7 个实体 (IMU 传感器)
316
- - Phase 8: 1 个实体 (表情控制)
317
- - Phase 9: 1 个实体 (麦克风音量)
318
- - Phase 10: 1 个实体 (摄像头)
319
- - Phase 11: 5 个实体 (LED 控制)
320
- - Phase 12: 4 个实体 (音频处理参数)
321
-
322
- ### SDK 数据结构参考
323
-
324
- ```python
325
- # 电机控制模式
326
- class MotorControlMode(str, Enum):
327
- Enabled = "enabled" # 扭矩开启,位置控制
328
- Disabled = "disabled" # 扭矩关闭
329
- GravityCompensation = "gravity_compensation" # 重力补偿模式
330
-
331
- # Daemon 状态
332
- class DaemonState(Enum):
333
- NOT_INITIALIZED = "not_initialized"
334
- STARTING = "starting"
335
- RUNNING = "running"
336
- STOPPING = "stopping"
337
- STOPPED = "stopped"
338
- ERROR = "error"
339
-
340
- # 完整状态
341
- class FullState:
342
- control_mode: MotorControlMode
343
- head_pose: XYZRPYPose # x, y, z (m), roll, pitch, yaw (rad)
344
- head_joints: list[float] # 7 个关节角度
345
- body_yaw: float
346
- antennas_position: list[float] # [right, left]
347
- doa: DoAInfo # angle (rad), speech_detected (bool)
348
-
349
- # IMU 数据 (仅无线版本)
350
- imu_data = {
351
- "accelerometer": [x, y, z], # m/s²
352
- "gyroscope": [x, y, z], # rad/s
353
- "quaternion": [w, x, y, z], # 姿态四元数
354
- "temperature": float # °C
355
- }
356
-
357
- # 安全限制
358
- HEAD_PITCH_ROLL_LIMIT = [-40°, +40°]
359
- HEAD_YAW_LIMIT = [-180°, +180°]
360
- BODY_YAW_LIMIT = [-160°, +160°]
361
- YAW_DELTA_MAX = 65° # 头部与身体偏航角最大差值
362
- ```
363
-
364
- ### ESPHome 协议实现说明
365
-
366
- ESPHome 协议通过 protobuf 消息与 Home Assistant 通信。需要实现以下消息类型:
367
-
368
- ```python
369
- from aioesphomeapi.api_pb2 import (
370
- # Number 实体 (音量/角度控制)
371
- ListEntitiesNumberResponse,
372
- NumberStateResponse,
373
- NumberCommandRequest,
374
-
375
- # Select 实体 (电机模式)
376
- ListEntitiesSelectResponse,
377
- SelectStateResponse,
378
- SelectCommandRequest,
379
-
380
- # Button 实体 (唤醒/睡眠)
381
- ListEntitiesButtonResponse,
382
- ButtonCommandRequest,
383
-
384
- # Switch 实体 (电机开关)
385
- ListEntitiesSwitchResponse,
386
- SwitchStateResponse,
387
- SwitchCommandRequest,
388
-
389
- # Sensor 实体 (数值传感器)
390
- ListEntitiesSensorResponse,
391
- SensorStateResponse,
392
-
393
- # Binary Sensor 实体 (布尔传感器)
394
- ListEntitiesBinarySensorResponse,
395
- BinarySensorStateResponse,
396
-
397
- # Text Sensor 实体 (文本传感器)
398
- ListEntitiesTextSensorResponse,
399
- TextSensorStateResponse,
400
- )
401
- ```
402
-
403
- ## 参考项目
404
-
405
- - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant)
406
- - [pollen-robotics/reachy_mini](https://github.com/pollen-robotics/reachy_mini)
 
1
+ # Reachy Mini Home Assistant Voice Assistant - 项目计划
2
+
3
+ ## 项目概述
4
+
5
+ 将 Home Assistant 语音助手功能集成到 Reachy Mini 机器人,通过 ESPHome 协议与 Home Assistant 通信。
6
+
7
+ ## 本地项目目录参考 (禁止修改参考目录内任何文件)
8
+ 1. [linux-voice-assistant](linux-voice-assistant)
9
+ 2. [Reachy Mini SDK](reachy_mini)
10
+ 3. [reachy_mini_conversation_app](reachy_mini_conversation_app)
11
+ 4. [reachy-mini-desktop-app](reachy-mini-desktop-app)
12
+
13
+ ## 核心设计原则
14
+
15
+ 1. **零配置安装** - 用户只需安装应用,无需手动配置
16
+ 2. **使用 Reachy Mini 原生硬件** - 使用机器人自带的麦克风和扬声器
17
+ 3. **Home Assistant 集中管理** - 所有配置在 Home Assistant 端完成
18
+ 4. **运动反馈** - 语音交互时提供头部运动和天线动画反馈
19
+
20
+ ## 技术架构
21
+
22
+ ```
23
+ ┌─────────────────────────────────────────────────────────────┐
24
+ │ Reachy Mini │
25
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
26
+ │ │ Microphone │→ │ Wake Word │→ │ ESPHome Protocol │ │
27
+ │ │ (ReSpeaker) │ │ Detection │ │ Server (Port 6053) │ │
28
+ │ └─────────────┘ └─────────────┘ └──────────┬──────────┘ │
29
+ │ │ │
30
+ │ ┌─────────────┐ ┌─────────────┐ │ │
31
+ │ │ Speaker │← │ Audio │←────────────┘ │
32
+ │ │ (ReSpeaker) │ │ Player │ │
33
+ │ └─────────────┘ └─────────────┘ │
34
+ │ │
35
+ │ ┌─────────────────────────────────────────────────────┐ │
36
+ │ │ Motion Controller (Head + Antennas) │ │
37
+ │ │ - on_wakeup: 点头确认 │ │
38
+ │ │ - on_listening: 注视用户 │ │
39
+ │ │ - on_thinking: 抬头思考 │ │
40
+ │ │ - on_speaking: 说话时微动 │ │
41
+ │ │ - on_idle: 返回中立位置 │ │
42
+ │ └─────────────────────────────────────────────────────┘ │
43
+ └─────────────────────────────────────────────────────────────┘
44
+
45
+ │ ESPHome Protocol
46
+
47
+ ┌─────────────────────────────────────────────────────────────┐
48
+ │ Home Assistant │
49
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
50
+ │ │ STT Engine │ │ Intent │ │ TTS Engine │ │
51
+ │ │ │ │ Processing │ │ │ │
52
+ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │
53
+ └─────────────────────────────────────────────────────────────┘
54
+ ```
55
+
56
+ ## 已完成功能
57
+
58
+ ### 核心功能
59
+ - [x] ESPHome 协议服务器实现
60
+ - [x] mDNS 服务发现(自动被 Home Assistant 发现)
61
+ - [x] 本地唤醒词检测(microWakeWord)
62
+ - [x] 音频流传输到 Home Assistant
63
+ - [x] TTS 音频播放
64
+ - [x] 停止词检测
65
+
66
+ ### Reachy Mini 集成
67
+ - [x] 使用 Reachy Mini SDK 的麦克风输入
68
+ - [x] 使用 Reachy Mini SDK 的扬声器输出
69
+ - [x] 头部运动控制(点头、摇头、注视)
70
+ - [x] 天线动画控制
71
+ - [x] 语音状态反馈动作
72
+
73
+ ### 应用架构
74
+ - [x] 符合 Reachy Mini App 架构
75
+ - [x] 自动下载唤醒词模型
76
+ - [x] 自动下载音效文件
77
+ - [x] 无需 .env 配置文件
78
+
79
+ ## 文件清单
80
+
81
+ ```
82
+ reachy_mini_ha_voice/
83
+ ├── reachy_mini_ha_voice/
84
+ │ ├── __init__.py # 包初始化
85
+ │ ├── __main__.py # 命令行入口
86
+ │ ├── main.py # ReachyMiniApp 入口
87
+ │ ├── voice_assistant.py # 语音助手服务
88
+ │ ├── satellite.py # ESPHome 协议处理
89
+ │ ├── audio_player.py # 音频播放器
90
+ │ ├── camera_server.py # MJPEG 摄像头流服务器
91
+ │ ├── motion.py # 运动控制
92
+ │ ├── models.py # 数据模型
93
+ │ ├── entity.py # ESPHome 基础实体
94
+ │ ├── entity_extensions.py # 扩展实体类型
95
+ │ ├── reachy_controller.py # Reachy Mini 控制器包装
96
+ │ ├── api_server.py # API 服务器
97
+ │ ├── zeroconf.py # mDNS 发现
98
+ │ └── util.py # 工具函数
99
+ ├── wakewords/ # 唤醒词模型(自动下载)
100
+ │ ├── okay_nabu.json
101
+ │ ├── okay_nabu.tflite
102
+ │ ├── hey_jarvis.json
103
+ │ ├── hey_jarvis.tflite
104
+ │ ├── stop.json
105
+ │ └── stop.tflite
106
+ ├── sounds/ # 音效文件(自动下载)
107
+ │ ├── wake_word_triggered.flac
108
+ │ └── timer_finished.flac
109
+ ├── pyproject.toml # 项目配置
110
+ ├── README.md # 说明文档
111
+ └── PROJECT_PLAN.md # 项目计划
112
+ ```
113
+
114
+ ## 依赖项
115
+
116
+ ```toml
117
+ dependencies = [
118
+ "reachy-mini", # Reachy Mini SDK
119
+ "sounddevice>=0.4.6", # 音频处理(备用)
120
+ "soundfile>=0.12.0", # 音频文件读取
121
+ "numpy>=1.24.0", # 数值计算
122
+ "pymicro-wakeword>=2.0.0,<3.0.0", # 唤醒词检测
123
+ "pyopen-wakeword>=1.0.0,<2.0.0", # 备用唤醒词
124
+ "aioesphomeapi>=42.0.0", # ESPHome 协议
125
+ "zeroconf>=0.100.0", # mDNS 发现
126
+ "scipy>=1.10.0", # 运动控制
127
+ "pydantic>=2.0.0", # 数据验证
128
+ ]
129
+ ```
130
+
131
+ ## 使用流程
132
+
133
+ 1. **安装应用**
134
+ - 从 Reachy Mini App Store 安装
135
+ - 或 `pip install reachy-mini-ha-voice`
136
+
137
+ 2. **启动应用**
138
+ - 应用自动启动 ESPHome 服务器(端口 6053)
139
+ - 自动下载所需模型和音效
140
+
141
+ 3. **连接 Home Assistant**
142
+ - Home Assistant 自动发现设备(mDNS)
143
+ - 或手动添加:设置 → 设备与服务 → 添加集成 → ESPHome
144
+
145
+ 4. **使用语音助手**
146
+ - 说 "Okay Nabu" 唤醒
147
+ - 说出命令
148
+ - Reachy Mini 会做出运动反馈
149
+
150
+ ## ESPHome 实体规划
151
+
152
+ 基于 Reachy Mini SDK 深入分析,以下实体已暴露给 Home Assistant:
153
+
154
+ ### 已实现实体
155
+
156
+ | 实体类型 | 名称 | 说明 |
157
+ |---------|------|------|
158
+ | Media Player | `media_player` | 音频播放控制 |
159
+ | Voice Assistant | `voice_assistant` | 语音助手管道 |
160
+
161
+ ### 已实现的控制实体 (Controls) - 可读写
162
+
163
+ #### Phase 1-3: 基础控制与姿态
164
+
165
+ | ESPHome 实体类型 | 名称 | SDK API | 范围/选项 | 说明 |
166
+ |-----------------|------|---------|----------|------|
167
+ | `Number` | `speaker_volume` | `AudioPlayer.set_volume()` | 0-100 | 扬声器音量 |
168
+ | `Select` | `motor_mode` | `set_motor_control_mode()` | enabled/disabled/gravity_compensation | 电机模式选择 |
169
+ | `Switch` | `motors_enabled` | `enable_motors()` / `disable_motors()` | on/off | 电机扭矩开关 |
170
+ | `Button` | `wake_up` | `mini.wake_up()` | - | 唤醒机器人动作 |
171
+ | `Button` | `go_to_sleep` | `mini.goto_sleep()` | - | 睡眠机器人动作 |
172
+ | `Number` | `head_x` | `goto_target(head=...)` | ±50mm | 头部 X 位置控制 |
173
+ | `Number` | `head_y` | `goto_target(head=...)` | ±50mm | 头部 Y 位置控制 |
174
+ | `Number` | `head_z` | `goto_target(head=...)` | ±50mm | 头部 Z 位置控制 |
175
+ | `Number` | `head_roll` | `goto_target(head=...)` | -40° ~ +40° | 头部翻滚角控制 |
176
+ | `Number` | `head_pitch` | `goto_target(head=...)` | -40° ~ +40° | 头部俯仰角控制 |
177
+ | `Number` | `head_yaw` | `goto_target(head=...)` | -180° ~ +180° | 头部偏航角控制 |
178
+ | `Number` | `body_yaw` | `goto_target(body_yaw=...)` | -160° ~ +160° | 身体偏航角控制 |
179
+ | `Number` | `antenna_left` | `goto_target(antennas=...)` | -90° ~ +90° | 左天线角度控制 |
180
+ | `Number` | `antenna_right` | `goto_target(antennas=...)` | -90° ~ +90° | 右天线角度控制 |
181
+
182
+ #### Phase 4: 注视控制
183
+
184
+ | ESPHome 实体类型 | 名称 | SDK API | 范围/选项 | 说明 |
185
+ |-----------------|------|---------|----------|------|
186
+ | `Number` | `look_at_x` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 X 坐标 |
187
+ | `Number` | `look_at_y` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 Y 坐标 |
188
+ | `Number` | `look_at_z` | `look_at_world(x, y, z)` | 世界坐标 | 注视点 Z 坐标 |
189
+
190
+ ### 已实现的传感器实体 (Sensors) - 只读
191
+
192
+ #### Phase 1 & 5: 基础状态与音频传感器
193
+
194
+ | ESPHome 实体类型 | 名称 | SDK API | 说明 |
195
+ |-----------------|------|---------|------|
196
+ | `Text Sensor` | `daemon_state` | `DaemonStatus.state` | Daemon 状态 |
197
+ | `Binary Sensor` | `backend_ready` | `backend_status.ready` | 后端是否就绪 |
198
+ | `Text Sensor` | `error_message` | `DaemonStatus.error` | 当前错误信息 |
199
+ | `Sensor` | `doa_angle` | `DoAInfo.angle` | 声源方向角度 (°) |
200
+ | `Binary Sensor` | `speech_detected` | `DoAInfo.speech_detected` | 是否检测到语音 |
201
+
202
+ #### Phase 6: 诊断信息
203
+
204
+ | ESPHome 实体类型 | 名称 | SDK API | 说明 |
205
+ |-----------------|------|---------|------|
206
+ | `Sensor` | `control_loop_frequency` | `control_loop_stats` | 控制循环频率 (Hz) |
207
+ | `Text Sensor` | `sdk_version` | `DaemonStatus.version` | SDK 版本号 |
208
+ | `Text Sensor` | `robot_name` | `DaemonStatus.robot_name` | 机器人名称 |
209
+ | `Binary Sensor` | `wireless_version` | `DaemonStatus.wireless_version` | 是否为无线版本 |
210
+ | `Binary Sensor` | `simulation_mode` | `DaemonStatus.simulation_enabled` | 是否在仿真模式 |
211
+ | `Text Sensor` | `wlan_ip` | `DaemonStatus.wlan_ip` | 无线网络 IP |
212
+
213
+ #### Phase 7: IMU 传感器 (仅无线版本)
214
+
215
+ | ESPHome 实体类型 | 名称 | SDK API | 说明 |
216
+ |-----------------|------|---------|------|
217
+ | `Sensor` | `imu_accel_x` | `mini.imu["accelerometer"][0]` | X 轴加速度 (m/s²) |
218
+ | `Sensor` | `imu_accel_y` | `mini.imu["accelerometer"][1]` | Y 轴加速度 (m/s²) |
219
+ | `Sensor` | `imu_accel_z` | `mini.imu["accelerometer"][2]` | Z 轴加速度 (m/s²) |
220
+ | `Sensor` | `imu_gyro_x` | `mini.imu["gyroscope"][0]` | X 轴角速度 (rad/s) |
221
+ | `Sensor` | `imu_gyro_y` | `mini.imu["gyroscope"][1]` | Y 轴角速度 (rad/s) |
222
+ | `Sensor` | `imu_gyro_z` | `mini.imu["gyroscope"][2]` | Z 轴角速度 (rad/s) |
223
+ | `Sensor` | `imu_temperature` | `mini.imu["temperature"]` | IMU 温度 (°C) |
224
+
225
+ #### Phase 8-12: 扩展功能
226
+
227
+ | ESPHome 实体类型 | 名称 | 说明 |
228
+ |-----------------|------|------|
229
+ | `Select` | `emotion` | 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust) |
230
+ | `Number` | `microphone_volume` | 麦克风音量 (0-100%) |
231
+ | `Camera` | `camera` | ESPHome Camera 实体(实时预览) |
232
+ | `Number` | `led_brightness` | LED 亮度 (0-100%) |
233
+ | `Select` | `led_effect` | LED 效果 (off/solid/breathing/rainbow/doa) |
234
+ | `Number` | `led_color_r` | LED 红色分量 (0-255) |
235
+ | `Number` | `led_color_g` | LED 绿色分量 (0-255) |
236
+ | `Number` | `led_color_b` | LED 蓝色分量 (0-255) |
237
+ | `Switch` | `agc_enabled` | 自动增益控制开关 |
238
+ | `Number` | `agc_max_gain` | AGC 最大增益 (0-30 dB) |
239
+ | `Number` | `noise_suppression` | 噪声抑制级别 (0-100%) |
240
+ | `Binary Sensor` | `echo_cancellation_converged` | 回声消除收敛状态 |
241
+
242
+ > **注意**: 头部位置 (x/y/z) 和角度 (roll/pitch/yaw)、身体偏航角、天线角度都是**可控制**的实体,
243
+ > 使用 `Number` 类型实现双向控制。设置新值时调用 `goto_target()`,读取当前值时调用 `get_current_head_pose()` 等。
244
+
245
+ ### 实现优先级
246
+
247
+ 1. **Phase 1 - 基础状态与音量** (高优先级) ✅ **已完成**
248
+ - [x] `daemon_state` - Daemon 状态传感器
249
+ - [x] `backend_ready` - 后端就绪状态
250
+ - [x] `error_message` - 错误信息
251
+ - [x] `speaker_volume` - 扬声器音量控制
252
+
253
+ 2. **Phase 2 - 电机控制** (高优先级) ✅ **已完成**
254
+ - [x] `motors_enabled` - 电机开关
255
+ - [x] `motor_mode` - 电机模式选择 (enabled/disabled/gravity_compensation)
256
+ - [x] `wake_up` / `go_to_sleep` - 唤醒/睡眠按钮
257
+
258
+ 3. **Phase 3 - 姿态控制** (中优先级) ✅ **已完成**
259
+ - [x] `head_x/y/z` - 头部位置控制
260
+ - [x] `head_roll/pitch/yaw` - 头部角度控制
261
+ - [x] `body_yaw` - 身体偏航角控制
262
+ - [x] `antenna_left/right` - 天线角度控制
263
+
264
+ 4. **Phase 4 - 注视控制** (中优先级) ✅ **已完成**
265
+ - [x] `look_at_x/y/z` - 注视点坐标控制
266
+
267
+ 5. **Phase 5 - 音频传感器** (低优先级) ✅ **已完成**
268
+ - [x] `doa_angle` - 声源方向
269
+ - [x] `speech_detected` - 语音检测
270
+
271
+ 6. **Phase 6 - 诊断信息** (低优先级) ✅ **已完成**
272
+ - [x] `control_loop_frequency` - 控制循环频率
273
+ - [x] `sdk_version` - SDK 版本
274
+ - [x] `robot_name` - 机器人名称
275
+ - [x] `wireless_version` - 无线版本标识
276
+ - [x] `simulation_mode` - 仿真模式标识
277
+ - [x] `wlan_ip` - 无线 IP 地址
278
+
279
+ 7. **Phase 7 - IMU 传感器** (可选,仅无线版本) ✅ **已完成**
280
+ - [x] `imu_accel_x/y/z` - 加速度计
281
+ - [x] `imu_gyro_x/y/z` - 陀螺仪
282
+ - [x] `imu_temperature` - IMU 温度
283
+
284
+ 8. **Phase 8 - 表情控制** ✅ **已完成**
285
+ - [x] `emotion` - 表情选择器 (Happy/Sad/Angry/Fear/Surprise/Disgust)
286
+
287
+ 9. **Phase 9 - 音频控制** ✅ **已完成**
288
+ - [x] `microphone_volume` - 麦克风音量控制 (0-100%)
289
+
290
+ 10. **Phase 10 - 摄像头集成** ✅ **已完成**
291
+ - [x] `camera` - ESPHome Camera 实体(实时预览)
292
+
293
+ 11. **Phase 11 - LED 控制** ✅ **已完成**
294
+ - [x] `led_brightness` - LED 亮度 (0-100%)
295
+ - [x] `led_effect` - LED 效果 (off/solid/breathing/rainbow/doa)
296
+ - [x] `led_color_r/g/b` - LED RGB 颜色 (0-255)
297
+
298
+ 12. **Phase 12 - 音频处理参数** ✅ **已完成**
299
+ - [x] `agc_enabled` - 自动增益控制开关
300
+ - [x] `agc_max_gain` - AGC 最大增益 (0-30 dB)
301
+ - [x] `noise_suppression` - 噪声抑制级别 (0-100%)
302
+ - [x] `echo_cancellation_converged` - 回声消除收敛状态(只读)
303
+
304
+ ---
305
+
306
+ ## 🎉 所有实体已完成!
307
+
308
+ **总计:45+ 个实体**
309
+ - Phase 1: 4 个实体 (基础状态与音量)
310
+ - Phase 2: 4 个实体 (电机控制)
311
+ - Phase 3: 9 个实体 (姿态控制)
312
+ - Phase 4: 3 个实体 (注视控制)
313
+ - Phase 5: 2 个实体 (音频传感器)
314
+ - Phase 6: 6 个实体 (诊断信息)
315
+ - Phase 7: 7 个实体 (IMU 传感器)
316
+ - Phase 8: 1 个实体 (表情控制)
317
+ - Phase 9: 1 个实体 (麦克风音量)
318
+ - Phase 10: 1 个实体 (摄像头)
319
+ - Phase 11: 5 个实体 (LED 控制)
320
+ - Phase 12: 4 个实体 (音频处理参数)
321
+
322
+ ### SDK 数据结构参考
323
+
324
+ ```python
325
+ # 电机控制模式
326
+ class MotorControlMode(str, Enum):
327
+ Enabled = "enabled" # 扭矩开启,位置控制
328
+ Disabled = "disabled" # 扭矩关闭
329
+ GravityCompensation = "gravity_compensation" # 重力补偿模式
330
+
331
+ # Daemon 状态
332
+ class DaemonState(Enum):
333
+ NOT_INITIALIZED = "not_initialized"
334
+ STARTING = "starting"
335
+ RUNNING = "running"
336
+ STOPPING = "stopping"
337
+ STOPPED = "stopped"
338
+ ERROR = "error"
339
+
340
+ # 完整状态
341
+ class FullState:
342
+ control_mode: MotorControlMode
343
+ head_pose: XYZRPYPose # x, y, z (m), roll, pitch, yaw (rad)
344
+ head_joints: list[float] # 7 个关节角度
345
+ body_yaw: float
346
+ antennas_position: list[float] # [right, left]
347
+ doa: DoAInfo # angle (rad), speech_detected (bool)
348
+
349
+ # IMU 数据 (仅无线版本)
350
+ imu_data = {
351
+ "accelerometer": [x, y, z], # m/s²
352
+ "gyroscope": [x, y, z], # rad/s
353
+ "quaternion": [w, x, y, z], # 姿态四元数
354
+ "temperature": float # °C
355
+ }
356
+
357
+ # 安全限制
358
+ HEAD_PITCH_ROLL_LIMIT = [-40°, +40°]
359
+ HEAD_YAW_LIMIT = [-180°, +180°]
360
+ BODY_YAW_LIMIT = [-160°, +160°]
361
+ YAW_DELTA_MAX = 65° # 头部与身体偏航角最大差值
362
+ ```
363
+
364
+ ### ESPHome 协议实现说明
365
+
366
+ ESPHome 协议通过 protobuf 消息与 Home Assistant 通信。需要实现以下消息类型:
367
+
368
+ ```python
369
+ from aioesphomeapi.api_pb2 import (
370
+ # Number 实体 (音量/角度控制)
371
+ ListEntitiesNumberResponse,
372
+ NumberStateResponse,
373
+ NumberCommandRequest,
374
+
375
+ # Select 实体 (电机模式)
376
+ ListEntitiesSelectResponse,
377
+ SelectStateResponse,
378
+ SelectCommandRequest,
379
+
380
+ # Button 实体 (唤醒/睡眠)
381
+ ListEntitiesButtonResponse,
382
+ ButtonCommandRequest,
383
+
384
+ # Switch 实体 (电机开关)
385
+ ListEntitiesSwitchResponse,
386
+ SwitchStateResponse,
387
+ SwitchCommandRequest,
388
+
389
+ # Sensor 实体 (数值传感器)
390
+ ListEntitiesSensorResponse,
391
+ SensorStateResponse,
392
+
393
+ # Binary Sensor 实体 (布尔传感器)
394
+ ListEntitiesBinarySensorResponse,
395
+ BinarySensorStateResponse,
396
+
397
+ # Text Sensor 实体 (文本传感器)
398
+ ListEntitiesTextSensorResponse,
399
+ TextSensorStateResponse,
400
+ )
401
+ ```
402
+
403
+ ## 参考项目
404
+
405
+ - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant)
406
+ - [pollen-robotics/reachy_mini](https://github.com/pollen-robotics/reachy_mini)
README.md CHANGED
@@ -1,203 +1,199 @@
1
- ---
2
- title: Reachy Mini HA Voice
3
- emoji: 🤖
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: "5.9.1"
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- # Reachy Mini Home Assistant Voice Assistant
13
-
14
- A voice assistant application for **Reachy Mini robot** that integrates with Home Assistant via ESPHome protocol.
15
-
16
- > **Note**: This is a Reachy Mini App, not a Hugging Face Space. Install it on your Reachy Mini robot.
17
-
18
- ## Features
19
-
20
- - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
21
- - **ESPHome Integration**: Seamlessly connects to Home Assistant
22
- - **Camera Streaming**: MJPEG video stream for Home Assistant Generic Camera integration
23
- - **Motion Control**: Head movements and antenna animations during voice interaction
24
- - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
25
- - **Full Robot Control**: Expose 30+ entities to Home Assistant for complete robot control
26
- - Motor control (enable/disable, mode selection)
27
- - Head position and orientation control
28
- - Body rotation control
29
- - Antenna animation control
30
- - Look-at target control
31
- - Audio sensors (DOA, speech detection)
32
- - System diagnostics and monitoring
33
- - IMU sensors (wireless version only)
34
-
35
- ## Requirements
36
-
37
- - Reachy Mini robot (with reachy-mini SDK)
38
- - Home Assistant with ESPHome integration
39
- - Python 3.10+
40
-
41
- ## Installation
42
-
43
- Install from Reachy Mini App Store or manually:
44
-
45
- ```bash
46
- pip install reachy-mini-ha-voice
47
- ```
48
-
49
- ## Usage
50
-
51
- The app runs automatically when installed on Reachy Mini. After installation:
52
-
53
- 1. Open Home Assistant
54
- 2. Go to **Settings** -> **Devices & Services** -> **Add Integration**
55
- 3. Search for **ESPHome**
56
- 4. Enter your Reachy Mini's IP address with port `6053`
57
- 5. The voice assistant will be automatically discovered
58
-
59
- ### Camera Setup
60
-
61
- The camera stream is available at `http://<reachy-mini-ip>:8081/stream`. To add it to Home Assistant:
62
-
63
- 1. Go to **Settings** -> **Devices & Services** -> **Add Integration**
64
- 2. Search for **Generic Camera**
65
- 3. Enter the stream URL: `http://<reachy-mini-ip>:8081/stream`
66
- 4. Set content type to `image/jpeg`
67
-
68
- You can also access:
69
- - **Live Stream**: `http://<reachy-mini-ip>:8081/stream` - MJPEG video stream
70
- - **Snapshot**: `http://<reachy-mini-ip>:8081/snapshot` - Single JPEG image
71
- - **Status Page**: `http://<reachy-mini-ip>:8081/` - Web interface with stream preview
72
-
73
- ### Wake Words
74
-
75
- Default wake word: **"Okay Nabu"**
76
-
77
- Additional wake words can be configured through Home Assistant.
78
-
79
- ## ESPHome Entities
80
-
81
- This application exposes 45+ entities to Home Assistant for complete robot control:
82
-
83
- ### Status & Control (Phase 1)
84
- - **Daemon State** - Monitor robot daemon status
85
- - **Backend Ready** - Check if backend is ready
86
- - **Error Message** - View current error messages
87
- - **Speaker Volume** - Control audio volume (0-100%)
88
-
89
- ### Motor Control (Phase 2)
90
- - **Motors Enabled** - Enable/disable motor torque
91
- - **Motor Mode** - Select motor mode (enabled/disabled/gravity_compensation)
92
- - **Wake Up** - Execute wake up animation
93
- - **Go to Sleep** - Execute sleep animation
94
-
95
- ### Pose Control (Phase 3)
96
- - **Head Position** - Control X/Y/Z position (±50mm)
97
- - **Head Orientation** - Control roll/pitch/yaw angles
98
- - **Body Yaw** - Rotate body (±160°)
99
- - **Antennas** - Control left/right antenna angles (±90°)
100
-
101
- ### Look At Control (Phase 4)
102
- - **Look At X/Y/Z** - Point head at world coordinates
103
-
104
- ### Audio Sensors (Phase 5)
105
- - **DOA Angle** - Direction of arrival angle
106
- - **Speech Detected** - Real-time speech detection
107
-
108
- ### Diagnostics (Phase 6)
109
- - **Control Loop Frequency** - Monitor control loop performance
110
- - **SDK Version** - View SDK version
111
- - **Robot Name** - Robot identifier
112
- - **Wireless Version** - Check if wireless version
113
- - **Simulation Mode** - Check if in simulation
114
- - **WLAN IP** - Wireless network IP address
115
-
116
- ### IMU Sensors (Phase 7 - Wireless only)
117
- - **Accelerometer** - X/Y/Z acceleration (m/s²)
118
- - **Gyroscope** - X/Y/Z angular velocity (rad/s)
119
- - **Temperature** - IMU temperature (°C)
120
-
121
- ### Emotion Control (Phase 8)
122
- - **Emotion** - Select emotion (Happy/Sad/Angry/Fear/Surprise/Disgust)
123
-
124
- ### Audio Control (Phase 9)
125
- - **Microphone Volume** - Control microphone input level (0-100%)
126
-
127
- ### Camera (Phase 10)
128
- - **Camera** - ESPHome Camera entity with live preview in Home Assistant
129
-
130
- ### LED Control (Phase 11)
131
- - **LED Brightness** - Control LED brightness (0-100%)
132
- - **LED Effect** - Select LED effect (off/solid/breathing/rainbow/doa)
133
- - **LED Color R/G/B** - Control LED color (0-255 per channel)
134
-
135
- ### Audio Processing (Phase 12)
136
- - **AGC Enabled** - Toggle automatic gain control
137
- - **AGC Max Gain** - Set maximum AGC gain (0-30 dB)
138
- - **Noise Suppression** - Set noise suppression level (0-100%)
139
- - **Echo Cancellation Converged** - Monitor echo cancellation status
140
-
141
- ## How It Works
142
-
143
- ```
144
- [Reachy Mini Microphone] -> [Local Wake Word Detection] -> [ESPHome Protocol]
145
- |
146
- v
147
- [Reachy Mini Speaker] <- [TTS Response] <- [Home Assistant STT/TTS]
148
- |
149
- v
150
- [Head Motion & Antenna Animation]
151
-
152
- [Reachy Mini Camera] -> [MJPEG Server :8081] -> [Home Assistant Generic Camera]
153
- ```
154
-
155
- - **Wake word detection** runs locally on Reachy Mini
156
- - **Speech-to-Text (STT)** and **Text-to-Speech (TTS)** are handled by Home Assistant
157
- - **Motion feedback** provides visual response during voice interaction
158
- - **Camera streaming** provides real-time video feed to Home Assistant
159
-
160
- ## Project Structure
161
-
162
- ```
163
- reachy_mini_ha_voice/
164
- ├── reachy_mini_ha_voice/
165
- ├── __init__.py
166
- ├── __main__.py # CLI entry point
167
- ├── main.py # App entry point
168
- ├── voice_assistant.py # Voice assistant service
169
- ├── camera_server.py # MJPEG camera streaming server
170
- ├── satellite.py # ESPHome protocol handler
171
- ├── audio_player.py # Audio playback
172
- ├── motion.py # Motion control
173
- ├── models.py # Data models
174
- ├── entity.py # ESPHome base entities
175
- ├── entity_extensions.py # Extended entity types
176
- ├── reachy_controller.py # Reachy Mini controller wrapper
177
- ├── api_server.py # API server
178
- ├── zeroconf.py # mDNS discovery
179
- └── util.py # Utilities
180
- ├── wakewords/ # Wake word models (auto-downloaded)
181
- ├── sounds/ # Sound effects (auto-downloaded)
182
- ├── pyproject.toml
183
- ├── README.md
184
- └── PROJECT_PLAN.md
185
- ```
186
-
187
- ## Dependencies
188
-
189
- - `reachy-mini` - Reachy Mini SDK
190
- - `aioesphomeapi` - ESPHome protocol
191
- - `pymicro-wakeword` - Wake word detection
192
- - `opencv-python` - Camera streaming
193
- - `sounddevice` / `soundfile` - Audio processing
194
- - `zeroconf` - mDNS discovery
195
-
196
- ## License
197
-
198
- Apache 2.0 License
199
-
200
- ## Acknowledgments
201
-
202
- - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant) - Original ESPHome voice assistant
203
- - [Pollen Robotics](https://www.pollen-robotics.com/) - Reachy Mini robot
 
1
+ ---
2
+ title: Reachy Mini Home Assistant Voice Assistant
3
+ tags:
4
+ - reachy_mini
5
+ - reachy_mini_python_app
6
+ ---
7
+
8
+ # Reachy Mini Home Assistant Voice Assistant
9
+
10
+ A voice assistant application for **Reachy Mini robot** that integrates with Home Assistant via ESPHome protocol.
11
+
12
+ > **Note**: This is a Reachy Mini App, not a Hugging Face Space. Install it on your Reachy Mini robot.
13
+
14
+ ## Features
15
+
16
+ - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
17
+ - **ESPHome Integration**: Seamlessly connects to Home Assistant
18
+ - **Camera Streaming**: MJPEG video stream for Home Assistant Generic Camera integration
19
+ - **Motion Control**: Head movements and antenna animations during voice interaction
20
+ - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
21
+ - **Full Robot Control**: Expose 30+ entities to Home Assistant for complete robot control
22
+ - Motor control (enable/disable, mode selection)
23
+ - Head position and orientation control
24
+ - Body rotation control
25
+ - Antenna animation control
26
+ - Look-at target control
27
+ - Audio sensors (DOA, speech detection)
28
+ - System diagnostics and monitoring
29
+ - IMU sensors (wireless version only)
30
+
31
+ ## Requirements
32
+
33
+ - Reachy Mini robot (with reachy-mini SDK)
34
+ - Home Assistant with ESPHome integration
35
+ - Python 3.10+
36
+
37
+ ## Installation
38
+
39
+ Install from Reachy Mini App Store or manually:
40
+
41
+ ```bash
42
+ pip install reachy-mini-ha-voice
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ The app runs automatically when installed on Reachy Mini. After installation:
48
+
49
+ 1. Open Home Assistant
50
+ 2. Go to **Settings** -> **Devices & Services** -> **Add Integration**
51
+ 3. Search for **ESPHome**
52
+ 4. Enter your Reachy Mini's IP address with port `6053`
53
+ 5. The voice assistant will be automatically discovered
54
+
55
+ ### Camera Setup
56
+
57
+ The camera stream is available at `http://<reachy-mini-ip>:8081/stream`. To add it to Home Assistant:
58
+
59
+ 1. Go to **Settings** -> **Devices & Services** -> **Add Integration**
60
+ 2. Search for **Generic Camera**
61
+ 3. Enter the stream URL: `http://<reachy-mini-ip>:8081/stream`
62
+ 4. Set content type to `image/jpeg`
63
+
64
+ You can also access:
65
+ - **Live Stream**: `http://<reachy-mini-ip>:8081/stream` - MJPEG video stream
66
+ - **Snapshot**: `http://<reachy-mini-ip>:8081/snapshot` - Single JPEG image
67
+ - **Status Page**: `http://<reachy-mini-ip>:8081/` - Web interface with stream preview
68
+
69
+ ### Wake Words
70
+
71
+ Default wake word: **"Okay Nabu"**
72
+
73
+ Additional wake words can be configured through Home Assistant.
74
+
75
+ ## ESPHome Entities
76
+
77
+ This application exposes 45+ entities to Home Assistant for complete robot control:
78
+
79
+ ### Status & Control (Phase 1)
80
+ - **Daemon State** - Monitor robot daemon status
81
+ - **Backend Ready** - Check if backend is ready
82
+ - **Error Message** - View current error messages
83
+ - **Speaker Volume** - Control audio volume (0-100%)
84
+
85
+ ### Motor Control (Phase 2)
86
+ - **Motors Enabled** - Enable/disable motor torque
87
+ - **Motor Mode** - Select motor mode (enabled/disabled/gravity_compensation)
88
+ - **Wake Up** - Execute wake up animation
89
+ - **Go to Sleep** - Execute sleep animation
90
+
91
+ ### Pose Control (Phase 3)
92
+ - **Head Position** - Control X/Y/Z position (+/-50mm)
93
+ - **Head Orientation** - Control roll/pitch/yaw angles
94
+ - **Body Yaw** - Rotate body (+/-160 degrees)
95
+ - **Antennas** - Control left/right antenna angles (+/-90 degrees)
96
+
97
+ ### Look At Control (Phase 4)
98
+ - **Look At X/Y/Z** - Point head at world coordinates
99
+
100
+ ### Audio Sensors (Phase 5)
101
+ - **DOA Angle** - Direction of arrival angle
102
+ - **Speech Detected** - Real-time speech detection
103
+
104
+ ### Diagnostics (Phase 6)
105
+ - **Control Loop Frequency** - Monitor control loop performance
106
+ - **SDK Version** - View SDK version
107
+ - **Robot Name** - Robot identifier
108
+ - **Wireless Version** - Check if wireless version
109
+ - **Simulation Mode** - Check if in simulation
110
+ - **WLAN IP** - Wireless network IP address
111
+
112
+ ### IMU Sensors (Phase 7 - Wireless only)
113
+ - **Accelerometer** - X/Y/Z acceleration (m/s^2)
114
+ - **Gyroscope** - X/Y/Z angular velocity (rad/s)
115
+ - **Temperature** - IMU temperature (degrees Celsius)
116
+
117
+ ### Emotion Control (Phase 8)
118
+ - **Emotion** - Select emotion (Happy/Sad/Angry/Fear/Surprise/Disgust)
119
+
120
+ ### Audio Control (Phase 9)
121
+ - **Microphone Volume** - Control microphone input level (0-100%)
122
+
123
+ ### Camera (Phase 10)
124
+ - **Camera** - ESPHome Camera entity with live preview in Home Assistant
125
+
126
+ ### LED Control (Phase 11)
127
+ - **LED Brightness** - Control LED brightness (0-100%)
128
+ - **LED Effect** - Select LED effect (off/solid/breathing/rainbow/doa)
129
+ - **LED Color R/G/B** - Control LED color (0-255 per channel)
130
+
131
+ ### Audio Processing (Phase 12)
132
+ - **AGC Enabled** - Toggle automatic gain control
133
+ - **AGC Max Gain** - Set maximum AGC gain (0-30 dB)
134
+ - **Noise Suppression** - Set noise suppression level (0-100%)
135
+ - **Echo Cancellation Converged** - Monitor echo cancellation status
136
+
137
+ ## How It Works
138
+
139
+ ```
140
+ [Reachy Mini Microphone] -> [Local Wake Word Detection] -> [ESPHome Protocol]
141
+ |
142
+ v
143
+ [Reachy Mini Speaker] <- [TTS Response] <- [Home Assistant STT/TTS]
144
+ |
145
+ v
146
+ [Head Motion & Antenna Animation]
147
+
148
+ [Reachy Mini Camera] -> [MJPEG Server :8081] -> [Home Assistant Generic Camera]
149
+ ```
150
+
151
+ - **Wake word detection** runs locally on Reachy Mini
152
+ - **Speech-to-Text (STT)** and **Text-to-Speech (TTS)** are handled by Home Assistant
153
+ - **Motion feedback** provides visual response during voice interaction
154
+ - **Camera streaming** provides real-time video feed to Home Assistant
155
+
156
+ ## Project Structure
157
+
158
+ ```
159
+ reachy_mini_ha_voice/
160
+ |-- reachy_mini_ha_voice/
161
+ | |-- __init__.py
162
+ | |-- __main__.py # CLI entry point
163
+ | |-- main.py # App entry point
164
+ | |-- voice_assistant.py # Voice assistant service
165
+ | |-- camera_server.py # MJPEG camera streaming server
166
+ | |-- satellite.py # ESPHome protocol handler
167
+ | |-- audio_player.py # Audio playback
168
+ | |-- motion.py # Motion control
169
+ | |-- models.py # Data models
170
+ | |-- entity.py # ESPHome base entities
171
+ | |-- entity_extensions.py # Extended entity types
172
+ | |-- reachy_controller.py # Reachy Mini controller wrapper
173
+ | |-- api_server.py # API server
174
+ | |-- zeroconf.py # mDNS discovery
175
+ | |-- util.py # Utilities
176
+ | |-- wakewords/ # Wake word models (auto-downloaded)
177
+ | |-- sounds/ # Sound effects (auto-downloaded)
178
+ | |-- pyproject.toml
179
+ | |-- README.md
180
+ | +-- PROJECT_PLAN.md
181
+ ```
182
+
183
+ ## Dependencies
184
+
185
+ - `reachy-mini` - Reachy Mini SDK
186
+ - `aioesphomeapi` - ESPHome protocol
187
+ - `pymicro-wakeword` - Wake word detection
188
+ - `opencv-python` - Camera streaming
189
+ - `sounddevice` / `soundfile` - Audio processing
190
+ - `zeroconf` - mDNS discovery
191
+
192
+ ## License
193
+
194
+ Apache 2.0 License
195
+
196
+ ## Acknowledgments
197
+
198
+ - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant) - Original ESPHome voice assistant
199
+ - [Pollen Robotics](https://www.pollen-robotics.com/) - Reachy Mini robot
 
 
 
 
app.py CHANGED
@@ -1,50 +1,50 @@
1
- """
2
- Hugging Face Space placeholder.
3
-
4
- This is a Reachy Mini App, not a Hugging Face Space.
5
- Install it on your Reachy Mini robot from the App Store.
6
- """
7
-
8
- import gradio as gr
9
-
10
- with gr.Blocks(title="Reachy Mini HA Voice") as demo:
11
- gr.Markdown("""
12
- # 🤖 Reachy Mini Home Assistant Voice Assistant
13
-
14
- **This is a Reachy Mini App, not a Hugging Face Space.**
15
-
16
- ## Installation
17
-
18
- Install this app directly from your **Reachy Mini Dashboard**:
19
-
20
- 1. Open Reachy Mini Dashboard in your browser
21
- 2. Go to **Apps** section
22
- 3. Find **Reachy Mini HA Voice** and click **Install**
23
-
24
- ## Features
25
-
26
- - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
27
- - **ESPHome Integration**: Seamlessly connects to Home Assistant
28
- - **Motion Control**: Head movements and antenna animations during voice interaction
29
- - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
30
-
31
- ## Usage
32
-
33
- After installation on Reachy Mini:
34
-
35
- 1. Open Home Assistant
36
- 2. Go to **Settings** → **Devices & Services** → **Add Integration**
37
- 3. Search for **ESPHome**
38
- 4. Enter your Reachy Mini's IP address with port `6053`
39
-
40
- Default wake word: **"Okay Nabu"**
41
-
42
- ## Links
43
-
44
- - [Source Code](https://huggingface.co/spaces/djhui5710/reachy_mini_ha_voice/tree/main)
45
- - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant)
46
- - [Pollen Robotics](https://www.pollen-robotics.com/)
47
- """)
48
-
49
- if __name__ == "__main__":
50
- demo.launch()
 
1
+ """
2
+ Hugging Face Space placeholder.
3
+
4
+ This is a Reachy Mini App, not a Hugging Face Space.
5
+ Install it on your Reachy Mini robot from the App Store.
6
+ """
7
+
8
+ import gradio as gr
9
+
10
+ with gr.Blocks(title="Reachy Mini HA Voice") as demo:
11
+ gr.Markdown("""
12
+ # 🤖 Reachy Mini Home Assistant Voice Assistant
13
+
14
+ **This is a Reachy Mini App, not a Hugging Face Space.**
15
+
16
+ ## Installation
17
+
18
+ Install this app directly from your **Reachy Mini Dashboard**:
19
+
20
+ 1. Open Reachy Mini Dashboard in your browser
21
+ 2. Go to **Apps** section
22
+ 3. Find **Reachy Mini HA Voice** and click **Install**
23
+
24
+ ## Features
25
+
26
+ - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
27
+ - **ESPHome Integration**: Seamlessly connects to Home Assistant
28
+ - **Motion Control**: Head movements and antenna animations during voice interaction
29
+ - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
30
+
31
+ ## Usage
32
+
33
+ After installation on Reachy Mini:
34
+
35
+ 1. Open Home Assistant
36
+ 2. Go to **Settings** → **Devices & Services** → **Add Integration**
37
+ 3. Search for **ESPHome**
38
+ 4. Enter your Reachy Mini's IP address with port `6053`
39
+
40
+ Default wake word: **"Okay Nabu"**
41
+
42
+ ## Links
43
+
44
+ - [Source Code](https://huggingface.co/spaces/djhui5710/reachy_mini_ha_voice/tree/main)
45
+ - [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant)
46
+ - [Pollen Robotics](https://www.pollen-robotics.com/)
47
+ """)
48
+
49
+ if __name__ == "__main__":
50
+ demo.launch()
index.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Reachy Mini Ha Voice </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🤖⚡</div>
15
+ <h1> Reachy Mini Ha Voice </h1>
16
+ <p class="tagline">Enter your tagline here</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">🛠️</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="footer">
31
+ <p>
32
+ 🤖 Reachy Mini Ha Voice •
33
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
34
+ <a href="https://huggingface.co/spaces/pollen-robotics/reachy-mini-landing-page#apps" target="_blank">Browse More
35
+ Apps</a>
36
+ </p>
37
+ </div>
38
+ </body>
39
+
40
+ </html>
linux-voice-assistant DELETED
@@ -1 +0,0 @@
1
- Subproject commit fd4c1d972bc87e6d7a0dddc5aa52465243d63265
 
 
pyproject.toml CHANGED
@@ -1,49 +1,49 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "reachy_mini_ha_voice"
7
- version = "0.1.0"
8
- description = "Home Assistant Voice Assistant for Reachy Mini"
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- license = {text = "Apache-2.0"}
12
- dependencies = [
13
- # Reachy Mini SDK (provides audio via media system)
14
- "reachy-mini",
15
-
16
- # Audio processing (fallback when not on Reachy Mini)
17
- "sounddevice>=0.4.6",
18
- "soundfile>=0.12.0",
19
- "numpy>=1.24.0",
20
-
21
- # Camera streaming
22
- "opencv-python>=4.8.0",
23
-
24
- # Wake word detection (local)
25
- # STT/TTS is handled by Home Assistant, not locally
26
- "pymicro-wakeword>=2.0.0,<3.0.0",
27
- "pyopen-wakeword>=1.0.0,<2.0.0",
28
-
29
- # ESPHome protocol (communication with Home Assistant)
30
- "aioesphomeapi>=42.0.0",
31
- "zeroconf>=0.100.0",
32
-
33
- # Motion control (head movements)
34
- "scipy>=1.10.0",
35
- ]
36
- keywords = ["reachy-mini-app", "reachy-mini", "home-assistant", "voice-assistant"]
37
-
38
- [project.entry-points."reachy_mini_apps"]
39
- reachy_mini_ha_voice = "reachy_mini_ha_voice.main:ReachyMiniHAVoiceApp"
40
-
41
- [tool.setuptools]
42
- package-dir = { "" = "." }
43
- include-package-data = true
44
-
45
- [tool.setuptools.packages.find]
46
- where = ["."]
47
-
48
- [tool.setuptools.package-data]
49
- "*" = ["*.json", "*.flac", "*.md", "*.tflite"]
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "reachy_mini_ha_voice"
7
+ version = "0.1.0"
8
+ description = "Home Assistant Voice Assistant for Reachy Mini"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = [
13
+ # Reachy Mini SDK (provides audio via media system)
14
+ "reachy-mini",
15
+
16
+ # Audio processing (fallback when not on Reachy Mini)
17
+ "sounddevice>=0.4.6",
18
+ "soundfile>=0.12.0",
19
+ "numpy>=1.24.0",
20
+
21
+ # Camera streaming
22
+ "opencv-python>=4.8.0",
23
+
24
+ # Wake word detection (local)
25
+ # STT/TTS is handled by Home Assistant, not locally
26
+ "pymicro-wakeword>=2.0.0,<3.0.0",
27
+ "pyopen-wakeword>=1.0.0,<2.0.0",
28
+
29
+ # ESPHome protocol (communication with Home Assistant)
30
+ "aioesphomeapi>=42.0.0",
31
+ "zeroconf>=0.100.0",
32
+
33
+ # Motion control (head movements)
34
+ "scipy>=1.10.0",
35
+ ]
36
+ keywords = ["reachy-mini-app", "reachy-mini", "home-assistant", "voice-assistant"]
37
+
38
+ [project.entry-points."reachy_mini_apps"]
39
+ reachy_mini_ha_voice = "reachy_mini_ha_voice.main:ReachyMiniHaVoice"
40
+
41
+ [tool.setuptools]
42
+ package-dir = { "" = "." }
43
+ include-package-data = true
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["."]
47
+
48
+ [tool.setuptools.package-data]
49
+ "*" = ["*.json", "*.flac", "*.md", "*.tflite"]
reachy_mini_conversation_app DELETED
@@ -1 +0,0 @@
1
- Subproject commit fc5123651cf50582d2b073b1779b37c26232e5cc
 
 
reachy_mini_ha_voice/__init__.py CHANGED
@@ -1,22 +1,22 @@
1
- """
2
- Reachy Mini Home Assistant Voice Assistant
3
-
4
- A voice assistant application that runs on Reachy Mini robot and integrates
5
- with Home Assistant via the ESPHome protocol.
6
-
7
- Key features:
8
- - Local wake word detection (microWakeWord/openWakeWord)
9
- - ESPHome protocol for Home Assistant integration
10
- - STT/TTS handled by Home Assistant (not locally)
11
- - Reachy Mini motion control integration
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
19
-
20
- __all__ = [
21
- "__version__",
22
- ]
 
1
+ """
2
+ Reachy Mini Home Assistant Voice Assistant
3
+
4
+ A voice assistant application that runs on Reachy Mini robot and integrates
5
+ with Home Assistant via the ESPHome protocol.
6
+
7
+ Key features:
8
+ - Local wake word detection (microWakeWord/openWakeWord)
9
+ - ESPHome protocol for Home Assistant integration
10
+ - STT/TTS handled by Home Assistant (not locally)
11
+ - Reachy Mini motion control integration
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
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ ]
reachy_mini_ha_voice/camera_server.py CHANGED
@@ -1,365 +1,365 @@
1
- """
2
- MJPEG Camera Server for Reachy Mini.
3
-
4
- This module provides an HTTP server that streams camera frames from Reachy Mini
5
- as MJPEG, which can be integrated with Home Assistant via Generic Camera.
6
- """
7
-
8
- import asyncio
9
- import logging
10
- import threading
11
- import time
12
- from typing import Optional, TYPE_CHECKING
13
-
14
- import cv2
15
- import numpy as np
16
-
17
- if TYPE_CHECKING:
18
- from reachy_mini import ReachyMini
19
-
20
- _LOGGER = logging.getLogger(__name__)
21
-
22
- # MJPEG boundary string
23
- MJPEG_BOUNDARY = "frame"
24
-
25
-
26
- class MJPEGCameraServer:
27
- """
28
- MJPEG streaming server for Reachy Mini camera.
29
-
30
- Provides HTTP endpoints:
31
- - /stream - MJPEG video stream
32
- - /snapshot - Single JPEG image
33
- - / - Simple status page
34
- """
35
-
36
- def __init__(
37
- self,
38
- reachy_mini: Optional["ReachyMini"] = None,
39
- host: str = "0.0.0.0",
40
- port: int = 8081,
41
- fps: int = 15,
42
- quality: int = 80,
43
- ):
44
- """
45
- Initialize the MJPEG camera server.
46
-
47
- Args:
48
- reachy_mini: Reachy Mini robot instance (can be None for testing)
49
- host: Host address to bind to
50
- port: Port number for the HTTP server
51
- fps: Target frames per second for the stream
52
- quality: JPEG quality (1-100)
53
- """
54
- self.reachy_mini = reachy_mini
55
- self.host = host
56
- self.port = port
57
- self.fps = fps
58
- self.quality = quality
59
-
60
- self._server: Optional[asyncio.Server] = None
61
- self._running = False
62
- self._frame_interval = 1.0 / fps
63
- self._last_frame: Optional[bytes] = None
64
- self._last_frame_time: float = 0
65
- self._frame_lock = threading.Lock()
66
-
67
- # Frame capture thread
68
- self._capture_thread: Optional[threading.Thread] = None
69
-
70
- async def start(self) -> None:
71
- """Start the MJPEG camera server."""
72
- if self._running:
73
- _LOGGER.warning("Camera server already running")
74
- return
75
-
76
- self._running = True
77
-
78
- # Start frame capture thread
79
- self._capture_thread = threading.Thread(
80
- target=self._capture_frames,
81
- daemon=True,
82
- name="camera-capture"
83
- )
84
- self._capture_thread.start()
85
-
86
- # Start HTTP server
87
- self._server = await asyncio.start_server(
88
- self._handle_client,
89
- self.host,
90
- self.port,
91
- )
92
-
93
- _LOGGER.info("MJPEG Camera server started on http://%s:%d", self.host, self.port)
94
- _LOGGER.info(" Stream URL: http://<ip>:%d/stream", self.port)
95
- _LOGGER.info(" Snapshot URL: http://<ip>:%d/snapshot", self.port)
96
-
97
- async def stop(self) -> None:
98
- """Stop the MJPEG camera server."""
99
- self._running = False
100
-
101
- if self._capture_thread:
102
- self._capture_thread.join(timeout=2.0)
103
- self._capture_thread = None
104
-
105
- if self._server:
106
- self._server.close()
107
- await self._server.wait_closed()
108
- self._server = None
109
-
110
- _LOGGER.info("MJPEG Camera server stopped")
111
-
112
- def _capture_frames(self) -> None:
113
- """Background thread to capture frames from Reachy Mini."""
114
- _LOGGER.info("Starting camera capture thread")
115
-
116
- while self._running:
117
- try:
118
- frame = self._get_camera_frame()
119
-
120
- if frame is not None:
121
- # Encode frame as JPEG
122
- encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality]
123
- success, jpeg_data = cv2.imencode('.jpg', frame, encode_params)
124
-
125
- if success:
126
- with self._frame_lock:
127
- self._last_frame = jpeg_data.tobytes()
128
- self._last_frame_time = time.time()
129
-
130
- # Sleep to maintain target FPS
131
- time.sleep(self._frame_interval)
132
-
133
- except Exception as e:
134
- _LOGGER.error("Error capturing frame: %s", e)
135
- time.sleep(0.5)
136
-
137
- _LOGGER.info("Camera capture thread stopped")
138
-
139
- def _get_camera_frame(self) -> Optional[np.ndarray]:
140
- """Get a frame from Reachy Mini's camera."""
141
- if self.reachy_mini is None:
142
- # Return a test pattern if no robot connected
143
- return self._generate_test_frame()
144
-
145
- try:
146
- frame = self.reachy_mini.media.get_frame()
147
- return frame
148
- except Exception as e:
149
- _LOGGER.debug("Failed to get camera frame: %s", e)
150
- return None
151
-
152
- def _generate_test_frame(self) -> np.ndarray:
153
- """Generate a test pattern frame when no camera is available."""
154
- # Create a simple test pattern
155
- frame = np.zeros((480, 640, 3), dtype=np.uint8)
156
-
157
- # Add some visual elements
158
- cv2.putText(
159
- frame,
160
- "Reachy Mini Camera",
161
- (150, 200),
162
- cv2.FONT_HERSHEY_SIMPLEX,
163
- 1.2,
164
- (255, 255, 255),
165
- 2,
166
- )
167
- cv2.putText(
168
- frame,
169
- "No camera connected",
170
- (180, 280),
171
- cv2.FONT_HERSHEY_SIMPLEX,
172
- 0.8,
173
- (128, 128, 128),
174
- 1,
175
- )
176
-
177
- # Add timestamp
178
- timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
179
- cv2.putText(
180
- frame,
181
- timestamp,
182
- (220, 350),
183
- cv2.FONT_HERSHEY_SIMPLEX,
184
- 0.6,
185
- (0, 255, 0),
186
- 1,
187
- )
188
-
189
- return frame
190
-
191
- def get_snapshot(self) -> Optional[bytes]:
192
- """Get the latest frame as JPEG bytes."""
193
- with self._frame_lock:
194
- return self._last_frame
195
-
196
- async def _handle_client(
197
- self,
198
- reader: asyncio.StreamReader,
199
- writer: asyncio.StreamWriter,
200
- ) -> None:
201
- """Handle incoming HTTP client connections."""
202
- try:
203
- # Read HTTP request
204
- request_line = await asyncio.wait_for(
205
- reader.readline(),
206
- timeout=10.0
207
- )
208
- request = request_line.decode('utf-8', errors='ignore').strip()
209
-
210
- # Read headers (we don't need them but must consume them)
211
- while True:
212
- line = await asyncio.wait_for(reader.readline(), timeout=5.0)
213
- if line == b'\r\n' or line == b'\n' or line == b'':
214
- break
215
-
216
- # Parse request path
217
- parts = request.split(' ')
218
- if len(parts) >= 2:
219
- path = parts[1]
220
- else:
221
- path = '/'
222
-
223
- _LOGGER.debug("HTTP request: %s", request)
224
-
225
- if path == '/stream':
226
- await self._handle_stream(writer)
227
- elif path == '/snapshot':
228
- await self._handle_snapshot(writer)
229
- else:
230
- await self._handle_index(writer)
231
-
232
- except asyncio.TimeoutError:
233
- _LOGGER.debug("Client connection timeout")
234
- except ConnectionResetError:
235
- _LOGGER.debug("Client connection reset")
236
- except Exception as e:
237
- _LOGGER.error("Error handling client: %s", e)
238
- finally:
239
- try:
240
- writer.close()
241
- await writer.wait_closed()
242
- except Exception:
243
- pass
244
-
245
- async def _handle_index(self, writer: asyncio.StreamWriter) -> None:
246
- """Handle index page request."""
247
- html = f"""<!DOCTYPE html>
248
- <html>
249
- <head>
250
- <title>Reachy Mini Camera</title>
251
- <style>
252
- body {{ font-family: Arial, sans-serif; margin: 40px; background: #1a1a2e; color: #eee; }}
253
- h1 {{ color: #00d4ff; }}
254
- .container {{ max-width: 800px; margin: 0 auto; }}
255
- .stream {{ width: 100%; max-width: 640px; border: 2px solid #00d4ff; border-radius: 8px; }}
256
- a {{ color: #00d4ff; }}
257
- .info {{ background: #16213e; padding: 20px; border-radius: 8px; margin-top: 20px; }}
258
- </style>
259
- </head>
260
- <body>
261
- <div class="container">
262
- <h1>Reachy Mini Camera</h1>
263
- <img class="stream" src="/stream" alt="Camera Stream">
264
- <div class="info">
265
- <h3>Endpoints:</h3>
266
- <ul>
267
- <li><a href="/stream">/stream</a> - MJPEG video stream</li>
268
- <li><a href="/snapshot">/snapshot</a> - Single JPEG snapshot</li>
269
- </ul>
270
- <h3>Home Assistant Integration:</h3>
271
- <p>Add a Generic Camera with URL: <code>http://&lt;ip&gt;:{self.port}/stream</code></p>
272
- </div>
273
- </div>
274
- </body>
275
- </html>"""
276
-
277
- response = (
278
- "HTTP/1.1 200 OK\r\n"
279
- "Content-Type: text/html; charset=utf-8\r\n"
280
- f"Content-Length: {len(html)}\r\n"
281
- "Connection: close\r\n"
282
- "\r\n"
283
- )
284
-
285
- writer.write(response.encode('utf-8'))
286
- writer.write(html.encode('utf-8'))
287
- await writer.drain()
288
-
289
- async def _handle_snapshot(self, writer: asyncio.StreamWriter) -> None:
290
- """Handle snapshot request - return single JPEG image."""
291
- jpeg_data = self.get_snapshot()
292
-
293
- if jpeg_data is None:
294
- response = (
295
- "HTTP/1.1 503 Service Unavailable\r\n"
296
- "Content-Type: text/plain\r\n"
297
- "Connection: close\r\n"
298
- "\r\n"
299
- "No frame available"
300
- )
301
- writer.write(response.encode('utf-8'))
302
- else:
303
- response = (
304
- "HTTP/1.1 200 OK\r\n"
305
- "Content-Type: image/jpeg\r\n"
306
- f"Content-Length: {len(jpeg_data)}\r\n"
307
- "Cache-Control: no-cache, no-store, must-revalidate\r\n"
308
- "Connection: close\r\n"
309
- "\r\n"
310
- )
311
- writer.write(response.encode('utf-8'))
312
- writer.write(jpeg_data)
313
-
314
- await writer.drain()
315
-
316
- async def _handle_stream(self, writer: asyncio.StreamWriter) -> None:
317
- """Handle MJPEG stream request."""
318
- # Send MJPEG headers
319
- response = (
320
- "HTTP/1.1 200 OK\r\n"
321
- f"Content-Type: multipart/x-mixed-replace; boundary={MJPEG_BOUNDARY}\r\n"
322
- "Cache-Control: no-cache, no-store, must-revalidate\r\n"
323
- "Connection: keep-alive\r\n"
324
- "\r\n"
325
- )
326
- writer.write(response.encode('utf-8'))
327
- await writer.drain()
328
-
329
- _LOGGER.debug("Started MJPEG stream")
330
-
331
- last_sent_time = 0
332
-
333
- try:
334
- while self._running:
335
- # Get latest frame
336
- with self._frame_lock:
337
- jpeg_data = self._last_frame
338
- frame_time = self._last_frame_time
339
-
340
- # Only send if we have a new frame
341
- if jpeg_data is not None and frame_time > last_sent_time:
342
- # Send MJPEG frame
343
- frame_header = (
344
- f"--{MJPEG_BOUNDARY}\r\n"
345
- "Content-Type: image/jpeg\r\n"
346
- f"Content-Length: {len(jpeg_data)}\r\n"
347
- "\r\n"
348
- )
349
-
350
- writer.write(frame_header.encode('utf-8'))
351
- writer.write(jpeg_data)
352
- writer.write(b"\r\n")
353
- await writer.drain()
354
-
355
- last_sent_time = frame_time
356
-
357
- # Small delay to prevent busy loop
358
- await asyncio.sleep(0.01)
359
-
360
- except (ConnectionResetError, BrokenPipeError):
361
- _LOGGER.debug("Client disconnected from stream")
362
- except Exception as e:
363
- _LOGGER.error("Error in MJPEG stream: %s", e)
364
-
365
- _LOGGER.debug("Ended MJPEG stream")
 
1
+ """
2
+ MJPEG Camera Server for Reachy Mini.
3
+
4
+ This module provides an HTTP server that streams camera frames from Reachy Mini
5
+ as MJPEG, which can be integrated with Home Assistant via Generic Camera.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import threading
11
+ import time
12
+ from typing import Optional, TYPE_CHECKING
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ if TYPE_CHECKING:
18
+ from reachy_mini import ReachyMini
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ # MJPEG boundary string
23
+ MJPEG_BOUNDARY = "frame"
24
+
25
+
26
+ class MJPEGCameraServer:
27
+ """
28
+ MJPEG streaming server for Reachy Mini camera.
29
+
30
+ Provides HTTP endpoints:
31
+ - /stream - MJPEG video stream
32
+ - /snapshot - Single JPEG image
33
+ - / - Simple status page
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ reachy_mini: Optional["ReachyMini"] = None,
39
+ host: str = "0.0.0.0",
40
+ port: int = 8081,
41
+ fps: int = 15,
42
+ quality: int = 80,
43
+ ):
44
+ """
45
+ Initialize the MJPEG camera server.
46
+
47
+ Args:
48
+ reachy_mini: Reachy Mini robot instance (can be None for testing)
49
+ host: Host address to bind to
50
+ port: Port number for the HTTP server
51
+ fps: Target frames per second for the stream
52
+ quality: JPEG quality (1-100)
53
+ """
54
+ self.reachy_mini = reachy_mini
55
+ self.host = host
56
+ self.port = port
57
+ self.fps = fps
58
+ self.quality = quality
59
+
60
+ self._server: Optional[asyncio.Server] = None
61
+ self._running = False
62
+ self._frame_interval = 1.0 / fps
63
+ self._last_frame: Optional[bytes] = None
64
+ self._last_frame_time: float = 0
65
+ self._frame_lock = threading.Lock()
66
+
67
+ # Frame capture thread
68
+ self._capture_thread: Optional[threading.Thread] = None
69
+
70
+ async def start(self) -> None:
71
+ """Start the MJPEG camera server."""
72
+ if self._running:
73
+ _LOGGER.warning("Camera server already running")
74
+ return
75
+
76
+ self._running = True
77
+
78
+ # Start frame capture thread
79
+ self._capture_thread = threading.Thread(
80
+ target=self._capture_frames,
81
+ daemon=True,
82
+ name="camera-capture"
83
+ )
84
+ self._capture_thread.start()
85
+
86
+ # Start HTTP server
87
+ self._server = await asyncio.start_server(
88
+ self._handle_client,
89
+ self.host,
90
+ self.port,
91
+ )
92
+
93
+ _LOGGER.info("MJPEG Camera server started on http://%s:%d", self.host, self.port)
94
+ _LOGGER.info(" Stream URL: http://<ip>:%d/stream", self.port)
95
+ _LOGGER.info(" Snapshot URL: http://<ip>:%d/snapshot", self.port)
96
+
97
+ async def stop(self) -> None:
98
+ """Stop the MJPEG camera server."""
99
+ self._running = False
100
+
101
+ if self._capture_thread:
102
+ self._capture_thread.join(timeout=2.0)
103
+ self._capture_thread = None
104
+
105
+ if self._server:
106
+ self._server.close()
107
+ await self._server.wait_closed()
108
+ self._server = None
109
+
110
+ _LOGGER.info("MJPEG Camera server stopped")
111
+
112
+ def _capture_frames(self) -> None:
113
+ """Background thread to capture frames from Reachy Mini."""
114
+ _LOGGER.info("Starting camera capture thread")
115
+
116
+ while self._running:
117
+ try:
118
+ frame = self._get_camera_frame()
119
+
120
+ if frame is not None:
121
+ # Encode frame as JPEG
122
+ encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality]
123
+ success, jpeg_data = cv2.imencode('.jpg', frame, encode_params)
124
+
125
+ if success:
126
+ with self._frame_lock:
127
+ self._last_frame = jpeg_data.tobytes()
128
+ self._last_frame_time = time.time()
129
+
130
+ # Sleep to maintain target FPS
131
+ time.sleep(self._frame_interval)
132
+
133
+ except Exception as e:
134
+ _LOGGER.error("Error capturing frame: %s", e)
135
+ time.sleep(0.5)
136
+
137
+ _LOGGER.info("Camera capture thread stopped")
138
+
139
+ def _get_camera_frame(self) -> Optional[np.ndarray]:
140
+ """Get a frame from Reachy Mini's camera."""
141
+ if self.reachy_mini is None:
142
+ # Return a test pattern if no robot connected
143
+ return self._generate_test_frame()
144
+
145
+ try:
146
+ frame = self.reachy_mini.media.get_frame()
147
+ return frame
148
+ except Exception as e:
149
+ _LOGGER.debug("Failed to get camera frame: %s", e)
150
+ return None
151
+
152
+ def _generate_test_frame(self) -> np.ndarray:
153
+ """Generate a test pattern frame when no camera is available."""
154
+ # Create a simple test pattern
155
+ frame = np.zeros((480, 640, 3), dtype=np.uint8)
156
+
157
+ # Add some visual elements
158
+ cv2.putText(
159
+ frame,
160
+ "Reachy Mini Camera",
161
+ (150, 200),
162
+ cv2.FONT_HERSHEY_SIMPLEX,
163
+ 1.2,
164
+ (255, 255, 255),
165
+ 2,
166
+ )
167
+ cv2.putText(
168
+ frame,
169
+ "No camera connected",
170
+ (180, 280),
171
+ cv2.FONT_HERSHEY_SIMPLEX,
172
+ 0.8,
173
+ (128, 128, 128),
174
+ 1,
175
+ )
176
+
177
+ # Add timestamp
178
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
179
+ cv2.putText(
180
+ frame,
181
+ timestamp,
182
+ (220, 350),
183
+ cv2.FONT_HERSHEY_SIMPLEX,
184
+ 0.6,
185
+ (0, 255, 0),
186
+ 1,
187
+ )
188
+
189
+ return frame
190
+
191
+ def get_snapshot(self) -> Optional[bytes]:
192
+ """Get the latest frame as JPEG bytes."""
193
+ with self._frame_lock:
194
+ return self._last_frame
195
+
196
+ async def _handle_client(
197
+ self,
198
+ reader: asyncio.StreamReader,
199
+ writer: asyncio.StreamWriter,
200
+ ) -> None:
201
+ """Handle incoming HTTP client connections."""
202
+ try:
203
+ # Read HTTP request
204
+ request_line = await asyncio.wait_for(
205
+ reader.readline(),
206
+ timeout=10.0
207
+ )
208
+ request = request_line.decode('utf-8', errors='ignore').strip()
209
+
210
+ # Read headers (we don't need them but must consume them)
211
+ while True:
212
+ line = await asyncio.wait_for(reader.readline(), timeout=5.0)
213
+ if line == b'\r\n' or line == b'\n' or line == b'':
214
+ break
215
+
216
+ # Parse request path
217
+ parts = request.split(' ')
218
+ if len(parts) >= 2:
219
+ path = parts[1]
220
+ else:
221
+ path = '/'
222
+
223
+ _LOGGER.debug("HTTP request: %s", request)
224
+
225
+ if path == '/stream':
226
+ await self._handle_stream(writer)
227
+ elif path == '/snapshot':
228
+ await self._handle_snapshot(writer)
229
+ else:
230
+ await self._handle_index(writer)
231
+
232
+ except asyncio.TimeoutError:
233
+ _LOGGER.debug("Client connection timeout")
234
+ except ConnectionResetError:
235
+ _LOGGER.debug("Client connection reset")
236
+ except Exception as e:
237
+ _LOGGER.error("Error handling client: %s", e)
238
+ finally:
239
+ try:
240
+ writer.close()
241
+ await writer.wait_closed()
242
+ except Exception:
243
+ pass
244
+
245
+ async def _handle_index(self, writer: asyncio.StreamWriter) -> None:
246
+ """Handle index page request."""
247
+ html = f"""<!DOCTYPE html>
248
+ <html>
249
+ <head>
250
+ <title>Reachy Mini Camera</title>
251
+ <style>
252
+ body {{ font-family: Arial, sans-serif; margin: 40px; background: #1a1a2e; color: #eee; }}
253
+ h1 {{ color: #00d4ff; }}
254
+ .container {{ max-width: 800px; margin: 0 auto; }}
255
+ .stream {{ width: 100%; max-width: 640px; border: 2px solid #00d4ff; border-radius: 8px; }}
256
+ a {{ color: #00d4ff; }}
257
+ .info {{ background: #16213e; padding: 20px; border-radius: 8px; margin-top: 20px; }}
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="container">
262
+ <h1>Reachy Mini Camera</h1>
263
+ <img class="stream" src="/stream" alt="Camera Stream">
264
+ <div class="info">
265
+ <h3>Endpoints:</h3>
266
+ <ul>
267
+ <li><a href="/stream">/stream</a> - MJPEG video stream</li>
268
+ <li><a href="/snapshot">/snapshot</a> - Single JPEG snapshot</li>
269
+ </ul>
270
+ <h3>Home Assistant Integration:</h3>
271
+ <p>Add a Generic Camera with URL: <code>http://&lt;ip&gt;:{self.port}/stream</code></p>
272
+ </div>
273
+ </div>
274
+ </body>
275
+ </html>"""
276
+
277
+ response = (
278
+ "HTTP/1.1 200 OK\r\n"
279
+ "Content-Type: text/html; charset=utf-8\r\n"
280
+ f"Content-Length: {len(html)}\r\n"
281
+ "Connection: close\r\n"
282
+ "\r\n"
283
+ )
284
+
285
+ writer.write(response.encode('utf-8'))
286
+ writer.write(html.encode('utf-8'))
287
+ await writer.drain()
288
+
289
+ async def _handle_snapshot(self, writer: asyncio.StreamWriter) -> None:
290
+ """Handle snapshot request - return single JPEG image."""
291
+ jpeg_data = self.get_snapshot()
292
+
293
+ if jpeg_data is None:
294
+ response = (
295
+ "HTTP/1.1 503 Service Unavailable\r\n"
296
+ "Content-Type: text/plain\r\n"
297
+ "Connection: close\r\n"
298
+ "\r\n"
299
+ "No frame available"
300
+ )
301
+ writer.write(response.encode('utf-8'))
302
+ else:
303
+ response = (
304
+ "HTTP/1.1 200 OK\r\n"
305
+ "Content-Type: image/jpeg\r\n"
306
+ f"Content-Length: {len(jpeg_data)}\r\n"
307
+ "Cache-Control: no-cache, no-store, must-revalidate\r\n"
308
+ "Connection: close\r\n"
309
+ "\r\n"
310
+ )
311
+ writer.write(response.encode('utf-8'))
312
+ writer.write(jpeg_data)
313
+
314
+ await writer.drain()
315
+
316
+ async def _handle_stream(self, writer: asyncio.StreamWriter) -> None:
317
+ """Handle MJPEG stream request."""
318
+ # Send MJPEG headers
319
+ response = (
320
+ "HTTP/1.1 200 OK\r\n"
321
+ f"Content-Type: multipart/x-mixed-replace; boundary={MJPEG_BOUNDARY}\r\n"
322
+ "Cache-Control: no-cache, no-store, must-revalidate\r\n"
323
+ "Connection: keep-alive\r\n"
324
+ "\r\n"
325
+ )
326
+ writer.write(response.encode('utf-8'))
327
+ await writer.drain()
328
+
329
+ _LOGGER.debug("Started MJPEG stream")
330
+
331
+ last_sent_time = 0
332
+
333
+ try:
334
+ while self._running:
335
+ # Get latest frame
336
+ with self._frame_lock:
337
+ jpeg_data = self._last_frame
338
+ frame_time = self._last_frame_time
339
+
340
+ # Only send if we have a new frame
341
+ if jpeg_data is not None and frame_time > last_sent_time:
342
+ # Send MJPEG frame
343
+ frame_header = (
344
+ f"--{MJPEG_BOUNDARY}\r\n"
345
+ "Content-Type: image/jpeg\r\n"
346
+ f"Content-Length: {len(jpeg_data)}\r\n"
347
+ "\r\n"
348
+ )
349
+
350
+ writer.write(frame_header.encode('utf-8'))
351
+ writer.write(jpeg_data)
352
+ writer.write(b"\r\n")
353
+ await writer.drain()
354
+
355
+ last_sent_time = frame_time
356
+
357
+ # Small delay to prevent busy loop
358
+ await asyncio.sleep(0.01)
359
+
360
+ except (ConnectionResetError, BrokenPipeError):
361
+ _LOGGER.debug("Client disconnected from stream")
362
+ except Exception as e:
363
+ _LOGGER.error("Error in MJPEG stream: %s", e)
364
+
365
+ _LOGGER.debug("Ended MJPEG stream")
reachy_mini_ha_voice/entity_extensions.py CHANGED
@@ -1,292 +1,292 @@
1
- """Extended ESPHome entity types for Reachy Mini control."""
2
-
3
- from collections.abc import Iterable
4
- from typing import Callable, List, Optional
5
- import logging
6
-
7
- from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
8
- ListEntitiesButtonResponse,
9
- ListEntitiesRequest,
10
- ListEntitiesSelectResponse,
11
- ListEntitiesSensorResponse,
12
- ListEntitiesSwitchResponse,
13
- ButtonCommandRequest,
14
- SelectCommandRequest,
15
- SelectStateResponse,
16
- SensorStateResponse,
17
- SubscribeHomeAssistantStatesRequest,
18
- SubscribeStatesRequest,
19
- SwitchCommandRequest,
20
- SwitchStateResponse,
21
- )
22
- from google.protobuf import message
23
-
24
- from .api_server import APIServer
25
- from .entity import ESPHomeEntity
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
-
30
- class SensorStateClass:
31
- """ESPHome SensorStateClass enum values."""
32
- NONE = 0
33
- MEASUREMENT = 1
34
- TOTAL_INCREASING = 2
35
- TOTAL = 3
36
-
37
-
38
- class SensorEntity(ESPHomeEntity):
39
- """Sensor entity for ESPHome (read-only numeric values)."""
40
-
41
- def __init__(
42
- self,
43
- server: APIServer,
44
- key: int,
45
- name: str,
46
- object_id: str,
47
- icon: str = "",
48
- unit_of_measurement: str = "",
49
- accuracy_decimals: int = 2,
50
- device_class: str = "",
51
- state_class: int = SensorStateClass.NONE,
52
- entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
53
- value_getter: Optional[Callable[[], float]] = None,
54
- ) -> None:
55
- ESPHomeEntity.__init__(self, server)
56
- self.key = key
57
- self.name = name
58
- self.object_id = object_id
59
- self.icon = icon
60
- self.unit_of_measurement = unit_of_measurement
61
- self.accuracy_decimals = accuracy_decimals
62
- self.device_class = device_class
63
- self.entity_category = entity_category
64
- # Convert string state_class to int if needed (for backward compatibility)
65
- if isinstance(state_class, str):
66
- state_class_map = {
67
- "": SensorStateClass.NONE,
68
- "measurement": SensorStateClass.MEASUREMENT,
69
- "total_increasing": SensorStateClass.TOTAL_INCREASING,
70
- "total": SensorStateClass.TOTAL,
71
- }
72
- self.state_class = state_class_map.get(state_class.lower(), SensorStateClass.NONE)
73
- else:
74
- self.state_class = state_class
75
- self._value_getter = value_getter
76
- self._value = 0.0
77
-
78
- @property
79
- def value(self) -> float:
80
- if self._value_getter:
81
- return self._value_getter()
82
- return self._value
83
-
84
- @value.setter
85
- def value(self, new_value: float) -> None:
86
- self._value = new_value
87
-
88
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
89
- if isinstance(msg, ListEntitiesRequest):
90
- yield ListEntitiesSensorResponse(
91
- object_id=self.object_id,
92
- key=self.key,
93
- name=self.name,
94
- icon=self.icon,
95
- unit_of_measurement=self.unit_of_measurement,
96
- accuracy_decimals=self.accuracy_decimals,
97
- device_class=self.device_class,
98
- state_class=self.state_class,
99
- entity_category=self.entity_category,
100
- )
101
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
102
- yield self._get_state_message()
103
-
104
- def _get_state_message(self) -> SensorStateResponse:
105
- return SensorStateResponse(
106
- key=self.key,
107
- state=self.value,
108
- missing_state=False,
109
- )
110
-
111
- def update_state(self) -> None:
112
- """Send state update to Home Assistant."""
113
- self.server.send_messages([self._get_state_message()])
114
-
115
-
116
- class SwitchEntity(ESPHomeEntity):
117
- """Switch entity for ESPHome (read-write boolean values)."""
118
-
119
- def __init__(
120
- self,
121
- server: APIServer,
122
- key: int,
123
- name: str,
124
- object_id: str,
125
- icon: str = "",
126
- device_class: str = "",
127
- entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
128
- value_getter: Optional[Callable[[], bool]] = None,
129
- value_setter: Optional[Callable[[bool], None]] = None,
130
- ) -> None:
131
- ESPHomeEntity.__init__(self, server)
132
- self.key = key
133
- self.name = name
134
- self.object_id = object_id
135
- self.icon = icon
136
- self.device_class = device_class
137
- self.entity_category = entity_category
138
- self._value_getter = value_getter
139
- self._value_setter = value_setter
140
- self._value = False
141
-
142
- @property
143
- def value(self) -> bool:
144
- if self._value_getter:
145
- return self._value_getter()
146
- return self._value
147
-
148
- @value.setter
149
- def value(self, new_value: bool) -> None:
150
- if self._value_setter:
151
- self._value_setter(new_value)
152
- self._value = new_value
153
-
154
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
155
- if isinstance(msg, ListEntitiesRequest):
156
- yield ListEntitiesSwitchResponse(
157
- object_id=self.object_id,
158
- key=self.key,
159
- name=self.name,
160
- icon=self.icon,
161
- device_class=self.device_class,
162
- entity_category=self.entity_category,
163
- )
164
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
165
- yield self._get_state_message()
166
- elif isinstance(msg, SwitchCommandRequest) and msg.key == self.key:
167
- self.value = msg.state
168
- yield self._get_state_message()
169
-
170
- def _get_state_message(self) -> SwitchStateResponse:
171
- return SwitchStateResponse(
172
- key=self.key,
173
- state=self.value,
174
- )
175
-
176
- def update_state(self) -> None:
177
- """Send state update to Home Assistant."""
178
- self.server.send_messages([self._get_state_message()])
179
-
180
-
181
- class SelectEntity(ESPHomeEntity):
182
- """Select entity for ESPHome (read-write string selection)."""
183
-
184
- def __init__(
185
- self,
186
- server: APIServer,
187
- key: int,
188
- name: str,
189
- object_id: str,
190
- options: List[str],
191
- icon: str = "",
192
- entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
193
- value_getter: Optional[Callable[[], str]] = None,
194
- value_setter: Optional[Callable[[str], None]] = None,
195
- ) -> None:
196
- ESPHomeEntity.__init__(self, server)
197
- self.key = key
198
- self.name = name
199
- self.object_id = object_id
200
- self.options = options
201
- self.icon = icon
202
- self.entity_category = entity_category
203
- self._value_getter = value_getter
204
- self._value_setter = value_setter
205
- self._value = options[0] if options else ""
206
-
207
- @property
208
- def value(self) -> str:
209
- if self._value_getter:
210
- return self._value_getter()
211
- return self._value
212
-
213
- @value.setter
214
- def value(self, new_value: str) -> None:
215
- if new_value in self.options:
216
- if self._value_setter:
217
- self._value_setter(new_value)
218
- self._value = new_value
219
- else:
220
- logger.warning(f"Invalid option '{new_value}' for {self.name}")
221
-
222
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
223
- if isinstance(msg, ListEntitiesRequest):
224
- yield ListEntitiesSelectResponse(
225
- object_id=self.object_id,
226
- key=self.key,
227
- name=self.name,
228
- icon=self.icon,
229
- options=self.options,
230
- entity_category=self.entity_category,
231
- )
232
- elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
233
- yield self._get_state_message()
234
- elif isinstance(msg, SelectCommandRequest) and msg.key == self.key:
235
- self.value = msg.state
236
- yield self._get_state_message()
237
-
238
- def _get_state_message(self) -> SelectStateResponse:
239
- return SelectStateResponse(
240
- key=self.key,
241
- state=self.value,
242
- missing_state=False,
243
- )
244
-
245
- def update_state(self) -> None:
246
- """Send state update to Home Assistant."""
247
- self.server.send_messages([self._get_state_message()])
248
-
249
-
250
- class ButtonEntity(ESPHomeEntity):
251
- """Button entity for ESPHome (trigger actions)."""
252
-
253
- def __init__(
254
- self,
255
- server: APIServer,
256
- key: int,
257
- name: str,
258
- object_id: str,
259
- icon: str = "",
260
- device_class: str = "",
261
- entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
262
- on_press: Optional[Callable[[], None]] = None,
263
- ) -> None:
264
- ESPHomeEntity.__init__(self, server)
265
- self.key = key
266
- self.name = name
267
- self.object_id = object_id
268
- self.icon = icon
269
- self.device_class = device_class
270
- self.entity_category = entity_category
271
- self._on_press = on_press
272
-
273
- def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
274
- if isinstance(msg, ListEntitiesRequest):
275
- yield ListEntitiesButtonResponse(
276
- object_id=self.object_id,
277
- key=self.key,
278
- name=self.name,
279
- icon=self.icon,
280
- device_class=self.device_class,
281
- entity_category=self.entity_category,
282
- )
283
- elif isinstance(msg, ButtonCommandRequest) and msg.key == self.key:
284
- if self._on_press:
285
- try:
286
- self._on_press()
287
- logger.info(f"Button '{self.name}' pressed")
288
- except Exception as e:
289
- logger.error(f"Error executing button '{self.name}': {e}")
290
- # Buttons don't have state responses
291
- return
292
- yield # Make this a generator
 
1
+ """Extended ESPHome entity types for Reachy Mini control."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Callable, List, Optional
5
+ import logging
6
+
7
+ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
8
+ ListEntitiesButtonResponse,
9
+ ListEntitiesRequest,
10
+ ListEntitiesSelectResponse,
11
+ ListEntitiesSensorResponse,
12
+ ListEntitiesSwitchResponse,
13
+ ButtonCommandRequest,
14
+ SelectCommandRequest,
15
+ SelectStateResponse,
16
+ SensorStateResponse,
17
+ SubscribeHomeAssistantStatesRequest,
18
+ SubscribeStatesRequest,
19
+ SwitchCommandRequest,
20
+ SwitchStateResponse,
21
+ )
22
+ from google.protobuf import message
23
+
24
+ from .api_server import APIServer
25
+ from .entity import ESPHomeEntity
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class SensorStateClass:
31
+ """ESPHome SensorStateClass enum values."""
32
+ NONE = 0
33
+ MEASUREMENT = 1
34
+ TOTAL_INCREASING = 2
35
+ TOTAL = 3
36
+
37
+
38
+ class SensorEntity(ESPHomeEntity):
39
+ """Sensor entity for ESPHome (read-only numeric values)."""
40
+
41
+ def __init__(
42
+ self,
43
+ server: APIServer,
44
+ key: int,
45
+ name: str,
46
+ object_id: str,
47
+ icon: str = "",
48
+ unit_of_measurement: str = "",
49
+ accuracy_decimals: int = 2,
50
+ device_class: str = "",
51
+ state_class: int = SensorStateClass.NONE,
52
+ entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
53
+ value_getter: Optional[Callable[[], float]] = None,
54
+ ) -> None:
55
+ ESPHomeEntity.__init__(self, server)
56
+ self.key = key
57
+ self.name = name
58
+ self.object_id = object_id
59
+ self.icon = icon
60
+ self.unit_of_measurement = unit_of_measurement
61
+ self.accuracy_decimals = accuracy_decimals
62
+ self.device_class = device_class
63
+ self.entity_category = entity_category
64
+ # Convert string state_class to int if needed (for backward compatibility)
65
+ if isinstance(state_class, str):
66
+ state_class_map = {
67
+ "": SensorStateClass.NONE,
68
+ "measurement": SensorStateClass.MEASUREMENT,
69
+ "total_increasing": SensorStateClass.TOTAL_INCREASING,
70
+ "total": SensorStateClass.TOTAL,
71
+ }
72
+ self.state_class = state_class_map.get(state_class.lower(), SensorStateClass.NONE)
73
+ else:
74
+ self.state_class = state_class
75
+ self._value_getter = value_getter
76
+ self._value = 0.0
77
+
78
+ @property
79
+ def value(self) -> float:
80
+ if self._value_getter:
81
+ return self._value_getter()
82
+ return self._value
83
+
84
+ @value.setter
85
+ def value(self, new_value: float) -> None:
86
+ self._value = new_value
87
+
88
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
89
+ if isinstance(msg, ListEntitiesRequest):
90
+ yield ListEntitiesSensorResponse(
91
+ object_id=self.object_id,
92
+ key=self.key,
93
+ name=self.name,
94
+ icon=self.icon,
95
+ unit_of_measurement=self.unit_of_measurement,
96
+ accuracy_decimals=self.accuracy_decimals,
97
+ device_class=self.device_class,
98
+ state_class=self.state_class,
99
+ entity_category=self.entity_category,
100
+ )
101
+ elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
102
+ yield self._get_state_message()
103
+
104
+ def _get_state_message(self) -> SensorStateResponse:
105
+ return SensorStateResponse(
106
+ key=self.key,
107
+ state=self.value,
108
+ missing_state=False,
109
+ )
110
+
111
+ def update_state(self) -> None:
112
+ """Send state update to Home Assistant."""
113
+ self.server.send_messages([self._get_state_message()])
114
+
115
+
116
+ class SwitchEntity(ESPHomeEntity):
117
+ """Switch entity for ESPHome (read-write boolean values)."""
118
+
119
+ def __init__(
120
+ self,
121
+ server: APIServer,
122
+ key: int,
123
+ name: str,
124
+ object_id: str,
125
+ icon: str = "",
126
+ device_class: str = "",
127
+ entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
128
+ value_getter: Optional[Callable[[], bool]] = None,
129
+ value_setter: Optional[Callable[[bool], None]] = None,
130
+ ) -> None:
131
+ ESPHomeEntity.__init__(self, server)
132
+ self.key = key
133
+ self.name = name
134
+ self.object_id = object_id
135
+ self.icon = icon
136
+ self.device_class = device_class
137
+ self.entity_category = entity_category
138
+ self._value_getter = value_getter
139
+ self._value_setter = value_setter
140
+ self._value = False
141
+
142
+ @property
143
+ def value(self) -> bool:
144
+ if self._value_getter:
145
+ return self._value_getter()
146
+ return self._value
147
+
148
+ @value.setter
149
+ def value(self, new_value: bool) -> None:
150
+ if self._value_setter:
151
+ self._value_setter(new_value)
152
+ self._value = new_value
153
+
154
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
155
+ if isinstance(msg, ListEntitiesRequest):
156
+ yield ListEntitiesSwitchResponse(
157
+ object_id=self.object_id,
158
+ key=self.key,
159
+ name=self.name,
160
+ icon=self.icon,
161
+ device_class=self.device_class,
162
+ entity_category=self.entity_category,
163
+ )
164
+ elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
165
+ yield self._get_state_message()
166
+ elif isinstance(msg, SwitchCommandRequest) and msg.key == self.key:
167
+ self.value = msg.state
168
+ yield self._get_state_message()
169
+
170
+ def _get_state_message(self) -> SwitchStateResponse:
171
+ return SwitchStateResponse(
172
+ key=self.key,
173
+ state=self.value,
174
+ )
175
+
176
+ def update_state(self) -> None:
177
+ """Send state update to Home Assistant."""
178
+ self.server.send_messages([self._get_state_message()])
179
+
180
+
181
+ class SelectEntity(ESPHomeEntity):
182
+ """Select entity for ESPHome (read-write string selection)."""
183
+
184
+ def __init__(
185
+ self,
186
+ server: APIServer,
187
+ key: int,
188
+ name: str,
189
+ object_id: str,
190
+ options: List[str],
191
+ icon: str = "",
192
+ entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
193
+ value_getter: Optional[Callable[[], str]] = None,
194
+ value_setter: Optional[Callable[[str], None]] = None,
195
+ ) -> None:
196
+ ESPHomeEntity.__init__(self, server)
197
+ self.key = key
198
+ self.name = name
199
+ self.object_id = object_id
200
+ self.options = options
201
+ self.icon = icon
202
+ self.entity_category = entity_category
203
+ self._value_getter = value_getter
204
+ self._value_setter = value_setter
205
+ self._value = options[0] if options else ""
206
+
207
+ @property
208
+ def value(self) -> str:
209
+ if self._value_getter:
210
+ return self._value_getter()
211
+ return self._value
212
+
213
+ @value.setter
214
+ def value(self, new_value: str) -> None:
215
+ if new_value in self.options:
216
+ if self._value_setter:
217
+ self._value_setter(new_value)
218
+ self._value = new_value
219
+ else:
220
+ logger.warning(f"Invalid option '{new_value}' for {self.name}")
221
+
222
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
223
+ if isinstance(msg, ListEntitiesRequest):
224
+ yield ListEntitiesSelectResponse(
225
+ object_id=self.object_id,
226
+ key=self.key,
227
+ name=self.name,
228
+ icon=self.icon,
229
+ options=self.options,
230
+ entity_category=self.entity_category,
231
+ )
232
+ elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
233
+ yield self._get_state_message()
234
+ elif isinstance(msg, SelectCommandRequest) and msg.key == self.key:
235
+ self.value = msg.state
236
+ yield self._get_state_message()
237
+
238
+ def _get_state_message(self) -> SelectStateResponse:
239
+ return SelectStateResponse(
240
+ key=self.key,
241
+ state=self.value,
242
+ missing_state=False,
243
+ )
244
+
245
+ def update_state(self) -> None:
246
+ """Send state update to Home Assistant."""
247
+ self.server.send_messages([self._get_state_message()])
248
+
249
+
250
+ class ButtonEntity(ESPHomeEntity):
251
+ """Button entity for ESPHome (trigger actions)."""
252
+
253
+ def __init__(
254
+ self,
255
+ server: APIServer,
256
+ key: int,
257
+ name: str,
258
+ object_id: str,
259
+ icon: str = "",
260
+ device_class: str = "",
261
+ entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
262
+ on_press: Optional[Callable[[], None]] = None,
263
+ ) -> None:
264
+ ESPHomeEntity.__init__(self, server)
265
+ self.key = key
266
+ self.name = name
267
+ self.object_id = object_id
268
+ self.icon = icon
269
+ self.device_class = device_class
270
+ self.entity_category = entity_category
271
+ self._on_press = on_press
272
+
273
+ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
274
+ if isinstance(msg, ListEntitiesRequest):
275
+ yield ListEntitiesButtonResponse(
276
+ object_id=self.object_id,
277
+ key=self.key,
278
+ name=self.name,
279
+ icon=self.icon,
280
+ device_class=self.device_class,
281
+ entity_category=self.entity_category,
282
+ )
283
+ elif isinstance(msg, ButtonCommandRequest) and msg.key == self.key:
284
+ if self._on_press:
285
+ try:
286
+ self._on_press()
287
+ logger.info(f"Button '{self.name}' pressed")
288
+ except Exception as e:
289
+ logger.error(f"Error executing button '{self.name}': {e}")
290
+ # Buttons don't have state responses
291
+ return
292
+ yield # Make this a generator
reachy_mini_ha_voice/main.py CHANGED
@@ -1,175 +1,175 @@
1
- """
2
- Reachy Mini Home Assistant Voice Assistant Application
3
-
4
- This is the main entry point for the Reachy Mini application that integrates
5
- with Home Assistant via ESPHome protocol for voice control.
6
- """
7
-
8
- import asyncio
9
- import logging
10
- import socket
11
- import threading
12
- from typing import Optional
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
-
17
- def _check_zenoh_available(timeout: float = 1.0) -> bool:
18
- """Check if Zenoh service is available."""
19
- try:
20
- with socket.create_connection(("127.0.0.1", 7447), timeout=timeout):
21
- return True
22
- except (socket.timeout, ConnectionRefusedError, OSError):
23
- return False
24
-
25
-
26
- # Only import ReachyMiniApp if we're running as an app
27
- try:
28
- from reachy_mini import ReachyMini, ReachyMiniApp
29
- REACHY_MINI_AVAILABLE = True
30
- except ImportError:
31
- REACHY_MINI_AVAILABLE = False
32
-
33
- # Create a dummy base class
34
- class ReachyMiniApp:
35
- custom_app_url = None
36
-
37
- def __init__(self):
38
- self.stop_event = threading.Event()
39
-
40
- def wrapped_run(self, *args, **kwargs):
41
- pass
42
-
43
- def stop(self):
44
- self.stop_event.set()
45
-
46
- ReachyMini = None
47
-
48
-
49
- from .voice_assistant import VoiceAssistantService
50
- from .motion import ReachyMiniMotion
51
-
52
-
53
- class ReachyMiniHAVoiceApp(ReachyMiniApp):
54
- """
55
- Reachy Mini Home Assistant Voice Assistant Application.
56
-
57
- This app runs an ESPHome-compatible voice satellite that connects
58
- to Home Assistant for STT/TTS processing while providing local
59
- wake word detection and robot motion feedback.
60
- """
61
-
62
- # No custom web UI needed - configuration is automatic via Home Assistant
63
- custom_app_url: Optional[str] = None
64
-
65
- def __init__(self, *args, **kwargs):
66
- """Initialize the app."""
67
- super().__init__(*args, **kwargs)
68
- if not hasattr(self, 'stop_event'):
69
- self.stop_event = threading.Event()
70
-
71
- def wrapped_run(self, *args, **kwargs) -> None:
72
- """
73
- Override wrapped_run to handle Zenoh connection failures gracefully.
74
-
75
- If Zenoh is not available, run in standalone mode without robot control.
76
- """
77
- logger.info("Starting Reachy Mini HA Voice App...")
78
-
79
- # Check if Zenoh is available before trying to connect
80
- if not _check_zenoh_available():
81
- logger.warning("Zenoh service not available (port 7447)")
82
- logger.info("Running in standalone mode without robot control")
83
- self._run_standalone()
84
- return
85
-
86
- # Zenoh is available, try normal startup with ReachyMini
87
- if REACHY_MINI_AVAILABLE:
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()
101
- else:
102
- raise
103
- else:
104
- logger.info("Reachy Mini SDK not available, running standalone")
105
- self._run_standalone()
106
-
107
- def _run_standalone(self) -> None:
108
- """Run in standalone mode without robot."""
109
- self.run(None, self.stop_event)
110
-
111
- def run(self, reachy_mini, stop_event: threading.Event) -> None:
112
- """
113
- Main application entry point.
114
-
115
- Args:
116
- reachy_mini: The Reachy Mini robot instance (can be None)
117
- stop_event: Event to signal graceful shutdown
118
- """
119
- logger.info("Starting Home Assistant Voice Assistant...")
120
-
121
- # Create and run the voice assistant service
122
- service = VoiceAssistantService(reachy_mini)
123
-
124
- # Run the async service in an event loop
125
- loop = asyncio.new_event_loop()
126
- asyncio.set_event_loop(loop)
127
-
128
- try:
129
- loop.run_until_complete(service.start())
130
-
131
- logger.info("=" * 50)
132
- logger.info("Home Assistant Voice Assistant Started!")
133
- logger.info("=" * 50)
134
- logger.info("ESPHome Server: 0.0.0.0:6053")
135
- logger.info("Camera Server: 0.0.0.0:8081")
136
- logger.info("Wake word: Okay Nabu")
137
- if reachy_mini:
138
- logger.info("Motion control: enabled")
139
- logger.info("Camera: enabled (Reachy Mini)")
140
- else:
141
- logger.info("Motion control: disabled (no robot)")
142
- logger.info("Camera: test pattern (no robot)")
143
- logger.info("=" * 50)
144
- logger.info("To connect from Home Assistant:")
145
- logger.info(" Settings -> Devices & Services -> Add Integration")
146
- logger.info(" -> ESPHome -> Enter this device's IP:6053")
147
- logger.info(" -> Generic Camera -> http://<ip>:8081/stream")
148
- logger.info("=" * 50)
149
-
150
- # Wait for stop signal
151
- while not stop_event.is_set():
152
- loop.run_until_complete(asyncio.sleep(0.5))
153
-
154
- except Exception as e:
155
- logger.error(f"Error running voice assistant: {e}")
156
- raise
157
- finally:
158
- logger.info("Shutting down voice assistant...")
159
- loop.run_until_complete(service.stop())
160
- loop.close()
161
- logger.info("Voice assistant stopped.")
162
-
163
-
164
- # This is called when running as: python -m reachy_mini_ha_voice.main
165
- if __name__ == "__main__":
166
- logging.basicConfig(
167
- level=logging.INFO,
168
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
169
- )
170
-
171
- app = ReachyMiniHAVoiceApp()
172
- try:
173
- app.wrapped_run()
174
- except KeyboardInterrupt:
175
- app.stop()
 
1
+ """
2
+ Reachy Mini Home Assistant Voice Assistant Application
3
+
4
+ This is the main entry point for the Reachy Mini application that integrates
5
+ with Home Assistant via ESPHome protocol for voice control.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import socket
11
+ import threading
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _check_zenoh_available(timeout: float = 1.0) -> bool:
18
+ """Check if Zenoh service is available."""
19
+ try:
20
+ with socket.create_connection(("127.0.0.1", 7447), timeout=timeout):
21
+ return True
22
+ except (socket.timeout, ConnectionRefusedError, OSError):
23
+ return False
24
+
25
+
26
+ # Only import ReachyMiniApp if we're running as an app
27
+ try:
28
+ from reachy_mini import ReachyMini, ReachyMiniApp
29
+ REACHY_MINI_AVAILABLE = True
30
+ except ImportError:
31
+ REACHY_MINI_AVAILABLE = False
32
+
33
+ # Create a dummy base class
34
+ class ReachyMiniApp:
35
+ custom_app_url = None
36
+
37
+ def __init__(self):
38
+ self.stop_event = threading.Event()
39
+
40
+ def wrapped_run(self, *args, **kwargs):
41
+ pass
42
+
43
+ def stop(self):
44
+ self.stop_event.set()
45
+
46
+ ReachyMini = None
47
+
48
+
49
+ from .voice_assistant import VoiceAssistantService
50
+ from .motion import ReachyMiniMotion
51
+
52
+
53
+ class ReachyMiniHaVoice(ReachyMiniApp):
54
+ """
55
+ Reachy Mini Home Assistant Voice Assistant Application.
56
+
57
+ This app runs an ESPHome-compatible voice satellite that connects
58
+ to Home Assistant for STT/TTS processing while providing local
59
+ wake word detection and robot motion feedback.
60
+ """
61
+
62
+ # No custom web UI needed - configuration is automatic via Home Assistant
63
+ custom_app_url: Optional[str] = None
64
+
65
+ def __init__(self, *args, **kwargs):
66
+ """Initialize the app."""
67
+ super().__init__(*args, **kwargs)
68
+ if not hasattr(self, 'stop_event'):
69
+ self.stop_event = threading.Event()
70
+
71
+ def wrapped_run(self, *args, **kwargs) -> None:
72
+ """
73
+ Override wrapped_run to handle Zenoh connection failures gracefully.
74
+
75
+ If Zenoh is not available, run in standalone mode without robot control.
76
+ """
77
+ logger.info("Starting Reachy Mini HA Voice App...")
78
+
79
+ # Check if Zenoh is available before trying to connect
80
+ if not _check_zenoh_available():
81
+ logger.warning("Zenoh service not available (port 7447)")
82
+ logger.info("Running in standalone mode without robot control")
83
+ self._run_standalone()
84
+ return
85
+
86
+ # Zenoh is available, try normal startup with ReachyMini
87
+ if REACHY_MINI_AVAILABLE:
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()
101
+ else:
102
+ raise
103
+ else:
104
+ logger.info("Reachy Mini SDK not available, running standalone")
105
+ self._run_standalone()
106
+
107
+ def _run_standalone(self) -> None:
108
+ """Run in standalone mode without robot."""
109
+ self.run(None, self.stop_event)
110
+
111
+ def run(self, reachy_mini, stop_event: threading.Event) -> None:
112
+ """
113
+ Main application entry point.
114
+
115
+ Args:
116
+ reachy_mini: The Reachy Mini robot instance (can be None)
117
+ stop_event: Event to signal graceful shutdown
118
+ """
119
+ logger.info("Starting Home Assistant Voice Assistant...")
120
+
121
+ # Create and run the voice assistant service
122
+ service = VoiceAssistantService(reachy_mini)
123
+
124
+ # Run the async service in an event loop
125
+ loop = asyncio.new_event_loop()
126
+ asyncio.set_event_loop(loop)
127
+
128
+ try:
129
+ loop.run_until_complete(service.start())
130
+
131
+ logger.info("=" * 50)
132
+ logger.info("Home Assistant Voice Assistant Started!")
133
+ logger.info("=" * 50)
134
+ logger.info("ESPHome Server: 0.0.0.0:6053")
135
+ logger.info("Camera Server: 0.0.0.0:8081")
136
+ logger.info("Wake word: Okay Nabu")
137
+ if reachy_mini:
138
+ logger.info("Motion control: enabled")
139
+ logger.info("Camera: enabled (Reachy Mini)")
140
+ else:
141
+ logger.info("Motion control: disabled (no robot)")
142
+ logger.info("Camera: test pattern (no robot)")
143
+ logger.info("=" * 50)
144
+ logger.info("To connect from Home Assistant:")
145
+ logger.info(" Settings -> Devices & Services -> Add Integration")
146
+ logger.info(" -> ESPHome -> Enter this device's IP:6053")
147
+ logger.info(" -> Generic Camera -> http://<ip>:8081/stream")
148
+ logger.info("=" * 50)
149
+
150
+ # Wait for stop signal
151
+ while not stop_event.is_set():
152
+ loop.run_until_complete(asyncio.sleep(0.5))
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error running voice assistant: {e}")
156
+ raise
157
+ finally:
158
+ logger.info("Shutting down voice assistant...")
159
+ loop.run_until_complete(service.stop())
160
+ loop.close()
161
+ logger.info("Voice assistant stopped.")
162
+
163
+
164
+ # This is called when running as: python -m reachy_mini_ha_voice.main
165
+ if __name__ == "__main__":
166
+ logging.basicConfig(
167
+ level=logging.INFO,
168
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
169
+ )
170
+
171
+ app = ReachyMiniHaVoice()
172
+ try:
173
+ app.wrapped_run()
174
+ except KeyboardInterrupt:
175
+ app.stop()
reachy_mini_ha_voice/reachy_controller.py CHANGED
@@ -1,1008 +1,1008 @@
1
- """Reachy Mini controller wrapper for ESPHome entities."""
2
-
3
- import logging
4
- from typing import Optional, TYPE_CHECKING
5
- import math
6
- import numpy as np
7
- from scipy.spatial.transform import Rotation as R
8
- import requests
9
-
10
- if TYPE_CHECKING:
11
- from reachy_mini import ReachyMini
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- class ReachyController:
17
- """
18
- Wrapper class for Reachy Mini control operations.
19
-
20
- Provides safe access to Reachy Mini SDK functions with error handling
21
- and fallback for standalone mode (when robot is not available).
22
- """
23
-
24
- def __init__(self, reachy_mini: Optional["ReachyMini"] = None):
25
- """
26
- Initialize the controller.
27
-
28
- Args:
29
- reachy_mini: ReachyMini instance, or None for standalone mode
30
- """
31
- self.reachy = reachy_mini
32
- self._speaker_volume = 100 # Default volume
33
-
34
- @property
35
- def is_available(self) -> bool:
36
- """Check if robot is available."""
37
- return self.reachy is not None
38
-
39
- # ========== Phase 1: Basic Status & Volume ==========
40
-
41
- def get_daemon_state(self) -> str:
42
- """Get daemon state."""
43
- if not self.is_available:
44
- return "not_available"
45
- try:
46
- # client.get_status() returns a dict with 'state' key
47
- status = self.reachy.client.get_status(wait=False)
48
- return status.get('state', 'unknown')
49
- except Exception as e:
50
- logger.error(f"Error getting daemon state: {e}")
51
- return "error"
52
-
53
- def get_backend_ready(self) -> bool:
54
- """Check if backend is ready."""
55
- if not self.is_available:
56
- return False
57
- try:
58
- # Check if daemon state is 'running'
59
- status = self.reachy.client.get_status(wait=False)
60
- return status.get('state') == 'running'
61
- except Exception as e:
62
- logger.error(f"Error getting backend status: {e}")
63
- return False
64
-
65
- def get_error_message(self) -> str:
66
- """Get current error message."""
67
- if not self.is_available:
68
- return "Robot not available"
69
- try:
70
- status = self.reachy.client.get_status(wait=False)
71
- return status.get('error') or ""
72
- except Exception as e:
73
- logger.error(f"Error getting error message: {e}")
74
- return str(e)
75
-
76
- def get_speaker_volume(self) -> float:
77
- """Get speaker volume (0-100)."""
78
- if not self.is_available:
79
- return self._speaker_volume
80
- try:
81
- # Get volume from daemon API
82
- status = self.reachy.client.get_status(wait=False)
83
- wlan_ip = status.get('wlan_ip', 'localhost')
84
- response = requests.get(f"http://{wlan_ip}:8000/api/volume/current", timeout=2)
85
- if response.status_code == 200:
86
- data = response.json()
87
- self._speaker_volume = float(data.get('volume', self._speaker_volume))
88
- except Exception as e:
89
- logger.debug(f"Could not get volume from API: {e}")
90
- return self._speaker_volume
91
-
92
- def set_speaker_volume(self, volume: float) -> None:
93
- """
94
- Set speaker volume (0-100).
95
-
96
- Args:
97
- volume: Volume level 0-100
98
- """
99
- volume = max(0.0, min(100.0, volume))
100
- self._speaker_volume = volume
101
-
102
- if not self.is_available:
103
- logger.warning("Cannot set volume: robot not available")
104
- return
105
-
106
- try:
107
- # Set volume via daemon API
108
- status = self.reachy.client.get_status(wait=False)
109
- wlan_ip = status.get('wlan_ip', 'localhost')
110
- response = requests.post(
111
- f"http://{wlan_ip}:8000/api/volume/set",
112
- json={"volume": int(volume)},
113
- timeout=5
114
- )
115
- if response.status_code == 200:
116
- logger.info(f"Speaker volume set to {volume}%")
117
- else:
118
- logger.error(f"Failed to set volume: {response.status_code} {response.text}")
119
- except Exception as e:
120
- logger.error(f"Error setting speaker volume: {e}")
121
-
122
- def get_microphone_volume(self) -> float:
123
- """Get microphone volume (0-100) using local SDK audio interface."""
124
- respeaker = self._get_respeaker()
125
- if respeaker is None:
126
- return getattr(self, '_microphone_volume', 50.0)
127
-
128
- try:
129
- # Try APMGR_MICGAIN first (0-31 range)
130
- result = respeaker.read("APMGR_MICGAIN")
131
- if result is not None:
132
- # Convert 0-31 to 0-100%
133
- self._microphone_volume = (result[0] / 31.0) * 100.0
134
- return self._microphone_volume
135
- except Exception as e:
136
- logger.debug(f"Could not get microphone volume: {e}")
137
-
138
- return getattr(self, '_microphone_volume', 50.0)
139
-
140
- def set_microphone_volume(self, volume: float) -> None:
141
- """
142
- Set microphone volume (0-100) using local SDK audio interface.
143
-
144
- Args:
145
- volume: Volume level 0-100
146
- """
147
- volume = max(0.0, min(100.0, volume))
148
- self._microphone_volume = volume
149
-
150
- respeaker = self._get_respeaker()
151
- if respeaker is None:
152
- logger.warning("Cannot set microphone volume: ReSpeaker not available")
153
- return
154
-
155
- try:
156
- # Convert 0-100% to 0-31 range for APMGR_MICGAIN
157
- gain = int((volume / 100.0) * 31.0)
158
- respeaker.write("APMGR_MICGAIN", [gain])
159
- logger.info(f"Microphone volume set to {volume}% (gain: {gain})")
160
- except Exception as e:
161
- logger.error(f"Failed to set microphone volume: {e}")
162
-
163
- # ========== Phase 2: Motor Control ==========
164
-
165
- def get_motors_enabled(self) -> bool:
166
- """Check if motors are enabled."""
167
- if not self.is_available:
168
- return False
169
- try:
170
- # Get motor control mode from backend status
171
- status = self.reachy.client.get_status(wait=False)
172
- backend_status = status.get('backend_status')
173
- if backend_status and isinstance(backend_status, dict):
174
- motor_mode = backend_status.get('motor_control_mode', 'disabled')
175
- return motor_mode == 'enabled'
176
- return status.get('state') == 'running'
177
- except Exception as e:
178
- logger.error(f"Error getting motor state: {e}")
179
- return False
180
-
181
- def set_motors_enabled(self, enabled: bool) -> None:
182
- """
183
- Enable or disable motors.
184
-
185
- Args:
186
- enabled: True to enable, False to disable
187
- """
188
- if not self.is_available:
189
- logger.warning("Cannot control motors: robot not available")
190
- return
191
-
192
- try:
193
- if enabled:
194
- self.reachy.enable_motors()
195
- logger.info("Motors enabled")
196
- else:
197
- self.reachy.disable_motors()
198
- logger.info("Motors disabled")
199
- except Exception as e:
200
- logger.error(f"Error setting motor state: {e}")
201
-
202
- def get_motor_mode(self) -> str:
203
- """Get current motor control mode."""
204
- if not self.is_available:
205
- return "disabled"
206
- try:
207
- # Get motor control mode from backend status
208
- status = self.reachy.client.get_status(wait=False)
209
- backend_status = status.get('backend_status')
210
- if backend_status and isinstance(backend_status, dict):
211
- motor_mode = backend_status.get('motor_control_mode', 'disabled')
212
- return motor_mode
213
- if status.get('state') == 'running':
214
- return "enabled"
215
- return "disabled"
216
- except Exception as e:
217
- logger.error(f"Error getting motor mode: {e}")
218
- return "error"
219
-
220
- def set_motor_mode(self, mode: str) -> None:
221
- """
222
- Set motor control mode.
223
-
224
- Args:
225
- mode: One of "enabled", "disabled", "gravity_compensation"
226
- """
227
- if not self.is_available:
228
- logger.warning("Cannot set motor mode: robot not available")
229
- return
230
-
231
- try:
232
- if mode == "enabled":
233
- self.reachy.enable_motors()
234
- elif mode == "disabled":
235
- self.reachy.disable_motors()
236
- elif mode == "gravity_compensation":
237
- self.reachy.enable_gravity_compensation()
238
- else:
239
- logger.warning(f"Invalid motor mode: {mode}")
240
- return
241
- logger.info(f"Motor mode set to {mode}")
242
- except Exception as e:
243
- logger.error(f"Error setting motor mode: {e}")
244
-
245
- def wake_up(self) -> None:
246
- """Execute wake up animation."""
247
- if not self.is_available:
248
- logger.warning("Cannot wake up: robot not available")
249
- return
250
-
251
- try:
252
- self.reachy.wake_up()
253
- logger.info("Wake up animation executed")
254
- except Exception as e:
255
- logger.error(f"Error executing wake up: {e}")
256
-
257
- def go_to_sleep(self) -> None:
258
- """Execute sleep animation."""
259
- if not self.is_available:
260
- logger.warning("Cannot sleep: robot not available")
261
- return
262
-
263
- try:
264
- self.reachy.goto_sleep()
265
- logger.info("Sleep animation executed")
266
- except Exception as e:
267
- logger.error(f"Error executing sleep: {e}")
268
-
269
- # ========== Phase 3: Pose Control ==========
270
-
271
- def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
272
- """
273
- Extract position (x, y, z) and rotation (roll, pitch, yaw) from 4x4 pose matrix.
274
-
275
- Args:
276
- pose_matrix: 4x4 homogeneous transformation matrix
277
-
278
- Returns:
279
- tuple: (x, y, z, roll, pitch, yaw) where position is in meters and angles in radians
280
- """
281
- # Extract position from the last column
282
- x = pose_matrix[0, 3]
283
- y = pose_matrix[1, 3]
284
- z = pose_matrix[2, 3]
285
-
286
- # Extract rotation matrix and convert to euler angles
287
- rotation_matrix = pose_matrix[:3, :3]
288
- rotation = R.from_matrix(rotation_matrix)
289
- # Use 'xyz' convention for roll, pitch, yaw
290
- roll, pitch, yaw = rotation.as_euler('xyz')
291
-
292
- return x, y, z, roll, pitch, yaw
293
-
294
- def get_head_x(self) -> float:
295
- """Get head X position in mm."""
296
- if not self.is_available:
297
- return 0.0
298
- try:
299
- pose = self.reachy.get_current_head_pose()
300
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
301
- return x * 1000 # Convert m to mm
302
- except Exception as e:
303
- logger.error(f"Error getting head X: {e}")
304
- return 0.0
305
-
306
- def set_head_x(self, x_mm: float) -> None:
307
- """Set head X position in mm."""
308
- if not self.is_available:
309
- return
310
- try:
311
- pose = self.reachy.get_current_head_pose()
312
- # Modify the X position in the matrix
313
- new_pose = pose.copy()
314
- new_pose[0, 3] = x_mm / 1000 # Convert mm to m
315
- self.reachy.goto_target(head=new_pose)
316
- except Exception as e:
317
- logger.error(f"Error setting head X: {e}")
318
-
319
- def get_head_y(self) -> float:
320
- """Get head Y position in mm."""
321
- if not self.is_available:
322
- return 0.0
323
- try:
324
- pose = self.reachy.get_current_head_pose()
325
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
326
- return y * 1000
327
- except Exception as e:
328
- logger.error(f"Error getting head Y: {e}")
329
- return 0.0
330
-
331
- def set_head_y(self, y_mm: float) -> None:
332
- """Set head Y position in mm."""
333
- if not self.is_available:
334
- return
335
- try:
336
- pose = self.reachy.get_current_head_pose()
337
- new_pose = pose.copy()
338
- new_pose[1, 3] = y_mm / 1000
339
- self.reachy.goto_target(head=new_pose)
340
- except Exception as e:
341
- logger.error(f"Error setting head Y: {e}")
342
-
343
- def get_head_z(self) -> float:
344
- """Get head Z position in mm."""
345
- if not self.is_available:
346
- return 0.0
347
- try:
348
- pose = self.reachy.get_current_head_pose()
349
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
350
- return z * 1000
351
- except Exception as e:
352
- logger.error(f"Error getting head Z: {e}")
353
- return 0.0
354
-
355
- def set_head_z(self, z_mm: float) -> None:
356
- """Set head Z position in mm."""
357
- if not self.is_available:
358
- return
359
- try:
360
- pose = self.reachy.get_current_head_pose()
361
- new_pose = pose.copy()
362
- new_pose[2, 3] = z_mm / 1000
363
- self.reachy.goto_target(head=new_pose)
364
- except Exception as e:
365
- logger.error(f"Error setting head Z: {e}")
366
-
367
- def get_head_roll(self) -> float:
368
- """Get head roll angle in degrees."""
369
- if not self.is_available:
370
- return 0.0
371
- try:
372
- pose = self.reachy.get_current_head_pose()
373
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
374
- return math.degrees(roll)
375
- except Exception as e:
376
- logger.error(f"Error getting head roll: {e}")
377
- return 0.0
378
-
379
- def set_head_roll(self, roll_deg: float) -> None:
380
- """Set head roll angle in degrees."""
381
- if not self.is_available:
382
- return
383
- try:
384
- pose = self.reachy.get_current_head_pose()
385
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
386
- # Create new rotation with updated roll
387
- new_rotation = R.from_euler('xyz', [math.radians(roll_deg), pitch, yaw])
388
- new_pose = pose.copy()
389
- new_pose[:3, :3] = new_rotation.as_matrix()
390
- self.reachy.goto_target(head=new_pose)
391
- except Exception as e:
392
- logger.error(f"Error setting head roll: {e}")
393
-
394
- def get_head_pitch(self) -> float:
395
- """Get head pitch angle in degrees."""
396
- if not self.is_available:
397
- return 0.0
398
- try:
399
- pose = self.reachy.get_current_head_pose()
400
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
401
- return math.degrees(pitch)
402
- except Exception as e:
403
- logger.error(f"Error getting head pitch: {e}")
404
- return 0.0
405
-
406
- def set_head_pitch(self, pitch_deg: float) -> None:
407
- """Set head pitch angle in degrees."""
408
- if not self.is_available:
409
- return
410
- try:
411
- pose = self.reachy.get_current_head_pose()
412
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
413
- new_rotation = R.from_euler('xyz', [roll, math.radians(pitch_deg), yaw])
414
- new_pose = pose.copy()
415
- new_pose[:3, :3] = new_rotation.as_matrix()
416
- self.reachy.goto_target(head=new_pose)
417
- except Exception as e:
418
- logger.error(f"Error setting head pitch: {e}")
419
-
420
- def get_head_yaw(self) -> float:
421
- """Get head yaw angle in degrees."""
422
- if not self.is_available:
423
- return 0.0
424
- try:
425
- pose = self.reachy.get_current_head_pose()
426
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
427
- return math.degrees(yaw)
428
- except Exception as e:
429
- logger.error(f"Error getting head yaw: {e}")
430
- return 0.0
431
-
432
- def set_head_yaw(self, yaw_deg: float) -> None:
433
- """Set head yaw angle in degrees."""
434
- if not self.is_available:
435
- return
436
- try:
437
- pose = self.reachy.get_current_head_pose()
438
- x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
439
- new_rotation = R.from_euler('xyz', [roll, pitch, math.radians(yaw_deg)])
440
- new_pose = pose.copy()
441
- new_pose[:3, :3] = new_rotation.as_matrix()
442
- self.reachy.goto_target(head=new_pose)
443
- except Exception as e:
444
- logger.error(f"Error setting head yaw: {e}")
445
-
446
- def get_body_yaw(self) -> float:
447
- """Get body yaw angle in degrees."""
448
- if not self.is_available:
449
- return 0.0
450
- try:
451
- # Body yaw is the first element of head joint positions
452
- head_joints, _ = self.reachy.get_current_joint_positions()
453
- return math.degrees(head_joints[0])
454
- except Exception as e:
455
- logger.error(f"Error getting body yaw: {e}")
456
- return 0.0
457
-
458
- def set_body_yaw(self, yaw_deg: float) -> None:
459
- """Set body yaw angle in degrees."""
460
- if not self.is_available:
461
- return
462
- try:
463
- self.reachy.goto_target(body_yaw=math.radians(yaw_deg))
464
- except Exception as e:
465
- logger.error(f"Error setting body yaw: {e}")
466
-
467
- def get_antenna_left(self) -> float:
468
- """Get left antenna angle in degrees."""
469
- if not self.is_available:
470
- return 0.0
471
- try:
472
- # get_current_joint_positions() returns (head_joints, antenna_joints)
473
- # antenna_joints is [right, left]
474
- _, antennas = self.reachy.get_current_joint_positions()
475
- return math.degrees(antennas[1]) # left is index 1
476
- except Exception as e:
477
- logger.error(f"Error getting left antenna: {e}")
478
- return 0.0
479
-
480
- def set_antenna_left(self, angle_deg: float) -> None:
481
- """Set left antenna angle in degrees."""
482
- if not self.is_available:
483
- return
484
- try:
485
- _, antennas = self.reachy.get_current_joint_positions()
486
- right = antennas[0]
487
- self.reachy.goto_target(antennas=[right, math.radians(angle_deg)])
488
- except Exception as e:
489
- logger.error(f"Error setting left antenna: {e}")
490
-
491
- def get_antenna_right(self) -> float:
492
- """Get right antenna angle in degrees."""
493
- if not self.is_available:
494
- return 0.0
495
- try:
496
- _, antennas = self.reachy.get_current_joint_positions()
497
- return math.degrees(antennas[0]) # right is index 0
498
- except Exception as e:
499
- logger.error(f"Error getting right antenna: {e}")
500
- return 0.0
501
-
502
- def set_antenna_right(self, angle_deg: float) -> None:
503
- """Set right antenna angle in degrees."""
504
- if not self.is_available:
505
- return
506
- try:
507
- _, antennas = self.reachy.get_current_joint_positions()
508
- left = antennas[1]
509
- self.reachy.goto_target(antennas=[math.radians(angle_deg), left])
510
- except Exception as e:
511
- logger.error(f"Error setting right antenna: {e}")
512
-
513
- # ========== Phase 4: Look At Control ==========
514
-
515
- def get_look_at_x(self) -> float:
516
- """Get look at target X coordinate in world frame (meters)."""
517
- # This is a target position, not a current state
518
- # We'll store it internally
519
- return getattr(self, '_look_at_x', 0.0)
520
-
521
- def set_look_at_x(self, x: float) -> None:
522
- """Set look at target X coordinate."""
523
- self._look_at_x = x
524
- self._update_look_at()
525
-
526
- def get_look_at_y(self) -> float:
527
- """Get look at target Y coordinate in world frame (meters)."""
528
- return getattr(self, '_look_at_y', 0.0)
529
-
530
- def set_look_at_y(self, y: float) -> None:
531
- """Set look at target Y coordinate."""
532
- self._look_at_y = y
533
- self._update_look_at()
534
-
535
- def get_look_at_z(self) -> float:
536
- """Get look at target Z coordinate in world frame (meters)."""
537
- return getattr(self, '_look_at_z', 0.0)
538
-
539
- def set_look_at_z(self, z: float) -> None:
540
- """Set look at target Z coordinate."""
541
- self._look_at_z = z
542
- self._update_look_at()
543
-
544
- def _update_look_at(self) -> None:
545
- """Update robot to look at the target coordinates."""
546
- if not self.is_available:
547
- return
548
- try:
549
- x = getattr(self, '_look_at_x', 0.0)
550
- y = getattr(self, '_look_at_y', 0.0)
551
- z = getattr(self, '_look_at_z', 0.0)
552
- self.reachy.look_at_world(x, y, z)
553
- logger.info(f"Looking at world coordinates: ({x}, {y}, {z})")
554
- except Exception as e:
555
- logger.error(f"Error updating look at: {e}")
556
-
557
- # ========== Phase 5: Audio Sensors ==========
558
-
559
- def get_doa_angle(self) -> float:
560
- """Get direction of arrival angle in degrees."""
561
- if not self.is_available:
562
- return 0.0
563
- try:
564
- # Access DOA through media_manager
565
- doa_result = self.reachy.media.get_DoA()
566
- if doa_result is not None:
567
- # Convert radians to degrees
568
- return math.degrees(doa_result[0])
569
- return 0.0
570
- except Exception as e:
571
- logger.error(f"Error getting DOA angle: {e}")
572
- return 0.0
573
-
574
- def get_speech_detected(self) -> bool:
575
- """Check if speech is detected."""
576
- if not self.is_available:
577
- return False
578
- try:
579
- # Access speech detection through media_manager
580
- doa_result = self.reachy.media.get_DoA()
581
- if doa_result is not None:
582
- return doa_result[1]
583
- return False
584
- except Exception as e:
585
- logger.error(f"Error getting speech detection: {e}")
586
- return False
587
-
588
- # ========== Phase 6: Diagnostic Information ==========
589
-
590
- def get_control_loop_frequency(self) -> float:
591
- """Get control loop frequency in Hz."""
592
- if not self.is_available:
593
- return 0.0
594
- try:
595
- # Get control loop stats from backend status
596
- status = self.reachy.client.get_status(wait=False)
597
- backend_status = status.get('backend_status')
598
- if backend_status and isinstance(backend_status, dict):
599
- control_loop_stats = backend_status.get('control_loop_stats', {})
600
- return control_loop_stats.get('mean_control_loop_frequency', 0.0)
601
- return 0.0
602
- except Exception as e:
603
- logger.error(f"Error getting control loop frequency: {e}")
604
- return 0.0
605
-
606
- def get_sdk_version(self) -> str:
607
- """Get SDK version."""
608
- if not self.is_available:
609
- return "N/A"
610
- try:
611
- status = self.reachy.client.get_status(wait=False)
612
- return status.get('version') or "unknown"
613
- except Exception as e:
614
- logger.error(f"Error getting SDK version: {e}")
615
- return "error"
616
-
617
- def get_robot_name(self) -> str:
618
- """Get robot name."""
619
- if not self.is_available:
620
- return "N/A"
621
- try:
622
- status = self.reachy.client.get_status(wait=False)
623
- return status.get('robot_name') or "unknown"
624
- except Exception as e:
625
- logger.error(f"Error getting robot name: {e}")
626
- return "error"
627
-
628
- def get_wireless_version(self) -> bool:
629
- """Check if this is a wireless version."""
630
- if not self.is_available:
631
- return False
632
- try:
633
- status = self.reachy.client.get_status(wait=False)
634
- return status.get('wireless_version', False)
635
- except Exception as e:
636
- logger.error(f"Error getting wireless version: {e}")
637
- return False
638
-
639
- def get_simulation_mode(self) -> bool:
640
- """Check if simulation mode is enabled."""
641
- if not self.is_available:
642
- return False
643
- try:
644
- status = self.reachy.client.get_status(wait=False)
645
- return status.get('simulation_enabled', False)
646
- except Exception as e:
647
- logger.error(f"Error getting simulation mode: {e}")
648
- return False
649
-
650
- def get_wlan_ip(self) -> str:
651
- """Get WLAN IP address."""
652
- if not self.is_available:
653
- return "N/A"
654
- try:
655
- status = self.reachy.client.get_status(wait=False)
656
- return status.get('wlan_ip') or "N/A"
657
- except Exception as e:
658
- logger.error(f"Error getting WLAN IP: {e}")
659
- return "error"
660
-
661
- # ========== Phase 7: IMU Sensors (Wireless only) ==========
662
-
663
- def get_imu_accel_x(self) -> float:
664
- """Get IMU X-axis acceleration in m/s²."""
665
- if not self.is_available:
666
- return 0.0
667
- try:
668
- imu_data = self.reachy.imu
669
- if imu_data is not None and 'accelerometer' in imu_data:
670
- return float(imu_data['accelerometer'][0])
671
- return 0.0
672
- except Exception as e:
673
- logger.error(f"Error getting IMU accel X: {e}")
674
- return 0.0
675
-
676
- def get_imu_accel_y(self) -> float:
677
- """Get IMU Y-axis acceleration in m/s²."""
678
- if not self.is_available:
679
- return 0.0
680
- try:
681
- imu_data = self.reachy.imu
682
- if imu_data is not None and 'accelerometer' in imu_data:
683
- return float(imu_data['accelerometer'][1])
684
- return 0.0
685
- except Exception as e:
686
- logger.error(f"Error getting IMU accel Y: {e}")
687
- return 0.0
688
-
689
- def get_imu_accel_z(self) -> float:
690
- """Get IMU Z-axis acceleration in m/s²."""
691
- if not self.is_available:
692
- return 0.0
693
- try:
694
- imu_data = self.reachy.imu
695
- if imu_data is not None and 'accelerometer' in imu_data:
696
- return float(imu_data['accelerometer'][2])
697
- return 0.0
698
- except Exception as e:
699
- logger.error(f"Error getting IMU accel Z: {e}")
700
- return 0.0
701
-
702
- def get_imu_gyro_x(self) -> float:
703
- """Get IMU X-axis angular velocity in rad/s."""
704
- if not self.is_available:
705
- return 0.0
706
- try:
707
- imu_data = self.reachy.imu
708
- if imu_data is not None and 'gyroscope' in imu_data:
709
- return float(imu_data['gyroscope'][0])
710
- return 0.0
711
- except Exception as e:
712
- logger.error(f"Error getting IMU gyro X: {e}")
713
- return 0.0
714
-
715
- def get_imu_gyro_y(self) -> float:
716
- """Get IMU Y-axis angular velocity in rad/s."""
717
- if not self.is_available:
718
- return 0.0
719
- try:
720
- imu_data = self.reachy.imu
721
- if imu_data is not None and 'gyroscope' in imu_data:
722
- return float(imu_data['gyroscope'][1])
723
- return 0.0
724
- except Exception as e:
725
- logger.error(f"Error getting IMU gyro Y: {e}")
726
- return 0.0
727
-
728
- def get_imu_gyro_z(self) -> float:
729
- """Get IMU Z-axis angular velocity in rad/s."""
730
- if not self.is_available:
731
- return 0.0
732
- try:
733
- imu_data = self.reachy.imu
734
- if imu_data is not None and 'gyroscope' in imu_data:
735
- return float(imu_data['gyroscope'][2])
736
- return 0.0
737
- except Exception as e:
738
- logger.error(f"Error getting IMU gyro Z: {e}")
739
- return 0.0
740
-
741
- def get_imu_temperature(self) -> float:
742
- """Get IMU temperature in °C."""
743
- if not self.is_available:
744
- return 0.0
745
- try:
746
- imu_data = self.reachy.imu
747
- if imu_data is not None and 'temperature' in imu_data:
748
- return float(imu_data['temperature'])
749
- return 0.0
750
- except Exception as e:
751
- logger.error(f"Error getting IMU temperature: {e}")
752
- return 0.0
753
-
754
- # ========== Phase 11: LED Control (via local SDK) ==========
755
-
756
- def _get_respeaker(self):
757
- """Get ReSpeaker device from media manager."""
758
- if not self.is_available:
759
- logger.debug("ReSpeaker not available: robot not connected")
760
- return None
761
- try:
762
- if not self.reachy.media:
763
- logger.debug("ReSpeaker not available: media manager is None")
764
- return None
765
- if not self.reachy.media.audio:
766
- logger.debug("ReSpeaker not available: audio is None")
767
- return None
768
- respeaker = self.reachy.media.audio._respeaker
769
- if respeaker is None:
770
- logger.debug("ReSpeaker not available: _respeaker is None (USB device not found)")
771
- return respeaker
772
- except Exception as e:
773
- logger.debug(f"ReSpeaker not available: {e}")
774
- return None
775
-
776
- def get_led_brightness(self) -> float:
777
- """Get LED brightness (0-100)."""
778
- respeaker = self._get_respeaker()
779
- if respeaker is None:
780
- return getattr(self, '_led_brightness', 50.0)
781
- try:
782
- result = respeaker.read("LED_BRIGHTNESS")
783
- if result is not None:
784
- # LED_BRIGHTNESS is 0-255, convert to 0-100
785
- self._led_brightness = (result[1] / 255.0) * 100.0
786
- return self._led_brightness
787
- except Exception as e:
788
- logger.debug(f"Error getting LED brightness: {e}")
789
- return getattr(self, '_led_brightness', 50.0)
790
-
791
- def set_led_brightness(self, brightness: float) -> None:
792
- """Set LED brightness (0-100)."""
793
- brightness = max(0.0, min(100.0, brightness))
794
- self._led_brightness = brightness
795
- respeaker = self._get_respeaker()
796
- if respeaker is None:
797
- return
798
- try:
799
- # Convert 0-100 to 0-255
800
- value = int((brightness / 100.0) * 255)
801
- respeaker.write("LED_BRIGHTNESS", [value])
802
- logger.info(f"LED brightness set to {brightness}%")
803
- except Exception as e:
804
- logger.error(f"Error setting LED brightness: {e}")
805
-
806
- def get_led_effect(self) -> str:
807
- """Get current LED effect."""
808
- respeaker = self._get_respeaker()
809
- if respeaker is None:
810
- return getattr(self, '_led_effect', 'off')
811
- try:
812
- result = respeaker.read("LED_EFFECT")
813
- if result is not None:
814
- effect_map = {0: 'off', 1: 'solid', 2: 'breathing', 3: 'rainbow', 4: 'doa'}
815
- self._led_effect = effect_map.get(result[1], 'off')
816
- return self._led_effect
817
- except Exception as e:
818
- logger.debug(f"Error getting LED effect: {e}")
819
- return getattr(self, '_led_effect', 'off')
820
-
821
- def set_led_effect(self, effect: str) -> None:
822
- """Set LED effect."""
823
- self._led_effect = effect
824
- respeaker = self._get_respeaker()
825
- if respeaker is None:
826
- return
827
- try:
828
- effect_map = {'off': 0, 'solid': 1, 'breathing': 2, 'rainbow': 3, 'doa': 4}
829
- value = effect_map.get(effect, 0)
830
- respeaker.write("LED_EFFECT", [value])
831
- logger.info(f"LED effect set to {effect}")
832
- except Exception as e:
833
- logger.error(f"Error setting LED effect: {e}")
834
-
835
- def get_led_color_r(self) -> float:
836
- """Get LED red color component (0-255)."""
837
- respeaker = self._get_respeaker()
838
- if respeaker is None:
839
- return getattr(self, '_led_color_r', 0.0)
840
- try:
841
- result = respeaker.read("LED_COLOR")
842
- if result is not None:
843
- # LED_COLOR is a 32-bit value: 0x00RRGGBB
844
- color = result[1] if len(result) > 1 else 0
845
- self._led_color_r = float((color >> 16) & 0xFF)
846
- return self._led_color_r
847
- except Exception as e:
848
- logger.debug(f"Error getting LED color R: {e}")
849
- return getattr(self, '_led_color_r', 0.0)
850
-
851
- def set_led_color_r(self, value: float) -> None:
852
- """Set LED red color component (0-255)."""
853
- self._led_color_r = max(0.0, min(255.0, value))
854
- self._update_led_color()
855
-
856
- def get_led_color_g(self) -> float:
857
- """Get LED green color component (0-255)."""
858
- respeaker = self._get_respeaker()
859
- if respeaker is None:
860
- return getattr(self, '_led_color_g', 0.0)
861
- try:
862
- result = respeaker.read("LED_COLOR")
863
- if result is not None:
864
- color = result[1] if len(result) > 1 else 0
865
- self._led_color_g = float((color >> 8) & 0xFF)
866
- return self._led_color_g
867
- except Exception as e:
868
- logger.debug(f"Error getting LED color G: {e}")
869
- return getattr(self, '_led_color_g', 0.0)
870
-
871
- def set_led_color_g(self, value: float) -> None:
872
- """Set LED green color component (0-255)."""
873
- self._led_color_g = max(0.0, min(255.0, value))
874
- self._update_led_color()
875
-
876
- def get_led_color_b(self) -> float:
877
- """Get LED blue color component (0-255)."""
878
- respeaker = self._get_respeaker()
879
- if respeaker is None:
880
- return getattr(self, '_led_color_b', 0.0)
881
- try:
882
- result = respeaker.read("LED_COLOR")
883
- if result is not None:
884
- color = result[1] if len(result) > 1 else 0
885
- self._led_color_b = float(color & 0xFF)
886
- return self._led_color_b
887
- except Exception as e:
888
- logger.debug(f"Error getting LED color B: {e}")
889
- return getattr(self, '_led_color_b', 0.0)
890
-
891
- def set_led_color_b(self, value: float) -> None:
892
- """Set LED blue color component (0-255)."""
893
- self._led_color_b = max(0.0, min(255.0, value))
894
- self._update_led_color()
895
-
896
- def _update_led_color(self) -> None:
897
- """Update LED color from R, G, B components."""
898
- respeaker = self._get_respeaker()
899
- if respeaker is None:
900
- return
901
- try:
902
- r = int(getattr(self, '_led_color_r', 0))
903
- g = int(getattr(self, '_led_color_g', 0))
904
- b = int(getattr(self, '_led_color_b', 0))
905
- color = (r << 16) | (g << 8) | b
906
- respeaker.write("LED_COLOR", [color])
907
- logger.info(f"LED color set to RGB({r}, {g}, {b})")
908
- except Exception as e:
909
- logger.error(f"Error setting LED color: {e}")
910
-
911
- # ========== Phase 12: Audio Processing (via local SDK) ==========
912
-
913
- def get_agc_enabled(self) -> bool:
914
- """Get AGC (Automatic Gain Control) enabled status."""
915
- respeaker = self._get_respeaker()
916
- if respeaker is None:
917
- return getattr(self, '_agc_enabled', False)
918
- try:
919
- result = respeaker.read("PP_AGCONOFF")
920
- if result is not None:
921
- self._agc_enabled = bool(result[1])
922
- return self._agc_enabled
923
- except Exception as e:
924
- logger.debug(f"Error getting AGC status: {e}")
925
- return getattr(self, '_agc_enabled', False)
926
-
927
- def set_agc_enabled(self, enabled: bool) -> None:
928
- """Set AGC (Automatic Gain Control) enabled status."""
929
- self._agc_enabled = enabled
930
- respeaker = self._get_respeaker()
931
- if respeaker is None:
932
- return
933
- try:
934
- respeaker.write("PP_AGCONOFF", [1 if enabled else 0])
935
- logger.info(f"AGC {'enabled' if enabled else 'disabled'}")
936
- except Exception as e:
937
- logger.error(f"Error setting AGC status: {e}")
938
-
939
- def get_agc_max_gain(self) -> float:
940
- """Get AGC maximum gain in dB."""
941
- respeaker = self._get_respeaker()
942
- if respeaker is None:
943
- return getattr(self, '_agc_max_gain', 15.0)
944
- try:
945
- result = respeaker.read("PP_AGCMAXGAIN")
946
- if result is not None:
947
- self._agc_max_gain = float(result[0])
948
- return self._agc_max_gain
949
- except Exception as e:
950
- logger.debug(f"Error getting AGC max gain: {e}")
951
- return getattr(self, '_agc_max_gain', 15.0)
952
-
953
- def set_agc_max_gain(self, gain: float) -> None:
954
- """Set AGC maximum gain in dB."""
955
- gain = max(0.0, min(30.0, gain))
956
- self._agc_max_gain = gain
957
- respeaker = self._get_respeaker()
958
- if respeaker is None:
959
- return
960
- try:
961
- respeaker.write("PP_AGCMAXGAIN", [gain])
962
- logger.info(f"AGC max gain set to {gain} dB")
963
- except Exception as e:
964
- logger.error(f"Error setting AGC max gain: {e}")
965
-
966
- def get_noise_suppression(self) -> float:
967
- """Get noise suppression level (0-100%)."""
968
- respeaker = self._get_respeaker()
969
- if respeaker is None:
970
- return getattr(self, '_noise_suppression', 50.0)
971
- try:
972
- result = respeaker.read("PP_MIN_NS")
973
- if result is not None:
974
- # PP_MIN_NS is typically a float value, convert to percentage
975
- # Lower values = more suppression
976
- self._noise_suppression = max(0.0, min(100.0, (1.0 - result[0]) * 100.0))
977
- return self._noise_suppression
978
- except Exception as e:
979
- logger.debug(f"Error getting noise suppression: {e}")
980
- return getattr(self, '_noise_suppression', 50.0)
981
-
982
- def set_noise_suppression(self, level: float) -> None:
983
- """Set noise suppression level (0-100%)."""
984
- level = max(0.0, min(100.0, level))
985
- self._noise_suppression = level
986
- respeaker = self._get_respeaker()
987
- if respeaker is None:
988
- return
989
- try:
990
- # Convert percentage to PP_MIN_NS value (inverted)
991
- value = 1.0 - (level / 100.0)
992
- respeaker.write("PP_MIN_NS", [value])
993
- logger.info(f"Noise suppression set to {level}%")
994
- except Exception as e:
995
- logger.error(f"Error setting noise suppression: {e}")
996
-
997
- def get_echo_cancellation_converged(self) -> bool:
998
- """Check if echo cancellation has converged."""
999
- respeaker = self._get_respeaker()
1000
- if respeaker is None:
1001
- return False
1002
- try:
1003
- result = respeaker.read("AEC_AECCONVERGED")
1004
- if result is not None:
1005
- return bool(result[1])
1006
- except Exception as e:
1007
- logger.debug(f"Error getting AEC converged status: {e}")
1008
- return False
 
1
+ """Reachy Mini controller wrapper for ESPHome entities."""
2
+
3
+ import logging
4
+ from typing import Optional, TYPE_CHECKING
5
+ import math
6
+ import numpy as np
7
+ from scipy.spatial.transform import Rotation as R
8
+ import requests
9
+
10
+ if TYPE_CHECKING:
11
+ from reachy_mini import ReachyMini
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ReachyController:
17
+ """
18
+ Wrapper class for Reachy Mini control operations.
19
+
20
+ Provides safe access to Reachy Mini SDK functions with error handling
21
+ and fallback for standalone mode (when robot is not available).
22
+ """
23
+
24
+ def __init__(self, reachy_mini: Optional["ReachyMini"] = None):
25
+ """
26
+ Initialize the controller.
27
+
28
+ Args:
29
+ reachy_mini: ReachyMini instance, or None for standalone mode
30
+ """
31
+ self.reachy = reachy_mini
32
+ self._speaker_volume = 100 # Default volume
33
+
34
+ @property
35
+ def is_available(self) -> bool:
36
+ """Check if robot is available."""
37
+ return self.reachy is not None
38
+
39
+ # ========== Phase 1: Basic Status & Volume ==========
40
+
41
+ def get_daemon_state(self) -> str:
42
+ """Get daemon state."""
43
+ if not self.is_available:
44
+ return "not_available"
45
+ try:
46
+ # client.get_status() returns a dict with 'state' key
47
+ status = self.reachy.client.get_status(wait=False)
48
+ return status.get('state', 'unknown')
49
+ except Exception as e:
50
+ logger.error(f"Error getting daemon state: {e}")
51
+ return "error"
52
+
53
+ def get_backend_ready(self) -> bool:
54
+ """Check if backend is ready."""
55
+ if not self.is_available:
56
+ return False
57
+ try:
58
+ # Check if daemon state is 'running'
59
+ status = self.reachy.client.get_status(wait=False)
60
+ return status.get('state') == 'running'
61
+ except Exception as e:
62
+ logger.error(f"Error getting backend status: {e}")
63
+ return False
64
+
65
+ def get_error_message(self) -> str:
66
+ """Get current error message."""
67
+ if not self.is_available:
68
+ return "Robot not available"
69
+ try:
70
+ status = self.reachy.client.get_status(wait=False)
71
+ return status.get('error') or ""
72
+ except Exception as e:
73
+ logger.error(f"Error getting error message: {e}")
74
+ return str(e)
75
+
76
+ def get_speaker_volume(self) -> float:
77
+ """Get speaker volume (0-100)."""
78
+ if not self.is_available:
79
+ return self._speaker_volume
80
+ try:
81
+ # Get volume from daemon API
82
+ status = self.reachy.client.get_status(wait=False)
83
+ wlan_ip = status.get('wlan_ip', 'localhost')
84
+ response = requests.get(f"http://{wlan_ip}:8000/api/volume/current", timeout=2)
85
+ if response.status_code == 200:
86
+ data = response.json()
87
+ self._speaker_volume = float(data.get('volume', self._speaker_volume))
88
+ except Exception as e:
89
+ logger.debug(f"Could not get volume from API: {e}")
90
+ return self._speaker_volume
91
+
92
+ def set_speaker_volume(self, volume: float) -> None:
93
+ """
94
+ Set speaker volume (0-100).
95
+
96
+ Args:
97
+ volume: Volume level 0-100
98
+ """
99
+ volume = max(0.0, min(100.0, volume))
100
+ self._speaker_volume = volume
101
+
102
+ if not self.is_available:
103
+ logger.warning("Cannot set volume: robot not available")
104
+ return
105
+
106
+ try:
107
+ # Set volume via daemon API
108
+ status = self.reachy.client.get_status(wait=False)
109
+ wlan_ip = status.get('wlan_ip', 'localhost')
110
+ response = requests.post(
111
+ f"http://{wlan_ip}:8000/api/volume/set",
112
+ json={"volume": int(volume)},
113
+ timeout=5
114
+ )
115
+ if response.status_code == 200:
116
+ logger.info(f"Speaker volume set to {volume}%")
117
+ else:
118
+ logger.error(f"Failed to set volume: {response.status_code} {response.text}")
119
+ except Exception as e:
120
+ logger.error(f"Error setting speaker volume: {e}")
121
+
122
+ def get_microphone_volume(self) -> float:
123
+ """Get microphone volume (0-100) using local SDK audio interface."""
124
+ respeaker = self._get_respeaker()
125
+ if respeaker is None:
126
+ return getattr(self, '_microphone_volume', 50.0)
127
+
128
+ try:
129
+ # Try APMGR_MICGAIN first (0-31 range)
130
+ result = respeaker.read("APMGR_MICGAIN")
131
+ if result is not None:
132
+ # Convert 0-31 to 0-100%
133
+ self._microphone_volume = (result[0] / 31.0) * 100.0
134
+ return self._microphone_volume
135
+ except Exception as e:
136
+ logger.debug(f"Could not get microphone volume: {e}")
137
+
138
+ return getattr(self, '_microphone_volume', 50.0)
139
+
140
+ def set_microphone_volume(self, volume: float) -> None:
141
+ """
142
+ Set microphone volume (0-100) using local SDK audio interface.
143
+
144
+ Args:
145
+ volume: Volume level 0-100
146
+ """
147
+ volume = max(0.0, min(100.0, volume))
148
+ self._microphone_volume = volume
149
+
150
+ respeaker = self._get_respeaker()
151
+ if respeaker is None:
152
+ logger.warning("Cannot set microphone volume: ReSpeaker not available")
153
+ return
154
+
155
+ try:
156
+ # Convert 0-100% to 0-31 range for APMGR_MICGAIN
157
+ gain = int((volume / 100.0) * 31.0)
158
+ respeaker.write("APMGR_MICGAIN", [gain])
159
+ logger.info(f"Microphone volume set to {volume}% (gain: {gain})")
160
+ except Exception as e:
161
+ logger.error(f"Failed to set microphone volume: {e}")
162
+
163
+ # ========== Phase 2: Motor Control ==========
164
+
165
+ def get_motors_enabled(self) -> bool:
166
+ """Check if motors are enabled."""
167
+ if not self.is_available:
168
+ return False
169
+ try:
170
+ # Get motor control mode from backend status
171
+ status = self.reachy.client.get_status(wait=False)
172
+ backend_status = status.get('backend_status')
173
+ if backend_status and isinstance(backend_status, dict):
174
+ motor_mode = backend_status.get('motor_control_mode', 'disabled')
175
+ return motor_mode == 'enabled'
176
+ return status.get('state') == 'running'
177
+ except Exception as e:
178
+ logger.error(f"Error getting motor state: {e}")
179
+ return False
180
+
181
+ def set_motors_enabled(self, enabled: bool) -> None:
182
+ """
183
+ Enable or disable motors.
184
+
185
+ Args:
186
+ enabled: True to enable, False to disable
187
+ """
188
+ if not self.is_available:
189
+ logger.warning("Cannot control motors: robot not available")
190
+ return
191
+
192
+ try:
193
+ if enabled:
194
+ self.reachy.enable_motors()
195
+ logger.info("Motors enabled")
196
+ else:
197
+ self.reachy.disable_motors()
198
+ logger.info("Motors disabled")
199
+ except Exception as e:
200
+ logger.error(f"Error setting motor state: {e}")
201
+
202
+ def get_motor_mode(self) -> str:
203
+ """Get current motor control mode."""
204
+ if not self.is_available:
205
+ return "disabled"
206
+ try:
207
+ # Get motor control mode from backend status
208
+ status = self.reachy.client.get_status(wait=False)
209
+ backend_status = status.get('backend_status')
210
+ if backend_status and isinstance(backend_status, dict):
211
+ motor_mode = backend_status.get('motor_control_mode', 'disabled')
212
+ return motor_mode
213
+ if status.get('state') == 'running':
214
+ return "enabled"
215
+ return "disabled"
216
+ except Exception as e:
217
+ logger.error(f"Error getting motor mode: {e}")
218
+ return "error"
219
+
220
+ def set_motor_mode(self, mode: str) -> None:
221
+ """
222
+ Set motor control mode.
223
+
224
+ Args:
225
+ mode: One of "enabled", "disabled", "gravity_compensation"
226
+ """
227
+ if not self.is_available:
228
+ logger.warning("Cannot set motor mode: robot not available")
229
+ return
230
+
231
+ try:
232
+ if mode == "enabled":
233
+ self.reachy.enable_motors()
234
+ elif mode == "disabled":
235
+ self.reachy.disable_motors()
236
+ elif mode == "gravity_compensation":
237
+ self.reachy.enable_gravity_compensation()
238
+ else:
239
+ logger.warning(f"Invalid motor mode: {mode}")
240
+ return
241
+ logger.info(f"Motor mode set to {mode}")
242
+ except Exception as e:
243
+ logger.error(f"Error setting motor mode: {e}")
244
+
245
+ def wake_up(self) -> None:
246
+ """Execute wake up animation."""
247
+ if not self.is_available:
248
+ logger.warning("Cannot wake up: robot not available")
249
+ return
250
+
251
+ try:
252
+ self.reachy.wake_up()
253
+ logger.info("Wake up animation executed")
254
+ except Exception as e:
255
+ logger.error(f"Error executing wake up: {e}")
256
+
257
+ def go_to_sleep(self) -> None:
258
+ """Execute sleep animation."""
259
+ if not self.is_available:
260
+ logger.warning("Cannot sleep: robot not available")
261
+ return
262
+
263
+ try:
264
+ self.reachy.goto_sleep()
265
+ logger.info("Sleep animation executed")
266
+ except Exception as e:
267
+ logger.error(f"Error executing sleep: {e}")
268
+
269
+ # ========== Phase 3: Pose Control ==========
270
+
271
+ def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
272
+ """
273
+ Extract position (x, y, z) and rotation (roll, pitch, yaw) from 4x4 pose matrix.
274
+
275
+ Args:
276
+ pose_matrix: 4x4 homogeneous transformation matrix
277
+
278
+ Returns:
279
+ tuple: (x, y, z, roll, pitch, yaw) where position is in meters and angles in radians
280
+ """
281
+ # Extract position from the last column
282
+ x = pose_matrix[0, 3]
283
+ y = pose_matrix[1, 3]
284
+ z = pose_matrix[2, 3]
285
+
286
+ # Extract rotation matrix and convert to euler angles
287
+ rotation_matrix = pose_matrix[:3, :3]
288
+ rotation = R.from_matrix(rotation_matrix)
289
+ # Use 'xyz' convention for roll, pitch, yaw
290
+ roll, pitch, yaw = rotation.as_euler('xyz')
291
+
292
+ return x, y, z, roll, pitch, yaw
293
+
294
+ def get_head_x(self) -> float:
295
+ """Get head X position in mm."""
296
+ if not self.is_available:
297
+ return 0.0
298
+ try:
299
+ pose = self.reachy.get_current_head_pose()
300
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
301
+ return x * 1000 # Convert m to mm
302
+ except Exception as e:
303
+ logger.error(f"Error getting head X: {e}")
304
+ return 0.0
305
+
306
+ def set_head_x(self, x_mm: float) -> None:
307
+ """Set head X position in mm."""
308
+ if not self.is_available:
309
+ return
310
+ try:
311
+ pose = self.reachy.get_current_head_pose()
312
+ # Modify the X position in the matrix
313
+ new_pose = pose.copy()
314
+ new_pose[0, 3] = x_mm / 1000 # Convert mm to m
315
+ self.reachy.goto_target(head=new_pose)
316
+ except Exception as e:
317
+ logger.error(f"Error setting head X: {e}")
318
+
319
+ def get_head_y(self) -> float:
320
+ """Get head Y position in mm."""
321
+ if not self.is_available:
322
+ return 0.0
323
+ try:
324
+ pose = self.reachy.get_current_head_pose()
325
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
326
+ return y * 1000
327
+ except Exception as e:
328
+ logger.error(f"Error getting head Y: {e}")
329
+ return 0.0
330
+
331
+ def set_head_y(self, y_mm: float) -> None:
332
+ """Set head Y position in mm."""
333
+ if not self.is_available:
334
+ return
335
+ try:
336
+ pose = self.reachy.get_current_head_pose()
337
+ new_pose = pose.copy()
338
+ new_pose[1, 3] = y_mm / 1000
339
+ self.reachy.goto_target(head=new_pose)
340
+ except Exception as e:
341
+ logger.error(f"Error setting head Y: {e}")
342
+
343
+ def get_head_z(self) -> float:
344
+ """Get head Z position in mm."""
345
+ if not self.is_available:
346
+ return 0.0
347
+ try:
348
+ pose = self.reachy.get_current_head_pose()
349
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
350
+ return z * 1000
351
+ except Exception as e:
352
+ logger.error(f"Error getting head Z: {e}")
353
+ return 0.0
354
+
355
+ def set_head_z(self, z_mm: float) -> None:
356
+ """Set head Z position in mm."""
357
+ if not self.is_available:
358
+ return
359
+ try:
360
+ pose = self.reachy.get_current_head_pose()
361
+ new_pose = pose.copy()
362
+ new_pose[2, 3] = z_mm / 1000
363
+ self.reachy.goto_target(head=new_pose)
364
+ except Exception as e:
365
+ logger.error(f"Error setting head Z: {e}")
366
+
367
+ def get_head_roll(self) -> float:
368
+ """Get head roll angle in degrees."""
369
+ if not self.is_available:
370
+ return 0.0
371
+ try:
372
+ pose = self.reachy.get_current_head_pose()
373
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
374
+ return math.degrees(roll)
375
+ except Exception as e:
376
+ logger.error(f"Error getting head roll: {e}")
377
+ return 0.0
378
+
379
+ def set_head_roll(self, roll_deg: float) -> None:
380
+ """Set head roll angle in degrees."""
381
+ if not self.is_available:
382
+ return
383
+ try:
384
+ pose = self.reachy.get_current_head_pose()
385
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
386
+ # Create new rotation with updated roll
387
+ new_rotation = R.from_euler('xyz', [math.radians(roll_deg), pitch, yaw])
388
+ new_pose = pose.copy()
389
+ new_pose[:3, :3] = new_rotation.as_matrix()
390
+ self.reachy.goto_target(head=new_pose)
391
+ except Exception as e:
392
+ logger.error(f"Error setting head roll: {e}")
393
+
394
+ def get_head_pitch(self) -> float:
395
+ """Get head pitch angle in degrees."""
396
+ if not self.is_available:
397
+ return 0.0
398
+ try:
399
+ pose = self.reachy.get_current_head_pose()
400
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
401
+ return math.degrees(pitch)
402
+ except Exception as e:
403
+ logger.error(f"Error getting head pitch: {e}")
404
+ return 0.0
405
+
406
+ def set_head_pitch(self, pitch_deg: float) -> None:
407
+ """Set head pitch angle in degrees."""
408
+ if not self.is_available:
409
+ return
410
+ try:
411
+ pose = self.reachy.get_current_head_pose()
412
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
413
+ new_rotation = R.from_euler('xyz', [roll, math.radians(pitch_deg), yaw])
414
+ new_pose = pose.copy()
415
+ new_pose[:3, :3] = new_rotation.as_matrix()
416
+ self.reachy.goto_target(head=new_pose)
417
+ except Exception as e:
418
+ logger.error(f"Error setting head pitch: {e}")
419
+
420
+ def get_head_yaw(self) -> float:
421
+ """Get head yaw angle in degrees."""
422
+ if not self.is_available:
423
+ return 0.0
424
+ try:
425
+ pose = self.reachy.get_current_head_pose()
426
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
427
+ return math.degrees(yaw)
428
+ except Exception as e:
429
+ logger.error(f"Error getting head yaw: {e}")
430
+ return 0.0
431
+
432
+ def set_head_yaw(self, yaw_deg: float) -> None:
433
+ """Set head yaw angle in degrees."""
434
+ if not self.is_available:
435
+ return
436
+ try:
437
+ pose = self.reachy.get_current_head_pose()
438
+ x, y, z, roll, pitch, yaw = self._extract_pose_from_matrix(pose)
439
+ new_rotation = R.from_euler('xyz', [roll, pitch, math.radians(yaw_deg)])
440
+ new_pose = pose.copy()
441
+ new_pose[:3, :3] = new_rotation.as_matrix()
442
+ self.reachy.goto_target(head=new_pose)
443
+ except Exception as e:
444
+ logger.error(f"Error setting head yaw: {e}")
445
+
446
+ def get_body_yaw(self) -> float:
447
+ """Get body yaw angle in degrees."""
448
+ if not self.is_available:
449
+ return 0.0
450
+ try:
451
+ # Body yaw is the first element of head joint positions
452
+ head_joints, _ = self.reachy.get_current_joint_positions()
453
+ return math.degrees(head_joints[0])
454
+ except Exception as e:
455
+ logger.error(f"Error getting body yaw: {e}")
456
+ return 0.0
457
+
458
+ def set_body_yaw(self, yaw_deg: float) -> None:
459
+ """Set body yaw angle in degrees."""
460
+ if not self.is_available:
461
+ return
462
+ try:
463
+ self.reachy.goto_target(body_yaw=math.radians(yaw_deg))
464
+ except Exception as e:
465
+ logger.error(f"Error setting body yaw: {e}")
466
+
467
+ def get_antenna_left(self) -> float:
468
+ """Get left antenna angle in degrees."""
469
+ if not self.is_available:
470
+ return 0.0
471
+ try:
472
+ # get_current_joint_positions() returns (head_joints, antenna_joints)
473
+ # antenna_joints is [right, left]
474
+ _, antennas = self.reachy.get_current_joint_positions()
475
+ return math.degrees(antennas[1]) # left is index 1
476
+ except Exception as e:
477
+ logger.error(f"Error getting left antenna: {e}")
478
+ return 0.0
479
+
480
+ def set_antenna_left(self, angle_deg: float) -> None:
481
+ """Set left antenna angle in degrees."""
482
+ if not self.is_available:
483
+ return
484
+ try:
485
+ _, antennas = self.reachy.get_current_joint_positions()
486
+ right = antennas[0]
487
+ self.reachy.goto_target(antennas=[right, math.radians(angle_deg)])
488
+ except Exception as e:
489
+ logger.error(f"Error setting left antenna: {e}")
490
+
491
+ def get_antenna_right(self) -> float:
492
+ """Get right antenna angle in degrees."""
493
+ if not self.is_available:
494
+ return 0.0
495
+ try:
496
+ _, antennas = self.reachy.get_current_joint_positions()
497
+ return math.degrees(antennas[0]) # right is index 0
498
+ except Exception as e:
499
+ logger.error(f"Error getting right antenna: {e}")
500
+ return 0.0
501
+
502
+ def set_antenna_right(self, angle_deg: float) -> None:
503
+ """Set right antenna angle in degrees."""
504
+ if not self.is_available:
505
+ return
506
+ try:
507
+ _, antennas = self.reachy.get_current_joint_positions()
508
+ left = antennas[1]
509
+ self.reachy.goto_target(antennas=[math.radians(angle_deg), left])
510
+ except Exception as e:
511
+ logger.error(f"Error setting right antenna: {e}")
512
+
513
+ # ========== Phase 4: Look At Control ==========
514
+
515
+ def get_look_at_x(self) -> float:
516
+ """Get look at target X coordinate in world frame (meters)."""
517
+ # This is a target position, not a current state
518
+ # We'll store it internally
519
+ return getattr(self, '_look_at_x', 0.0)
520
+
521
+ def set_look_at_x(self, x: float) -> None:
522
+ """Set look at target X coordinate."""
523
+ self._look_at_x = x
524
+ self._update_look_at()
525
+
526
+ def get_look_at_y(self) -> float:
527
+ """Get look at target Y coordinate in world frame (meters)."""
528
+ return getattr(self, '_look_at_y', 0.0)
529
+
530
+ def set_look_at_y(self, y: float) -> None:
531
+ """Set look at target Y coordinate."""
532
+ self._look_at_y = y
533
+ self._update_look_at()
534
+
535
+ def get_look_at_z(self) -> float:
536
+ """Get look at target Z coordinate in world frame (meters)."""
537
+ return getattr(self, '_look_at_z', 0.0)
538
+
539
+ def set_look_at_z(self, z: float) -> None:
540
+ """Set look at target Z coordinate."""
541
+ self._look_at_z = z
542
+ self._update_look_at()
543
+
544
+ def _update_look_at(self) -> None:
545
+ """Update robot to look at the target coordinates."""
546
+ if not self.is_available:
547
+ return
548
+ try:
549
+ x = getattr(self, '_look_at_x', 0.0)
550
+ y = getattr(self, '_look_at_y', 0.0)
551
+ z = getattr(self, '_look_at_z', 0.0)
552
+ self.reachy.look_at_world(x, y, z)
553
+ logger.info(f"Looking at world coordinates: ({x}, {y}, {z})")
554
+ except Exception as e:
555
+ logger.error(f"Error updating look at: {e}")
556
+
557
+ # ========== Phase 5: Audio Sensors ==========
558
+
559
+ def get_doa_angle(self) -> float:
560
+ """Get direction of arrival angle in degrees."""
561
+ if not self.is_available:
562
+ return 0.0
563
+ try:
564
+ # Access DOA through media_manager
565
+ doa_result = self.reachy.media.get_DoA()
566
+ if doa_result is not None:
567
+ # Convert radians to degrees
568
+ return math.degrees(doa_result[0])
569
+ return 0.0
570
+ except Exception as e:
571
+ logger.error(f"Error getting DOA angle: {e}")
572
+ return 0.0
573
+
574
+ def get_speech_detected(self) -> bool:
575
+ """Check if speech is detected."""
576
+ if not self.is_available:
577
+ return False
578
+ try:
579
+ # Access speech detection through media_manager
580
+ doa_result = self.reachy.media.get_DoA()
581
+ if doa_result is not None:
582
+ return doa_result[1]
583
+ return False
584
+ except Exception as e:
585
+ logger.error(f"Error getting speech detection: {e}")
586
+ return False
587
+
588
+ # ========== Phase 6: Diagnostic Information ==========
589
+
590
+ def get_control_loop_frequency(self) -> float:
591
+ """Get control loop frequency in Hz."""
592
+ if not self.is_available:
593
+ return 0.0
594
+ try:
595
+ # Get control loop stats from backend status
596
+ status = self.reachy.client.get_status(wait=False)
597
+ backend_status = status.get('backend_status')
598
+ if backend_status and isinstance(backend_status, dict):
599
+ control_loop_stats = backend_status.get('control_loop_stats', {})
600
+ return control_loop_stats.get('mean_control_loop_frequency', 0.0)
601
+ return 0.0
602
+ except Exception as e:
603
+ logger.error(f"Error getting control loop frequency: {e}")
604
+ return 0.0
605
+
606
+ def get_sdk_version(self) -> str:
607
+ """Get SDK version."""
608
+ if not self.is_available:
609
+ return "N/A"
610
+ try:
611
+ status = self.reachy.client.get_status(wait=False)
612
+ return status.get('version') or "unknown"
613
+ except Exception as e:
614
+ logger.error(f"Error getting SDK version: {e}")
615
+ return "error"
616
+
617
+ def get_robot_name(self) -> str:
618
+ """Get robot name."""
619
+ if not self.is_available:
620
+ return "N/A"
621
+ try:
622
+ status = self.reachy.client.get_status(wait=False)
623
+ return status.get('robot_name') or "unknown"
624
+ except Exception as e:
625
+ logger.error(f"Error getting robot name: {e}")
626
+ return "error"
627
+
628
+ def get_wireless_version(self) -> bool:
629
+ """Check if this is a wireless version."""
630
+ if not self.is_available:
631
+ return False
632
+ try:
633
+ status = self.reachy.client.get_status(wait=False)
634
+ return status.get('wireless_version', False)
635
+ except Exception as e:
636
+ logger.error(f"Error getting wireless version: {e}")
637
+ return False
638
+
639
+ def get_simulation_mode(self) -> bool:
640
+ """Check if simulation mode is enabled."""
641
+ if not self.is_available:
642
+ return False
643
+ try:
644
+ status = self.reachy.client.get_status(wait=False)
645
+ return status.get('simulation_enabled', False)
646
+ except Exception as e:
647
+ logger.error(f"Error getting simulation mode: {e}")
648
+ return False
649
+
650
+ def get_wlan_ip(self) -> str:
651
+ """Get WLAN IP address."""
652
+ if not self.is_available:
653
+ return "N/A"
654
+ try:
655
+ status = self.reachy.client.get_status(wait=False)
656
+ return status.get('wlan_ip') or "N/A"
657
+ except Exception as e:
658
+ logger.error(f"Error getting WLAN IP: {e}")
659
+ return "error"
660
+
661
+ # ========== Phase 7: IMU Sensors (Wireless only) ==========
662
+
663
+ def get_imu_accel_x(self) -> float:
664
+ """Get IMU X-axis acceleration in m/s²."""
665
+ if not self.is_available:
666
+ return 0.0
667
+ try:
668
+ imu_data = self.reachy.imu
669
+ if imu_data is not None and 'accelerometer' in imu_data:
670
+ return float(imu_data['accelerometer'][0])
671
+ return 0.0
672
+ except Exception as e:
673
+ logger.error(f"Error getting IMU accel X: {e}")
674
+ return 0.0
675
+
676
+ def get_imu_accel_y(self) -> float:
677
+ """Get IMU Y-axis acceleration in m/s²."""
678
+ if not self.is_available:
679
+ return 0.0
680
+ try:
681
+ imu_data = self.reachy.imu
682
+ if imu_data is not None and 'accelerometer' in imu_data:
683
+ return float(imu_data['accelerometer'][1])
684
+ return 0.0
685
+ except Exception as e:
686
+ logger.error(f"Error getting IMU accel Y: {e}")
687
+ return 0.0
688
+
689
+ def get_imu_accel_z(self) -> float:
690
+ """Get IMU Z-axis acceleration in m/s²."""
691
+ if not self.is_available:
692
+ return 0.0
693
+ try:
694
+ imu_data = self.reachy.imu
695
+ if imu_data is not None and 'accelerometer' in imu_data:
696
+ return float(imu_data['accelerometer'][2])
697
+ return 0.0
698
+ except Exception as e:
699
+ logger.error(f"Error getting IMU accel Z: {e}")
700
+ return 0.0
701
+
702
+ def get_imu_gyro_x(self) -> float:
703
+ """Get IMU X-axis angular velocity in rad/s."""
704
+ if not self.is_available:
705
+ return 0.0
706
+ try:
707
+ imu_data = self.reachy.imu
708
+ if imu_data is not None and 'gyroscope' in imu_data:
709
+ return float(imu_data['gyroscope'][0])
710
+ return 0.0
711
+ except Exception as e:
712
+ logger.error(f"Error getting IMU gyro X: {e}")
713
+ return 0.0
714
+
715
+ def get_imu_gyro_y(self) -> float:
716
+ """Get IMU Y-axis angular velocity in rad/s."""
717
+ if not self.is_available:
718
+ return 0.0
719
+ try:
720
+ imu_data = self.reachy.imu
721
+ if imu_data is not None and 'gyroscope' in imu_data:
722
+ return float(imu_data['gyroscope'][1])
723
+ return 0.0
724
+ except Exception as e:
725
+ logger.error(f"Error getting IMU gyro Y: {e}")
726
+ return 0.0
727
+
728
+ def get_imu_gyro_z(self) -> float:
729
+ """Get IMU Z-axis angular velocity in rad/s."""
730
+ if not self.is_available:
731
+ return 0.0
732
+ try:
733
+ imu_data = self.reachy.imu
734
+ if imu_data is not None and 'gyroscope' in imu_data:
735
+ return float(imu_data['gyroscope'][2])
736
+ return 0.0
737
+ except Exception as e:
738
+ logger.error(f"Error getting IMU gyro Z: {e}")
739
+ return 0.0
740
+
741
+ def get_imu_temperature(self) -> float:
742
+ """Get IMU temperature in °C."""
743
+ if not self.is_available:
744
+ return 0.0
745
+ try:
746
+ imu_data = self.reachy.imu
747
+ if imu_data is not None and 'temperature' in imu_data:
748
+ return float(imu_data['temperature'])
749
+ return 0.0
750
+ except Exception as e:
751
+ logger.error(f"Error getting IMU temperature: {e}")
752
+ return 0.0
753
+
754
+ # ========== Phase 11: LED Control (via local SDK) ==========
755
+
756
+ def _get_respeaker(self):
757
+ """Get ReSpeaker device from media manager."""
758
+ if not self.is_available:
759
+ logger.debug("ReSpeaker not available: robot not connected")
760
+ return None
761
+ try:
762
+ if not self.reachy.media:
763
+ logger.debug("ReSpeaker not available: media manager is None")
764
+ return None
765
+ if not self.reachy.media.audio:
766
+ logger.debug("ReSpeaker not available: audio is None")
767
+ return None
768
+ respeaker = self.reachy.media.audio._respeaker
769
+ if respeaker is None:
770
+ logger.debug("ReSpeaker not available: _respeaker is None (USB device not found)")
771
+ return respeaker
772
+ except Exception as e:
773
+ logger.debug(f"ReSpeaker not available: {e}")
774
+ return None
775
+
776
+ def get_led_brightness(self) -> float:
777
+ """Get LED brightness (0-100)."""
778
+ respeaker = self._get_respeaker()
779
+ if respeaker is None:
780
+ return getattr(self, '_led_brightness', 50.0)
781
+ try:
782
+ result = respeaker.read("LED_BRIGHTNESS")
783
+ if result is not None:
784
+ # LED_BRIGHTNESS is 0-255, convert to 0-100
785
+ self._led_brightness = (result[1] / 255.0) * 100.0
786
+ return self._led_brightness
787
+ except Exception as e:
788
+ logger.debug(f"Error getting LED brightness: {e}")
789
+ return getattr(self, '_led_brightness', 50.0)
790
+
791
+ def set_led_brightness(self, brightness: float) -> None:
792
+ """Set LED brightness (0-100)."""
793
+ brightness = max(0.0, min(100.0, brightness))
794
+ self._led_brightness = brightness
795
+ respeaker = self._get_respeaker()
796
+ if respeaker is None:
797
+ return
798
+ try:
799
+ # Convert 0-100 to 0-255
800
+ value = int((brightness / 100.0) * 255)
801
+ respeaker.write("LED_BRIGHTNESS", [value])
802
+ logger.info(f"LED brightness set to {brightness}%")
803
+ except Exception as e:
804
+ logger.error(f"Error setting LED brightness: {e}")
805
+
806
+ def get_led_effect(self) -> str:
807
+ """Get current LED effect."""
808
+ respeaker = self._get_respeaker()
809
+ if respeaker is None:
810
+ return getattr(self, '_led_effect', 'off')
811
+ try:
812
+ result = respeaker.read("LED_EFFECT")
813
+ if result is not None:
814
+ effect_map = {0: 'off', 1: 'solid', 2: 'breathing', 3: 'rainbow', 4: 'doa'}
815
+ self._led_effect = effect_map.get(result[1], 'off')
816
+ return self._led_effect
817
+ except Exception as e:
818
+ logger.debug(f"Error getting LED effect: {e}")
819
+ return getattr(self, '_led_effect', 'off')
820
+
821
+ def set_led_effect(self, effect: str) -> None:
822
+ """Set LED effect."""
823
+ self._led_effect = effect
824
+ respeaker = self._get_respeaker()
825
+ if respeaker is None:
826
+ return
827
+ try:
828
+ effect_map = {'off': 0, 'solid': 1, 'breathing': 2, 'rainbow': 3, 'doa': 4}
829
+ value = effect_map.get(effect, 0)
830
+ respeaker.write("LED_EFFECT", [value])
831
+ logger.info(f"LED effect set to {effect}")
832
+ except Exception as e:
833
+ logger.error(f"Error setting LED effect: {e}")
834
+
835
+ def get_led_color_r(self) -> float:
836
+ """Get LED red color component (0-255)."""
837
+ respeaker = self._get_respeaker()
838
+ if respeaker is None:
839
+ return getattr(self, '_led_color_r', 0.0)
840
+ try:
841
+ result = respeaker.read("LED_COLOR")
842
+ if result is not None:
843
+ # LED_COLOR is a 32-bit value: 0x00RRGGBB
844
+ color = result[1] if len(result) > 1 else 0
845
+ self._led_color_r = float((color >> 16) & 0xFF)
846
+ return self._led_color_r
847
+ except Exception as e:
848
+ logger.debug(f"Error getting LED color R: {e}")
849
+ return getattr(self, '_led_color_r', 0.0)
850
+
851
+ def set_led_color_r(self, value: float) -> None:
852
+ """Set LED red color component (0-255)."""
853
+ self._led_color_r = max(0.0, min(255.0, value))
854
+ self._update_led_color()
855
+
856
+ def get_led_color_g(self) -> float:
857
+ """Get LED green color component (0-255)."""
858
+ respeaker = self._get_respeaker()
859
+ if respeaker is None:
860
+ return getattr(self, '_led_color_g', 0.0)
861
+ try:
862
+ result = respeaker.read("LED_COLOR")
863
+ if result is not None:
864
+ color = result[1] if len(result) > 1 else 0
865
+ self._led_color_g = float((color >> 8) & 0xFF)
866
+ return self._led_color_g
867
+ except Exception as e:
868
+ logger.debug(f"Error getting LED color G: {e}")
869
+ return getattr(self, '_led_color_g', 0.0)
870
+
871
+ def set_led_color_g(self, value: float) -> None:
872
+ """Set LED green color component (0-255)."""
873
+ self._led_color_g = max(0.0, min(255.0, value))
874
+ self._update_led_color()
875
+
876
+ def get_led_color_b(self) -> float:
877
+ """Get LED blue color component (0-255)."""
878
+ respeaker = self._get_respeaker()
879
+ if respeaker is None:
880
+ return getattr(self, '_led_color_b', 0.0)
881
+ try:
882
+ result = respeaker.read("LED_COLOR")
883
+ if result is not None:
884
+ color = result[1] if len(result) > 1 else 0
885
+ self._led_color_b = float(color & 0xFF)
886
+ return self._led_color_b
887
+ except Exception as e:
888
+ logger.debug(f"Error getting LED color B: {e}")
889
+ return getattr(self, '_led_color_b', 0.0)
890
+
891
+ def set_led_color_b(self, value: float) -> None:
892
+ """Set LED blue color component (0-255)."""
893
+ self._led_color_b = max(0.0, min(255.0, value))
894
+ self._update_led_color()
895
+
896
+ def _update_led_color(self) -> None:
897
+ """Update LED color from R, G, B components."""
898
+ respeaker = self._get_respeaker()
899
+ if respeaker is None:
900
+ return
901
+ try:
902
+ r = int(getattr(self, '_led_color_r', 0))
903
+ g = int(getattr(self, '_led_color_g', 0))
904
+ b = int(getattr(self, '_led_color_b', 0))
905
+ color = (r << 16) | (g << 8) | b
906
+ respeaker.write("LED_COLOR", [color])
907
+ logger.info(f"LED color set to RGB({r}, {g}, {b})")
908
+ except Exception as e:
909
+ logger.error(f"Error setting LED color: {e}")
910
+
911
+ # ========== Phase 12: Audio Processing (via local SDK) ==========
912
+
913
+ def get_agc_enabled(self) -> bool:
914
+ """Get AGC (Automatic Gain Control) enabled status."""
915
+ respeaker = self._get_respeaker()
916
+ if respeaker is None:
917
+ return getattr(self, '_agc_enabled', False)
918
+ try:
919
+ result = respeaker.read("PP_AGCONOFF")
920
+ if result is not None:
921
+ self._agc_enabled = bool(result[1])
922
+ return self._agc_enabled
923
+ except Exception as e:
924
+ logger.debug(f"Error getting AGC status: {e}")
925
+ return getattr(self, '_agc_enabled', False)
926
+
927
+ def set_agc_enabled(self, enabled: bool) -> None:
928
+ """Set AGC (Automatic Gain Control) enabled status."""
929
+ self._agc_enabled = enabled
930
+ respeaker = self._get_respeaker()
931
+ if respeaker is None:
932
+ return
933
+ try:
934
+ respeaker.write("PP_AGCONOFF", [1 if enabled else 0])
935
+ logger.info(f"AGC {'enabled' if enabled else 'disabled'}")
936
+ except Exception as e:
937
+ logger.error(f"Error setting AGC status: {e}")
938
+
939
+ def get_agc_max_gain(self) -> float:
940
+ """Get AGC maximum gain in dB."""
941
+ respeaker = self._get_respeaker()
942
+ if respeaker is None:
943
+ return getattr(self, '_agc_max_gain', 15.0)
944
+ try:
945
+ result = respeaker.read("PP_AGCMAXGAIN")
946
+ if result is not None:
947
+ self._agc_max_gain = float(result[0])
948
+ return self._agc_max_gain
949
+ except Exception as e:
950
+ logger.debug(f"Error getting AGC max gain: {e}")
951
+ return getattr(self, '_agc_max_gain', 15.0)
952
+
953
+ def set_agc_max_gain(self, gain: float) -> None:
954
+ """Set AGC maximum gain in dB."""
955
+ gain = max(0.0, min(30.0, gain))
956
+ self._agc_max_gain = gain
957
+ respeaker = self._get_respeaker()
958
+ if respeaker is None:
959
+ return
960
+ try:
961
+ respeaker.write("PP_AGCMAXGAIN", [gain])
962
+ logger.info(f"AGC max gain set to {gain} dB")
963
+ except Exception as e:
964
+ logger.error(f"Error setting AGC max gain: {e}")
965
+
966
+ def get_noise_suppression(self) -> float:
967
+ """Get noise suppression level (0-100%)."""
968
+ respeaker = self._get_respeaker()
969
+ if respeaker is None:
970
+ return getattr(self, '_noise_suppression', 50.0)
971
+ try:
972
+ result = respeaker.read("PP_MIN_NS")
973
+ if result is not None:
974
+ # PP_MIN_NS is typically a float value, convert to percentage
975
+ # Lower values = more suppression
976
+ self._noise_suppression = max(0.0, min(100.0, (1.0 - result[0]) * 100.0))
977
+ return self._noise_suppression
978
+ except Exception as e:
979
+ logger.debug(f"Error getting noise suppression: {e}")
980
+ return getattr(self, '_noise_suppression', 50.0)
981
+
982
+ def set_noise_suppression(self, level: float) -> None:
983
+ """Set noise suppression level (0-100%)."""
984
+ level = max(0.0, min(100.0, level))
985
+ self._noise_suppression = level
986
+ respeaker = self._get_respeaker()
987
+ if respeaker is None:
988
+ return
989
+ try:
990
+ # Convert percentage to PP_MIN_NS value (inverted)
991
+ value = 1.0 - (level / 100.0)
992
+ respeaker.write("PP_MIN_NS", [value])
993
+ logger.info(f"Noise suppression set to {level}%")
994
+ except Exception as e:
995
+ logger.error(f"Error setting noise suppression: {e}")
996
+
997
+ def get_echo_cancellation_converged(self) -> bool:
998
+ """Check if echo cancellation has converged."""
999
+ respeaker = self._get_respeaker()
1000
+ if respeaker is None:
1001
+ return False
1002
+ try:
1003
+ result = respeaker.read("AEC_AECCONVERGED")
1004
+ if result is not None:
1005
+ return bool(result[1])
1006
+ except Exception as e:
1007
+ logger.debug(f"Error getting AEC converged status: {e}")
1008
+ return False
reachy_mini_ha_voice/satellite.py CHANGED
The diff for this file is too large to render. See raw diff
 
reachy_mini_ha_voice/sounds/.gitkeep CHANGED
@@ -1,2 +1,2 @@
1
- # This file ensures the sounds directory is tracked by git
2
  # Actual sound files (.flac, .mp3, .wav) should be added by users
 
1
+ # This file ensures the sounds directory is tracked by git
2
  # Actual sound files (.flac, .mp3, .wav) should be added by users
reachy_mini_ha_voice/sounds/LICENSE.md CHANGED
@@ -1 +1 @@
1
- [Home Assistant Voice Preview Edition Sounds](https://github.com/esphome/home-assistant-voice-pe/tree/dev/sounds) © 2024 by [Clayton Charles Tapp](https://www.cctaudio.com/) is licensed under [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)
 
1
+ [Home Assistant Voice Preview Edition Sounds](https://github.com/esphome/home-assistant-voice-pe/tree/dev/sounds) © 2024 by [Clayton Charles Tapp](https://www.cctaudio.com/) is licensed under [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)
reachy_mini_ha_voice/sounds/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # Sound Effects
2
-
3
- Sound files are automatically downloaded on first run.
4
-
5
- ## Files
6
-
7
- - `wake_word_triggered.flac` - Played when wake word is detected
8
- - `timer_finished.flac` - Played when a timer completes
9
-
10
  These files are downloaded from [linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant).
 
1
+ # Sound Effects
2
+
3
+ Sound files are automatically downloaded on first run.
4
+
5
+ ## Files
6
+
7
+ - `wake_word_triggered.flac` - Played when wake word is detected
8
+ - `timer_finished.flac` - Played when a timer completes
9
+
10
  These files are downloaded from [linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant).
reachy_mini_ha_voice/static/index.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Reachy Mini example app template</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+
11
+ <body>
12
+ <h1>Reachy Mini – Control Panel</h1>
13
+
14
+ <div id="controls">
15
+ <label style="display:flex; align-items:center; gap:8px;">
16
+ <input type="checkbox" id="antenna-checkbox" checked>
17
+ Antennas
18
+ </label>
19
+
20
+ <button id="sound-btn">Play Sound</button>
21
+ </div>
22
+
23
+ <div id="status">Antennas status: running</div>
24
+ <script src="/static/main.js"></script>
25
+ </body>
26
+
27
+ </html>
reachy_mini_ha_voice/static/main.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let antennasEnabled = true;
2
+
3
+ async function updateAntennasState(enabled) {
4
+ try {
5
+ const resp = await fetch("/antennas", {
6
+ method: "POST",
7
+ headers: { "Content-Type": "application/json" },
8
+ body: JSON.stringify({ enabled }),
9
+ });
10
+ const data = await resp.json();
11
+ antennasEnabled = data.antennas_enabled;
12
+ updateUI();
13
+ } catch (e) {
14
+ document.getElementById("status").textContent = "Backend error";
15
+ }
16
+ }
17
+
18
+ async function playSound() {
19
+ try {
20
+ await fetch("/play_sound", { method: "POST" });
21
+ } catch (e) {
22
+ console.error("Error triggering sound:", e);
23
+ }
24
+ }
25
+
26
+ function updateUI() {
27
+ const checkbox = document.getElementById("antenna-checkbox");
28
+ const status = document.getElementById("status");
29
+
30
+ checkbox.checked = antennasEnabled;
31
+
32
+ if (antennasEnabled) {
33
+ status.textContent = "Antennas status: running";
34
+ } else {
35
+ status.textContent = "Antennas status: stopped";
36
+ }
37
+ }
38
+
39
+ document.getElementById("antenna-checkbox").addEventListener("change", (e) => {
40
+ updateAntennasState(e.target.checked);
41
+ });
42
+
43
+ document.getElementById("sound-btn").addEventListener("click", () => {
44
+ playSound();
45
+ });
46
+
47
+ updateUI();
reachy_mini_ha_voice/static/style.css ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: sans-serif;
3
+ margin: 24px;
4
+ }
5
+
6
+ #sound-btn {
7
+ padding: 10px 20px;
8
+ border: none;
9
+ color: white;
10
+ cursor: pointer;
11
+ font-size: 16px;
12
+ border-radius: 6px;
13
+ background-color: #3498db;
14
+ }
15
+
16
+ #status {
17
+ margin-top: 16px;
18
+ font-weight: bold;
19
+ }
20
+
21
+ #controls {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: 20px;
25
+ }
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -1,671 +1,671 @@
1
- """
2
- Voice Assistant Service for Reachy Mini.
3
-
4
- This module provides the main voice assistant service that integrates
5
- with Home Assistant via ESPHome protocol.
6
- """
7
-
8
- import asyncio
9
- 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
16
-
17
- import numpy as np
18
-
19
- from reachy_mini import ReachyMini
20
-
21
- from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
22
- from .audio_player import AudioPlayer
23
- from .satellite import VoiceSatelliteProtocol
24
- from .util import get_mac
25
- from .zeroconf import HomeAssistantZeroconf
26
- from .motion import ReachyMiniMotion
27
- from .camera_server import MJPEGCameraServer
28
-
29
- _LOGGER = logging.getLogger(__name__)
30
-
31
- _MODULE_DIR = Path(__file__).parent
32
- _WAKEWORDS_DIR = _MODULE_DIR / "wakewords"
33
- _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
-
40
- def __init__(
41
- self,
42
- reachy_mini: Optional[ReachyMini] = None,
43
- name: str = "Reachy Mini",
44
- host: str = "0.0.0.0",
45
- port: int = 6053,
46
- wake_model: str = "okay_nabu",
47
- camera_port: int = 8081,
48
- camera_enabled: bool = True,
49
- ):
50
- self.reachy_mini = reachy_mini
51
- self.name = name
52
- self.host = host
53
- self.port = port
54
- self.wake_model = wake_model
55
- self.camera_port = camera_port
56
- self.camera_enabled = camera_enabled
57
-
58
- self._server = None
59
- self._discovery = None
60
- self._audio_thread = None
61
- self._running = False
62
- self._state: Optional[ServerState] = None
63
- self._motion = ReachyMiniMotion(reachy_mini)
64
- self._camera_server: Optional[MJPEGCameraServer] = None
65
-
66
- async def start(self) -> None:
67
- """Start the voice assistant service."""
68
- _LOGGER.info("Initializing voice assistant service...")
69
-
70
- # Ensure directories exist
71
- _WAKEWORDS_DIR.mkdir(parents=True, exist_ok=True)
72
- _SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
73
- _LOCAL_DIR.mkdir(parents=True, exist_ok=True)
74
-
75
- # Verify required files (bundled with package)
76
- await self._verify_required_files()
77
-
78
- # Load wake words
79
- available_wake_words = self._load_available_wake_words()
80
- _LOGGER.debug("Available wake words: %s", list(available_wake_words.keys()))
81
-
82
- # Load preferences
83
- preferences_path = _LOCAL_DIR / "preferences.json"
84
- preferences = self._load_preferences(preferences_path)
85
-
86
- # Load wake word models
87
- wake_models, active_wake_words = self._load_wake_models(
88
- available_wake_words, preferences
89
- )
90
-
91
- # Load stop model
92
- stop_model = self._load_stop_model()
93
-
94
- # Create audio players with Reachy Mini reference
95
- music_player = AudioPlayer(self.reachy_mini)
96
- tts_player = AudioPlayer(self.reachy_mini)
97
-
98
- # Create server state
99
- self._state = ServerState(
100
- name=self.name,
101
- mac_address=get_mac(),
102
- audio_queue=Queue(),
103
- entities=[],
104
- available_wake_words=available_wake_words,
105
- wake_words=wake_models,
106
- active_wake_words=active_wake_words,
107
- stop_word=stop_model,
108
- music_player=music_player,
109
- tts_player=tts_player,
110
- wakeup_sound=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
111
- timer_finished_sound=str(_SOUNDS_DIR / "timer_finished.flac"),
112
- preferences=preferences,
113
- preferences_path=preferences_path,
114
- refractory_seconds=2.0,
115
- download_dir=_LOCAL_DIR,
116
- reachy_mini=self.reachy_mini,
117
- motion_enabled=self.reachy_mini is not None,
118
- )
119
-
120
- # Set motion controller reference in state
121
- self._state.motion = self._motion
122
-
123
- # Start Reachy Mini media system if available
124
- if self.reachy_mini is not None:
125
- try:
126
- self.reachy_mini.media.start_recording()
127
- self.reachy_mini.media.start_playing()
128
- _LOGGER.info("Reachy Mini media system initialized")
129
- except Exception as e:
130
- _LOGGER.warning("Failed to initialize Reachy Mini media: %s", e)
131
-
132
- # Start audio processing thread
133
- self._running = True
134
- self._audio_thread = threading.Thread(
135
- target=self._process_audio,
136
- daemon=True,
137
- )
138
- self._audio_thread.start()
139
-
140
- # Start camera server if enabled (must be before ESPHome server)
141
- if self.camera_enabled:
142
- self._camera_server = MJPEGCameraServer(
143
- reachy_mini=self.reachy_mini,
144
- host=self.host,
145
- port=self.camera_port,
146
- fps=15,
147
- quality=80,
148
- )
149
- await self._camera_server.start()
150
-
151
- # Create ESPHome server (pass camera_server for camera entity)
152
- loop = asyncio.get_running_loop()
153
- camera_server = self._camera_server # Capture for lambda
154
- self._server = await loop.create_server(
155
- lambda: VoiceSatelliteProtocol(self._state, camera_server=camera_server),
156
- host=self.host,
157
- port=self.port,
158
- )
159
-
160
- # Start mDNS discovery
161
- self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
162
- await self._discovery.register_server()
163
-
164
- _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
165
-
166
- async def stop(self) -> None:
167
- """Stop the voice assistant service."""
168
- _LOGGER.info("Stopping voice assistant service...")
169
-
170
- self._running = False
171
-
172
- if self._audio_thread:
173
- self._audio_thread.join(timeout=2.0)
174
-
175
- if self._server:
176
- self._server.close()
177
- await self._server.wait_closed()
178
-
179
- if self._discovery:
180
- await self._discovery.unregister_server()
181
-
182
- # Stop camera server
183
- if self._camera_server:
184
- await self._camera_server.stop()
185
- self._camera_server = None
186
-
187
- # Stop Reachy Mini media system
188
- if self.reachy_mini is not None:
189
- try:
190
- self.reachy_mini.media.stop_recording()
191
- self.reachy_mini.media.stop_playing()
192
- except Exception as e:
193
- _LOGGER.warning("Error stopping Reachy Mini media: %s", e)
194
-
195
- _LOGGER.info("Voice assistant service stopped.")
196
-
197
- async def _verify_required_files(self) -> None:
198
- """Verify required model and sound files exist (bundled with package)."""
199
- # Required wake word files (bundled in wakewords/ directory)
200
- required_wakewords = [
201
- "okay_nabu.tflite",
202
- "okay_nabu.json",
203
- "hey_jarvis.tflite",
204
- "hey_jarvis.json",
205
- "stop.tflite",
206
- "stop.json",
207
- ]
208
-
209
- # Required sound files (bundled in sounds/ directory)
210
- required_sounds = [
211
- "wake_word_triggered.flac",
212
- "timer_finished.flac",
213
- ]
214
-
215
- # Verify wake word files
216
- missing_wakewords = []
217
- for filename in required_wakewords:
218
- filepath = _WAKEWORDS_DIR / filename
219
- if not filepath.exists():
220
- missing_wakewords.append(filename)
221
-
222
- if missing_wakewords:
223
- _LOGGER.warning(
224
- "Missing wake word files: %s. These should be bundled with the package.",
225
- missing_wakewords
226
- )
227
-
228
- # Verify sound files
229
- missing_sounds = []
230
- for filename in required_sounds:
231
- filepath = _SOUNDS_DIR / filename
232
- if not filepath.exists():
233
- missing_sounds.append(filename)
234
-
235
- if missing_sounds:
236
- _LOGGER.warning(
237
- "Missing sound files: %s. These should be bundled with the package.",
238
- missing_sounds
239
- )
240
-
241
- if not missing_wakewords and not missing_sounds:
242
- _LOGGER.info("All required files verified successfully.")
243
-
244
- def _load_available_wake_words(self) -> Dict[str, AvailableWakeWord]:
245
- """Load available wake word configurations."""
246
- available_wake_words: Dict[str, AvailableWakeWord] = {}
247
-
248
- wake_word_dirs = [_WAKEWORDS_DIR, _LOCAL_DIR / "external_wake_words"]
249
-
250
- for wake_word_dir in wake_word_dirs:
251
- if not wake_word_dir.exists():
252
- continue
253
-
254
- for config_path in wake_word_dir.glob("*.json"):
255
- model_id = config_path.stem
256
- if model_id == "stop":
257
- continue
258
-
259
- try:
260
- with open(config_path, "r", encoding="utf-8") as f:
261
- config = json.load(f)
262
-
263
- model_type = WakeWordType(config.get("type", "micro"))
264
-
265
- if model_type == WakeWordType.OPEN_WAKE_WORD:
266
- wake_word_path = config_path.parent / config["model"]
267
- else:
268
- wake_word_path = config_path
269
-
270
- available_wake_words[model_id] = AvailableWakeWord(
271
- id=model_id,
272
- type=model_type,
273
- wake_word=config.get("wake_word", model_id),
274
- trained_languages=config.get("trained_languages", []),
275
- wake_word_path=wake_word_path,
276
- )
277
- except Exception as e:
278
- _LOGGER.warning("Failed to load wake word %s: %s", config_path, e)
279
-
280
- return available_wake_words
281
-
282
- def _load_preferences(self, preferences_path: Path) -> Preferences:
283
- """Load user preferences."""
284
- if preferences_path.exists():
285
- try:
286
- with open(preferences_path, "r", encoding="utf-8") as f:
287
- data = json.load(f)
288
- return Preferences(**data)
289
- except Exception as e:
290
- _LOGGER.warning("Failed to load preferences: %s", e)
291
-
292
- return Preferences()
293
-
294
- def _load_wake_models(
295
- self,
296
- available_wake_words: Dict[str, AvailableWakeWord],
297
- preferences: Preferences,
298
- ):
299
- """Load wake word models."""
300
- from pymicro_wakeword import MicroWakeWord
301
- from pyopen_wakeword import OpenWakeWord
302
-
303
- wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
304
- active_wake_words: Set[str] = set()
305
-
306
- # Try to load preferred models
307
- if preferences.active_wake_words:
308
- for wake_word_id in preferences.active_wake_words:
309
- wake_word = available_wake_words.get(wake_word_id)
310
- if wake_word is None:
311
- _LOGGER.warning("Unknown wake word: %s", wake_word_id)
312
- continue
313
-
314
- try:
315
- _LOGGER.debug("Loading wake model: %s", wake_word_id)
316
- wake_models[wake_word_id] = wake_word.load()
317
- active_wake_words.add(wake_word_id)
318
- except Exception as e:
319
- _LOGGER.warning("Failed to load wake model %s: %s", wake_word_id, e)
320
-
321
- # Load default model if none loaded
322
- if not wake_models:
323
- wake_word = available_wake_words.get(self.wake_model)
324
- if wake_word:
325
- try:
326
- _LOGGER.debug("Loading default wake model: %s", self.wake_model)
327
- wake_models[self.wake_model] = wake_word.load()
328
- active_wake_words.add(self.wake_model)
329
- except Exception as e:
330
- _LOGGER.error("Failed to load default wake model: %s", e)
331
-
332
- return wake_models, active_wake_words
333
-
334
- def _load_stop_model(self):
335
- """Load the stop word model."""
336
- from pymicro_wakeword import MicroWakeWord
337
-
338
- stop_config = _WAKEWORDS_DIR / "stop.json"
339
- if stop_config.exists():
340
- try:
341
- return MicroWakeWord.from_config(stop_config)
342
- except Exception as e:
343
- _LOGGER.warning("Failed to load stop model: %s", e)
344
-
345
- # Return a dummy model if stop model not available
346
- _LOGGER.warning("Stop model not available, using fallback")
347
- okay_nabu_config = _WAKEWORDS_DIR / "okay_nabu.json"
348
- if okay_nabu_config.exists():
349
- return MicroWakeWord.from_config(okay_nabu_config)
350
-
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,
543
- channels=1,
544
- blocksize=block_size,
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.
627
-
628
- The ReSpeaker DOA returns angle in radians where:
629
- - 0 radians = left
630
- - π/2 radians = front/back
631
- - π radians = right
632
-
633
- We convert this to head yaw degrees where:
634
- - 0 = front
635
- - positive = right
636
- - negative = left
637
-
638
- Returns:
639
- DOA angle in degrees suitable for head yaw, or None if unavailable.
640
- """
641
- if self.reachy_mini is None:
642
- return None
643
-
644
- try:
645
- import math
646
- doa_result = self.reachy_mini.media.get_DoA()
647
- if doa_result is None:
648
- _LOGGER.debug("DOA not available")
649
- return None
650
-
651
- doa_radians, speech_detected = doa_result
652
-
653
- # Note: We don't check speech_detected here because we already know
654
- # speech was detected (wake word triggered this call).
655
- # The DOA value should still be valid from the recent speech.
656
-
657
- # Convert ReSpeaker DOA to head yaw angle
658
- # ReSpeaker: 0=left, π/2=front, π=right
659
- # Head yaw: 0=front, positive=right, negative=left
660
- # Formula: yaw = (doa - π/2) converted to degrees
661
- yaw_radians = doa_radians - (math.pi / 2)
662
- yaw_degrees = math.degrees(yaw_radians)
663
-
664
- _LOGGER.info("DOA detected: %.1f rad -> yaw %.1f deg (speech=%s)",
665
- doa_radians, yaw_degrees, speech_detected)
666
-
667
- return yaw_degrees
668
-
669
- except Exception as e:
670
- _LOGGER.error("Error getting DOA angle: %s", e)
671
- return None
 
1
+ """
2
+ Voice Assistant Service for Reachy Mini.
3
+
4
+ This module provides the main voice assistant service that integrates
5
+ with Home Assistant via ESPHome protocol.
6
+ """
7
+
8
+ import asyncio
9
+ 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
16
+
17
+ import numpy as np
18
+
19
+ from reachy_mini import ReachyMini
20
+
21
+ from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
22
+ from .audio_player import AudioPlayer
23
+ from .satellite import VoiceSatelliteProtocol
24
+ from .util import get_mac
25
+ from .zeroconf import HomeAssistantZeroconf
26
+ from .motion import ReachyMiniMotion
27
+ from .camera_server import MJPEGCameraServer
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+
31
+ _MODULE_DIR = Path(__file__).parent
32
+ _WAKEWORDS_DIR = _MODULE_DIR / "wakewords"
33
+ _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
+
40
+ def __init__(
41
+ self,
42
+ reachy_mini: Optional[ReachyMini] = None,
43
+ name: str = "Reachy Mini",
44
+ host: str = "0.0.0.0",
45
+ port: int = 6053,
46
+ wake_model: str = "okay_nabu",
47
+ camera_port: int = 8081,
48
+ camera_enabled: bool = True,
49
+ ):
50
+ self.reachy_mini = reachy_mini
51
+ self.name = name
52
+ self.host = host
53
+ self.port = port
54
+ self.wake_model = wake_model
55
+ self.camera_port = camera_port
56
+ self.camera_enabled = camera_enabled
57
+
58
+ self._server = None
59
+ self._discovery = None
60
+ self._audio_thread = None
61
+ self._running = False
62
+ self._state: Optional[ServerState] = None
63
+ self._motion = ReachyMiniMotion(reachy_mini)
64
+ self._camera_server: Optional[MJPEGCameraServer] = None
65
+
66
+ async def start(self) -> None:
67
+ """Start the voice assistant service."""
68
+ _LOGGER.info("Initializing voice assistant service...")
69
+
70
+ # Ensure directories exist
71
+ _WAKEWORDS_DIR.mkdir(parents=True, exist_ok=True)
72
+ _SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
73
+ _LOCAL_DIR.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Verify required files (bundled with package)
76
+ await self._verify_required_files()
77
+
78
+ # Load wake words
79
+ available_wake_words = self._load_available_wake_words()
80
+ _LOGGER.debug("Available wake words: %s", list(available_wake_words.keys()))
81
+
82
+ # Load preferences
83
+ preferences_path = _LOCAL_DIR / "preferences.json"
84
+ preferences = self._load_preferences(preferences_path)
85
+
86
+ # Load wake word models
87
+ wake_models, active_wake_words = self._load_wake_models(
88
+ available_wake_words, preferences
89
+ )
90
+
91
+ # Load stop model
92
+ stop_model = self._load_stop_model()
93
+
94
+ # Create audio players with Reachy Mini reference
95
+ music_player = AudioPlayer(self.reachy_mini)
96
+ tts_player = AudioPlayer(self.reachy_mini)
97
+
98
+ # Create server state
99
+ self._state = ServerState(
100
+ name=self.name,
101
+ mac_address=get_mac(),
102
+ audio_queue=Queue(),
103
+ entities=[],
104
+ available_wake_words=available_wake_words,
105
+ wake_words=wake_models,
106
+ active_wake_words=active_wake_words,
107
+ stop_word=stop_model,
108
+ music_player=music_player,
109
+ tts_player=tts_player,
110
+ wakeup_sound=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
111
+ timer_finished_sound=str(_SOUNDS_DIR / "timer_finished.flac"),
112
+ preferences=preferences,
113
+ preferences_path=preferences_path,
114
+ refractory_seconds=2.0,
115
+ download_dir=_LOCAL_DIR,
116
+ reachy_mini=self.reachy_mini,
117
+ motion_enabled=self.reachy_mini is not None,
118
+ )
119
+
120
+ # Set motion controller reference in state
121
+ self._state.motion = self._motion
122
+
123
+ # Start Reachy Mini media system if available
124
+ if self.reachy_mini is not None:
125
+ try:
126
+ self.reachy_mini.media.start_recording()
127
+ self.reachy_mini.media.start_playing()
128
+ _LOGGER.info("Reachy Mini media system initialized")
129
+ except Exception as e:
130
+ _LOGGER.warning("Failed to initialize Reachy Mini media: %s", e)
131
+
132
+ # Start audio processing thread
133
+ self._running = True
134
+ self._audio_thread = threading.Thread(
135
+ target=self._process_audio,
136
+ daemon=True,
137
+ )
138
+ self._audio_thread.start()
139
+
140
+ # Start camera server if enabled (must be before ESPHome server)
141
+ if self.camera_enabled:
142
+ self._camera_server = MJPEGCameraServer(
143
+ reachy_mini=self.reachy_mini,
144
+ host=self.host,
145
+ port=self.camera_port,
146
+ fps=15,
147
+ quality=80,
148
+ )
149
+ await self._camera_server.start()
150
+
151
+ # Create ESPHome server (pass camera_server for camera entity)
152
+ loop = asyncio.get_running_loop()
153
+ camera_server = self._camera_server # Capture for lambda
154
+ self._server = await loop.create_server(
155
+ lambda: VoiceSatelliteProtocol(self._state, camera_server=camera_server),
156
+ host=self.host,
157
+ port=self.port,
158
+ )
159
+
160
+ # Start mDNS discovery
161
+ self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
162
+ await self._discovery.register_server()
163
+
164
+ _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
165
+
166
+ async def stop(self) -> None:
167
+ """Stop the voice assistant service."""
168
+ _LOGGER.info("Stopping voice assistant service...")
169
+
170
+ self._running = False
171
+
172
+ if self._audio_thread:
173
+ self._audio_thread.join(timeout=2.0)
174
+
175
+ if self._server:
176
+ self._server.close()
177
+ await self._server.wait_closed()
178
+
179
+ if self._discovery:
180
+ await self._discovery.unregister_server()
181
+
182
+ # Stop camera server
183
+ if self._camera_server:
184
+ await self._camera_server.stop()
185
+ self._camera_server = None
186
+
187
+ # Stop Reachy Mini media system
188
+ if self.reachy_mini is not None:
189
+ try:
190
+ self.reachy_mini.media.stop_recording()
191
+ self.reachy_mini.media.stop_playing()
192
+ except Exception as e:
193
+ _LOGGER.warning("Error stopping Reachy Mini media: %s", e)
194
+
195
+ _LOGGER.info("Voice assistant service stopped.")
196
+
197
+ async def _verify_required_files(self) -> None:
198
+ """Verify required model and sound files exist (bundled with package)."""
199
+ # Required wake word files (bundled in wakewords/ directory)
200
+ required_wakewords = [
201
+ "okay_nabu.tflite",
202
+ "okay_nabu.json",
203
+ "hey_jarvis.tflite",
204
+ "hey_jarvis.json",
205
+ "stop.tflite",
206
+ "stop.json",
207
+ ]
208
+
209
+ # Required sound files (bundled in sounds/ directory)
210
+ required_sounds = [
211
+ "wake_word_triggered.flac",
212
+ "timer_finished.flac",
213
+ ]
214
+
215
+ # Verify wake word files
216
+ missing_wakewords = []
217
+ for filename in required_wakewords:
218
+ filepath = _WAKEWORDS_DIR / filename
219
+ if not filepath.exists():
220
+ missing_wakewords.append(filename)
221
+
222
+ if missing_wakewords:
223
+ _LOGGER.warning(
224
+ "Missing wake word files: %s. These should be bundled with the package.",
225
+ missing_wakewords
226
+ )
227
+
228
+ # Verify sound files
229
+ missing_sounds = []
230
+ for filename in required_sounds:
231
+ filepath = _SOUNDS_DIR / filename
232
+ if not filepath.exists():
233
+ missing_sounds.append(filename)
234
+
235
+ if missing_sounds:
236
+ _LOGGER.warning(
237
+ "Missing sound files: %s. These should be bundled with the package.",
238
+ missing_sounds
239
+ )
240
+
241
+ if not missing_wakewords and not missing_sounds:
242
+ _LOGGER.info("All required files verified successfully.")
243
+
244
+ def _load_available_wake_words(self) -> Dict[str, AvailableWakeWord]:
245
+ """Load available wake word configurations."""
246
+ available_wake_words: Dict[str, AvailableWakeWord] = {}
247
+
248
+ wake_word_dirs = [_WAKEWORDS_DIR, _LOCAL_DIR / "external_wake_words"]
249
+
250
+ for wake_word_dir in wake_word_dirs:
251
+ if not wake_word_dir.exists():
252
+ continue
253
+
254
+ for config_path in wake_word_dir.glob("*.json"):
255
+ model_id = config_path.stem
256
+ if model_id == "stop":
257
+ continue
258
+
259
+ try:
260
+ with open(config_path, "r", encoding="utf-8") as f:
261
+ config = json.load(f)
262
+
263
+ model_type = WakeWordType(config.get("type", "micro"))
264
+
265
+ if model_type == WakeWordType.OPEN_WAKE_WORD:
266
+ wake_word_path = config_path.parent / config["model"]
267
+ else:
268
+ wake_word_path = config_path
269
+
270
+ available_wake_words[model_id] = AvailableWakeWord(
271
+ id=model_id,
272
+ type=model_type,
273
+ wake_word=config.get("wake_word", model_id),
274
+ trained_languages=config.get("trained_languages", []),
275
+ wake_word_path=wake_word_path,
276
+ )
277
+ except Exception as e:
278
+ _LOGGER.warning("Failed to load wake word %s: %s", config_path, e)
279
+
280
+ return available_wake_words
281
+
282
+ def _load_preferences(self, preferences_path: Path) -> Preferences:
283
+ """Load user preferences."""
284
+ if preferences_path.exists():
285
+ try:
286
+ with open(preferences_path, "r", encoding="utf-8") as f:
287
+ data = json.load(f)
288
+ return Preferences(**data)
289
+ except Exception as e:
290
+ _LOGGER.warning("Failed to load preferences: %s", e)
291
+
292
+ return Preferences()
293
+
294
+ def _load_wake_models(
295
+ self,
296
+ available_wake_words: Dict[str, AvailableWakeWord],
297
+ preferences: Preferences,
298
+ ):
299
+ """Load wake word models."""
300
+ from pymicro_wakeword import MicroWakeWord
301
+ from pyopen_wakeword import OpenWakeWord
302
+
303
+ wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
304
+ active_wake_words: Set[str] = set()
305
+
306
+ # Try to load preferred models
307
+ if preferences.active_wake_words:
308
+ for wake_word_id in preferences.active_wake_words:
309
+ wake_word = available_wake_words.get(wake_word_id)
310
+ if wake_word is None:
311
+ _LOGGER.warning("Unknown wake word: %s", wake_word_id)
312
+ continue
313
+
314
+ try:
315
+ _LOGGER.debug("Loading wake model: %s", wake_word_id)
316
+ wake_models[wake_word_id] = wake_word.load()
317
+ active_wake_words.add(wake_word_id)
318
+ except Exception as e:
319
+ _LOGGER.warning("Failed to load wake model %s: %s", wake_word_id, e)
320
+
321
+ # Load default model if none loaded
322
+ if not wake_models:
323
+ wake_word = available_wake_words.get(self.wake_model)
324
+ if wake_word:
325
+ try:
326
+ _LOGGER.debug("Loading default wake model: %s", self.wake_model)
327
+ wake_models[self.wake_model] = wake_word.load()
328
+ active_wake_words.add(self.wake_model)
329
+ except Exception as e:
330
+ _LOGGER.error("Failed to load default wake model: %s", e)
331
+
332
+ return wake_models, active_wake_words
333
+
334
+ def _load_stop_model(self):
335
+ """Load the stop word model."""
336
+ from pymicro_wakeword import MicroWakeWord
337
+
338
+ stop_config = _WAKEWORDS_DIR / "stop.json"
339
+ if stop_config.exists():
340
+ try:
341
+ return MicroWakeWord.from_config(stop_config)
342
+ except Exception as e:
343
+ _LOGGER.warning("Failed to load stop model: %s", e)
344
+
345
+ # Return a dummy model if stop model not available
346
+ _LOGGER.warning("Stop model not available, using fallback")
347
+ okay_nabu_config = _WAKEWORDS_DIR / "okay_nabu.json"
348
+ if okay_nabu_config.exists():
349
+ return MicroWakeWord.from_config(okay_nabu_config)
350
+
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,
543
+ channels=1,
544
+ blocksize=block_size,
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.
627
+
628
+ The ReSpeaker DOA returns angle in radians where:
629
+ - 0 radians = left
630
+ - π/2 radians = front/back
631
+ - π radians = right
632
+
633
+ We convert this to head yaw degrees where:
634
+ - 0 = front
635
+ - positive = right
636
+ - negative = left
637
+
638
+ Returns:
639
+ DOA angle in degrees suitable for head yaw, or None if unavailable.
640
+ """
641
+ if self.reachy_mini is None:
642
+ return None
643
+
644
+ try:
645
+ import math
646
+ doa_result = self.reachy_mini.media.get_DoA()
647
+ if doa_result is None:
648
+ _LOGGER.debug("DOA not available")
649
+ return None
650
+
651
+ doa_radians, speech_detected = doa_result
652
+
653
+ # Note: We don't check speech_detected here because we already know
654
+ # speech was detected (wake word triggered this call).
655
+ # The DOA value should still be valid from the recent speech.
656
+
657
+ # Convert ReSpeaker DOA to head yaw angle
658
+ # ReSpeaker: 0=left, π/2=front, π=right
659
+ # Head yaw: 0=front, positive=right, negative=left
660
+ # Formula: yaw = (doa - π/2) converted to degrees
661
+ yaw_radians = doa_radians - (math.pi / 2)
662
+ yaw_degrees = math.degrees(yaw_radians)
663
+
664
+ _LOGGER.info("DOA detected: %.1f rad -> yaw %.1f deg (speech=%s)",
665
+ doa_radians, yaw_degrees, speech_detected)
666
+
667
+ return yaw_degrees
668
+
669
+ except Exception as e:
670
+ _LOGGER.error("Error getting DOA angle: %s", e)
671
+ return None
reachy_mini_ha_voice/wakewords/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # Wake Word Models
2
-
3
- Wake word models are automatically downloaded on first run.
4
-
5
- ## Files
6
-
7
- - `okay_nabu.json` / `okay_nabu.tflite` - Default wake word "Okay Nabu"
8
- - `hey_jarvis.json` / `hey_jarvis.tflite` - Alternative wake word "Hey Jarvis"
9
- - `stop.json` / `stop.tflite` - Stop word for canceling
10
-
11
- These models are downloaded from [esphome/micro-wake-word-models](https://github.com/esphome/micro-wake-word-models).
 
1
+ # Wake Word Models
2
+
3
+ Wake word models are automatically downloaded on first run.
4
+
5
+ ## Files
6
+
7
+ - `okay_nabu.json` / `okay_nabu.tflite` - Default wake word "Okay Nabu"
8
+ - `hey_jarvis.json` / `hey_jarvis.tflite` - Alternative wake word "Hey Jarvis"
9
+ - `stop.json` / `stop.tflite` - Stop word for canceling
10
+
11
+ These models are downloaded from [esphome/micro-wake-word-models](https://github.com/esphome/micro-wake-word-models).
reachy_mini_ha_voice/wakewords/alexa.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Alexa",
4
- "author": "Kevin Ahrendt",
5
- "website": "https://www.kevinahrendt.com/",
6
- "model": "alexa.tflite",
7
- "trained_languages": ["en"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.9,
11
- "sliding_window_size": 5,
12
- "feature_step_size": 10,
13
- "tensor_arena_size": 22348,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Alexa",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "alexa.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.9,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22348,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/choo_choo_homie.json CHANGED
@@ -1,18 +1,18 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Choo Choo Homie",
4
- "author": "Michael Hansen",
5
- "website": "https://www.home-assistant.io",
6
- "model": "choo_choo_homie.tflite",
7
- "trained_languages": [
8
- "en"
9
- ],
10
- "version": 2,
11
- "micro": {
12
- "probability_cutoff": 0.97,
13
- "feature_step_size": 10,
14
- "sliding_window_size": 5,
15
- "tensor_arena_size": 30000,
16
- "minimum_esphome_version": "2024.7.0"
17
- }
18
  }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Choo Choo Homie",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "choo_choo_homie.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
  }
reachy_mini_ha_voice/wakewords/hey_home_assistant.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Hey Home Assistant",
4
- "author": "Michael Hansen",
5
- "website": "https://www.home-assistant.io",
6
- "model": "hey_home_assistant.tflite",
7
- "trained_languages": ["en"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.97,
11
- "feature_step_size": 10,
12
- "sliding_window_size": 5,
13
- "tensor_arena_size": 30000,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Home Assistant",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "hey_home_assistant.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 30000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/hey_jarvis.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Hey Jarvis",
4
- "author": "Kevin Ahrendt",
5
- "website": "https://www.kevinahrendt.com/",
6
- "model": "hey_jarvis.tflite",
7
- "trained_languages": ["en"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.97,
11
- "feature_step_size": 10,
12
- "sliding_window_size": 5,
13
- "tensor_arena_size": 22860,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Jarvis",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_jarvis.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/hey_luna.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "hey_luna",
4
- "author": "adamlonsdale",
5
- "website": "https://github.com/adamlonsdale",
6
- "model": "hey_luna.tflite",
7
- "version": 2,
8
- "trained_languages": ["en"],
9
- "micro": {
10
- "probability_cutoff": 0.63,
11
- "sliding_window_size": 5,
12
- "feature_step_size": 10,
13
- "tensor_arena_size": 22860,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "hey_luna",
4
+ "author": "adamlonsdale",
5
+ "website": "https://github.com/adamlonsdale",
6
+ "model": "hey_luna.tflite",
7
+ "version": 2,
8
+ "trained_languages": ["en"],
9
+ "micro": {
10
+ "probability_cutoff": 0.63,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/hey_mycroft.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Hey Mycroft",
4
- "author": "Kevin Ahrendt",
5
- "website": "https://www.kevinahrendt.com/",
6
- "model": "hey_mycroft.tflite",
7
- "trained_languages": ["en"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.95,
11
- "sliding_window_size": 5,
12
- "feature_step_size": 10,
13
- "tensor_arena_size": 23628,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Mycroft",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_mycroft.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.95,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 23628,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/okay_computer.json CHANGED
@@ -1,18 +1,18 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Okay Computer",
4
- "author": "Michael Hansen",
5
- "website": "https://www.home-assistant.io",
6
- "model": "okay_computer.tflite",
7
- "trained_languages": [
8
- "en"
9
- ],
10
- "version": 2,
11
- "micro": {
12
- "probability_cutoff": 0.97,
13
- "feature_step_size": 10,
14
- "sliding_window_size": 5,
15
- "tensor_arena_size": 30000,
16
- "minimum_esphome_version": "2024.7.0"
17
- }
18
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Computer",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "okay_computer.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
+ }
reachy_mini_ha_voice/wakewords/okay_nabu.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Okay Nabu",
4
- "author": "Kevin Ahrendt",
5
- "website": "https://www.kevinahrendt.com/",
6
- "model": "okay_nabu.tflite",
7
- "trained_languages": ["en","nl","fr","de","it","es","sv"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.85,
11
- "feature_step_size": 10,
12
- "sliding_window_size": 5,
13
- "tensor_arena_size": 37000,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Nabu",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "okay_nabu.tflite",
7
+ "trained_languages": ["en","nl","fr","de","it","es","sv"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.85,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 37000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
reachy_mini_ha_voice/wakewords/openWakeWord/alexa_v0.1.json CHANGED
@@ -1,5 +1,5 @@
1
- {
2
- "type": "openWakeWord",
3
- "wake_word": "Alexa",
4
- "model": "alexa_v0.1.tflite"
5
- }
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Alexa",
4
+ "model": "alexa_v0.1.tflite"
5
+ }
reachy_mini_ha_voice/wakewords/openWakeWord/hey_jarvis_v0.1.json CHANGED
@@ -1,5 +1,5 @@
1
- {
2
- "type": "openWakeWord",
3
- "wake_word": "Hey Jarvis",
4
- "model": "hey_jarvis_v0.1.tflite"
5
- }
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Jarvis",
4
+ "model": "hey_jarvis_v0.1.tflite"
5
+ }
reachy_mini_ha_voice/wakewords/openWakeWord/hey_mycroft_v0.1.json CHANGED
@@ -1,5 +1,5 @@
1
- {
2
- "type": "openWakeWord",
3
- "wake_word": "Hey Mycroft",
4
- "model": "hey_mycroft_v0.1.tflite"
5
- }
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Mycroft",
4
+ "model": "hey_mycroft_v0.1.tflite"
5
+ }
reachy_mini_ha_voice/wakewords/openWakeWord/hey_rhasspy_v0.1.json CHANGED
@@ -1,5 +1,5 @@
1
- {
2
- "type": "openWakeWord",
3
- "wake_word": "Hey Rhasspy",
4
- "model": "hey_rhasspy_v0.1.tflite"
5
- }
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Rhasspy",
4
+ "model": "hey_rhasspy_v0.1.tflite"
5
+ }
reachy_mini_ha_voice/wakewords/openWakeWord/ok_nabu_v0.1.json CHANGED
@@ -1,5 +1,5 @@
1
- {
2
- "type": "openWakeWord",
3
- "wake_word": "Okay Nabu",
4
- "model": "ok_nabu_v0.1.tflite"
5
- }
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Okay Nabu",
4
+ "model": "ok_nabu_v0.1.tflite"
5
+ }
reachy_mini_ha_voice/wakewords/stop.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "type": "micro",
3
- "wake_word": "Stop",
4
- "author": "Kevin Ahrendt",
5
- "website": "https://www.kevinahrendt.com/",
6
- "model": "stop.tflite",
7
- "trained_languages": ["en"],
8
- "version": 2,
9
- "micro": {
10
- "probability_cutoff": 0.5,
11
- "feature_step_size": 10,
12
- "sliding_window_size": 5,
13
- "tensor_arena_size": 21000,
14
- "minimum_esphome_version": "2024.7.0"
15
- }
16
- }
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Stop",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "stop.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.5,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 21000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
style.css ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ color: white;
18
+ padding: 4rem 2rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 800px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 4rem;
29
+ margin-bottom: 1rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 3rem;
35
+ font-weight: 700;
36
+ margin-bottom: 1rem;
37
+ background: linear-gradient(45deg, #fff, #f0f9ff);
38
+ background-clip: text;
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ }
42
+
43
+ .tagline {
44
+ font-size: 1.25rem;
45
+ opacity: 0.9;
46
+ max-width: 600px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .container {
51
+ max-width: 1200px;
52
+ margin: 0 auto;
53
+ padding: 0 2rem;
54
+ position: relative;
55
+ z-index: 2;
56
+ }
57
+
58
+ .main-card {
59
+ background: white;
60
+ border-radius: 20px;
61
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
+ margin-top: -2rem;
63
+ overflow: hidden;
64
+ margin-bottom: 3rem;
65
+ }
66
+
67
+ .app-preview {
68
+ background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
+ padding: 3rem;
70
+ color: white;
71
+ text-align: center;
72
+ position: relative;
73
+ }
74
+
75
+ .preview-image {
76
+ background: #000;
77
+ border-radius: 15px;
78
+ padding: 2rem;
79
+ max-width: 500px;
80
+ margin: 0 auto;
81
+ position: relative;
82
+ overflow: hidden;
83
+ }
84
+
85
+ .camera-feed {
86
+ font-size: 4rem;
87
+ margin-bottom: 1rem;
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .detection-overlay {
92
+ position: absolute;
93
+ top: 50%;
94
+ left: 50%;
95
+ transform: translate(-50%, -50%);
96
+ width: 100%;
97
+ }
98
+
99
+ .bbox {
100
+ background: rgba(34, 197, 94, 0.9);
101
+ color: white;
102
+ padding: 0.5rem 1rem;
103
+ border-radius: 8px;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ margin: 0.5rem;
107
+ display: inline-block;
108
+ border: 2px solid #22c55e;
109
+ }
110
+
111
+ .app-details {
112
+ padding: 3rem;
113
+ }
114
+
115
+ .app-details h2 {
116
+ font-size: 2rem;
117
+ color: #1e293b;
118
+ margin-bottom: 2rem;
119
+ text-align: center;
120
+ }
121
+
122
+ .template-info {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
+ gap: 2rem;
126
+ margin-bottom: 3rem;
127
+ }
128
+
129
+ .info-box {
130
+ background: #f0f9ff;
131
+ border: 2px solid #e0f2fe;
132
+ border-radius: 12px;
133
+ padding: 2rem;
134
+ }
135
+
136
+ .info-box h3 {
137
+ color: #0c4a6e;
138
+ margin-bottom: 1rem;
139
+ font-size: 1.2rem;
140
+ }
141
+
142
+ .info-box p {
143
+ color: #0369a1;
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .how-to-use {
148
+ background: #fefce8;
149
+ border: 2px solid #fde047;
150
+ border-radius: 12px;
151
+ padding: 2rem;
152
+ margin-top: 3rem;
153
+ }
154
+
155
+ .how-to-use h3 {
156
+ color: #a16207;
157
+ margin-bottom: 1.5rem;
158
+ font-size: 1.3rem;
159
+ text-align: center;
160
+ }
161
+
162
+ .steps {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1.5rem;
166
+ }
167
+
168
+ .step {
169
+ display: flex;
170
+ align-items: flex-start;
171
+ gap: 1rem;
172
+ }
173
+
174
+ .step-number {
175
+ background: #eab308;
176
+ color: white;
177
+ width: 2rem;
178
+ height: 2rem;
179
+ border-radius: 50%;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-weight: bold;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .step h4 {
188
+ color: #a16207;
189
+ margin-bottom: 0.5rem;
190
+ font-size: 1.1rem;
191
+ }
192
+
193
+ .step p {
194
+ color: #ca8a04;
195
+ }
196
+
197
+ .download-card {
198
+ background: white;
199
+ border-radius: 20px;
200
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
+ padding: 3rem;
202
+ text-align: center;
203
+ }
204
+
205
+ .download-card h2 {
206
+ font-size: 2rem;
207
+ color: #1e293b;
208
+ margin-bottom: 1rem;
209
+ }
210
+
211
+ .download-card>p {
212
+ color: #64748b;
213
+ font-size: 1.1rem;
214
+ margin-bottom: 2rem;
215
+ }
216
+
217
+ .dashboard-config {
218
+ margin-bottom: 2rem;
219
+ text-align: left;
220
+ max-width: 400px;
221
+ margin-left: auto;
222
+ margin-right: auto;
223
+ }
224
+
225
+ .dashboard-config label {
226
+ display: block;
227
+ color: #374151;
228
+ font-weight: 600;
229
+ margin-bottom: 0.5rem;
230
+ }
231
+
232
+ .dashboard-config input {
233
+ width: 100%;
234
+ padding: 0.75rem 1rem;
235
+ border: 2px solid #e5e7eb;
236
+ border-radius: 8px;
237
+ font-size: 0.95rem;
238
+ transition: border-color 0.2s;
239
+ }
240
+
241
+ .dashboard-config input:focus {
242
+ outline: none;
243
+ border-color: #667eea;
244
+ }
245
+
246
+ .install-btn {
247
+ background: linear-gradient(135deg, #667eea, #764ba2);
248
+ color: white;
249
+ border: none;
250
+ padding: 1.25rem 3rem;
251
+ border-radius: 16px;
252
+ font-size: 1.2rem;
253
+ font-weight: 700;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ margin-bottom: 2rem;
260
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
+ }
262
+
263
+ .install-btn:hover:not(:disabled) {
264
+ transform: translateY(-3px);
265
+ box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .install-btn:disabled {
269
+ opacity: 0.7;
270
+ cursor: not-allowed;
271
+ transform: none;
272
+ }
273
+
274
+ .manual-option {
275
+ background: #f8fafc;
276
+ border-radius: 12px;
277
+ padding: 2rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ .manual-option h3 {
282
+ color: #1e293b;
283
+ margin-bottom: 1rem;
284
+ font-size: 1.2rem;
285
+ }
286
+
287
+ .manual-option>p {
288
+ color: #64748b;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ .btn-icon {
293
+ font-size: 1.1rem;
294
+ }
295
+
296
+ .install-status {
297
+ padding: 1rem;
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ text-align: center;
301
+ display: none;
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .install-status.success {
306
+ background: #dcfce7;
307
+ color: #166534;
308
+ border: 1px solid #bbf7d0;
309
+ }
310
+
311
+ .install-status.error {
312
+ background: #fef2f2;
313
+ color: #dc2626;
314
+ border: 1px solid #fecaca;
315
+ }
316
+
317
+ .install-status.loading {
318
+ background: #dbeafe;
319
+ color: #1d4ed8;
320
+ border: 1px solid #bfdbfe;
321
+ }
322
+
323
+ .install-status.info {
324
+ background: #e0f2fe;
325
+ color: #0369a1;
326
+ border: 1px solid #7dd3fc;
327
+ }
328
+
329
+ .manual-install {
330
+ background: #1f2937;
331
+ border-radius: 8px;
332
+ padding: 1rem;
333
+ margin-bottom: 1rem;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ }
338
+
339
+ .manual-install code {
340
+ color: #10b981;
341
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
+ font-size: 0.85rem;
343
+ flex: 1;
344
+ overflow-x: auto;
345
+ }
346
+
347
+ .copy-btn {
348
+ background: #374151;
349
+ color: white;
350
+ border: none;
351
+ padding: 0.5rem 1rem;
352
+ border-radius: 6px;
353
+ font-size: 0.8rem;
354
+ cursor: pointer;
355
+ transition: background-color 0.2s;
356
+ }
357
+
358
+ .copy-btn:hover {
359
+ background: #4b5563;
360
+ }
361
+
362
+ .manual-steps {
363
+ color: #6b7280;
364
+ font-size: 0.9rem;
365
+ line-height: 1.8;
366
+ }
367
+
368
+ .footer {
369
+ text-align: center;
370
+ padding: 2rem;
371
+ color: white;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .footer a {
376
+ color: white;
377
+ text-decoration: none;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .footer a:hover {
382
+ text-decoration: underline;
383
+ }
384
+
385
+ /* Responsive Design */
386
+ @media (max-width: 768px) {
387
+ .hero {
388
+ padding: 2rem 1rem;
389
+ }
390
+
391
+ .hero h1 {
392
+ font-size: 2rem;
393
+ }
394
+
395
+ .container {
396
+ padding: 0 1rem;
397
+ }
398
+
399
+ .app-details,
400
+ .download-card {
401
+ padding: 2rem;
402
+ }
403
+
404
+ .features-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .download-options {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ }