Commit ·
9da872f
1
Parent(s): 80662a9
Update app
Browse files- .claude/settings.local.json +0 -20
- .gitattributes +0 -4
- .gitignore +1 -74
- .serena/.gitignore +0 -1
- .serena/project.yml +0 -84
- PROJECT_PLAN.md +406 -406
- README.md +199 -203
- app.py +50 -50
- index.html +40 -0
- linux-voice-assistant +0 -1
- pyproject.toml +49 -49
- reachy_mini_conversation_app +0 -1
- reachy_mini_ha_voice/__init__.py +22 -22
- reachy_mini_ha_voice/camera_server.py +365 -365
- reachy_mini_ha_voice/entity_extensions.py +292 -292
- reachy_mini_ha_voice/main.py +175 -175
- reachy_mini_ha_voice/reachy_controller.py +1008 -1008
- reachy_mini_ha_voice/satellite.py +0 -0
- reachy_mini_ha_voice/sounds/.gitkeep +1 -1
- reachy_mini_ha_voice/sounds/LICENSE.md +1 -1
- reachy_mini_ha_voice/sounds/README.md +9 -9
- reachy_mini_ha_voice/static/index.html +27 -0
- reachy_mini_ha_voice/static/main.js +47 -0
- reachy_mini_ha_voice/static/style.css +25 -0
- reachy_mini_ha_voice/voice_assistant.py +671 -671
- reachy_mini_ha_voice/wakewords/README.md +11 -11
- reachy_mini_ha_voice/wakewords/alexa.json +16 -16
- reachy_mini_ha_voice/wakewords/choo_choo_homie.json +17 -17
- reachy_mini_ha_voice/wakewords/hey_home_assistant.json +16 -16
- reachy_mini_ha_voice/wakewords/hey_jarvis.json +16 -16
- reachy_mini_ha_voice/wakewords/hey_luna.json +16 -16
- reachy_mini_ha_voice/wakewords/hey_mycroft.json +16 -16
- reachy_mini_ha_voice/wakewords/okay_computer.json +18 -18
- reachy_mini_ha_voice/wakewords/okay_nabu.json +16 -16
- reachy_mini_ha_voice/wakewords/openWakeWord/alexa_v0.1.json +5 -5
- reachy_mini_ha_voice/wakewords/openWakeWord/hey_jarvis_v0.1.json +5 -5
- reachy_mini_ha_voice/wakewords/openWakeWord/hey_mycroft_v0.1.json +5 -5
- reachy_mini_ha_voice/wakewords/openWakeWord/hey_rhasspy_v0.1.json +5 -5
- reachy_mini_ha_voice/wakewords/openWakeWord/ok_nabu_v0.1.json +5 -5
- reachy_mini_ha_voice/wakewords/stop.json +16 -16
- 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 |
-
|
| 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
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
- **
|
| 21 |
-
- **
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
##
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
- **
|
| 87 |
-
- **
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
- **
|
| 93 |
-
- **
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
- **
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
- **
|
| 103 |
-
|
| 104 |
-
###
|
| 105 |
-
- **
|
| 106 |
-
- **
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
- **
|
| 110 |
-
- **
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
- **
|
| 114 |
-
- **
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
- **
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
- **
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
- **
|
| 133 |
-
- **
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
``
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
- `
|
| 190 |
-
- `
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
##
|
| 197 |
-
|
| 198 |
-
|
| 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:
|
| 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://<ip>:{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://<ip>:{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
|
| 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 =
|
| 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 |
+
}
|