GitHub Action commited on
Commit ·
c5fd1f5
0
Parent(s):
Fresh sync: 2026-05-05 13:12:05
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/settings.local.json +55 -0
- .gitattributes +5 -0
- .github/dependabot.yml +13 -0
- .github/workflows/sync_develop_to_hf_edge.yml +86 -0
- .github/workflows/sync_to_hf.yml +36 -0
- .gitignore +83 -0
- .pre-commit-config.yaml +20 -0
- CHANGELOG.md +713 -0
- Project_Summary.md +1439 -0
- README.md +15 -0
- changelog.json +666 -0
- docs/USER_MANUAL_CN.md +244 -0
- docs/USER_MANUAL_EN.md +244 -0
- home_assistant_blueprints/reachy_mini_presence_companion.yaml +246 -0
- index.html +301 -0
- pyproject.toml +179 -0
- reachy_mini_home_assistant/__init__.py +29 -0
- reachy_mini_home_assistant/__main__.py +121 -0
- reachy_mini_home_assistant/animations/animation_config.py +100 -0
- reachy_mini_home_assistant/animations/conversation_animations.json +0 -0
- reachy_mini_home_assistant/audio/__init__.py +15 -0
- reachy_mini_home_assistant/audio/audio_player.py +79 -0
- reachy_mini_home_assistant/audio/audio_player_local.py +144 -0
- reachy_mini_home_assistant/audio/audio_player_playback.py +198 -0
- reachy_mini_home_assistant/audio/audio_player_sendspin.py +643 -0
- reachy_mini_home_assistant/audio/audio_player_shared.py +125 -0
- reachy_mini_home_assistant/audio/audio_player_stream_decoded.py +243 -0
- reachy_mini_home_assistant/audio/audio_player_stream_pcm.py +102 -0
- reachy_mini_home_assistant/audio/audio_player_wobble.py +7 -0
- reachy_mini_home_assistant/audio/doa_tracker.py +198 -0
- reachy_mini_home_assistant/audio/local_audio_player.py +39 -0
- reachy_mini_home_assistant/core/__init__.py +47 -0
- reachy_mini_home_assistant/core/config.py +435 -0
- reachy_mini_home_assistant/core/exceptions.py +72 -0
- reachy_mini_home_assistant/core/service_base.py +551 -0
- reachy_mini_home_assistant/core/system_diagnostics.py +207 -0
- reachy_mini_home_assistant/core/util.py +26 -0
- reachy_mini_home_assistant/entities/__init__.py +74 -0
- reachy_mini_home_assistant/entities/emotion_detector.py +115 -0
- reachy_mini_home_assistant/entities/entity.py +409 -0
- reachy_mini_home_assistant/entities/entity_extensions.py +300 -0
- reachy_mini_home_assistant/entities/entity_factory.py +538 -0
- reachy_mini_home_assistant/entities/entity_keys.py +133 -0
- reachy_mini_home_assistant/entities/entity_registry.py +428 -0
- reachy_mini_home_assistant/entities/event_emotion_mapper.py +403 -0
- reachy_mini_home_assistant/entities/runtime_entity_setup.py +257 -0
- reachy_mini_home_assistant/entities/sensor_entity_setup.py +203 -0
- reachy_mini_home_assistant/main.py +140 -0
- reachy_mini_home_assistant/models.py +178 -0
- reachy_mini_home_assistant/models/crops_classifier.onnx +3 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
| 3 |
+
"includeCoAuthoredBy": false,
|
| 4 |
+
"permissions": {
|
| 5 |
+
"allow": [
|
| 6 |
+
"Bash",
|
| 7 |
+
"BashOutput",
|
| 8 |
+
"Edit",
|
| 9 |
+
"Glob",
|
| 10 |
+
"Grep",
|
| 11 |
+
"KillShell",
|
| 12 |
+
"NotebookEdit",
|
| 13 |
+
"Read",
|
| 14 |
+
"SlashCommand",
|
| 15 |
+
"Task",
|
| 16 |
+
"TodoWrite",
|
| 17 |
+
"WebFetch",
|
| 18 |
+
"WebSearch",
|
| 19 |
+
"Write",
|
| 20 |
+
"mcp__ide",
|
| 21 |
+
"mcp__exa",
|
| 22 |
+
"mcp__context7",
|
| 23 |
+
"mcp__mcp-deepwiki",
|
| 24 |
+
"mcp__Playwright",
|
| 25 |
+
"mcp__spec-workflow",
|
| 26 |
+
"mcp__open-websearch",
|
| 27 |
+
"mcp__serena",
|
| 28 |
+
"All",
|
| 29 |
+
"Bash(copy:*)",
|
| 30 |
+
"mcp__zread__search_doc",
|
| 31 |
+
"mcp__zread__read_file",
|
| 32 |
+
"Bash(cd:*)",
|
| 33 |
+
"Bash(ls:*)",
|
| 34 |
+
"Bash(find:*)",
|
| 35 |
+
"mcp__acp__Bash",
|
| 36 |
+
"Skill(commit-commands:commit)",
|
| 37 |
+
"Skill(commit-commands:commit:*)"
|
| 38 |
+
],
|
| 39 |
+
"deny": [],
|
| 40 |
+
"ask": []
|
| 41 |
+
},
|
| 42 |
+
"model": "opus",
|
| 43 |
+
"hooks": {},
|
| 44 |
+
"statusLine": {
|
| 45 |
+
"type": "command",
|
| 46 |
+
"command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe",
|
| 47 |
+
"padding": 0
|
| 48 |
+
},
|
| 49 |
+
"enabledPlugins": {
|
| 50 |
+
"glm-plan-usage@zai-coding-plugins": true,
|
| 51 |
+
"glm-plan-bug@zai-coding-plugins": true
|
| 52 |
+
},
|
| 53 |
+
"outputStyle": "Explanatory",
|
| 54 |
+
"alwaysThinkingEnabled": true
|
| 55 |
+
}
|
.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LFS tracking for large binary files
|
| 2 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.flac filter=lfs diff=lfs merge=lfs -text
|
.github/dependabot.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
updates:
|
| 3 |
+
# Enable version updates for pip
|
| 4 |
+
- package-ecosystem: "pip"
|
| 5 |
+
directory: "/"
|
| 6 |
+
schedule:
|
| 7 |
+
interval: "weekly"
|
| 8 |
+
# Ignore PyTorch updates - locked version required for compatibility
|
| 9 |
+
ignore:
|
| 10 |
+
- dependency-name: "torch"
|
| 11 |
+
versions: [">2.5.1"]
|
| 12 |
+
- dependency-name: "torchvision"
|
| 13 |
+
versions: [">0.20.1"]
|
.github/workflows/sync_develop_to_hf_edge.yml
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync Develop to Hugging Face Edge
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [develop]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync-edge:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout GitHub repo
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
with:
|
| 15 |
+
lfs: true
|
| 16 |
+
|
| 17 |
+
- name: Transform project name for edge channel
|
| 18 |
+
run: |
|
| 19 |
+
python - <<'PY'
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
# Keep runtime module path unchanged, only rewrite package/app naming metadata.
|
| 23 |
+
pyproject = Path('pyproject.toml')
|
| 24 |
+
text = pyproject.read_text(encoding='utf-8')
|
| 25 |
+
text = text.replace(
|
| 26 |
+
'name = "reachy_mini_home_assistant"',
|
| 27 |
+
'name = "reachy_mini_home_assistant_edge"',
|
| 28 |
+
1,
|
| 29 |
+
)
|
| 30 |
+
text = text.replace(
|
| 31 |
+
'reachy_mini_home_assistant = "reachy_mini_home_assistant.main:ReachyMiniHaVoice"',
|
| 32 |
+
'reachy_mini_home_assistant_edge = "reachy_mini_home_assistant.main:ReachyMiniHaVoice"',
|
| 33 |
+
1,
|
| 34 |
+
)
|
| 35 |
+
pyproject.write_text(text, encoding='utf-8')
|
| 36 |
+
|
| 37 |
+
init_file = Path('reachy_mini_home_assistant/__init__.py')
|
| 38 |
+
init_text = init_file.read_text(encoding='utf-8')
|
| 39 |
+
init_text = init_text.replace(
|
| 40 |
+
'version("reachy_mini_home_assistant")',
|
| 41 |
+
'version("reachy_mini_home_assistant_edge")',
|
| 42 |
+
1,
|
| 43 |
+
)
|
| 44 |
+
init_file.write_text(init_text, encoding='utf-8')
|
| 45 |
+
|
| 46 |
+
readme = Path('README.md')
|
| 47 |
+
if readme.exists():
|
| 48 |
+
readme_text = readme.read_text(encoding='utf-8')
|
| 49 |
+
readme_text = readme_text.replace(
|
| 50 |
+
'title: Reachy Mini for Home Assistant',
|
| 51 |
+
'title: Reachy Mini for Home Assistant (Edge)',
|
| 52 |
+
1,
|
| 53 |
+
)
|
| 54 |
+
readme_text = readme_text.replace(
|
| 55 |
+
'short_description: Deep integration of Reachy Mini robot with Home Assistant',
|
| 56 |
+
'short_description: Edge channel for Reachy Mini Home Assistant integration',
|
| 57 |
+
1,
|
| 58 |
+
)
|
| 59 |
+
readme_text = readme_text.replace(
|
| 60 |
+
' - reachy_mini_home_assistant',
|
| 61 |
+
' - reachy_mini_home_assistant_edge',
|
| 62 |
+
1,
|
| 63 |
+
)
|
| 64 |
+
readme.write_text(readme_text, encoding='utf-8')
|
| 65 |
+
PY
|
| 66 |
+
|
| 67 |
+
- name: Create fresh commit and push to Hugging Face edge space
|
| 68 |
+
env:
|
| 69 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 70 |
+
run: |
|
| 71 |
+
git config --global user.email "action@github.com"
|
| 72 |
+
git config --global user.name "GitHub Action"
|
| 73 |
+
|
| 74 |
+
# Create a new orphan branch with no history
|
| 75 |
+
git checkout --orphan hf-edge-sync
|
| 76 |
+
git add -A
|
| 77 |
+
git commit -m "Fresh edge sync: $(date +%Y-%m-%d_%H:%M:%S)"
|
| 78 |
+
|
| 79 |
+
# Add Hugging Face edge remote
|
| 80 |
+
git remote add hf-edge https://djhui5710:$HF_TOKEN@huggingface.co/spaces/djhui5710/reachy_mini_home_assistant_edge
|
| 81 |
+
|
| 82 |
+
# Push LFS objects first
|
| 83 |
+
git lfs push hf-edge hf-edge-sync --all
|
| 84 |
+
|
| 85 |
+
# Force push as main to HF edge space
|
| 86 |
+
git push hf-edge hf-edge-sync:main --force
|
.github/workflows/sync_to_hf.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout GitHub repo
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
with:
|
| 15 |
+
lfs: true
|
| 16 |
+
|
| 17 |
+
- name: Create fresh commit and push to Hugging Face
|
| 18 |
+
env:
|
| 19 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 20 |
+
run: |
|
| 21 |
+
git config --global user.email "action@github.com"
|
| 22 |
+
git config --global user.name "GitHub Action"
|
| 23 |
+
|
| 24 |
+
# Create a new orphan branch with no history
|
| 25 |
+
git checkout --orphan hf-sync
|
| 26 |
+
git add -A
|
| 27 |
+
git commit -m "Fresh sync: $(date +%Y-%m-%d\ %H:%M:%S)"
|
| 28 |
+
|
| 29 |
+
# Add Hugging Face remote
|
| 30 |
+
git remote add hf https://djhui5710:$HF_TOKEN@huggingface.co/spaces/djhui5710/reachy_mini_home_assistant
|
| 31 |
+
|
| 32 |
+
# Push LFS objects first
|
| 33 |
+
git lfs push hf hf-sync --all
|
| 34 |
+
|
| 35 |
+
# Force push as main to HF (overwrites all history)
|
| 36 |
+
git push hf hf-sync:main --force
|
.gitignore
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
pip-wheel-metadata/
|
| 20 |
+
share/python-wheels/
|
| 21 |
+
*.egg-info/
|
| 22 |
+
.installed.cfg
|
| 23 |
+
*.egg
|
| 24 |
+
MANIFEST
|
| 25 |
+
|
| 26 |
+
# Virtual Environment
|
| 27 |
+
.venv/
|
| 28 |
+
venv/
|
| 29 |
+
ENV/
|
| 30 |
+
env/
|
| 31 |
+
|
| 32 |
+
# IDE
|
| 33 |
+
.vscode/
|
| 34 |
+
.idea/
|
| 35 |
+
*.swp
|
| 36 |
+
*.swo
|
| 37 |
+
.claude/*
|
| 38 |
+
.serena/*
|
| 39 |
+
.spec-workflow/
|
| 40 |
+
.playwright-mcp/
|
| 41 |
+
*~
|
| 42 |
+
CLAUDE.md
|
| 43 |
+
commit_msg.txt
|
| 44 |
+
|
| 45 |
+
# Configuration
|
| 46 |
+
config.json
|
| 47 |
+
.env
|
| 48 |
+
*.log
|
| 49 |
+
|
| 50 |
+
# Cache
|
| 51 |
+
.cache/
|
| 52 |
+
*.cache
|
| 53 |
+
.DS_Store
|
| 54 |
+
|
| 55 |
+
# Testing
|
| 56 |
+
.pytest_cache/
|
| 57 |
+
.coverage
|
| 58 |
+
htmlcov/
|
| 59 |
+
.tox/
|
| 60 |
+
|
| 61 |
+
# Audio (exclude package bundled files)
|
| 62 |
+
*.wav
|
| 63 |
+
*.mp3
|
| 64 |
+
# *.flac - bundled in package
|
| 65 |
+
!reachy_mini_ha_voice/sounds/*.flac
|
| 66 |
+
|
| 67 |
+
# Models (exclude package bundled files)
|
| 68 |
+
# models/ - ignore external models directory
|
| 69 |
+
models/
|
| 70 |
+
# Package bundled models
|
| 71 |
+
!reachy_mini_ha_voice/models/
|
| 72 |
+
reachy_mini_ha_voice/models/*.tflite
|
| 73 |
+
reachy_mini_ha_voice/models/*.onnx
|
| 74 |
+
reachy_mini_ha_voice/models/*.pt
|
| 75 |
+
|
| 76 |
+
# SDK Reference (local development only)
|
| 77 |
+
reference/
|
| 78 |
+
local/
|
| 79 |
+
# ha/ - temporarily commented out for path fixes
|
| 80 |
+
# ha/ will be moved to separate repository soon
|
| 81 |
+
|
| 82 |
+
# Temporary check scripts
|
| 83 |
+
temp_check_scripts/
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pre-commit hooks for code quality
|
| 2 |
+
# Install: pip install pre-commit && pre-commit install
|
| 3 |
+
# Run manually: pre-commit run --all-files
|
| 4 |
+
|
| 5 |
+
repos:
|
| 6 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 7 |
+
rev: v0.8.6
|
| 8 |
+
hooks:
|
| 9 |
+
- id: ruff
|
| 10 |
+
args: [--fix]
|
| 11 |
+
- id: ruff-format
|
| 12 |
+
|
| 13 |
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
| 14 |
+
rev: v1.14.1
|
| 15 |
+
hooks:
|
| 16 |
+
- id: mypy
|
| 17 |
+
additional_dependencies: []
|
| 18 |
+
args: [--ignore-missing-imports]
|
| 19 |
+
# Only check changed files for speed
|
| 20 |
+
pass_filenames: true
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to the Reachy Mini HA Voice project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
### Fixed
|
| 11 |
+
- **NameError** - Add missing deque import in gesture smoother
|
| 12 |
+
- **Syntax Error** - Add missing class indentation for volume methods in audio_player.py
|
| 13 |
+
- **Audio Card Name Detection** - Use SDK's detection logic instead of hardcoded values
|
| 14 |
+
- **SDK Port 8000 Blocking** - Use amixer directly for volume control to avoid SDK HTTP API blocking
|
| 15 |
+
- **Memory Leak Root Cause** - Audio buffer array creation in loop causing unbounded memory growth
|
| 16 |
+
- **Indentation Error** - Fix indentation in audio_player.py stop_sendspin method
|
| 17 |
+
|
| 18 |
+
## [1.0.7] - 2026-05-05
|
| 19 |
+
|
| 20 |
+
### Changed
|
| 21 |
+
- Align audio runtime with current SDK patterns by splitting local TTS playback from Sendspin-capable music playback and moving wakeword/stopword loading into shared helpers
|
| 22 |
+
- Raise the Reachy Mini SDK baseline to `reachy-mini>=1.7.1`
|
| 23 |
+
|
| 24 |
+
### Fixed
|
| 25 |
+
- Keep wakeup/TTS playback on the local player path while binding both local and Sendspin players to shared speech sway helpers
|
| 26 |
+
- Synchronize `Idle Behavior` shutdown with ESPHome face/gesture switches and runtime state updates
|
| 27 |
+
- Remove obsolete runtime monitor modules that are no longer needed with the current SDK behavior
|
| 28 |
+
|
| 29 |
+
### Optimized
|
| 30 |
+
- Tighten Sendspin buffering with proactive backpressure and cleaner local queue handling
|
| 31 |
+
|
| 32 |
+
## [1.0.6] - 2026-05-01
|
| 33 |
+
|
| 34 |
+
### Changed
|
| 35 |
+
- Align `pyproject.toml` with the current Reachy Mini SDK baseline by requiring `reachy-mini>=1.7.0`, `Python>=3.12`, `zeroconf>=0.131,<1`, `aiohttp`, `websockets>=12,<16`, and `gstreamer-bundle==1.28.1` on non-Linux platforms
|
| 36 |
+
- Align Sendspin client dependency with the current upstream line via `aiosendspin>=5.1,<6.0`
|
| 37 |
+
|
| 38 |
+
### Fixed
|
| 39 |
+
- Fetch camera snapshot frames on demand when the MJPEG cache is empty so Home Assistant camera proxy requests keep working with the Reachy Mini SDK 1.7.0 media pull model
|
| 40 |
+
|
| 41 |
+
### Optimized
|
| 42 |
+
- Stop the camera server entirely when `Idle Behavior` is disabled instead of only unloading vision models, so idle-without-animation behaves more like a low-resource sleep state
|
| 43 |
+
|
| 44 |
+
## [1.0.5] - 2026-04-12
|
| 45 |
+
|
| 46 |
+
### Changed
|
| 47 |
+
- Remove app-managed robot sleep/wake handling because current Reachy Mini SDK no longer supports mini apps remaining active while the robot enters sleep
|
| 48 |
+
- Keep resource suspend/resume limited to ESPHome-driven runtime toggles such as Home Assistant disconnect, mute, camera disable, and service recovery
|
| 49 |
+
- Align `pyproject.toml` runtime constraints with the current Reachy Mini reference SDK package (`reachy-mini>=1.6.3`, `websockets>=12,<16`, Python baseline `>=3.10`, and uv gstreamer metadata)
|
| 50 |
+
|
| 51 |
+
### Removed
|
| 52 |
+
- Remove `SleepManager` integration and app-side sleep/wake callback flow from the voice assistant runtime
|
| 53 |
+
- Remove Home Assistant sleep control entities and internal robot sleep state tracking from the mini app
|
| 54 |
+
|
| 55 |
+
## [1.0.4] - 2026-03-19
|
| 56 |
+
|
| 57 |
+
### Fixed
|
| 58 |
+
- Align Reachy Mini integration with current SDK assumptions by removing legacy compatibility paths and private client health checks
|
| 59 |
+
- Replace direct SDK private `_respeaker` access with `audio_control_utils`-based ReSpeaker initialization
|
| 60 |
+
- Tighten camera and pose composition to require current SDK media/utils APIs and valid `look_at_image` inputs
|
| 61 |
+
|
| 62 |
+
### Improved
|
| 63 |
+
- Unify idle behavior into a single persisted Home Assistant entity and remove old idle compatibility aliases
|
| 64 |
+
- Replace separate wake/sleep buttons with a single sleep control entity
|
| 65 |
+
- Update Sendspin integration for current `aiosendspin` lifecycle, stream handling, listener cleanup, and synchronized buffering
|
| 66 |
+
- Standardize daemon URL usage on shared config across controller, sleep manager, and daemon monitor
|
| 67 |
+
|
| 68 |
+
## [1.0.3] - 2026-03-07
|
| 69 |
+
|
| 70 |
+
### Added
|
| 71 |
+
- Idle Random Actions switch in Home Assistant with preferences persistence and startup restore
|
| 72 |
+
- Configurable `idle_random_actions` presets in `conversation_animations.json` for centralized idle motion tuning
|
| 73 |
+
|
| 74 |
+
### Fixed
|
| 75 |
+
- Remove duplicate `idle_random_actions` fields/methods and complete runtime control wiring in controller/entity registry/movement manager
|
| 76 |
+
|
| 77 |
+
### Optimized
|
| 78 |
+
- Increase idle breathing and antenna sway cadence to 0.24Hz with wiggle antenna profile for more natural standby motion
|
| 79 |
+
- Remove `set_target` global rate limiting and unchanged-pose skip gating to continuously stream motion commands each control tick
|
| 80 |
+
- Remove idle antenna slew-rate limiter so antenna motion follows animation waveforms directly for reference-like smoothness
|
| 81 |
+
|
| 82 |
+
## [1.0.2] - 2026-03-06
|
| 83 |
+
|
| 84 |
+
### Fixed
|
| 85 |
+
- Restore idle antenna sway animation and tune idle breathing parameters to reduce perceived stiffness
|
| 86 |
+
- Reintroduce idle anti-chatter smoothing/deadband for antenna and body updates to reduce mechanical jitter/noise
|
| 87 |
+
- Switch sleep/wake control to daemon API (`start` with `wake_up=true`, `stop` with `goto_sleep=true`) so `/api/daemon/status` reflects real sleep state on SDK 1.5
|
| 88 |
+
- Normalize daemon status parsing for SDK 1.5 object-based status responses
|
| 89 |
+
- Remove all app-side antenna power on/off operations to avoid SDK instability and external-control conflicts
|
| 90 |
+
- Sync Idle Motion toggle with Idle Antenna Motion toggle for expected behavior in ESPHome
|
| 91 |
+
- Remove legacy app-managed audio routing hooks and rely on native SDK/system audio selection
|
| 92 |
+
- Harden startup against import-time failures (lazy emotion library loading and graceful Sendspin disable)
|
| 93 |
+
|
| 94 |
+
### Changed
|
| 95 |
+
- Keep idle antenna behavior as animation-only control (no torque coupling)
|
| 96 |
+
- Tighten preference loading to current schema (no legacy config fallback filtering)
|
| 97 |
+
|
| 98 |
+
### Added
|
| 99 |
+
- Home Assistant blueprint for Reachy presence companion automation
|
| 100 |
+
- GitHub workflow to auto-create releases when pyproject/changelog version updates produce a new tag
|
| 101 |
+
|
| 102 |
+
### Improved
|
| 103 |
+
- Blueprint supports device-first auto-binding and richer usage instructions
|
| 104 |
+
- Refresh landing page (`index.html`) with current version, GitHub source link, and new Blueprint/Auto Release capability cards
|
| 105 |
+
|
| 106 |
+
## [1.0.1] - 2026-03-05
|
| 107 |
+
|
| 108 |
+
### Changed
|
| 109 |
+
- Update runtime dependency baseline to `reachy-mini>=1.5.0`
|
| 110 |
+
|
| 111 |
+
### Fixed
|
| 112 |
+
- Remove legacy Zenoh 7447 startup precheck for SDK v1.5 compatibility
|
| 113 |
+
- Remove legacy ZError string matching from connection error handling
|
| 114 |
+
- Adapt daemon status handling to SDK v1.5 `DaemonStatus` object (prevents `AttributeError` on `status.get`)
|
| 115 |
+
- Harden stop-word handling with runtime activation/deactivation and mute-aware trigger gating
|
| 116 |
+
- Align wakeup stream start timing with reference behavior (start microphone stream after wakeup sound)
|
| 117 |
+
- Improve TTS streaming robustness and reduce cutoffs with retry-based audio push
|
| 118 |
+
|
| 119 |
+
### Optimized
|
| 120 |
+
- Support single-request streaming with in-memory fallback cache for one-time TTS URLs (no temp file dependency)
|
| 121 |
+
- Lower streaming fetch chunk size and apply unthrottled preroll for faster first audio
|
| 122 |
+
|
| 123 |
+
## [1.0.0] - 2026-03-04
|
| 124 |
+
|
| 125 |
+
### Changed
|
| 126 |
+
- Require `reachy-mini[gstreamer]>=1.4.1`
|
| 127 |
+
|
| 128 |
+
### Added
|
| 129 |
+
- Sendspin switch in ESPHome (default OFF, persistent, runtime enable/disable)
|
| 130 |
+
- Face Tracking and Gesture Detection switches in ESPHome (both default OFF, persistent)
|
| 131 |
+
- Face Confidence number entity (0.0-1.0, persistent)
|
| 132 |
+
|
| 133 |
+
### Fixed
|
| 134 |
+
- Improve gesture responsiveness and stability (faster smoothing, min processing cadence, no-gesture alignment)
|
| 135 |
+
- Auto-match ONNX gesture input size from model shape to prevent `INVALID_ARGUMENT` dimension errors
|
| 136 |
+
- Disable antenna torque in idle mode and re-enable outside idle to reduce chatter/noise
|
| 137 |
+
- Enforce deterministic audio startup path and fail fast when microphone capture is not ready
|
| 138 |
+
- Add on-demand `/snapshot` JPEG generation when no cached stream frame is available
|
| 139 |
+
|
| 140 |
+
### Optimized
|
| 141 |
+
- Unload/reload face and gesture models when toggled off/on to save resources
|
| 142 |
+
- Update idle behavior to breathing + look-around alternation, idle antenna sway disabled
|
| 143 |
+
- Adjust idle breathing to human-like cadence
|
| 144 |
+
- Make MJPEG streaming viewer-aware (skip continuous JPEG encode/push when no stream clients)
|
| 145 |
+
- Keep face/gesture AI processing active even when stream viewers are absent
|
| 146 |
+
|
| 147 |
+
### Changed
|
| 148 |
+
- Use camera backend default FPS/resolution for stream path instead of forcing fixed 1080p/25fps
|
| 149 |
+
|
| 150 |
+
## [0.9.9] - 2026-01-28
|
| 151 |
+
|
| 152 |
+
### Fixed
|
| 153 |
+
- **SDK Buffer Overflow During Idle**
|
| 154 |
+
- Add SDK buffer flush on GStreamer lock timeout
|
| 155 |
+
- Prevents buffer overflow during long idle periods when lock contention prevents buffer drainage
|
| 156 |
+
- Audio thread flushes SDK audio buffer when lock acquisition times out
|
| 157 |
+
- Camera thread flushes SDK video buffer when lock acquisition times out
|
| 158 |
+
- Audio playback flushes SDK playback buffer when lock acquisition times out
|
| 159 |
+
- Resolves SDK crashes during extended wake-up idle periods without conversation
|
| 160 |
+
- Requires Reachy Mini hardware (not applicable to simulation mode)
|
| 161 |
+
|
| 162 |
+
### Fixed
|
| 163 |
+
- **Memory Leaks**
|
| 164 |
+
- Audio buffer memory leak - added size limit to prevent unbounded growth
|
| 165 |
+
- Temp file leak - downloaded audio files now cleaned up after playback
|
| 166 |
+
- Multiple memory leak and resource leak issues fixed
|
| 167 |
+
- Thread-safe draining flag using threading.Event
|
| 168 |
+
- Silent failures now logged for debugging
|
| 169 |
+
|
| 170 |
+
### Optimized
|
| 171 |
+
- **Gesture Recognition Sensitivity**
|
| 172 |
+
- Simplify GestureSmoother to frequency-based confirmation (1 frame)
|
| 173 |
+
- Remove all confidence filtering - return all detections to Home Assistant
|
| 174 |
+
- Remove unused parameters (confidence_threshold, detection_threshold, GestureConfig)
|
| 175 |
+
- Remove duplicate empty check in gesture detection
|
| 176 |
+
- Add GestureSmoother class with history tracking for stable output
|
| 177 |
+
- Reduce gesture detection interval from 3 frames to 1 frame for higher frequency
|
| 178 |
+
- Fix: Gesture detection now returns all detected hands instead of only the highest confidence one
|
| 179 |
+
- Matches reference implementation behavior for improved detection rate
|
| 180 |
+
- No conflicts with face tracking (shared frame, independent processing)
|
| 181 |
+
|
| 182 |
+
### Code Quality
|
| 183 |
+
- Fix Ruff linter issues (import ordering, missing newlines, __all__ sorting)
|
| 184 |
+
- Format code with Ruff formatter (5 files reformatted)
|
| 185 |
+
- Fix slice index error in gesture detection (convert coordinates to integers)
|
| 186 |
+
- Fix Python 3.12 type annotation compatibility
|
| 187 |
+
|
| 188 |
+
## [0.9.8] - 2026-01-27
|
| 189 |
+
|
| 190 |
+
### New
|
| 191 |
+
- Mute switch entity - suspends voice services only (not camera/motion)
|
| 192 |
+
- Disable Camera switch entity - suspends camera and AI processing
|
| 193 |
+
- Home Assistant connection-driven feature loading
|
| 194 |
+
- Automatic suspend/resume on HA disconnect/reconnect
|
| 195 |
+
|
| 196 |
+
### Fixed
|
| 197 |
+
- Camera disable logic - corrected inverted conditions for proper operation
|
| 198 |
+
- Prevent daemon crash when entering idle state
|
| 199 |
+
- Camera preview in Home Assistant
|
| 200 |
+
- SDK crash during idle - optimized audio processing to skip get_frame() when not streaming to Home Assistant, reducing GStreamer resource competition
|
| 201 |
+
- Add GStreamer threading lock to prevent pipeline competition between audio, playback, and camera threads
|
| 202 |
+
- Audio thread gets priority during conversations - bypasses lock when conversation is active
|
| 203 |
+
- Remove GStreamer lock to fix wake word detection in idle state (lock was preventing wake word detection)
|
| 204 |
+
|
| 205 |
+
### Optimized
|
| 206 |
+
- Reduce log output by 30-40%
|
| 207 |
+
- Bundle face tracking model with package - eliminated HuggingFace download dependency, removed huggingface_hub from requirements, models now load from local package directory for offline operation
|
| 208 |
+
- Replace HTTP API polling with SDK Zenoh for daemon status monitoring to reduce uvicorn blocking and improve stability
|
| 209 |
+
- Device ID now reads /etc/machine-id directly - removed uuid.getnode() and file persistence
|
| 210 |
+
- Implement high-priority SDK improvements
|
| 211 |
+
- Remove aiohttp dependency from daemon_monitor - fully migrated to SDK Zenoh
|
| 212 |
+
|
| 213 |
+
### Removed
|
| 214 |
+
- Temporarily disable emotion playback during TTS
|
| 215 |
+
- Unused config items (connection_timeout)
|
| 216 |
+
|
| 217 |
+
### Code Quality
|
| 218 |
+
- Code quality improvements
|
| 219 |
+
|
| 220 |
+
## [0.9.7] - 2026-01-20
|
| 221 |
+
|
| 222 |
+
### Fixed
|
| 223 |
+
- Device ID file path corrected after util.py moved to core/ subdirectory (prevents HA seeing device as new)
|
| 224 |
+
- Animation file path corrected (was looking in wrong directory)
|
| 225 |
+
- Remove hey_jarvis from required wake words (it's optional in openWakeWord/)
|
| 226 |
+
|
| 227 |
+
## [0.9.6] - 2026-01-20
|
| 228 |
+
|
| 229 |
+
### New
|
| 230 |
+
- Add ruff linter/formatter and mypy type checker configuration
|
| 231 |
+
- Add pre-commit hooks for automated code quality checks
|
| 232 |
+
|
| 233 |
+
### Fixed
|
| 234 |
+
- Remove duplicate resume() method in audio_player.py
|
| 235 |
+
- Remove duplicate connection_lost() method in satellite.py
|
| 236 |
+
- Store asyncio task reference in sleep_manager.py to prevent garbage collection
|
| 237 |
+
|
| 238 |
+
### Optimized
|
| 239 |
+
- Use dict.items() for efficient iteration in smoothing.py
|
| 240 |
+
|
| 241 |
+
## [0.9.5] - 2026-01-19
|
| 242 |
+
|
| 243 |
+
### Refactored
|
| 244 |
+
- Modularize codebase - new core/motion/vision/audio/entities module structure
|
| 245 |
+
- Remove legacy/compatibility code
|
| 246 |
+
- Remove audio diagnostics debug code
|
| 247 |
+
|
| 248 |
+
### New
|
| 249 |
+
- Direct callbacks for HA sleep/wake buttons to suspend/resume services
|
| 250 |
+
|
| 251 |
+
### Optimized
|
| 252 |
+
- Audio processing latency - reduced chunk size from 1024 to 256 samples (64ms -> 16ms)
|
| 253 |
+
- Audio loop delay reduced from 10ms to 1ms for faster VAD response
|
| 254 |
+
- Stereo to mono conversion uses first channel instead of mean for cleaner signal
|
| 255 |
+
|
| 256 |
+
### Improved
|
| 257 |
+
- Camera resume_from_suspend now synchronous for reliable wake from sleep
|
| 258 |
+
- Rotation clamping in face tracking to prevent IK collisions
|
| 259 |
+
- Audio gain boosted for faster VAD detection
|
| 260 |
+
- Audio NaN/Inf values causing STT issues fixed
|
| 261 |
+
|
| 262 |
+
## [0.9.0] - 2026-01-18
|
| 263 |
+
|
| 264 |
+
### New
|
| 265 |
+
- Robot state monitor for proper sleep mode handling - services pause when robot disconnects and resume on reconnect
|
| 266 |
+
- System diagnostics entities (CPU, memory, disk, uptime) exposed as Home Assistant diagnostic sensors
|
| 267 |
+
- Phase 24 with 9 diagnostic sensors (cpu_percent, cpu_temperature, memory_percent, memory_used_gb, disk_percent, disk_free_gb, uptime_hours, process_cpu_percent, process_memory_mb)
|
| 268 |
+
|
| 269 |
+
### Fixed
|
| 270 |
+
- Voice assistant and movement manager now properly pause during robot sleep mode instead of generating error spam
|
| 271 |
+
|
| 272 |
+
### Improved
|
| 273 |
+
- Graceful service lifecycle management with RobotStateMonitor callbacks
|
| 274 |
+
|
| 275 |
+
## [0.8.7] - 2026-01-18
|
| 276 |
+
|
| 277 |
+
### Fixed
|
| 278 |
+
- Clamp body_yaw to safe range to prevent IK collision warnings during emotion playback
|
| 279 |
+
- Emotion moves and face tracking now respect SDK safety limits
|
| 280 |
+
|
| 281 |
+
### Improved
|
| 282 |
+
- Face tracking smoothness - removed EMA smoothing (matches reference project)
|
| 283 |
+
- Face tracking timing updated to match reference (2s delay, 1s interpolation)
|
| 284 |
+
|
| 285 |
+
## [0.8.6] - 2026-01-18
|
| 286 |
+
|
| 287 |
+
### Fixed
|
| 288 |
+
- Audio buffer memory leak - added size limit to prevent unbounded growth
|
| 289 |
+
- Temp file leak - downloaded audio files now cleaned up after playback
|
| 290 |
+
- Camera thread termination timeout increased for clean shutdown
|
| 291 |
+
- Thread-safe draining flag using threading.Event
|
| 292 |
+
- Silent failures now logged for debugging
|
| 293 |
+
|
| 294 |
+
## [0.8.5] - 2026-01-18
|
| 295 |
+
|
| 296 |
+
### Fixed
|
| 297 |
+
- DOA turn-to-sound direction inverted - now turns correctly toward sound source
|
| 298 |
+
- Graceful shutdown prevents daemon crash on app stop
|
| 299 |
+
|
| 300 |
+
## [0.8.4] - 2026-01-18
|
| 301 |
+
|
| 302 |
+
### Improved
|
| 303 |
+
- Smooth idle animation with interpolation phase (matches reference BreathingMove)
|
| 304 |
+
- Two-phase animation - interpolates to neutral before oscillation
|
| 305 |
+
- Antenna frequency updated to 0.5Hz (was 0.15Hz) for more natural sway
|
| 306 |
+
|
| 307 |
+
## [0.8.3] - 2026-01-18
|
| 308 |
+
|
| 309 |
+
### Fixed
|
| 310 |
+
- Body now properly follows head rotation during face tracking
|
| 311 |
+
- body_yaw extracted from final head pose matrix and synced with head_yaw
|
| 312 |
+
- Matches reference project sweep_look behavior for natural body movement
|
| 313 |
+
|
| 314 |
+
## [0.8.2] - 2026-01-18
|
| 315 |
+
|
| 316 |
+
### Fixed
|
| 317 |
+
- Body follows head rotation during face tracking - body_yaw syncs with head_yaw
|
| 318 |
+
- Matches reference project sweep_look behavior for natural body movement
|
| 319 |
+
|
| 320 |
+
## [0.8.1] - 2026-01-18
|
| 321 |
+
|
| 322 |
+
### Fixed
|
| 323 |
+
- face_detected entity now pushes state updates to Home Assistant in real-time
|
| 324 |
+
- Body yaw simplified to match reference project - SDK automatic_body_yaw handles collision prevention
|
| 325 |
+
- Idle animation now starts immediately on app launch
|
| 326 |
+
- Smooth antenna animation - removed pose change threshold for continuous motion
|
| 327 |
+
|
| 328 |
+
## [0.8.0] - 2026-01-17
|
| 329 |
+
|
| 330 |
+
### New
|
| 331 |
+
- Comprehensive emotion keyword mapping with 280+ Chinese and English keywords
|
| 332 |
+
- 35 emotion categories mapped to robot expressions
|
| 333 |
+
- Auto-trigger expressions from conversation text patterns
|
| 334 |
+
|
| 335 |
+
## [0.7.3] - 2026-01-12
|
| 336 |
+
|
| 337 |
+
### Fixed
|
| 338 |
+
- Revert to reference project pattern - use refractory period instead of state flags
|
| 339 |
+
- Remove broken _in_pipeline and _tts_playing state management
|
| 340 |
+
- Restore correct RUN_END event handling from linux-voice-assistant
|
| 341 |
+
|
| 342 |
+
## [0.7.2] - 2026-01-12
|
| 343 |
+
|
| 344 |
+
### Fixed
|
| 345 |
+
- Remove premature _tts_played reset in RUN_END event
|
| 346 |
+
- Ensure _in_pipeline stays True until TTS playback completes
|
| 347 |
+
|
| 348 |
+
## [0.7.1] - 2026-01-12
|
| 349 |
+
|
| 350 |
+
### Fixed
|
| 351 |
+
- Prevent wake word detection during TTS playback
|
| 352 |
+
- Add _tts_playing flag to track TTS audio state precisely
|
| 353 |
+
|
| 354 |
+
## [0.7.0] - 2026-01-12
|
| 355 |
+
|
| 356 |
+
### New
|
| 357 |
+
- Gesture detection using HaGRID ONNX models (18 gesture classes)
|
| 358 |
+
- gesture_detected and gesture_confidence entities in Home Assistant
|
| 359 |
+
|
| 360 |
+
### Fixed
|
| 361 |
+
- Gesture state now properly pushed to Home Assistant in real-time
|
| 362 |
+
|
| 363 |
+
### Optimized
|
| 364 |
+
- Aggressive power saving - 0.5fps idle mode after 30s without face
|
| 365 |
+
- Gesture detection only runs when face detected (saves CPU)
|
| 366 |
+
|
| 367 |
+
## [0.6.1] - 2026-01-12
|
| 368 |
+
|
| 369 |
+
### Fixed
|
| 370 |
+
- Prioritize MicroWakeWord over OpenWakeWord for same-name wake words
|
| 371 |
+
- OpenWakeWord wake words now visible in Home Assistant selection
|
| 372 |
+
- Stop word detection now works correctly
|
| 373 |
+
- STT/LLM response time improved with fixed audio chunk size
|
| 374 |
+
|
| 375 |
+
## [0.6.0] - 2026-01-11
|
| 376 |
+
|
| 377 |
+
### New
|
| 378 |
+
- Real-time audio-driven speech animation (SwayRollRT algorithm)
|
| 379 |
+
- JSON-driven animation system - all animations configurable
|
| 380 |
+
|
| 381 |
+
### Refactored
|
| 382 |
+
- Remove hardcoded actions, use animation offsets only
|
| 383 |
+
|
| 384 |
+
### Fixed
|
| 385 |
+
- TTS audio analysis now works with local playback
|
| 386 |
+
|
| 387 |
+
## [0.5.16] - 2026-01-11
|
| 388 |
+
|
| 389 |
+
### Removed
|
| 390 |
+
- Tap-to-wake feature (too many false triggers)
|
| 391 |
+
|
| 392 |
+
### New
|
| 393 |
+
- Continuous Conversation switch in Home Assistant
|
| 394 |
+
|
| 395 |
+
### Refactored
|
| 396 |
+
- Simplified satellite.py and voice_assistant.py
|
| 397 |
+
|
| 398 |
+
## [0.5.15] - 2026-01-11
|
| 399 |
+
|
| 400 |
+
### New
|
| 401 |
+
- Audio settings persistence (AGC, Noise Suppression, Tap Sensitivity)
|
| 402 |
+
|
| 403 |
+
### Refactored
|
| 404 |
+
- Move Sendspin mDNS discovery to zeroconf.py
|
| 405 |
+
|
| 406 |
+
### Fixed
|
| 407 |
+
- Tap detection not re-enabled during emotion playback in conversation
|
| 408 |
+
|
| 409 |
+
## [0.5.14] - 2026-01-11
|
| 410 |
+
|
| 411 |
+
### Fixed
|
| 412 |
+
- Skip ALL wake word processing when pipeline is active
|
| 413 |
+
- Eliminate race condition in pipeline state during continuous conversation
|
| 414 |
+
|
| 415 |
+
### Improved
|
| 416 |
+
- Control loop increased to 100Hz (daemon updated)
|
| 417 |
+
|
| 418 |
+
## [0.5.13] - 2026-01-10
|
| 419 |
+
|
| 420 |
+
### New
|
| 421 |
+
- JSON-driven animation system for conversation states
|
| 422 |
+
- AnimationPlayer class inspired by SimpleDances project
|
| 423 |
+
|
| 424 |
+
### Refactored
|
| 425 |
+
- Replace SpeechSwayGenerator and BreathingAnimation with unified animation system
|
| 426 |
+
|
| 427 |
+
## [0.5.12] - 2026-01-10
|
| 428 |
+
|
| 429 |
+
### Removed
|
| 430 |
+
- Deleted broken hey_reachy wake word model
|
| 431 |
+
|
| 432 |
+
### Revert
|
| 433 |
+
- Default wake word back to "Okay Nabu"
|
| 434 |
+
|
| 435 |
+
## [0.5.11] - 2026-01-10
|
| 436 |
+
|
| 437 |
+
### Fixed
|
| 438 |
+
- Reset feature extractors when switching wake words
|
| 439 |
+
- Add refractory period after wake word switch
|
| 440 |
+
|
| 441 |
+
## [0.5.10] - 2026-01-10
|
| 442 |
+
|
| 443 |
+
### Fixed
|
| 444 |
+
- Wake word models now have 'id' attribute set correctly
|
| 445 |
+
- Wake word switching from Home Assistant now works
|
| 446 |
+
|
| 447 |
+
## [0.5.9] - 2026-01-10
|
| 448 |
+
|
| 449 |
+
### New
|
| 450 |
+
- Default wake word changed to hey_reachy
|
| 451 |
+
|
| 452 |
+
### Fixed
|
| 453 |
+
- Wake word switching bug
|
| 454 |
+
|
| 455 |
+
## [0.5.8] - 2026-01-09
|
| 456 |
+
|
| 457 |
+
### Fixed
|
| 458 |
+
- Tap detection waits for emotion playback to complete
|
| 459 |
+
- Poll daemon API for move completion
|
| 460 |
+
|
| 461 |
+
## [0.5.7] - 2026-01-09
|
| 462 |
+
|
| 463 |
+
### New
|
| 464 |
+
- DOA turn-to-sound at wakeup
|
| 465 |
+
|
| 466 |
+
### Fixed
|
| 467 |
+
- Show raw DOA angle in Home Assistant (0-180)
|
| 468 |
+
- Invert DOA yaw direction
|
| 469 |
+
|
| 470 |
+
## [0.5.6] - 2026-01-08
|
| 471 |
+
|
| 472 |
+
### Fixed
|
| 473 |
+
- Better pipeline state tracking to prevent duplicate audio
|
| 474 |
+
|
| 475 |
+
## [0.5.5] - 2026-01-08
|
| 476 |
+
|
| 477 |
+
### New
|
| 478 |
+
- Prevent concurrent pipelines
|
| 479 |
+
- Add prompt sound for continuous conversation
|
| 480 |
+
|
| 481 |
+
## [0.5.4] - 2026-01-08
|
| 482 |
+
|
| 483 |
+
### Fixed
|
| 484 |
+
- Wait for RUN_END before starting new conversation
|
| 485 |
+
|
| 486 |
+
## [0.5.3] - 2026-01-08
|
| 487 |
+
|
| 488 |
+
### Fixed
|
| 489 |
+
- Improve continuous conversation with conversation_id tracking
|
| 490 |
+
|
| 491 |
+
## [0.5.2] - 2026-01-08
|
| 492 |
+
|
| 493 |
+
### Fixed
|
| 494 |
+
- Enable HA control of robot pose
|
| 495 |
+
- Continuous conversation improvements
|
| 496 |
+
|
| 497 |
+
## [0.5.1] - 2026-01-08
|
| 498 |
+
|
| 499 |
+
### Fixed
|
| 500 |
+
- Sendspin connects to music_player instead of tts_player
|
| 501 |
+
- Persist tap_sensitivity settings
|
| 502 |
+
- Pause Sendspin during voice assistant wakeup
|
| 503 |
+
- Sendspin prioritize 16kHz sample rate
|
| 504 |
+
|
| 505 |
+
## [0.5.0] - 2026-01-07
|
| 506 |
+
|
| 507 |
+
### New
|
| 508 |
+
- Face tracking with adaptive frequency
|
| 509 |
+
- Sendspin multi-room audio integration
|
| 510 |
+
|
| 511 |
+
### Optimized
|
| 512 |
+
- Shutdown mechanism improvements
|
| 513 |
+
|
| 514 |
+
## [0.4.0] - 2026-01-07
|
| 515 |
+
|
| 516 |
+
### Fixed
|
| 517 |
+
- Daemon stability fixes
|
| 518 |
+
|
| 519 |
+
### New
|
| 520 |
+
- Face tracking enabled by default
|
| 521 |
+
|
| 522 |
+
### Optimized
|
| 523 |
+
- Microphone settings for better sensitivity
|
| 524 |
+
|
| 525 |
+
## [0.3.0] - 2026-01-06
|
| 526 |
+
|
| 527 |
+
### New
|
| 528 |
+
- Tap sensitivity slider entity
|
| 529 |
+
|
| 530 |
+
### Fixed
|
| 531 |
+
- Music Assistant compatibility
|
| 532 |
+
|
| 533 |
+
### Optimized
|
| 534 |
+
- Face tracking and tap detection
|
| 535 |
+
|
| 536 |
+
## [0.2.21] - 2026-01-06
|
| 537 |
+
|
| 538 |
+
### Fixed
|
| 539 |
+
- Daemon crash - reduce control loop to 2Hz
|
| 540 |
+
- Pause control loop during audio playback
|
| 541 |
+
|
| 542 |
+
## [0.2.20] - 2026-01-06
|
| 543 |
+
|
| 544 |
+
### Revert
|
| 545 |
+
- Audio/satellite/voice_assistant to v0.2.9 working state
|
| 546 |
+
|
| 547 |
+
## [0.2.19] - 2026-01-06
|
| 548 |
+
|
| 549 |
+
### Fixed
|
| 550 |
+
- Force localhost connection mode to prevent WebRTC errors
|
| 551 |
+
|
| 552 |
+
## [0.2.18] - 2026-01-06
|
| 553 |
+
|
| 554 |
+
### Fixed
|
| 555 |
+
- Audio playback - restore wakeup sound
|
| 556 |
+
- Use push_audio_sample for TTS
|
| 557 |
+
|
| 558 |
+
## [0.2.17] - 2026-01-06
|
| 559 |
+
|
| 560 |
+
### Removed
|
| 561 |
+
- head_joints/passive_joints entities
|
| 562 |
+
- error_message to diagnostic category
|
| 563 |
+
|
| 564 |
+
## [0.2.16] - 2026-01-06
|
| 565 |
+
|
| 566 |
+
### Fixed
|
| 567 |
+
- TTS playback - pause recording during playback
|
| 568 |
+
|
| 569 |
+
## [0.2.15] - 2026-01-06
|
| 570 |
+
|
| 571 |
+
### Fixed
|
| 572 |
+
- Use play_sound() instead of push_audio_sample() for TTS
|
| 573 |
+
|
| 574 |
+
## [0.2.14] - 2026-01-06
|
| 575 |
+
|
| 576 |
+
### Fixed
|
| 577 |
+
- Pause audio recording during TTS playback
|
| 578 |
+
|
| 579 |
+
## [0.2.13] - 2026-01-06
|
| 580 |
+
|
| 581 |
+
### Fixed
|
| 582 |
+
- Don't manually start/stop media - let SDK/daemon manage it
|
| 583 |
+
|
| 584 |
+
## [0.2.12] - 2026-01-05
|
| 585 |
+
|
| 586 |
+
### Fixed
|
| 587 |
+
- Disable breathing animation to prevent serial port overflow
|
| 588 |
+
|
| 589 |
+
## [0.2.11] - 2026-01-05
|
| 590 |
+
|
| 591 |
+
### Fixed
|
| 592 |
+
- Disable wakeup sound to prevent daemon crash
|
| 593 |
+
- Add debug logging for troubleshooting
|
| 594 |
+
|
| 595 |
+
## [0.2.10] - 2026-01-05
|
| 596 |
+
|
| 597 |
+
### Added
|
| 598 |
+
- Debug logging for motion init
|
| 599 |
+
|
| 600 |
+
### Fixed
|
| 601 |
+
- Audio fallback samplerate
|
| 602 |
+
|
| 603 |
+
## [0.2.9] - 2026-01-05
|
| 604 |
+
|
| 605 |
+
### Removed
|
| 606 |
+
- DOA/speech detection - replaced by face tracking
|
| 607 |
+
|
| 608 |
+
## [0.2.8] - 2026-01-05
|
| 609 |
+
|
| 610 |
+
### New
|
| 611 |
+
- Replace DOA with YOLO face tracking
|
| 612 |
+
|
| 613 |
+
## [0.2.7] - 2026-01-05
|
| 614 |
+
|
| 615 |
+
### Fixed
|
| 616 |
+
- Add DOA caching to prevent ReSpeaker query overload
|
| 617 |
+
|
| 618 |
+
## [0.2.6] - 2026-01-05
|
| 619 |
+
|
| 620 |
+
### New
|
| 621 |
+
- Thread-safe ReSpeaker USB access to prevent daemon deadlock
|
| 622 |
+
|
| 623 |
+
## [0.2.4] - 2026-01-05
|
| 624 |
+
|
| 625 |
+
### Fixed
|
| 626 |
+
- Microphone volume control via daemon HTTP API
|
| 627 |
+
|
| 628 |
+
## [0.2.3] - 2026-01-05
|
| 629 |
+
|
| 630 |
+
### Fixed
|
| 631 |
+
- Daemon crash caused by conflicting pose commands
|
| 632 |
+
- Disable: Pose setter methods in ReachyController
|
| 633 |
+
|
| 634 |
+
## [0.2.2] - 2026-01-05
|
| 635 |
+
|
| 636 |
+
### Fixed
|
| 637 |
+
- Second conversation motion failure
|
| 638 |
+
- Reduce: Control loop from 20Hz to 10Hz
|
| 639 |
+
- Improve: Connection recovery (faster reconnect)
|
| 640 |
+
|
| 641 |
+
## [0.2.1] - 2026-01-05
|
| 642 |
+
|
| 643 |
+
### Fixed
|
| 644 |
+
- Daemon crash issue
|
| 645 |
+
- Optimize: Code structure
|
| 646 |
+
|
| 647 |
+
## [0.2.0] - 2026-01-05
|
| 648 |
+
|
| 649 |
+
### New
|
| 650 |
+
- Automatic facial expressions during conversation
|
| 651 |
+
- New: Emotion playback integration
|
| 652 |
+
|
| 653 |
+
### Refactored
|
| 654 |
+
- Integrate emotion playback into MovementManager
|
| 655 |
+
|
| 656 |
+
## [0.1.5] - 2026-01-04
|
| 657 |
+
|
| 658 |
+
### Optimized
|
| 659 |
+
- Code splitting and organization
|
| 660 |
+
|
| 661 |
+
### Fixed
|
| 662 |
+
- Program crash issues
|
| 663 |
+
|
| 664 |
+
## [0.1.0] - 2026-01-01
|
| 665 |
+
|
| 666 |
+
### New
|
| 667 |
+
- Initial release
|
| 668 |
+
- ESPHome protocol server implementation
|
| 669 |
+
- mDNS auto-discovery for Home Assistant
|
| 670 |
+
- Local wake word detection (microWakeWord)
|
| 671 |
+
- Voice assistant pipeline integration
|
| 672 |
+
- Basic motion feedback (nod, shake)
|
| 673 |
+
|
| 674 |
+
---
|
| 675 |
+
|
| 676 |
+
## Version History Summary
|
| 677 |
+
|
| 678 |
+
| Version | Date | Major Changes |
|
| 679 |
+
|---------|------|--------------|
|
| 680 |
+
| 0.9.9 | 2026-01-28 | SDK buffer overflow fixes, memory leak fixes, gesture detection optimization |
|
| 681 |
+
| 0.9.8 | 2026-01-27 | Mute/Disable entities, HA connection-driven features, log reduction |
|
| 682 |
+
| 0.9.7 | 2026-01-20 | Device ID path fix, animation path fix |
|
| 683 |
+
| 0.9.6 | 2026-01-20 | Code quality tools (ruff, mypy, pre-commit) |
|
| 684 |
+
| 0.9.5 | 2026-01-19 | Modular architecture refactoring, audio latency optimization |
|
| 685 |
+
| 0.9.0 | 2026-01-18 | Robot state monitor, system diagnostics entities |
|
| 686 |
+
| 0.8.7 | 2026-01-18 | Body yaw clamping, face tracking smoothness |
|
| 687 |
+
| 0.8.0 | 2026-01-17 | Emotion keyword mapping (280+ keywords, 35 categories) |
|
| 688 |
+
| 0.7.0 | 2026-01-12 | Gesture detection with HaGRID ONNX models (18 gestures) |
|
| 689 |
+
| 0.6.0 | 2026-01-11 | Real-time audio-driven speech animation, JSON animation system |
|
| 690 |
+
| 0.5.0 | 2026-01-07 | Face tracking, Sendspin multi-room audio |
|
| 691 |
+
| 0.4.0 | 2026-01-07 | Daemon stability, microphone optimization |
|
| 692 |
+
| 0.3.0 | 2026-01-06 | Tap sensitivity slider |
|
| 693 |
+
| 0.2.0 | 2026-01-05 | Emotion playback integration |
|
| 694 |
+
| 0.1.0 | 2026-01-01 | Initial release |
|
| 695 |
+
|
| 696 |
+
## Project Statistics
|
| 697 |
+
|
| 698 |
+
- **Total Versions**: 29 (from 0.1.0 to 0.9.9)
|
| 699 |
+
- **Development Period**: ~30 days (2026-01-01 to 2026-01-28)
|
| 700 |
+
- **Average Release Rate**: ~1 version per day
|
| 701 |
+
- **Lines of Code**: ~18,000 lines across 52 Python files
|
| 702 |
+
- **ESPHome Entities**: 54 entities implemented
|
| 703 |
+
- **Supported Features**:
|
| 704 |
+
- Voice assistant pipeline integration
|
| 705 |
+
- Local wake word detection (multiple models)
|
| 706 |
+
- Face tracking with YOLO
|
| 707 |
+
- Gesture detection (18 classes)
|
| 708 |
+
- Multi-room audio (Sendspin)
|
| 709 |
+
- Real-time speech animation
|
| 710 |
+
- Emotion keyword detection (280+ keywords)
|
| 711 |
+
- System diagnostics
|
| 712 |
+
|
| 713 |
+
For detailed implementation notes, see [PROJECT_PLAN.md](./PROJECT_PLAN.md).
|
Project_Summary.md
ADDED
|
@@ -0,0 +1,1439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Mini for Home Assistant - Project Plan (Current snapshot: v1.0.6)
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
|
| 5 |
+
Integrate Home Assistant voice assistant functionality into Reachy Mini Wi-Fi robot, communicating with Home Assistant via ESPHome protocol.
|
| 6 |
+
|
| 7 |
+
## Local Reference Directories (DO NOT modify any files in reference directories)
|
| 8 |
+
1. [linux-voice-assistant](reference/linux-voice-assistant) - Linux-based Home Assistant voice assistant app for reference
|
| 9 |
+
2. [Reachy Mini SDK](reference/reachy_mini) - Reachy Mini SDK local directory for reference
|
| 10 |
+
3. [reachy_mini_conversation_app](reference/reachy_mini_conversation_app) - Reachy Mini conversation app for reference
|
| 11 |
+
4. [reachy-mini-desktop-app](reference/reachy-mini-desktop-app) - Reachy Mini desktop app for reference
|
| 12 |
+
5. [sendspin](reference/sendspin-cli/) - Sendspin client for reference
|
| 13 |
+
6. [aiosendspin](reference/aiosendspin/) - Sendspin protocol client library reference
|
| 14 |
+
7. [dynamic_gestures](reference/dynamic_gestures/) - Dynamic gesture reference
|
| 15 |
+
8. [SimpleDances](reference/SimpleDances/) - Local reference snapshot
|
| 16 |
+
|
| 17 |
+
## Core Design Principles
|
| 18 |
+
|
| 19 |
+
1. **Zero Configuration** - Users only need to install the app, no manual configuration required
|
| 20 |
+
2. **Native Hardware** - Use robot's built-in microphone and speaker
|
| 21 |
+
3. **Home Assistant Centralized Management** - STT/TTS/intent configuration stays on Home Assistant side
|
| 22 |
+
4. **Motion Feedback** - Provide head movement and antenna animation feedback during voice interaction
|
| 23 |
+
5. **Project Constraints** - Strictly follow [Reachy Mini SDK](reachy_mini) architecture design and constraints
|
| 24 |
+
6. **Code Quality** - Follow Python development standards with consistent code style, clear structure, complete comments, comprehensive documentation, high test coverage, high code quality, readability, maintainability, extensibility, and reusability
|
| 25 |
+
7. **Feature Priority** - Voice conversation with Home Assistant is highest priority; other features are auxiliary and must not affect voice conversation functionality or response speed
|
| 26 |
+
8. **No LED Functions** - LEDs are hidden inside the robot; all LED control is ignored
|
| 27 |
+
9. **Preserve Functionality** - Any code modifications should optimize while preserving completed features; do not remove features to solve problems. When issues occur, prioritize solving problems after referencing examples, not adding various log outputs
|
| 28 |
+
10. **No App-Managed Sleep/Wake** - The app no longer manages robot sleep/wake transitions; current SDK behavior is treated as source of truth
|
| 29 |
+
|
| 30 |
+
## Technical Architecture
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?
|
| 34 |
+
鈹? Reachy Mini (ARM64) 鈹?
|
| 35 |
+
鈹? 鈹?
|
| 36 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ AUDIO INPUT 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 37 |
+
鈹? 鈹? ReSpeaker XVF3800 (16kHz) 鈹? 鈹?
|
| 38 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 39 |
+
鈹? 鈹? 鈹?4-Mic Array 鈹?鈫?鈹?XVF3800 DSP 鈹? 鈹? 鈹?
|
| 40 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?鈥?Hardware DSP path available 鈹? 鈹? 鈹?
|
| 41 |
+
鈹? 鈹? 鈹?鈥?App currently relies on HA STT/TTS 鈹? 鈹? 鈹?
|
| 42 |
+
鈹? 鈹? 鈹?鈥?DOA/VAD used by the current runtime 鈹? 鈹? 鈹?
|
| 43 |
+
鈹? 鈹? 鈹?鈥?Direction of Arrival (DOA) 鈹? 鈹? 鈹?
|
| 44 |
+
鈹? 鈹? 鈹?鈥?Voice Activity Detection (VAD) 鈹? 鈹? 鈹?
|
| 45 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 46 |
+
鈹? 鈹? 鈹? 鈹? 鈹?
|
| 47 |
+
鈹? 鈹? 鈻? 鈹? 鈹?
|
| 48 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 49 |
+
鈹? 鈹? 鈹?Wake Word Detection (microWakeWord) 鈹? 鈹? 鈹?
|
| 50 |
+
鈹? 鈹? 鈹?鈥?"Okay Nabu" / "Hey Jarvis" 鈹? 鈹? 鈹?
|
| 51 |
+
鈹? 鈹? 鈹?鈥?Stop word detection 鈹? 鈹? 鈹?
|
| 52 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 53 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 54 |
+
鈹? 鈹?
|
| 55 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ AUDIO OUTPUT 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 56 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? 鈹?
|
| 57 |
+
鈹? 鈹? 鈹?TTS Player 鈹? 鈹?Music Player (Sendspin) 鈹?鈹? 鈹?
|
| 58 |
+
鈹? 鈹? 鈹?鈥?Voice assistant speech 鈹? 鈹?鈥?Multi-room audio streaming 鈹?鈹? 鈹?
|
| 59 |
+
鈹? 鈹? 鈹?鈥?Sound effects 鈹? 鈹?鈥?Auto-discovery via mDNS 鈹?鈹? 鈹?
|
| 60 |
+
鈹? 鈹? 鈹?鈥?Priority over music 鈹? 鈹?鈥?Auto-pause during conversation 鈹?鈹? 鈹?
|
| 61 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? 鈹?
|
| 62 |
+
鈹? 鈹? 鈹? 鈹? 鈹? 鈹?
|
| 63 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 64 |
+
鈹? 鈹? 鈻? 鈹? 鈹?
|
| 65 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 66 |
+
鈹? 鈹? 鈹?ReSpeaker Speaker (16kHz) 鈹? 鈹? 鈹?
|
| 67 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 68 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 69 |
+
鈹? 鈹?
|
| 70 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ VISION & TRACKING 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 71 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? 鈹?
|
| 72 |
+
鈹? 鈹? 鈹?Camera (VPU accelerated) 鈹?鈫? 鈹?YOLO Face Detection 鈹?鈹? 鈹?
|
| 73 |
+
鈹? 鈹? 鈹?鈥?MJPEG stream server 鈹? 鈹?鈥?AdamCodd/YOLOv11n-face 鈹?鈹? 鈹?
|
| 74 |
+
鈹? 鈹? 鈹?鈥?ESPHome Camera entity 鈹? 鈹?鈥?Adaptive frame rate: 鈹?鈹? 鈹?
|
| 75 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? - 15fps: conversation/face 鈹?鈹? 鈹?
|
| 76 |
+
鈹? 鈹? 鈹? - 2fps: idle (power saving) 鈹?鈹? 鈹?
|
| 77 |
+
鈹? 鈹? 鈹?鈥?look_at_image() pose calc 鈹?鈹? 鈹?
|
| 78 |
+
鈹? 鈹? 鈹?鈥?Smooth return after face lost 鈹?鈹? 鈹?
|
| 79 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? 鈹?
|
| 80 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 81 |
+
鈹? 鈹?
|
| 82 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ MOTION CONTROL 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 83 |
+
鈹? 鈹? MovementManager (50Hz Control Loop) 鈹? 鈹?
|
| 84 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 85 |
+
鈹? 鈹? 鈹?Motion Layers (Priority: Move > Action > SpeechSway > Breath) 鈹? 鈹? 鈹?
|
| 86 |
+
鈹? 鈹? 鈹?鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹? 鈹?
|
| 87 |
+
鈹? 鈹? 鈹?鈹?Move Queue 鈹?鈹?Actions 鈹?鈹?SpeechSway 鈹?鈹?Breathing 鈹? 鈹? 鈹? 鈹?
|
| 88 |
+
鈹? 鈹? 鈹?鈹?(Emotions) 鈹?鈹?(Nod/Shake)鈹?鈹?(Voice VAD)鈹?鈹?(Idle anim) 鈹? 鈹? 鈹? 鈹?
|
| 89 |
+
鈹? 鈹? 鈹?鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹? 鈹?
|
| 90 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 91 |
+
鈹? 鈹? 鈹? 鈹?
|
| 92 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 93 |
+
鈹? 鈹? 鈹?Face Tracking Offsets (Secondary Pose Overlay) 鈹? 鈹? 鈹?
|
| 94 |
+
鈹? 鈹? 鈹?鈥?Pitch offset: +9掳 (down compensation) 鈹? 鈹? 鈹?
|
| 95 |
+
鈹? 鈹? 鈹?鈥?Yaw offset: -7掳 (right compensation) 鈹? 鈹? 鈹?
|
| 96 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 97 |
+
鈹? 鈹? 鈹? 鈹?
|
| 98 |
+
鈹? 鈹? State Machine: on_wakeup 鈫?on_listening 鈫?on_speaking 鈫?on_idle 鈹? 鈹?
|
| 99 |
+
鈹? 鈹? 鈹? 鈹?
|
| 100 |
+
鈹? 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 101 |
+
鈹? 鈹? 鈹?Body Following 鈹? 鈹? 鈹?
|
| 102 |
+
鈹? 鈹? 鈹?鈥?Body yaw syncs with head yaw for natural tracking 鈹? 鈹? 鈹?
|
| 103 |
+
鈹? 鈹? 鈹?鈥?Extracted from final head pose matrix 鈹? 鈹? 鈹?
|
| 104 |
+
鈹? 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? 鈹?
|
| 105 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 106 |
+
鈹? 鈹?
|
| 107 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ GESTURE DETECTION 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 108 |
+
鈹? 鈹? HaGRID ONNX Models 鈹? 鈹?
|
| 109 |
+
鈹? 鈹? 鈥?18 gesture classes (call, like, dislike, fist, ok, palm, etc.) 鈹? 鈹?
|
| 110 |
+
鈹? 鈹? 鈥?Runtime result publishing only 鈹? 鈹?
|
| 111 |
+
鈹? 鈹? 鈥?Batch detection: all hands (not just highest confidence) 鈹? 鈹?
|
| 112 |
+
鈹? 鈹? 鈥?Detection cadence: adaptive scheduler + minimum processing FPS 鈹? 鈹?
|
| 113 |
+
鈹? 鈹? 鈥?No confidence filtering - all detections passed to Home Assistant鈹? 鈹?
|
| 114 |
+
鈹? 鈹? 鈥?Runtime switchable (default OFF, model unloaded when disabled) 鈹? 鈹?
|
| 115 |
+
鈹? 鈹? 鈥?Real-time state push to Home Assistant 鈹? 鈹?
|
| 116 |
+
鈹? 鈹? 鈥?No conflicts with face tracking (shared frame, independent) 鈹? 鈹?
|
| 117 |
+
鈹? 鈹? 鈥?SDK integration: MediaBackend detection, proper resource cleanup 鈹? 鈹?
|
| 118 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 119 |
+
鈹? 鈹?
|
| 120 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ ESPHOME SERVER 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 121 |
+
鈹? 鈹? Port 6053 (mDNS auto-discovery) 鈹? 鈹?
|
| 122 |
+
鈹? 鈹? 鈥?Entity count evolves by release (sensors, controls, media, camera) 鈹? 鈹?
|
| 123 |
+
鈹? 鈹? 鈥?Voice Assistant pipeline integration 鈹? 鈹?
|
| 124 |
+
鈹? 鈹? 鈥?Real-time state synchronization 鈹? 鈹?
|
| 125 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹���鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹?
|
| 126 |
+
鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?
|
| 127 |
+
鈹?
|
| 128 |
+
鈹?ESPHome Protocol (protobuf)
|
| 129 |
+
鈻?
|
| 130 |
+
鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?
|
| 131 |
+
鈹? Home Assistant 鈹?
|
| 132 |
+
鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹?
|
| 133 |
+
鈹? 鈹?STT Engine 鈹? 鈹?Intent Processing鈹? 鈹?TTS Engine 鈹?鈹?
|
| 134 |
+
鈹? 鈹?(User configured)鈹? 鈹?(Conversation) 鈹? 鈹?(User configured) 鈹?鈹?
|
| 135 |
+
鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹?
|
| 136 |
+
鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
### Software Module Architecture (v1.0.6)
|
| 140 |
+
|
| 141 |
+
```
|
| 142 |
+
reachy_mini_home_assistant/
|
| 143 |
+
鈹?
|
| 144 |
+
鈹溾攢鈹€ main.py # ReachyMiniApp entry point
|
| 145 |
+
鈹溾攢鈹€ __main__.py # Standalone CLI entry point
|
| 146 |
+
鈹溾攢鈹€ voice_assistant.py # Voice assistant service orchestrator
|
| 147 |
+
鈹溾攢鈹€ reachy_controller.py # Reachy Mini SDK wrapper
|
| 148 |
+
鈹溾攢鈹€ models.py # Data models / preferences / server state
|
| 149 |
+
鈹?
|
| 150 |
+
鈹溾攢鈹€ core/ # Core Infrastructure
|
| 151 |
+
鈹? 鈹溾攢鈹€ config.py # Centralized nested configuration
|
| 152 |
+
鈹? 鈹溾攢鈹€ service_base.py # Suspend/resume-aware service helpers
|
| 153 |
+
鈹? 鈹溾攢鈹€ system_diagnostics.py # System diagnostics
|
| 154 |
+
鈹? 鈹溾攢鈹€ exceptions.py # Custom exception classes
|
| 155 |
+
鈹? 鈹斺攢鈹€ util.py # Utility functions
|
| 156 |
+
鈹?
|
| 157 |
+
鈹溾攢鈹€ motion/ # Motion Control
|
| 158 |
+
鈹? 鈹溾攢鈹€ movement_manager.py # 50Hz unified motion control loop
|
| 159 |
+
鈹? 鈹溾攢鈹€ command_runtime.py # Command queue handling / state transitions
|
| 160 |
+
鈹? 鈹溾攢鈹€ control_runtime.py # Control-loop runtime helpers
|
| 161 |
+
鈹? 鈹溾攢鈹€ idle_runtime.py # Idle behavior / idle rest handling
|
| 162 |
+
鈹? 鈹溾攢鈹€ antenna.py # Antenna control / freeze logic
|
| 163 |
+
鈹? 鈹溾攢鈹€ pose_composer.py # Pose composition from multiple sources
|
| 164 |
+
鈹? 鈹溾攢鈹€ smoothing.py # Motion smoothing algorithms
|
| 165 |
+
鈹? 鈹溾攢鈹€ state_machine.py # Robot state definitions / idle config parsing
|
| 166 |
+
鈹? 鈹溾攢鈹€ animation_player.py # Animation player
|
| 167 |
+
鈹? 鈹溾攢鈹€ emotion_moves.py # Emotion moves
|
| 168 |
+
鈹? 鈹溾攢鈹€ speech_sway.py # Speech-driven head micro-movements
|
| 169 |
+
鈹? 鈹斺攢鈹€ reachy_motion.py # Reachy motion API
|
| 170 |
+
鈹?
|
| 171 |
+
鈹溾攢鈹€ vision/ # Vision Processing
|
| 172 |
+
鈹? 鈹溾攢鈹€ camera_server.py # MJPEG camera stream server facade
|
| 173 |
+
鈹? 鈹溾攢鈹€ camera_runtime.py # Camera lifecycle helpers
|
| 174 |
+
鈹? 鈹溾攢鈹€ camera_processing.py # Frame capture / AI processing helpers
|
| 175 |
+
鈹? 鈹溾攢鈹€ camera_http.py # HTTP handlers for stream/snapshot
|
| 176 |
+
鈹? 鈹溾攢鈹€ head_tracker.py # YOLO face detector
|
| 177 |
+
鈹? 鈹溾攢鈹€ gesture_detector.py # HaGRID gesture detection
|
| 178 |
+
鈹? 鈹溾攢鈹€ face_tracking_interpolator.py # Smooth face tracking
|
| 179 |
+
鈹? 鈹斺攢鈹€ frame_processor.py # Adaptive frame rate management
|
| 180 |
+
鈹?
|
| 181 |
+
鈹溾攢鈹€ audio/ # Audio runtime support
|
| 182 |
+
鈹? 鈹溾攢鈹€ audio_player.py # AudioPlayer facade
|
| 183 |
+
鈹? 鈹溾攢鈹€ audio_player_shared.py # Shared audio/sendspin constants + helpers
|
| 184 |
+
鈹? 鈹溾攢鈹€ audio_player_playback.py # Playback orchestration / lifecycle
|
| 185 |
+
鈹? 鈹溾攢鈹€ audio_player_local.py # Local file + fallback playback
|
| 186 |
+
鈹? 鈹溾攢鈹€ audio_player_stream_pcm.py # PCM streaming playback
|
| 187 |
+
鈹? 鈹溾攢鈹€ audio_player_stream_decoded.py # Decoded/GStreamer streaming playback
|
| 188 |
+
鈹? 鈹溾攢鈹€ audio_player_sendspin.py # Sendspin runtime integration
|
| 189 |
+
鈹? 鈹溾攢鈹€ microphone.py # Hardware audio helper / legacy tuning code
|
| 190 |
+
鈹? 鈹斺攢鈹€ doa_tracker.py # Direction of Arrival tracking
|
| 191 |
+
鈹?
|
| 192 |
+
鈹溾攢鈹€ entities/ # Home Assistant Entities
|
| 193 |
+
鈹? 鈹溾攢鈹€ entity.py # ESPHome base entity
|
| 194 |
+
鈹? 鈹溾攢鈹€ entity_registry.py # ESPHome entity registry
|
| 195 |
+
鈹? 鈹溾攢鈹€ entity_factory.py # Entity creation factory
|
| 196 |
+
鈹? 鈹溾攢鈹€ entity_keys.py # Entity key constants
|
| 197 |
+
鈹? 鈹溾攢鈹€ entity_extensions.py # Extended entity types
|
| 198 |
+
鈹? 鈹溾攢鈹€ runtime_entity_setup.py # Runtime/control entity wiring
|
| 199 |
+
鈹? 鈹溾攢鈹€ sensor_entity_setup.py # Sensor/diagnostic entity wiring
|
| 200 |
+
鈹? 鈹溾攢鈹€ event_emotion_mapper.py # HA event 鈫?Emotion mapping
|
| 201 |
+
鈹? 鈹斺攢鈹€ emotion_detector.py # Disabled runtime path for text emotion detection
|
| 202 |
+
鈹?
|
| 203 |
+
鈹溾攢鈹€ protocol/ # Protocol Handling
|
| 204 |
+
鈹? 鈹溾攢鈹€ satellite.py # ESPHome protocol handler facade
|
| 205 |
+
鈹? 鈹溾攢鈹€ api_server.py # HTTP API server
|
| 206 |
+
鈹? 鈹溾攢鈹€ zeroconf.py # mDNS discovery
|
| 207 |
+
鈹? 鈹溾攢鈹€ entity_bridge.py # Protocol/entity bridge helpers
|
| 208 |
+
鈹? 鈹溾攢鈹€ message_dispatch.py # ESPHome message dispatch
|
| 209 |
+
鈹? 鈹溾攢鈹€ motion_bridge.py # Voice 鈫?motion bridge
|
| 210 |
+
鈹? 鈹溾攢鈹€ session_flow.py # Conversation lifecycle helpers
|
| 211 |
+
鈹? 鈹溾攢鈹€ voice_pipeline.py # Voice event handling / TTS / stop / ducking
|
| 212 |
+
鈹? 鈹斺攢鈹€ wakeword_assets.py # Wake word asset helpers
|
| 213 |
+
鈹?
|
| 214 |
+
鈹溾攢鈹€ animations/ # Animation definitions
|
| 215 |
+
鈹? 鈹斺攢鈹€ conversation_animations.json # Unified built-in behavior resource file
|
| 216 |
+
鈹?
|
| 217 |
+
鈹斺攢鈹€ wakewords/ # Wake word models
|
| 218 |
+
鈹溾攢鈹€ okay_nabu.json/.tflite
|
| 219 |
+
鈹溾攢鈹€ hey_jarvis.json/.tflite
|
| 220 |
+
鈹溾攢鈹€ alexa.json/.tflite
|
| 221 |
+
鈹溾攢鈹€ hey_luna.json/.tflite
|
| 222 |
+
鈹斺攢鈹€ stop.json/.tflite
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
### Current Runtime Defaults (v1.0.6)
|
| 227 |
+
|
| 228 |
+
- `idle_behavior_enabled`: user-controlled
|
| 229 |
+
- `sendspin_enabled`: OFF
|
| 230 |
+
- `face_tracking_enabled`: OFF
|
| 231 |
+
- `gesture_detection_enabled`: OFF
|
| 232 |
+
- `face_confidence_threshold`: 0.5 (persistent)
|
| 233 |
+
- `continuous_conversation`: user-controlled
|
| 234 |
+
- `Idle Behavior = OFF` means a parked no-animation state aligned to configured idle rest pose
|
| 235 |
+
- When `Idle Behavior = OFF`, camera server is stopped entirely to save resources
|
| 236 |
+
- When `Idle Behavior = ON`, camera server can run and `/snapshot` supports on-demand frame capture when cache is empty
|
| 237 |
+
- Idle antenna behavior: torque disabled in `IDLE`, re-enabled when leaving `IDLE`
|
| 238 |
+
- Voice phases and HA-triggered emotions are routed through one built-in zero-config behavior layer
|
| 239 |
+
|
| 240 |
+
When face/gesture switches are OFF, their models are unloaded to save resources.
|
| 241 |
+
|
| 242 |
+
### Current Audio Startup Note (SDK 1.7.0)
|
| 243 |
+
|
| 244 |
+
- The app now aligns to the current Reachy Mini SDK media model instead of carrying older compatibility paths.
|
| 245 |
+
- Camera snapshots can be fetched on demand when the MJPEG cache is empty and the camera server is still running.
|
| 246 |
+
- Audio block size is currently `512` samples to reduce CPU overhead versus the earlier `256`-sample path.
|
| 247 |
+
|
| 248 |
+
### Latest Incremental Update (2026-03-04) - Viewer-Aware Camera Streaming
|
| 249 |
+
|
| 250 |
+
- MJPEG encoding/push is now viewer-aware: when no `/stream` client is connected, continuous MJPEG encoding is skipped to reduce CPU usage.
|
| 251 |
+
- Face tracking and gesture detection still run without active stream viewers, so AI behavior remains available.
|
| 252 |
+
- `/snapshot` now supports on-demand frame encode when no cached stream frame exists.
|
| 253 |
+
- Stream output no longer forces fixed 1080p/25fps; it follows camera backend defaults (resolution/FPS) and only falls back when backend FPS is unavailable.
|
| 254 |
+
- Transition from "watching" to "not watching" returns to adaptive idle pacing for resource saving.
|
| 255 |
+
|
| 256 |
+
## Completed Features
|
| 257 |
+
|
| 258 |
+
### Core Features
|
| 259 |
+
- [x] ESPHome protocol server implementation
|
| 260 |
+
- [x] mDNS service discovery (auto-discovered by Home Assistant)
|
| 261 |
+
- [x] Local wake word detection (microWakeWord)
|
| 262 |
+
- [x] Continuous conversation mode (controlled via Home Assistant switch)
|
| 263 |
+
- [x] Audio stream transmission to Home Assistant
|
| 264 |
+
- [x] TTS audio playback
|
| 265 |
+
- [x] Stop word detection
|
| 266 |
+
|
| 267 |
+
### Reachy Mini Integration
|
| 268 |
+
- [x] Use Reachy Mini SDK microphone input
|
| 269 |
+
- [x] Use Reachy Mini SDK speaker output
|
| 270 |
+
- [x] Head motion control (nod, shake, gaze)
|
| 271 |
+
- [x] Antenna animation control
|
| 272 |
+
- [x] Voice state feedback actions
|
| 273 |
+
- [x] YOLO face tracking (complements DOA wakeup orientation)
|
| 274 |
+
- [x] 50Hz unified motion control loop
|
| 275 |
+
|
| 276 |
+
### Application Architecture
|
| 277 |
+
- [x] Compliant with Reachy Mini App architecture
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
## File List
|
| 282 |
+
|
| 283 |
+
```
|
| 284 |
+
reachy_mini_ha_voice/
|
| 285 |
+
鈹溾攢鈹€ reachy_mini_ha_voice/
|
| 286 |
+
鈹? 鈹溾攢鈹€ __init__.py # Package initialization (v0.9.9)
|
| 287 |
+
鈹? 鈹溾攢鈹€ __main__.py # Command line entry
|
| 288 |
+
鈹? 鈹溾攢鈹€ main.py # ReachyMiniApp entry
|
| 289 |
+
鈹? 鈹溾攢鈹€ voice_assistant.py # Voice assistant service (1270 lines)
|
| 290 |
+
鈹? 鈹溾攢鈹€ protocol/ # ESPHome protocol handling
|
| 291 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports (13 lines)
|
| 292 |
+
鈹? 鈹? 鈹溾攢鈹€ satellite.py # ESPHome protocol handler facade
|
| 293 |
+
鈹? 鈹? 鈹溾攢鈹€ api_server.py # HTTP API server
|
| 294 |
+
鈹? 鈹? 鈹溾攢鈹€ zeroconf.py # mDNS discovery
|
| 295 |
+
鈹? 鈹? 鈹溾攢鈹€ entity_bridge.py # Protocol/entity bridge helpers
|
| 296 |
+
鈹? 鈹? 鈹溾攢鈹€ message_dispatch.py # ESPHome message dispatch
|
| 297 |
+
鈹? 鈹? 鈹溾攢鈹€ motion_bridge.py # Voice 鈫?motion bridge
|
| 298 |
+
鈹? 鈹? 鈹溾攢鈹€ session_flow.py # Conversation lifecycle helpers
|
| 299 |
+
鈹? 鈹? 鈹溾攢鈹€ voice_pipeline.py # Voice event handling / TTS / stop / ducking
|
| 300 |
+
鈹? 鈹? 鈹斺攢鈹€ wakeword_assets.py # Wake word asset helpers
|
| 301 |
+
鈹? 鈹溾攢鈹€ models.py # Data models
|
| 302 |
+
鈹? 鈹斺攢鈹€ reachy_controller.py # Reachy Mini controller wrapper (961 lines)
|
| 303 |
+
鈹? 鈹?
|
| 304 |
+
鈹? 鈹溾攢鈹€ core/ # Core infrastructure modules
|
| 305 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports
|
| 306 |
+
鈹? 鈹? 鈹溾攢鈹€ config.py # Centralized configuration (368 lines)
|
| 307 |
+
鈹? 鈹? 鈹溾攢鈹€ service_base.py # Suspend/resume-aware service helpers
|
| 308 |
+
鈹? 鈹? 鈹溾攢鈹€ system_diagnostics.py # System diagnostics (250 lines)
|
| 309 |
+
鈹? 鈹? 鈹斺攢鈹€ exceptions.py # Custom exception classes (68 lines)
|
| 310 |
+
鈹? 鈹? 鈹斺攢鈹€ util.py # Utility functions (28 lines)
|
| 311 |
+
鈹? 鈹?
|
| 312 |
+
鈹? 鈹溾攢鈹€ motion/ # Motion control modules
|
| 313 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports
|
| 314 |
+
鈹? 鈹? 鈹溾攢鈹€ antenna.py # Antenna freeze/unfreeze control
|
| 315 |
+
鈹? 鈹? 鈹溾攢鈹€ pose_composer.py # Pose composition utilities
|
| 316 |
+
鈹? 鈹? 鈹溾攢鈹€ command_runtime.py # Command queue handling / state transitions
|
| 317 |
+
鈹? 鈹? 鈹溾攢鈹€ control_runtime.py # Control-loop runtime helpers
|
| 318 |
+
鈹? 鈹? 鈹溾攢鈹€ idle_runtime.py # Idle behavior / idle rest handling
|
| 319 |
+
鈹? 鈹? 鈹溾攢鈹€ smoothing.py # Smoothing/transition algorithms
|
| 320 |
+
鈹? 鈹? 鈹溾攢鈹€ state_machine.py # State machine definitions
|
| 321 |
+
鈹? 鈹? 鈹溾攢鈹€ animation_player.py # Animation player
|
| 322 |
+
鈹? 鈹? 鈹溾攢鈹€ emotion_moves.py # Emotion moves
|
| 323 |
+
鈹? 鈹? 鈹溾攢鈹€ speech_sway.py # Speech-driven head micro-movements (338 lines)
|
| 324 |
+
鈹? 鈹? 鈹斺攢鈹€ reachy_motion.py # Reachy motion API
|
| 325 |
+
鈹? 鈹?
|
| 326 |
+
鈹? 鈹溾攢鈹€ vision/ # Vision processing modules
|
| 327 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports (30 lines)
|
| 328 |
+
鈹? 鈹? 鈹溾攢鈹€ frame_processor.py # Adaptive frame rate management (227 lines)
|
| 329 |
+
鈹? 鈹? 鈹溾攢鈹€ face_tracking_interpolator.py # Face lost interpolation (253 lines)
|
| 330 |
+
鈹? 鈹? 鈹溾攢鈹€ gesture_detector.py # HaGRID gesture detection
|
| 331 |
+
鈹? 鈹? 鈹溾攢鈹€ head_tracker.py # YOLO face detector
|
| 332 |
+
鈹? 鈹? 鈹溾攢鈹€ camera_runtime.py # Camera lifecycle helpers
|
| 333 |
+
鈹? 鈹? 鈹溾攢鈹€ camera_processing.py # Frame capture / AI processing helpers
|
| 334 |
+
鈹? 鈹? 鈹溾攢鈹€ camera_http.py # HTTP handlers for stream/snapshot
|
| 335 |
+
鈹? 鈹? 鈹斺攢鈹€ camera_server.py # MJPEG camera stream server facade
|
| 336 |
+
鈹? 鈹?
|
| 337 |
+
鈹? 鈹溾攢鈹€ audio/ # Audio runtime modules
|
| 338 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports (21 lines)
|
| 339 |
+
鈹? 鈹? 鈹溾攢鈹€ microphone.py # Hardware audio helper / legacy tuning code
|
| 340 |
+
鈹? 鈹? 鈹溾攢鈹€ doa_tracker.py # Direction of Arrival tracking
|
| 341 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player.py # AudioPlayer facade
|
| 342 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player_shared.py # Shared audio/sendspin constants + helpers
|
| 343 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player_playback.py # Playback orchestration / lifecycle
|
| 344 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player_local.py # Local file + fallback playback
|
| 345 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player_stream_pcm.py # PCM streaming playback
|
| 346 |
+
鈹? 鈹? 鈹溾攢鈹€ audio_player_stream_decoded.py # Decoded/GStreamer streaming playback
|
| 347 |
+
鈹? 鈹? 鈹斺攢鈹€ audio_player_sendspin.py # Sendspin runtime integration
|
| 348 |
+
鈹? 鈹?
|
| 349 |
+
鈹? 鈹溾攢鈹€ entities/ # Home Assistant entity modules
|
| 350 |
+
鈹? 鈹? 鈹溾攢鈹€ __init__.py # Module exports (38 lines)
|
| 351 |
+
鈹? 鈹? 鈹溾攢鈹€ entity.py # ESPHome base entity (402 lines)
|
| 352 |
+
鈹? 鈹? 鈹溾攢鈹€ entity_factory.py # Entity factory pattern (440 lines)
|
| 353 |
+
鈹? 鈹? 鈹溾攢鈹€ entity_keys.py # Entity key constants (155 lines)
|
| 354 |
+
鈹? 鈹? 鈹溾攢鈹€ entity_extensions.py # Extended entity types (258 lines)
|
| 355 |
+
鈹? 鈹? 鈹溾攢鈹€ entity_registry.py # ESPHome entity registry
|
| 356 |
+
鈹? 鈹? 鈹溾攢鈹€ runtime_entity_setup.py # Runtime/control entity wiring
|
| 357 |
+
鈹? 鈹? 鈹溾攢鈹€ sensor_entity_setup.py # Sensor/diagnostic entity wiring
|
| 358 |
+
鈹? 鈹? 鈹溾攢鈹€ event_emotion_mapper.py # HA event to emotion mapping
|
| 359 |
+
鈹? 鈹? 鈹斺攢鈹€ emotion_detector.py # Disabled runtime path for text emotion detection
|
| 360 |
+
鈹? 鈹?
|
| 361 |
+
鈹? 鈹溾攢鈹€ animations/ # Animation definitions
|
| 362 |
+
鈹? 鈹? 鈹斺攢鈹€ conversation_animations.json # Unified animations / gestures / HA events / keyword resources
|
| 363 |
+
鈹? 鈹?
|
| 364 |
+
鈹? 鈹斺攢鈹€ wakewords/ # Wake word models
|
| 365 |
+
鈹? 鈹溾攢鈹€ okay_nabu.json/.tflite
|
| 366 |
+
鈹? 鈹溾攢鈹€ hey_jarvis.json/.tflite (openWakeWord)
|
| 367 |
+
鈹? 鈹溾攢鈹€ alexa.json/.tflite
|
| 368 |
+
鈹? 鈹溾攢鈹€ hey_luna.json/.tflite
|
| 369 |
+
鈹? 鈹斺攢鈹€ stop.json/.tflite # Stop word detection
|
| 370 |
+
鈹?
|
| 371 |
+
鈹溾攢鈹€ sounds/ # Sound effect files (auto-download)
|
| 372 |
+
鈹? 鈹溾攢鈹€ wake_word_triggered.flac
|
| 373 |
+
鈹? 鈹斺攢鈹€ timer_finished.flac
|
| 374 |
+
鈹溾攢鈹€ pyproject.toml # Project configuration
|
| 375 |
+
鈹溾攢鈹€ README.md # Documentation
|
| 376 |
+
鈹溾攢鈹€ changelog.json # Version changelog
|
| 377 |
+
鈹斺攢鈹€ PROJECT_PLAN.md # Project plan
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
## Dependencies
|
| 381 |
+
|
| 382 |
+
```toml
|
| 383 |
+
dependencies = [
|
| 384 |
+
"reachy-mini>=1.7.0",
|
| 385 |
+
"soundfile>=0.13.0",
|
| 386 |
+
"numpy>=2.2.5,<=2.2.5",
|
| 387 |
+
"opencv-python>=4.12.0.88",
|
| 388 |
+
"pymicro-wakeword>=2.0.0,<3.0.0",
|
| 389 |
+
"pyopen-wakeword>=1.0.0,<2.0.0",
|
| 390 |
+
"aioesphomeapi>=43.10.1",
|
| 391 |
+
"zeroconf>=0.131,<1",
|
| 392 |
+
"websockets>=12,<16",
|
| 393 |
+
"aiohttp",
|
| 394 |
+
"scipy>=1.15.3,<2.0.0",
|
| 395 |
+
"ultralytics",
|
| 396 |
+
"supervision",
|
| 397 |
+
"aiosendspin>=5.1,<6.0",
|
| 398 |
+
"onnxruntime>=1.18.0",
|
| 399 |
+
"torch==2.5.1",
|
| 400 |
+
"torchvision==0.20.1",
|
| 401 |
+
"pillow<12.0",
|
| 402 |
+
"pydantic<=2.12.5",
|
| 403 |
+
"requests>=2.33.0",
|
| 404 |
+
"gstreamer-bundle==1.28.1; sys_platform != 'linux'",
|
| 405 |
+
]
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
## Usage Flow
|
| 409 |
+
|
| 410 |
+
1. **Install App**
|
| 411 |
+
- Install `reachy_mini_ha_voice` from Reachy Mini App Store
|
| 412 |
+
|
| 413 |
+
2. **Start App**
|
| 414 |
+
- App auto-starts ESPHome server (port 6053)
|
| 415 |
+
- Auto-downloads required models and sounds
|
| 416 |
+
|
| 417 |
+
3. **Connect Home Assistant**
|
| 418 |
+
- Home Assistant auto-discovers device (mDNS)
|
| 419 |
+
- Or manually add: Settings 閳?Devices & Services 閳?Add Integration 閳?ESPHome
|
| 420 |
+
|
| 421 |
+
4. **Use Voice Assistant**
|
| 422 |
+
- Say "Okay Nabu" to wake
|
| 423 |
+
- Speak command
|
| 424 |
+
- Reachy Mini provides motion feedback
|
| 425 |
+
|
| 426 |
+
## ESPHome Entity Planning
|
| 427 |
+
|
| 428 |
+
Based on deep analysis of Reachy Mini SDK, the following entities are exposed to Home Assistant:
|
| 429 |
+
|
| 430 |
+
### Implemented Entities
|
| 431 |
+
|
| 432 |
+
| Entity Type | Name | Description |
|
| 433 |
+
|-------------|------|-------------|
|
| 434 |
+
| Media Player | `media_player` | Audio playback control |
|
| 435 |
+
| Voice Assistant | `voice_assistant` | Voice assistant pipeline |
|
| 436 |
+
|
| 437 |
+
### Implemented Control Entities (Read/Write)
|
| 438 |
+
|
| 439 |
+
#### Phase 1-3: Basic Controls and Pose
|
| 440 |
+
|
| 441 |
+
| ESPHome Entity Type | Name | SDK API | Range/Options | Description |
|
| 442 |
+
|---------------------|------|---------|---------------|-------------|
|
| 443 |
+
| `Number` | `speaker_volume` | `AudioPlayer.set_volume()` | 0-100 | Speaker volume |
|
| 444 |
+
| `Switch` | `idle_behavior_enabled` | `set_idle_behavior_enabled()` | off=parked/on=idle runtime enabled | Unified idle behavior toggle |
|
| 445 |
+
| `Number` | `head_x` | `goto_target(head=...)` | 卤50mm | Head X position control |
|
| 446 |
+
| `Number` | `head_y` | `goto_target(head=...)` | 卤50mm | Head Y position control |
|
| 447 |
+
| `Number` | `head_z` | `goto_target(head=...)` | 卤50mm | Head Z position control |
|
| 448 |
+
| `Number` | `head_roll` | `goto_target(head=...)` | -40掳 ~ +40掳 | Head roll angle control |
|
| 449 |
+
| `Number` | `head_pitch` | `goto_target(head=...)` | -40掳 ~ +40掳 | Head pitch angle control |
|
| 450 |
+
| `Number` | `head_yaw` | `goto_target(head=...)` | -180掳 ~ +180掳 | Head yaw angle control |
|
| 451 |
+
| `Number` | `body_yaw` | `goto_target(body_yaw=...)` | -160掳 ~ +160掳 | Body yaw angle control |
|
| 452 |
+
| `Number` | `antenna_left` | `goto_target(antennas=...)` | -90掳 ~ +90掳 | Left antenna angle control |
|
| 453 |
+
| `Number` | `antenna_right` | `goto_target(antennas=...)` | -90掳 ~ +90掳 | Right antenna angle control |
|
| 454 |
+
|
| 455 |
+
#### Phase 4: Gaze Control
|
| 456 |
+
|
| 457 |
+
| ESPHome Entity Type | Name | SDK API | Range/Options | Description |
|
| 458 |
+
|---------------------|------|---------|---------------|-------------|
|
| 459 |
+
| `Number` | `look_at_x` | `look_at_world(x, y, z)` | World coordinates | Gaze point X coordinate |
|
| 460 |
+
| `Number` | `look_at_y` | `look_at_world(x, y, z)` | World coordinates | Gaze point Y coordinate |
|
| 461 |
+
| `Number` | `look_at_z` | `look_at_world(x, y, z)` | World coordinates | Gaze point Z coordinate |
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
### Implemented Sensor Entities (Read-only)
|
| 465 |
+
|
| 466 |
+
#### Phase 1 & 5: Basic Status and Audio Sensors
|
| 467 |
+
|
| 468 |
+
| ESPHome Entity Type | Name | SDK API | Description |
|
| 469 |
+
|---------------------|------|---------|-------------|
|
| 470 |
+
| `Text Sensor` | `daemon_state` | `DaemonStatus.state` | Daemon status |
|
| 471 |
+
| `Binary Sensor` | `backend_ready` | `backend_status.ready` | Backend ready status |
|
| 472 |
+
| `Text Sensor` | `error_message` | `DaemonStatus.error` | Current error message |
|
| 473 |
+
| `Sensor` | `doa_angle` | `DoAInfo.angle` | Sound source direction angle (鎺? |
|
| 474 |
+
| `Binary Sensor` | `speech_detected` | `DoAInfo.speech_detected` | Speech detection status |
|
| 475 |
+
|
| 476 |
+
#### Phase 6: Diagnostic Information
|
| 477 |
+
|
| 478 |
+
| ESPHome Entity Type | Name | SDK API | Description |
|
| 479 |
+
|---------------------|------|---------|-------------|
|
| 480 |
+
| `Sensor` | `control_loop_frequency` | `control_loop_stats` | Control loop frequency (Hz) |
|
| 481 |
+
| `Text Sensor` | `sdk_version` | `DaemonStatus.version` | SDK version |
|
| 482 |
+
| `Text Sensor` | `robot_name` | `DaemonStatus.robot_name` | Robot name |
|
| 483 |
+
| `Binary Sensor` | `wireless_version` | `DaemonStatus.wireless_version` | Wireless version flag |
|
| 484 |
+
| `Binary Sensor` | `simulation_mode` | `DaemonStatus.simulation_enabled` | Simulation mode flag |
|
| 485 |
+
| `Text Sensor` | `wlan_ip` | `DaemonStatus.wlan_ip` | Wireless IP address |
|
| 486 |
+
|
| 487 |
+
#### Phase 7: IMU Sensors (Wireless version only)
|
| 488 |
+
|
| 489 |
+
| ESPHome Entity Type | Name | SDK API | Description |
|
| 490 |
+
|---------------------|------|---------|-------------|
|
| 491 |
+
| `Sensor` | `imu_accel_x` | `mini.imu["accelerometer"][0]` | X-axis acceleration (m/s铏? |
|
| 492 |
+
| `Sensor` | `imu_accel_y` | `mini.imu["accelerometer"][1]` | Y-axis acceleration (m/s铏? |
|
| 493 |
+
| `Sensor` | `imu_accel_z` | `mini.imu["accelerometer"][2]` | Z-axis acceleration (m/s铏? |
|
| 494 |
+
| `Sensor` | `imu_gyro_x` | `mini.imu["gyroscope"][0]` | X-axis angular velocity (rad/s) |
|
| 495 |
+
| `Sensor` | `imu_gyro_y` | `mini.imu["gyroscope"][1]` | Y-axis angular velocity (rad/s) |
|
| 496 |
+
| `Sensor` | `imu_gyro_z` | `mini.imu["gyroscope"][2]` | Z-axis angular velocity (rad/s) |
|
| 497 |
+
| `Sensor` | `imu_temperature` | `mini.imu["temperature"]` | IMU temperature (鎺矯) |
|
| 498 |
+
|
| 499 |
+
#### Current Runtime Control and Sensor Entities
|
| 500 |
+
|
| 501 |
+
| Phase | ESPHome Entity Type | Name | Description |
|
| 502 |
+
|------|---------------------|------|-------------|
|
| 503 |
+
| 1 | `Switch` | `mute` | Suspend/resume the voice pipeline |
|
| 504 |
+
| 1 | `Switch` | `camera_disabled` | Disable/enable camera runtime |
|
| 505 |
+
| 1 | `Switch` | `idle_behavior_enabled` | Unified idle motion / antenna / micro-actions toggle |
|
| 506 |
+
| 1 | `Switch` | `sendspin_enabled` | Enable/disable Sendspin playback integration |
|
| 507 |
+
| 1 | `Switch` | `face_tracking_enabled` | Enable/disable face tracking models |
|
| 508 |
+
| 1 | `Switch` | `gesture_detection_enabled` | Enable/disable gesture detection models |
|
| 509 |
+
| 1 | `Number` | `face_confidence_threshold` | Face tracking confidence threshold (0-1) |
|
| 510 |
+
| 2 | `Binary Sensor` | `services_suspended` | Runtime suspension state |
|
| 511 |
+
| 8 | `Select` | `emotion` | Manual emotion trigger |
|
| 512 |
+
| 10 | `Camera` | `camera` | ESPHome camera entity / live preview |
|
| 513 |
+
| 21 | `Switch` | `continuous_conversation` | Multi-turn conversation mode |
|
| 514 |
+
| 22 | `Text Sensor` | `gesture_detected` | Current detected gesture |
|
| 515 |
+
| 22 | `Sensor` | `gesture_confidence` | Current gesture confidence |
|
| 516 |
+
| 23 | `Binary Sensor` | `face_detected` | Face currently visible |
|
| 517 |
+
|
| 518 |
+
> **Note**: Head position (x/y/z) and angles (roll/pitch/yaw), body yaw, antenna angles are all **controllable** entities,
|
| 519 |
+
> using `Number` type for bidirectional control. Call `goto_target()` when setting new values, call `get_current_head_pose()` etc. when reading current values.
|
| 520 |
+
|
| 521 |
+
### Implementation Priority
|
| 522 |
+
|
| 523 |
+
1. **Phase 1 - Basic Status and Volume** (High Priority) 閴?**Completed**
|
| 524 |
+
- [x] `daemon_state` - Daemon status sensor
|
| 525 |
+
- [x] `backend_ready` - Backend ready status
|
| 526 |
+
- [x] `error_message` - Error message
|
| 527 |
+
- [x] `speaker_volume` - Speaker volume control
|
| 528 |
+
|
| 529 |
+
2. **Phase 2 - Runtime State** (High Priority) 鉁?**Completed**
|
| 530 |
+
- [x] `services_suspended` - Service suspension state sensor
|
| 531 |
+
- [x] App-managed sleep/wake entities removed from the current runtime
|
| 532 |
+
|
| 533 |
+
3. **Phase 3 - Pose Control** (Medium Priority) 閴?**Completed**
|
| 534 |
+
- [x] `head_x/y/z` - Head position control
|
| 535 |
+
- [x] `head_roll/pitch/yaw` - Head angle control
|
| 536 |
+
- [x] `body_yaw` - Body yaw angle control
|
| 537 |
+
- [x] `antenna_left/right` - Antenna angle control
|
| 538 |
+
|
| 539 |
+
4. **Phase 4 - Gaze Control** (Medium Priority) 閴?**Completed**
|
| 540 |
+
- [x] `look_at_x/y/z` - Gaze point coordinate control
|
| 541 |
+
|
| 542 |
+
5. **Phase 5 - DOA (Direction of Arrival)** 閴?**Re-added for wakeup turn-to-sound**
|
| 543 |
+
- [x] `doa_angle` - Sound source direction (degrees, 0-180鎺? where 0鎺?left, 90鎺?front, 180鎺?right)
|
| 544 |
+
- [x] `speech_detected` - Speech detection status
|
| 545 |
+
- [x] Turn-to-sound at wakeup (robot turns toward speaker when wake word detected)
|
| 546 |
+
- [x] Direction correction: `yaw = 锜?2 - doa` (fixed left/right inversion)
|
| 547 |
+
- Note: DOA only read once at wakeup to avoid daemon pressure; face tracking takes over after
|
| 548 |
+
|
| 549 |
+
6. **Phase 6 - Diagnostic Information** (Low Priority) 閴?**Completed**
|
| 550 |
+
- [x] `control_loop_frequency` - Control loop frequency
|
| 551 |
+
- [x] `sdk_version` - SDK version
|
| 552 |
+
- [x] `robot_name` - Robot name
|
| 553 |
+
- [x] `wireless_version` - Wireless version flag
|
| 554 |
+
- [x] `simulation_mode` - Simulation mode flag
|
| 555 |
+
- [x] `wlan_ip` - Wireless IP address
|
| 556 |
+
|
| 557 |
+
7. **Phase 7 - IMU Sensors** (Optional, wireless version only) 閴?**Completed**
|
| 558 |
+
- [x] `imu_accel_x/y/z` - Accelerometer
|
| 559 |
+
- [x] `imu_gyro_x/y/z` - Gyroscope
|
| 560 |
+
- [x] `imu_temperature` - IMU temperature
|
| 561 |
+
|
| 562 |
+
8. **Phase 8 - Emotion Control** 閴?**Completed**
|
| 563 |
+
- [x] `emotion` - Emotion selector (Happy/Sad/Angry/Fear/Surprise/Disgust)
|
| 564 |
+
|
| 565 |
+
9. **Phase 10 - Camera Integration** 閴?**Completed**
|
| 566 |
+
- [x] `camera` - ESPHome Camera entity (live preview)
|
| 567 |
+
|
| 568 |
+
10. **Phase 11 - LED Control** 閴?**Disabled (LEDs hidden inside robot)**
|
| 569 |
+
- [ ] `led_brightness` - LED brightness (0-100%) - Commented out
|
| 570 |
+
- [ ] `led_effect` - LED effect (off/solid/breathing/rainbow/doa) - Commented out
|
| 571 |
+
- [ ] `led_color_r/g/b` - LED RGB color (0-255) - Commented out
|
| 572 |
+
|
| 573 |
+
11. **Phase 13 - Sendspin Audio Playback Support** 閴?**Completed**
|
| 574 |
+
- [x] `sendspin_enabled` - Sendspin switch (Switch)
|
| 575 |
+
- [x] AudioPlayer integrates aiosendspin library
|
| 576 |
+
- [x] Local music/sendspin path coexists with voice playback and is auto-paused during conversation
|
| 577 |
+
|
| 578 |
+
12. **Phase 21 - Continuous Conversation** 閴?**Completed**
|
| 579 |
+
- [x] `continuous_conversation` - Conversation continuation switch
|
| 580 |
+
|
| 581 |
+
13. **Phase 22 - Gesture Detection** 鉁?**Completed (current runtime behavior)**
|
| 582 |
+
- [x] `gesture_detected` - Detected gesture name (Text Sensor)
|
| 583 |
+
- [x] `gesture_confidence` - Gesture detection confidence % (Sensor)
|
| 584 |
+
- [x] HaGRID ONNX models: hand_detector.onnx + crops_classifier.onnx
|
| 585 |
+
- [x] Real-time state push to Home Assistant
|
| 586 |
+
- [x] Runtime gesture result publishing only (no gesture-driven robot actions)
|
| 587 |
+
- [x] Runtime toggle supported (default OFF, model unload on disable)
|
| 588 |
+
- [x] Batch detection: returns all detected hands (not just highest confidence)
|
| 589 |
+
- [x] Minimum processing cadence preserved for responsiveness
|
| 590 |
+
- [x] No conflicts with face tracking (shared frame, independent processing)
|
| 591 |
+
- [x] SDK integration: MediaBackend detection, proper resource cleanup on shutdown
|
| 592 |
+
- [x] 18 supported gestures:
|
| 593 |
+
| Gesture | Emoji | Gesture | Emoji |
|
| 594 |
+
|---------|-------|---------|-------|
|
| 595 |
+
| call | 棣冾樉 | like | 棣冩啢 |
|
| 596 |
+
| dislike | 棣冩啣 | mute | 棣冦亱 |
|
| 597 |
+
| fist | 閴?| ok | 棣冩啠 |
|
| 598 |
+
| four | 棣冩瀾閿?| one | 閳芥繐绗?|
|
| 599 |
+
| palm | 閴?| peace | 閴佸矉绗?|
|
| 600 |
+
| peace_inverted | 棣冩暰閴佸矉绗?| rock | 棣冾樈 |
|
| 601 |
+
| stop | 棣冩磧 | stop_inverted | 棣冩暰棣冩磧 |
|
| 602 |
+
| three | 3閿斿繆鍎?| three2 | 棣冾檮 |
|
| 603 |
+
| two_up | 閴佸矉绗嶉埥婵撶瑣 | two_up_inverted | 棣冩暰閴佸矉绗嶉埥婵撶瑣 |
|
| 604 |
+
|
| 605 |
+
14. **Phase 23 - Face Detection** 閴?**Completed**
|
| 606 |
+
- [x] `face_detected` - Face visibility sensor
|
| 607 |
+
|
| 608 |
+
15. **Phase 24 - System Diagnostics** 閴?**Completed**
|
| 609 |
+
- [x] `sys_cpu_percent` - CPU usage percentage (Sensor, diagnostic)
|
| 610 |
+
- [x] `sys_cpu_temperature` - CPU temperature in Celsius (Sensor, diagnostic)
|
| 611 |
+
- [x] `sys_memory_percent` - Memory usage percentage (Sensor, diagnostic)
|
| 612 |
+
- [x] `sys_memory_used` - Used memory in GB (Sensor, diagnostic)
|
| 613 |
+
- [x] `sys_disk_percent` - Disk usage percentage (Sensor, diagnostic)
|
| 614 |
+
- [x] `sys_disk_free` - Free disk space in GB (Sensor, diagnostic)
|
| 615 |
+
- [x] `sys_uptime` - System uptime in hours (Sensor, diagnostic)
|
| 616 |
+
- [x] `sys_process_cpu` - This process CPU usage (Sensor, diagnostic)
|
| 617 |
+
- [x] `sys_process_memory` - This process memory in MB (Sensor, diagnostic)
|
| 618 |
+
|
| 619 |
+
---
|
| 620 |
+
|
| 621 |
+
## 棣冨竴 Current Runtime Entity Coverage
|
| 622 |
+
|
| 623 |
+
**Total Completed: See runtime registry (count evolves with releases)**
|
| 624 |
+
- Phase 1: 10 entities (status, zero-config runtime switches, volume)
|
| 625 |
+
- Phase 2: runtime state entities only (`services_suspended`; sleep entities removed)
|
| 626 |
+
- Phase 3: 9 entities (Pose control)
|
| 627 |
+
- Phase 4: 3 entities (Gaze control)
|
| 628 |
+
- Phase 5: 3 entities (DOA sensors and tracking switch)
|
| 629 |
+
- Phase 6: 7 entities (Diagnostic information)
|
| 630 |
+
- Phase 7: 7 entities (IMU sensors)
|
| 631 |
+
- Phase 8: 1 entity (Emotion control)
|
| 632 |
+
- Phase 10: 1 entity (Camera)
|
| 633 |
+
- Phase 11: 0 entities (LED control - Disabled)
|
| 634 |
+
- Phase 13: 1 entity (Sendspin toggle)
|
| 635 |
+
- Phase 21: 1 entity (Continuous conversation)
|
| 636 |
+
- Phase 22: 2 entities (Gesture detection)
|
| 637 |
+
- Phase 23: 1 entity (Face detection)
|
| 638 |
+
- Phase 24: 9 entities (System diagnostics)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
---
|
| 642 |
+
|
| 643 |
+
## 棣冩畬 Voice Assistant Enhancement Features Implementation Status
|
| 644 |
+
|
| 645 |
+
### Phase 14 - Emotion and Motion Feedback 閴?
|
| 646 |
+
**Current Status**: Manual emotion playback and non-blocking motion feedback are implemented. Automatic keyword-based emotion triggering is currently disabled in the runtime.
|
| 647 |
+
|
| 648 |
+
**Implemented Features**:
|
| 649 |
+
- 閴?Phase 8 Emotion Selector entity (`emotion`)
|
| 650 |
+
- 閴?`_play_emotion()` queues emotion moves through `MovementManager`
|
| 651 |
+
- 閴?Wake/listen/think/speak/idle motion transitions are non-blocking
|
| 652 |
+
- 閴?Timer-finished motion feedback is implemented
|
| 653 |
+
- 閴?Gesture detection publishes recognized gesture label and confidence to Home Assistant entities
|
| 654 |
+
- 閴?Voice phases and HA state reactions share one built-in behavior dispatcher
|
| 655 |
+
|
| 656 |
+
**Current Behavior**:
|
| 657 |
+
|
| 658 |
+
| Voice Assistant Event | Actual Action | Implementation Status |
|
| 659 |
+
|----------------------|---------------|----------------------|
|
| 660 |
+
| Wake word detected | Turn toward sound source + listening pose | 閴?Implemented |
|
| 661 |
+
| Listening | Attentive listening state | 閴?Implemented |
|
| 662 |
+
| Thinking | Thinking state animation | 閴?Implemented |
|
| 663 |
+
| Speaking | Speech-reactive motion | 閴?Implemented |
|
| 664 |
+
| Timer completed | Alert shake motion | 閴?Implemented |
|
| 665 |
+
| Manual emotion trigger | Play via ESPHome `emotion` entity | 閴?Implemented |
|
| 666 |
+
|
| 667 |
+
**Deliberately Not Active In Runtime**:
|
| 668 |
+
- Automatic emotion keyword detection from assistant text
|
| 669 |
+
- Blocking full-action choreography during conversation
|
| 670 |
+
- Dance/personalization layers that require user configuration
|
| 671 |
+
|
| 672 |
+
**Manual Emotion Trigger Example**:
|
| 673 |
+
```yaml
|
| 674 |
+
# Home Assistant automation example - Manual emotion trigger
|
| 675 |
+
automation:
|
| 676 |
+
- alias: "Reachy Good Morning Greeting"
|
| 677 |
+
trigger:
|
| 678 |
+
- platform: time
|
| 679 |
+
at: "07:00:00"
|
| 680 |
+
action:
|
| 681 |
+
- service: select.select_option
|
| 682 |
+
target:
|
| 683 |
+
entity_id: select.reachy_mini_emotion
|
| 684 |
+
data:
|
| 685 |
+
option: "Happy"
|
| 686 |
+
```
|
| 687 |
+
|
| 688 |
+
### Phase 15 - Face Tracking (Complements DOA Turn-to-Sound) 閴?**Completed**
|
| 689 |
+
|
| 690 |
+
**Goal**: Implement natural face tracking so robot looks at speaker during conversation.
|
| 691 |
+
|
| 692 |
+
**Design Decision**:
|
| 693 |
+
- 閴?DOA (Direction of Arrival): Used once at wakeup to turn toward sound source
|
| 694 |
+
- 閴?YOLO face detection: Takes over after initial turn for continuous tracking
|
| 695 |
+
- 閴?Body follows head rotation: Body yaw automatically syncs with head yaw for natural tracking
|
| 696 |
+
- Reason: DOA provides quick initial orientation, face tracking provides accurate continuous tracking, body following enables natural whole-body tracking similar to human behavior
|
| 697 |
+
|
| 698 |
+
**Wakeup Turn-to-Sound Flow**:
|
| 699 |
+
1. Wake word detected 閳?Read DOA angle once (avoid daemon pressure)
|
| 700 |
+
2. If DOA angle > 10鎺? Turn head toward sound source (80% of angle, conservative)
|
| 701 |
+
3. Face tracking takes over for continuous tracking during conversation
|
| 702 |
+
|
| 703 |
+
**Implemented Features**:
|
| 704 |
+
|
| 705 |
+
| Feature | Description | Implementation Location | Status |
|
| 706 |
+
|---------|-------------|------------------------|--------|
|
| 707 |
+
| DOA turn-to-sound | Turn toward speaker at wakeup | `protocol/satellite.py:_turn_to_sound_source()` | 閴?Implemented |
|
| 708 |
+
| YOLO face detection | Uses `AdamCodd/YOLOv11n-face-detection` model | `vision/head_tracker.py` | 閴?Implemented |
|
| 709 |
+
| Adaptive frame rate tracking | 15fps during conversation, 2fps when idle without face | `camera_server.py` | 閴?Implemented |
|
| 710 |
+
| look_at_image() | Calculate target pose from face position | `camera_server.py` | 閴?Implemented |
|
| 711 |
+
| Smooth return to neutral | Smooth return within 1 second after face lost | `camera_server.py` | 閴?Implemented |
|
| 712 |
+
| face_tracking_offsets | As secondary pose overlay to motion control | `movement_manager.py` | 閴?Implemented |
|
| 713 |
+
| Body follows head rotation | Body yaw syncs with head yaw extracted from final pose matrix | `motion/movement_manager.py:_compose_final_pose()` | 閴?Implemented (v0.8.3) |
|
| 714 |
+
| DOA entities | `doa_angle` and `speech_detected` exposed to Home Assistant | `entity_registry.py` | 閴?Implemented |
|
| 715 |
+
| face_detected entity | Binary sensor for face detection state | `entity_registry.py` | 閴?Implemented |
|
| 716 |
+
| Model download retry | 3 retries, 5 second interval | `head_tracker.py` | 閴?Implemented |
|
| 717 |
+
| Conversation mode integration | Auto-switch tracking frequency on voice assistant state change | `satellite.py` | 閴?Implemented |
|
| 718 |
+
|
| 719 |
+
**Resource Optimization (v0.5.1, updated v0.6.2)**:
|
| 720 |
+
- During conversation (listening/thinking/speaking): High-frequency tracking 15fps
|
| 721 |
+
- Idle with face detected: High-frequency tracking 15fps
|
| 722 |
+
- Idle without face for 5s: Low-power mode 2fps
|
| 723 |
+
- Idle without face for 30s: Ultra-low power mode 0.5fps (every 2 seconds)
|
| 724 |
+
- Gesture detection is switch-controlled and can run independently of face tracking
|
| 725 |
+
- Immediately restore high-frequency tracking when face detected
|
| 726 |
+
|
| 727 |
+
**Code Locations**:
|
| 728 |
+
- `protocol/satellite.py:_turn_to_sound_source()` - DOA turn-to-sound at wakeup
|
| 729 |
+
- `vision/head_tracker.py` - YOLO face detector (`HeadTracker` class)
|
| 730 |
+
- `vision/camera_server.py:_capture_frames()` - Adaptive frame rate face tracking
|
| 731 |
+
- `vision/camera_server.py:set_conversation_mode()` - Conversation mode switch API
|
| 732 |
+
- `protocol/satellite.py:_set_conversation_mode()` - Voice assistant state integration
|
| 733 |
+
- `motion/movement_manager.py:set_face_tracking_offsets()` - Face tracking offset API
|
| 734 |
+
- `motion/movement_manager.py:_compose_final_pose()` - Body yaw follows head yaw (v0.8.3)
|
| 735 |
+
|
| 736 |
+
**Technical Details**:
|
| 737 |
+
```python
|
| 738 |
+
# vision/camera_server.py - Adaptive frame rate face tracking
|
| 739 |
+
class MJPEGCameraServer:
|
| 740 |
+
def __init__(self):
|
| 741 |
+
self._fps_high = 15 # During conversation/face detected
|
| 742 |
+
self._fps_low = 2 # Idle without face (5-30s)
|
| 743 |
+
self._fps_idle = 0.5 # Ultra-low power (>30s without face)
|
| 744 |
+
self._low_power_threshold = 5.0 # 5s without face switches to low power
|
| 745 |
+
self._idle_threshold = 30.0 # 30s without face switches to idle mode
|
| 746 |
+
|
| 747 |
+
def _should_run_ai_inference(self, current_time):
|
| 748 |
+
# Conversation mode: Always high-frequency tracking
|
| 749 |
+
if self._in_conversation:
|
| 750 |
+
return True
|
| 751 |
+
# High-frequency mode: Track every frame
|
| 752 |
+
if self._current_fps == self._fps_high:
|
| 753 |
+
return True
|
| 754 |
+
# Low/idle power mode: Periodic detection
|
| 755 |
+
return time.since_last_check >= 1/self._current_fps
|
| 756 |
+
|
| 757 |
+
# protocol/satellite.py - Voice assistant state integration
|
| 758 |
+
def _reachy_on_listening(self):
|
| 759 |
+
self._set_conversation_mode(True) # Start conversation, high-frequency tracking
|
| 760 |
+
|
| 761 |
+
def _reachy_on_idle(self):
|
| 762 |
+
self._set_conversation_mode(False) # End conversation, adaptive tracking
|
| 763 |
+
|
| 764 |
+
# motion/movement_manager.py - Body follows head rotation (v0.8.3)
|
| 765 |
+
# This enables natural body rotation when tracking faces, similar to how
|
| 766 |
+
# the reference project's sweep_look tool synchronizes body_yaw with head_yaw.
|
| 767 |
+
def _compose_final_pose(self) -> Tuple[np.ndarray, Tuple[float, float], float]:
|
| 768 |
+
# ... compose head pose from all motion sources ...
|
| 769 |
+
|
| 770 |
+
# Extract yaw from final head pose rotation matrix
|
| 771 |
+
# The rotation matrix uses xyz euler convention
|
| 772 |
+
final_rotation = R.from_matrix(final_head[:3, :3])
|
| 773 |
+
_, _, final_head_yaw = final_rotation.as_euler('xyz')
|
| 774 |
+
|
| 775 |
+
# Body follows head yaw directly
|
| 776 |
+
# SDK's automatic_body_yaw (inverse_kinematics_safe) only handles collision
|
| 777 |
+
# prevention by clamping relative angle to max 65鎺? not active following
|
| 778 |
+
body_yaw = final_head_yaw
|
| 779 |
+
|
| 780 |
+
return final_head, (antenna_right, antenna_left), body_yaw
|
| 781 |
+
```
|
| 782 |
+
|
| 783 |
+
**Body Following Head Rotation (v0.8.3)**:
|
| 784 |
+
- SDK's `automatic_body_yaw` is only **collision protection**, not active body following
|
| 785 |
+
- The `inverse_kinematics_safe` function with `max_relative_yaw=65鎺砢 only prevents head-body collision
|
| 786 |
+
- To enable natural body following, `body_yaw` must be explicitly set to match `head_yaw`
|
| 787 |
+
- Body yaw is extracted from final head pose matrix using scipy's `R.from_matrix().as_euler('xyz')`
|
| 788 |
+
- This matches the reference project's `sweep_look.py` behavior where `target_body_yaw = head_yaw`
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
### Phase 16 - Cartoon Style Motion Mode (Partial) 棣冪厸
|
| 792 |
+
|
| 793 |
+
**Goal**: Use SDK interpolation techniques for more expressive robot movements.
|
| 794 |
+
|
| 795 |
+
**SDK Support**: `InterpolationTechnique` enum
|
| 796 |
+
- `LINEAR` - Linear, mechanical feel
|
| 797 |
+
- `MIN_JERK` - Minimum jerk, natural and smooth (default)
|
| 798 |
+
- `EASE_IN_OUT` - Ease in-out, elegant
|
| 799 |
+
- `CARTOON` - Cartoon style, with bounce effect, lively and cute
|
| 800 |
+
|
| 801 |
+
**Implemented Features**:
|
| 802 |
+
- 閴?50Hz unified control loop (`motion/movement_manager.py`) - Current stable frequency
|
| 803 |
+
- 閴?JSON-driven animation system (`AnimationPlayer`) - Inspired by SimpleDances project
|
| 804 |
+
- 閴?Conversation state animations (idle/listening/thinking/speaking)
|
| 805 |
+
- 閴?Pose change detection - Only send commands on significant changes (threshold 0.005)
|
| 806 |
+
- 閴?State query caching - 2s TTL, reduces daemon load
|
| 807 |
+
- 閴?Smooth interpolation (ease in-out curve)
|
| 808 |
+
- 閴?Command queue mode - Thread-safe external API
|
| 809 |
+
- 閴?Error throttling - Prevents log explosion
|
| 810 |
+
- 閴?Connection health monitoring - Auto-detect and recover from connection loss
|
| 811 |
+
|
| 812 |
+
**Animation System (v0.5.13)**:
|
| 813 |
+
- `AnimationPlayer` class loads animations from `conversation_animations.json`
|
| 814 |
+
- Each animation defines: pitch/yaw/roll amplitudes, position offsets, antenna movements, frequency
|
| 815 |
+
- Smooth transitions between animations (configurable duration)
|
| 816 |
+
- State-to-animation mapping: idle閳姕dle, listening閳姡istening, thinking閳姲hinking, speaking閳姱peaking
|
| 817 |
+
|
| 818 |
+
**Not Implemented**:
|
| 819 |
+
- 閴?Dynamic interpolation technique switching (CARTOON/EASE_IN_OUT etc.)
|
| 820 |
+
- 閴?Exaggerated cartoon bounce effects
|
| 821 |
+
|
| 822 |
+
**Code Locations**:
|
| 823 |
+
- `motion/animation_player.py` - AnimationPlayer class
|
| 824 |
+
- `animations/conversation_animations.json` - Animation definitions
|
| 825 |
+
- `motion/movement_manager.py` - 50Hz control loop with animation integration
|
| 826 |
+
|
| 827 |
+
**Scene Implementation Status**:
|
| 828 |
+
|
| 829 |
+
| Scene | Recommended Interpolation | Effect | Status |
|
| 830 |
+
|-------|--------------------------|--------|--------|
|
| 831 |
+
| Wake nod | `CARTOON` | Lively bounce effect | 閴?Not implemented |
|
| 832 |
+
| Thinking head up | `EASE_IN_OUT` | Elegant transition | 閴?Implemented (smooth interpolation) |
|
| 833 |
+
| Speaking micro-movements | `MIN_JERK` | Natural and fluid | 閴?Implemented (SpeechSway) |
|
| 834 |
+
| Error head shake | `CARTOON` | Exaggerated denial | 閴?Not implemented |
|
| 835 |
+
| Return to neutral | `MIN_JERK` | Smooth return | 閴?Implemented |
|
| 836 |
+
| Idle breathing | - | Subtle sense of life | 閴?Implemented (BreathingAnimation) |
|
| 837 |
+
|
| 838 |
+
### Phase 17 - Antenna Sync Animation During Speech (Completed) 閴?
|
| 839 |
+
**Goal**: Antennas sway with audio rhythm during TTS playback, simulating "speaking" effect.
|
| 840 |
+
|
| 841 |
+
**Implemented Features**:
|
| 842 |
+
- 閴?JSON-driven animation system with antenna movements
|
| 843 |
+
- 閴?Different antenna patterns: "both" (sync), "wiggle" (opposite phase)
|
| 844 |
+
- 閴?State-specific antenna animations (listening/thinking/speaking)
|
| 845 |
+
- 閴?Smooth transitions between animation states
|
| 846 |
+
- 閴?v1.0.0 idle refinement: idle antenna sway disabled while conversation-state antenna behaviors are retained
|
| 847 |
+
- 閴?v1.0.0 hardware refinement: antenna torque disabled in `IDLE` to reduce idle chatter/noise
|
| 848 |
+
|
| 849 |
+
**Code Locations**:
|
| 850 |
+
- `motion/animation_player.py` - AnimationPlayer with antenna offset calculation
|
| 851 |
+
- `animations/conversation_animations.json` - Antenna amplitude and pattern definitions
|
| 852 |
+
- `motion/movement_manager.py` - Antenna offset composition in final pose
|
| 853 |
+
|
| 854 |
+
### Phase 18 - Visual Gaze Interaction (Single-face only) 閴?
|
| 855 |
+
**Goal**: Use camera to detect faces for eye contact.
|
| 856 |
+
|
| 857 |
+
**SDK Support**:
|
| 858 |
+
- `look_at_image(u, v)` - Look at point in image
|
| 859 |
+
- `look_at_world(x, y, z)` - Look at world coordinate point
|
| 860 |
+
- `media.get_frame()` - Get camera frame (閴?Already implemented in `vision/camera_server.py:146`)
|
| 861 |
+
|
| 862 |
+
**Current Status**:
|
| 863 |
+
|
| 864 |
+
| Feature | Description | Status |
|
| 865 |
+
|---------|-------------|--------|
|
| 866 |
+
| Face detection | YOLO-based face detection (`AdamCodd/YOLOv11n-face-detection`) | 閴?Implemented |
|
| 867 |
+
| Eye tracking | Robot tracks detected face during conversation/active mode | 閴?Implemented |
|
| 868 |
+
| Idle scanning | Random look-around in idle cycles (switch-controlled) | 閴?Implemented |
|
| 869 |
+
|
| 870 |
+
> Scope note: Current implementation is intentionally single-face tracking for stability and device performance.
|
| 871 |
+
|
| 872 |
+
### Phase 19 - Gravity Compensation Interactive Mode (Historical / Not Current Target)
|
| 873 |
+
|
| 874 |
+
This was an exploration direction for manual teaching workflows.
|
| 875 |
+
|
| 876 |
+
**Current Runtime Position**:
|
| 877 |
+
- The zero-config runtime does not depend on a teaching flow
|
| 878 |
+
- No user-facing teaching interaction is exposed as a core feature
|
| 879 |
+
- If gravity-compensation support is revisited, it should remain optional and not become a required setup path
|
| 880 |
+
|
| 881 |
+
### Phase 20 - Environment Awareness Response (Partial) 棣冪厸
|
| 882 |
+
|
| 883 |
+
**Goal**: Use IMU sensors to sense environment changes and respond.
|
| 884 |
+
|
| 885 |
+
**SDK Support**:
|
| 886 |
+
- 閴?`mini.imu["accelerometer"]` - Accelerometer (Phase 7 implemented as entity)
|
| 887 |
+
- 閴?`mini.imu["gyroscope"]` - Gyroscope (Phase 7 implemented as entity)
|
| 888 |
+
|
| 889 |
+
**Implemented Features**:
|
| 890 |
+
|
| 891 |
+
| Feature | Description | Status |
|
| 892 |
+
|---------|-------------|--------|
|
| 893 |
+
| Continuous conversation | Controlled via Home Assistant switch | 閴?Implemented |
|
| 894 |
+
| IMU sensor entities | Accelerometer and gyroscope exposed to HA | 閴?Implemented |
|
| 895 |
+
|
| 896 |
+
> **Note**: Tap-to-wake feature was removed in v0.5.16 due to false triggers from robot movement. Continuous conversation is now controlled via Home Assistant switch.
|
| 897 |
+
|
| 898 |
+
**Not Implemented**:
|
| 899 |
+
|
| 900 |
+
| Detection Event | Response Action | Status |
|
| 901 |
+
|-----------------|-----------------|--------|
|
| 902 |
+
| Being shaken | Play dizzy action + voice "Don't shake me~" | 閴?Not implemented |
|
| 903 |
+
| Tilted/fallen | Play help action + voice "I fell, help me" | 閴?Not implemented |
|
| 904 |
+
| Long idle | Enter sleep animation | 閴?Not implemented |
|
| 905 |
+
|
| 906 |
+
### Phase 21 - Home Assistant Orchestration Scope
|
| 907 |
+
|
| 908 |
+
The current runtime already exposes the main zero-config controls needed by Home Assistant:
|
| 909 |
+
|
| 910 |
+
- `services_suspended`
|
| 911 |
+
- `idle_behavior_enabled`
|
| 912 |
+
- `continuous_conversation`
|
| 913 |
+
- `emotion`
|
| 914 |
+
- gesture / face / diagnostic sensors
|
| 915 |
+
|
| 916 |
+
More elaborate scene orchestration remains intentionally outside the core runtime scope unless it can be delivered without introducing user configuration burden.
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
---
|
| 920 |
+
|
| 921 |
+
## 棣冩惓 Feature Implementation Summary
|
| 922 |
+
|
| 923 |
+
### 閴?Completed Features
|
| 924 |
+
|
| 925 |
+
#### Core Voice Assistant (Phase 1-12)
|
| 926 |
+
- **ESPHome entities** - Core phases implemented (Phase 11 LED intentionally disabled); exact count evolves by release
|
| 927 |
+
- **Basic voice interaction** - Wake word detection (microWakeWord/openWakeWord), STT/TTS integration
|
| 928 |
+
- **Motion feedback** - Nod, shake, gaze and other basic actions
|
| 929 |
+
- **Audio path** - local wake word / stop word detection plus HA-managed STT/TTS
|
| 930 |
+
- **Camera stream** - MJPEG live preview with ESPHome Camera entity
|
| 931 |
+
|
| 932 |
+
#### Extended Features (Phase 13-22)
|
| 933 |
+
- **Phase 13** 閴?- Sendspin multi-room audio support
|
| 934 |
+
- **Phase 14** 閴?- Manual emotion playback + non-blocking motion feedback
|
| 935 |
+
- **Phase 15** 閴?- Face tracking with body following (DOA + YOLO + body_yaw sync)
|
| 936 |
+
- **Phase 16** 閴?- JSON-driven animation system (50Hz control loop)
|
| 937 |
+
- **Phase 17** 閴?- Antenna sync animation during speech
|
| 938 |
+
- **Phase 22** 閴?- Gesture detection (HaGRID ONNX, 18 gestures)
|
| 939 |
+
|
| 940 |
+
### 棣冪厸 Partially Implemented Features
|
| 941 |
+
|
| 942 |
+
- **Phase 20** - IMU sensor entities are exposed; higher-level trigger logic is intentionally minimal
|
| 943 |
+
|
| 944 |
+
### 閴?Not Implemented Features
|
| 945 |
+
|
| 946 |
+
- Zero-config scene orchestration beyond the provided runtime switches and blueprint defaults
|
| 947 |
+
|
| 948 |
+
---
|
| 949 |
+
|
| 950 |
+
## Feature Priority Summary (Updated v1.0.6)
|
| 951 |
+
|
| 952 |
+
### Completed 鉁?
|
| 953 |
+
- 鉁?**Phase 1-12**: Core ESPHome entities and voice assistant
|
| 954 |
+
- 鉁?**Phase 13**: Sendspin audio playback
|
| 955 |
+
- 鉁?**Phase 14**: Emotion playback and motion feedback
|
| 956 |
+
- 鉁?**Phase 15**: Face tracking with body following
|
| 957 |
+
- 鉁?**Phase 16**: JSON-driven animation system
|
| 958 |
+
- 鉁?**Phase 17**: Antenna sync animation + v1.0.0 idle antenna behavior refinements
|
| 959 |
+
- 鉁?**Phase 21**: Continuous conversation switch
|
| 960 |
+
- 鉁?**Phase 22**: Gesture detection
|
| 961 |
+
- 鉁?**Phase 23**: Face detection sensor
|
| 962 |
+
- 鉁?**Phase 24**: System diagnostics entities
|
| 963 |
+
|
| 964 |
+
### Partial 棣冪厸
|
| 965 |
+
- 棣冪厸 **Phase 20**: Environment awareness (IMU entities done, triggers pending)
|
| 966 |
+
|
| 967 |
+
### Not Implemented 閴?- 閴?Zero-config scene orchestration layer beyond current runtime behavior
|
| 968 |
+
|
| 969 |
+
---
|
| 970 |
+
|
| 971 |
+
## 棣冩惐 Completion Statistics
|
| 972 |
+
|
| 973 |
+
| Phase | Status | Completion | Notes |
|
| 974 |
+
|-------|--------|------------|-------|
|
| 975 |
+
| Phase 1-12 | 閴?Complete | 100% | Core ESPHome entities implemented (Phase 11 LED intentionally disabled) |
|
| 976 |
+
| Phase 13 | 閴?Complete | 100% | Sendspin audio playback support |
|
| 977 |
+
| Phase 14 | 閴?Complete | 100% | Manual emotion playback and non-blocking motion feedback |
|
| 978 |
+
| Phase 15 | 閴?Complete | 100% | Face tracking with DOA, YOLO detection, body follows head |
|
| 979 |
+
| Phase 16 | 閴?Complete | 100% | JSON-driven animation system (50Hz control loop) |
|
| 980 |
+
| Phase 17 | 閴?Complete | 100% | Antenna sync animation during speech |
|
| 981 |
+
| Phase 18 | 閴?Complete | 100% | Single-face visual gaze interaction with idle scanning |
|
| 982 |
+
| Phase 19 | Not a current runtime target | - | Historical planning item, not part of the zero-config runtime model |
|
| 983 |
+
| Phase 20 | 馃煛 Partial | 30% | IMU sensors exposed, missing trigger logic |
|
| 984 |
+
| Phase 21 | 鉁?Complete | 100% | Continuous conversation switch implemented |
|
| 985 |
+
| Phase 22 | 鉁?Complete | 100% | Gesture detection with HaGRID ONNX models |
|
| 986 |
+
| Phase 23 | 鉁?Complete | 100% | Face detection sensor exposed |
|
| 987 |
+
| Phase 24 | 鉁?Complete | 100% | System diagnostics entities (9 sensors) |
|
| 988 |
+
| **v0.9.5** | 鉁?Complete | 100% | Modular architecture refactoring |
|
| 989 |
+
| **v1.0.0** | 鉁?Complete | 100% | Runtime toggles/persistence (Sendspin, face, gesture, confidence) + idle and gesture stability updates |
|
| 990 |
+
|
| 991 |
+
**Overall Completion**: current zero-config runtime path is functionally complete; remaining gaps are optional orchestration ideas rather than missing core runtime features.
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
---
|
| 995 |
+
|
| 996 |
+
## 棣冩暋 Daemon Crash Fix (2025-01-05)
|
| 997 |
+
|
| 998 |
+
### Problem Description
|
| 999 |
+
During long-term operation, `reachy_mini daemon` would crash, causing robot to become unresponsive.
|
| 1000 |
+
|
| 1001 |
+
### Root Cause
|
| 1002 |
+
1. **50Hz control loop** - Current stable frequency for motion control
|
| 1003 |
+
2. **Frequent state queries** - Every entity state read calls `get_status()`, `get_current_head_pose()` etc.
|
| 1004 |
+
3. **Missing change detection** - Even when pose hasn't changed, continues sending same commands
|
| 1005 |
+
4. **Zenoh message queue blocking** - Accumulated 150+ messages per second, daemon cannot process in time
|
| 1006 |
+
|
| 1007 |
+
### Fix Solution
|
| 1008 |
+
|
| 1009 |
+
#### 1. Control loop frequency (motion/movement_manager.py)
|
| 1010 |
+
```python
|
| 1011 |
+
# Evolution: 100Hz -> 20Hz -> 10Hz -> 50Hz (current)
|
| 1012 |
+
# Current stable frequency for production use
|
| 1013 |
+
CONTROL_LOOP_FREQUENCY_HZ = 50 # Current stable frequency
|
| 1014 |
+
```
|
| 1015 |
+
|
| 1016 |
+
#### 2. Add pose change detection (movement_manager.py)
|
| 1017 |
+
```python
|
| 1018 |
+
# Only send commands on significant pose changes
|
| 1019 |
+
if self._last_sent_pose is not None:
|
| 1020 |
+
max_diff = max(abs(pose[k] - self._last_sent_pose.get(k, 0.0)) for k in pose.keys())
|
| 1021 |
+
if max_diff < 0.001: # Threshold: 0.001 rad or 0.001 m
|
| 1022 |
+
return # Skip sending
|
| 1023 |
+
```
|
| 1024 |
+
|
| 1025 |
+
#### 3. State query caching (reachy_controller.py)
|
| 1026 |
+
```python
|
| 1027 |
+
# Cache daemon status query results
|
| 1028 |
+
self._cache_ttl = 0.1 # 100ms TTL
|
| 1029 |
+
self._last_status_query = 0.0
|
| 1030 |
+
|
| 1031 |
+
def _get_cached_status(self):
|
| 1032 |
+
now = time.time()
|
| 1033 |
+
if now - self._last_status_query < self._cache_ttl:
|
| 1034 |
+
return self._state_cache.get('status') # Use cache
|
| 1035 |
+
# ... query and update cache
|
| 1036 |
+
```
|
| 1037 |
+
|
| 1038 |
+
#### 4. Head pose query caching (reachy_controller.py)
|
| 1039 |
+
```python
|
| 1040 |
+
# Cache get_current_head_pose() and get_current_joint_positions() results
|
| 1041 |
+
def _get_cached_head_pose(self):
|
| 1042 |
+
# Reuse cached results within 100ms
|
| 1043 |
+
```
|
| 1044 |
+
|
| 1045 |
+
### Fix Results
|
| 1046 |
+
|
| 1047 |
+
| Metric | Before Fix | After Fix | Improvement |
|
| 1048 |
+
|--------|------------|-----------|-------------|
|
| 1049 |
+
| Control message frequency | ~100 msg/s | ~20 msg/s | 閳?80% |
|
| 1050 |
+
| State query frequency | ~50 msg/s | ~5 msg/s | 閳?90% |
|
| 1051 |
+
| Total Zenoh messages | ~150 msg/s | ~25 msg/s | 閳?83% |
|
| 1052 |
+
| Daemon CPU load | Sustained high load | Normal load | Significantly reduced |
|
| 1053 |
+
| Expected stability | Crash within hours | Stable for days | Major improvement |
|
| 1054 |
+
|
| 1055 |
+
### Related Files
|
| 1056 |
+
- `DAEMON_CRASH_FIX_PLAN.md` - Detailed fix plan and test plan
|
| 1057 |
+
- `movement_manager.py` - Control loop optimization
|
| 1058 |
+
- `reachy_controller.py` - State query caching
|
| 1059 |
+
|
| 1060 |
+
### Future Optimization Suggestions
|
| 1061 |
+
1. 鈴?Dynamic frequency adjustment - 50Hz during motion, 5Hz when idle
|
| 1062 |
+
2. 鈴?Batch state queries - Get all states at once
|
| 1063 |
+
3. 鈴?Further runtime efficiency tuning after real usage profiling
|
| 1064 |
+
|
| 1065 |
+
---
|
| 1066 |
+
|
| 1067 |
+
## 棣冩暋 Daemon Crash Deep Fix (2026-01-07)
|
| 1068 |
+
|
| 1069 |
+
> **Update (2026-01-30)**: Current implementation uses 50Hz control loop for stability and performance. The control loop frequency aligns with daemon backend processing capacity. The pose change threshold (0.005) and state cache TTL (2s) optimizations remain in place to reduce unnecessary Zenoh messages.
|
| 1070 |
+
|
| 1071 |
+
### Problem Description
|
| 1072 |
+
During long-term operation, `reachy_mini daemon` still crashes, previous fix not thorough enough.
|
| 1073 |
+
|
| 1074 |
+
### Root Cause Analysis
|
| 1075 |
+
|
| 1076 |
+
Through deep analysis of SDK source code:
|
| 1077 |
+
|
| 1078 |
+
1. **Each `set_target()` sends 3 Zenoh messages**
|
| 1079 |
+
- `set_target_head_pose()` - 1 message
|
| 1080 |
+
- `set_target_antenna_joint_positions()` - 1 message
|
| 1081 |
+
- `set_target_body_yaw()` - 1 message
|
| 1082 |
+
|
| 1083 |
+
2. **Daemon control loop is 50Hz**
|
| 1084 |
+
- See `reachy_mini/daemon/backend/robot/backend.py`: `control_loop_frequency = 50.0`
|
| 1085 |
+
- If message send frequency exceeds 50Hz, daemon may not process in time
|
| 1086 |
+
|
| 1087 |
+
3. **Previous 20Hz control loop still too high**
|
| 1088 |
+
- 20Hz 鑴?3 messages = 60 messages/second
|
| 1089 |
+
- Already exceeds daemon's 50Hz processing capacity
|
| 1090 |
+
|
| 1091 |
+
4. **Pose change threshold too small (0.002)**
|
| 1092 |
+
- Breathing animation, speech sway, face tracking continuously produce tiny changes
|
| 1093 |
+
- Almost every loop triggers `set_target()`
|
| 1094 |
+
|
| 1095 |
+
### Fix Solution
|
| 1096 |
+
|
| 1097 |
+
#### 1. Control loop frequency history (motion/movement_manager.py)
|
| 1098 |
+
```python
|
| 1099 |
+
# Evolution: 100Hz -> 20Hz -> 10Hz -> 50Hz (current)
|
| 1100 |
+
# Current stable frequency for production use
|
| 1101 |
+
CONTROL_LOOP_FREQUENCY_HZ = 50 # Current (2026-01-30)
|
| 1102 |
+
```
|
| 1103 |
+
|
| 1104 |
+
#### 2. Increase pose change threshold (movement_manager.py)
|
| 1105 |
+
```python
|
| 1106 |
+
# Increased from 0.002 to 0.005
|
| 1107 |
+
# 0.005 rad 閳?0.29 degrees, still smooth enough
|
| 1108 |
+
self._pose_change_threshold = 0.005
|
| 1109 |
+
```
|
| 1110 |
+
|
| 1111 |
+
#### 3. Reduce camera/face tracking frequency (camera_server.py)
|
| 1112 |
+
```python
|
| 1113 |
+
# Reduced from 15fps to 10fps
|
| 1114 |
+
fps: int = 10
|
| 1115 |
+
```
|
| 1116 |
+
|
| 1117 |
+
#### 4. Increase state cache TTL (reachy_controller.py)
|
| 1118 |
+
```python
|
| 1119 |
+
# Increased from 1 second to 2 seconds
|
| 1120 |
+
self._cache_ttl = 2.0
|
| 1121 |
+
```
|
| 1122 |
+
|
| 1123 |
+
### Fix Results
|
| 1124 |
+
|
| 1125 |
+
> **Note**: Current implementation uses 50Hz control loop as of 2026-01-30. The table below shows historical evolution.
|
| 1126 |
+
|
| 1127 |
+
| Metric | Before (20Hz) | After (10Hz) | Current (50Hz) |
|
| 1128 |
+
|--------|---------------|--------------|-----------------|
|
| 1129 |
+
| Control loop frequency | 20 Hz | 10 Hz | 50 Hz (current) |
|
| 1130 |
+
| Max Zenoh messages | 60 msg/s | 30 msg/s | ~50 msg/s (optimized) |
|
| 1131 |
+
| Actual messages (with change detection) | ~40 msg/s | ~15 msg/s | ~30 msg/s |
|
| 1132 |
+
| Face tracking frequency | 15 Hz | 10 Hz | Adaptive (2-15 Hz) |
|
| 1133 |
+
| State cache TTL | 1 second | 2 seconds | 2 seconds |
|
| 1134 |
+
| Expected stability | Crash within hours | Stable operation | Stable (daemon updated) |
|
| 1135 |
+
|
| 1136 |
+
### Key Finding
|
| 1137 |
+
|
| 1138 |
+
Current implementation uses 50Hz control loop for stability and performance. The control loop frequency aligns with daemon backend processing capacity.
|
| 1139 |
+
|
| 1140 |
+
### Related Files
|
| 1141 |
+
- `motion/movement_manager.py` - Control loop frequency and pose threshold
|
| 1142 |
+
- `vision/camera_server.py` - Face tracking frequency
|
| 1143 |
+
- `reachy_controller.py` - State cache TTL
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
---
|
| 1147 |
+
|
| 1148 |
+
## 棣冩暋 Microphone Sensitivity Optimization (2026-01-07)
|
| 1149 |
+
|
| 1150 |
+
> Historical background only. These notes describe earlier low-level microphone tuning experiments and should not be read as current Home Assistant entity capabilities.
|
| 1151 |
+
|
| 1152 |
+
### Problem
|
| 1153 |
+
Low microphone sensitivity - Need to be very close for voice recognition.
|
| 1154 |
+
|
| 1155 |
+
### Solution
|
| 1156 |
+
Comprehensive ReSpeaker XVF3800 microphone optimization:
|
| 1157 |
+
|
| 1158 |
+
| Parameter | Default | Optimized | Notes |
|
| 1159 |
+
|-----------|---------|-----------|-------|
|
| 1160 |
+
| AGC | Off | On | Auto volume normalization |
|
| 1161 |
+
| AGC max gain | ~15dB | 30dB | Better distant speech pickup |
|
| 1162 |
+
| AGC target level | -25dB | -18dB | Stronger output signal |
|
| 1163 |
+
| Microphone gain | 1.0x | 2.0x | Base gain doubled |
|
| 1164 |
+
| Noise suppression | ~0.5 | 0.15 | Reduced speech mis-suppression |
|
| 1165 |
+
|
| 1166 |
+
### Result
|
| 1167 |
+
Microphone sensitivity improved from ~30cm to ~2-3m effective range.
|
| 1168 |
+
|
| 1169 |
+
---
|
| 1170 |
+
|
| 1171 |
+
## 棣冩暋 v0.5.1 Bug Fixes (2026-01-08)
|
| 1172 |
+
|
| 1173 |
+
### Issue 1: Music Not Resuming After Voice Conversation
|
| 1174 |
+
|
| 1175 |
+
**Fix**: Sendspin now connects to `music_player` instead of `tts_player`
|
| 1176 |
+
|
| 1177 |
+
### Issue 2: Audio Conflict During Voice Assistant Wakeup
|
| 1178 |
+
|
| 1179 |
+
**Fix**: Added `pause_sendspin()` and `resume_sendspin()` methods to `audio/audio_player.py`
|
| 1180 |
+
|
| 1181 |
+
### Issue 3: Sendspin Sample Rate Optimization
|
| 1182 |
+
|
| 1183 |
+
**Fix**: Prioritize 16kHz in Sendspin supported formats (hardware limitation)
|
| 1184 |
+
|
| 1185 |
+
---
|
| 1186 |
+
|
| 1187 |
+
## 棣冩暋 v0.5.15 Updates (2026-01-11)
|
| 1188 |
+
|
| 1189 |
+
### Feature 1: Audio Settings Persistence
|
| 1190 |
+
|
| 1191 |
+
Historical note: older audio processing preferences were once persisted here. The current app no longer exposes AGC or noise suppression entities.
|
| 1192 |
+
|
| 1193 |
+
### Feature 2: Sendspin Discovery Refactoring
|
| 1194 |
+
|
| 1195 |
+
Moved mDNS discovery to `zeroconf.py` for better separation of concerns.
|
| 1196 |
+
|
| 1197 |
+
|
| 1198 |
+
---
|
| 1199 |
+
|
| 1200 |
+
### SDK Data Structure Reference
|
| 1201 |
+
|
| 1202 |
+
```python
|
| 1203 |
+
# Motor control mode
|
| 1204 |
+
class MotorControlMode(str, Enum):
|
| 1205 |
+
Enabled = "enabled" # Torque on, position control
|
| 1206 |
+
Disabled = "disabled" # Torque off
|
| 1207 |
+
GravityCompensation = "gravity_compensation" # Gravity compensation mode
|
| 1208 |
+
|
| 1209 |
+
# Daemon state
|
| 1210 |
+
class DaemonState(Enum):
|
| 1211 |
+
NOT_INITIALIZED = "not_initialized"
|
| 1212 |
+
STARTING = "starting"
|
| 1213 |
+
RUNNING = "running"
|
| 1214 |
+
STOPPING = "stopping"
|
| 1215 |
+
STOPPED = "stopped"
|
| 1216 |
+
ERROR = "error"
|
| 1217 |
+
|
| 1218 |
+
# Full state
|
| 1219 |
+
class FullState:
|
| 1220 |
+
control_mode: MotorControlMode
|
| 1221 |
+
head_pose: XYZRPYPose # x, y, z (m), roll, pitch, yaw (rad)
|
| 1222 |
+
head_joints: list[float] # 7 joint angles
|
| 1223 |
+
body_yaw: float
|
| 1224 |
+
antennas_position: list[float] # [right, left]
|
| 1225 |
+
doa: DoAInfo # angle (rad), speech_detected (bool)
|
| 1226 |
+
|
| 1227 |
+
# IMU data (wireless version only)
|
| 1228 |
+
imu_data = {
|
| 1229 |
+
"accelerometer": [x, y, z], # m/s铏?
|
| 1230 |
+
"gyroscope": [x, y, z], # rad/s
|
| 1231 |
+
"quaternion": [w, x, y, z], # Attitude quaternion
|
| 1232 |
+
"temperature": float # 鎺矯
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
# Safety limits
|
| 1236 |
+
HEAD_PITCH_ROLL_LIMIT = [-40鎺? +40鎺砞
|
| 1237 |
+
HEAD_YAW_LIMIT = [-180鎺? +180鎺砞
|
| 1238 |
+
BODY_YAW_LIMIT = [-160鎺? +160鎺砞
|
| 1239 |
+
YAW_DELTA_MAX = 65鎺? # Max difference between head and body yaw
|
| 1240 |
+
```
|
| 1241 |
+
|
| 1242 |
+
### ESPHome Protocol Implementation Notes
|
| 1243 |
+
|
| 1244 |
+
ESPHome protocol communicates with Home Assistant via protobuf messages. The runtime primarily uses switch/number/select/sensor/binary_sensor/text_sensor/camera entities; button-only wake/sleep flows are historical and no longer the main control model.
|
| 1245 |
+
|
| 1246 |
+
```python
|
| 1247 |
+
from aioesphomeapi.api_pb2 import (
|
| 1248 |
+
# Number entity (volume/angle/confidence control)
|
| 1249 |
+
ListEntitiesNumberResponse,
|
| 1250 |
+
NumberStateResponse,
|
| 1251 |
+
NumberCommandRequest,
|
| 1252 |
+
|
| 1253 |
+
# Select entity (emotion)
|
| 1254 |
+
ListEntitiesSelectResponse,
|
| 1255 |
+
SelectStateResponse,
|
| 1256 |
+
SelectCommandRequest,
|
| 1257 |
+
|
| 1258 |
+
# Switch entity (sleep/runtime toggles)
|
| 1259 |
+
ListEntitiesSwitchResponse,
|
| 1260 |
+
SwitchStateResponse,
|
| 1261 |
+
SwitchCommandRequest,
|
| 1262 |
+
|
| 1263 |
+
# Sensor entity (numeric sensors)
|
| 1264 |
+
ListEntitiesSensorResponse,
|
| 1265 |
+
SensorStateResponse,
|
| 1266 |
+
|
| 1267 |
+
# Binary Sensor entity (boolean sensors)
|
| 1268 |
+
ListEntitiesBinarySensorResponse,
|
| 1269 |
+
BinarySensorStateResponse,
|
| 1270 |
+
|
| 1271 |
+
# Text Sensor entity (text sensors)
|
| 1272 |
+
ListEntitiesTextSensorResponse,
|
| 1273 |
+
TextSensorStateResponse,
|
| 1274 |
+
)
|
| 1275 |
+
```
|
| 1276 |
+
|
| 1277 |
+
## Reference Projects
|
| 1278 |
+
|
| 1279 |
+
- [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant)
|
| 1280 |
+
- [pollen-robotics/reachy_mini](https://github.com/pollen-robotics/reachy_mini)
|
| 1281 |
+
- [reachy_mini_conversation_app](https://github.com/pollen-robotics/reachy_mini_conversation_app)
|
| 1282 |
+
- [sendspin-cli](https://github.com/Sendspin/sendspin-cli)
|
| 1283 |
+
- [home-assistant-voice](https://github.com/esphome/home-assistant-voice-pe/blob/dev/home-assistant-voice.yaml)
|
| 1284 |
+
|
| 1285 |
+
---
|
| 1286 |
+
|
| 1287 |
+
## 棣冩暋 Code Refactoring & Improvement Plan (v0.9.5)
|
| 1288 |
+
|
| 1289 |
+
> Comprehensive improvement plan based on code analysis
|
| 1290 |
+
> Target Platform: Raspberry Pi CM4 (4GB RAM, 4-core CPU)
|
| 1291 |
+
|
| 1292 |
+
### Code Size Statistics (Updated 2026-01-19)
|
| 1293 |
+
|
| 1294 |
+
| File | Original | Current | Status |
|
| 1295 |
+
|------|----------|---------|--------|
|
| 1296 |
+
| `movement_manager.py` | 1205 | 1260 | 閳跨媴绗?Modularized but still large |
|
| 1297 |
+
| `voice_assistant.py` | 1097 | 1270 | 閴?Enhanced with new features |
|
| 1298 |
+
| `satellite.py` | 1003 | 1022 | 閴?Optimized (-2%) |
|
| 1299 |
+
| `camera_server.py` | 1070 | 1009 | 閴?Optimized (-6%) |
|
| 1300 |
+
| `reachy_controller.py` | 878 | 961 | 閴?Enhanced |
|
| 1301 |
+
| `entity_registry.py` | 1129 | 844 | 閴?Optimized (-25%) |
|
| 1302 |
+
| `audio_player.py` | 599 | 679 | 閴?Acceptable |
|
| 1303 |
+
| `core/service_base.py` | - | 552 | 棣冨晭 New module |
|
| 1304 |
+
| `entities/entity_factory.py` | - | 440 | 棣冨晭 New module |
|
| 1305 |
+
|
| 1306 |
+
> **Optimization Notes**:
|
| 1307 |
+
> - `entity_registry.py`: Factory pattern refactoring reduced 285 lines
|
| 1308 |
+
> - `camera_server.py`: Using `FaceTrackingInterpolator` module reduced 61 lines
|
| 1309 |
+
> - `protocol/satellite.py`: Runtime paths are now centered on voice state handling and HA event reactions
|
| 1310 |
+
> - New modular architecture with 6 sub-packages: `core/`, `motion/`, `vision/`, `audio/`, `entities/`, `protocol/`
|
| 1311 |
+
|
| 1312 |
+
### New Module List (Updated 2026-01-19)
|
| 1313 |
+
|
| 1314 |
+
| Directory | Module | Lines | Description |
|
| 1315 |
+
|-----------|--------|-------|-------------|
|
| 1316 |
+
| `core/` | `config.py` | 454 | Centralized nested configuration |
|
| 1317 |
+
| `core/` | `service_base.py` | 552 | Suspend/resume service helpers + RobustOperationMixin |
|
| 1318 |
+
| `core/` | `system_diagnostics.py` | 250 | System diagnostics |
|
| 1319 |
+
| `core/` | `exceptions.py` | 68 | Custom exception classes |
|
| 1320 |
+
| `core/` | `util.py` | 28 | Utility functions |
|
| 1321 |
+
| `motion/` | `antenna.py` | - | Antenna freeze/unfreeze control |
|
| 1322 |
+
| `motion/` | `pose_composer.py` | - | Pose composition utilities |
|
| 1323 |
+
| `motion/` | `command_runtime.py` | - | Command queue handling / state transitions |
|
| 1324 |
+
| `motion/` | `control_runtime.py` | - | Control-loop runtime helpers |
|
| 1325 |
+
| `motion/` | `idle_runtime.py` | - | Idle behavior / idle rest handling |
|
| 1326 |
+
| `motion/` | `state_machine.py` | - | State machine definitions |
|
| 1327 |
+
| `motion/` | `smoothing.py` | - | Smoothing/transition algorithms |
|
| 1328 |
+
| `motion/` | `animation_player.py` | - | Animation player |
|
| 1329 |
+
| `motion/` | `emotion_moves.py` | - | Emotion moves |
|
| 1330 |
+
| `motion/` | `speech_sway.py` | 338 | Speech-driven head micro-movements |
|
| 1331 |
+
| `motion/` | `reachy_motion.py` | - | Reachy motion API |
|
| 1332 |
+
| `vision/` | `frame_processor.py` | 227 | Adaptive frame rate management |
|
| 1333 |
+
| `vision/` | `face_tracking_interpolator.py` | 253 | Face lost interpolation |
|
| 1334 |
+
| `vision/` | `gesture_smoother.py` | 80 | Historical gesture smoothing module; current runtime no longer depends on it |
|
| 1335 |
+
| `vision/` | `gesture_detector.py` | 285 | HaGRID gesture detection |
|
| 1336 |
+
| `vision/` | `head_tracker.py` | 367 | YOLO face detector |
|
| 1337 |
+
| `vision/` | `camera_server.py` | 1009 | MJPEG camera stream server facade |
|
| 1338 |
+
| `audio/` | `doa_tracker.py` | 206 | Direction of Arrival tracking |
|
| 1339 |
+
| `audio/` | `microphone.py` | 219 | Hardware audio helper / legacy tuning code |
|
| 1340 |
+
| `audio/` | `audio_player.py` | facade | AudioPlayer facade (split into playback/sendspin/local streaming modules) |
|
| 1341 |
+
| `entities/` | `entity.py` | 402 | ESPHome base entity |
|
| 1342 |
+
| `entities/` | `entity_factory.py` | 440 | Entity factory pattern |
|
| 1343 |
+
| `entities/` | `entity_keys.py` | 155 | Entity key constants |
|
| 1344 |
+
| `entities/` | `entity_extensions.py` | 258 | Extended entity types |
|
| 1345 |
+
| `entities/` | `event_emotion_mapper.py` | 351 | HA event to emotion mapping |
|
| 1346 |
+
| `protocol/` | `satellite.py` | 1022 | ESPHome protocol handler |
|
| 1347 |
+
| `protocol/` | `api_server.py` | 172 | HTTP API server |
|
| 1348 |
+
| `protocol/` | `zeroconf.py` | - | mDNS discovery |
|
| 1349 |
+
|
| 1350 |
+
### Improvement Plan Status
|
| 1351 |
+
|
| 1352 |
+
#### Phase 1: Runtime Suspend/Resume Foundation 鉁?Complete
|
| 1353 |
+
|
| 1354 |
+
- [x] Create `core/service_base.py` - runtime suspend/resume service helpers
|
| 1355 |
+
- [x] All required services implement `suspend()` / `resume()` methods where needed
|
| 1356 |
+
- [x] Historical app-managed sleep/wake flow was later removed to align with the current SDK
|
| 1357 |
+
|
| 1358 |
+
#### Phase 2: Code Modularization 閴?Complete
|
| 1359 |
+
|
| 1360 |
+
- [x] Create new directory structure (`core/`, `motion/`, `audio/`, `vision/`, `entities/`)
|
| 1361 |
+
- [x] Extract from `movement_manager.py` 閳?`motion/antenna.py`, `motion/pose_composer.py`
|
| 1362 |
+
- [x] Extract from `camera_server.py` 閳?`vision/frame_processor.py`, `vision/face_tracking_interpolator.py`
|
| 1363 |
+
- [x] Extract from `entity_registry.py` 閳?`entities/entity_factory.py`, `entities/entity_keys.py`
|
| 1364 |
+
- [x] Create `core/config.py` for centralized configuration
|
| 1365 |
+
- [x] Ensure no circular dependencies
|
| 1366 |
+
|
| 1367 |
+
#### Phase 3: Stability & Performance 閴?Complete
|
| 1368 |
+
|
| 1369 |
+
- [x] Create `core/exceptions.py` - Custom exception classes
|
| 1370 |
+
- [x] Implement `RobustOperationMixin` - Unified error handling
|
| 1371 |
+
- [x] `CameraServer` implements Context Manager pattern
|
| 1372 |
+
- [x] Improve `CameraServer` resource cleanup
|
| 1373 |
+
- [x] Fix MJPEG client tracking (proper register/unregister)
|
| 1374 |
+
- [x] Historical health/memory monitor modules were added during earlier SDK instability periods
|
| 1375 |
+
- [x] Health/memory monitor modules were later removed after runtime simplification
|
| 1376 |
+
- [ ] Long-running stability test (24h+)
|
| 1377 |
+
|
| 1378 |
+
#### Phase 4: Feature Enhancements 閴?Complete
|
| 1379 |
+
|
| 1380 |
+
- [x] Historical gesture-action runtime path explored
|
| 1381 |
+
- [x] Gesture runtime later simplified to publish recognition results only
|
| 1382 |
+
- [x] Create `audio/doa_tracker.py` - DOATracker
|
| 1383 |
+
- [x] Implement sound source tracking with motion control integration
|
| 1384 |
+
- [x] Create `entities/event_emotion_mapper.py` - EventEmotionMapper
|
| 1385 |
+
- [x] Fold HA event behavior config into `animations/conversation_animations.json`
|
| 1386 |
+
- [x] Add DOA tracking toggle HA entity
|
| 1387 |
+
|
| 1388 |
+
### SDK Compatibility Verification 閴?Passed
|
| 1389 |
+
|
| 1390 |
+
| API Call | Status | Notes |
|
| 1391 |
+
|----------|--------|-------|
|
| 1392 |
+
| `set_target(head, antennas, body_yaw)` | 閴?| Correct usage |
|
| 1393 |
+
| `goto_target()` | 閴?| Correct usage |
|
| 1394 |
+
| `look_at_image(u: int, v: int)` | 閴?| Fixed float閳姕nt |
|
| 1395 |
+
| `create_head_pose(degrees=False)` | 閴?| Using radians |
|
| 1396 |
+
| `compose_world_offset()` | 閴?| SDK function correctly called |
|
| 1397 |
+
| `linear_pose_interpolation()` | 閴?| Has fallback implementation |
|
| 1398 |
+
| Body yaw range | 閴?| Clamped to 鍗?60鎺?|
|
| 1399 |
+
|
| 1400 |
+
---
|
| 1401 |
+
|
| 1402 |
+
## 棣冩暋 v0.9.5 Updates (2026-01-19)
|
| 1403 |
+
|
| 1404 |
+
### Major Changes: Modular Architecture Refactoring
|
| 1405 |
+
|
| 1406 |
+
The codebase has been restructured into a modular architecture with 5 sub-packages:
|
| 1407 |
+
|
| 1408 |
+
| Package | Purpose | Key Modules |
|
| 1409 |
+
|---------|---------|-------------|
|
| 1410 |
+
| `core/` | Core infrastructure | `config.py`, `service_base.py`, `system_diagnostics.py` |
|
| 1411 |
+
| `motion/` | Motion control | `antenna.py`, `pose_composer.py`, `command_runtime.py`, `control_runtime.py`, `idle_runtime.py`, `smoothing.py` |
|
| 1412 |
+
| `vision/` | Vision processing | `frame_processor.py`, `face_tracking_interpolator.py` |
|
| 1413 |
+
| `audio/` | Audio processing | `microphone.py`, `doa_tracker.py` |
|
| 1414 |
+
| `entities/` | HA entity management | `entity_factory.py`, `entity_keys.py`, `event_emotion_mapper.py` |
|
| 1415 |
+
|
| 1416 |
+
### New Features
|
| 1417 |
+
|
| 1418 |
+
1. **Historical note**
|
| 1419 |
+
- Earlier versions explored direct sleep/wake callbacks and polling-based state handling
|
| 1420 |
+
- Current runtime no longer uses app-managed sleep/wake callbacks
|
| 1421 |
+
|
| 1422 |
+
2. **Camera runtime evolution**
|
| 1423 |
+
- Camera lifecycle was later split into dedicated runtime/processing/http helpers
|
| 1424 |
+
- Current runtime can fully stop camera service when `Idle Behavior` is disabled
|
| 1425 |
+
|
| 1426 |
+
### Audio Optimizations
|
| 1427 |
+
|
| 1428 |
+
| Parameter | Before | After | Improvement |
|
| 1429 |
+
|-----------|--------|-------|-------------|
|
| 1430 |
+
| Audio chunk size | 1024 samples | 512 samples | 64ms 鈫?32ms latency with lower CPU load |
|
| 1431 |
+
| Audio loop delay | 10ms | 1ms | Faster VAD response |
|
| 1432 |
+
| Stereo閳墷ono | Mean of channels | First channel | Cleaner signal |
|
| 1433 |
+
|
| 1434 |
+
### Code Quality Improvements
|
| 1435 |
+
|
| 1436 |
+
- Removed all legacy/compatibility code
|
| 1437 |
+
- Centralized configuration in nested dataclasses
|
| 1438 |
+
- NaN/Inf cleaning in audio pipeline
|
| 1439 |
+
- Rotation clamping in face tracking to prevent IK collisions
|
README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Reachy Mini for Home Assistant
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Deep integration of Reachy Mini robot with Home Assistant
|
| 9 |
+
tags:
|
| 10 |
+
- reachy_mini
|
| 11 |
+
- reachy_mini_python_app
|
| 12 |
+
- reachy_mini_home_assistant
|
| 13 |
+
- home_assistant
|
| 14 |
+
- homeassistant
|
| 15 |
+
---
|
changelog.json
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ {
|
| 2 |
+
"version": "1.0.7",
|
| 3 |
+
"date": "2026-05-05",
|
| 4 |
+
"changes": [
|
| 5 |
+
"Build: Bump package version to 1.0.7",
|
| 6 |
+
"Change: Align audio runtime with current SDK patterns by splitting local TTS playback from Sendspin-capable music playback and moving wakeword/stopword loading into shared helpers",
|
| 7 |
+
"Change: Raise the Reachy Mini SDK baseline to reachy-mini>=1.7.1",
|
| 8 |
+
"Fix: Keep wakeup and TTS playback on the local player path while binding both local and Sendspin players to shared speech sway helpers",
|
| 9 |
+
"Fix: Synchronize Idle Behavior shutdown with ESPHome face and gesture switches plus runtime state updates",
|
| 10 |
+
"Fix: Remove obsolete runtime monitor modules that are no longer needed with the current SDK behavior",
|
| 11 |
+
"Optimize: Tighten Sendspin buffering with proactive backpressure and cleaner local queue handling"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"version": "1.0.6",
|
| 16 |
+
"date": "2026-05-01",
|
| 17 |
+
"changes": [
|
| 18 |
+
"Build: Bump package version to 1.0.6",
|
| 19 |
+
"Change: Align pyproject.toml with the current Reachy Mini SDK baseline (reachy-mini>=1.7.0, Python>=3.12, zeroconf>=0.131,<1, aiohttp, websockets>=12,<16, and gstreamer-bundle==1.28.1 on non-Linux)",
|
| 20 |
+
"Change: Align Sendspin dependency with the current upstream client line via aiosendspin>=5.1,<6.0",
|
| 21 |
+
"Fix: Fetch camera snapshot frames on demand when the MJPEG cache is empty so Home Assistant camera proxy requests keep working with the Reachy Mini SDK 1.7.0 media pull model",
|
| 22 |
+
"Optimize: Stop the camera server entirely when Idle Behavior is disabled instead of only unloading vision models"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"version": "1.0.5",
|
| 27 |
+
"date": "2026-04-12",
|
| 28 |
+
"changes": [
|
| 29 |
+
"Build: Bump package version to 1.0.5",
|
| 30 |
+
"Change: Remove app-managed robot sleep/wake handling because the current Reachy Mini SDK no longer allows mini apps to stay active while the robot enters sleep",
|
| 31 |
+
"Change: Limit resource suspend/resume to ESPHome-driven runtime toggles such as Home Assistant disconnect, mute, camera disable, and service recovery",
|
| 32 |
+
"Change: Align pyproject.toml runtime constraints with the current Reachy Mini reference SDK package (reachy-mini>=1.6.3, websockets>=12,<16, Python baseline >=3.10, and uv gstreamer metadata)",
|
| 33 |
+
"Remove: Delete SleepManager integration and app-side sleep/wake callback flow from the voice assistant runtime",
|
| 34 |
+
"Remove: Delete Home Assistant sleep control entities and internal robot sleep state tracking from the mini app"
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"version": "1.0.4",
|
| 39 |
+
"date": "2026-03-19",
|
| 40 |
+
"changes": [
|
| 41 |
+
"Build: Bump package version to 1.0.4",
|
| 42 |
+
"Fix: Align Reachy Mini integration with current SDK assumptions by removing legacy compatibility paths and private client health checks",
|
| 43 |
+
"Fix: Replace direct SDK private _respeaker access with audio_control_utils-based ReSpeaker initialization",
|
| 44 |
+
"Fix: Tighten camera and pose composition to require current SDK media/utils APIs and valid look_at_image inputs",
|
| 45 |
+
"Improve: Unify idle behavior into a single persisted Home Assistant entity and remove old idle compatibility aliases",
|
| 46 |
+
"Improve: Replace separate wake/sleep buttons with a single sleep control entity",
|
| 47 |
+
"Improve: Update Sendspin integration for current aiosendspin lifecycle, stream handling, listener cleanup, and synchronized buffering",
|
| 48 |
+
"Improve: Standardize daemon URL usage on shared config across controller, sleep manager, and daemon monitor"
|
| 49 |
+
]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"version": "1.0.3",
|
| 53 |
+
"date": "2026-03-07",
|
| 54 |
+
"changes": [
|
| 55 |
+
"Build: Bump package version to 1.0.3",
|
| 56 |
+
"New: Add Idle Random Actions switch in Home Assistant with preferences persistence and startup restore",
|
| 57 |
+
"New: Add configurable idle_random_actions action presets in conversation_animations.json for centralized idle motion tuning",
|
| 58 |
+
"Fix: Remove duplicate idle_random_actions fields/methods and complete runtime control wiring in controller/entity registry/movement manager",
|
| 59 |
+
"Improve: Increase idle breathing and antenna sway cadence to 0.24Hz with wiggle antenna profile for more natural standby motion",
|
| 60 |
+
"Optimize: Remove set_target global rate limiting and unchanged-pose skip gating to continuously stream motion commands each control tick",
|
| 61 |
+
"Optimize: Remove idle antenna slew-rate limiter so antenna motion follows animation waveforms directly for reference-like smoothness"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"version": "1.0.2",
|
| 66 |
+
"date": "2026-03-06",
|
| 67 |
+
"changes": [
|
| 68 |
+
"Build: Bump package version to 1.0.2",
|
| 69 |
+
"Fix: Restore idle antenna sway animation and tune idle breathing parameters to reduce perceived stiffness",
|
| 70 |
+
"Fix: Reintroduce idle anti-chatter smoothing/deadband for antenna and body updates to reduce mechanical jitter/noise",
|
| 71 |
+
"Fix: Switch sleep/wake control to daemon API (start/stop with wake_up/goto_sleep) so /api/daemon/status reflects real sleep state on SDK 1.5",
|
| 72 |
+
"Fix: Normalize daemon status parsing for SDK 1.5 object-based status responses",
|
| 73 |
+
"Fix: Remove all app-side antenna power on/off operations to avoid SDK instability and external-control conflicts",
|
| 74 |
+
"Change: Keep idle antenna behavior as animation-only control (no torque coupling)",
|
| 75 |
+
"Change: Tighten preference loading to current schema (no legacy config fallback filtering)",
|
| 76 |
+
"Fix: Sync Idle Motion toggle with Idle Antenna Motion toggle for expected behavior in ESPHome",
|
| 77 |
+
"Fix: Remove legacy app-managed audio routing hooks and rely on native SDK/system audio selection",
|
| 78 |
+
"New: Add Home Assistant blueprint for Reachy presence companion automation",
|
| 79 |
+
"Improve: Blueprint supports device-first auto-binding and richer usage instructions",
|
| 80 |
+
"Docs: Refresh landing page (index.html) with current version, GitHub source link, and new Blueprint/Auto Release capability cards",
|
| 81 |
+
"New: Add GitHub workflow to auto-create releases when pyproject/changelog version updates produce a new tag",
|
| 82 |
+
"Chore: Ignore local wiki workspace artifacts (local/) from repository tracking"
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"version": "1.0.1",
|
| 87 |
+
"date": "2026-03-05",
|
| 88 |
+
"changes": [
|
| 89 |
+
"Build: Bump package version to 1.0.1",
|
| 90 |
+
"Deps: Update runtime dependency baseline to reachy-mini>=1.5.0",
|
| 91 |
+
"Fix: Remove legacy Zenoh 7447 startup precheck for SDK v1.5 compatibility",
|
| 92 |
+
"Fix: Remove legacy ZError string matching from connection error handling",
|
| 93 |
+
"Fix: Adapt daemon status handling to SDK v1.5 DaemonStatus object (prevents AttributeError on status.get)",
|
| 94 |
+
"Fix: Harden stop-word handling with runtime activation/deactivation and mute-aware trigger gating",
|
| 95 |
+
"Fix: Align wakeup stream start timing with reference behavior (start microphone stream after wakeup sound)",
|
| 96 |
+
"Fix: Improve TTS streaming robustness and reduce cutoffs with retry-based audio push",
|
| 97 |
+
"Optimize: Support single-request streaming with in-memory fallback cache for one-time TTS URLs (no temp file dependency)",
|
| 98 |
+
"Optimize: Lower streaming fetch chunk size and apply unthrottled preroll for faster first audio"
|
| 99 |
+
]
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"version": "1.0.0",
|
| 103 |
+
"date": "2026-03-04",
|
| 104 |
+
"changes": [
|
| 105 |
+
"Build: Bump package version to 1.0.0",
|
| 106 |
+
"Deps: Require reachy-mini[gstreamer]>=1.4.1",
|
| 107 |
+
"Fix: Improve gesture responsiveness and stability (faster smoothing, min processing cadence, no-gesture alignment)",
|
| 108 |
+
"Fix: Auto-match ONNX gesture input size from model shape to prevent INVALID_ARGUMENT dimension errors",
|
| 109 |
+
"New: Add Sendspin switch in ESPHome (default OFF, persistent, runtime enable/disable)",
|
| 110 |
+
"New: Add Face Tracking and Gesture Detection switches in ESPHome (both default OFF, persistent)",
|
| 111 |
+
"New: Add Face Confidence number entity (0.0-1.0, persistent)",
|
| 112 |
+
"Optimize: Unload/reload face and gesture models when toggled off/on to save resources",
|
| 113 |
+
"Optimize: Idle behavior updated to breathing + look-around alternation, idle antenna sway disabled",
|
| 114 |
+
"Optimize: Adjust idle breathing to human-like cadence",
|
| 115 |
+
"Fix: Disable antenna torque in idle mode and re-enable outside idle to reduce chatter/noise",
|
| 116 |
+
"Fix: Harden startup against import-time failures (lazy emotion library loading and graceful Sendspin disable)",
|
| 117 |
+
"Fix: Enforce deterministic audio startup path and fail fast when microphone capture is not ready",
|
| 118 |
+
"Optimize: Make MJPEG streaming viewer-aware (skip continuous JPEG encode/push when no stream clients)",
|
| 119 |
+
"Optimize: Keep face/gesture AI processing active even when stream viewers are absent",
|
| 120 |
+
"Fix: Add on-demand /snapshot JPEG generation when no cached stream frame is available",
|
| 121 |
+
"Change: Use camera backend default FPS/resolution for stream path instead of forcing fixed 1080p/25fps"
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"version": "0.9.9",
|
| 126 |
+
"date": "2026-01-28",
|
| 127 |
+
"changes": [
|
| 128 |
+
"Fix: Audio buffer overflow - require Reachy Mini hardware, use only Reachy microphone with 50ms sleep",
|
| 129 |
+
"Optimize: Gesture detection sensitivity - remove all confidence filtering, return all detections to Home Assistant",
|
| 130 |
+
"Optimize: Gesture detection now runs at 1 frame interval for maximum responsiveness",
|
| 131 |
+
"Refactor: Simplify GestureSmoother to frequency-based confirmation (1 frame)",
|
| 132 |
+
"Refactor: Remove unused parameters (confidence_threshold, detection_threshold, GestureConfig)",
|
| 133 |
+
"Fix: Remove duplicate empty check in gesture detection",
|
| 134 |
+
"Optimize: SDK integration - add MediaBackend detection and proper resource cleanup",
|
| 135 |
+
"Document: ReSpeaker private attribute access risk with TODO comments"
|
| 136 |
+
]
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"version": "0.9.8",
|
| 140 |
+
"date": "2026-01-27",
|
| 141 |
+
"changes": [
|
| 142 |
+
"New: Mute switch and Disable Camera entities for granular control",
|
| 143 |
+
"Fix: Camera disable logic and daemon crash prevention",
|
| 144 |
+
"New: Home Assistant connection-driven feature loading with auto suspend/resume",
|
| 145 |
+
"Optimize: Reduce log output by 30-40%",
|
| 146 |
+
"Fix: Code quality improvements",
|
| 147 |
+
"Fix: SDK crash during idle - optimize audio processing and add GStreamer threading lock",
|
| 148 |
+
"Optimize: Bundle face tracking model, use SDK Zenoh for daemon monitoring",
|
| 149 |
+
"Simplify: Device ID reads /etc/machine-id directly",
|
| 150 |
+
"Clean up: Remove unused config items"
|
| 151 |
+
]
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"version": "0.9.7",
|
| 155 |
+
"date": "2026-01-20",
|
| 156 |
+
"changes": [
|
| 157 |
+
"Fix: Device ID file path corrected after util.py moved to core/ subdirectory (prevents HA seeing device as new)",
|
| 158 |
+
"Fix: Animation file path corrected (was looking in wrong directory)",
|
| 159 |
+
"Fix: Remove hey_jarvis from required wake words (it's optional in openWakeWord/)"
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"version": "0.9.6",
|
| 164 |
+
"date": "2026-01-20",
|
| 165 |
+
"changes": [
|
| 166 |
+
"New: Add ruff linter/formatter and mypy type checker configuration",
|
| 167 |
+
"New: Add pre-commit hooks for automated code quality checks",
|
| 168 |
+
"Fix: Remove duplicate resume() method in audio_player.py",
|
| 169 |
+
"Fix: Remove duplicate connection_lost() method in satellite.py",
|
| 170 |
+
"Fix: Store asyncio task reference in sleep_manager.py to prevent garbage collection",
|
| 171 |
+
"Optimize: Use dict.items() for efficient iteration in smoothing.py"
|
| 172 |
+
]
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"version": "0.9.5",
|
| 176 |
+
"date": "2026-01-19",
|
| 177 |
+
"changes": [
|
| 178 |
+
"Refactor: Modularize codebase - new core/motion/vision/audio/entities module structure",
|
| 179 |
+
"New: Direct callbacks for HA sleep/wake buttons to suspend/resume services",
|
| 180 |
+
"Optimize: Audio processing latency - reduced chunk size from 1024 to 256 samples (64ms -> 16ms)",
|
| 181 |
+
"Optimize: Audio loop delay reduced from 10ms to 1ms for faster VAD response",
|
| 182 |
+
"Optimize: Stereo to mono conversion uses first channel instead of mean for cleaner signal",
|
| 183 |
+
"Improve: Camera resume_from_suspend now synchronous for reliable wake from sleep",
|
| 184 |
+
"Improve: Rotation clamping in face tracking to prevent IK collisions"
|
| 185 |
+
]
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"version": "0.9.0",
|
| 189 |
+
"date": "2026-01-18",
|
| 190 |
+
"changes": [
|
| 191 |
+
"New: Robot state monitor for proper sleep mode handling - services pause when robot disconnects and resume on reconnect",
|
| 192 |
+
"New: System diagnostics entities (CPU, memory, disk, uptime) exposed as Home Assistant diagnostic sensors",
|
| 193 |
+
"New: Phase 24 with 9 diagnostic sensors (cpu_percent, cpu_temperature, memory_percent, memory_used_gb, disk_percent, disk_free_gb, uptime_hours, process_cpu_percent, process_memory_mb)",
|
| 194 |
+
"Fix: Voice assistant and movement manager now properly pause during robot sleep mode instead of generating error spam",
|
| 195 |
+
"Improve: Graceful service lifecycle management with RobotStateMonitor callbacks"
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"version": "0.8.7",
|
| 200 |
+
"date": "2026-01-18",
|
| 201 |
+
"changes": [
|
| 202 |
+
"Fix: Clamp body_yaw to safe range to prevent IK collision warnings during emotion playback",
|
| 203 |
+
"Fix: Emotion moves and face tracking now respect SDK safety limits",
|
| 204 |
+
"Improve: Face tracking smoothness - removed EMA smoothing (matches reference project)",
|
| 205 |
+
"Improve: Face tracking timing updated to match reference (2s delay, 1s interpolation)"
|
| 206 |
+
]
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"version": "0.8.6",
|
| 210 |
+
"date": "2026-01-18",
|
| 211 |
+
"changes": [
|
| 212 |
+
"Fix: Audio buffer memory leak - added size limit to prevent unbounded growth",
|
| 213 |
+
"Fix: Temp file leak - downloaded audio files now cleaned up after playback",
|
| 214 |
+
"Fix: Camera thread termination timeout increased for clean shutdown",
|
| 215 |
+
"Fix: Thread-safe draining flag using threading.Event",
|
| 216 |
+
"Fix: Silent failures now logged for debugging"
|
| 217 |
+
]
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"version": "0.8.5",
|
| 221 |
+
"date": "2026-01-18",
|
| 222 |
+
"changes": [
|
| 223 |
+
"Fix: DOA turn-to-sound direction inverted - now turns correctly toward sound source",
|
| 224 |
+
"Fix: Graceful shutdown prevents daemon crash on app stop"
|
| 225 |
+
]
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"version": "0.8.4",
|
| 229 |
+
"date": "2026-01-18",
|
| 230 |
+
"changes": [
|
| 231 |
+
"Improve: Smooth idle animation with interpolation phase (matches reference BreathingMove)",
|
| 232 |
+
"Improve: Two-phase animation - interpolates to neutral before oscillation",
|
| 233 |
+
"Fix: Antenna frequency updated to 0.5Hz (was 0.15Hz) for more natural sway"
|
| 234 |
+
]
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"version": "0.8.3",
|
| 238 |
+
"date": "2026-01-18",
|
| 239 |
+
"changes": [
|
| 240 |
+
"Fix: Body now properly follows head rotation during face tracking",
|
| 241 |
+
"Fix: body_yaw extracted from final head pose matrix and synced with head_yaw",
|
| 242 |
+
"Fix: Matches reference project sweep_look behavior for natural body movement"
|
| 243 |
+
]
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"version": "0.8.2",
|
| 247 |
+
"date": "2026-01-18",
|
| 248 |
+
"changes": [
|
| 249 |
+
"Fix: Body now follows head rotation during face tracking - body_yaw syncs with head_yaw",
|
| 250 |
+
"Fix: Matches reference project sweep_look behavior for natural body movement"
|
| 251 |
+
]
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"version": "0.8.1",
|
| 255 |
+
"date": "2026-01-18",
|
| 256 |
+
"changes": [
|
| 257 |
+
"Fix: face_detected entity now pushes state updates to Home Assistant in real-time",
|
| 258 |
+
"Fix: Body yaw simplified to match reference project - SDK automatic_body_yaw handles collision prevention",
|
| 259 |
+
"Fix: Idle animation now starts immediately on app launch",
|
| 260 |
+
"Fix: Smooth antenna animation - removed pose change threshold for continuous motion"
|
| 261 |
+
]
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"version": "0.8.0",
|
| 265 |
+
"date": "2026-01-17",
|
| 266 |
+
"changes": [
|
| 267 |
+
"New: Comprehensive emotion keyword mapping with 280+ Chinese and English keywords",
|
| 268 |
+
"New: 35 emotion categories mapped to robot expressions",
|
| 269 |
+
"New: Auto-trigger expressions from conversation text patterns"
|
| 270 |
+
]
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"version": "0.7.3",
|
| 274 |
+
"date": "2026-01-12",
|
| 275 |
+
"changes": [
|
| 276 |
+
"Fix: Revert to reference project pattern - use refractory period instead of state flags",
|
| 277 |
+
"Fix: Remove broken _in_pipeline and _tts_playing state management",
|
| 278 |
+
"Fix: Restore correct RUN_END event handling from linux-voice-assistant"
|
| 279 |
+
]
|
| 280 |
+
},
|
| 281 |
+
{
|
| 282 |
+
"version": "0.7.2",
|
| 283 |
+
"date": "2026-01-12",
|
| 284 |
+
"changes": [
|
| 285 |
+
"Fix: Remove premature _tts_played reset in RUN_END event",
|
| 286 |
+
"Fix: Ensure _in_pipeline stays True until TTS playback completes"
|
| 287 |
+
]
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"version": "0.7.1",
|
| 291 |
+
"date": "2026-01-12",
|
| 292 |
+
"changes": [
|
| 293 |
+
"Fix: Prevent wake word detection during TTS playback",
|
| 294 |
+
"Fix: Add _tts_playing flag to track TTS audio state precisely"
|
| 295 |
+
]
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
"version": "0.7.0",
|
| 299 |
+
"date": "2026-01-12",
|
| 300 |
+
"changes": [
|
| 301 |
+
"New: Gesture detection using HaGRID ONNX models (18 gesture classes)",
|
| 302 |
+
"New: gesture_detected and gesture_confidence entities in Home Assistant",
|
| 303 |
+
"Fix: Gesture state now properly pushed to Home Assistant in real-time",
|
| 304 |
+
"Optimize: Aggressive power saving - 0.5fps idle mode after 30s without face",
|
| 305 |
+
"Optimize: Gesture detection only runs when face detected (saves CPU)"
|
| 306 |
+
]
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"version": "0.6.1",
|
| 310 |
+
"date": "2026-01-12",
|
| 311 |
+
"changes": [
|
| 312 |
+
"Fix: Prioritize MicroWakeWord over OpenWakeWord for same-name wake words",
|
| 313 |
+
"Fix: OpenWakeWord wake words now visible in Home Assistant selection",
|
| 314 |
+
"Fix: Stop word detection now works correctly",
|
| 315 |
+
"Fix: STT/LLM response time improved with fixed audio chunk size"
|
| 316 |
+
]
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"version": "0.6.0",
|
| 320 |
+
"date": "2026-01-11",
|
| 321 |
+
"changes": [
|
| 322 |
+
"New: Real-time audio-driven speech animation (SwayRollRT algorithm)",
|
| 323 |
+
"New: JSON-driven animation system - all animations configurable",
|
| 324 |
+
"Refactor: Remove hardcoded actions, use animation offsets only",
|
| 325 |
+
"Fix: TTS audio analysis now works with local playback"
|
| 326 |
+
]
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"version": "0.5.16",
|
| 330 |
+
"date": "2026-01-11",
|
| 331 |
+
"changes": [
|
| 332 |
+
"Remove: Tap-to-wake feature (too many false triggers)",
|
| 333 |
+
"New: Continuous Conversation switch in Home Assistant",
|
| 334 |
+
"Refactor: Simplified satellite.py and voice_assistant.py"
|
| 335 |
+
]
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"version": "0.5.15",
|
| 339 |
+
"date": "2026-01-11",
|
| 340 |
+
"changes": [
|
| 341 |
+
"New: Audio settings persistence (AGC, Noise Suppression, Tap Sensitivity)",
|
| 342 |
+
"Refactor: Move Sendspin mDNS discovery to zeroconf.py",
|
| 343 |
+
"Fix: Tap detection not re-enabled during emotion playback in conversation"
|
| 344 |
+
]
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"version": "0.5.14",
|
| 348 |
+
"date": "2026-01-11",
|
| 349 |
+
"changes": [
|
| 350 |
+
"Fix: Skip ALL wake word processing when pipeline is active",
|
| 351 |
+
"Fix: Eliminate race condition in pipeline state during continuous conversation",
|
| 352 |
+
"Improve: Control loop increased to 100Hz (daemon updated)"
|
| 353 |
+
]
|
| 354 |
+
},
|
| 355 |
+
{
|
| 356 |
+
"version": "0.5.13",
|
| 357 |
+
"date": "2026-01-10",
|
| 358 |
+
"changes": [
|
| 359 |
+
"New: JSON-driven animation system for conversation states",
|
| 360 |
+
"New: AnimationPlayer class inspired by SimpleDances project",
|
| 361 |
+
"Refactor: Replace SpeechSwayGenerator and BreathingAnimation with unified animation system"
|
| 362 |
+
]
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"version": "0.5.12",
|
| 366 |
+
"date": "2026-01-10",
|
| 367 |
+
"changes": [
|
| 368 |
+
"Remove: Deleted broken hey_reachy wake word model",
|
| 369 |
+
"Revert: Default wake word back to \"Okay Nabu\""
|
| 370 |
+
]
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"version": "0.5.11",
|
| 374 |
+
"date": "2026-01-10",
|
| 375 |
+
"changes": [
|
| 376 |
+
"Fix: Reset feature extractors when switching wake words",
|
| 377 |
+
"Fix: Add refractory period after wake word switch"
|
| 378 |
+
]
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
"version": "0.5.10",
|
| 382 |
+
"date": "2026-01-10",
|
| 383 |
+
"changes": [
|
| 384 |
+
"Fix: Wake word models now have 'id' attribute set correctly",
|
| 385 |
+
"Fix: Wake word switching from Home Assistant now works"
|
| 386 |
+
]
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"version": "0.5.9",
|
| 390 |
+
"date": "2026-01-10",
|
| 391 |
+
"changes": [
|
| 392 |
+
"New: Default wake word changed to hey_reachy",
|
| 393 |
+
"Fix: Wake word switching bug"
|
| 394 |
+
]
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
"version": "0.5.8",
|
| 398 |
+
"date": "2026-01-09",
|
| 399 |
+
"changes": [
|
| 400 |
+
"Fix: Tap detection waits for emotion playback to complete",
|
| 401 |
+
"Fix: Poll daemon API for move completion"
|
| 402 |
+
]
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"version": "0.5.7",
|
| 406 |
+
"date": "2026-01-09",
|
| 407 |
+
"changes": [
|
| 408 |
+
"New: DOA turn-to-sound at wakeup",
|
| 409 |
+
"Fix: Show raw DOA angle in Home Assistant (0-180)",
|
| 410 |
+
"Fix: Invert DOA yaw direction"
|
| 411 |
+
]
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"version": "0.5.6",
|
| 415 |
+
"date": "2026-01-08",
|
| 416 |
+
"changes": [
|
| 417 |
+
"Fix: Better pipeline state tracking to prevent duplicate audio"
|
| 418 |
+
]
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"version": "0.5.5",
|
| 422 |
+
"date": "2026-01-08",
|
| 423 |
+
"changes": [
|
| 424 |
+
"Fix: Prevent concurrent pipelines",
|
| 425 |
+
"New: Add prompt sound for continuous conversation"
|
| 426 |
+
]
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"version": "0.5.4",
|
| 430 |
+
"date": "2026-01-08",
|
| 431 |
+
"changes": [
|
| 432 |
+
"Fix: Wait for RUN_END before starting new conversation"
|
| 433 |
+
]
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
"version": "0.5.3",
|
| 437 |
+
"date": "2026-01-08",
|
| 438 |
+
"changes": [
|
| 439 |
+
"Fix: Improve continuous conversation with conversation_id tracking"
|
| 440 |
+
]
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"version": "0.5.2",
|
| 444 |
+
"date": "2026-01-08",
|
| 445 |
+
"changes": [
|
| 446 |
+
"Fix: Enable HA control of robot pose",
|
| 447 |
+
"Fix: Continuous conversation improvements"
|
| 448 |
+
]
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"version": "0.5.1",
|
| 452 |
+
"date": "2026-01-08",
|
| 453 |
+
"changes": [
|
| 454 |
+
"Fix: Sendspin connects to music_player instead of tts_player",
|
| 455 |
+
"Fix: Persist tap_sensitivity settings",
|
| 456 |
+
"Fix: Pause Sendspin during voice assistant wakeup",
|
| 457 |
+
"Fix: Sendspin prioritize 16kHz sample rate"
|
| 458 |
+
]
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"version": "0.5.0",
|
| 462 |
+
"date": "2026-01-07",
|
| 463 |
+
"changes": [
|
| 464 |
+
"New: Face tracking with adaptive frequency",
|
| 465 |
+
"New: Sendspin multi-room audio integration",
|
| 466 |
+
"Optimize: Shutdown mechanism improvements"
|
| 467 |
+
]
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"version": "0.4.0",
|
| 471 |
+
"date": "2026-01-07",
|
| 472 |
+
"changes": [
|
| 473 |
+
"Fix: Daemon stability fixes",
|
| 474 |
+
"New: Face tracking enabled by default",
|
| 475 |
+
"Optimize: Microphone settings for better sensitivity"
|
| 476 |
+
]
|
| 477 |
+
},
|
| 478 |
+
{
|
| 479 |
+
"version": "0.3.0",
|
| 480 |
+
"date": "2026-01-06",
|
| 481 |
+
"changes": [
|
| 482 |
+
"New: Tap sensitivity slider entity",
|
| 483 |
+
"Fix: Music Assistant compatibility",
|
| 484 |
+
"Optimize: Face tracking and tap detection"
|
| 485 |
+
]
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
"version": "0.2.21",
|
| 489 |
+
"date": "2026-01-06",
|
| 490 |
+
"changes": [
|
| 491 |
+
"Fix: Daemon crash - reduce control loop to 2Hz",
|
| 492 |
+
"Fix: Pause control loop during audio playback"
|
| 493 |
+
]
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"version": "0.2.20",
|
| 497 |
+
"date": "2026-01-06",
|
| 498 |
+
"changes": [
|
| 499 |
+
"Revert: Audio/satellite/voice_assistant to v0.2.9 working state"
|
| 500 |
+
]
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"version": "0.2.19",
|
| 504 |
+
"date": "2026-01-06",
|
| 505 |
+
"changes": [
|
| 506 |
+
"Fix: Force localhost connection mode to prevent WebRTC errors"
|
| 507 |
+
]
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"version": "0.2.18",
|
| 511 |
+
"date": "2026-01-06",
|
| 512 |
+
"changes": [
|
| 513 |
+
"Fix: Audio playback - restore wakeup sound",
|
| 514 |
+
"Fix: Use push_audio_sample for TTS"
|
| 515 |
+
]
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"version": "0.2.17",
|
| 519 |
+
"date": "2026-01-06",
|
| 520 |
+
"changes": [
|
| 521 |
+
"Remove: head_joints/passive_joints entities",
|
| 522 |
+
"Move: error_message to diagnostic category"
|
| 523 |
+
]
|
| 524 |
+
},
|
| 525 |
+
{
|
| 526 |
+
"version": "0.2.16",
|
| 527 |
+
"date": "2026-01-06",
|
| 528 |
+
"changes": [
|
| 529 |
+
"Fix: TTS playback - pause recording during playback"
|
| 530 |
+
]
|
| 531 |
+
},
|
| 532 |
+
{
|
| 533 |
+
"version": "0.2.15",
|
| 534 |
+
"date": "2026-01-06",
|
| 535 |
+
"changes": [
|
| 536 |
+
"Fix: Use play_sound() instead of push_audio_sample() for TTS"
|
| 537 |
+
]
|
| 538 |
+
},
|
| 539 |
+
{
|
| 540 |
+
"version": "0.2.14",
|
| 541 |
+
"date": "2026-01-06",
|
| 542 |
+
"changes": [
|
| 543 |
+
"Fix: Pause audio recording during TTS playback"
|
| 544 |
+
]
|
| 545 |
+
},
|
| 546 |
+
{
|
| 547 |
+
"version": "0.2.13",
|
| 548 |
+
"date": "2026-01-06",
|
| 549 |
+
"changes": [
|
| 550 |
+
"Fix: Don't manually start/stop media - let SDK/daemon manage it"
|
| 551 |
+
]
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"version": "0.2.12",
|
| 555 |
+
"date": "2026-01-05",
|
| 556 |
+
"changes": [
|
| 557 |
+
"Fix: Disable breathing animation to prevent serial port overflow"
|
| 558 |
+
]
|
| 559 |
+
},
|
| 560 |
+
{
|
| 561 |
+
"version": "0.2.11",
|
| 562 |
+
"date": "2026-01-05",
|
| 563 |
+
"changes": [
|
| 564 |
+
"Fix: Disable wakeup sound to prevent daemon crash",
|
| 565 |
+
"Add: Debug logging for troubleshooting"
|
| 566 |
+
]
|
| 567 |
+
},
|
| 568 |
+
{
|
| 569 |
+
"version": "0.2.10",
|
| 570 |
+
"date": "2026-01-05",
|
| 571 |
+
"changes": [
|
| 572 |
+
"Add: Debug logging for motion init",
|
| 573 |
+
"Fix: Audio fallback samplerate"
|
| 574 |
+
]
|
| 575 |
+
},
|
| 576 |
+
{
|
| 577 |
+
"version": "0.2.9",
|
| 578 |
+
"date": "2026-01-05",
|
| 579 |
+
"changes": [
|
| 580 |
+
"Remove: DOA/speech detection - replaced by face tracking"
|
| 581 |
+
]
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
"version": "0.2.8",
|
| 585 |
+
"date": "2026-01-05",
|
| 586 |
+
"changes": [
|
| 587 |
+
"New: Replace DOA with YOLO face tracking"
|
| 588 |
+
]
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
"version": "0.2.7",
|
| 592 |
+
"date": "2026-01-05",
|
| 593 |
+
"changes": [
|
| 594 |
+
"Fix: Add DOA caching to prevent ReSpeaker query overload"
|
| 595 |
+
]
|
| 596 |
+
},
|
| 597 |
+
{
|
| 598 |
+
"version": "0.2.6",
|
| 599 |
+
"date": "2026-01-05",
|
| 600 |
+
"changes": [
|
| 601 |
+
"New: Thread-safe ReSpeaker USB access to prevent daemon deadlock"
|
| 602 |
+
]
|
| 603 |
+
},
|
| 604 |
+
{
|
| 605 |
+
"version": "0.2.4",
|
| 606 |
+
"date": "2026-01-05",
|
| 607 |
+
"changes": [
|
| 608 |
+
"Fix: Microphone volume control via daemon HTTP API"
|
| 609 |
+
]
|
| 610 |
+
},
|
| 611 |
+
{
|
| 612 |
+
"version": "0.2.3",
|
| 613 |
+
"date": "2026-01-05",
|
| 614 |
+
"changes": [
|
| 615 |
+
"Fix: Daemon crash caused by conflicting pose commands",
|
| 616 |
+
"Disable: Pose setter methods in ReachyController"
|
| 617 |
+
]
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"version": "0.2.2",
|
| 621 |
+
"date": "2026-01-05",
|
| 622 |
+
"changes": [
|
| 623 |
+
"Fix: Second conversation motion failure",
|
| 624 |
+
"Reduce: Control loop from 20Hz to 10Hz",
|
| 625 |
+
"Improve: Connection recovery (faster reconnect)"
|
| 626 |
+
]
|
| 627 |
+
},
|
| 628 |
+
{
|
| 629 |
+
"version": "0.2.1",
|
| 630 |
+
"date": "2026-01-05",
|
| 631 |
+
"changes": [
|
| 632 |
+
"Fix: Daemon crash issue",
|
| 633 |
+
"Optimize: Code structure"
|
| 634 |
+
]
|
| 635 |
+
},
|
| 636 |
+
{
|
| 637 |
+
"version": "0.2.0",
|
| 638 |
+
"date": "2026-01-05",
|
| 639 |
+
"changes": [
|
| 640 |
+
"New: Automatic facial expressions during conversation",
|
| 641 |
+
"New: Emotion playback integration",
|
| 642 |
+
"Refactor: Integrate emotion playback into MovementManager"
|
| 643 |
+
]
|
| 644 |
+
},
|
| 645 |
+
{
|
| 646 |
+
"version": "0.1.5",
|
| 647 |
+
"date": "2026-01-04",
|
| 648 |
+
"changes": [
|
| 649 |
+
"Optimize: Code splitting and organization",
|
| 650 |
+
"Fix: Program crash issues"
|
| 651 |
+
]
|
| 652 |
+
},
|
| 653 |
+
{
|
| 654 |
+
"version": "0.1.0",
|
| 655 |
+
"date": "2026-01-01",
|
| 656 |
+
"changes": [
|
| 657 |
+
"Initial release",
|
| 658 |
+
"ESPHome protocol server implementation",
|
| 659 |
+
"mDNS auto-discovery for Home Assistant",
|
| 660 |
+
"Local wake word detection (microWakeWord)",
|
| 661 |
+
"Voice assistant pipeline integration",
|
| 662 |
+
"Basic motion feedback (nod, shake)"
|
| 663 |
+
]
|
| 664 |
+
}
|
| 665 |
+
]
|
| 666 |
+
|
docs/USER_MANUAL_CN.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Mini 语音助手 - 用户手册
|
| 2 |
+
|
| 3 |
+
## 系统要求
|
| 4 |
+
|
| 5 |
+
### 硬件
|
| 6 |
+
- Reachy Mini 机器人(带 ReSpeaker XVF3800 麦克风)
|
| 7 |
+
- WiFi 网络连接
|
| 8 |
+
|
| 9 |
+
### 软件
|
| 10 |
+
- Home Assistant(2024.1 或更高版本)
|
| 11 |
+
- Home Assistant 中已启用 ESPHome 集成
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 安装步骤
|
| 16 |
+
|
| 17 |
+
### 第一步:安装应用
|
| 18 |
+
从 Reachy Mini 应用商店安装 `reachy_mini_home_assistant`。
|
| 19 |
+
|
| 20 |
+
### 第二步:启动应用
|
| 21 |
+
应用将自动:
|
| 22 |
+
- 在端口 6053 启动 ESPHome 服务器
|
| 23 |
+
- 加载预打包的唤醒词模型
|
| 24 |
+
- 通过 mDNS 注册以便自动发现
|
| 25 |
+
- 如果网络上有 Sendspin 服务器则自动连接
|
| 26 |
+
|
| 27 |
+
### 第三步:连接 Home Assistant
|
| 28 |
+
**自动连接(推荐):**
|
| 29 |
+
Home Assistant 会通过 mDNS 自动发现 Reachy Mini。
|
| 30 |
+
|
| 31 |
+
**手动连接:**
|
| 32 |
+
1. 进入 设置 → 设备与服务
|
| 33 |
+
2. 点击"添加集成"
|
| 34 |
+
3. 选择"ESPHome"
|
| 35 |
+
4. 输入机器人的 IP 地址和端口 6053
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 功能介绍
|
| 40 |
+
|
| 41 |
+
### 语音助手
|
| 42 |
+
- **唤醒词检测**:说 "Okay Nabu" 激活(本地处理)
|
| 43 |
+
- **停止词**:说 "Stop" 结束对话
|
| 44 |
+
- **连续对话模式**:无需重复唤醒词即可持续对话
|
| 45 |
+
- **语音识别/合成**:使用 Home Assistant 配置的语音引擎
|
| 46 |
+
|
| 47 |
+
**支持的唤醒词:**
|
| 48 |
+
- Okay Nabu(默认)
|
| 49 |
+
- Hey Jarvis
|
| 50 |
+
- Alexa
|
| 51 |
+
- Hey Luna
|
| 52 |
+
|
| 53 |
+
### 人脸追踪
|
| 54 |
+
- 基于 YOLO 的人脸检测
|
| 55 |
+
- 头部跟随检测到的人脸
|
| 56 |
+
- 头部转动时身体随之旋转
|
| 57 |
+
- 自适应帧率:活跃时 15fps,空闲时 2fps
|
| 58 |
+
- 可在 Home Assistant 中运行时开关
|
| 59 |
+
|
| 60 |
+
### 手势检测
|
| 61 |
+
检测到的手势会作为实体状态同步到 Home Assistant。
|
| 62 |
+
当前默认运行时不会直接用手势触发机器人动作。
|
| 63 |
+
|
| 64 |
+
| 输出 | 说明 |
|
| 65 |
+
|------|------|
|
| 66 |
+
| `gesture_detected` | 当前识别到的手势标签 |
|
| 67 |
+
| `gesture_confidence` | 手势识别置信度 |
|
| 68 |
+
|
| 69 |
+
### 情绪响应
|
| 70 |
+
机器人可播放 35 种不同情绪:
|
| 71 |
+
- 基础:开心、难过、愤怒、恐惧、惊讶、厌恶
|
| 72 |
+
- 扩展:大笑、爱慕、骄傲、感激、热情、好奇、惊叹、害羞、困惑、沉思、焦虑、害怕、沮丧、烦躁、狂怒、轻蔑、无聊、疲倦、精疲力竭、孤独、沮丧、顺从、不确定、不舒服
|
| 73 |
+
|
| 74 |
+
### 音频功能
|
| 75 |
+
- 扬声器音量控制(0-100%)
|
| 76 |
+
- 静音开关,可暂停/恢复语音链路
|
| 77 |
+
- 支持唤醒提示音与计时器完成提示音
|
| 78 |
+
- STT/TTS 由 Home Assistant 负责
|
| 79 |
+
|
| 80 |
+
### Sendspin 多房间音频
|
| 81 |
+
- 通过 mDNS 自动发现 Sendspin 服务器
|
| 82 |
+
- 同步多房间音频播放
|
| 83 |
+
- Reachy Mini 作为 PLAYER 接收音频流
|
| 84 |
+
- 语音对话时自动暂停
|
| 85 |
+
- 无需用户配置
|
| 86 |
+
|
| 87 |
+
### DOA 声源追踪
|
| 88 |
+
- 声源方向检测
|
| 89 |
+
- 唤醒时机器人转向声源
|
| 90 |
+
- 可通过开关启用/禁用
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Home Assistant 实体
|
| 95 |
+
|
| 96 |
+
### 阶段 1:基础状态
|
| 97 |
+
| 实体 | 类型 | 说明 |
|
| 98 |
+
|------|------|------|
|
| 99 |
+
| Daemon State | 文本传感器 | 机器人守护进程状态 |
|
| 100 |
+
| Backend Ready | 二进制传感器 | 后端连接状态 |
|
| 101 |
+
| Mute | 开关 | 暂停/恢复语音链路 |
|
| 102 |
+
| Speaker Volume | 数值 (0-100%) | 扬声器音量控制 |
|
| 103 |
+
| Disable Camera | 开关 | 暂停/恢复摄像头服务 |
|
| 104 |
+
| Idle Behavior | 开关 | 统一空闲行为:头部、天线、微动作 |
|
| 105 |
+
| Sendspin | 开关 | 启用/禁用 Sendspin 发现与播放 |
|
| 106 |
+
| Face Tracking | 开关 | 启用/禁用人脸跟踪 |
|
| 107 |
+
| Gesture Detection | 开关 | 启用/禁用手势检测 |
|
| 108 |
+
| Face Confidence | 数值 (0-1) | 人脸跟踪置信度阈值 |
|
| 109 |
+
|
| 110 |
+
### 阶段 2:睡眠与运行状态
|
| 111 |
+
| 实体 | 类型 | 说明 |
|
| 112 |
+
|------|------|------|
|
| 113 |
+
| Sleep Control | 开关 | 打开表示进入睡眠,关闭表示唤醒 |
|
| 114 |
+
| Sleep Mode | 二进制传感器 | 运行中表示唤醒,非运行表示睡眠 |
|
| 115 |
+
| Services Suspended | 二进制传感器 | 运行中表示服务活跃 |
|
| 116 |
+
|
| 117 |
+
### 阶段 3:姿态控制
|
| 118 |
+
| 实体 | 类型 | 范围 |
|
| 119 |
+
|------|------|------|
|
| 120 |
+
| Head X/Y/Z | 数值 | ±50mm |
|
| 121 |
+
| Head Roll/Pitch/Yaw | 数值 | ±40° |
|
| 122 |
+
| Body Yaw | 数值 | ±160° |
|
| 123 |
+
| Antenna Left/Right | 数值 | ±90° |
|
| 124 |
+
|
| 125 |
+
### 阶段 4:注视控制
|
| 126 |
+
| 实体 | 类型 | 说明 |
|
| 127 |
+
|------|------|------|
|
| 128 |
+
| Look At X/Y/Z | 数值 | 注视目标的世界坐标 |
|
| 129 |
+
|
| 130 |
+
### 阶段 5:DOA(声源定位)
|
| 131 |
+
| 实体 | 类型 | 说明 |
|
| 132 |
+
|------|------|------|
|
| 133 |
+
| DOA Angle | 传感器 (°) | 声源方向 |
|
| 134 |
+
| Speech Detected | 二进制传感器 | 语音活动检测 |
|
| 135 |
+
| DOA Sound Tracking | 开关 | 启用/禁用 DOA 追踪 |
|
| 136 |
+
|
| 137 |
+
### 阶段 6:诊断信息
|
| 138 |
+
| 实体 | 类型 | 说明 |
|
| 139 |
+
|------|------|------|
|
| 140 |
+
| Control Loop Frequency | 传感器 (Hz) | 运动控制循环频率 |
|
| 141 |
+
| SDK Version | 文本传感器 | Reachy Mini SDK 版本 |
|
| 142 |
+
| Robot Name | 文本传感器 | 设备名称 |
|
| 143 |
+
| Wireless Version | 二进制传感器 | 无线版本标志 |
|
| 144 |
+
| Simulation Mode | 二进制传感器 | 仿真模式标志 |
|
| 145 |
+
| WLAN IP | 文本传感器 | WiFi IP 地址 |
|
| 146 |
+
| Error Message | 文本传感器 | 当前错误 |
|
| 147 |
+
|
| 148 |
+
### 阶段 7:IMU 传感器(仅无线版本)
|
| 149 |
+
| 实体 | 类型 | 说明 |
|
| 150 |
+
|------|------|------|
|
| 151 |
+
| IMU Accel X/Y/Z | 传感器 (m/s²) | 加速度计 |
|
| 152 |
+
| IMU Gyro X/Y/Z | 传感器 (rad/s) | ���螺仪 |
|
| 153 |
+
| IMU Temperature | 传感器 (°C) | IMU 温度 |
|
| 154 |
+
|
| 155 |
+
### 阶段 8:情绪控制
|
| 156 |
+
| 实体 | 类型 | 说明 |
|
| 157 |
+
|------|------|------|
|
| 158 |
+
| Emotion | 选择器 | 选择要播放的情绪(35 个选项)|
|
| 159 |
+
|
| 160 |
+
### 阶段 10:摄像头
|
| 161 |
+
| 实体 | 类型 | 说明 |
|
| 162 |
+
|------|------|------|
|
| 163 |
+
| Camera | 摄像头 | 实时 MJPEG 流 |
|
| 164 |
+
|
| 165 |
+
### 3D 可视化卡片
|
| 166 |
+
可在 Home Assistant 中安装自定义 Lovelace 卡片,实时 3D 可视化 Reachy Mini 机器人。
|
| 167 |
+
|
| 168 |
+
安装地址:[ha-reachy-mini](https://github.com/Desmond-Dong/ha-reachy-mini)
|
| 169 |
+
|
| 170 |
+
功能:
|
| 171 |
+
- 实时 3D 机器人可视化
|
| 172 |
+
- 交互式机器人状态视图
|
| 173 |
+
- 连接机器人守护进程获取实时更新
|
| 174 |
+
|
| 175 |
+
### 阶段 21:对话
|
| 176 |
+
| 实体 | 类型 | 说明 |
|
| 177 |
+
|------|------|------|
|
| 178 |
+
| Continuous Conversation | 开关 | 多轮对话模式 |
|
| 179 |
+
|
| 180 |
+
### 阶段 22:手势检测
|
| 181 |
+
| 实体 | 类型 | 说明 |
|
| 182 |
+
|------|------|------|
|
| 183 |
+
| Gesture Detected | 文本传感器 | 当前手势名称 |
|
| 184 |
+
| Gesture Confidence | 传感器 (%) | 检测置信度 |
|
| 185 |
+
|
| 186 |
+
### 阶段 23:人脸检测
|
| 187 |
+
| 实体 | 类型 | 说明 |
|
| 188 |
+
|------|------|------|
|
| 189 |
+
| Face Detected | 二进制传感器 | 视野中是否有人脸 |
|
| 190 |
+
|
| 191 |
+
### 阶段 24:系统诊断
|
| 192 |
+
| 实体 | 类型 | 说明 |
|
| 193 |
+
|------|------|------|
|
| 194 |
+
| CPU Percent | 传感器 (%) | CPU 使用率 |
|
| 195 |
+
| CPU Temperature | 传感器 (°C) | CPU 温度 |
|
| 196 |
+
| Memory Percent | 传感器 (%) | 内存使用率 |
|
| 197 |
+
| Memory Used | 传感器 (GB) | 已用内存 |
|
| 198 |
+
| Disk Percent | 传感器 (%) | 磁盘使用率 |
|
| 199 |
+
| Disk Free | 传感器 (GB) | 磁盘可用空间 |
|
| 200 |
+
| Uptime | 传感器 (hours) | 系统运行时间 |
|
| 201 |
+
| Process CPU | 传感器 (%) | 应用 CPU 使用率 |
|
| 202 |
+
| Process Memory | 传感器 (MB) | 应用内存使用 |
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 睡眠模式
|
| 207 |
+
|
| 208 |
+
运行时反应是零配置的:语音阶段、计时器提醒和 HA 状态触发情绪,共用同一套内建行为模型。
|
| 209 |
+
|
| 210 |
+
### 进入睡眠
|
| 211 |
+
- 在 Home Assistant 中打开 `Sleep Control` 开关
|
| 212 |
+
- 机器人放松电机、停止摄像头、暂停语音检测
|
| 213 |
+
|
| 214 |
+
### 唤醒
|
| 215 |
+
- 在 Home Assistant 中关闭 `Sleep Control` 开关
|
| 216 |
+
- 或说唤醒词
|
| 217 |
+
- 机器人恢复所有功能
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## 故障排除
|
| 222 |
+
|
| 223 |
+
| 问题 | 解决方案 |
|
| 224 |
+
|------|----------|
|
| 225 |
+
| 不响应唤醒词 | 检查 Mute 是否关闭,减少背景噪音,并确认已连接 Home Assistant |
|
| 226 |
+
| 人脸追踪不工作 | 确保光线充足,检查 Face Detected 传感器 |
|
| 227 |
+
| 没有音频输出 | 检查 Speaker Volume,验证 HA 中的 TTS 引擎 |
|
| 228 |
+
| 无法连接 HA | 确认在同一网络,检查端口 6053 |
|
| 229 |
+
| 手势检测不到 | 确保光线充足,正对摄像头 |
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## 快速参考
|
| 234 |
+
|
| 235 |
+
```
|
| 236 |
+
唤醒词: "Okay Nabu"
|
| 237 |
+
停止词: "Stop"
|
| 238 |
+
ESPHome 端口: 6053
|
| 239 |
+
摄像头端口: 8081 (MJPEG)
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
*Reachy Mini 语音助手 v1.0.4*
|
docs/USER_MANUAL_EN.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Mini Voice Assistant - User Manual
|
| 2 |
+
|
| 3 |
+
## Requirements
|
| 4 |
+
|
| 5 |
+
### Hardware
|
| 6 |
+
- Reachy Mini robot (with ReSpeaker XVF3800 microphone)
|
| 7 |
+
- WiFi network connection
|
| 8 |
+
|
| 9 |
+
### Software
|
| 10 |
+
- Home Assistant (2024.1 or later)
|
| 11 |
+
- ESPHome integration enabled in Home Assistant
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Installation
|
| 16 |
+
|
| 17 |
+
### Step 1: Install the App
|
| 18 |
+
Install `reachy_mini_home_assistant` from the Reachy Mini App Store.
|
| 19 |
+
|
| 20 |
+
### Step 2: Start the App
|
| 21 |
+
The app will automatically:
|
| 22 |
+
- Start the ESPHome server on port 6053
|
| 23 |
+
- Load pre-packaged wake word models
|
| 24 |
+
- Register with mDNS for auto-discovery
|
| 25 |
+
- Connect to Sendspin server if available on network
|
| 26 |
+
|
| 27 |
+
### Step 3: Connect to Home Assistant
|
| 28 |
+
**Automatic (Recommended):**
|
| 29 |
+
Home Assistant will auto-discover Reachy Mini via mDNS.
|
| 30 |
+
|
| 31 |
+
**Manual:**
|
| 32 |
+
1. Go to Settings → Devices & Services
|
| 33 |
+
2. Click "Add Integration"
|
| 34 |
+
3. Select "ESPHome"
|
| 35 |
+
4. Enter the robot's IP address and port 6053
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Features
|
| 40 |
+
|
| 41 |
+
### Voice Assistant
|
| 42 |
+
- **Wake Word Detection**: Say "Okay Nabu" to activate (local processing)
|
| 43 |
+
- **Stop Word**: Say "Stop" to end conversation
|
| 44 |
+
- **Continuous Conversation Mode**: Keep talking without repeating wake word
|
| 45 |
+
- **STT/TTS**: Uses Home Assistant's configured speech engines
|
| 46 |
+
|
| 47 |
+
**Supported Wake Words:**
|
| 48 |
+
- Okay Nabu (default)
|
| 49 |
+
- Hey Jarvis
|
| 50 |
+
- Alexa
|
| 51 |
+
- Hey Luna
|
| 52 |
+
|
| 53 |
+
### Face Tracking
|
| 54 |
+
- YOLO-based face detection
|
| 55 |
+
- Head follows detected face
|
| 56 |
+
- Body follows head when turned far
|
| 57 |
+
- Adaptive frame rate: 15fps active, 2fps idle
|
| 58 |
+
- Runtime switchable from Home Assistant
|
| 59 |
+
|
| 60 |
+
### Gesture Detection
|
| 61 |
+
Detected gestures are published to Home Assistant as entity state updates.
|
| 62 |
+
The default runtime does not trigger built-in robot actions from gestures.
|
| 63 |
+
|
| 64 |
+
| Output | Description |
|
| 65 |
+
|--------|-------------|
|
| 66 |
+
| `gesture_detected` | Current gesture label |
|
| 67 |
+
| `gesture_confidence` | Detection confidence |
|
| 68 |
+
|
| 69 |
+
### Emotion Responses
|
| 70 |
+
The robot can play 35 different emotions:
|
| 71 |
+
- Basic: Happy, Sad, Angry, Fear, Surprise, Disgust
|
| 72 |
+
- Extended: Laughing, Loving, Proud, Grateful, Enthusiastic, Curious, Amazed, Shy, Confused, Thoughtful, Anxious, Scared, Frustrated, Irritated, Furious, Contempt, Bored, Tired, Exhausted, Lonely, Downcast, Resigned, Uncertain, Uncomfortable
|
| 73 |
+
|
| 74 |
+
### Audio Features
|
| 75 |
+
- Speaker volume control (0-100%)
|
| 76 |
+
- Mute switch for voice pipeline pause/resume
|
| 77 |
+
- Wake sound and timer-finished sound playback
|
| 78 |
+
- Home Assistant handles STT/TTS engines
|
| 79 |
+
|
| 80 |
+
### Sendspin Multi-Room Audio
|
| 81 |
+
- Automatic discovery of Sendspin servers via mDNS
|
| 82 |
+
- Synchronized multi-room audio playback
|
| 83 |
+
- Reachy Mini acts as a PLAYER to receive audio streams
|
| 84 |
+
- Auto-pause during voice conversations
|
| 85 |
+
- No user configuration required
|
| 86 |
+
|
| 87 |
+
### DOA Sound Tracking
|
| 88 |
+
- Direction of Arrival detection
|
| 89 |
+
- Robot turns toward sound source on wake word
|
| 90 |
+
- Can be enabled/disabled via switch
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Home Assistant Entities
|
| 95 |
+
|
| 96 |
+
### Phase 1: Basic Status
|
| 97 |
+
| Entity | Type | Description |
|
| 98 |
+
|--------|------|-------------|
|
| 99 |
+
| Daemon State | Text Sensor | Robot daemon status |
|
| 100 |
+
| Backend Ready | Binary Sensor | Backend connection status |
|
| 101 |
+
| Mute | Switch | Suspend/resume voice pipeline |
|
| 102 |
+
| Speaker Volume | Number (0-100%) | Speaker volume control |
|
| 103 |
+
| Disable Camera | Switch | Suspend/resume camera service |
|
| 104 |
+
| Idle Behavior | Switch | Unified idle motion + idle antenna + idle micro-actions |
|
| 105 |
+
| Sendspin | Switch | Enable/disable Sendspin discovery and playback |
|
| 106 |
+
| Face Tracking | Switch | Enable/disable face tracking |
|
| 107 |
+
| Gesture Detection | Switch | Enable/disable gesture detection |
|
| 108 |
+
| Face Confidence | Number (0-1) | Face tracking confidence threshold |
|
| 109 |
+
|
| 110 |
+
### Phase 2: Sleep and Runtime State
|
| 111 |
+
| Entity | Type | Description |
|
| 112 |
+
|--------|------|-------------|
|
| 113 |
+
| Sleep Control | Switch | Turn on to sleep, turn off to wake |
|
| 114 |
+
| Sleep Mode | Binary Sensor | Running when awake, not running when sleeping |
|
| 115 |
+
| Services Suspended | Binary Sensor | Running when services are active |
|
| 116 |
+
|
| 117 |
+
### Phase 3: Pose Control
|
| 118 |
+
| Entity | Type | Range |
|
| 119 |
+
|--------|------|-------|
|
| 120 |
+
| Head X/Y/Z | Number | ±50mm |
|
| 121 |
+
| Head Roll/Pitch/Yaw | Number | ±40° |
|
| 122 |
+
| Body Yaw | Number | ±160° |
|
| 123 |
+
| Antenna Left/Right | Number | ±90° |
|
| 124 |
+
|
| 125 |
+
### Phase 4: Look At Control
|
| 126 |
+
| Entity | Type | Description |
|
| 127 |
+
|--------|------|-------------|
|
| 128 |
+
| Look At X/Y/Z | Number | World coordinates for gaze target |
|
| 129 |
+
|
| 130 |
+
### Phase 5: DOA (Direction of Arrival)
|
| 131 |
+
| Entity | Type | Description |
|
| 132 |
+
|--------|------|-------------|
|
| 133 |
+
| DOA Angle | Sensor (°) | Sound source direction |
|
| 134 |
+
| Speech Detected | Binary Sensor | Voice activity detection |
|
| 135 |
+
| DOA Sound Tracking | Switch | Enable/disable DOA tracking |
|
| 136 |
+
|
| 137 |
+
### Phase 6: Diagnostics
|
| 138 |
+
| Entity | Type | Description |
|
| 139 |
+
|--------|------|-------------|
|
| 140 |
+
| Control Loop Frequency | Sensor (Hz) | Motion control loop rate |
|
| 141 |
+
| SDK Version | Text Sensor | Reachy Mini SDK version |
|
| 142 |
+
| Robot Name | Text Sensor | Device name |
|
| 143 |
+
| Wireless Version | Binary Sensor | Wireless model flag |
|
| 144 |
+
| Simulation Mode | Binary Sensor | Simulation flag |
|
| 145 |
+
| WLAN IP | Text Sensor | WiFi IP address |
|
| 146 |
+
| Error Message | Text Sensor | Current error |
|
| 147 |
+
|
| 148 |
+
### Phase 7: IMU Sensors (Wireless version only)
|
| 149 |
+
| Entity | Type | Description |
|
| 150 |
+
|--------|------|-------------|
|
| 151 |
+
| IMU Accel X/Y/Z | Sensor (m/s²) | Accelerometer |
|
| 152 |
+
| IMU Gyro X/Y/Z | Sensor (rad/s) | Gyroscope |
|
| 153 |
+
| IMU Temperature | Sensor (°C) | IMU temperature |
|
| 154 |
+
|
| 155 |
+
### Phase 8: Emotion Control
|
| 156 |
+
| Entity | Type | Description |
|
| 157 |
+
|--------|------|-------------|
|
| 158 |
+
| Emotion | Select | Choose emotion to play (35 options) |
|
| 159 |
+
|
| 160 |
+
### Phase 10: Camera
|
| 161 |
+
| Entity | Type | Description |
|
| 162 |
+
|--------|------|-------------|
|
| 163 |
+
| Camera | Camera | Live MJPEG stream |
|
| 164 |
+
|
| 165 |
+
### 3D Visualization Card
|
| 166 |
+
A custom Lovelace card is available for real-time 3D visualization of the Reachy Mini robot in Home Assistant.
|
| 167 |
+
|
| 168 |
+
Install from: [ha-reachy-mini](https://github.com/Desmond-Dong/ha-reachy-mini)
|
| 169 |
+
|
| 170 |
+
Features:
|
| 171 |
+
- Real-time 3D robot visualization
|
| 172 |
+
- Interactive view of robot state
|
| 173 |
+
- Connects to robot daemon for live updates
|
| 174 |
+
|
| 175 |
+
### Phase 21: Conversation
|
| 176 |
+
| Entity | Type | Description |
|
| 177 |
+
|--------|------|-------------|
|
| 178 |
+
| Continuous Conversation | Switch | Multi-turn conversation mode |
|
| 179 |
+
|
| 180 |
+
### Phase 22: Gesture Detection
|
| 181 |
+
| Entity | Type | Description |
|
| 182 |
+
|--------|------|-------------|
|
| 183 |
+
| Gesture Detected | Text Sensor | Current gesture name |
|
| 184 |
+
| Gesture Confidence | Sensor (%) | Detection confidence |
|
| 185 |
+
|
| 186 |
+
### Phase 23: Face Detection
|
| 187 |
+
| Entity | Type | Description |
|
| 188 |
+
|--------|------|-------------|
|
| 189 |
+
| Face Detected | Binary Sensor | Face in view |
|
| 190 |
+
|
| 191 |
+
### Phase 24: System Diagnostics
|
| 192 |
+
| Entity | Type | Description |
|
| 193 |
+
|--------|------|-------------|
|
| 194 |
+
| CPU Percent | Sensor (%) | CPU usage |
|
| 195 |
+
| CPU Temperature | Sensor (°C) | CPU temperature |
|
| 196 |
+
| Memory Percent | Sensor (%) | RAM usage |
|
| 197 |
+
| Memory Used | Sensor (GB) | RAM used |
|
| 198 |
+
| Disk Percent | Sensor (%) | Disk usage |
|
| 199 |
+
| Disk Free | Sensor (GB) | Disk free space |
|
| 200 |
+
| Uptime | Sensor (hours) | System uptime |
|
| 201 |
+
| Process CPU | Sensor (%) | App CPU usage |
|
| 202 |
+
| Process Memory | Sensor (MB) | App memory usage |
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Sleep Mode
|
| 207 |
+
|
| 208 |
+
Runtime reactions are zero-config: voice phases, timer alerts, and HA state-triggered emotions use the same built-in behavior model.
|
| 209 |
+
|
| 210 |
+
### Enter Sleep
|
| 211 |
+
- Turn on the `Sleep Control` switch in Home Assistant
|
| 212 |
+
- Robot relaxes motors, stops camera, pauses voice detection
|
| 213 |
+
|
| 214 |
+
### Wake Up
|
| 215 |
+
- Turn off the `Sleep Control` switch in Home Assistant
|
| 216 |
+
- Or say the wake word
|
| 217 |
+
- Robot resumes all functions
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Troubleshooting
|
| 222 |
+
|
| 223 |
+
| Problem | Solution |
|
| 224 |
+
|---------|----------|
|
| 225 |
+
| Not responding to wake word | Check Mute is off, reduce background noise, verify Home Assistant is connected |
|
| 226 |
+
| Face tracking not working | Ensure adequate lighting, check Face Detected sensor |
|
| 227 |
+
| No audio output | Check Speaker Volume, verify TTS engine in HA |
|
| 228 |
+
| Can't connect to HA | Verify same network, check port 6053 |
|
| 229 |
+
| Gestures not detected | Ensure good lighting, face the camera directly |
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Quick Reference
|
| 234 |
+
|
| 235 |
+
```
|
| 236 |
+
Wake Word: "Okay Nabu"
|
| 237 |
+
Stop Word: "Stop"
|
| 238 |
+
ESPHome Port: 6053
|
| 239 |
+
Camera Port: 8081 (MJPEG)
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
*Reachy Mini Voice Assistant v1.0.4*
|
home_assistant_blueprints/reachy_mini_presence_companion.yaml
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blueprint:
|
| 2 |
+
name: Reachy Mini Presence Companion
|
| 3 |
+
description: >-
|
| 4 |
+
Presence-driven automation for Reachy Mini in Home Assistant.
|
| 5 |
+
|
| 6 |
+
How to use:
|
| 7 |
+
1) Select Home occupancy entity (person/group/binary_sensor).
|
| 8 |
+
2) Select Reachy ESPHome device (recommended).
|
| 9 |
+
3) Leave optional fallback entity inputs empty unless auto-binding fails.
|
| 10 |
+
4) Set away delay and day/night volume.
|
| 11 |
+
|
| 12 |
+
What this automation does:
|
| 13 |
+
- Occupied: Wake Reachy, enable unified idle behavior, set day volume.
|
| 14 |
+
- Empty (after delay): Disable unified idle behavior, send Reachy to sleep.
|
| 15 |
+
- Quiet hours start/end: Apply night/day volume while occupied.
|
| 16 |
+
|
| 17 |
+
Auto-binding rules (when Reachy device is selected):
|
| 18 |
+
- Sleep switch suffix: sleep_control
|
| 19 |
+
- Idle behavior switch suffix: idle_behavior_enabled
|
| 20 |
+
- Volume number suffix: speaker_volume
|
| 21 |
+
|
| 22 |
+
If your entities use different names, fill optional fallback entity inputs manually.
|
| 23 |
+
domain: automation
|
| 24 |
+
input:
|
| 25 |
+
occupancy_entity:
|
| 26 |
+
name: Home occupancy entity
|
| 27 |
+
description: Person, group, or binary sensor representing home presence.
|
| 28 |
+
selector:
|
| 29 |
+
entity: {}
|
| 30 |
+
|
| 31 |
+
reachy_device:
|
| 32 |
+
name: Reachy device (recommended)
|
| 33 |
+
description: Select your Reachy ESPHome device for automatic entity binding.
|
| 34 |
+
default: ""
|
| 35 |
+
selector:
|
| 36 |
+
device:
|
| 37 |
+
filter:
|
| 38 |
+
- integration: esphome
|
| 39 |
+
|
| 40 |
+
reachy_sleep_switch:
|
| 41 |
+
name: Sleep Control switch (optional fallback)
|
| 42 |
+
description: Leave empty to auto-bind from Reachy device.
|
| 43 |
+
default: ""
|
| 44 |
+
selector:
|
| 45 |
+
entity:
|
| 46 |
+
domain: switch
|
| 47 |
+
|
| 48 |
+
idle_behavior_switch:
|
| 49 |
+
name: Idle Behavior switch (optional fallback)
|
| 50 |
+
description: Leave empty to auto-bind from Reachy device.
|
| 51 |
+
default: ""
|
| 52 |
+
selector:
|
| 53 |
+
entity:
|
| 54 |
+
domain: switch
|
| 55 |
+
|
| 56 |
+
reachy_volume_number:
|
| 57 |
+
name: Speaker Volume number (optional fallback)
|
| 58 |
+
description: Leave empty to auto-bind from Reachy device.
|
| 59 |
+
default: ""
|
| 60 |
+
selector:
|
| 61 |
+
entity:
|
| 62 |
+
domain: number
|
| 63 |
+
|
| 64 |
+
away_delay_minutes:
|
| 65 |
+
name: Away delay (minutes)
|
| 66 |
+
description: Wait before sleeping after everyone leaves.
|
| 67 |
+
default: 20
|
| 68 |
+
selector:
|
| 69 |
+
number:
|
| 70 |
+
min: 1
|
| 71 |
+
max: 180
|
| 72 |
+
mode: box
|
| 73 |
+
unit_of_measurement: min
|
| 74 |
+
|
| 75 |
+
day_volume:
|
| 76 |
+
name: Day volume
|
| 77 |
+
default: 80
|
| 78 |
+
selector:
|
| 79 |
+
number:
|
| 80 |
+
min: 0
|
| 81 |
+
max: 100
|
| 82 |
+
step: 1
|
| 83 |
+
mode: slider
|
| 84 |
+
|
| 85 |
+
night_volume:
|
| 86 |
+
name: Night volume
|
| 87 |
+
default: 35
|
| 88 |
+
selector:
|
| 89 |
+
number:
|
| 90 |
+
min: 0
|
| 91 |
+
max: 100
|
| 92 |
+
step: 1
|
| 93 |
+
mode: slider
|
| 94 |
+
|
| 95 |
+
quiet_start:
|
| 96 |
+
name: Quiet hours start
|
| 97 |
+
default: "22:30:00"
|
| 98 |
+
selector:
|
| 99 |
+
time: {}
|
| 100 |
+
|
| 101 |
+
quiet_end:
|
| 102 |
+
name: Quiet hours end
|
| 103 |
+
default: "07:30:00"
|
| 104 |
+
selector:
|
| 105 |
+
time: {}
|
| 106 |
+
|
| 107 |
+
mode: restart
|
| 108 |
+
|
| 109 |
+
variables:
|
| 110 |
+
occupancy_entity: !input occupancy_entity
|
| 111 |
+
reachy_device: !input reachy_device
|
| 112 |
+
manual_sleep_switch: !input reachy_sleep_switch
|
| 113 |
+
manual_idle_behavior_switch: !input idle_behavior_switch
|
| 114 |
+
manual_volume_number: !input reachy_volume_number
|
| 115 |
+
day_volume: !input day_volume
|
| 116 |
+
night_volume: !input night_volume
|
| 117 |
+
|
| 118 |
+
device_entities_list: >-
|
| 119 |
+
{{ device_entities(reachy_device) if reachy_device else [] }}
|
| 120 |
+
|
| 121 |
+
sleep_switch_auto: >-
|
| 122 |
+
{{ (device_entities_list | select('match', '^switch\..*sleep_control$') | list | first) or '' }}
|
| 123 |
+
idle_behavior_switch_auto: >-
|
| 124 |
+
{{ (device_entities_list | select('match', '^switch\..*idle_behavior_enabled$') | list | first) or '' }}
|
| 125 |
+
volume_number_auto: >-
|
| 126 |
+
{{ (device_entities_list | select('match', '^number\..*speaker_volume$') | list | first) or '' }}
|
| 127 |
+
|
| 128 |
+
sleep_switch: >-
|
| 129 |
+
{{ manual_sleep_switch if manual_sleep_switch else sleep_switch_auto }}
|
| 130 |
+
idle_behavior_switch: >-
|
| 131 |
+
{{ manual_idle_behavior_switch if manual_idle_behavior_switch else idle_behavior_switch_auto }}
|
| 132 |
+
volume_number: >-
|
| 133 |
+
{{ manual_volume_number if manual_volume_number else volume_number_auto }}
|
| 134 |
+
|
| 135 |
+
is_occupied: >-
|
| 136 |
+
{{ states(occupancy_entity) in ['home', 'on'] }}
|
| 137 |
+
|
| 138 |
+
trigger:
|
| 139 |
+
- platform: state
|
| 140 |
+
id: occupied_home
|
| 141 |
+
entity_id: !input occupancy_entity
|
| 142 |
+
to: "home"
|
| 143 |
+
|
| 144 |
+
- platform: state
|
| 145 |
+
id: occupied_on
|
| 146 |
+
entity_id: !input occupancy_entity
|
| 147 |
+
to: "on"
|
| 148 |
+
|
| 149 |
+
- platform: state
|
| 150 |
+
id: empty_not_home
|
| 151 |
+
entity_id: !input occupancy_entity
|
| 152 |
+
to: "not_home"
|
| 153 |
+
for:
|
| 154 |
+
minutes: !input away_delay_minutes
|
| 155 |
+
|
| 156 |
+
- platform: state
|
| 157 |
+
id: empty_off
|
| 158 |
+
entity_id: !input occupancy_entity
|
| 159 |
+
to: "off"
|
| 160 |
+
for:
|
| 161 |
+
minutes: !input away_delay_minutes
|
| 162 |
+
|
| 163 |
+
- platform: time
|
| 164 |
+
id: quiet_start
|
| 165 |
+
at: !input quiet_start
|
| 166 |
+
|
| 167 |
+
- platform: time
|
| 168 |
+
id: quiet_end
|
| 169 |
+
at: !input quiet_end
|
| 170 |
+
|
| 171 |
+
action:
|
| 172 |
+
- choose:
|
| 173 |
+
- conditions:
|
| 174 |
+
- condition: template
|
| 175 |
+
value_template: "{{ trigger.id in ['occupied_home', 'occupied_on'] }}"
|
| 176 |
+
sequence:
|
| 177 |
+
- if:
|
| 178 |
+
- condition: template
|
| 179 |
+
value_template: "{{ sleep_switch != '' }}"
|
| 180 |
+
then:
|
| 181 |
+
- service: switch.turn_off
|
| 182 |
+
target:
|
| 183 |
+
entity_id: "{{ sleep_switch }}"
|
| 184 |
+
- if:
|
| 185 |
+
- condition: template
|
| 186 |
+
value_template: "{{ idle_behavior_switch != '' }}"
|
| 187 |
+
then:
|
| 188 |
+
- service: switch.turn_on
|
| 189 |
+
target:
|
| 190 |
+
entity_id: "{{ idle_behavior_switch }}"
|
| 191 |
+
- if:
|
| 192 |
+
- condition: template
|
| 193 |
+
value_template: "{{ volume_number != '' }}"
|
| 194 |
+
then:
|
| 195 |
+
- service: number.set_value
|
| 196 |
+
target:
|
| 197 |
+
entity_id: "{{ volume_number }}"
|
| 198 |
+
data:
|
| 199 |
+
value: "{{ day_volume }}"
|
| 200 |
+
|
| 201 |
+
- conditions:
|
| 202 |
+
- condition: template
|
| 203 |
+
value_template: "{{ trigger.id in ['empty_not_home', 'empty_off'] }}"
|
| 204 |
+
sequence:
|
| 205 |
+
- if:
|
| 206 |
+
- condition: template
|
| 207 |
+
value_template: "{{ idle_behavior_switch != '' }}"
|
| 208 |
+
then:
|
| 209 |
+
- service: switch.turn_off
|
| 210 |
+
target:
|
| 211 |
+
entity_id: "{{ idle_behavior_switch }}"
|
| 212 |
+
- if:
|
| 213 |
+
- condition: template
|
| 214 |
+
value_template: "{{ sleep_switch != '' }}"
|
| 215 |
+
then:
|
| 216 |
+
- service: switch.turn_on
|
| 217 |
+
target:
|
| 218 |
+
entity_id: "{{ sleep_switch }}"
|
| 219 |
+
|
| 220 |
+
- conditions:
|
| 221 |
+
- condition: template
|
| 222 |
+
value_template: "{{ trigger.id == 'quiet_start' and is_occupied }}"
|
| 223 |
+
sequence:
|
| 224 |
+
- if:
|
| 225 |
+
- condition: template
|
| 226 |
+
value_template: "{{ volume_number != '' }}"
|
| 227 |
+
then:
|
| 228 |
+
- service: number.set_value
|
| 229 |
+
target:
|
| 230 |
+
entity_id: "{{ volume_number }}"
|
| 231 |
+
data:
|
| 232 |
+
value: "{{ night_volume }}"
|
| 233 |
+
|
| 234 |
+
- conditions:
|
| 235 |
+
- condition: template
|
| 236 |
+
value_template: "{{ trigger.id == 'quiet_end' and is_occupied }}"
|
| 237 |
+
sequence:
|
| 238 |
+
- if:
|
| 239 |
+
- condition: template
|
| 240 |
+
value_template: "{{ volume_number != '' }}"
|
| 241 |
+
then:
|
| 242 |
+
- service: number.set_value
|
| 243 |
+
target:
|
| 244 |
+
entity_id: "{{ volume_number }}"
|
| 245 |
+
data:
|
| 246 |
+
value: "{{ day_volume }}"
|
index.html
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Reachy Mini for Home Assistant</title>
|
| 7 |
+
<meta name="description" content="Voice assistant integration for Reachy Mini robot with Home Assistant. Control your smart home with voice commands and expressive robot movements.">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="style.css" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<header class="hero">
|
| 15 |
+
<div class="topline">
|
| 16 |
+
<div class="brand">
|
| 17 |
+
<span class="logo">🤖</span>
|
| 18 |
+
<span class="brand-name">Reachy Mini for Home Assistant</span>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="pill">Voice · Gestures · Smart Home</div>
|
| 21 |
+
<div class="version-pill" id="version-pill">v1.0.4</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="hero-grid">
|
| 24 |
+
<div class="hero-copy">
|
| 25 |
+
<p class="eyebrow">Reachy Mini App</p>
|
| 26 |
+
<h1>Your robot meets your Home Assistant.</h1>
|
| 27 |
+
<p class="lede">
|
| 28 |
+
Transform Reachy Mini Wi-Fi into a voice-controlled smart home hub. Natural conversations, expressive movements, gesture recognition — all seamlessly connected to Home Assistant.
|
| 29 |
+
</p>
|
| 30 |
+
<div class="hero-actions">
|
| 31 |
+
<a class="btn primary" href="#requirements">Requirements</a>
|
| 32 |
+
<a class="btn ghost" href="#install">Quick Start</a>
|
| 33 |
+
<a class="btn ghost" href="#features">Features</a>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="hero-badges">
|
| 36 |
+
<span>🎤 Wake Word</span>
|
| 37 |
+
<span>👀 Face Tracking</span>
|
| 38 |
+
<span>🔄 Body Following</span>
|
| 39 |
+
<span>🤚 18 Gestures</span>
|
| 40 |
+
<span>🔊 Multi-room Audio</span>
|
| 41 |
+
<span>⚡ Zero Config</span>
|
| 42 |
+
<span>🃏 Dashboard Card</span>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="hero-visual">
|
| 46 |
+
<div class="video-container">
|
| 47 |
+
<iframe src="https://www.youtube.com/embed/OuhTSTKB25o" title="Reachy Mini for Home Assistant Demo" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</header>
|
| 52 |
+
|
| 53 |
+
<section id="requirements" class="section">
|
| 54 |
+
<div class="section-header">
|
| 55 |
+
<p class="eyebrow">Before You Start</p>
|
| 56 |
+
<h2>Requirements</h2>
|
| 57 |
+
<p class="intro">Make sure you have everything ready for a smooth setup.</p>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="requirements-grid">
|
| 60 |
+
<div class="requirement-card">
|
| 61 |
+
<span class="icon">🤖</span>
|
| 62 |
+
<h3>Reachy Mini Wi-Fi</h3>
|
| 63 |
+
<p>This app requires the <strong>Wi-Fi version</strong> of Reachy Mini. The USB version has not been validated</p>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="requirement-card">
|
| 66 |
+
<span class="icon">🏠</span>
|
| 67 |
+
<h3>Home Assistant</h3>
|
| 68 |
+
<p>A running Home Assistant instance </p>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="requirement-card">
|
| 71 |
+
<span class="icon">📶</span>
|
| 72 |
+
<h3>Same Network</h3>
|
| 73 |
+
<p>Both Reachy Mini and Home Assistant must be on the <strong>same local network</strong>.</p>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="requirement-card">
|
| 76 |
+
<span class="icon">🎙️</span>
|
| 77 |
+
<h3>Voice Pipeline</h3>
|
| 78 |
+
<p>Configure a <strong>Voice Assistant pipeline</strong> in Home Assistant (STT + TTS + LLM).</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</section>
|
| 82 |
+
|
| 83 |
+
<section id="install" class="section story">
|
| 84 |
+
<div class="section-header">
|
| 85 |
+
<p class="eyebrow">Getting Started</p>
|
| 86 |
+
<h2>Quick Start</h2>
|
| 87 |
+
<p class="intro">Install and connect in under a minute. No configuration needed.</p>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="story-grid">
|
| 90 |
+
<div class="story-card">
|
| 91 |
+
<p class="eyebrow">Installation</p>
|
| 92 |
+
<h3>Up and running in 1 minute</h3>
|
| 93 |
+
<ul class="story-list">
|
| 94 |
+
<li><span>1️⃣</span> Open Reachy Mini Dashboard → Applications</li>
|
| 95 |
+
<li><span>2️⃣</span> Enable "Show community apps"</li>
|
| 96 |
+
<li><span>3️⃣</span> Install "Reachy Mini for Home Assistant"</li>
|
| 97 |
+
<li><span>4️⃣</span> Home Assistant discovers automatically</li>
|
| 98 |
+
</ul>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="story-card secondary">
|
| 101 |
+
<p class="eyebrow">How it works</p>
|
| 102 |
+
<h3>Seamless integration</h3>
|
| 103 |
+
<p class="story-text">
|
| 104 |
+
This Reachy Mini app uses ESPHome protocol to communicate with Home Assistant — no ESPHome device needed. Home Assistant discovers it via mDNS and adds the robot entities automatically. Voice commands are processed by your Home Assistant instance — STT, intent recognition, and TTS all happen there.
|
| 105 |
+
</p>
|
| 106 |
+
<div class="chips">
|
| 107 |
+
<span class="chip">ESPHome Protocol</span>
|
| 108 |
+
<span class="chip">mDNS Discovery</span>
|
| 109 |
+
<span class="chip">Robot Entities</span>
|
| 110 |
+
<span class="chip">Zero Config</span>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</section>
|
| 115 |
+
|
| 116 |
+
<section id="features" class="section features">
|
| 117 |
+
<div class="section-header">
|
| 118 |
+
<p class="eyebrow">Capabilities</p>
|
| 119 |
+
<h2>Everything you need for smart home control</h2>
|
| 120 |
+
<p class="intro">Zero-configuration robot entities, built-in reactions, and auto-discovery via mDNS.</p>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="feature-grid">
|
| 123 |
+
<div class="feature-card">
|
| 124 |
+
<span class="icon">🎤</span>
|
| 125 |
+
<h3>Voice Wake</h3>
|
| 126 |
+
<p>Local wake word detection with MicroWakeWord and OpenWakeWord. Say "Okay Nabu" or "Hey Reachy" to activate.</p>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="feature-card">
|
| 129 |
+
<span class="icon">🏠</span>
|
| 130 |
+
<h3>Smart Home Control</h3>
|
| 131 |
+
<p>Full Home Assistant integration. Control lights, switches, climate, media — anything in your Home Assistant.</p>
|
| 132 |
+
</div>
|
| 133 |
+
<div class="feature-card">
|
| 134 |
+
<span class="icon">👀</span>
|
| 135 |
+
<h3>Face Tracking</h3>
|
| 136 |
+
<p>YOLO-based face detection with body following. Head and body move together naturally to track you during conversations.</p>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="feature-card">
|
| 139 |
+
<span class="icon">🤚</span>
|
| 140 |
+
<h3>Gesture Detection</h3>
|
| 141 |
+
<p>HaGRID ONNX models recognize hand gestures and publish the detected gesture label and confidence to Home Assistant entities.</p>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="feature-card">
|
| 144 |
+
<span class="icon">😊</span>
|
| 145 |
+
<h3>Expressive Motion</h3>
|
| 146 |
+
<p>Built-in listening, thinking, speaking, timer, and emotion reactions with natural head sway and non-blocking motion during conversations.</p>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="feature-card">
|
| 149 |
+
<span class="icon">📹</span>
|
| 150 |
+
<h3>Camera Stream</h3>
|
| 151 |
+
<p>MJPEG video stream as ESPHome Camera entity. Real-time monitoring in Home Assistant dashboard.</p>
|
| 152 |
+
</div>
|
| 153 |
+
<div class="feature-card">
|
| 154 |
+
<span class="icon">🔊</span>
|
| 155 |
+
<h3>Multi-room Audio</h3>
|
| 156 |
+
<p>Sendspin protocol support. Sync audio playback with other speakers throughout your home.</p>
|
| 157 |
+
</div>
|
| 158 |
+
<div class="feature-card">
|
| 159 |
+
<span class="icon">⚡</span>
|
| 160 |
+
<h3>Zero Configuration</h3>
|
| 161 |
+
<p>Install and go. mDNS auto-discovery and built-in HA reactions mean the default experience works without extra setup.</p>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="feature-card">
|
| 164 |
+
<span class="icon">🃏</span>
|
| 165 |
+
<h3>Dashboard Card</h3>
|
| 166 |
+
<p>Custom Lovelace card for Home Assistant. Real-time 3D visualization of robot pose and status.</p>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="feature-card">
|
| 169 |
+
<span class="icon">🧩</span>
|
| 170 |
+
<h3>HA Blueprint</h3>
|
| 171 |
+
<p>Device-first Home Assistant blueprint for presence automations using the current zero-config model: sleep control, idle behavior, and speaker volume.</p>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="feature-card">
|
| 174 |
+
<span class="icon">🚀</span>
|
| 175 |
+
<h3>Auto Release</h3>
|
| 176 |
+
<p>Version-driven GitHub release workflow. Update pyproject/changelog, then release is created automatically.</p>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</section>
|
| 180 |
+
|
| 181 |
+
<section id="changelog" class="section">
|
| 182 |
+
<div class="section-header">
|
| 183 |
+
<p class="eyebrow">Updates</p>
|
| 184 |
+
<h2>Changelog</h2>
|
| 185 |
+
</div>
|
| 186 |
+
<div id="changelog-grid" class="changelog-grid"></div>
|
| 187 |
+
<div class="changelog-more">
|
| 188 |
+
<details id="changelog-details">
|
| 189 |
+
<summary>View older versions</summary>
|
| 190 |
+
<div id="changelog-older" class="changelog-grid older"></div>
|
| 191 |
+
</details>
|
| 192 |
+
</div>
|
| 193 |
+
</section>
|
| 194 |
+
|
| 195 |
+
<script>
|
| 196 |
+
const VISIBLE_COUNT = 6;
|
| 197 |
+
fetch('changelog.json')
|
| 198 |
+
.then(res => res.json())
|
| 199 |
+
.then(data => {
|
| 200 |
+
// Update version pill with latest version
|
| 201 |
+
if (data.length > 0) {
|
| 202 |
+
const versionPill = document.getElementById('version-pill');
|
| 203 |
+
if (versionPill) {
|
| 204 |
+
versionPill.textContent = `v${data[0].version}`;
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Populate changelog grid
|
| 209 |
+
const mainGrid = document.getElementById('changelog-grid');
|
| 210 |
+
const olderGrid = document.getElementById('changelog-older');
|
| 211 |
+
data.forEach((item, index) => {
|
| 212 |
+
const card = document.createElement('div');
|
| 213 |
+
card.className = 'changelog-card';
|
| 214 |
+
card.innerHTML = `
|
| 215 |
+
<div class="version-badge">v${item.version}</div>
|
| 216 |
+
<span class="date">${item.date}</span>
|
| 217 |
+
<ul>${item.changes.map(c => `<li>${c}</li>`).join('')}</ul>
|
| 218 |
+
`;
|
| 219 |
+
(index < VISIBLE_COUNT ? mainGrid : olderGrid).appendChild(card);
|
| 220 |
+
});
|
| 221 |
+
if (data.length <= VISIBLE_COUNT) {
|
| 222 |
+
document.getElementById('changelog-details').style.display = 'none';
|
| 223 |
+
}
|
| 224 |
+
})
|
| 225 |
+
.catch(err => console.error('Failed to load changelog:', err));
|
| 226 |
+
</script>
|
| 227 |
+
|
| 228 |
+
<section class="section links">
|
| 229 |
+
<div class="section-header">
|
| 230 |
+
<p class="eyebrow">Resources</p>
|
| 231 |
+
<h2>Links & References</h2>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="links-grid">
|
| 234 |
+
<a href="https://github.com/Desmond-Dong/ha-reachy-mini" target="_blank" class="link-card">
|
| 235 |
+
<span class="icon">🃏</span>
|
| 236 |
+
<h3>HA Dashboard Card</h3>
|
| 237 |
+
<p>Lovelace Card for HA</p>
|
| 238 |
+
</a>
|
| 239 |
+
<a href="https://github.com/ha-china/Reachy_Mini_For_Home_Assistant" target="_blank" class="link-card">
|
| 240 |
+
<span class="icon">📦</span>
|
| 241 |
+
<h3>Source Code</h3>
|
| 242 |
+
<p>GitHub Repository</p>
|
| 243 |
+
</a>
|
| 244 |
+
<a href="home_assistant_blueprints/reachy_mini_presence_companion.yaml" target="_blank" class="link-card">
|
| 245 |
+
<span class="icon">🧩</span>
|
| 246 |
+
<h3>HA Blueprint</h3>
|
| 247 |
+
<p>Presence Companion YAML</p>
|
| 248 |
+
</a>
|
| 249 |
+
<a href="https://www.pollen-robotics.com/" target="_blank" class="link-card">
|
| 250 |
+
<span class="icon">🤖</span>
|
| 251 |
+
<h3>Pollen Robotics</h3>
|
| 252 |
+
<p>Reachy Mini Creator</p>
|
| 253 |
+
</a>
|
| 254 |
+
<a href="https://www.home-assistant.io/" target="_blank" class="link-card">
|
| 255 |
+
<span class="icon">🏠</span>
|
| 256 |
+
<h3>Home Assistant</h3>
|
| 257 |
+
<p>Smart Home Platform</p>
|
| 258 |
+
</a>
|
| 259 |
+
<a href="https://esphome.io/" target="_blank" class="link-card">
|
| 260 |
+
<span class="icon">⚡</span>
|
| 261 |
+
<h3>ESPHome Protocol</h3>
|
| 262 |
+
<p>Communication Protocol</p>
|
| 263 |
+
</a>
|
| 264 |
+
<a href="https://github.com/OHF-Voice/linux-voice-assistant" target="_blank" class="link-card">
|
| 265 |
+
<span class="icon">🎤</span>
|
| 266 |
+
<h3>linux-voice-assistant</h3>
|
| 267 |
+
<p>Voice Assistant Base</p>
|
| 268 |
+
</a>
|
| 269 |
+
<a href="https://github.com/kahrendt/microWakeWord" target="_blank" class="link-card">
|
| 270 |
+
<span class="icon">👂</span>
|
| 271 |
+
<h3>microWakeWord</h3>
|
| 272 |
+
<p>Wake Word Detection</p>
|
| 273 |
+
</a>
|
| 274 |
+
<a href="https://huggingface.co/AdamCodd/YOLOv11n-face-detection" target="_blank" class="link-card">
|
| 275 |
+
<span class="icon">👀</span>
|
| 276 |
+
<h3>YOLOv11n-face</h3>
|
| 277 |
+
<p>Face Detection Model</p>
|
| 278 |
+
</a>
|
| 279 |
+
<a href="https://github.com/ai-forever/dynamic_gestures" target="_blank" class="link-card">
|
| 280 |
+
<span class="icon">✋</span>
|
| 281 |
+
<h3>Dynamic Gestures</h3>
|
| 282 |
+
<p>Reference Project</p>
|
| 283 |
+
</a>
|
| 284 |
+
<a href="https://github.com/Sendspin/sendspin-cli" target="_blank" class="link-card">
|
| 285 |
+
<span class="icon">🔊</span>
|
| 286 |
+
<h3>Sendspin</h3>
|
| 287 |
+
<p>Multi-room Audio</p>
|
| 288 |
+
</a>
|
| 289 |
+
<a href="https://huggingface.co/spaces/pollen-robotics/reachy-mini-landing-page#apps" target="_blank" class="link-card">
|
| 290 |
+
<span class="icon">🛒</span>
|
| 291 |
+
<h3>Reachy Mini App Store</h3>
|
| 292 |
+
<p>More Apps</p>
|
| 293 |
+
</a>
|
| 294 |
+
</div>
|
| 295 |
+
</section>
|
| 296 |
+
|
| 297 |
+
<footer class="footer">
|
| 298 |
+
<p>Built by <a href="https://github.com/Desmond-Dong" target="_blank">Desmond</a></p>
|
| 299 |
+
</footer>
|
| 300 |
+
</body>
|
| 301 |
+
</html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "reachy_mini_home_assistant"
|
| 7 |
+
version = "1.0.7"
|
| 8 |
+
description = "Deep integration of Reachy Mini robot with Home Assistant"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.12"
|
| 11 |
+
license = {text = "Apache-2.0"}
|
| 12 |
+
dependencies = [
|
| 13 |
+
# Reachy Mini SDK with gstreamer support (for camera streaming)
|
| 14 |
+
"reachy-mini>=1.7.1",
|
| 15 |
+
|
| 16 |
+
# Audio processing (for audio file analysis)
|
| 17 |
+
"soundfile>=0.13.0",
|
| 18 |
+
"numpy>=2.2.5,<=2.2.5",
|
| 19 |
+
|
| 20 |
+
# Camera streaming
|
| 21 |
+
"opencv-python>=4.12.0.88",
|
| 22 |
+
|
| 23 |
+
# Wake word detection (local)
|
| 24 |
+
# STT/TTS is handled by Home Assistant, not locally
|
| 25 |
+
"pymicro-wakeword>=2.0.0,<3.0.0",
|
| 26 |
+
"pyopen-wakeword>=1.0.0,<2.0.0",
|
| 27 |
+
|
| 28 |
+
# ESPHome protocol (communication with Home Assistant)
|
| 29 |
+
"aioesphomeapi>=43.10.1",
|
| 30 |
+
"zeroconf>=0.131,<1",
|
| 31 |
+
"websockets>=12,<16",
|
| 32 |
+
"aiohttp",
|
| 33 |
+
|
| 34 |
+
# Motion control (head movements)
|
| 35 |
+
"scipy>=1.15.3,<2.0.0",
|
| 36 |
+
|
| 37 |
+
# Face tracking (YOLO-based head detection)
|
| 38 |
+
"ultralytics",
|
| 39 |
+
"supervision",
|
| 40 |
+
|
| 41 |
+
# Sendspin synchronized audio (optional, for multi-room playback)
|
| 42 |
+
"aiosendspin>=5.1,<6.0",
|
| 43 |
+
|
| 44 |
+
# Gesture detection (ONNX runtime for HaGRID models)
|
| 45 |
+
"onnxruntime>=1.18.0",
|
| 46 |
+
|
| 47 |
+
# PyTorch (for vision models)
|
| 48 |
+
"torch==2.5.1",
|
| 49 |
+
"torchvision==0.20.1",
|
| 50 |
+
|
| 51 |
+
# Compatibility with system packages (gradio, etc.)
|
| 52 |
+
"pillow<12.0",
|
| 53 |
+
"pydantic<=2.12.5",
|
| 54 |
+
"requests>=2.33.0",
|
| 55 |
+
]
|
| 56 |
+
keywords = ["reachy-mini-app", "reachy-mini", "home-assistant", "voice-assistant"]
|
| 57 |
+
|
| 58 |
+
[project.entry-points."reachy_mini_apps"]
|
| 59 |
+
reachy_mini_home_assistant = "reachy_mini_home_assistant.main:ReachyMiniHaVoice"
|
| 60 |
+
|
| 61 |
+
[tool.setuptools]
|
| 62 |
+
package-dir = { "" = "." }
|
| 63 |
+
include-package-data = true
|
| 64 |
+
|
| 65 |
+
[tool.setuptools.packages.find]
|
| 66 |
+
where = ["."]
|
| 67 |
+
|
| 68 |
+
[tool.setuptools.package-data]
|
| 69 |
+
"*" = ["*.json", "*.flac", "*.md", "*.tflite", "*.onnx", "*.pt"]
|
| 70 |
+
|
| 71 |
+
# ============================================================================
|
| 72 |
+
# Ruff - Fast Python linter and formatter
|
| 73 |
+
# ============================================================================
|
| 74 |
+
[tool.ruff]
|
| 75 |
+
target-version = "py312"
|
| 76 |
+
line-length = 120
|
| 77 |
+
src = ["reachy_mini_home_assistant"]
|
| 78 |
+
|
| 79 |
+
# Exclude reference code and generated files
|
| 80 |
+
exclude = [
|
| 81 |
+
"reference/",
|
| 82 |
+
"__pycache__",
|
| 83 |
+
".git",
|
| 84 |
+
"*.egg-info",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
[dependency-groups]
|
| 88 |
+
dev = [
|
| 89 |
+
"ruff==0.15.4",
|
| 90 |
+
"mypy==1.20.0",
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
[tool.uv]
|
| 94 |
+
dependency-metadata = [
|
| 95 |
+
{ name = "gstreamer-libs", version = "1.28.1", requires-dist = ["gstreamer-msvc-runtime; sys_platform == 'win32'", "setuptools"] },
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
[tool.ruff.lint]
|
| 99 |
+
select = [
|
| 100 |
+
"E", # pycodestyle errors
|
| 101 |
+
"W", # pycodestyle warnings
|
| 102 |
+
"F", # Pyflakes
|
| 103 |
+
"I", # isort (import sorting)
|
| 104 |
+
"B", # flake8-bugbear (common bugs)
|
| 105 |
+
"C4", # flake8-comprehensions
|
| 106 |
+
"UP", # pyupgrade (modern Python syntax)
|
| 107 |
+
"SIM", # flake8-simplify
|
| 108 |
+
"TCH", # flake8-type-checking (TYPE_CHECKING optimization)
|
| 109 |
+
"RUF", # Ruff-specific rules
|
| 110 |
+
"PTH", # flake8-use-pathlib
|
| 111 |
+
"PL", # Pylint
|
| 112 |
+
]
|
| 113 |
+
ignore = [
|
| 114 |
+
"E501", # line too long (handled by formatter)
|
| 115 |
+
"PLR0913", # too many arguments (common in robot control)
|
| 116 |
+
"PLR2004", # magic value comparison (many thresholds in motion code)
|
| 117 |
+
"PLR0912", # too many branches
|
| 118 |
+
"PLR0915", # too many statements
|
| 119 |
+
"PLR0911", # too many return statements
|
| 120 |
+
"SIM108", # use ternary operator (sometimes less readable)
|
| 121 |
+
"B008", # function call in default argument (used for field factories)
|
| 122 |
+
# The following are intentional patterns in this codebase:
|
| 123 |
+
"PLC0415", # import-outside-top-level (lazy imports for optional deps)
|
| 124 |
+
"PLW0603", # global-statement (used for singletons)
|
| 125 |
+
"SIM102", # collapsible-if (sometimes more readable expanded)
|
| 126 |
+
"SIM105", # suppressible-exception (explicit try/except is clearer)
|
| 127 |
+
"PTH123", # builtin-open (pathlib not always better)
|
| 128 |
+
"PTH108", # os-unlink (pathlib not always better)
|
| 129 |
+
"RUF013", # implicit-optional (legacy code)
|
| 130 |
+
"TC002", # third-party import (numpy is required at runtime)
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
[tool.ruff.lint.per-file-ignores]
|
| 134 |
+
"__init__.py" = ["F401"] # unused imports in __init__ are intentional
|
| 135 |
+
|
| 136 |
+
[tool.ruff.lint.isort]
|
| 137 |
+
known-first-party = ["reachy_mini_home_assistant"]
|
| 138 |
+
|
| 139 |
+
# ============================================================================
|
| 140 |
+
# Mypy - Static type checker
|
| 141 |
+
# ============================================================================
|
| 142 |
+
[tool.mypy]
|
| 143 |
+
python_version = "3.12"
|
| 144 |
+
warn_return_any = false # Too noisy for mixed typed/untyped codebase
|
| 145 |
+
warn_unused_ignores = true
|
| 146 |
+
disallow_untyped_defs = false # Start lenient, can tighten later
|
| 147 |
+
check_untyped_defs = false # Too strict for initial setup
|
| 148 |
+
ignore_missing_imports = true # Many robot SDK libs lack type stubs
|
| 149 |
+
no_implicit_optional = false # Allow implicit Optional for now
|
| 150 |
+
# Disable some checks that are too strict for this codebase
|
| 151 |
+
disable_error_code = [
|
| 152 |
+
"union-attr", # Too many Optional accesses without None checks
|
| 153 |
+
"no-redef", # Class redefinitions for SDK compatibility
|
| 154 |
+
"attr-defined", # Some dynamic attributes from SDK
|
| 155 |
+
"assignment", # Variable type changes (common in Python)
|
| 156 |
+
"arg-type", # Argument type mismatches (often SDK issues)
|
| 157 |
+
"unused-ignore", # Type ignore comments from before config
|
| 158 |
+
"return-value", # Return type mismatches (often fine)
|
| 159 |
+
"no-untyped-def", # Missing type annotations (too strict initially)
|
| 160 |
+
"valid-type", # Type validity (some edge cases)
|
| 161 |
+
"has-type", # Cannot determine type
|
| 162 |
+
"call-arg", # Too few/many arguments
|
| 163 |
+
"import-untyped", # Missing stubs for third-party libs
|
| 164 |
+
"misc", # Miscellaneous errors
|
| 165 |
+
]
|
| 166 |
+
exclude = [
|
| 167 |
+
"reference/",
|
| 168 |
+
"tests/",
|
| 169 |
+
]
|
| 170 |
+
|
| 171 |
+
# Stricter checking for core modules (can enable gradually)
|
| 172 |
+
[[tool.mypy.overrides]]
|
| 173 |
+
module = [
|
| 174 |
+
"reachy_mini_home_assistant.core.*",
|
| 175 |
+
"reachy_mini_home_assistant.motion.smoothing",
|
| 176 |
+
"reachy_mini_home_assistant.motion.pose_composer",
|
| 177 |
+
]
|
| 178 |
+
disallow_untyped_defs = true
|
| 179 |
+
warn_unreachable = true
|
reachy_mini_home_assistant/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reachy Mini for Home Assistant
|
| 3 |
+
|
| 4 |
+
A deep integration app combining Reachy Mini robot with Home Assistant,
|
| 5 |
+
enabling voice control, smart home automation, and expressive robot interactions.
|
| 6 |
+
|
| 7 |
+
Key features:
|
| 8 |
+
- Local wake word detection (microWakeWord/openWakeWord)
|
| 9 |
+
- ESPHome protocol for seamless Home Assistant communication
|
| 10 |
+
- STT/TTS powered by Home Assistant voice pipeline
|
| 11 |
+
- Reachy Mini motion control with expressive animations
|
| 12 |
+
- Camera streaming and gesture detection
|
| 13 |
+
- Smart home entity control through natural voice commands
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from importlib.metadata import version
|
| 18 |
+
|
| 19 |
+
__version__ = version("reachy_mini_home_assistant")
|
| 20 |
+
except Exception:
|
| 21 |
+
__version__ = "0.0.0" # Fallback for development
|
| 22 |
+
__author__ = "Desmond Dong"
|
| 23 |
+
|
| 24 |
+
# Don't import main module here to avoid runpy warning
|
| 25 |
+
# The app is loaded via entry point: reachy_mini_home_assistant.main:ReachyMiniHaVoiceApp
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
"__version__",
|
| 29 |
+
]
|
reachy_mini_home_assistant/__main__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Main entry point for Reachy Mini for Home Assistant.
|
| 3 |
+
|
| 4 |
+
This module provides a command-line interface for running the voice assistant
|
| 5 |
+
without the ReachyMini App framework.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import asyncio
|
| 10 |
+
import logging
|
| 11 |
+
import threading
|
| 12 |
+
|
| 13 |
+
from .protocol.zeroconf import get_default_friendly_name
|
| 14 |
+
|
| 15 |
+
_LOGGER = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def main() -> None:
|
| 19 |
+
parser = argparse.ArgumentParser(description="Reachy Mini for Home Assistant")
|
| 20 |
+
parser.add_argument(
|
| 21 |
+
"--name",
|
| 22 |
+
default=get_default_friendly_name(),
|
| 23 |
+
help="Name of the voice assistant (default: auto-generated from MAC)",
|
| 24 |
+
)
|
| 25 |
+
parser.add_argument(
|
| 26 |
+
"--host",
|
| 27 |
+
default="0.0.0.0",
|
| 28 |
+
help="Address for ESPHome server (default: 0.0.0.0)",
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--port",
|
| 32 |
+
type=int,
|
| 33 |
+
default=6053,
|
| 34 |
+
help="Port for ESPHome server (default: 6053)",
|
| 35 |
+
)
|
| 36 |
+
parser.add_argument(
|
| 37 |
+
"--wake-model",
|
| 38 |
+
default="okay_nabu",
|
| 39 |
+
help="Id of active wake model (default: okay_nabu)",
|
| 40 |
+
)
|
| 41 |
+
parser.add_argument(
|
| 42 |
+
"--camera-port",
|
| 43 |
+
type=int,
|
| 44 |
+
default=8081,
|
| 45 |
+
help="Port for camera server (default: 8081)",
|
| 46 |
+
)
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"--no-camera",
|
| 49 |
+
action="store_true",
|
| 50 |
+
help="Disable camera server",
|
| 51 |
+
)
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--debug",
|
| 54 |
+
action="store_true",
|
| 55 |
+
help="Print DEBUG messages to console",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
args = parser.parse_args()
|
| 59 |
+
|
| 60 |
+
# Setup logging
|
| 61 |
+
logging.basicConfig(
|
| 62 |
+
level=logging.DEBUG if args.debug else logging.INFO,
|
| 63 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Initialize Reachy Mini (required)
|
| 67 |
+
from reachy_mini import ReachyMini
|
| 68 |
+
|
| 69 |
+
with ReachyMini() as reachy_mini:
|
| 70 |
+
_LOGGER.info("Reachy Mini connected")
|
| 71 |
+
|
| 72 |
+
# Import and create VoiceAssistantService
|
| 73 |
+
from .voice_assistant import VoiceAssistantService
|
| 74 |
+
|
| 75 |
+
service = VoiceAssistantService(
|
| 76 |
+
reachy_mini=reachy_mini,
|
| 77 |
+
name=args.name,
|
| 78 |
+
host=args.host,
|
| 79 |
+
port=args.port,
|
| 80 |
+
wake_model=args.wake_model,
|
| 81 |
+
camera_port=args.camera_port,
|
| 82 |
+
camera_enabled=not args.no_camera,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Create stop event for graceful shutdown
|
| 86 |
+
stop_event = threading.Event()
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
await service.start()
|
| 90 |
+
|
| 91 |
+
_LOGGER.info("=" * 50)
|
| 92 |
+
_LOGGER.info("Reachy Mini Voice Assistant Started")
|
| 93 |
+
_LOGGER.info("=" * 50)
|
| 94 |
+
_LOGGER.info("Name: %s", args.name)
|
| 95 |
+
_LOGGER.info("ESPHome Server: %s:%s", args.host, args.port)
|
| 96 |
+
_LOGGER.info("Camera Server: %s:%s", args.host, args.camera_port)
|
| 97 |
+
_LOGGER.info("Motion control: enabled")
|
| 98 |
+
_LOGGER.info("=" * 50)
|
| 99 |
+
_LOGGER.info("Add this device in Home Assistant:")
|
| 100 |
+
_LOGGER.info(" Settings -> Devices & Services -> Add Integration -> ESPHome")
|
| 101 |
+
_LOGGER.info(" Enter: <this-device-ip>:%s", args.port)
|
| 102 |
+
_LOGGER.info("=" * 50)
|
| 103 |
+
|
| 104 |
+
# Wait for stop signal
|
| 105 |
+
while not stop_event.is_set():
|
| 106 |
+
await asyncio.sleep(0.5)
|
| 107 |
+
|
| 108 |
+
except KeyboardInterrupt:
|
| 109 |
+
_LOGGER.info("Shutting down...")
|
| 110 |
+
finally:
|
| 111 |
+
await service.stop()
|
| 112 |
+
_LOGGER.info("Voice assistant stopped")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def run():
|
| 116 |
+
"""Entry point for the application."""
|
| 117 |
+
asyncio.run(main())
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
run()
|
reachy_mini_home_assistant/animations/animation_config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared loading and minimal validation for unified animation config."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AnimationConfigError(ValueError):
|
| 14 |
+
"""Raised when the unified animation configuration is structurally invalid."""
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
_REQUIRED_TOP_LEVEL_TYPES: dict[str, type] = {
|
| 18 |
+
"animations": dict,
|
| 19 |
+
"emotions": dict,
|
| 20 |
+
"settings": dict,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
_OPTIONAL_TOP_LEVEL_TYPES: dict[str, type] = {
|
| 24 |
+
"ha_event_behaviors": dict,
|
| 25 |
+
"emotion_keywords": dict,
|
| 26 |
+
"idle_random_actions": dict,
|
| 27 |
+
"idle_rest_pose": dict,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_animation_config(config_path: Path) -> dict[str, Any]:
|
| 32 |
+
"""Load and minimally validate the unified animation config file."""
|
| 33 |
+
if not config_path.exists():
|
| 34 |
+
raise AnimationConfigError(f"Animation config file not found: {config_path}")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
with open(config_path, encoding="utf-8") as f:
|
| 38 |
+
data = json.load(f)
|
| 39 |
+
except Exception as e:
|
| 40 |
+
raise AnimationConfigError(f"Failed to read animation config: {e}") from e
|
| 41 |
+
|
| 42 |
+
if not isinstance(data, dict):
|
| 43 |
+
raise AnimationConfigError("Animation config root must be an object")
|
| 44 |
+
|
| 45 |
+
for key, expected_type in _REQUIRED_TOP_LEVEL_TYPES.items():
|
| 46 |
+
value = data.get(key)
|
| 47 |
+
if not isinstance(value, expected_type):
|
| 48 |
+
raise AnimationConfigError(f"Animation config section '{key}' must be a {expected_type.__name__}")
|
| 49 |
+
|
| 50 |
+
for key, expected_type in _OPTIONAL_TOP_LEVEL_TYPES.items():
|
| 51 |
+
value = data.get(key)
|
| 52 |
+
if value is not None and not isinstance(value, expected_type):
|
| 53 |
+
raise AnimationConfigError(f"Animation config section '{key}' must be a {expected_type.__name__}")
|
| 54 |
+
|
| 55 |
+
_validate_ha_event_behaviors(data.get("ha_event_behaviors"))
|
| 56 |
+
_validate_emotion_keywords(data.get("emotion_keywords"))
|
| 57 |
+
_validate_idle_random_actions(data.get("idle_random_actions"))
|
| 58 |
+
|
| 59 |
+
return data
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_animation_config_section(config_path: Path, section_name: str) -> dict[str, Any]:
|
| 63 |
+
"""Load one validated section from the unified animation config."""
|
| 64 |
+
data = load_animation_config(config_path)
|
| 65 |
+
section = data.get(section_name)
|
| 66 |
+
if section is None:
|
| 67 |
+
return {}
|
| 68 |
+
if not isinstance(section, dict):
|
| 69 |
+
raise AnimationConfigError(f"Animation config section '{section_name}' must be a dict")
|
| 70 |
+
return section
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _validate_ha_event_behaviors(section: Any) -> None:
|
| 74 |
+
if section is None:
|
| 75 |
+
return
|
| 76 |
+
mappings = section.get("mappings", {})
|
| 77 |
+
if not isinstance(mappings, dict):
|
| 78 |
+
raise AnimationConfigError("ha_event_behaviors.mappings must be a dict")
|
| 79 |
+
settings = section.get("settings", {})
|
| 80 |
+
if not isinstance(settings, dict):
|
| 81 |
+
raise AnimationConfigError("ha_event_behaviors.settings must be a dict")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _validate_emotion_keywords(section: Any) -> None:
|
| 85 |
+
if section is None:
|
| 86 |
+
return
|
| 87 |
+
keywords = section.get("keywords", {})
|
| 88 |
+
if not isinstance(keywords, dict):
|
| 89 |
+
raise AnimationConfigError("emotion_keywords.keywords must be a dict")
|
| 90 |
+
settings = section.get("settings", {})
|
| 91 |
+
if not isinstance(settings, dict):
|
| 92 |
+
raise AnimationConfigError("emotion_keywords.settings must be a dict")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _validate_idle_random_actions(section: Any) -> None:
|
| 96 |
+
if section is None:
|
| 97 |
+
return
|
| 98 |
+
actions = section.get("actions", [])
|
| 99 |
+
if not isinstance(actions, list):
|
| 100 |
+
raise AnimationConfigError("idle_random_actions.actions must be a list")
|
reachy_mini_home_assistant/animations/conversation_animations.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
reachy_mini_home_assistant/audio/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio module for Reachy Mini.
|
| 2 |
+
|
| 3 |
+
This module handles all audio-related functionality:
|
| 4 |
+
- AudioPlayer: Audio playback with Sendspin support
|
| 5 |
+
- DOATracker: Direction of Arrival sound localization
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .audio_player import AudioPlayer
|
| 9 |
+
from .doa_tracker import DOAConfig, DOATracker
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"AudioPlayer",
|
| 13 |
+
"DOAConfig",
|
| 14 |
+
"DOATracker",
|
| 15 |
+
]
|
reachy_mini_home_assistant/audio/audio_player.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio player facade for Reachy Mini audio playback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import threading
|
| 7 |
+
from collections import deque
|
| 8 |
+
from typing import TYPE_CHECKING
|
| 9 |
+
|
| 10 |
+
from .audio_player_playback import AudioPlayerPlaybackMixin
|
| 11 |
+
from .audio_player_sendspin import AudioFormat, AudioPlayerSendspinMixin, ClientListener, SendspinClient
|
| 12 |
+
from .audio_player_shared import get_stable_client_id
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from collections.abc import Callable
|
| 16 |
+
|
| 17 |
+
from ..protocol.zeroconf import SendspinDiscovery
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AudioPlayer(AudioPlayerSendspinMixin, AudioPlayerPlaybackMixin):
|
| 21 |
+
"""Audio player using Reachy Mini's media system with automatic Sendspin support."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, reachy_mini=None, gstreamer_lock=None) -> None:
|
| 24 |
+
self.reachy_mini = reachy_mini
|
| 25 |
+
self._gstreamer_lock = gstreamer_lock if gstreamer_lock is not None else threading.Lock()
|
| 26 |
+
self.is_playing = False
|
| 27 |
+
self._playlist: list[str] = []
|
| 28 |
+
self._done_callback: Callable[[], None] | None = None
|
| 29 |
+
self._done_callback_lock = threading.Lock()
|
| 30 |
+
self._duck_volume: float = 0.5
|
| 31 |
+
self._unduck_volume: float = 1.0
|
| 32 |
+
self._current_volume: float = 1.0
|
| 33 |
+
self._stop_flag = threading.Event()
|
| 34 |
+
self._playback_thread: threading.Thread | None = None
|
| 35 |
+
self._sway_callback: Callable[[dict], None] | None = None
|
| 36 |
+
|
| 37 |
+
self._sendspin_client_id = get_stable_client_id()
|
| 38 |
+
self._sendspin_client: SendspinClient | None = None
|
| 39 |
+
self._sendspin_listener: ClientListener | None = None
|
| 40 |
+
self._sendspin_enabled = False
|
| 41 |
+
self._sendspin_url: str | None = None
|
| 42 |
+
self._sendspin_discovery: SendspinDiscovery | None = None
|
| 43 |
+
self._sendspin_unsubscribers: list[Callable] = []
|
| 44 |
+
self._sendspin_connect_lock: asyncio.Lock | None = None
|
| 45 |
+
self._sendspin_audio_format: AudioFormat | None = None
|
| 46 |
+
self._sendspin_playback_started = False
|
| 47 |
+
self._sendspin_stream_active = False
|
| 48 |
+
self._sendspin_paused = False
|
| 49 |
+
self._sendspin_remote_volume = 100
|
| 50 |
+
self._sendspin_muted = False
|
| 51 |
+
self._sendspin_queue = deque()
|
| 52 |
+
self._sendspin_queue_bytes = 0
|
| 53 |
+
self._sendspin_sway_queue = deque()
|
| 54 |
+
self._sendspin_queue_lock = threading.Lock()
|
| 55 |
+
self._sendspin_queue_event = threading.Event()
|
| 56 |
+
self._sendspin_queue_stop = threading.Event()
|
| 57 |
+
self._sendspin_queue_thread: threading.Thread | None = None
|
| 58 |
+
self._sendspin_sway_state: dict | None = None
|
| 59 |
+
self._logged_resample = False
|
| 60 |
+
self._last_sendspin_overflow_log = 0.0
|
| 61 |
+
self._http_host_override: str | None = None
|
| 62 |
+
|
| 63 |
+
def set_sway_callback(self, callback: Callable[[dict], None] | None) -> None:
|
| 64 |
+
self._sway_callback = callback
|
| 65 |
+
|
| 66 |
+
def set_reachy_mini(self, reachy_mini) -> None:
|
| 67 |
+
self.reachy_mini = reachy_mini
|
| 68 |
+
|
| 69 |
+
def set_http_host_override(self, host: str | None) -> None:
|
| 70 |
+
self._http_host_override = host
|
| 71 |
+
|
| 72 |
+
def __del__(self) -> None:
|
| 73 |
+
try:
|
| 74 |
+
self._remove_sendspin_listeners()
|
| 75 |
+
self._clear_sendspin_queue()
|
| 76 |
+
self._stop_sendspin_worker()
|
| 77 |
+
self._sendspin_client = None
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
reachy_mini_home_assistant/audio/audio_player_local.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from .audio_player_shared import MOVEMENT_LATENCY_S, STREAM_FETCH_CHUNK_SIZE, _LOGGER, sniff_audio_content_type
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class AudioPlayerLocalMixin:
|
| 9 |
+
def _play_cached_audio(self, audio_bytes: bytes | bytearray, content_type: str, source_url: str = "") -> bool:
|
| 10 |
+
if not audio_bytes:
|
| 11 |
+
return False
|
| 12 |
+
audio_data = bytes(audio_bytes)
|
| 13 |
+
if (not content_type) or (content_type == "application/octet-stream"):
|
| 14 |
+
sniffed = sniff_audio_content_type(audio_data[: min(len(audio_data), 64)])
|
| 15 |
+
if sniffed:
|
| 16 |
+
content_type = sniffed
|
| 17 |
+
mem_iter = (
|
| 18 |
+
audio_data[i : i + STREAM_FETCH_CHUNK_SIZE] for i in range(0, len(audio_data), STREAM_FETCH_CHUNK_SIZE)
|
| 19 |
+
)
|
| 20 |
+
adapted_response = self._iterator_response_adapter(mem_iter)
|
| 21 |
+
if self._is_pcm_content_type(content_type):
|
| 22 |
+
return self._stream_pcm_response(adapted_response, content_type)
|
| 23 |
+
if self._stream_decoded_response(adapted_response, source_url or "memory-cache", content_type):
|
| 24 |
+
return True
|
| 25 |
+
return self._play_cached_audio_via_tempfile(audio_data, content_type, source_url)
|
| 26 |
+
|
| 27 |
+
def _play_cached_audio_via_tempfile(self, audio_data: bytes, content_type: str, source_url: str) -> bool:
|
| 28 |
+
import os
|
| 29 |
+
import tempfile
|
| 30 |
+
|
| 31 |
+
temp_path = None
|
| 32 |
+
try:
|
| 33 |
+
with tempfile.NamedTemporaryFile(
|
| 34 |
+
delete=False, suffix=self._guess_audio_suffix(content_type, source_url)
|
| 35 |
+
) as tmp:
|
| 36 |
+
tmp.write(audio_data)
|
| 37 |
+
temp_path = tmp.name
|
| 38 |
+
self._play_local_file(temp_path)
|
| 39 |
+
return True
|
| 40 |
+
except Exception as e:
|
| 41 |
+
_LOGGER.debug("Tempfile fallback playback failed: %s", e)
|
| 42 |
+
return False
|
| 43 |
+
finally:
|
| 44 |
+
if temp_path:
|
| 45 |
+
try:
|
| 46 |
+
os.unlink(temp_path)
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
def _guess_audio_suffix(self, content_type: str, source_url: str) -> str:
|
| 51 |
+
from urllib.parse import urlparse
|
| 52 |
+
|
| 53 |
+
ct = (content_type or "").split(";", 1)[0].strip().lower()
|
| 54 |
+
mapping = {
|
| 55 |
+
"audio/mpeg": ".mp3",
|
| 56 |
+
"audio/mp3": ".mp3",
|
| 57 |
+
"audio/aac": ".aac",
|
| 58 |
+
"audio/mp4": ".m4a",
|
| 59 |
+
"audio/ogg": ".ogg",
|
| 60 |
+
"application/ogg": ".ogg",
|
| 61 |
+
"audio/opus": ".opus",
|
| 62 |
+
"audio/webm": ".webm",
|
| 63 |
+
"audio/wav": ".wav",
|
| 64 |
+
"audio/wave": ".wav",
|
| 65 |
+
"audio/x-wav": ".wav",
|
| 66 |
+
"audio/flac": ".flac",
|
| 67 |
+
"audio/x-flac": ".flac",
|
| 68 |
+
}
|
| 69 |
+
if ct in mapping:
|
| 70 |
+
return mapping[ct]
|
| 71 |
+
try:
|
| 72 |
+
path = urlparse(source_url).path
|
| 73 |
+
if "." in path:
|
| 74 |
+
suffix = "." + path.rsplit(".", 1)[1]
|
| 75 |
+
if len(suffix) <= 8:
|
| 76 |
+
return suffix
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
return ".bin"
|
| 80 |
+
|
| 81 |
+
def _play_local_file(self, file_path: str) -> None:
|
| 82 |
+
try:
|
| 83 |
+
duration: float | None = None
|
| 84 |
+
sway_frames: list[dict] = []
|
| 85 |
+
try:
|
| 86 |
+
import soundfile as sf
|
| 87 |
+
|
| 88 |
+
info = sf.info(file_path)
|
| 89 |
+
if info.samplerate > 0 and info.frames > 0:
|
| 90 |
+
duration = float(info.frames) / float(info.samplerate)
|
| 91 |
+
except Exception:
|
| 92 |
+
duration = None
|
| 93 |
+
if self._sway_callback is not None:
|
| 94 |
+
try:
|
| 95 |
+
import soundfile as sf
|
| 96 |
+
|
| 97 |
+
data, sample_rate = sf.read(file_path)
|
| 98 |
+
if duration is None and sample_rate > 0:
|
| 99 |
+
duration = len(data) / sample_rate
|
| 100 |
+
sway = self._new_sway_analyzer()
|
| 101 |
+
sway_frames = self._compute_sway_frames(sway, data, sample_rate)
|
| 102 |
+
except Exception:
|
| 103 |
+
sway_frames = []
|
| 104 |
+
self.reachy_mini.media.play_sound(file_path)
|
| 105 |
+
start_time = time.monotonic()
|
| 106 |
+
frame_duration = 0.05
|
| 107 |
+
frame_idx = 0
|
| 108 |
+
has_duration = (duration is not None) and (duration > 0)
|
| 109 |
+
duration_s = duration if has_duration and duration is not None else 0.0
|
| 110 |
+
max_duration = (duration_s * 1.5) if has_duration else 60.0
|
| 111 |
+
playback_timeout = start_time + max_duration
|
| 112 |
+
sway_base_ts = start_time + MOVEMENT_LATENCY_S
|
| 113 |
+
while True:
|
| 114 |
+
now = time.monotonic()
|
| 115 |
+
if now > playback_timeout:
|
| 116 |
+
_LOGGER.warning("Audio playback timeout (%.1fs), stopping", max_duration)
|
| 117 |
+
self.reachy_mini.media.stop_playing()
|
| 118 |
+
break
|
| 119 |
+
if self._stop_flag.is_set():
|
| 120 |
+
self.reachy_mini.media.stop_playing()
|
| 121 |
+
break
|
| 122 |
+
if has_duration:
|
| 123 |
+
if (now - start_time) >= duration_s:
|
| 124 |
+
break
|
| 125 |
+
else:
|
| 126 |
+
try:
|
| 127 |
+
if not bool(self.reachy_mini.media.is_playing()):
|
| 128 |
+
break
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
if self._sway_callback and frame_idx < len(sway_frames):
|
| 132 |
+
target_frame = frame_idx
|
| 133 |
+
while target_frame < len(sway_frames) and now >= (sway_base_ts + target_frame * frame_duration):
|
| 134 |
+
target_frame += 1
|
| 135 |
+
while frame_idx < target_frame and frame_idx < len(sway_frames):
|
| 136 |
+
self._sway_callback(sway_frames[frame_idx])
|
| 137 |
+
frame_idx += 1
|
| 138 |
+
next_sleep = 0.02
|
| 139 |
+
if self._sway_callback and frame_idx < len(sway_frames):
|
| 140 |
+
next_sway_ts = sway_base_ts + frame_idx * frame_duration
|
| 141 |
+
next_sleep = min(next_sleep, max(0.0, next_sway_ts - now))
|
| 142 |
+
time.sleep(next_sleep)
|
| 143 |
+
finally:
|
| 144 |
+
self._reset_sway_output()
|
reachy_mini_home_assistant/audio/audio_player_playback.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
from typing import TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from .audio_player_local import AudioPlayerLocalMixin
|
| 9 |
+
from .audio_player_shared import STREAM_FETCH_CHUNK_SIZE, _LOGGER, rewrite_local_service_url, sniff_audio_content_type
|
| 10 |
+
from .audio_player_stream_decoded import AudioPlayerStreamDecodedMixin
|
| 11 |
+
from .audio_player_stream_pcm import AudioPlayerStreamPCMMixin
|
| 12 |
+
from .audio_player_wobble import AudioPlayerWobbleMixin
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from collections.abc import Callable
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AudioPlayerPlaybackMixin(
|
| 19 |
+
AudioPlayerLocalMixin,
|
| 20 |
+
AudioPlayerStreamDecodedMixin,
|
| 21 |
+
AudioPlayerStreamPCMMixin,
|
| 22 |
+
AudioPlayerWobbleMixin,
|
| 23 |
+
):
|
| 24 |
+
def play(
|
| 25 |
+
self, url: str | list[str], done_callback: Callable[[], None] | None = None, stop_first: bool = True
|
| 26 |
+
) -> None:
|
| 27 |
+
if stop_first:
|
| 28 |
+
self.stop()
|
| 29 |
+
self._playlist = [url] if isinstance(url, str) else list(url)
|
| 30 |
+
self._done_callback = done_callback
|
| 31 |
+
self._stop_flag.clear()
|
| 32 |
+
if self._playback_thread and self._playback_thread.is_alive():
|
| 33 |
+
_LOGGER.warning("Previous playback still active, stopping it")
|
| 34 |
+
self.stop()
|
| 35 |
+
self._play_next()
|
| 36 |
+
|
| 37 |
+
def _play_next(self) -> None:
|
| 38 |
+
if not self._playlist or self._stop_flag.is_set():
|
| 39 |
+
self._on_playback_finished()
|
| 40 |
+
return
|
| 41 |
+
next_url = self._playlist.pop(0)
|
| 42 |
+
_LOGGER.debug("Playing %s", next_url)
|
| 43 |
+
self.is_playing = True
|
| 44 |
+
self._playback_thread = threading.Thread(target=self._play_file, args=(next_url,), daemon=True)
|
| 45 |
+
self._playback_thread.start()
|
| 46 |
+
|
| 47 |
+
def _play_file(self, file_path: str) -> None:
|
| 48 |
+
try:
|
| 49 |
+
if file_path.startswith(("http://", "https://")):
|
| 50 |
+
source_url = rewrite_local_service_url(file_path, getattr(self, "_http_host_override", None))
|
| 51 |
+
streamed = False
|
| 52 |
+
cached_audio = bytearray()
|
| 53 |
+
content_type = ""
|
| 54 |
+
try:
|
| 55 |
+
request_kwargs = {"stream": True, "timeout": (5.0, 30.0)}
|
| 56 |
+
try:
|
| 57 |
+
response_ctx = requests.get(source_url, **request_kwargs)
|
| 58 |
+
except requests.exceptions.SSLError:
|
| 59 |
+
request_kwargs["verify"] = False
|
| 60 |
+
response_ctx = requests.get(source_url, **request_kwargs)
|
| 61 |
+
|
| 62 |
+
with response_ctx as response:
|
| 63 |
+
response.raise_for_status()
|
| 64 |
+
content_type = (response.headers.get("Content-Type") or "").lower()
|
| 65 |
+
stream_iter = response.iter_content(chunk_size=STREAM_FETCH_CHUNK_SIZE)
|
| 66 |
+
|
| 67 |
+
first_chunk = b""
|
| 68 |
+
for chunk in stream_iter:
|
| 69 |
+
if chunk:
|
| 70 |
+
first_chunk = chunk
|
| 71 |
+
cached_audio.extend(chunk)
|
| 72 |
+
break
|
| 73 |
+
|
| 74 |
+
if (not content_type) or (content_type == "application/octet-stream"):
|
| 75 |
+
sniffed = sniff_audio_content_type(first_chunk)
|
| 76 |
+
if sniffed:
|
| 77 |
+
content_type = sniffed
|
| 78 |
+
|
| 79 |
+
def caching_iter_content(chunk_size: int = STREAM_FETCH_CHUNK_SIZE):
|
| 80 |
+
del chunk_size
|
| 81 |
+
if first_chunk:
|
| 82 |
+
yield first_chunk
|
| 83 |
+
for chunk in stream_iter:
|
| 84 |
+
if chunk:
|
| 85 |
+
if chunk is not first_chunk:
|
| 86 |
+
cached_audio.extend(chunk)
|
| 87 |
+
yield chunk
|
| 88 |
+
|
| 89 |
+
adapted_response = self._iterator_response_adapter(caching_iter_content())
|
| 90 |
+
if self._is_pcm_content_type(content_type):
|
| 91 |
+
_LOGGER.info("TTS playback mode: streaming_pcm")
|
| 92 |
+
streamed = self._stream_pcm_response(adapted_response, content_type)
|
| 93 |
+
else:
|
| 94 |
+
_LOGGER.info("TTS playback mode: streaming_decoded")
|
| 95 |
+
streamed = self._stream_decoded_response(adapted_response, source_url, content_type)
|
| 96 |
+
if not streamed:
|
| 97 |
+
for chunk in stream_iter:
|
| 98 |
+
if chunk:
|
| 99 |
+
cached_audio.extend(chunk)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
_LOGGER.debug("Streaming TTS failed, fallback to memory playback: %s", e)
|
| 102 |
+
if streamed:
|
| 103 |
+
return
|
| 104 |
+
_LOGGER.info("TTS playback mode: fallback_memory")
|
| 105 |
+
played = self._play_cached_audio(cached_audio, content_type, source_url=source_url)
|
| 106 |
+
if played:
|
| 107 |
+
return
|
| 108 |
+
_LOGGER.error("Failed to play cached TTS audio from memory")
|
| 109 |
+
return
|
| 110 |
+
if self._stop_flag.is_set():
|
| 111 |
+
return
|
| 112 |
+
self._play_local_file(file_path)
|
| 113 |
+
except Exception as e:
|
| 114 |
+
_LOGGER.error("Error playing audio: %s", e)
|
| 115 |
+
finally:
|
| 116 |
+
self.is_playing = False
|
| 117 |
+
if self._playlist and not self._stop_flag.is_set():
|
| 118 |
+
self._play_next()
|
| 119 |
+
else:
|
| 120 |
+
self._on_playback_finished()
|
| 121 |
+
|
| 122 |
+
@staticmethod
|
| 123 |
+
def _iterator_response_adapter(iterator):
|
| 124 |
+
class _ResponseAdapter:
|
| 125 |
+
def __init__(self, iter_obj) -> None:
|
| 126 |
+
self._iter_obj = iter_obj
|
| 127 |
+
|
| 128 |
+
def iter_content(self, chunk_size: int = 8192):
|
| 129 |
+
del chunk_size
|
| 130 |
+
return self._iter_obj
|
| 131 |
+
|
| 132 |
+
return _ResponseAdapter(iterator)
|
| 133 |
+
|
| 134 |
+
def _on_playback_finished(self) -> None:
|
| 135 |
+
self.is_playing = False
|
| 136 |
+
todo_callback: Callable[[], None] | None = None
|
| 137 |
+
with self._done_callback_lock:
|
| 138 |
+
if self._done_callback:
|
| 139 |
+
todo_callback = self._done_callback
|
| 140 |
+
self._done_callback = None
|
| 141 |
+
if todo_callback:
|
| 142 |
+
try:
|
| 143 |
+
todo_callback()
|
| 144 |
+
except Exception:
|
| 145 |
+
_LOGGER.exception("Unexpected error running done callback")
|
| 146 |
+
|
| 147 |
+
def pause(self) -> None:
|
| 148 |
+
self._stop_flag.set()
|
| 149 |
+
try:
|
| 150 |
+
self.reachy_mini.media.stop_playing()
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
self.is_playing = False
|
| 154 |
+
|
| 155 |
+
def resume_playback(self) -> None:
|
| 156 |
+
self._stop_flag.clear()
|
| 157 |
+
if self._playlist:
|
| 158 |
+
self._play_next()
|
| 159 |
+
|
| 160 |
+
def stop(self) -> None:
|
| 161 |
+
self._stop_flag.set()
|
| 162 |
+
try:
|
| 163 |
+
self.reachy_mini.media.stop_playing()
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
if self._playback_thread and self._playback_thread.is_alive():
|
| 167 |
+
try:
|
| 168 |
+
self._playback_thread.join(timeout=2.0)
|
| 169 |
+
if self._playback_thread.is_alive():
|
| 170 |
+
_LOGGER.warning("Playback thread did not stop in time")
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
+
self._playback_thread = None
|
| 174 |
+
self._playlist.clear()
|
| 175 |
+
self.is_playing = False
|
| 176 |
+
|
| 177 |
+
def duck(self) -> None:
|
| 178 |
+
self._current_volume = self._duck_volume
|
| 179 |
+
|
| 180 |
+
def unduck(self) -> None:
|
| 181 |
+
self._current_volume = self._unduck_volume
|
| 182 |
+
|
| 183 |
+
def set_volume(self, volume: int) -> None:
|
| 184 |
+
volume = max(0, min(100, volume))
|
| 185 |
+
self._unduck_volume = volume / 100.0
|
| 186 |
+
self._duck_volume = self._unduck_volume / 2
|
| 187 |
+
self._current_volume = self._unduck_volume
|
| 188 |
+
|
| 189 |
+
def suspend(self) -> None:
|
| 190 |
+
_LOGGER.info("Suspending AudioPlayer resources...")
|
| 191 |
+
self.stop()
|
| 192 |
+
self._sway_callback = None
|
| 193 |
+
_LOGGER.info("AudioPlayer resources suspended")
|
| 194 |
+
|
| 195 |
+
def resume(self) -> None:
|
| 196 |
+
_LOGGER.info("Resuming AudioPlayer resources...")
|
| 197 |
+
self._stop_flag.clear()
|
| 198 |
+
_LOGGER.info("AudioPlayer resources resumed")
|
reachy_mini_home_assistant/audio/audio_player_sendspin.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import TYPE_CHECKING
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .audio_player_shared import (
|
| 12 |
+
AudioPlayerSwayMixin,
|
| 13 |
+
MOVEMENT_LATENCY_S,
|
| 14 |
+
SENDSPIN_HIGH_WATERMARK_BYTES,
|
| 15 |
+
SENDSPIN_LATE_DROP_GRACE_US,
|
| 16 |
+
SENDSPIN_LOCAL_BUFFER_CAPACITY_BYTES,
|
| 17 |
+
SENDSPIN_SCHEDULE_AHEAD_LIMIT_US,
|
| 18 |
+
SWAY_FRAME_DT_S,
|
| 19 |
+
_LOGGER,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
if TYPE_CHECKING:
|
| 23 |
+
from aiosendspin.models.core import StreamStartMessage
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from aiosendspin.client import SendspinClient
|
| 27 |
+
from aiosendspin.client.client import AudioFormat, PCMFormat
|
| 28 |
+
from aiosendspin.models.core import DeviceInfo
|
| 29 |
+
from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat
|
| 30 |
+
from aiosendspin.models.types import AudioCodec, PlayerCommand, Roles
|
| 31 |
+
|
| 32 |
+
SENDSPIN_AVAILABLE = True
|
| 33 |
+
except Exception as e:
|
| 34 |
+
SENDSPIN_AVAILABLE = False
|
| 35 |
+
_LOGGER.warning("Sendspin unavailable, disabling integration: %s", e)
|
| 36 |
+
PCMFormat = None # type: ignore[assignment]
|
| 37 |
+
AudioFormat = None # type: ignore[assignment]
|
| 38 |
+
SendspinClient = None # type: ignore[assignment]
|
| 39 |
+
ClientHelloPlayerSupport = None # type: ignore[assignment]
|
| 40 |
+
DeviceInfo = None # type: ignore[assignment]
|
| 41 |
+
SupportedAudioFormat = None # type: ignore[assignment]
|
| 42 |
+
AudioCodec = None # type: ignore[assignment]
|
| 43 |
+
PlayerCommand = None # type: ignore[assignment]
|
| 44 |
+
Roles = None # type: ignore[assignment]
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
from aiosendspin.client.listener import DEFAULT_PORT as SENDSPIN_DEFAULT_PORT
|
| 48 |
+
from aiosendspin.client.listener import ClientListener
|
| 49 |
+
except Exception:
|
| 50 |
+
ClientListener = None # type: ignore[assignment]
|
| 51 |
+
SENDSPIN_DEFAULT_PORT = 8928 # type: ignore[assignment]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass(slots=True)
|
| 55 |
+
class _QueuedSendspinChunk:
|
| 56 |
+
play_time_us: int
|
| 57 |
+
audio_float: np.ndarray
|
| 58 |
+
byte_count: int
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass(slots=True)
|
| 62 |
+
class _QueuedSendspinSwayFrame:
|
| 63 |
+
target_time_us: int
|
| 64 |
+
sway: dict[str, float]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class AudioPlayerSendspinMixin(AudioPlayerSwayMixin):
|
| 68 |
+
@property
|
| 69 |
+
def sendspin_available(self) -> bool:
|
| 70 |
+
return SENDSPIN_AVAILABLE
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def sendspin_enabled(self) -> bool:
|
| 74 |
+
return self._sendspin_enabled and self._sendspin_client is not None
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def sendspin_url(self) -> str | None:
|
| 78 |
+
return self._sendspin_url
|
| 79 |
+
|
| 80 |
+
def _get_sendspin_connect_lock(self) -> asyncio.Lock:
|
| 81 |
+
if self._sendspin_connect_lock is None:
|
| 82 |
+
self._sendspin_connect_lock = asyncio.Lock()
|
| 83 |
+
return self._sendspin_connect_lock
|
| 84 |
+
|
| 85 |
+
def _get_sendspin_effective_volume(self) -> float:
|
| 86 |
+
if self._sendspin_muted:
|
| 87 |
+
return 0.0
|
| 88 |
+
return self._current_volume * (self._sendspin_remote_volume / 100.0)
|
| 89 |
+
|
| 90 |
+
def _ensure_sendspin_worker(self) -> None:
|
| 91 |
+
if self._sendspin_queue_thread is not None and self._sendspin_queue_thread.is_alive():
|
| 92 |
+
return
|
| 93 |
+
self._sendspin_queue_stop.clear()
|
| 94 |
+
self._sendspin_queue_event.clear()
|
| 95 |
+
self._sendspin_queue_thread = threading.Thread(
|
| 96 |
+
target=self._sendspin_worker_loop, name="sendspin-playback", daemon=True
|
| 97 |
+
)
|
| 98 |
+
self._sendspin_queue_thread.start()
|
| 99 |
+
|
| 100 |
+
def _stop_sendspin_worker(self) -> None:
|
| 101 |
+
self._sendspin_queue_stop.set()
|
| 102 |
+
self._sendspin_queue_event.set()
|
| 103 |
+
if self._sendspin_queue_thread is not None:
|
| 104 |
+
try:
|
| 105 |
+
self._sendspin_queue_thread.join(timeout=1.0)
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
self._sendspin_queue_thread = None
|
| 109 |
+
self._sendspin_queue_stop.clear()
|
| 110 |
+
self._sendspin_queue_event.clear()
|
| 111 |
+
|
| 112 |
+
def _sendspin_worker_loop(self) -> None:
|
| 113 |
+
while not self._sendspin_queue_stop.is_set():
|
| 114 |
+
if self._sendspin_paused:
|
| 115 |
+
self._sendspin_queue_event.wait(timeout=0.05)
|
| 116 |
+
self._sendspin_queue_event.clear()
|
| 117 |
+
continue
|
| 118 |
+
with self._sendspin_queue_lock:
|
| 119 |
+
chunk = self._sendspin_queue[0] if self._sendspin_queue else None
|
| 120 |
+
sway_frame = self._sendspin_sway_queue[0] if self._sendspin_sway_queue else None
|
| 121 |
+
if chunk is None and sway_frame is None:
|
| 122 |
+
self._sendspin_queue_event.wait(timeout=0.1)
|
| 123 |
+
self._sendspin_queue_event.clear()
|
| 124 |
+
continue
|
| 125 |
+
now_us = time.monotonic_ns() // 1000
|
| 126 |
+
next_audio_us = chunk.play_time_us if chunk is not None else None
|
| 127 |
+
next_sway_us = sway_frame.target_time_us if sway_frame is not None else None
|
| 128 |
+
next_event_us = min(ts for ts in (next_audio_us, next_sway_us) if ts is not None)
|
| 129 |
+
delay_us = next_event_us - now_us
|
| 130 |
+
if delay_us > 2_000:
|
| 131 |
+
self._sendspin_queue_event.wait(timeout=min(delay_us / 1_000_000.0, 0.05))
|
| 132 |
+
self._sendspin_queue_event.clear()
|
| 133 |
+
continue
|
| 134 |
+
handle_sway = False
|
| 135 |
+
with self._sendspin_queue_lock:
|
| 136 |
+
chunk = self._sendspin_queue[0] if self._sendspin_queue else None
|
| 137 |
+
sway_frame = self._sendspin_sway_queue[0] if self._sendspin_sway_queue else None
|
| 138 |
+
now_us = time.monotonic_ns() // 1000
|
| 139 |
+
next_audio_us = chunk.play_time_us if chunk is not None else None
|
| 140 |
+
next_sway_us = sway_frame.target_time_us if sway_frame is not None else None
|
| 141 |
+
if next_audio_us is None and next_sway_us is None:
|
| 142 |
+
continue
|
| 143 |
+
if next_audio_us is None:
|
| 144 |
+
handle_sway = True
|
| 145 |
+
elif next_sway_us is None:
|
| 146 |
+
handle_sway = False
|
| 147 |
+
else:
|
| 148 |
+
handle_sway = next_sway_us < next_audio_us
|
| 149 |
+
if handle_sway:
|
| 150 |
+
sway_frame = self._sendspin_sway_queue.popleft()
|
| 151 |
+
else:
|
| 152 |
+
chunk = self._sendspin_queue.popleft()
|
| 153 |
+
self._sendspin_queue_bytes = max(0, self._sendspin_queue_bytes - chunk.byte_count)
|
| 154 |
+
if handle_sway:
|
| 155 |
+
self._apply_sendspin_sway_frame(sway_frame)
|
| 156 |
+
continue
|
| 157 |
+
late_by_us = now_us - chunk.play_time_us
|
| 158 |
+
if late_by_us > SENDSPIN_LATE_DROP_GRACE_US:
|
| 159 |
+
_LOGGER.debug("Dropping late Sendspin chunk (%d ms late)", late_by_us // 1000)
|
| 160 |
+
continue
|
| 161 |
+
self._push_sendspin_audio_sample(chunk.audio_float)
|
| 162 |
+
|
| 163 |
+
def _apply_sendspin_sway_frame(self, sway_frame: _QueuedSendspinSwayFrame) -> None:
|
| 164 |
+
if self._sway_callback is None or self._sendspin_paused:
|
| 165 |
+
return
|
| 166 |
+
try:
|
| 167 |
+
self._sway_callback(sway_frame.sway)
|
| 168 |
+
except Exception:
|
| 169 |
+
_LOGGER.debug("Failed to apply Sendspin sway frame", exc_info=True)
|
| 170 |
+
|
| 171 |
+
def _push_sendspin_audio_sample(self, audio_float: np.ndarray) -> None:
|
| 172 |
+
if self.reachy_mini is None:
|
| 173 |
+
return
|
| 174 |
+
if not self._sendspin_playback_started:
|
| 175 |
+
try:
|
| 176 |
+
self.reachy_mini.media.start_playing()
|
| 177 |
+
self._sendspin_playback_started = True
|
| 178 |
+
except Exception:
|
| 179 |
+
_LOGGER.exception("Failed to start media playback for Sendspin")
|
| 180 |
+
return
|
| 181 |
+
acquired = self._gstreamer_lock.acquire(timeout=0.05)
|
| 182 |
+
if not acquired:
|
| 183 |
+
_LOGGER.debug("GStreamer lock busy, dropping due Sendspin chunk")
|
| 184 |
+
return
|
| 185 |
+
try:
|
| 186 |
+
self.reachy_mini.media.push_audio_sample(audio_float)
|
| 187 |
+
except Exception:
|
| 188 |
+
_LOGGER.exception("Failed to push Sendspin audio chunk")
|
| 189 |
+
finally:
|
| 190 |
+
self._gstreamer_lock.release()
|
| 191 |
+
|
| 192 |
+
def _stop_sendspin_output(self) -> None:
|
| 193 |
+
if self.reachy_mini is None:
|
| 194 |
+
return
|
| 195 |
+
try:
|
| 196 |
+
self.reachy_mini.media.audio.clear_output_buffer()
|
| 197 |
+
except Exception:
|
| 198 |
+
_LOGGER.debug("Failed to clear output buffer", exc_info=True)
|
| 199 |
+
if self._sendspin_playback_started:
|
| 200 |
+
try:
|
| 201 |
+
self.reachy_mini.media.stop_playing()
|
| 202 |
+
except Exception:
|
| 203 |
+
_LOGGER.debug("Failed to stop Sendspin playback", exc_info=True)
|
| 204 |
+
self._sendspin_playback_started = False
|
| 205 |
+
|
| 206 |
+
def _clear_sendspin_queue(self) -> None:
|
| 207 |
+
with self._sendspin_queue_lock:
|
| 208 |
+
self._sendspin_queue.clear()
|
| 209 |
+
self._sendspin_queue_bytes = 0
|
| 210 |
+
self._sendspin_sway_queue.clear()
|
| 211 |
+
self._sendspin_queue_event.set()
|
| 212 |
+
|
| 213 |
+
def _reset_sendspin_sway_state(self, *, reset_output: bool) -> None:
|
| 214 |
+
self._sendspin_sway_state = None
|
| 215 |
+
if reset_output:
|
| 216 |
+
self._reset_sway_output()
|
| 217 |
+
|
| 218 |
+
def _reset_sendspin_stream_state(self, *, stop_output: bool) -> None:
|
| 219 |
+
self._clear_sendspin_queue()
|
| 220 |
+
self._reset_sendspin_sway_state(reset_output=True)
|
| 221 |
+
self._sendspin_audio_format = None
|
| 222 |
+
self._logged_resample = False
|
| 223 |
+
if stop_output:
|
| 224 |
+
self._stop_sendspin_output()
|
| 225 |
+
|
| 226 |
+
def _queue_sendspin_audio(self, play_time_us: int, audio_float: np.ndarray, byte_count: int) -> None:
|
| 227 |
+
with self._sendspin_queue_lock:
|
| 228 |
+
self._sendspin_queue.append(_QueuedSendspinChunk(play_time_us, audio_float, byte_count))
|
| 229 |
+
self._sendspin_queue_bytes += byte_count
|
| 230 |
+
while self._sendspin_queue_bytes > SENDSPIN_LOCAL_BUFFER_CAPACITY_BYTES and self._sendspin_queue:
|
| 231 |
+
dropped = self._sendspin_queue.popleft()
|
| 232 |
+
self._sendspin_queue_bytes = max(0, self._sendspin_queue_bytes - dropped.byte_count)
|
| 233 |
+
now = time.monotonic()
|
| 234 |
+
if now - getattr(self, "_last_sendspin_overflow_log", 0.0) >= 1.0:
|
| 235 |
+
_LOGGER.warning("Sendspin buffer overflow, dropping oldest queued audio")
|
| 236 |
+
self._last_sendspin_overflow_log = now
|
| 237 |
+
self._sendspin_queue_event.set()
|
| 238 |
+
|
| 239 |
+
def _should_backpressure_sendspin_chunk(self, play_time_us: int, byte_count: int) -> bool:
|
| 240 |
+
with self._sendspin_queue_lock:
|
| 241 |
+
queued_bytes = self._sendspin_queue_bytes
|
| 242 |
+
if queued_bytes + byte_count < SENDSPIN_HIGH_WATERMARK_BYTES:
|
| 243 |
+
return False
|
| 244 |
+
|
| 245 |
+
now_us = time.monotonic_ns() // 1000
|
| 246 |
+
queued_ahead_us = max(0, play_time_us - now_us)
|
| 247 |
+
if queued_ahead_us < 500_000:
|
| 248 |
+
return False
|
| 249 |
+
|
| 250 |
+
now = time.monotonic()
|
| 251 |
+
if now - getattr(self, "_last_sendspin_overflow_log", 0.0) >= 1.0:
|
| 252 |
+
_LOGGER.warning(
|
| 253 |
+
"Sendspin backpressure active, skipping queued audio (queued=%d bytes, ahead=%d ms)",
|
| 254 |
+
queued_bytes,
|
| 255 |
+
queued_ahead_us // 1000,
|
| 256 |
+
)
|
| 257 |
+
self._last_sendspin_overflow_log = now
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
def _get_sendspin_sway_state(self) -> dict | None:
|
| 261 |
+
if self._sway_callback is None:
|
| 262 |
+
return None
|
| 263 |
+
if self._sendspin_sway_state is None:
|
| 264 |
+
analyzer = self._new_sway_analyzer()
|
| 265 |
+
if analyzer is None:
|
| 266 |
+
_LOGGER.debug("Failed to initialize Sendspin sway analyzer")
|
| 267 |
+
self._sendspin_sway_state = None
|
| 268 |
+
else:
|
| 269 |
+
self._sendspin_sway_state = {"sway": analyzer}
|
| 270 |
+
return self._sendspin_sway_state
|
| 271 |
+
|
| 272 |
+
def _queue_sendspin_sway(self, play_time_us: int, pcm: np.ndarray, sample_rate: int) -> None:
|
| 273 |
+
ctx = self._get_sendspin_sway_state()
|
| 274 |
+
if ctx is None:
|
| 275 |
+
return
|
| 276 |
+
try:
|
| 277 |
+
results = self._compute_sway_frames(ctx["sway"], pcm, sample_rate)
|
| 278 |
+
if not results:
|
| 279 |
+
return
|
| 280 |
+
latency_us = int(MOVEMENT_LATENCY_S * 1_000_000)
|
| 281 |
+
hop_us = int(SWAY_FRAME_DT_S * 1_000_000)
|
| 282 |
+
with self._sendspin_queue_lock:
|
| 283 |
+
for idx, item in enumerate(results):
|
| 284 |
+
self._sendspin_sway_queue.append(
|
| 285 |
+
_QueuedSendspinSwayFrame(target_time_us=play_time_us + latency_us + idx * hop_us, sway=item)
|
| 286 |
+
)
|
| 287 |
+
except Exception:
|
| 288 |
+
_LOGGER.debug("Failed to queue Sendspin sway frames", exc_info=True)
|
| 289 |
+
self._sendspin_queue_event.set()
|
| 290 |
+
|
| 291 |
+
def _decode_pcm_bytes(self, audio_data: bytes, pcm_format: PCMFormat) -> np.ndarray:
|
| 292 |
+
if pcm_format.bit_depth == 16:
|
| 293 |
+
audio_int = np.frombuffer(audio_data, dtype="<i2")
|
| 294 |
+
audio_float = audio_int.astype(np.float32) / 32768.0
|
| 295 |
+
elif pcm_format.bit_depth == 24:
|
| 296 |
+
raw = np.frombuffer(audio_data, dtype=np.uint8)
|
| 297 |
+
frame_count = len(raw) // 3
|
| 298 |
+
raw = raw[: frame_count * 3].reshape(-1, 3)
|
| 299 |
+
audio_int = (
|
| 300 |
+
raw[:, 0].astype(np.int32) | (raw[:, 1].astype(np.int32) << 8) | (raw[:, 2].astype(np.int32) << 16)
|
| 301 |
+
)
|
| 302 |
+
sign_mask = 1 << 23
|
| 303 |
+
audio_int = (audio_int ^ sign_mask) - sign_mask
|
| 304 |
+
audio_float = audio_int.astype(np.float32) / 8388608.0
|
| 305 |
+
elif pcm_format.bit_depth == 32:
|
| 306 |
+
audio_int = np.frombuffer(audio_data, dtype="<i4")
|
| 307 |
+
audio_float = audio_int.astype(np.float32) / 2147483648.0
|
| 308 |
+
else:
|
| 309 |
+
raise ValueError(f"Unsupported PCM bit depth: {pcm_format.bit_depth}")
|
| 310 |
+
audio_float = np.clip(audio_float, -1.0, 1.0)
|
| 311 |
+
channels = max(1, int(pcm_format.channels))
|
| 312 |
+
frame_count = len(audio_float) // channels
|
| 313 |
+
if frame_count <= 0:
|
| 314 |
+
raise ValueError("Audio chunk does not contain a complete frame")
|
| 315 |
+
return audio_float[: frame_count * channels].reshape(frame_count, channels)
|
| 316 |
+
|
| 317 |
+
def _decode_sendspin_audio(self, audio_data: bytes, fmt: AudioFormat) -> np.ndarray:
|
| 318 |
+
if fmt.codec != AudioCodec.PCM:
|
| 319 |
+
raise ValueError(f"Unsupported Sendspin codec for Reachy playback: {fmt.codec.value}")
|
| 320 |
+
pcm_format = fmt.pcm_format
|
| 321 |
+
audio_float = self._decode_pcm_bytes(audio_data, pcm_format)
|
| 322 |
+
target_sample_rate = self.reachy_mini.media.get_output_audio_samplerate()
|
| 323 |
+
if pcm_format.sample_rate != target_sample_rate and target_sample_rate > 0:
|
| 324 |
+
import scipy.signal
|
| 325 |
+
|
| 326 |
+
new_length = int(len(audio_float) * target_sample_rate / pcm_format.sample_rate)
|
| 327 |
+
if new_length > 0:
|
| 328 |
+
audio_float = scipy.signal.resample(audio_float, new_length, axis=0)
|
| 329 |
+
if not self._logged_resample:
|
| 330 |
+
_LOGGER.debug(
|
| 331 |
+
"Resampling Sendspin audio: %d Hz -> %d Hz", pcm_format.sample_rate, target_sample_rate
|
| 332 |
+
)
|
| 333 |
+
self._logged_resample = True
|
| 334 |
+
return np.clip(audio_float * self._get_sendspin_effective_volume(), -1.0, 1.0).astype(np.float32, copy=False)
|
| 335 |
+
|
| 336 |
+
def _build_sendspin_client(self) -> SendspinClient:
|
| 337 |
+
player_support = ClientHelloPlayerSupport(
|
| 338 |
+
supported_formats=[
|
| 339 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=2, sample_rate=16000, bit_depth=16),
|
| 340 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=1, sample_rate=16000, bit_depth=16),
|
| 341 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=2, sample_rate=48000, bit_depth=16),
|
| 342 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=2, sample_rate=44100, bit_depth=16),
|
| 343 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=1, sample_rate=48000, bit_depth=16),
|
| 344 |
+
SupportedAudioFormat(codec=AudioCodec.PCM, channels=1, sample_rate=44100, bit_depth=16),
|
| 345 |
+
],
|
| 346 |
+
buffer_capacity=32_000_000,
|
| 347 |
+
supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE],
|
| 348 |
+
)
|
| 349 |
+
return SendspinClient(
|
| 350 |
+
client_id=self._sendspin_client_id,
|
| 351 |
+
client_name="Reachy Mini",
|
| 352 |
+
roles=[Roles.PLAYER],
|
| 353 |
+
device_info=DeviceInfo(
|
| 354 |
+
product_name="Reachy Mini",
|
| 355 |
+
manufacturer="Pollen Robotics",
|
| 356 |
+
),
|
| 357 |
+
player_support=player_support,
|
| 358 |
+
initial_volume=max(0, min(100, round(self._unduck_volume * 100.0))),
|
| 359 |
+
initial_muted=self._sendspin_muted,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
def _remove_sendspin_listeners(self) -> None:
|
| 363 |
+
for unsub in self._sendspin_unsubscribers:
|
| 364 |
+
try:
|
| 365 |
+
unsub()
|
| 366 |
+
except Exception:
|
| 367 |
+
_LOGGER.debug("Error during Sendspin unsubscribe", exc_info=True)
|
| 368 |
+
self._sendspin_unsubscribers.clear()
|
| 369 |
+
|
| 370 |
+
def _register_sendspin_listeners(self, client: SendspinClient) -> None:
|
| 371 |
+
def _is_current() -> bool:
|
| 372 |
+
return self._sendspin_client is client
|
| 373 |
+
|
| 374 |
+
def _handle_audio_chunk(ts: int, audio_data: bytes, fmt: AudioFormat) -> None:
|
| 375 |
+
if _is_current():
|
| 376 |
+
self._on_sendspin_audio_chunk(client, ts, audio_data, fmt)
|
| 377 |
+
|
| 378 |
+
def _handle_stream_start(message: StreamStartMessage) -> None:
|
| 379 |
+
if _is_current():
|
| 380 |
+
self._on_sendspin_stream_start(client, message)
|
| 381 |
+
|
| 382 |
+
def _handle_stream_end(roles: list[str] | None) -> None:
|
| 383 |
+
if _is_current():
|
| 384 |
+
self._on_sendspin_stream_end(client, roles)
|
| 385 |
+
|
| 386 |
+
def _handle_stream_clear(roles: list[str] | None) -> None:
|
| 387 |
+
if _is_current():
|
| 388 |
+
self._on_sendspin_stream_clear(client, roles)
|
| 389 |
+
|
| 390 |
+
def _handle_disconnect() -> None:
|
| 391 |
+
if _is_current():
|
| 392 |
+
self._on_sendspin_disconnected(client)
|
| 393 |
+
|
| 394 |
+
def _handle_server_command(payload) -> None:
|
| 395 |
+
if _is_current():
|
| 396 |
+
self._on_sendspin_server_command(client, payload)
|
| 397 |
+
|
| 398 |
+
self._sendspin_unsubscribers = [
|
| 399 |
+
client.add_audio_chunk_listener(_handle_audio_chunk),
|
| 400 |
+
client.add_stream_start_listener(_handle_stream_start),
|
| 401 |
+
client.add_stream_end_listener(_handle_stream_end),
|
| 402 |
+
client.add_stream_clear_listener(_handle_stream_clear),
|
| 403 |
+
client.add_disconnect_listener(_handle_disconnect),
|
| 404 |
+
client.add_server_command_listener(_handle_server_command),
|
| 405 |
+
]
|
| 406 |
+
|
| 407 |
+
def _activate_sendspin_client(self, client: SendspinClient, *, server_url: str | None) -> None:
|
| 408 |
+
self._remove_sendspin_listeners()
|
| 409 |
+
self._sendspin_client = client
|
| 410 |
+
self._sendspin_url = server_url
|
| 411 |
+
self._sendspin_enabled = True
|
| 412 |
+
self._sendspin_remote_volume = max(0, min(100, round(self._unduck_volume * 100.0)))
|
| 413 |
+
self._register_sendspin_listeners(client)
|
| 414 |
+
self._ensure_sendspin_worker()
|
| 415 |
+
|
| 416 |
+
def _on_sendspin_disconnected(self, client: SendspinClient) -> None:
|
| 417 |
+
if self._sendspin_client is not client:
|
| 418 |
+
return
|
| 419 |
+
_LOGGER.info("Sendspin disconnected")
|
| 420 |
+
self._remove_sendspin_listeners()
|
| 421 |
+
self._sendspin_enabled = False
|
| 422 |
+
self._sendspin_client = None
|
| 423 |
+
self._sendspin_url = None
|
| 424 |
+
self._sendspin_stream_active = False
|
| 425 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 426 |
+
|
| 427 |
+
def pause_sendspin(self) -> None:
|
| 428 |
+
if self._sendspin_paused and not self._sendspin_stream_active:
|
| 429 |
+
return
|
| 430 |
+
self._sendspin_paused = True
|
| 431 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 432 |
+
_LOGGER.debug("Sendspin audio paused (voice assistant active)")
|
| 433 |
+
|
| 434 |
+
def resume_sendspin(self) -> None:
|
| 435 |
+
if not self._sendspin_paused:
|
| 436 |
+
return
|
| 437 |
+
self._sendspin_paused = False
|
| 438 |
+
self._sendspin_queue_event.set()
|
| 439 |
+
_LOGGER.debug("Sendspin audio resumed")
|
| 440 |
+
|
| 441 |
+
async def _start_sendspin_listener(self) -> None:
|
| 442 |
+
if ClientListener is None:
|
| 443 |
+
return
|
| 444 |
+
if self._sendspin_listener is not None:
|
| 445 |
+
return
|
| 446 |
+
self._sendspin_listener = ClientListener(
|
| 447 |
+
client_id=self._sendspin_client_id,
|
| 448 |
+
client_name="Reachy Mini",
|
| 449 |
+
port=SENDSPIN_DEFAULT_PORT,
|
| 450 |
+
on_connection=self._handle_sendspin_listener_connection,
|
| 451 |
+
)
|
| 452 |
+
try:
|
| 453 |
+
await self._sendspin_listener.start()
|
| 454 |
+
except Exception:
|
| 455 |
+
self._sendspin_listener = None
|
| 456 |
+
raise
|
| 457 |
+
_LOGGER.info("Sendspin listener started on port %d", self._sendspin_listener.port)
|
| 458 |
+
|
| 459 |
+
async def _handle_sendspin_listener_connection(self, ws) -> None:
|
| 460 |
+
if not SENDSPIN_AVAILABLE:
|
| 461 |
+
await ws.close()
|
| 462 |
+
return
|
| 463 |
+
disconnect_event = asyncio.Event()
|
| 464 |
+
client = self._build_sendspin_client()
|
| 465 |
+
async with self._get_sendspin_connect_lock():
|
| 466 |
+
if self._sendspin_client is not None:
|
| 467 |
+
await self._disconnect_sendspin()
|
| 468 |
+
self._activate_sendspin_client(client, server_url=None)
|
| 469 |
+
|
| 470 |
+
def _on_disconnect() -> None:
|
| 471 |
+
disconnect_event.set()
|
| 472 |
+
|
| 473 |
+
disconnect_unsub = client.add_disconnect_listener(_on_disconnect)
|
| 474 |
+
try:
|
| 475 |
+
await client.attach_websocket(ws)
|
| 476 |
+
_LOGGER.info("Accepted incoming Sendspin connection")
|
| 477 |
+
await disconnect_event.wait()
|
| 478 |
+
except Exception:
|
| 479 |
+
_LOGGER.exception("Failed to attach incoming Sendspin websocket")
|
| 480 |
+
async with self._get_sendspin_connect_lock():
|
| 481 |
+
if self._sendspin_client is client:
|
| 482 |
+
await self._disconnect_sendspin()
|
| 483 |
+
raise
|
| 484 |
+
finally:
|
| 485 |
+
try:
|
| 486 |
+
disconnect_unsub()
|
| 487 |
+
except Exception:
|
| 488 |
+
_LOGGER.debug("Failed to remove temporary Sendspin disconnect listener", exc_info=True)
|
| 489 |
+
|
| 490 |
+
async def start_sendspin_discovery(self) -> None:
|
| 491 |
+
if not SENDSPIN_AVAILABLE:
|
| 492 |
+
_LOGGER.debug("aiosendspin not installed, skipping Sendspin discovery")
|
| 493 |
+
return
|
| 494 |
+
if self._sendspin_discovery is not None and self._sendspin_discovery.is_running:
|
| 495 |
+
_LOGGER.debug("Sendspin discovery already running")
|
| 496 |
+
return
|
| 497 |
+
from ..protocol.zeroconf import SendspinDiscovery
|
| 498 |
+
|
| 499 |
+
_LOGGER.info("Starting Sendspin server discovery...")
|
| 500 |
+
self._sendspin_discovery = SendspinDiscovery(self._on_sendspin_server_found, self._on_sendspin_server_removed)
|
| 501 |
+
await self._sendspin_discovery.start()
|
| 502 |
+
self._ensure_sendspin_worker()
|
| 503 |
+
try:
|
| 504 |
+
await self._start_sendspin_listener()
|
| 505 |
+
except Exception:
|
| 506 |
+
_LOGGER.warning(
|
| 507 |
+
"Sendspin incoming listener unavailable; continuing with discovery/client mode", exc_info=True
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
async def _on_sendspin_server_found(self, server_url: str) -> None:
|
| 511 |
+
await self._connect_to_server(server_url)
|
| 512 |
+
|
| 513 |
+
async def _on_sendspin_server_removed(self, server_url: str) -> None:
|
| 514 |
+
if self._sendspin_url == server_url:
|
| 515 |
+
_LOGGER.info("Active Sendspin server disappeared: %s", server_url)
|
| 516 |
+
await self._disconnect_sendspin()
|
| 517 |
+
|
| 518 |
+
async def _connect_to_server(self, server_url: str) -> bool:
|
| 519 |
+
if not SENDSPIN_AVAILABLE:
|
| 520 |
+
return False
|
| 521 |
+
async with self._get_sendspin_connect_lock():
|
| 522 |
+
if self._sendspin_enabled and self._sendspin_url == server_url and self._sendspin_client is not None:
|
| 523 |
+
return True
|
| 524 |
+
if self._sendspin_client is not None:
|
| 525 |
+
await self._disconnect_sendspin()
|
| 526 |
+
client = self._build_sendspin_client()
|
| 527 |
+
try:
|
| 528 |
+
await client.connect(server_url)
|
| 529 |
+
except Exception:
|
| 530 |
+
_LOGGER.exception("Failed to connect to Sendspin server %s", server_url)
|
| 531 |
+
try:
|
| 532 |
+
await client.disconnect()
|
| 533 |
+
except Exception:
|
| 534 |
+
_LOGGER.debug("Failed to clean up Sendspin client after connect error", exc_info=True)
|
| 535 |
+
return False
|
| 536 |
+
self._activate_sendspin_client(client, server_url=server_url)
|
| 537 |
+
_LOGGER.info("Sendspin connected as PLAYER: %s (client_id=%s)", server_url, self._sendspin_client_id)
|
| 538 |
+
return True
|
| 539 |
+
|
| 540 |
+
def _on_sendspin_audio_chunk(
|
| 541 |
+
self, client: SendspinClient, server_timestamp_us: int, audio_data: bytes, fmt: AudioFormat
|
| 542 |
+
) -> None:
|
| 543 |
+
if self._sendspin_client is not client or self._sendspin_paused or self.reachy_mini is None:
|
| 544 |
+
return
|
| 545 |
+
try:
|
| 546 |
+
play_time_us = int(client.compute_play_time(server_timestamp_us))
|
| 547 |
+
now_us = time.monotonic_ns() // 1000
|
| 548 |
+
play_time_us = min(play_time_us, now_us + SENDSPIN_SCHEDULE_AHEAD_LIMIT_US)
|
| 549 |
+
if self._should_backpressure_sendspin_chunk(play_time_us, len(audio_data)):
|
| 550 |
+
return
|
| 551 |
+
|
| 552 |
+
self._sendspin_audio_format = fmt
|
| 553 |
+
audio_float = self._decode_sendspin_audio(audio_data, fmt)
|
| 554 |
+
sway_sample_rate = self.reachy_mini.media.get_output_audio_samplerate()
|
| 555 |
+
if sway_sample_rate <= 0:
|
| 556 |
+
sway_sample_rate = fmt.pcm_format.sample_rate
|
| 557 |
+
self._queue_sendspin_audio(play_time_us, audio_float, len(audio_data))
|
| 558 |
+
self._queue_sendspin_sway(play_time_us, audio_float, sway_sample_rate)
|
| 559 |
+
except Exception:
|
| 560 |
+
_LOGGER.exception("Error handling Sendspin audio chunk")
|
| 561 |
+
|
| 562 |
+
def _on_sendspin_stream_start(self, client: SendspinClient, message: StreamStartMessage) -> None:
|
| 563 |
+
if self._sendspin_client is not client:
|
| 564 |
+
return
|
| 565 |
+
self._sendspin_stream_active = True
|
| 566 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 567 |
+
player = getattr(message.payload, "player", None)
|
| 568 |
+
if player is None:
|
| 569 |
+
_LOGGER.debug("Sendspin stream started without player payload")
|
| 570 |
+
return
|
| 571 |
+
_LOGGER.info(
|
| 572 |
+
"Sendspin stream started: codec=%s sample_rate=%s channels=%s bit_depth=%s",
|
| 573 |
+
getattr(player.codec, "value", player.codec),
|
| 574 |
+
player.sample_rate,
|
| 575 |
+
player.channels,
|
| 576 |
+
player.bit_depth,
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
def _on_sendspin_stream_end(self, client: SendspinClient, roles: list[str] | None) -> None:
|
| 580 |
+
if self._sendspin_client is not client:
|
| 581 |
+
return
|
| 582 |
+
if roles is None or "player" in roles:
|
| 583 |
+
self._sendspin_stream_active = False
|
| 584 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 585 |
+
_LOGGER.debug("Sendspin stream ended")
|
| 586 |
+
|
| 587 |
+
def _on_sendspin_stream_clear(self, client: SendspinClient, roles: list[str] | None) -> None:
|
| 588 |
+
if self._sendspin_client is not client:
|
| 589 |
+
return
|
| 590 |
+
if roles is None or "player" in roles:
|
| 591 |
+
_LOGGER.debug("Sendspin stream cleared")
|
| 592 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 593 |
+
|
| 594 |
+
def _on_sendspin_server_command(self, client: SendspinClient, payload) -> None:
|
| 595 |
+
if self._sendspin_client is not client:
|
| 596 |
+
return
|
| 597 |
+
player_payload = getattr(payload, "player", None)
|
| 598 |
+
if player_payload is None:
|
| 599 |
+
return
|
| 600 |
+
try:
|
| 601 |
+
if player_payload.command == PlayerCommand.VOLUME and player_payload.volume is not None:
|
| 602 |
+
self._sendspin_remote_volume = max(0, min(100, int(player_payload.volume)))
|
| 603 |
+
_LOGGER.debug("Sendspin remote volume set to %d", self._sendspin_remote_volume)
|
| 604 |
+
elif player_payload.command == PlayerCommand.MUTE and player_payload.mute is not None:
|
| 605 |
+
self._sendspin_muted = bool(player_payload.mute)
|
| 606 |
+
if self._sendspin_muted:
|
| 607 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 608 |
+
_LOGGER.debug("Sendspin remote mute set to %s", self._sendspin_muted)
|
| 609 |
+
except Exception:
|
| 610 |
+
_LOGGER.exception("Failed to handle Sendspin server command")
|
| 611 |
+
|
| 612 |
+
async def _disconnect_sendspin(self) -> None:
|
| 613 |
+
client = self._sendspin_client
|
| 614 |
+
self._remove_sendspin_listeners()
|
| 615 |
+
if client is not None:
|
| 616 |
+
try:
|
| 617 |
+
await client.disconnect()
|
| 618 |
+
except Exception:
|
| 619 |
+
_LOGGER.debug("Error disconnecting from Sendspin", exc_info=True)
|
| 620 |
+
self._sendspin_client = None
|
| 621 |
+
self._sendspin_enabled = False
|
| 622 |
+
self._sendspin_url = None
|
| 623 |
+
self._sendspin_stream_active = False
|
| 624 |
+
self._reset_sendspin_stream_state(stop_output=True)
|
| 625 |
+
|
| 626 |
+
async def stop_sendspin(self) -> None:
|
| 627 |
+
if self._sendspin_discovery is not None:
|
| 628 |
+
await self._sendspin_discovery.stop()
|
| 629 |
+
self._sendspin_discovery = None
|
| 630 |
+
if self._sendspin_listener is not None:
|
| 631 |
+
await self._sendspin_listener.stop()
|
| 632 |
+
self._sendspin_listener = None
|
| 633 |
+
await self._disconnect_sendspin()
|
| 634 |
+
self._sendspin_client = None
|
| 635 |
+
self._sendspin_url = None
|
| 636 |
+
self._sendspin_audio_format = None
|
| 637 |
+
self._sendspin_enabled = False
|
| 638 |
+
self._sendspin_stream_active = False
|
| 639 |
+
self._sendspin_paused = False
|
| 640 |
+
self._sendspin_muted = False
|
| 641 |
+
self._sendspin_remote_volume = 100
|
| 642 |
+
self._stop_sendspin_worker()
|
| 643 |
+
_LOGGER.info("Sendspin stopped")
|
reachy_mini_home_assistant/audio/audio_player_shared.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import logging
|
| 5 |
+
import socket
|
| 6 |
+
import time
|
| 7 |
+
from urllib.parse import urlparse, urlunparse
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
_LOGGER = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
MOVEMENT_LATENCY_S = 0.2
|
| 14 |
+
SWAY_FRAME_DT_S = 0.05
|
| 15 |
+
STREAM_FETCH_CHUNK_SIZE = 2048
|
| 16 |
+
UNTHROTTLED_PREROLL_S = 0.35
|
| 17 |
+
SENDSPIN_LOCAL_BUFFER_CAPACITY_BYTES = 32_000_000
|
| 18 |
+
SENDSPIN_HIGH_WATERMARK_BYTES = 24_000_000
|
| 19 |
+
SENDSPIN_LATE_DROP_GRACE_US = 150_000
|
| 20 |
+
SENDSPIN_SCHEDULE_AHEAD_LIMIT_US = 2_000_000
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def sniff_audio_content_type(audio_bytes: bytes) -> str:
|
| 24 |
+
if len(audio_bytes) >= 12:
|
| 25 |
+
if audio_bytes.startswith(b"RIFF") and audio_bytes[8:12] == b"WAVE":
|
| 26 |
+
return "audio/wav"
|
| 27 |
+
if audio_bytes.startswith(b"fLaC"):
|
| 28 |
+
return "audio/flac"
|
| 29 |
+
if audio_bytes.startswith(b"OggS"):
|
| 30 |
+
return "audio/ogg"
|
| 31 |
+
if audio_bytes[:4] == b"ID3":
|
| 32 |
+
return "audio/mpeg"
|
| 33 |
+
if audio_bytes[:2] == b"\xff\xfb" or audio_bytes[:2] == b"\xff\xf3" or audio_bytes[:2] == b"\xff\xf2":
|
| 34 |
+
return "audio/mpeg"
|
| 35 |
+
if audio_bytes[:4] == b"ADIF" or (audio_bytes[0] == 0xFF and (audio_bytes[1] & 0xF0) == 0xF0):
|
| 36 |
+
return "audio/aac"
|
| 37 |
+
if audio_bytes[4:8] == b"ftyp":
|
| 38 |
+
return "audio/mp4"
|
| 39 |
+
if audio_bytes.startswith(b"\x1aE\xdf\xa3"):
|
| 40 |
+
return "audio/webm"
|
| 41 |
+
return ""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def rewrite_local_service_url(url: str, host_override: str | None) -> str:
|
| 45 |
+
if not host_override:
|
| 46 |
+
return url
|
| 47 |
+
try:
|
| 48 |
+
parsed = urlparse(url)
|
| 49 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 50 |
+
return url
|
| 51 |
+
hostname = (parsed.hostname or "").lower()
|
| 52 |
+
if hostname not in {"localhost", "127.0.0.1", "::1", "homeassistant.local", "homeassistant"}:
|
| 53 |
+
return url
|
| 54 |
+
netloc = host_override
|
| 55 |
+
if parsed.port is not None:
|
| 56 |
+
netloc = f"{host_override}:{parsed.port}"
|
| 57 |
+
return urlunparse(parsed._replace(netloc=netloc))
|
| 58 |
+
except Exception:
|
| 59 |
+
return url
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class AudioPlayerSwayMixin:
|
| 63 |
+
def _new_sway_analyzer(self):
|
| 64 |
+
try:
|
| 65 |
+
from ..motion.speech_sway import SpeechSwayRT
|
| 66 |
+
|
| 67 |
+
return SpeechSwayRT()
|
| 68 |
+
except Exception:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
def _compute_sway_frames(self, analyzer, pcm: np.ndarray, sample_rate: int) -> list[dict]:
|
| 72 |
+
if analyzer is None:
|
| 73 |
+
return []
|
| 74 |
+
try:
|
| 75 |
+
return analyzer.feed(pcm, sample_rate) or []
|
| 76 |
+
except Exception:
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
def _reset_sway_output(self) -> None:
|
| 80 |
+
if getattr(self, "_sway_callback", None) is None:
|
| 81 |
+
return
|
| 82 |
+
try:
|
| 83 |
+
self._sway_callback({"pitch_rad": 0.0, "yaw_rad": 0.0, "roll_rad": 0.0, "x_m": 0.0, "y_m": 0.0, "z_m": 0.0})
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
def _init_stream_sway_context(self) -> dict | None:
|
| 88 |
+
if getattr(self, "_sway_callback", None) is None:
|
| 89 |
+
return None
|
| 90 |
+
analyzer = self._new_sway_analyzer()
|
| 91 |
+
if analyzer is None:
|
| 92 |
+
return None
|
| 93 |
+
return {"sway": analyzer, "base_ts": time.monotonic(), "frames_done": 0}
|
| 94 |
+
|
| 95 |
+
def _feed_stream_sway(self, ctx: dict | None, pcm: np.ndarray, sample_rate: int) -> None:
|
| 96 |
+
if ctx is None or getattr(self, "_sway_callback", None) is None:
|
| 97 |
+
return
|
| 98 |
+
try:
|
| 99 |
+
results = self._compute_sway_frames(ctx["sway"], pcm, sample_rate)
|
| 100 |
+
if not results:
|
| 101 |
+
return
|
| 102 |
+
base_ts = float(ctx["base_ts"])
|
| 103 |
+
for item in results:
|
| 104 |
+
target = base_ts + MOVEMENT_LATENCY_S + ctx["frames_done"] * SWAY_FRAME_DT_S
|
| 105 |
+
now = time.monotonic()
|
| 106 |
+
if target > now:
|
| 107 |
+
time.sleep(min(0.02, target - now))
|
| 108 |
+
self._sway_callback(item)
|
| 109 |
+
ctx["frames_done"] += 1
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
def _finalize_stream_sway(self, ctx: dict | None) -> None:
|
| 114 |
+
if ctx is None or getattr(self, "_sway_callback", None) is None:
|
| 115 |
+
return
|
| 116 |
+
self._reset_sway_output()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_stable_client_id() -> str:
|
| 120 |
+
try:
|
| 121 |
+
hostname = socket.gethostname()
|
| 122 |
+
hash_input = f"reachy-mini-{hostname}"
|
| 123 |
+
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
|
| 124 |
+
except Exception:
|
| 125 |
+
return "reachy-mini-default"
|
reachy_mini_home_assistant/audio/audio_player_stream_decoded.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from .audio_player_shared import STREAM_FETCH_CHUNK_SIZE, UNTHROTTLED_PREROLL_S, _LOGGER
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AudioPlayerStreamDecodedMixin:
|
| 12 |
+
@staticmethod
|
| 13 |
+
def _guess_gst_input_caps(content_type: str) -> str | None:
|
| 14 |
+
ct = (content_type or "").split(";", 1)[0].strip().lower()
|
| 15 |
+
mapping = {
|
| 16 |
+
"audio/mpeg": "audio/mpeg,mpegversion=(int)1",
|
| 17 |
+
"audio/mp3": "audio/mpeg,mpegversion=(int)1",
|
| 18 |
+
"audio/aac": "audio/mpeg,mpegversion=(int)4,stream-format=(string)raw",
|
| 19 |
+
"audio/mp4": "audio/mpeg,mpegversion=(int)4,stream-format=(string)raw",
|
| 20 |
+
"audio/ogg": "application/ogg",
|
| 21 |
+
"application/ogg": "application/ogg",
|
| 22 |
+
"audio/opus": "audio/x-opus",
|
| 23 |
+
"audio/webm": "video/webm",
|
| 24 |
+
"audio/wav": "audio/x-wav",
|
| 25 |
+
"audio/wave": "audio/x-wav",
|
| 26 |
+
"audio/x-wav": "audio/x-wav",
|
| 27 |
+
"audio/flac": "audio/x-flac",
|
| 28 |
+
"audio/x-flac": "audio/x-flac",
|
| 29 |
+
}
|
| 30 |
+
return mapping.get(ct)
|
| 31 |
+
|
| 32 |
+
def _stream_decoded_response(self, response, source_url: str, content_type: str) -> bool:
|
| 33 |
+
try:
|
| 34 |
+
import gi
|
| 35 |
+
|
| 36 |
+
gi.require_version("Gst", "1.0")
|
| 37 |
+
from gi.repository import Gst
|
| 38 |
+
except Exception:
|
| 39 |
+
return False
|
| 40 |
+
try:
|
| 41 |
+
Gst.init(None)
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
target_sr = self.reachy_mini.media.get_output_audio_samplerate()
|
| 45 |
+
if target_sr <= 0:
|
| 46 |
+
target_sr = 16000
|
| 47 |
+
target_channels = 1
|
| 48 |
+
if not self._ensure_media_playback_started():
|
| 49 |
+
return False
|
| 50 |
+
pipeline = Gst.Pipeline.new("tts_stream_decode")
|
| 51 |
+
appsrc = Gst.ElementFactory.make("appsrc", "src")
|
| 52 |
+
decodebin = Gst.ElementFactory.make("decodebin", "decode")
|
| 53 |
+
audioconvert = Gst.ElementFactory.make("audioconvert", "conv")
|
| 54 |
+
audioresample = Gst.ElementFactory.make("audioresample", "resample")
|
| 55 |
+
capsfilter = Gst.ElementFactory.make("capsfilter", "caps")
|
| 56 |
+
appsink = Gst.ElementFactory.make("appsink", "sink")
|
| 57 |
+
if not all((pipeline, appsrc, decodebin, audioconvert, audioresample, capsfilter, appsink)):
|
| 58 |
+
return False
|
| 59 |
+
target_caps = Gst.Caps.from_string(f"audio/x-raw,format=S16LE,channels={target_channels},rate={target_sr}")
|
| 60 |
+
capsfilter.set_property("caps", target_caps)
|
| 61 |
+
appsrc.set_property("is-live", True)
|
| 62 |
+
appsrc.set_property("format", Gst.Format.BYTES)
|
| 63 |
+
appsrc.set_property("block", False)
|
| 64 |
+
appsrc.set_property("do-timestamp", True)
|
| 65 |
+
src_caps = self._guess_gst_input_caps(content_type)
|
| 66 |
+
if src_caps:
|
| 67 |
+
try:
|
| 68 |
+
appsrc.set_property("caps", Gst.Caps.from_string(src_caps))
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
try:
|
| 72 |
+
decodebin.set_property("caps", Gst.Caps.from_string("audio/x-raw"))
|
| 73 |
+
except Exception:
|
| 74 |
+
pass
|
| 75 |
+
appsink.set_property("emit-signals", False)
|
| 76 |
+
appsink.set_property("sync", False)
|
| 77 |
+
appsink.set_property("max-buffers", 0)
|
| 78 |
+
appsink.set_property("drop", False)
|
| 79 |
+
pipeline.add(appsrc)
|
| 80 |
+
pipeline.add(decodebin)
|
| 81 |
+
pipeline.add(audioconvert)
|
| 82 |
+
pipeline.add(audioresample)
|
| 83 |
+
pipeline.add(capsfilter)
|
| 84 |
+
pipeline.add(appsink)
|
| 85 |
+
if (
|
| 86 |
+
not appsrc.link(decodebin)
|
| 87 |
+
or not audioconvert.link(audioresample)
|
| 88 |
+
or not audioresample.link(capsfilter)
|
| 89 |
+
or not capsfilter.link(appsink)
|
| 90 |
+
):
|
| 91 |
+
return False
|
| 92 |
+
audio_state = {"linked": False}
|
| 93 |
+
|
| 94 |
+
def on_pad_added(_decodebin, pad) -> None:
|
| 95 |
+
sink_pad = audioconvert.get_static_pad("sink")
|
| 96 |
+
if sink_pad is None or sink_pad.is_linked():
|
| 97 |
+
return
|
| 98 |
+
caps_obj = pad.get_current_caps() or pad.query_caps(None)
|
| 99 |
+
if caps_obj is None:
|
| 100 |
+
return
|
| 101 |
+
if caps_obj.to_string().startswith("audio/"):
|
| 102 |
+
try:
|
| 103 |
+
result = pad.link(sink_pad)
|
| 104 |
+
if result == Gst.PadLinkReturn.OK:
|
| 105 |
+
audio_state["linked"] = True
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
decodebin.connect("pad-added", on_pad_added)
|
| 110 |
+
pushed_any = False
|
| 111 |
+
played_frames = 0
|
| 112 |
+
stream_start = time.monotonic()
|
| 113 |
+
sway_ctx = self._init_stream_sway_context()
|
| 114 |
+
bytes_per_frame = 2 * target_channels
|
| 115 |
+
feed_done = threading.Event()
|
| 116 |
+
decode_error = False
|
| 117 |
+
|
| 118 |
+
def writer() -> None:
|
| 119 |
+
try:
|
| 120 |
+
for chunk in response.iter_content(chunk_size=STREAM_FETCH_CHUNK_SIZE):
|
| 121 |
+
if self._stop_flag.is_set():
|
| 122 |
+
break
|
| 123 |
+
if not chunk:
|
| 124 |
+
continue
|
| 125 |
+
gst_buffer = Gst.Buffer.new_allocate(None, len(chunk), None)
|
| 126 |
+
if gst_buffer is None:
|
| 127 |
+
continue
|
| 128 |
+
gst_buffer.fill(0, chunk)
|
| 129 |
+
ret = appsrc.emit("push-buffer", gst_buffer)
|
| 130 |
+
if ret not in (Gst.FlowReturn.OK, Gst.FlowReturn.FLUSHING):
|
| 131 |
+
_LOGGER.debug("appsrc push-buffer returned %s", ret)
|
| 132 |
+
break
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
finally:
|
| 136 |
+
feed_done.set()
|
| 137 |
+
try:
|
| 138 |
+
appsrc.emit("end-of-stream")
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
state_ret = pipeline.set_state(Gst.State.PLAYING)
|
| 144 |
+
if state_ret == Gst.StateChangeReturn.FAILURE:
|
| 145 |
+
_LOGGER.debug("Failed to set GStreamer decode pipeline PLAYING for URL=%s", source_url)
|
| 146 |
+
return False
|
| 147 |
+
writer_thread = threading.Thread(target=writer, daemon=True)
|
| 148 |
+
writer_thread.start()
|
| 149 |
+
remainder = b""
|
| 150 |
+
timeout_ns = 20_000_000
|
| 151 |
+
bus = pipeline.get_bus()
|
| 152 |
+
eos_seen = False
|
| 153 |
+
eos_drain_empty_polls = 0
|
| 154 |
+
while True:
|
| 155 |
+
sample = appsink.emit("try-pull-sample", timeout_ns)
|
| 156 |
+
if sample is not None:
|
| 157 |
+
eos_drain_empty_polls = 0
|
| 158 |
+
try:
|
| 159 |
+
gst_buffer = sample.get_buffer()
|
| 160 |
+
if gst_buffer is None:
|
| 161 |
+
continue
|
| 162 |
+
ok, map_info = gst_buffer.map(Gst.MapFlags.READ)
|
| 163 |
+
if not ok:
|
| 164 |
+
continue
|
| 165 |
+
try:
|
| 166 |
+
raw = bytes(map_info.data)
|
| 167 |
+
finally:
|
| 168 |
+
gst_buffer.unmap(map_info)
|
| 169 |
+
data = remainder + raw
|
| 170 |
+
usable_len = (len(data) // bytes_per_frame) * bytes_per_frame
|
| 171 |
+
remainder = data[usable_len:]
|
| 172 |
+
if usable_len == 0:
|
| 173 |
+
continue
|
| 174 |
+
pcm = np.frombuffer(data[:usable_len], dtype=np.int16).astype(np.float32) / 32768.0
|
| 175 |
+
pcm = np.clip(pcm * self._current_volume, -1.0, 1.0).reshape(-1, target_channels)
|
| 176 |
+
target_elapsed = played_frames / float(target_sr)
|
| 177 |
+
actual_elapsed = time.monotonic() - stream_start
|
| 178 |
+
if target_elapsed > UNTHROTTLED_PREROLL_S and target_elapsed > actual_elapsed:
|
| 179 |
+
time.sleep(min(0.05, target_elapsed - actual_elapsed))
|
| 180 |
+
if not self._push_audio_float(pcm):
|
| 181 |
+
continue
|
| 182 |
+
pushed_any = True
|
| 183 |
+
played_frames += int(pcm.shape[0])
|
| 184 |
+
self._feed_stream_sway(sway_ctx, pcm, target_sr)
|
| 185 |
+
finally:
|
| 186 |
+
sample = None
|
| 187 |
+
elif eos_seen and feed_done.is_set():
|
| 188 |
+
eos_drain_empty_polls += 1
|
| 189 |
+
msg = bus.timed_pop_filtered(0, Gst.MessageType.ERROR | Gst.MessageType.EOS)
|
| 190 |
+
if msg is not None:
|
| 191 |
+
if msg.type == Gst.MessageType.EOS:
|
| 192 |
+
eos_seen = True
|
| 193 |
+
elif msg.type == Gst.MessageType.ERROR:
|
| 194 |
+
err, debug = msg.parse_error()
|
| 195 |
+
err_text = str(err).lower()
|
| 196 |
+
debug_text = str(debug).lower() if debug is not None else ""
|
| 197 |
+
if audio_state["linked"] and (
|
| 198 |
+
"not-linked" in err_text
|
| 199 |
+
or "not-linked" in debug_text
|
| 200 |
+
or "streaming stopped, reason not-linked" in debug_text
|
| 201 |
+
):
|
| 202 |
+
continue
|
| 203 |
+
decode_error = True
|
| 204 |
+
_LOGGER.debug(
|
| 205 |
+
"GStreamer decode error content-type=%s url=%s err=%s debug=%s",
|
| 206 |
+
content_type or "unknown",
|
| 207 |
+
source_url,
|
| 208 |
+
err,
|
| 209 |
+
debug,
|
| 210 |
+
)
|
| 211 |
+
break
|
| 212 |
+
if feed_done.is_set() and eos_seen:
|
| 213 |
+
sink_eos = False
|
| 214 |
+
try:
|
| 215 |
+
sink_eos = bool(appsink.is_eos())
|
| 216 |
+
except Exception:
|
| 217 |
+
sink_eos = False
|
| 218 |
+
if sink_eos and eos_drain_empty_polls >= 2:
|
| 219 |
+
break
|
| 220 |
+
if eos_drain_empty_polls >= 100:
|
| 221 |
+
break
|
| 222 |
+
if self._stop_flag.is_set():
|
| 223 |
+
break
|
| 224 |
+
writer_thread.join(timeout=1.0)
|
| 225 |
+
if self._stop_flag.is_set():
|
| 226 |
+
return True
|
| 227 |
+
if decode_error:
|
| 228 |
+
return False
|
| 229 |
+
if pushed_any:
|
| 230 |
+
return True
|
| 231 |
+
completed_cleanly = feed_done.is_set() and eos_seen
|
| 232 |
+
if not completed_cleanly:
|
| 233 |
+
return False
|
| 234 |
+
except Exception as e:
|
| 235 |
+
_LOGGER.debug("Error during GStreamer stream decode: %s", e)
|
| 236 |
+
pushed_any = False
|
| 237 |
+
finally:
|
| 238 |
+
self._finalize_stream_sway(sway_ctx)
|
| 239 |
+
try:
|
| 240 |
+
pipeline.set_state(Gst.State.NULL)
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
return pushed_any
|
reachy_mini_home_assistant/audio/audio_player_stream_pcm.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from .audio_player_shared import STREAM_FETCH_CHUNK_SIZE, UNTHROTTLED_PREROLL_S
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AudioPlayerStreamPCMMixin:
|
| 11 |
+
@staticmethod
|
| 12 |
+
def _is_pcm_content_type(content_type: str) -> bool:
|
| 13 |
+
return ("audio/l16" in content_type) or ("audio/pcm" in content_type) or ("audio/raw" in content_type)
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def _parse_pcm_format(content_type: str) -> tuple[int, int]:
|
| 17 |
+
channels = 1
|
| 18 |
+
sample_rate = 16000
|
| 19 |
+
if ";" in content_type:
|
| 20 |
+
for part in content_type.split(";"):
|
| 21 |
+
token = part.strip()
|
| 22 |
+
if token.startswith("channels="):
|
| 23 |
+
try:
|
| 24 |
+
channels = max(1, int(token.split("=", 1)[1]))
|
| 25 |
+
except Exception:
|
| 26 |
+
pass
|
| 27 |
+
elif token.startswith("rate="):
|
| 28 |
+
try:
|
| 29 |
+
sample_rate = max(8000, int(token.split("=", 1)[1]))
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
return channels, sample_rate
|
| 33 |
+
|
| 34 |
+
def _ensure_media_playback_started(self) -> bool:
|
| 35 |
+
acquired = self._gstreamer_lock.acquire(timeout=0.3)
|
| 36 |
+
if not acquired:
|
| 37 |
+
return False
|
| 38 |
+
try:
|
| 39 |
+
self.reachy_mini.media.start_playing()
|
| 40 |
+
return True
|
| 41 |
+
except Exception:
|
| 42 |
+
return False
|
| 43 |
+
finally:
|
| 44 |
+
self._gstreamer_lock.release()
|
| 45 |
+
|
| 46 |
+
def _push_audio_float(self, audio_float: np.ndarray, max_wait_s: float = 1.0) -> bool:
|
| 47 |
+
deadline = time.monotonic() + max(0.05, max_wait_s)
|
| 48 |
+
while time.monotonic() < deadline:
|
| 49 |
+
if self._stop_flag.is_set():
|
| 50 |
+
return False
|
| 51 |
+
acquired = self._gstreamer_lock.acquire(timeout=0.1)
|
| 52 |
+
if not acquired:
|
| 53 |
+
continue
|
| 54 |
+
try:
|
| 55 |
+
self.reachy_mini.media.push_audio_sample(audio_float)
|
| 56 |
+
return True
|
| 57 |
+
finally:
|
| 58 |
+
self._gstreamer_lock.release()
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
def _stream_pcm_response(self, response, content_type: str) -> bool:
|
| 62 |
+
channels, sample_rate = self._parse_pcm_format(content_type)
|
| 63 |
+
target_sr = self.reachy_mini.media.get_output_audio_samplerate()
|
| 64 |
+
if target_sr <= 0:
|
| 65 |
+
target_sr = 16000
|
| 66 |
+
if not self._ensure_media_playback_started():
|
| 67 |
+
return False
|
| 68 |
+
remainder = b""
|
| 69 |
+
pushed_any = False
|
| 70 |
+
played_frames = 0
|
| 71 |
+
stream_start = time.monotonic()
|
| 72 |
+
sway_ctx = self._init_stream_sway_context()
|
| 73 |
+
bytes_per_frame = 2 * channels
|
| 74 |
+
for chunk in response.iter_content(chunk_size=STREAM_FETCH_CHUNK_SIZE):
|
| 75 |
+
if self._stop_flag.is_set():
|
| 76 |
+
break
|
| 77 |
+
if not chunk:
|
| 78 |
+
continue
|
| 79 |
+
data = remainder + chunk
|
| 80 |
+
usable_len = (len(data) // bytes_per_frame) * bytes_per_frame
|
| 81 |
+
remainder = data[usable_len:]
|
| 82 |
+
if usable_len == 0:
|
| 83 |
+
continue
|
| 84 |
+
pcm = np.frombuffer(data[:usable_len], dtype=np.int16).astype(np.float32) / 32768.0
|
| 85 |
+
pcm = np.clip(pcm * self._current_volume, -1.0, 1.0).reshape(-1, channels)
|
| 86 |
+
if sample_rate != target_sr and target_sr > 0:
|
| 87 |
+
import scipy.signal
|
| 88 |
+
|
| 89 |
+
new_len = int(len(pcm) * target_sr / sample_rate)
|
| 90 |
+
if new_len > 0:
|
| 91 |
+
pcm = scipy.signal.resample(pcm, new_len, axis=0).astype(np.float32, copy=False)
|
| 92 |
+
target_elapsed = played_frames / float(target_sr)
|
| 93 |
+
actual_elapsed = time.monotonic() - stream_start
|
| 94 |
+
if target_elapsed > UNTHROTTLED_PREROLL_S and target_elapsed > actual_elapsed:
|
| 95 |
+
time.sleep(min(0.05, target_elapsed - actual_elapsed))
|
| 96 |
+
if not self._push_audio_float(pcm):
|
| 97 |
+
continue
|
| 98 |
+
pushed_any = True
|
| 99 |
+
played_frames += int(pcm.shape[0])
|
| 100 |
+
self._feed_stream_sway(sway_ctx, pcm, target_sr)
|
| 101 |
+
self._finalize_stream_sway(sway_ctx)
|
| 102 |
+
return pushed_any
|
reachy_mini_home_assistant/audio/audio_player_wobble.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .audio_player_shared import AudioPlayerSwayMixin
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AudioPlayerWobbleMixin(AudioPlayerSwayMixin):
|
| 7 |
+
pass
|
reachy_mini_home_assistant/audio/doa_tracker.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direction of Arrival (DOA) sound localization tracker.
|
| 2 |
+
|
| 3 |
+
This module implements sound source tracking using the microphone array's
|
| 4 |
+
DOA (Direction of Arrival) data to make the robot turn towards sounds
|
| 5 |
+
when idle.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
from collections.abc import Callable
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class DOAConfig:
|
| 18 |
+
"""Configuration for DOA tracking behavior."""
|
| 19 |
+
|
| 20 |
+
# Minimum energy threshold to consider a sound significant
|
| 21 |
+
energy_threshold: float = 0.3
|
| 22 |
+
|
| 23 |
+
# Minimum angle change (degrees) to trigger a turn
|
| 24 |
+
angle_threshold_deg: float = 15.0
|
| 25 |
+
|
| 26 |
+
# Cooldown time (seconds) before responding to same direction
|
| 27 |
+
direction_cooldown: float = 5.0
|
| 28 |
+
|
| 29 |
+
# Duration of turn animation (seconds)
|
| 30 |
+
turn_duration: float = 1.5
|
| 31 |
+
|
| 32 |
+
# Number of direction zones for cooldown tracking
|
| 33 |
+
num_zones: int = 8
|
| 34 |
+
|
| 35 |
+
# Maximum turn angle (degrees)
|
| 36 |
+
max_turn_angle_deg: float = 60.0
|
| 37 |
+
|
| 38 |
+
# Minimum time between any turns (seconds)
|
| 39 |
+
min_turn_interval: float = 2.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class DOATracker:
|
| 43 |
+
"""Tracks sound direction and triggers head turns when idle.
|
| 44 |
+
|
| 45 |
+
This class monitors DOA (Direction of Arrival) data from the microphone
|
| 46 |
+
array and triggers smooth head turns towards sound sources when the
|
| 47 |
+
robot is idle and not tracking a face.
|
| 48 |
+
|
| 49 |
+
Usage:
|
| 50 |
+
tracker = DOATracker(movement_callback=robot.turn_to_angle)
|
| 51 |
+
|
| 52 |
+
# In audio processing loop:
|
| 53 |
+
tracker.update(doa_angle=45.0, energy=0.5)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
movement_callback: Callable[[float, float], None] | None = None,
|
| 59 |
+
config: DOAConfig | None = None,
|
| 60 |
+
):
|
| 61 |
+
"""Initialize the DOA tracker.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
movement_callback: Function to call for turning.
|
| 65 |
+
Signature: (yaw_degrees, duration) -> None
|
| 66 |
+
config: DOA tracking configuration
|
| 67 |
+
"""
|
| 68 |
+
self._movement_callback = movement_callback
|
| 69 |
+
self._config = config or DOAConfig()
|
| 70 |
+
|
| 71 |
+
# State
|
| 72 |
+
self._enabled = True
|
| 73 |
+
self._face_detected = False
|
| 74 |
+
self._in_conversation = False
|
| 75 |
+
self._last_angle: float = 0.0
|
| 76 |
+
self._last_turn_time: float = 0.0
|
| 77 |
+
|
| 78 |
+
# Zone-based cooldown tracking
|
| 79 |
+
self._zone_cooldowns: dict[int, float] = {}
|
| 80 |
+
|
| 81 |
+
# Time function
|
| 82 |
+
self._now = time.monotonic
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def enabled(self) -> bool:
|
| 86 |
+
"""Check if DOA tracking is enabled."""
|
| 87 |
+
return self._enabled
|
| 88 |
+
|
| 89 |
+
@enabled.setter
|
| 90 |
+
def enabled(self, value: bool) -> None:
|
| 91 |
+
"""Enable or disable DOA tracking."""
|
| 92 |
+
self._enabled = value
|
| 93 |
+
if value:
|
| 94 |
+
logger.debug("DOA tracking enabled")
|
| 95 |
+
else:
|
| 96 |
+
logger.debug("DOA tracking disabled")
|
| 97 |
+
|
| 98 |
+
def set_face_detected(self, detected: bool) -> None:
|
| 99 |
+
"""Update face detection state.
|
| 100 |
+
|
| 101 |
+
DOA tracking is suppressed when a face is detected.
|
| 102 |
+
"""
|
| 103 |
+
self._face_detected = detected
|
| 104 |
+
|
| 105 |
+
def set_conversation_mode(self, in_conversation: bool) -> None:
|
| 106 |
+
"""Update conversation mode state.
|
| 107 |
+
|
| 108 |
+
DOA tracking is suppressed during conversation.
|
| 109 |
+
"""
|
| 110 |
+
self._in_conversation = in_conversation
|
| 111 |
+
|
| 112 |
+
def set_movement_callback(self, callback: Callable[[float, float], None]) -> None:
|
| 113 |
+
"""Set the movement callback function.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
callback: Function(yaw_degrees, duration) to call for turning
|
| 117 |
+
"""
|
| 118 |
+
self._movement_callback = callback
|
| 119 |
+
|
| 120 |
+
def update(self, doa_angle: float, energy: float) -> bool:
|
| 121 |
+
"""Process DOA data and trigger turn if appropriate.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
doa_angle: Direction of arrival in degrees (-180 to 180)
|
| 125 |
+
energy: Sound energy level (0 to 1)
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
True if a turn was triggered, False otherwise
|
| 129 |
+
"""
|
| 130 |
+
# Check if tracking should be active
|
| 131 |
+
if not self._should_track():
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
# Check energy threshold
|
| 135 |
+
if energy < self._config.energy_threshold:
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
# Check angle change threshold
|
| 139 |
+
angle_diff = abs(doa_angle - self._last_angle)
|
| 140 |
+
if angle_diff < self._config.angle_threshold_deg:
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
# Check minimum turn interval
|
| 144 |
+
now = self._now()
|
| 145 |
+
if now - self._last_turn_time < self._config.min_turn_interval:
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
# Check zone cooldown
|
| 149 |
+
zone = self._get_zone(doa_angle)
|
| 150 |
+
zone_last_time = self._zone_cooldowns.get(zone, 0)
|
| 151 |
+
if now - zone_last_time < self._config.direction_cooldown:
|
| 152 |
+
logger.debug(f"DOA zone {zone} in cooldown")
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
# Clamp angle
|
| 156 |
+
clamped_angle = max(-self._config.max_turn_angle_deg, min(self._config.max_turn_angle_deg, doa_angle))
|
| 157 |
+
|
| 158 |
+
# Trigger turn
|
| 159 |
+
if self._movement_callback:
|
| 160 |
+
logger.info(f"DOA turn triggered: {clamped_angle:.1f}° (energy={energy:.2f})")
|
| 161 |
+
self._movement_callback(clamped_angle, self._config.turn_duration)
|
| 162 |
+
|
| 163 |
+
# Update state
|
| 164 |
+
self._last_angle = doa_angle
|
| 165 |
+
self._last_turn_time = now
|
| 166 |
+
self._zone_cooldowns[zone] = now
|
| 167 |
+
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
def _should_track(self) -> bool:
|
| 173 |
+
"""Check if DOA tracking should be active."""
|
| 174 |
+
if not self._enabled:
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
if self._face_detected:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
return not self._in_conversation
|
| 181 |
+
|
| 182 |
+
def _get_zone(self, angle: float) -> int:
|
| 183 |
+
"""Get the direction zone for an angle.
|
| 184 |
+
|
| 185 |
+
Divides the 360° space into zones for cooldown tracking.
|
| 186 |
+
"""
|
| 187 |
+
# Normalize to 0-360
|
| 188 |
+
normalized = (angle + 180) % 360
|
| 189 |
+
|
| 190 |
+
# Calculate zone
|
| 191 |
+
zone_size = 360 / self._config.num_zones
|
| 192 |
+
return int(normalized / zone_size)
|
| 193 |
+
|
| 194 |
+
def reset_cooldowns(self) -> None:
|
| 195 |
+
"""Reset all zone cooldowns."""
|
| 196 |
+
self._zone_cooldowns.clear()
|
| 197 |
+
self._last_turn_time = 0.0
|
| 198 |
+
logger.debug("DOA cooldowns reset")
|
reachy_mini_home_assistant/audio/local_audio_player.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local-only audio player for TTS and announcements."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from .audio_player_playback import AudioPlayerPlaybackMixin
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from collections.abc import Callable
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LocalAudioPlayer(AudioPlayerPlaybackMixin):
|
| 15 |
+
"""Audio player for local/TTS playback without Sendspin runtime state."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, reachy_mini=None, gstreamer_lock=None) -> None:
|
| 18 |
+
self.reachy_mini = reachy_mini
|
| 19 |
+
self._gstreamer_lock = gstreamer_lock if gstreamer_lock is not None else threading.Lock()
|
| 20 |
+
self.is_playing = False
|
| 21 |
+
self._playlist: list[str] = []
|
| 22 |
+
self._done_callback: Callable[[], None] | None = None
|
| 23 |
+
self._done_callback_lock = threading.Lock()
|
| 24 |
+
self._duck_volume: float = 0.5
|
| 25 |
+
self._unduck_volume: float = 1.0
|
| 26 |
+
self._current_volume: float = 1.0
|
| 27 |
+
self._stop_flag = threading.Event()
|
| 28 |
+
self._playback_thread: threading.Thread | None = None
|
| 29 |
+
self._sway_callback: Callable[[dict], None] | None = None
|
| 30 |
+
self._http_host_override: str | None = None
|
| 31 |
+
|
| 32 |
+
def set_sway_callback(self, callback: Callable[[dict], None] | None) -> None:
|
| 33 |
+
self._sway_callback = callback
|
| 34 |
+
|
| 35 |
+
def set_reachy_mini(self, reachy_mini) -> None:
|
| 36 |
+
self.reachy_mini = reachy_mini
|
| 37 |
+
|
| 38 |
+
def set_http_host_override(self, host: str | None) -> None:
|
| 39 |
+
self._http_host_override = host
|
reachy_mini_home_assistant/core/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core module for Reachy Mini HA Voice.
|
| 2 |
+
|
| 3 |
+
This module contains fundamental components:
|
| 4 |
+
- SleepAwareService: Base class for services that support resource suspend/resume
|
| 5 |
+
- ServiceManager: Manages multiple suspend-aware services
|
| 6 |
+
- Config: Centralized configuration management
|
| 7 |
+
- Exceptions: Custom exception classes
|
| 8 |
+
- SystemDiagnostics: System diagnostics utilities
|
| 9 |
+
- Util: Common utility functions
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from .config import Config
|
| 13 |
+
from .exceptions import (
|
| 14 |
+
ConfigurationError,
|
| 15 |
+
DaemonUnavailableError,
|
| 16 |
+
EntityRegistrationError,
|
| 17 |
+
ModelLoadError,
|
| 18 |
+
ReachyHAError,
|
| 19 |
+
ResourceUnavailableError,
|
| 20 |
+
RobotConnectionError,
|
| 21 |
+
ServiceSuspendedError,
|
| 22 |
+
)
|
| 23 |
+
from .service_base import RobustOperationMixin, ServiceManager, ServiceState, SleepAwareService
|
| 24 |
+
from .system_diagnostics import get_system_diagnostics
|
| 25 |
+
from .util import call_all, get_mac
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
"Config",
|
| 29 |
+
"ConfigurationError",
|
| 30 |
+
"DaemonUnavailableError",
|
| 31 |
+
"EntityRegistrationError",
|
| 32 |
+
"ModelLoadError",
|
| 33 |
+
# Exceptions
|
| 34 |
+
"ReachyHAError",
|
| 35 |
+
"ResourceUnavailableError",
|
| 36 |
+
"RobotConnectionError",
|
| 37 |
+
"RobustOperationMixin",
|
| 38 |
+
"ServiceManager",
|
| 39 |
+
"ServiceState",
|
| 40 |
+
"ServiceSuspendedError",
|
| 41 |
+
"SleepAwareService",
|
| 42 |
+
"call_all",
|
| 43 |
+
# Utilities
|
| 44 |
+
"get_mac",
|
| 45 |
+
# System diagnostics
|
| 46 |
+
"get_system_diagnostics",
|
| 47 |
+
]
|
reachy_mini_home_assistant/core/config.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized configuration for Reachy Mini HA Voice.
|
| 2 |
+
|
| 3 |
+
This module provides a single source of truth for all configurable values,
|
| 4 |
+
organized by subsystem. Values can be overridden via environment variables
|
| 5 |
+
or a configuration file.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
from core.config import Config
|
| 9 |
+
|
| 10 |
+
# Access configuration
|
| 11 |
+
port = Config.ESPHOME_PORT
|
| 12 |
+
fps = Config.CAMERA_FPS
|
| 13 |
+
|
| 14 |
+
# Or use grouped access
|
| 15 |
+
camera_cfg = Config.camera
|
| 16 |
+
fps = camera_cfg.fps
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _env_bool(key: str, default: bool) -> bool:
|
| 29 |
+
"""Get boolean from environment variable."""
|
| 30 |
+
val = os.environ.get(key, "").lower()
|
| 31 |
+
if val in ("true", "1", "yes", "on"):
|
| 32 |
+
return True
|
| 33 |
+
if val in ("false", "0", "no", "off"):
|
| 34 |
+
return False
|
| 35 |
+
return default
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _env_float(key: str, default: float) -> float:
|
| 39 |
+
"""Get float from environment variable."""
|
| 40 |
+
try:
|
| 41 |
+
return float(os.environ.get(key, default))
|
| 42 |
+
except (ValueError, TypeError):
|
| 43 |
+
return default
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _env_int(key: str, default: int) -> int:
|
| 47 |
+
"""Get int from environment variable."""
|
| 48 |
+
try:
|
| 49 |
+
return int(os.environ.get(key, default))
|
| 50 |
+
except (ValueError, TypeError):
|
| 51 |
+
return default
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class DaemonConfig:
|
| 56 |
+
"""Configuration for daemon monitoring."""
|
| 57 |
+
|
| 58 |
+
url: str = "http://127.0.0.1:8000"
|
| 59 |
+
check_interval_active: float = 2.0 # seconds
|
| 60 |
+
check_interval_sleep: float = 8.0 # seconds
|
| 61 |
+
check_interval_error: float = 6.0 # seconds
|
| 62 |
+
max_backoff_interval: float = 15.0 # seconds
|
| 63 |
+
backoff_multiplier: float = 1.5
|
| 64 |
+
backoff_error_threshold: int = 2
|
| 65 |
+
status_cache_ttl: float = 2.0 # seconds
|
| 66 |
+
volume_cache_ttl: float = 3.0 # seconds
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class ESPHomeConfig:
|
| 71 |
+
"""Configuration for ESPHome protocol server."""
|
| 72 |
+
|
| 73 |
+
port: int = 6053
|
| 74 |
+
device_name: str = "reachy-mini"
|
| 75 |
+
friendly_name: str = "Reachy Mini"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class CameraConfig:
|
| 80 |
+
"""Configuration for camera and video streaming."""
|
| 81 |
+
|
| 82 |
+
# HTTP server
|
| 83 |
+
port: int = 8081
|
| 84 |
+
|
| 85 |
+
# Frame capture
|
| 86 |
+
fps_high: int = 15 # Active mode: smooth face tracking
|
| 87 |
+
fps_low: int = 10 # Low power: periodic face check
|
| 88 |
+
fps_idle: float = 5 # Ultra-low power: minimal CPU
|
| 89 |
+
|
| 90 |
+
# JPEG encoding
|
| 91 |
+
quality: int = 80
|
| 92 |
+
|
| 93 |
+
# Face tracking runtime tuning
|
| 94 |
+
face_confidence_threshold: float = 0.5 # Min confidence for face detection (0.3 too low, causes false positives)
|
| 95 |
+
face_lost_delay: float = 2.0 # Wait before returning to neutral
|
| 96 |
+
interpolation_duration: float = 1.0 # Time to return to neutral
|
| 97 |
+
offset_scale: float = 0.6 # Face offset multiplier
|
| 98 |
+
|
| 99 |
+
# Power management
|
| 100 |
+
low_power_threshold: float = 5.0 # Seconds without face -> low power
|
| 101 |
+
idle_threshold: float = 30.0 # Seconds without face -> idle
|
| 102 |
+
|
| 103 |
+
# Gesture detection runtime tuning
|
| 104 |
+
gesture_detection_interval: int = 1 # Run every frame for maximum gesture responsiveness
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class MotionConfig:
|
| 109 |
+
"""Configuration for motion control."""
|
| 110 |
+
|
| 111 |
+
# Control loop
|
| 112 |
+
control_rate_hz: float = 100.0
|
| 113 |
+
control_interval: float = 0.01 # 1 / control_rate_hz
|
| 114 |
+
max_send_rate_hz: float = 15.0 # Hard cap for set_target send rate
|
| 115 |
+
idle_heartbeat_interval_s: float = 1.0 # Keepalive interval when pose unchanged
|
| 116 |
+
|
| 117 |
+
# Face tracking
|
| 118 |
+
face_detected_threshold: float = 0.001 # Min offset to consider face detected
|
| 119 |
+
|
| 120 |
+
# Idle behavior
|
| 121 |
+
idle_look_around_min_interval: float = 8.0 # Min seconds between look-arounds
|
| 122 |
+
idle_look_around_max_interval: float = 20.0 # Max seconds between look-arounds
|
| 123 |
+
idle_inactivity_threshold: float = 5.0 # Seconds before look-around starts
|
| 124 |
+
|
| 125 |
+
# Animation
|
| 126 |
+
animation_fps: float = 30.0
|
| 127 |
+
|
| 128 |
+
# Smoothing
|
| 129 |
+
default_transition_duration: float = 0.3 # seconds
|
| 130 |
+
body_yaw_max_rate_deg_s: float = 85.0 # Faster body follow for smoother head/body coherence
|
| 131 |
+
body_yaw_deadband_rad: float = 0.0015 # Smaller deadband reduces visible stepwise catch-up
|
| 132 |
+
body_yaw_min_send_interval_s: float = 0.05 # Min interval for yaw updates
|
| 133 |
+
|
| 134 |
+
# Connection recovery backoff for set_target
|
| 135 |
+
reconnect_backoff_initial_s: float = 2.0
|
| 136 |
+
reconnect_backoff_max_s: float = 60.0
|
| 137 |
+
reconnect_backoff_multiplier: float = 2.0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@dataclass
|
| 141 |
+
class AudioConfig:
|
| 142 |
+
"""Configuration for audio processing."""
|
| 143 |
+
|
| 144 |
+
# Audio format
|
| 145 |
+
sample_rate: int = 16000
|
| 146 |
+
channels: int = 1
|
| 147 |
+
|
| 148 |
+
# Buffering
|
| 149 |
+
block_size: int = 1024 # samples
|
| 150 |
+
max_buffer_size: int = 10240 # samples (10 blocks)
|
| 151 |
+
|
| 152 |
+
# Idle pacing
|
| 153 |
+
idle_sleep_active: float = 0.01 # seconds
|
| 154 |
+
idle_sleep_sleeping: float = 0.1 # seconds
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@dataclass
|
| 158 |
+
class DOAConfig:
|
| 159 |
+
"""Configuration for Direction of Arrival (DOA) sound tracking."""
|
| 160 |
+
|
| 161 |
+
# Enable/disable DOA tracking
|
| 162 |
+
enabled: bool = True
|
| 163 |
+
|
| 164 |
+
# Threshold settings
|
| 165 |
+
energy_threshold: float = 0.3 # Min energy to consider sound significant
|
| 166 |
+
angle_threshold_deg: float = 15.0 # Min angle change to trigger turn
|
| 167 |
+
|
| 168 |
+
# Cooldown timing
|
| 169 |
+
direction_cooldown: float = 5.0 # Seconds before responding to same direction
|
| 170 |
+
min_turn_interval: float = 2.0 # Min seconds between any turns
|
| 171 |
+
|
| 172 |
+
# Turn behavior
|
| 173 |
+
turn_duration: float = 1.5 # Duration of turn animation
|
| 174 |
+
max_turn_angle_deg: float = 60.0 # Maximum turn angle
|
| 175 |
+
|
| 176 |
+
# Zone tracking
|
| 177 |
+
num_zones: int = 8 # Number of direction zones for cooldown
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@dataclass
|
| 181 |
+
class ShutdownConfig:
|
| 182 |
+
"""Configuration for shutdown behavior."""
|
| 183 |
+
|
| 184 |
+
audio_thread_join_timeout: float = 1.0 # seconds
|
| 185 |
+
camera_stop_timeout: float = 3.0 # seconds
|
| 186 |
+
server_close_timeout: float = 3.0 # seconds
|
| 187 |
+
sendspin_stop_timeout: float = 3.0 # seconds
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@dataclass
|
| 191 |
+
class RobotStateConfig:
|
| 192 |
+
"""Configuration for robot state monitoring."""
|
| 193 |
+
|
| 194 |
+
check_interval_active: float = 3.0 # seconds
|
| 195 |
+
check_interval_sleep: float = 8.0 # seconds
|
| 196 |
+
check_interval_error: float = 6.0 # seconds
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@dataclass
|
| 200 |
+
class APIConfig:
|
| 201 |
+
"""Configuration for the HTTP API server."""
|
| 202 |
+
|
| 203 |
+
port: int = 8080
|
| 204 |
+
host: str = "0.0.0.0"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class Config:
|
| 208 |
+
"""Centralized configuration access.
|
| 209 |
+
|
| 210 |
+
All configuration values are accessible as class attributes.
|
| 211 |
+
Grouped configs are available via nested dataclasses.
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
# Subsystem configurations
|
| 215 |
+
daemon: DaemonConfig = DaemonConfig()
|
| 216 |
+
esphome: ESPHomeConfig = ESPHomeConfig()
|
| 217 |
+
camera: CameraConfig = CameraConfig()
|
| 218 |
+
motion: MotionConfig = MotionConfig()
|
| 219 |
+
audio: AudioConfig = AudioConfig()
|
| 220 |
+
doa: DOAConfig = DOAConfig()
|
| 221 |
+
robot_state: RobotStateConfig = RobotStateConfig()
|
| 222 |
+
shutdown: ShutdownConfig = ShutdownConfig()
|
| 223 |
+
api: APIConfig = APIConfig()
|
| 224 |
+
|
| 225 |
+
_initialized = False
|
| 226 |
+
_config_file: Path | None = None
|
| 227 |
+
|
| 228 |
+
@classmethod
|
| 229 |
+
def load_from_file(cls, path: Path) -> None:
|
| 230 |
+
"""Load configuration overrides from a JSON file.
|
| 231 |
+
|
| 232 |
+
Args:
|
| 233 |
+
path: Path to the JSON configuration file
|
| 234 |
+
"""
|
| 235 |
+
if not path.exists():
|
| 236 |
+
logger.debug(f"Config file not found: {path}")
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
+
try:
|
| 240 |
+
with open(path, encoding="utf-8") as f:
|
| 241 |
+
data = json.load(f)
|
| 242 |
+
|
| 243 |
+
cls._apply_overrides(data)
|
| 244 |
+
cls._config_file = path
|
| 245 |
+
logger.info(f"Loaded configuration from {path}")
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Failed to load config file: {e}")
|
| 248 |
+
|
| 249 |
+
@classmethod
|
| 250 |
+
def load_from_env(cls) -> None:
|
| 251 |
+
"""Load configuration overrides from environment variables.
|
| 252 |
+
|
| 253 |
+
Environment variables follow the pattern: REACHY_<SECTION>_<KEY>
|
| 254 |
+
Example: REACHY_CAMERA_FPS=30
|
| 255 |
+
"""
|
| 256 |
+
# Daemon
|
| 257 |
+
cls.daemon.url = os.environ.get("REACHY_DAEMON_URL", cls.daemon.url)
|
| 258 |
+
cls.daemon.check_interval_active = _env_float(
|
| 259 |
+
"REACHY_DAEMON_CHECK_INTERVAL_ACTIVE", cls.daemon.check_interval_active
|
| 260 |
+
)
|
| 261 |
+
cls.daemon.check_interval_sleep = _env_float(
|
| 262 |
+
"REACHY_DAEMON_CHECK_INTERVAL_SLEEP", cls.daemon.check_interval_sleep
|
| 263 |
+
)
|
| 264 |
+
cls.daemon.check_interval_error = _env_float(
|
| 265 |
+
"REACHY_DAEMON_CHECK_INTERVAL_ERROR", cls.daemon.check_interval_error
|
| 266 |
+
)
|
| 267 |
+
cls.daemon.max_backoff_interval = _env_float(
|
| 268 |
+
"REACHY_DAEMON_MAX_BACKOFF_INTERVAL", cls.daemon.max_backoff_interval
|
| 269 |
+
)
|
| 270 |
+
cls.daemon.backoff_multiplier = _env_float("REACHY_DAEMON_BACKOFF_MULTIPLIER", cls.daemon.backoff_multiplier)
|
| 271 |
+
cls.daemon.backoff_error_threshold = _env_int(
|
| 272 |
+
"REACHY_DAEMON_BACKOFF_ERROR_THRESHOLD", cls.daemon.backoff_error_threshold
|
| 273 |
+
)
|
| 274 |
+
cls.daemon.status_cache_ttl = _env_float("REACHY_DAEMON_STATUS_CACHE_TTL", cls.daemon.status_cache_ttl)
|
| 275 |
+
cls.daemon.volume_cache_ttl = _env_float("REACHY_DAEMON_VOLUME_CACHE_TTL", cls.daemon.volume_cache_ttl)
|
| 276 |
+
|
| 277 |
+
# ESPHome
|
| 278 |
+
cls.esphome.port = _env_int("REACHY_ESPHOME_PORT", cls.esphome.port)
|
| 279 |
+
cls.esphome.device_name = os.environ.get("REACHY_ESPHOME_DEVICE_NAME", cls.esphome.device_name)
|
| 280 |
+
|
| 281 |
+
# Camera
|
| 282 |
+
cls.camera.port = _env_int("REACHY_CAMERA_PORT", cls.camera.port)
|
| 283 |
+
|
| 284 |
+
# Motion
|
| 285 |
+
cls.motion.control_rate_hz = _env_float("REACHY_MOTION_CONTROL_RATE", cls.motion.control_rate_hz)
|
| 286 |
+
cls.motion.max_send_rate_hz = _env_float("REACHY_MOTION_MAX_SEND_RATE", cls.motion.max_send_rate_hz)
|
| 287 |
+
cls.motion.idle_heartbeat_interval_s = _env_float(
|
| 288 |
+
"REACHY_MOTION_IDLE_HEARTBEAT_INTERVAL", cls.motion.idle_heartbeat_interval_s
|
| 289 |
+
)
|
| 290 |
+
cls.motion.reconnect_backoff_initial_s = _env_float(
|
| 291 |
+
"REACHY_MOTION_RECONNECT_BACKOFF_INITIAL", cls.motion.reconnect_backoff_initial_s
|
| 292 |
+
)
|
| 293 |
+
cls.motion.reconnect_backoff_max_s = _env_float(
|
| 294 |
+
"REACHY_MOTION_RECONNECT_BACKOFF_MAX", cls.motion.reconnect_backoff_max_s
|
| 295 |
+
)
|
| 296 |
+
cls.motion.reconnect_backoff_multiplier = _env_float(
|
| 297 |
+
"REACHY_MOTION_RECONNECT_BACKOFF_MULTIPLIER", cls.motion.reconnect_backoff_multiplier
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Audio
|
| 301 |
+
cls.audio.idle_sleep_active = _env_float("REACHY_AUDIO_IDLE_SLEEP_ACTIVE", cls.audio.idle_sleep_active)
|
| 302 |
+
cls.audio.idle_sleep_sleeping = _env_float("REACHY_AUDIO_IDLE_SLEEP_SLEEPING", cls.audio.idle_sleep_sleeping)
|
| 303 |
+
|
| 304 |
+
# Robot state
|
| 305 |
+
cls.robot_state.check_interval_active = _env_float(
|
| 306 |
+
"REACHY_ROBOT_STATE_CHECK_INTERVAL_ACTIVE", cls.robot_state.check_interval_active
|
| 307 |
+
)
|
| 308 |
+
cls.robot_state.check_interval_sleep = _env_float(
|
| 309 |
+
"REACHY_ROBOT_STATE_CHECK_INTERVAL_SLEEP", cls.robot_state.check_interval_sleep
|
| 310 |
+
)
|
| 311 |
+
cls.robot_state.check_interval_error = _env_float(
|
| 312 |
+
"REACHY_ROBOT_STATE_CHECK_INTERVAL_ERROR", cls.robot_state.check_interval_error
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
logger.debug("Loaded configuration from environment")
|
| 316 |
+
|
| 317 |
+
@classmethod
|
| 318 |
+
def _apply_overrides(cls, data: dict) -> None:
|
| 319 |
+
"""Apply configuration overrides from a dictionary."""
|
| 320 |
+
if "daemon" in data:
|
| 321 |
+
for key, value in data["daemon"].items():
|
| 322 |
+
if hasattr(cls.daemon, key):
|
| 323 |
+
setattr(cls.daemon, key, value)
|
| 324 |
+
|
| 325 |
+
if "esphome" in data:
|
| 326 |
+
for key, value in data["esphome"].items():
|
| 327 |
+
if hasattr(cls.esphome, key):
|
| 328 |
+
setattr(cls.esphome, key, value)
|
| 329 |
+
|
| 330 |
+
if "camera" in data:
|
| 331 |
+
for key, value in data["camera"].items():
|
| 332 |
+
if hasattr(cls.camera, key):
|
| 333 |
+
setattr(cls.camera, key, value)
|
| 334 |
+
|
| 335 |
+
if "motion" in data:
|
| 336 |
+
for key, value in data["motion"].items():
|
| 337 |
+
if hasattr(cls.motion, key):
|
| 338 |
+
setattr(cls.motion, key, value)
|
| 339 |
+
|
| 340 |
+
if "audio" in data:
|
| 341 |
+
for key, value in data["audio"].items():
|
| 342 |
+
if hasattr(cls.audio, key):
|
| 343 |
+
setattr(cls.audio, key, value)
|
| 344 |
+
|
| 345 |
+
if "doa" in data:
|
| 346 |
+
for key, value in data["doa"].items():
|
| 347 |
+
if hasattr(cls.doa, key):
|
| 348 |
+
setattr(cls.doa, key, value)
|
| 349 |
+
|
| 350 |
+
if "robot_state" in data:
|
| 351 |
+
for key, value in data["robot_state"].items():
|
| 352 |
+
if hasattr(cls.robot_state, key):
|
| 353 |
+
setattr(cls.robot_state, key, value)
|
| 354 |
+
|
| 355 |
+
if "api" in data:
|
| 356 |
+
for key, value in data["api"].items():
|
| 357 |
+
if hasattr(cls.api, key):
|
| 358 |
+
setattr(cls.api, key, value)
|
| 359 |
+
|
| 360 |
+
@classmethod
|
| 361 |
+
def initialize(cls, config_file: Path | None = None) -> None:
|
| 362 |
+
"""Initialize configuration.
|
| 363 |
+
|
| 364 |
+
Loads from config file if provided, then applies environment overrides.
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
config_file: Optional path to JSON configuration file
|
| 368 |
+
"""
|
| 369 |
+
if cls._initialized:
|
| 370 |
+
return
|
| 371 |
+
|
| 372 |
+
if config_file:
|
| 373 |
+
cls.load_from_file(config_file)
|
| 374 |
+
|
| 375 |
+
cls.load_from_env()
|
| 376 |
+
cls._initialized = True
|
| 377 |
+
|
| 378 |
+
@classmethod
|
| 379 |
+
def to_dict(cls) -> dict:
|
| 380 |
+
"""Export current configuration as a dictionary."""
|
| 381 |
+
return {
|
| 382 |
+
"daemon": {
|
| 383 |
+
"url": cls.daemon.url,
|
| 384 |
+
"check_interval_active": cls.daemon.check_interval_active,
|
| 385 |
+
"check_interval_sleep": cls.daemon.check_interval_sleep,
|
| 386 |
+
"check_interval_error": cls.daemon.check_interval_error,
|
| 387 |
+
"max_backoff_interval": cls.daemon.max_backoff_interval,
|
| 388 |
+
"backoff_multiplier": cls.daemon.backoff_multiplier,
|
| 389 |
+
"backoff_error_threshold": cls.daemon.backoff_error_threshold,
|
| 390 |
+
"status_cache_ttl": cls.daemon.status_cache_ttl,
|
| 391 |
+
"volume_cache_ttl": cls.daemon.volume_cache_ttl,
|
| 392 |
+
},
|
| 393 |
+
"esphome": {
|
| 394 |
+
"port": cls.esphome.port,
|
| 395 |
+
"device_name": cls.esphome.device_name,
|
| 396 |
+
"friendly_name": cls.esphome.friendly_name,
|
| 397 |
+
},
|
| 398 |
+
"camera": {
|
| 399 |
+
"port": cls.camera.port,
|
| 400 |
+
},
|
| 401 |
+
"motion": {
|
| 402 |
+
"control_rate_hz": cls.motion.control_rate_hz,
|
| 403 |
+
"animation_fps": cls.motion.animation_fps,
|
| 404 |
+
"max_send_rate_hz": cls.motion.max_send_rate_hz,
|
| 405 |
+
"idle_heartbeat_interval_s": cls.motion.idle_heartbeat_interval_s,
|
| 406 |
+
"reconnect_backoff_initial_s": cls.motion.reconnect_backoff_initial_s,
|
| 407 |
+
"reconnect_backoff_max_s": cls.motion.reconnect_backoff_max_s,
|
| 408 |
+
"reconnect_backoff_multiplier": cls.motion.reconnect_backoff_multiplier,
|
| 409 |
+
},
|
| 410 |
+
"audio": {
|
| 411 |
+
"sample_rate": cls.audio.sample_rate,
|
| 412 |
+
"block_size": cls.audio.block_size,
|
| 413 |
+
"idle_sleep_active": cls.audio.idle_sleep_active,
|
| 414 |
+
"idle_sleep_sleeping": cls.audio.idle_sleep_sleeping,
|
| 415 |
+
},
|
| 416 |
+
"doa": {
|
| 417 |
+
"enabled": cls.doa.enabled,
|
| 418 |
+
"energy_threshold": cls.doa.energy_threshold,
|
| 419 |
+
"angle_threshold_deg": cls.doa.angle_threshold_deg,
|
| 420 |
+
"direction_cooldown": cls.doa.direction_cooldown,
|
| 421 |
+
"min_turn_interval": cls.doa.min_turn_interval,
|
| 422 |
+
"turn_duration": cls.doa.turn_duration,
|
| 423 |
+
"max_turn_angle_deg": cls.doa.max_turn_angle_deg,
|
| 424 |
+
"num_zones": cls.doa.num_zones,
|
| 425 |
+
},
|
| 426 |
+
"robot_state": {
|
| 427 |
+
"check_interval_active": cls.robot_state.check_interval_active,
|
| 428 |
+
"check_interval_sleep": cls.robot_state.check_interval_sleep,
|
| 429 |
+
"check_interval_error": cls.robot_state.check_interval_error,
|
| 430 |
+
},
|
| 431 |
+
"api": {
|
| 432 |
+
"port": cls.api.port,
|
| 433 |
+
"host": cls.api.host,
|
| 434 |
+
},
|
| 435 |
+
}
|
reachy_mini_home_assistant/core/exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exceptions for Reachy Mini HA Voice.
|
| 2 |
+
|
| 3 |
+
This module defines application-specific exceptions for better
|
| 4 |
+
error handling and debugging.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ReachyHAError(Exception):
|
| 9 |
+
"""Base exception for Reachy HA Voice errors."""
|
| 10 |
+
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RobotConnectionError(ReachyHAError):
|
| 15 |
+
"""Error connecting to or communicating with the robot."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, message: str = "Robot connection failed", cause: Exception = None):
|
| 18 |
+
super().__init__(message)
|
| 19 |
+
self.cause = cause
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DaemonUnavailableError(ReachyHAError):
|
| 23 |
+
"""The Reachy Mini daemon is not available."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, message: str = "Daemon unavailable"):
|
| 26 |
+
super().__init__(message)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ServiceSuspendedError(ReachyHAError):
|
| 30 |
+
"""Operation attempted while service is suspended for sleep."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, service_name: str):
|
| 33 |
+
super().__init__(f"Service '{service_name}' is suspended")
|
| 34 |
+
self.service_name = service_name
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ResourceUnavailableError(ReachyHAError):
|
| 38 |
+
"""A required resource is not available."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, resource_name: str, reason: str = None):
|
| 41 |
+
message = f"Resource '{resource_name}' unavailable"
|
| 42 |
+
if reason:
|
| 43 |
+
message += f": {reason}"
|
| 44 |
+
super().__init__(message)
|
| 45 |
+
self.resource_name = resource_name
|
| 46 |
+
self.reason = reason
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ModelLoadError(ReachyHAError):
|
| 50 |
+
"""Error loading an ML model."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, model_name: str, cause: Exception = None):
|
| 53 |
+
super().__init__(f"Failed to load model: {model_name}")
|
| 54 |
+
self.model_name = model_name
|
| 55 |
+
self.cause = cause
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ConfigurationError(ReachyHAError):
|
| 59 |
+
"""Configuration error."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, message: str, key: str = None):
|
| 62 |
+
super().__init__(message)
|
| 63 |
+
self.key = key
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class EntityRegistrationError(ReachyHAError):
|
| 67 |
+
"""Error registering an ESPHome entity."""
|
| 68 |
+
|
| 69 |
+
def __init__(self, entity_name: str, cause: Exception = None):
|
| 70 |
+
super().__init__(f"Failed to register entity: {entity_name}")
|
| 71 |
+
self.entity_name = entity_name
|
| 72 |
+
self.cause = cause
|
reachy_mini_home_assistant/core/service_base.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base classes for sleep-aware services.
|
| 2 |
+
|
| 3 |
+
This module provides the SleepAwareService abstract base class that all
|
| 4 |
+
services responding to sleep/wake events should implement.
|
| 5 |
+
|
| 6 |
+
The sleep-aware lifecycle:
|
| 7 |
+
1. Service starts in active state
|
| 8 |
+
2. When robot sleeps: suspend() is called -> release resources
|
| 9 |
+
3. When robot wakes: resume() is called -> restore resources
|
| 10 |
+
4. Service can be stopped completely via stop()
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from abc import ABC, abstractmethod
|
| 17 |
+
from collections.abc import Callable
|
| 18 |
+
from enum import Enum
|
| 19 |
+
from typing import Any, TypeVar
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# Type variable for generic return types
|
| 24 |
+
T = TypeVar("T")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class RobustOperationMixin:
|
| 28 |
+
"""Mixin that adds automatic error tracking and recovery to services.
|
| 29 |
+
|
| 30 |
+
This mixin provides a pattern for executing operations with automatic
|
| 31 |
+
error counting, timeout-based error rate reset, and optional restart
|
| 32 |
+
triggers when error thresholds are exceeded.
|
| 33 |
+
|
| 34 |
+
Usage:
|
| 35 |
+
class MyService(RobustOperationMixin):
|
| 36 |
+
def __init__(self):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self._init_error_tracking()
|
| 39 |
+
|
| 40 |
+
def do_something(self):
|
| 41 |
+
def operation():
|
| 42 |
+
# Actual work here
|
| 43 |
+
pass
|
| 44 |
+
return self._execute_with_recovery(operation)
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
# Default configuration (can be overridden per-service)
|
| 48 |
+
_max_consecutive_errors: int = 5
|
| 49 |
+
_error_reset_interval: float = 60.0 # seconds
|
| 50 |
+
_restart_on_max_errors: bool = False
|
| 51 |
+
|
| 52 |
+
def _init_error_tracking(
|
| 53 |
+
self,
|
| 54 |
+
max_errors: int = 5,
|
| 55 |
+
reset_interval: float = 60.0,
|
| 56 |
+
restart_on_max_errors: bool = False,
|
| 57 |
+
) -> None:
|
| 58 |
+
"""Initialize error tracking with custom configuration.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
max_errors: Maximum consecutive errors before triggering action
|
| 62 |
+
reset_interval: Time in seconds before error count resets
|
| 63 |
+
restart_on_max_errors: Whether to trigger restart on max errors
|
| 64 |
+
"""
|
| 65 |
+
self._error_count = 0
|
| 66 |
+
self._last_error_time = 0.0
|
| 67 |
+
self._max_consecutive_errors = max_errors
|
| 68 |
+
self._error_reset_interval = reset_interval
|
| 69 |
+
self._restart_on_max_errors = restart_on_max_errors
|
| 70 |
+
self._restart_callback: Callable | None = None
|
| 71 |
+
self._error_logger = logging.getLogger(f"{__name__}.robust")
|
| 72 |
+
|
| 73 |
+
def set_restart_callback(self, callback: Callable) -> None:
|
| 74 |
+
"""Set a callback to be called when max errors is reached.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
callback: Function to call for service restart/recovery
|
| 78 |
+
"""
|
| 79 |
+
self._restart_callback = callback
|
| 80 |
+
|
| 81 |
+
def _handle_error(self, error: Exception) -> bool:
|
| 82 |
+
"""Track an error and determine if action is needed.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
error: The exception that occurred
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
True if max errors reached and action should be taken
|
| 89 |
+
"""
|
| 90 |
+
now = time.monotonic()
|
| 91 |
+
|
| 92 |
+
# Reset error count if enough time has passed since last error
|
| 93 |
+
if now - self._last_error_time > self._error_reset_interval:
|
| 94 |
+
self._error_count = 0
|
| 95 |
+
|
| 96 |
+
self._error_count += 1
|
| 97 |
+
self._last_error_time = now
|
| 98 |
+
|
| 99 |
+
# Log with frequency limiting
|
| 100 |
+
if self._error_count <= 3 or self._error_count == self._max_consecutive_errors:
|
| 101 |
+
self._error_logger.error(
|
| 102 |
+
"Service error (%d/%d): %s",
|
| 103 |
+
self._error_count,
|
| 104 |
+
self._max_consecutive_errors,
|
| 105 |
+
error,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
return self._error_count >= self._max_consecutive_errors
|
| 109 |
+
|
| 110 |
+
def _reset_error_count(self) -> None:
|
| 111 |
+
"""Reset the error counter after successful operation."""
|
| 112 |
+
self._error_count = min(self._error_count, 0)
|
| 113 |
+
|
| 114 |
+
def _execute_with_recovery(
|
| 115 |
+
self,
|
| 116 |
+
operation: Callable[[], T],
|
| 117 |
+
*args,
|
| 118 |
+
suppress_errors: bool = False,
|
| 119 |
+
default_return: T = None,
|
| 120 |
+
**kwargs,
|
| 121 |
+
) -> T:
|
| 122 |
+
"""Execute an operation with automatic error tracking.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
operation: The function to execute
|
| 126 |
+
*args: Arguments to pass to operation
|
| 127 |
+
suppress_errors: If True, return default_return instead of raising
|
| 128 |
+
default_return: Value to return on error if suppress_errors=True
|
| 129 |
+
**kwargs: Keyword arguments to pass to operation
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
The operation result, or default_return on suppressed error
|
| 133 |
+
|
| 134 |
+
Raises:
|
| 135 |
+
The original exception if not suppressed
|
| 136 |
+
"""
|
| 137 |
+
try:
|
| 138 |
+
result = operation(*args, **kwargs)
|
| 139 |
+
self._reset_error_count()
|
| 140 |
+
return result
|
| 141 |
+
except Exception as e:
|
| 142 |
+
should_restart = self._handle_error(e)
|
| 143 |
+
|
| 144 |
+
if should_restart and self._restart_on_max_errors:
|
| 145 |
+
if self._restart_callback is not None:
|
| 146 |
+
self._error_logger.warning("Max errors reached - triggering restart")
|
| 147 |
+
try:
|
| 148 |
+
self._restart_callback()
|
| 149 |
+
except Exception as restart_error:
|
| 150 |
+
self._error_logger.error("Restart failed: %s", restart_error)
|
| 151 |
+
|
| 152 |
+
if suppress_errors:
|
| 153 |
+
return default_return
|
| 154 |
+
raise
|
| 155 |
+
|
| 156 |
+
async def _execute_async_with_recovery(
|
| 157 |
+
self,
|
| 158 |
+
operation: Callable[..., Any],
|
| 159 |
+
*args,
|
| 160 |
+
suppress_errors: bool = False,
|
| 161 |
+
default_return: T = None,
|
| 162 |
+
**kwargs,
|
| 163 |
+
) -> T:
|
| 164 |
+
"""Async version of _execute_with_recovery.
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
operation: The async function to execute
|
| 168 |
+
*args: Arguments to pass to operation
|
| 169 |
+
suppress_errors: If True, return default_return instead of raising
|
| 170 |
+
default_return: Value to return on error if suppress_errors=True
|
| 171 |
+
**kwargs: Keyword arguments to pass to operation
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
The operation result, or default_return on suppressed error
|
| 175 |
+
|
| 176 |
+
Raises:
|
| 177 |
+
The original exception if not suppressed
|
| 178 |
+
"""
|
| 179 |
+
try:
|
| 180 |
+
result = await operation(*args, **kwargs)
|
| 181 |
+
self._reset_error_count()
|
| 182 |
+
return result
|
| 183 |
+
except Exception as e:
|
| 184 |
+
should_restart = self._handle_error(e)
|
| 185 |
+
|
| 186 |
+
if should_restart and self._restart_on_max_errors:
|
| 187 |
+
if self._restart_callback is not None:
|
| 188 |
+
self._error_logger.warning("Max errors reached - triggering restart")
|
| 189 |
+
try:
|
| 190 |
+
if asyncio.iscoroutinefunction(self._restart_callback):
|
| 191 |
+
await self._restart_callback()
|
| 192 |
+
else:
|
| 193 |
+
self._restart_callback()
|
| 194 |
+
except Exception as restart_error:
|
| 195 |
+
self._error_logger.error("Restart failed: %s", restart_error)
|
| 196 |
+
|
| 197 |
+
if suppress_errors:
|
| 198 |
+
return default_return
|
| 199 |
+
raise
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class ServiceState(Enum):
|
| 203 |
+
"""Represents the state of a sleep-aware service."""
|
| 204 |
+
|
| 205 |
+
STOPPED = "stopped" # Service not started
|
| 206 |
+
STARTING = "starting" # Service is starting up
|
| 207 |
+
ACTIVE = "active" # Service is fully operational
|
| 208 |
+
SUSPENDING = "suspending" # Service is being suspended (sleep)
|
| 209 |
+
SUSPENDED = "suspended" # Service is suspended (sleeping)
|
| 210 |
+
RESUMING = "resuming" # Service is resuming from sleep
|
| 211 |
+
STOPPING = "stopping" # Service is shutting down
|
| 212 |
+
ERROR = "error" # Service encountered an error
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class SleepAwareService(ABC):
|
| 216 |
+
"""Abstract base class for services that respond to sleep/wake events.
|
| 217 |
+
|
| 218 |
+
Services implementing this interface will have their resources managed
|
| 219 |
+
during robot sleep/wake cycles. When the robot goes to sleep, suspend()
|
| 220 |
+
is called to release resources. When it wakes, resume() restores them.
|
| 221 |
+
|
| 222 |
+
Example:
|
| 223 |
+
class CameraService(SleepAwareService):
|
| 224 |
+
@property
|
| 225 |
+
def service_name(self) -> str:
|
| 226 |
+
return "camera"
|
| 227 |
+
|
| 228 |
+
async def _do_start(self) -> None:
|
| 229 |
+
self._init_camera()
|
| 230 |
+
self._start_streaming()
|
| 231 |
+
|
| 232 |
+
async def _do_suspend(self) -> None:
|
| 233 |
+
self._stop_streaming()
|
| 234 |
+
self._release_camera()
|
| 235 |
+
|
| 236 |
+
async def _do_resume(self) -> None:
|
| 237 |
+
self._init_camera()
|
| 238 |
+
self._start_streaming()
|
| 239 |
+
|
| 240 |
+
async def _do_stop(self) -> None:
|
| 241 |
+
self._stop_streaming()
|
| 242 |
+
self._release_camera()
|
| 243 |
+
"""
|
| 244 |
+
|
| 245 |
+
def __init__(self):
|
| 246 |
+
"""Initialize the service."""
|
| 247 |
+
self._state = ServiceState.STOPPED
|
| 248 |
+
self._state_lock = asyncio.Lock()
|
| 249 |
+
self._logger = logging.getLogger(f"{__name__}.{self.service_name}")
|
| 250 |
+
|
| 251 |
+
@property
|
| 252 |
+
@abstractmethod
|
| 253 |
+
def service_name(self) -> str:
|
| 254 |
+
"""Return the name of this service for logging and identification."""
|
| 255 |
+
pass
|
| 256 |
+
|
| 257 |
+
@property
|
| 258 |
+
def state(self) -> ServiceState:
|
| 259 |
+
"""Get the current service state."""
|
| 260 |
+
return self._state
|
| 261 |
+
|
| 262 |
+
@property
|
| 263 |
+
def is_active(self) -> bool:
|
| 264 |
+
"""Check if the service is currently active."""
|
| 265 |
+
return self._state == ServiceState.ACTIVE
|
| 266 |
+
|
| 267 |
+
@property
|
| 268 |
+
def is_suspended(self) -> bool:
|
| 269 |
+
"""Check if the service is currently suspended."""
|
| 270 |
+
return self._state == ServiceState.SUSPENDED
|
| 271 |
+
|
| 272 |
+
@property
|
| 273 |
+
def is_running(self) -> bool:
|
| 274 |
+
"""Check if the service is running (active or suspended)."""
|
| 275 |
+
return self._state in (
|
| 276 |
+
ServiceState.ACTIVE,
|
| 277 |
+
ServiceState.SUSPENDED,
|
| 278 |
+
ServiceState.SUSPENDING,
|
| 279 |
+
ServiceState.RESUMING,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
async def start(self) -> None:
|
| 283 |
+
"""Start the service.
|
| 284 |
+
|
| 285 |
+
This initializes and activates the service. Should only be called
|
| 286 |
+
when the service is in STOPPED state.
|
| 287 |
+
"""
|
| 288 |
+
async with self._state_lock:
|
| 289 |
+
if self._state != ServiceState.STOPPED:
|
| 290 |
+
self._logger.warning(f"Cannot start service in state {self._state.value}")
|
| 291 |
+
return
|
| 292 |
+
|
| 293 |
+
self._state = ServiceState.STARTING
|
| 294 |
+
self._logger.info(f"Starting {self.service_name}...")
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
await self._do_start()
|
| 298 |
+
async with self._state_lock:
|
| 299 |
+
self._state = ServiceState.ACTIVE
|
| 300 |
+
self._logger.info(f"{self.service_name} started successfully")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
async with self._state_lock:
|
| 303 |
+
self._state = ServiceState.ERROR
|
| 304 |
+
self._logger.error(f"Failed to start {self.service_name}: {e}")
|
| 305 |
+
raise
|
| 306 |
+
|
| 307 |
+
async def stop(self) -> None:
|
| 308 |
+
"""Stop the service completely.
|
| 309 |
+
|
| 310 |
+
This releases all resources and stops the service. Can be called
|
| 311 |
+
from any running state.
|
| 312 |
+
"""
|
| 313 |
+
async with self._state_lock:
|
| 314 |
+
if self._state == ServiceState.STOPPED:
|
| 315 |
+
return
|
| 316 |
+
if self._state == ServiceState.STOPPING:
|
| 317 |
+
self._logger.debug("Service already stopping")
|
| 318 |
+
return
|
| 319 |
+
|
| 320 |
+
self._state = ServiceState.STOPPING
|
| 321 |
+
self._logger.info(f"Stopping {self.service_name}...")
|
| 322 |
+
|
| 323 |
+
try:
|
| 324 |
+
await self._do_stop()
|
| 325 |
+
async with self._state_lock:
|
| 326 |
+
self._state = ServiceState.STOPPED
|
| 327 |
+
self._logger.info(f"{self.service_name} stopped successfully")
|
| 328 |
+
except Exception as e:
|
| 329 |
+
async with self._state_lock:
|
| 330 |
+
self._state = ServiceState.ERROR
|
| 331 |
+
self._logger.error(f"Error stopping {self.service_name}: {e}")
|
| 332 |
+
raise
|
| 333 |
+
|
| 334 |
+
async def suspend(self) -> None:
|
| 335 |
+
"""Suspend the service (for robot sleep).
|
| 336 |
+
|
| 337 |
+
This releases resources while keeping the service in a resumable state.
|
| 338 |
+
Should only be called when the service is ACTIVE.
|
| 339 |
+
"""
|
| 340 |
+
async with self._state_lock:
|
| 341 |
+
if self._state != ServiceState.ACTIVE:
|
| 342 |
+
self._logger.warning(f"Cannot suspend service in state {self._state.value}")
|
| 343 |
+
return
|
| 344 |
+
|
| 345 |
+
self._state = ServiceState.SUSPENDING
|
| 346 |
+
self._logger.info(f"Suspending {self.service_name}...")
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
await self._do_suspend()
|
| 350 |
+
async with self._state_lock:
|
| 351 |
+
self._state = ServiceState.SUSPENDED
|
| 352 |
+
self._logger.info(f"{self.service_name} suspended")
|
| 353 |
+
except Exception as e:
|
| 354 |
+
async with self._state_lock:
|
| 355 |
+
self._state = ServiceState.ERROR
|
| 356 |
+
self._logger.error(f"Error suspending {self.service_name}: {e}")
|
| 357 |
+
raise
|
| 358 |
+
|
| 359 |
+
async def resume(self) -> None:
|
| 360 |
+
"""Resume the service (after robot wake).
|
| 361 |
+
|
| 362 |
+
This restores resources and re-activates the service.
|
| 363 |
+
Should only be called when the service is SUSPENDED.
|
| 364 |
+
"""
|
| 365 |
+
async with self._state_lock:
|
| 366 |
+
if self._state != ServiceState.SUSPENDED:
|
| 367 |
+
self._logger.warning(f"Cannot resume service in state {self._state.value}")
|
| 368 |
+
return
|
| 369 |
+
|
| 370 |
+
self._state = ServiceState.RESUMING
|
| 371 |
+
self._logger.info(f"Resuming {self.service_name}...")
|
| 372 |
+
|
| 373 |
+
try:
|
| 374 |
+
await self._do_resume()
|
| 375 |
+
async with self._state_lock:
|
| 376 |
+
self._state = ServiceState.ACTIVE
|
| 377 |
+
self._logger.info(f"{self.service_name} resumed")
|
| 378 |
+
except Exception as e:
|
| 379 |
+
async with self._state_lock:
|
| 380 |
+
self._state = ServiceState.ERROR
|
| 381 |
+
self._logger.error(f"Error resuming {self.service_name}: {e}")
|
| 382 |
+
raise
|
| 383 |
+
|
| 384 |
+
@abstractmethod
|
| 385 |
+
async def _do_start(self) -> None:
|
| 386 |
+
"""Implementation-specific start logic.
|
| 387 |
+
|
| 388 |
+
Subclasses should implement this to initialize and start their
|
| 389 |
+
resources.
|
| 390 |
+
"""
|
| 391 |
+
pass
|
| 392 |
+
|
| 393 |
+
@abstractmethod
|
| 394 |
+
async def _do_stop(self) -> None:
|
| 395 |
+
"""Implementation-specific stop logic.
|
| 396 |
+
|
| 397 |
+
Subclasses should implement this to release all resources and
|
| 398 |
+
stop any background tasks.
|
| 399 |
+
"""
|
| 400 |
+
pass
|
| 401 |
+
|
| 402 |
+
@abstractmethod
|
| 403 |
+
async def _do_suspend(self) -> None:
|
| 404 |
+
"""Implementation-specific suspend logic.
|
| 405 |
+
|
| 406 |
+
Subclasses should implement this to release resources that are
|
| 407 |
+
not needed during sleep, while keeping the service in a resumable
|
| 408 |
+
state.
|
| 409 |
+
|
| 410 |
+
Typical actions:
|
| 411 |
+
- Stop background threads
|
| 412 |
+
- Release ML models from memory
|
| 413 |
+
- Close network connections
|
| 414 |
+
- Stop timers
|
| 415 |
+
"""
|
| 416 |
+
pass
|
| 417 |
+
|
| 418 |
+
@abstractmethod
|
| 419 |
+
async def _do_resume(self) -> None:
|
| 420 |
+
"""Implementation-specific resume logic.
|
| 421 |
+
|
| 422 |
+
Subclasses should implement this to restore resources and
|
| 423 |
+
re-activate the service after sleep.
|
| 424 |
+
|
| 425 |
+
Typical actions:
|
| 426 |
+
- Restart background threads
|
| 427 |
+
- Reload ML models
|
| 428 |
+
- Re-establish network connections
|
| 429 |
+
- Restart timers
|
| 430 |
+
"""
|
| 431 |
+
pass
|
| 432 |
+
|
| 433 |
+
async def __aenter__(self) -> "SleepAwareService":
|
| 434 |
+
"""Context manager entry - starts the service."""
|
| 435 |
+
await self.start()
|
| 436 |
+
return self
|
| 437 |
+
|
| 438 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
|
| 439 |
+
"""Context manager exit - stops the service."""
|
| 440 |
+
await self.stop()
|
| 441 |
+
return False
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
class ServiceManager:
|
| 445 |
+
"""Manages multiple SleepAwareService instances.
|
| 446 |
+
|
| 447 |
+
Provides coordinated suspend/resume for all registered services,
|
| 448 |
+
ensuring proper ordering and error handling.
|
| 449 |
+
|
| 450 |
+
Usage:
|
| 451 |
+
manager = ServiceManager()
|
| 452 |
+
manager.register(camera_service)
|
| 453 |
+
manager.register(motion_service)
|
| 454 |
+
|
| 455 |
+
# Suspend all services
|
| 456 |
+
await manager.suspend_all()
|
| 457 |
+
|
| 458 |
+
# Resume all services after delay
|
| 459 |
+
await asyncio.sleep(30)
|
| 460 |
+
await manager.resume_all()
|
| 461 |
+
"""
|
| 462 |
+
|
| 463 |
+
def __init__(self, resume_delay: float = 30.0):
|
| 464 |
+
"""Initialize the service manager.
|
| 465 |
+
|
| 466 |
+
Args:
|
| 467 |
+
resume_delay: Delay in seconds before resuming services after wake
|
| 468 |
+
"""
|
| 469 |
+
self._services: list[SleepAwareService] = []
|
| 470 |
+
self._resume_delay = resume_delay
|
| 471 |
+
self._is_suspended = False
|
| 472 |
+
self._logger = logging.getLogger(__name__)
|
| 473 |
+
|
| 474 |
+
def register(self, service: SleepAwareService) -> None:
|
| 475 |
+
"""Register a service to be managed."""
|
| 476 |
+
if service not in self._services:
|
| 477 |
+
self._services.append(service)
|
| 478 |
+
self._logger.debug(f"Registered service: {service.service_name}")
|
| 479 |
+
|
| 480 |
+
def unregister(self, service: SleepAwareService) -> None:
|
| 481 |
+
"""Unregister a service."""
|
| 482 |
+
if service in self._services:
|
| 483 |
+
self._services.remove(service)
|
| 484 |
+
self._logger.debug(f"Unregistered service: {service.service_name}")
|
| 485 |
+
|
| 486 |
+
@property
|
| 487 |
+
def is_suspended(self) -> bool:
|
| 488 |
+
"""Check if all services are suspended."""
|
| 489 |
+
return self._is_suspended
|
| 490 |
+
|
| 491 |
+
async def start_all(self) -> None:
|
| 492 |
+
"""Start all registered services."""
|
| 493 |
+
self._logger.info(f"Starting {len(self._services)} services...")
|
| 494 |
+
for service in self._services:
|
| 495 |
+
try:
|
| 496 |
+
await service.start()
|
| 497 |
+
except Exception as e:
|
| 498 |
+
self._logger.error(f"Failed to start {service.service_name}: {e}")
|
| 499 |
+
|
| 500 |
+
async def stop_all(self) -> None:
|
| 501 |
+
"""Stop all registered services."""
|
| 502 |
+
self._logger.info(f"Stopping {len(self._services)} services...")
|
| 503 |
+
# Stop in reverse order (LIFO)
|
| 504 |
+
for service in reversed(self._services):
|
| 505 |
+
try:
|
| 506 |
+
await service.stop()
|
| 507 |
+
except Exception as e:
|
| 508 |
+
self._logger.error(f"Failed to stop {service.service_name}: {e}")
|
| 509 |
+
|
| 510 |
+
async def suspend_all(self) -> None:
|
| 511 |
+
"""Suspend all active services."""
|
| 512 |
+
if self._is_suspended:
|
| 513 |
+
self._logger.debug("Services already suspended")
|
| 514 |
+
return
|
| 515 |
+
|
| 516 |
+
self._logger.info("Suspending all services for sleep...")
|
| 517 |
+
for service in self._services:
|
| 518 |
+
if service.is_active:
|
| 519 |
+
try:
|
| 520 |
+
await service.suspend()
|
| 521 |
+
except Exception as e:
|
| 522 |
+
self._logger.error(f"Failed to suspend {service.service_name}: {e}")
|
| 523 |
+
|
| 524 |
+
self._is_suspended = True
|
| 525 |
+
self._logger.info("All services suspended")
|
| 526 |
+
|
| 527 |
+
async def resume_all(self, delay: float | None = None) -> None:
|
| 528 |
+
"""Resume all suspended services.
|
| 529 |
+
|
| 530 |
+
Args:
|
| 531 |
+
delay: Optional override for resume delay. If None, uses default.
|
| 532 |
+
"""
|
| 533 |
+
if not self._is_suspended:
|
| 534 |
+
self._logger.debug("Services not suspended")
|
| 535 |
+
return
|
| 536 |
+
|
| 537 |
+
actual_delay = delay if delay is not None else self._resume_delay
|
| 538 |
+
if actual_delay > 0:
|
| 539 |
+
self._logger.info(f"Waiting {actual_delay}s before resuming services...")
|
| 540 |
+
await asyncio.sleep(actual_delay)
|
| 541 |
+
|
| 542 |
+
self._logger.info("Resuming all services after wake...")
|
| 543 |
+
for service in self._services:
|
| 544 |
+
if service.is_suspended:
|
| 545 |
+
try:
|
| 546 |
+
await service.resume()
|
| 547 |
+
except Exception as e:
|
| 548 |
+
self._logger.error(f"Failed to resume {service.service_name}: {e}")
|
| 549 |
+
|
| 550 |
+
self._is_suspended = False
|
| 551 |
+
self._logger.info("All services resumed")
|
reachy_mini_home_assistant/core/system_diagnostics.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System Diagnostics for Home Assistant.
|
| 2 |
+
|
| 3 |
+
This module provides system diagnostic sensors using psutil to monitor
|
| 4 |
+
CPU, memory, disk, and network usage on the Reachy Mini robot.
|
| 5 |
+
|
| 6 |
+
All sensors are registered with entity_category=2 (diagnostic) so they
|
| 7 |
+
appear in the Diagnostics section in Home Assistant.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
import psutil
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SystemDiagnostics:
|
| 19 |
+
"""System diagnostics provider using psutil.
|
| 20 |
+
|
| 21 |
+
This class provides getter methods for various system metrics that can
|
| 22 |
+
be used with SensorEntity's value_getter parameter.
|
| 23 |
+
|
| 24 |
+
Metrics are cached briefly to avoid excessive system calls when multiple
|
| 25 |
+
entities are updated in quick succession.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, cache_ttl: float = 1.0):
|
| 29 |
+
"""Initialize system diagnostics.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
cache_ttl: Cache time-to-live in seconds. Metrics are cached
|
| 33 |
+
to avoid excessive system calls.
|
| 34 |
+
"""
|
| 35 |
+
self._cache_ttl = cache_ttl
|
| 36 |
+
self._cache: dict = {}
|
| 37 |
+
self._cache_time: dict = {}
|
| 38 |
+
|
| 39 |
+
# Get initial disk path (root partition)
|
| 40 |
+
self._disk_path = "/" if psutil.POSIX else "C:\\"
|
| 41 |
+
|
| 42 |
+
logger.info("SystemDiagnostics initialized")
|
| 43 |
+
|
| 44 |
+
def _get_cached(self, key: str, getter) -> any:
|
| 45 |
+
"""Get a cached value or compute it.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
key: Cache key
|
| 49 |
+
getter: Callable to get fresh value
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Cached or fresh value
|
| 53 |
+
"""
|
| 54 |
+
now = time.monotonic()
|
| 55 |
+
if key in self._cache:
|
| 56 |
+
if now - self._cache_time.get(key, 0) < self._cache_ttl:
|
| 57 |
+
return self._cache[key]
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
value = getter()
|
| 61 |
+
self._cache[key] = value
|
| 62 |
+
self._cache_time[key] = now
|
| 63 |
+
return value
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.debug("Error getting %s: %s", key, e)
|
| 66 |
+
return self._cache.get(key, 0.0)
|
| 67 |
+
|
| 68 |
+
# =========================================================================
|
| 69 |
+
# CPU Metrics
|
| 70 |
+
# =========================================================================
|
| 71 |
+
|
| 72 |
+
def get_cpu_percent(self) -> float:
|
| 73 |
+
"""Get CPU usage percentage (0-100)."""
|
| 74 |
+
return self._get_cached("cpu_percent", lambda: psutil.cpu_percent(interval=None))
|
| 75 |
+
|
| 76 |
+
def get_cpu_temperature(self) -> float:
|
| 77 |
+
"""Get CPU temperature in Celsius.
|
| 78 |
+
|
| 79 |
+
Note: May not be available on all platforms.
|
| 80 |
+
Returns 0.0 if temperature sensors are not available.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
def _get_temp():
|
| 84 |
+
temps = psutil.sensors_temperatures()
|
| 85 |
+
if not temps:
|
| 86 |
+
return 0.0
|
| 87 |
+
|
| 88 |
+
# Try common sensor names
|
| 89 |
+
for name in ["coretemp", "cpu_thermal", "cpu-thermal", "k10temp", "zenpower"]:
|
| 90 |
+
if temps.get(name):
|
| 91 |
+
return temps[name][0].current
|
| 92 |
+
|
| 93 |
+
# Fallback: first available sensor
|
| 94 |
+
for sensors in temps.values():
|
| 95 |
+
if sensors:
|
| 96 |
+
return sensors[0].current
|
| 97 |
+
|
| 98 |
+
return 0.0
|
| 99 |
+
|
| 100 |
+
return self._get_cached("cpu_temperature", _get_temp)
|
| 101 |
+
|
| 102 |
+
def get_cpu_count(self) -> float:
|
| 103 |
+
"""Get number of CPU cores."""
|
| 104 |
+
return float(psutil.cpu_count() or 1)
|
| 105 |
+
|
| 106 |
+
# =========================================================================
|
| 107 |
+
# Memory Metrics
|
| 108 |
+
# =========================================================================
|
| 109 |
+
|
| 110 |
+
def get_memory_percent(self) -> float:
|
| 111 |
+
"""Get memory usage percentage (0-100)."""
|
| 112 |
+
return self._get_cached("memory_percent", lambda: psutil.virtual_memory().percent)
|
| 113 |
+
|
| 114 |
+
def get_memory_used_gb(self) -> float:
|
| 115 |
+
"""Get used memory in GB."""
|
| 116 |
+
return self._get_cached("memory_used_gb", lambda: psutil.virtual_memory().used / (1024**3))
|
| 117 |
+
|
| 118 |
+
def get_memory_total_gb(self) -> float:
|
| 119 |
+
"""Get total memory in GB."""
|
| 120 |
+
return self._get_cached("memory_total_gb", lambda: psutil.virtual_memory().total / (1024**3))
|
| 121 |
+
|
| 122 |
+
def get_memory_available_gb(self) -> float:
|
| 123 |
+
"""Get available memory in GB."""
|
| 124 |
+
return self._get_cached("memory_available_gb", lambda: psutil.virtual_memory().available / (1024**3))
|
| 125 |
+
|
| 126 |
+
# =========================================================================
|
| 127 |
+
# Disk Metrics
|
| 128 |
+
# =========================================================================
|
| 129 |
+
|
| 130 |
+
def get_disk_percent(self) -> float:
|
| 131 |
+
"""Get disk usage percentage (0-100)."""
|
| 132 |
+
return self._get_cached("disk_percent", lambda: psutil.disk_usage(self._disk_path).percent)
|
| 133 |
+
|
| 134 |
+
def get_disk_used_gb(self) -> float:
|
| 135 |
+
"""Get used disk space in GB."""
|
| 136 |
+
return self._get_cached("disk_used_gb", lambda: psutil.disk_usage(self._disk_path).used / (1024**3))
|
| 137 |
+
|
| 138 |
+
def get_disk_total_gb(self) -> float:
|
| 139 |
+
"""Get total disk space in GB."""
|
| 140 |
+
return self._get_cached("disk_total_gb", lambda: psutil.disk_usage(self._disk_path).total / (1024**3))
|
| 141 |
+
|
| 142 |
+
def get_disk_free_gb(self) -> float:
|
| 143 |
+
"""Get free disk space in GB."""
|
| 144 |
+
return self._get_cached("disk_free_gb", lambda: psutil.disk_usage(self._disk_path).free / (1024**3))
|
| 145 |
+
|
| 146 |
+
# =========================================================================
|
| 147 |
+
# Network Metrics
|
| 148 |
+
# =========================================================================
|
| 149 |
+
|
| 150 |
+
def get_network_bytes_sent_mb(self) -> float:
|
| 151 |
+
"""Get total bytes sent since boot in MB."""
|
| 152 |
+
return self._get_cached("network_bytes_sent_mb", lambda: psutil.net_io_counters().bytes_sent / (1024**2))
|
| 153 |
+
|
| 154 |
+
def get_network_bytes_recv_mb(self) -> float:
|
| 155 |
+
"""Get total bytes received since boot in MB."""
|
| 156 |
+
return self._get_cached("network_bytes_recv_mb", lambda: psutil.net_io_counters().bytes_recv / (1024**2))
|
| 157 |
+
|
| 158 |
+
# =========================================================================
|
| 159 |
+
# Process Metrics (this process)
|
| 160 |
+
# =========================================================================
|
| 161 |
+
|
| 162 |
+
def get_process_cpu_percent(self) -> float:
|
| 163 |
+
"""Get CPU usage of this process (0-100)."""
|
| 164 |
+
return self._get_cached("process_cpu_percent", lambda: psutil.Process().cpu_percent(interval=None))
|
| 165 |
+
|
| 166 |
+
def get_process_memory_mb(self) -> float:
|
| 167 |
+
"""Get memory usage of this process in MB."""
|
| 168 |
+
return self._get_cached("process_memory_mb", lambda: psutil.Process().memory_info().rss / (1024**2))
|
| 169 |
+
|
| 170 |
+
def get_process_threads(self) -> float:
|
| 171 |
+
"""Get number of threads in this process."""
|
| 172 |
+
return self._get_cached("process_threads", lambda: float(psutil.Process().num_threads()))
|
| 173 |
+
|
| 174 |
+
# =========================================================================
|
| 175 |
+
# System Metrics
|
| 176 |
+
# =========================================================================
|
| 177 |
+
|
| 178 |
+
def get_uptime_hours(self) -> float:
|
| 179 |
+
"""Get system uptime in hours."""
|
| 180 |
+
return self._get_cached("uptime_hours", lambda: (time.time() - psutil.boot_time()) / 3600)
|
| 181 |
+
|
| 182 |
+
def get_load_average_1m(self) -> float:
|
| 183 |
+
"""Get 1-minute load average.
|
| 184 |
+
|
| 185 |
+
Note: Returns 0.0 on Windows.
|
| 186 |
+
"""
|
| 187 |
+
|
| 188 |
+
def _get_load():
|
| 189 |
+
try:
|
| 190 |
+
return psutil.getloadavg()[0]
|
| 191 |
+
except (AttributeError, OSError):
|
| 192 |
+
# Windows doesn't support getloadavg
|
| 193 |
+
return 0.0
|
| 194 |
+
|
| 195 |
+
return self._get_cached("load_average_1m", _get_load)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# Singleton instance for easy access
|
| 199 |
+
_diagnostics_instance: SystemDiagnostics | None = None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_system_diagnostics() -> SystemDiagnostics:
|
| 203 |
+
"""Get or create the singleton SystemDiagnostics instance."""
|
| 204 |
+
global _diagnostics_instance
|
| 205 |
+
if _diagnostics_instance is None:
|
| 206 |
+
_diagnostics_instance = SystemDiagnostics()
|
| 207 |
+
return _diagnostics_instance
|
reachy_mini_home_assistant/core/util.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def call_all(*funcs: Callable[[], None] | None) -> None:
|
| 7 |
+
"""Call all non-None functions."""
|
| 8 |
+
for func in funcs:
|
| 9 |
+
if func is not None:
|
| 10 |
+
func()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_mac() -> str:
|
| 14 |
+
"""Return the machine ID as device ID.
|
| 15 |
+
|
| 16 |
+
Reads /etc/machine-id and returns first 12 characters.
|
| 17 |
+
"""
|
| 18 |
+
machine_id = "00000000000000000000000000000000"
|
| 19 |
+
try:
|
| 20 |
+
with open("/etc/machine-id") as f:
|
| 21 |
+
machine_id = f.read().strip()
|
| 22 |
+
except Exception:
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
# Return first 12 characters
|
| 26 |
+
return machine_id[:12]
|
reachy_mini_home_assistant/entities/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entities module for Home Assistant integration.
|
| 2 |
+
|
| 3 |
+
This module handles ESPHome entity definitions:
|
| 4 |
+
- ESPHomeEntity: Base class for all entities
|
| 5 |
+
- EntityRegistry: Entity registration and management
|
| 6 |
+
- EventEmotionMapper: HA event to robot emotion mapping
|
| 7 |
+
# - EmotionKeywordDetector: LLM response emotion detection (DISABLED - moved to HA blueprint)
|
| 8 |
+
- Entity keys: Consistent key management
|
| 9 |
+
- Entity factory: Entity creation utilities
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
# DISABLED: Emotion detection moved to Home Assistant blueprint
|
| 13 |
+
# from .emotion_detector import EmotionKeywordDetector
|
| 14 |
+
from .entity import (
|
| 15 |
+
BinarySensorEntity,
|
| 16 |
+
CameraEntity,
|
| 17 |
+
ESPHomeEntity,
|
| 18 |
+
MediaPlayerEntity,
|
| 19 |
+
NumberEntity,
|
| 20 |
+
TextSensorEntity,
|
| 21 |
+
)
|
| 22 |
+
from .entity_extensions import (
|
| 23 |
+
ButtonEntity,
|
| 24 |
+
SelectEntity,
|
| 25 |
+
SensorEntity,
|
| 26 |
+
SwitchEntity,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Entity keys - single source of truth
|
| 30 |
+
from .entity_keys import (
|
| 31 |
+
ENTITY_KEYS,
|
| 32 |
+
get_entity_key,
|
| 33 |
+
get_next_available_key,
|
| 34 |
+
register_entity_key,
|
| 35 |
+
)
|
| 36 |
+
from .entity_registry import EntityRegistry
|
| 37 |
+
from .event_emotion_mapper import (
|
| 38 |
+
DEFAULT_EVENT_EMOTION_MAP,
|
| 39 |
+
EventEmotionMapper,
|
| 40 |
+
EventEmotionMapping,
|
| 41 |
+
EventSource,
|
| 42 |
+
EventTrigger,
|
| 43 |
+
load_event_mappings,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
__all__ = [
|
| 47 |
+
"DEFAULT_EVENT_EMOTION_MAP",
|
| 48 |
+
# Entity keys
|
| 49 |
+
"ENTITY_KEYS",
|
| 50 |
+
"BinarySensorEntity",
|
| 51 |
+
"ButtonEntity",
|
| 52 |
+
"CameraEntity",
|
| 53 |
+
# Entity base classes
|
| 54 |
+
"ESPHomeEntity",
|
| 55 |
+
# Emotion detection (DISABLED - moved to HA blueprint)
|
| 56 |
+
# "EmotionKeywordDetector",
|
| 57 |
+
# Entity registry
|
| 58 |
+
"EntityRegistry",
|
| 59 |
+
"EventEmotionMapper",
|
| 60 |
+
"EventEmotionMapping",
|
| 61 |
+
# Event emotion mapping
|
| 62 |
+
"EventSource",
|
| 63 |
+
"EventTrigger",
|
| 64 |
+
"MediaPlayerEntity",
|
| 65 |
+
"NumberEntity",
|
| 66 |
+
"SelectEntity",
|
| 67 |
+
"SensorEntity",
|
| 68 |
+
"SwitchEntity",
|
| 69 |
+
"TextSensorEntity",
|
| 70 |
+
"get_entity_key",
|
| 71 |
+
"get_next_available_key",
|
| 72 |
+
"load_event_mappings",
|
| 73 |
+
"register_entity_key",
|
| 74 |
+
]
|
reachy_mini_home_assistant/entities/emotion_detector.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Emotion keyword detection from text responses.
|
| 2 |
+
|
| 3 |
+
This module provides automatic emotion detection based on keywords in LLM responses,
|
| 4 |
+
allowing the robot to express emotions naturally during conversation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from collections.abc import Callable
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from ..animations.animation_config import get_animation_config_section
|
| 13 |
+
|
| 14 |
+
_LOGGER = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
_UNIFIED_BEHAVIORS_FILE = Path(__file__).parent.parent / "animations" / "conversation_animations.json"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class EmotionKeywordDetector:
|
| 20 |
+
"""Detects emotions from text using keyword matching.
|
| 21 |
+
|
| 22 |
+
Loads keyword-to-emotion mappings from a JSON configuration file
|
| 23 |
+
and provides automatic emotion detection for LLM responses.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
config_path: Path | None = None,
|
| 29 |
+
play_emotion_callback: Callable[[str], None] | None = None,
|
| 30 |
+
):
|
| 31 |
+
"""Initialize the emotion detector.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
config_path: Path to the unified behavior JSON. Defaults to animations folder.
|
| 35 |
+
play_emotion_callback: Function to call when emotion is detected.
|
| 36 |
+
"""
|
| 37 |
+
self._keywords: dict[str, str] = {}
|
| 38 |
+
self._enabled: bool = True
|
| 39 |
+
self._play_emotion_callback = play_emotion_callback
|
| 40 |
+
|
| 41 |
+
if config_path is None:
|
| 42 |
+
config_path = _UNIFIED_BEHAVIORS_FILE
|
| 43 |
+
|
| 44 |
+
self._load_keywords(config_path)
|
| 45 |
+
|
| 46 |
+
def _load_keywords(self, config_path: Path) -> None:
|
| 47 |
+
"""Load emotion keywords from JSON configuration file."""
|
| 48 |
+
if not config_path.exists():
|
| 49 |
+
_LOGGER.warning("Emotion keywords file not found: %s", config_path)
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
data = get_animation_config_section(config_path, "emotion_keywords") or {}
|
| 54 |
+
|
| 55 |
+
self._keywords = data.get("keywords", {})
|
| 56 |
+
settings = data.get("settings", {})
|
| 57 |
+
self._enabled = settings.get("enabled", True)
|
| 58 |
+
|
| 59 |
+
_LOGGER.info("Loaded %d emotion keywords (enabled=%s)", len(self._keywords), self._enabled)
|
| 60 |
+
except Exception as e:
|
| 61 |
+
_LOGGER.error("Failed to load emotion keywords: %s", e)
|
| 62 |
+
|
| 63 |
+
def set_play_emotion_callback(self, callback: Callable[[str], None]) -> None:
|
| 64 |
+
"""Set the callback for playing emotions.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
callback: Function that takes emotion name and plays it
|
| 68 |
+
"""
|
| 69 |
+
self._play_emotion_callback = callback
|
| 70 |
+
|
| 71 |
+
def detect_and_play(self, text: str) -> str | None:
|
| 72 |
+
"""Detect emotion from text and trigger corresponding animation.
|
| 73 |
+
|
| 74 |
+
Keywords are matched case-insensitively against the text.
|
| 75 |
+
Only triggers one emotion per response (first match wins).
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
text: The text to analyze for emotional content
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
The detected emotion name, or None if no emotion detected
|
| 82 |
+
"""
|
| 83 |
+
if not text or not self._enabled:
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
if not self._keywords:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
text_lower = text.lower()
|
| 90 |
+
|
| 91 |
+
# Check each keyword pattern
|
| 92 |
+
for keyword, emotion_name in self._keywords.items():
|
| 93 |
+
if keyword.lower() in text_lower:
|
| 94 |
+
_LOGGER.info("Auto-detected emotion '%s' from keyword '%s' in response", emotion_name, keyword)
|
| 95 |
+
if self._play_emotion_callback:
|
| 96 |
+
self._play_emotion_callback(emotion_name)
|
| 97 |
+
return emotion_name
|
| 98 |
+
|
| 99 |
+
_LOGGER.debug("No emotion keywords detected in response text")
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def enabled(self) -> bool:
|
| 104 |
+
"""Check if emotion detection is enabled."""
|
| 105 |
+
return self._enabled
|
| 106 |
+
|
| 107 |
+
@enabled.setter
|
| 108 |
+
def enabled(self, value: bool) -> None:
|
| 109 |
+
"""Enable or disable emotion detection."""
|
| 110 |
+
self._enabled = value
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def keyword_count(self) -> int:
|
| 114 |
+
"""Get the number of loaded keywords."""
|
| 115 |
+
return len(self._keywords)
|
reachy_mini_home_assistant/entities/entity.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ESPHome entity definitions."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from abc import abstractmethod
|
| 5 |
+
from collections.abc import Callable, Iterable
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
# pylint: disable=no-name-in-module
|
| 9 |
+
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 10 |
+
BinarySensorStateResponse,
|
| 11 |
+
CameraImageRequest,
|
| 12 |
+
CameraImageResponse,
|
| 13 |
+
ListEntitiesBinarySensorResponse,
|
| 14 |
+
ListEntitiesCameraResponse,
|
| 15 |
+
ListEntitiesMediaPlayerResponse,
|
| 16 |
+
ListEntitiesNumberResponse,
|
| 17 |
+
ListEntitiesRequest,
|
| 18 |
+
ListEntitiesTextSensorResponse,
|
| 19 |
+
MediaPlayerCommandRequest,
|
| 20 |
+
MediaPlayerStateResponse,
|
| 21 |
+
NumberCommandRequest,
|
| 22 |
+
NumberStateResponse,
|
| 23 |
+
SubscribeHomeAssistantStatesRequest,
|
| 24 |
+
SubscribeStatesRequest,
|
| 25 |
+
TextSensorStateResponse,
|
| 26 |
+
)
|
| 27 |
+
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerEntityFeature, MediaPlayerState
|
| 28 |
+
from google.protobuf import message
|
| 29 |
+
|
| 30 |
+
from ..audio.audio_player import AudioPlayer
|
| 31 |
+
from ..core.util import call_all
|
| 32 |
+
|
| 33 |
+
if TYPE_CHECKING:
|
| 34 |
+
from ..protocol.api_server import APIServer
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _safe_get_value(getter: Callable[[], object] | None, current_value: object, entity_name: str) -> object:
|
| 40 |
+
"""Read an entity value without letting getter failures break the ESPHome session."""
|
| 41 |
+
if getter is None:
|
| 42 |
+
return current_value
|
| 43 |
+
try:
|
| 44 |
+
return getter()
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error("Entity getter failed for %s: %s", entity_name, e)
|
| 47 |
+
return current_value
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ESPHomeEntity:
|
| 51 |
+
"""Base class for ESPHome entities."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, server: "APIServer") -> None:
|
| 54 |
+
self.server = server
|
| 55 |
+
|
| 56 |
+
@abstractmethod
|
| 57 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MediaPlayerEntity(ESPHomeEntity):
|
| 62 |
+
"""Media player entity for ESPHome."""
|
| 63 |
+
|
| 64 |
+
def __init__(
|
| 65 |
+
self,
|
| 66 |
+
server: "APIServer",
|
| 67 |
+
key: int,
|
| 68 |
+
name: str,
|
| 69 |
+
object_id: str,
|
| 70 |
+
music_player: AudioPlayer,
|
| 71 |
+
announce_player: AudioPlayer,
|
| 72 |
+
) -> None:
|
| 73 |
+
ESPHomeEntity.__init__(self, server)
|
| 74 |
+
self.key = key
|
| 75 |
+
self.name = name
|
| 76 |
+
self.object_id = object_id
|
| 77 |
+
self.state = MediaPlayerState.IDLE
|
| 78 |
+
self.volume = 1.0
|
| 79 |
+
self.muted = False
|
| 80 |
+
self.music_player = music_player
|
| 81 |
+
self.announce_player = announce_player
|
| 82 |
+
|
| 83 |
+
def play(
|
| 84 |
+
self,
|
| 85 |
+
url: str | list[str],
|
| 86 |
+
announcement: bool = False,
|
| 87 |
+
done_callback: Callable[[], None] | None = None,
|
| 88 |
+
) -> Iterable[message.Message]:
|
| 89 |
+
if announcement:
|
| 90 |
+
if self.music_player.is_playing:
|
| 91 |
+
# Announce, resume music
|
| 92 |
+
self.music_player.pause()
|
| 93 |
+
self.announce_player.play(
|
| 94 |
+
url,
|
| 95 |
+
done_callback=lambda: call_all(self.music_player.resume, done_callback),
|
| 96 |
+
)
|
| 97 |
+
else:
|
| 98 |
+
# Announce, idle
|
| 99 |
+
self.announce_player.play(
|
| 100 |
+
url,
|
| 101 |
+
done_callback=lambda: call_all(
|
| 102 |
+
lambda: self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
| 103 |
+
done_callback,
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
else:
|
| 107 |
+
# Music
|
| 108 |
+
self.music_player.play(
|
| 109 |
+
url,
|
| 110 |
+
done_callback=lambda: call_all(
|
| 111 |
+
lambda: self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
| 112 |
+
done_callback,
|
| 113 |
+
),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
yield self._update_state(MediaPlayerState.PLAYING)
|
| 117 |
+
|
| 118 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 119 |
+
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
| 120 |
+
if msg.has_media_url:
|
| 121 |
+
announcement = msg.has_announcement and msg.announcement
|
| 122 |
+
yield from self.play(msg.media_url, announcement=announcement)
|
| 123 |
+
elif msg.has_command:
|
| 124 |
+
if msg.command == MediaPlayerCommand.PAUSE:
|
| 125 |
+
self.music_player.pause()
|
| 126 |
+
yield self._update_state(MediaPlayerState.PAUSED)
|
| 127 |
+
elif msg.command == MediaPlayerCommand.PLAY:
|
| 128 |
+
self.music_player.resume()
|
| 129 |
+
yield self._update_state(MediaPlayerState.PLAYING)
|
| 130 |
+
elif msg.command == MediaPlayerCommand.STOP:
|
| 131 |
+
self.music_player.stop()
|
| 132 |
+
yield self._update_state(MediaPlayerState.IDLE)
|
| 133 |
+
elif msg.has_volume:
|
| 134 |
+
volume = int(msg.volume * 100)
|
| 135 |
+
self.music_player.set_volume(volume)
|
| 136 |
+
self.announce_player.set_volume(volume)
|
| 137 |
+
self.volume = msg.volume
|
| 138 |
+
yield self._update_state(self.state)
|
| 139 |
+
elif isinstance(msg, ListEntitiesRequest):
|
| 140 |
+
# Set feature flags for Music Assistant compatibility
|
| 141 |
+
# PLAY_MEDIA (512) is required for Music Assistant to recognize the player
|
| 142 |
+
feature_flags = (
|
| 143 |
+
MediaPlayerEntityFeature.PAUSE
|
| 144 |
+
| MediaPlayerEntityFeature.PLAY_MEDIA
|
| 145 |
+
| MediaPlayerEntityFeature.VOLUME_SET
|
| 146 |
+
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
|
| 147 |
+
)
|
| 148 |
+
yield ListEntitiesMediaPlayerResponse(
|
| 149 |
+
object_id=self.object_id,
|
| 150 |
+
key=self.key,
|
| 151 |
+
name=self.name,
|
| 152 |
+
supports_pause=True,
|
| 153 |
+
feature_flags=feature_flags,
|
| 154 |
+
)
|
| 155 |
+
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
| 156 |
+
yield self._get_state_message()
|
| 157 |
+
|
| 158 |
+
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
| 159 |
+
self.state = new_state
|
| 160 |
+
return self._get_state_message()
|
| 161 |
+
|
| 162 |
+
def _get_state_message(self) -> MediaPlayerStateResponse:
|
| 163 |
+
return MediaPlayerStateResponse(
|
| 164 |
+
key=self.key,
|
| 165 |
+
state=self.state,
|
| 166 |
+
volume=self.volume,
|
| 167 |
+
muted=self.muted,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class TextSensorEntity(ESPHomeEntity):
|
| 172 |
+
"""Text sensor entity for ESPHome (read-only string values)."""
|
| 173 |
+
|
| 174 |
+
def __init__(
|
| 175 |
+
self,
|
| 176 |
+
server: "APIServer",
|
| 177 |
+
key: int,
|
| 178 |
+
name: str,
|
| 179 |
+
object_id: str,
|
| 180 |
+
icon: str = "",
|
| 181 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 182 |
+
value_getter: Callable[[], str] | None = None,
|
| 183 |
+
) -> None:
|
| 184 |
+
ESPHomeEntity.__init__(self, server)
|
| 185 |
+
self.key = key
|
| 186 |
+
self.name = name
|
| 187 |
+
self.object_id = object_id
|
| 188 |
+
self.icon = icon
|
| 189 |
+
self.entity_category = entity_category
|
| 190 |
+
self._value_getter = value_getter
|
| 191 |
+
self._value = ""
|
| 192 |
+
|
| 193 |
+
@property
|
| 194 |
+
def value(self) -> str:
|
| 195 |
+
return str(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 196 |
+
|
| 197 |
+
@value.setter
|
| 198 |
+
def value(self, new_value: str) -> None:
|
| 199 |
+
self._value = new_value
|
| 200 |
+
|
| 201 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 202 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 203 |
+
yield ListEntitiesTextSensorResponse(
|
| 204 |
+
object_id=self.object_id,
|
| 205 |
+
key=self.key,
|
| 206 |
+
name=self.name,
|
| 207 |
+
icon=self.icon,
|
| 208 |
+
entity_category=self.entity_category,
|
| 209 |
+
)
|
| 210 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 211 |
+
yield self._get_state_message()
|
| 212 |
+
|
| 213 |
+
def _get_state_message(self) -> TextSensorStateResponse:
|
| 214 |
+
return TextSensorStateResponse(
|
| 215 |
+
key=self.key,
|
| 216 |
+
state=self.value,
|
| 217 |
+
missing_state=False,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def update_state(self) -> None:
|
| 221 |
+
"""Send state update to Home Assistant."""
|
| 222 |
+
self.server.send_messages([self._get_state_message()])
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class BinarySensorEntity(ESPHomeEntity):
|
| 226 |
+
"""Binary sensor entity for ESPHome (read-only boolean values)."""
|
| 227 |
+
|
| 228 |
+
def __init__(
|
| 229 |
+
self,
|
| 230 |
+
server: "APIServer",
|
| 231 |
+
key: int,
|
| 232 |
+
name: str,
|
| 233 |
+
object_id: str,
|
| 234 |
+
icon: str = "",
|
| 235 |
+
device_class: str = "",
|
| 236 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 237 |
+
value_getter: Callable[[], bool] | None = None,
|
| 238 |
+
) -> None:
|
| 239 |
+
ESPHomeEntity.__init__(self, server)
|
| 240 |
+
self.key = key
|
| 241 |
+
self.name = name
|
| 242 |
+
self.object_id = object_id
|
| 243 |
+
self.icon = icon
|
| 244 |
+
self.device_class = device_class
|
| 245 |
+
self.entity_category = entity_category
|
| 246 |
+
self._value_getter = value_getter
|
| 247 |
+
self._value = False
|
| 248 |
+
|
| 249 |
+
@property
|
| 250 |
+
def value(self) -> bool:
|
| 251 |
+
return bool(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 252 |
+
|
| 253 |
+
@value.setter
|
| 254 |
+
def value(self, new_value: bool) -> None:
|
| 255 |
+
self._value = new_value
|
| 256 |
+
|
| 257 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 258 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 259 |
+
yield ListEntitiesBinarySensorResponse(
|
| 260 |
+
object_id=self.object_id,
|
| 261 |
+
key=self.key,
|
| 262 |
+
name=self.name,
|
| 263 |
+
icon=self.icon,
|
| 264 |
+
device_class=self.device_class,
|
| 265 |
+
entity_category=self.entity_category,
|
| 266 |
+
)
|
| 267 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 268 |
+
yield self._get_state_message()
|
| 269 |
+
|
| 270 |
+
def _get_state_message(self) -> BinarySensorStateResponse:
|
| 271 |
+
return BinarySensorStateResponse(
|
| 272 |
+
key=self.key,
|
| 273 |
+
state=self.value,
|
| 274 |
+
missing_state=False,
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
def update_state(self) -> None:
|
| 278 |
+
"""Send state update to Home Assistant."""
|
| 279 |
+
self.server.send_messages([self._get_state_message()])
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class NumberEntity(ESPHomeEntity):
|
| 283 |
+
"""Number entity for ESPHome (read-write numeric values)."""
|
| 284 |
+
|
| 285 |
+
def __init__(
|
| 286 |
+
self,
|
| 287 |
+
server: "APIServer",
|
| 288 |
+
key: int,
|
| 289 |
+
name: str,
|
| 290 |
+
object_id: str,
|
| 291 |
+
min_value: float = 0.0,
|
| 292 |
+
max_value: float = 100.0,
|
| 293 |
+
step: float = 1.0,
|
| 294 |
+
icon: str = "",
|
| 295 |
+
unit_of_measurement: str = "",
|
| 296 |
+
mode: int = 0, # 0 = auto, 1 = box, 2 = slider
|
| 297 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 298 |
+
value_getter: Callable[[], float] | None = None,
|
| 299 |
+
value_setter: Callable[[float], None] | None = None,
|
| 300 |
+
) -> None:
|
| 301 |
+
ESPHomeEntity.__init__(self, server)
|
| 302 |
+
self.key = key
|
| 303 |
+
self.name = name
|
| 304 |
+
self.object_id = object_id
|
| 305 |
+
self.min_value = min_value
|
| 306 |
+
self.max_value = max_value
|
| 307 |
+
self.step = step
|
| 308 |
+
self.icon = icon
|
| 309 |
+
self.unit_of_measurement = unit_of_measurement
|
| 310 |
+
self.mode = mode
|
| 311 |
+
self.entity_category = entity_category
|
| 312 |
+
self._value_getter = value_getter
|
| 313 |
+
self._value_setter = value_setter
|
| 314 |
+
self._value = min_value
|
| 315 |
+
|
| 316 |
+
@property
|
| 317 |
+
def value(self) -> float:
|
| 318 |
+
return float(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 319 |
+
|
| 320 |
+
@value.setter
|
| 321 |
+
def value(self, new_value: float) -> None:
|
| 322 |
+
# Clamp value to valid range
|
| 323 |
+
new_value = max(self.min_value, min(self.max_value, new_value))
|
| 324 |
+
if self._value_setter:
|
| 325 |
+
self._value_setter(new_value)
|
| 326 |
+
self._value = new_value
|
| 327 |
+
|
| 328 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 329 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 330 |
+
yield ListEntitiesNumberResponse(
|
| 331 |
+
object_id=self.object_id,
|
| 332 |
+
key=self.key,
|
| 333 |
+
name=self.name,
|
| 334 |
+
icon=self.icon,
|
| 335 |
+
min_value=self.min_value,
|
| 336 |
+
max_value=self.max_value,
|
| 337 |
+
step=self.step,
|
| 338 |
+
unit_of_measurement=self.unit_of_measurement,
|
| 339 |
+
mode=self.mode,
|
| 340 |
+
entity_category=self.entity_category,
|
| 341 |
+
)
|
| 342 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 343 |
+
yield self._get_state_message()
|
| 344 |
+
elif isinstance(msg, NumberCommandRequest) and msg.key == self.key:
|
| 345 |
+
self.value = msg.state
|
| 346 |
+
yield self._get_state_message()
|
| 347 |
+
|
| 348 |
+
def _get_state_message(self) -> NumberStateResponse:
|
| 349 |
+
return NumberStateResponse(
|
| 350 |
+
key=self.key,
|
| 351 |
+
state=self.value,
|
| 352 |
+
missing_state=False,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
def update_state(self) -> None:
|
| 356 |
+
"""Send state update to Home Assistant."""
|
| 357 |
+
self.server.send_messages([self._get_state_message()])
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
class CameraEntity(ESPHomeEntity):
|
| 361 |
+
"""Camera entity for ESPHome (provides image snapshots)."""
|
| 362 |
+
|
| 363 |
+
def __init__(
|
| 364 |
+
self,
|
| 365 |
+
server: "APIServer",
|
| 366 |
+
key: int,
|
| 367 |
+
name: str,
|
| 368 |
+
object_id: str,
|
| 369 |
+
icon: str = "mdi:camera",
|
| 370 |
+
image_getter: Callable[[], bytes | None] | None = None,
|
| 371 |
+
) -> None:
|
| 372 |
+
ESPHomeEntity.__init__(self, server)
|
| 373 |
+
self.key = key
|
| 374 |
+
self.name = name
|
| 375 |
+
self.object_id = object_id
|
| 376 |
+
self.icon = icon
|
| 377 |
+
self._image_getter = image_getter
|
| 378 |
+
|
| 379 |
+
def get_image(self) -> bytes | None:
|
| 380 |
+
"""Get the current camera image as JPEG bytes."""
|
| 381 |
+
if self._image_getter:
|
| 382 |
+
return self._image_getter()
|
| 383 |
+
return None
|
| 384 |
+
|
| 385 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 386 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 387 |
+
yield ListEntitiesCameraResponse(
|
| 388 |
+
object_id=self.object_id,
|
| 389 |
+
key=self.key,
|
| 390 |
+
name=self.name,
|
| 391 |
+
icon=self.icon,
|
| 392 |
+
)
|
| 393 |
+
elif isinstance(msg, CameraImageRequest):
|
| 394 |
+
# CameraImageRequest doesn't have a key field - it's a global request
|
| 395 |
+
# Return camera image for any camera request
|
| 396 |
+
image_data = self.get_image()
|
| 397 |
+
if image_data:
|
| 398 |
+
yield CameraImageResponse(
|
| 399 |
+
key=self.key,
|
| 400 |
+
data=image_data,
|
| 401 |
+
done=True,
|
| 402 |
+
)
|
| 403 |
+
else:
|
| 404 |
+
# Return empty response if no image available
|
| 405 |
+
yield CameraImageResponse(
|
| 406 |
+
key=self.key,
|
| 407 |
+
data=b"",
|
| 408 |
+
done=True,
|
| 409 |
+
)
|
reachy_mini_home_assistant/entities/entity_extensions.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extended ESPHome entity types for Reachy Mini control."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from collections.abc import Callable, Iterable
|
| 5 |
+
from typing import TYPE_CHECKING
|
| 6 |
+
|
| 7 |
+
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
| 8 |
+
ButtonCommandRequest,
|
| 9 |
+
ListEntitiesButtonResponse,
|
| 10 |
+
ListEntitiesRequest,
|
| 11 |
+
ListEntitiesSelectResponse,
|
| 12 |
+
ListEntitiesSensorResponse,
|
| 13 |
+
ListEntitiesSwitchResponse,
|
| 14 |
+
SelectCommandRequest,
|
| 15 |
+
SelectStateResponse,
|
| 16 |
+
SensorStateResponse,
|
| 17 |
+
SubscribeHomeAssistantStatesRequest,
|
| 18 |
+
SubscribeStatesRequest,
|
| 19 |
+
SwitchCommandRequest,
|
| 20 |
+
SwitchStateResponse,
|
| 21 |
+
)
|
| 22 |
+
from google.protobuf import message
|
| 23 |
+
|
| 24 |
+
from .entity import ESPHomeEntity
|
| 25 |
+
|
| 26 |
+
if TYPE_CHECKING:
|
| 27 |
+
from ..protocol.api_server import APIServer
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _safe_get_value(getter: Callable[[], object] | None, current_value: object, entity_name: str) -> object:
|
| 33 |
+
"""Read an entity value without letting getter failures break the ESPHome session."""
|
| 34 |
+
if getter is None:
|
| 35 |
+
return current_value
|
| 36 |
+
try:
|
| 37 |
+
return getter()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error("Entity getter failed for %s: %s", entity_name, e)
|
| 40 |
+
return current_value
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SensorStateClass:
|
| 44 |
+
"""ESPHome SensorStateClass enum values."""
|
| 45 |
+
|
| 46 |
+
NONE = 0
|
| 47 |
+
MEASUREMENT = 1
|
| 48 |
+
TOTAL_INCREASING = 2
|
| 49 |
+
TOTAL = 3
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class SensorEntity(ESPHomeEntity):
|
| 53 |
+
"""Sensor entity for ESPHome (read-only numeric values)."""
|
| 54 |
+
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
server: "APIServer",
|
| 58 |
+
key: int,
|
| 59 |
+
name: str,
|
| 60 |
+
object_id: str,
|
| 61 |
+
icon: str = "",
|
| 62 |
+
unit_of_measurement: str = "",
|
| 63 |
+
accuracy_decimals: int = 2,
|
| 64 |
+
device_class: str = "",
|
| 65 |
+
state_class: int = SensorStateClass.NONE,
|
| 66 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 67 |
+
value_getter: Callable[[], float] | None = None,
|
| 68 |
+
) -> None:
|
| 69 |
+
ESPHomeEntity.__init__(self, server)
|
| 70 |
+
self.key = key
|
| 71 |
+
self.name = name
|
| 72 |
+
self.object_id = object_id
|
| 73 |
+
self.icon = icon
|
| 74 |
+
self.unit_of_measurement = unit_of_measurement
|
| 75 |
+
self.accuracy_decimals = accuracy_decimals
|
| 76 |
+
self.device_class = device_class
|
| 77 |
+
self.entity_category = entity_category
|
| 78 |
+
# Convert string state_class to enum
|
| 79 |
+
if isinstance(state_class, str):
|
| 80 |
+
state_class_map = {
|
| 81 |
+
"": SensorStateClass.NONE,
|
| 82 |
+
"measurement": SensorStateClass.MEASUREMENT,
|
| 83 |
+
"total_increasing": SensorStateClass.TOTAL_INCREASING,
|
| 84 |
+
"total": SensorStateClass.TOTAL,
|
| 85 |
+
}
|
| 86 |
+
self.state_class = state_class_map.get(state_class.lower(), SensorStateClass.NONE)
|
| 87 |
+
else:
|
| 88 |
+
self.state_class = state_class
|
| 89 |
+
self._value_getter = value_getter
|
| 90 |
+
self._value = 0.0
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def value(self) -> float:
|
| 94 |
+
return float(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 95 |
+
|
| 96 |
+
@value.setter
|
| 97 |
+
def value(self, new_value: float) -> None:
|
| 98 |
+
self._value = new_value
|
| 99 |
+
|
| 100 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 101 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 102 |
+
yield ListEntitiesSensorResponse(
|
| 103 |
+
object_id=self.object_id,
|
| 104 |
+
key=self.key,
|
| 105 |
+
name=self.name,
|
| 106 |
+
icon=self.icon,
|
| 107 |
+
unit_of_measurement=self.unit_of_measurement,
|
| 108 |
+
accuracy_decimals=self.accuracy_decimals,
|
| 109 |
+
device_class=self.device_class,
|
| 110 |
+
state_class=self.state_class,
|
| 111 |
+
entity_category=self.entity_category,
|
| 112 |
+
)
|
| 113 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 114 |
+
yield self._get_state_message()
|
| 115 |
+
|
| 116 |
+
def _get_state_message(self) -> SensorStateResponse:
|
| 117 |
+
return SensorStateResponse(
|
| 118 |
+
key=self.key,
|
| 119 |
+
state=self.value,
|
| 120 |
+
missing_state=False,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
def update_state(self) -> None:
|
| 124 |
+
"""Send state update to Home Assistant."""
|
| 125 |
+
self.server.send_messages([self._get_state_message()])
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class SwitchEntity(ESPHomeEntity):
|
| 129 |
+
"""Switch entity for ESPHome (read-write boolean values)."""
|
| 130 |
+
|
| 131 |
+
def __init__(
|
| 132 |
+
self,
|
| 133 |
+
server: "APIServer",
|
| 134 |
+
key: int,
|
| 135 |
+
name: str,
|
| 136 |
+
object_id: str,
|
| 137 |
+
icon: str = "",
|
| 138 |
+
device_class: str = "",
|
| 139 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 140 |
+
value_getter: Callable[[], bool] | None = None,
|
| 141 |
+
value_setter: Callable[[bool], None] | None = None,
|
| 142 |
+
) -> None:
|
| 143 |
+
ESPHomeEntity.__init__(self, server)
|
| 144 |
+
self.key = key
|
| 145 |
+
self.name = name
|
| 146 |
+
self.object_id = object_id
|
| 147 |
+
self.icon = icon
|
| 148 |
+
self.device_class = device_class
|
| 149 |
+
self.entity_category = entity_category
|
| 150 |
+
self._value_getter = value_getter
|
| 151 |
+
self._value_setter = value_setter
|
| 152 |
+
self._value = False
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def value(self) -> bool:
|
| 156 |
+
return bool(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 157 |
+
|
| 158 |
+
@value.setter
|
| 159 |
+
def value(self, new_value: bool) -> None:
|
| 160 |
+
if self._value_setter:
|
| 161 |
+
self._value_setter(new_value)
|
| 162 |
+
self._value = new_value
|
| 163 |
+
|
| 164 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 165 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 166 |
+
yield ListEntitiesSwitchResponse(
|
| 167 |
+
object_id=self.object_id,
|
| 168 |
+
key=self.key,
|
| 169 |
+
name=self.name,
|
| 170 |
+
icon=self.icon,
|
| 171 |
+
device_class=self.device_class,
|
| 172 |
+
entity_category=self.entity_category,
|
| 173 |
+
)
|
| 174 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 175 |
+
yield self._get_state_message()
|
| 176 |
+
elif isinstance(msg, SwitchCommandRequest) and msg.key == self.key:
|
| 177 |
+
self.value = msg.state
|
| 178 |
+
yield self._get_state_message()
|
| 179 |
+
|
| 180 |
+
def _get_state_message(self) -> SwitchStateResponse:
|
| 181 |
+
return SwitchStateResponse(
|
| 182 |
+
key=self.key,
|
| 183 |
+
state=self.value,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
def update_state(self) -> None:
|
| 187 |
+
"""Send state update to Home Assistant."""
|
| 188 |
+
self.server.send_messages([self._get_state_message()])
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class SelectEntity(ESPHomeEntity):
|
| 192 |
+
"""Select entity for ESPHome (read-write string selection)."""
|
| 193 |
+
|
| 194 |
+
def __init__(
|
| 195 |
+
self,
|
| 196 |
+
server: "APIServer",
|
| 197 |
+
key: int,
|
| 198 |
+
name: str,
|
| 199 |
+
object_id: str,
|
| 200 |
+
options: list[str],
|
| 201 |
+
icon: str = "",
|
| 202 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 203 |
+
value_getter: Callable[[], str] | None = None,
|
| 204 |
+
value_setter: Callable[[str], None] | None = None,
|
| 205 |
+
) -> None:
|
| 206 |
+
ESPHomeEntity.__init__(self, server)
|
| 207 |
+
self.key = key
|
| 208 |
+
self.name = name
|
| 209 |
+
self.object_id = object_id
|
| 210 |
+
self.options = options
|
| 211 |
+
self.icon = icon
|
| 212 |
+
self.entity_category = entity_category
|
| 213 |
+
self._value_getter = value_getter
|
| 214 |
+
self._value_setter = value_setter
|
| 215 |
+
self._value = options[0] if options else ""
|
| 216 |
+
|
| 217 |
+
@property
|
| 218 |
+
def value(self) -> str:
|
| 219 |
+
return str(_safe_get_value(self._value_getter, self._value, self.object_id))
|
| 220 |
+
|
| 221 |
+
@value.setter
|
| 222 |
+
def value(self, new_value: str) -> None:
|
| 223 |
+
if new_value in self.options:
|
| 224 |
+
if self._value_setter:
|
| 225 |
+
self._value_setter(new_value)
|
| 226 |
+
self._value = new_value
|
| 227 |
+
else:
|
| 228 |
+
logger.warning(f"Invalid option '{new_value}' for {self.name}")
|
| 229 |
+
|
| 230 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 231 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 232 |
+
yield ListEntitiesSelectResponse(
|
| 233 |
+
object_id=self.object_id,
|
| 234 |
+
key=self.key,
|
| 235 |
+
name=self.name,
|
| 236 |
+
icon=self.icon,
|
| 237 |
+
options=self.options,
|
| 238 |
+
entity_category=self.entity_category,
|
| 239 |
+
)
|
| 240 |
+
elif isinstance(msg, (SubscribeHomeAssistantStatesRequest, SubscribeStatesRequest)):
|
| 241 |
+
yield self._get_state_message()
|
| 242 |
+
elif isinstance(msg, SelectCommandRequest) and msg.key == self.key:
|
| 243 |
+
self.value = msg.state
|
| 244 |
+
yield self._get_state_message()
|
| 245 |
+
|
| 246 |
+
def _get_state_message(self) -> SelectStateResponse:
|
| 247 |
+
return SelectStateResponse(
|
| 248 |
+
key=self.key,
|
| 249 |
+
state=self.value,
|
| 250 |
+
missing_state=False,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
def update_state(self) -> None:
|
| 254 |
+
"""Send state update to Home Assistant."""
|
| 255 |
+
self.server.send_messages([self._get_state_message()])
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class ButtonEntity(ESPHomeEntity):
|
| 259 |
+
"""Button entity for ESPHome (trigger actions)."""
|
| 260 |
+
|
| 261 |
+
def __init__(
|
| 262 |
+
self,
|
| 263 |
+
server: "APIServer",
|
| 264 |
+
key: int,
|
| 265 |
+
name: str,
|
| 266 |
+
object_id: str,
|
| 267 |
+
icon: str = "",
|
| 268 |
+
device_class: str = "",
|
| 269 |
+
entity_category: int = 0, # 0 = none, 1 = config, 2 = diagnostic
|
| 270 |
+
on_press: Callable[[], None] | None = None,
|
| 271 |
+
) -> None:
|
| 272 |
+
ESPHomeEntity.__init__(self, server)
|
| 273 |
+
self.key = key
|
| 274 |
+
self.name = name
|
| 275 |
+
self.object_id = object_id
|
| 276 |
+
self.icon = icon
|
| 277 |
+
self.device_class = device_class
|
| 278 |
+
self.entity_category = entity_category
|
| 279 |
+
self._on_press = on_press
|
| 280 |
+
|
| 281 |
+
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
| 282 |
+
if isinstance(msg, ListEntitiesRequest):
|
| 283 |
+
yield ListEntitiesButtonResponse(
|
| 284 |
+
object_id=self.object_id,
|
| 285 |
+
key=self.key,
|
| 286 |
+
name=self.name,
|
| 287 |
+
icon=self.icon,
|
| 288 |
+
device_class=self.device_class,
|
| 289 |
+
entity_category=self.entity_category,
|
| 290 |
+
)
|
| 291 |
+
elif isinstance(msg, ButtonCommandRequest) and msg.key == self.key:
|
| 292 |
+
if self._on_press:
|
| 293 |
+
try:
|
| 294 |
+
self._on_press()
|
| 295 |
+
logger.info(f"Button '{self.name}' pressed")
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.error(f"Error executing button '{self.name}': {e}")
|
| 298 |
+
# Buttons don't have state responses
|
| 299 |
+
return
|
| 300 |
+
yield # Make this a generator
|
reachy_mini_home_assistant/entities/entity_factory.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity factory for creating ESPHome entities.
|
| 2 |
+
|
| 3 |
+
This module provides factory functions for creating entities in a declarative way,
|
| 4 |
+
reducing boilerplate code in entity_registry.py.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from .entity import BinarySensorEntity, CameraEntity, NumberEntity, TextSensorEntity
|
| 14 |
+
from .entity_extensions import ButtonEntity, SelectEntity, SensorEntity, SwitchEntity
|
| 15 |
+
from .entity_keys import get_entity_key
|
| 16 |
+
|
| 17 |
+
_LOGGER = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class EntityType(Enum):
|
| 21 |
+
"""Supported entity types."""
|
| 22 |
+
|
| 23 |
+
SENSOR = "sensor"
|
| 24 |
+
BINARY_SENSOR = "binary_sensor"
|
| 25 |
+
TEXT_SENSOR = "text_sensor"
|
| 26 |
+
SWITCH = "switch"
|
| 27 |
+
SELECT = "select"
|
| 28 |
+
BUTTON = "button"
|
| 29 |
+
NUMBER = "number"
|
| 30 |
+
CAMERA = "camera"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class EntityDefinition:
|
| 35 |
+
"""Definition for an entity to be created."""
|
| 36 |
+
|
| 37 |
+
entity_type: EntityType
|
| 38 |
+
key_name: str
|
| 39 |
+
name: str
|
| 40 |
+
object_id: str
|
| 41 |
+
icon: str = "mdi:information"
|
| 42 |
+
|
| 43 |
+
# Common optional fields
|
| 44 |
+
entity_category: int | None = None # 0=None, 1=config, 2=diagnostic
|
| 45 |
+
|
| 46 |
+
# Sensor specific
|
| 47 |
+
unit_of_measurement: str | None = None
|
| 48 |
+
accuracy_decimals: int | None = None
|
| 49 |
+
state_class: str | None = None
|
| 50 |
+
device_class: str | None = None
|
| 51 |
+
|
| 52 |
+
# Number specific
|
| 53 |
+
min_value: float | None = None
|
| 54 |
+
max_value: float | None = None
|
| 55 |
+
step: float | None = None
|
| 56 |
+
mode: int | None = None # 0=auto, 1=box, 2=slider
|
| 57 |
+
|
| 58 |
+
# Select specific
|
| 59 |
+
options: list[str] | None = None
|
| 60 |
+
|
| 61 |
+
# Callbacks (set at runtime)
|
| 62 |
+
value_getter: Callable | None = None
|
| 63 |
+
command_handler: Callable | None = None
|
| 64 |
+
|
| 65 |
+
# Additional kwargs
|
| 66 |
+
extra: dict[str, Any] = field(default_factory=dict)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def create_entity(server, definition: EntityDefinition) -> Any:
|
| 70 |
+
"""Create an entity from a definition.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
server: The VoiceSatelliteProtocol server instance
|
| 74 |
+
definition: The entity definition
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
The created entity instance
|
| 78 |
+
"""
|
| 79 |
+
key = get_entity_key(definition.key_name)
|
| 80 |
+
|
| 81 |
+
common_args = {
|
| 82 |
+
"server": server,
|
| 83 |
+
"key": key,
|
| 84 |
+
"name": definition.name,
|
| 85 |
+
"object_id": definition.object_id,
|
| 86 |
+
"icon": definition.icon,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if definition.entity_category is not None:
|
| 90 |
+
common_args["entity_category"] = definition.entity_category
|
| 91 |
+
|
| 92 |
+
if definition.entity_type == EntityType.SENSOR:
|
| 93 |
+
args = {**common_args}
|
| 94 |
+
if definition.unit_of_measurement:
|
| 95 |
+
args["unit_of_measurement"] = definition.unit_of_measurement
|
| 96 |
+
if definition.accuracy_decimals is not None:
|
| 97 |
+
args["accuracy_decimals"] = definition.accuracy_decimals
|
| 98 |
+
if definition.state_class:
|
| 99 |
+
args["state_class"] = definition.state_class
|
| 100 |
+
if definition.device_class:
|
| 101 |
+
args["device_class"] = definition.device_class
|
| 102 |
+
if definition.value_getter:
|
| 103 |
+
args["value_getter"] = definition.value_getter
|
| 104 |
+
args.update(definition.extra)
|
| 105 |
+
return SensorEntity(**args)
|
| 106 |
+
|
| 107 |
+
elif definition.entity_type == EntityType.BINARY_SENSOR:
|
| 108 |
+
args = {**common_args}
|
| 109 |
+
if definition.device_class:
|
| 110 |
+
args["device_class"] = definition.device_class
|
| 111 |
+
if definition.value_getter:
|
| 112 |
+
args["value_getter"] = definition.value_getter
|
| 113 |
+
args.update(definition.extra)
|
| 114 |
+
return BinarySensorEntity(**args)
|
| 115 |
+
|
| 116 |
+
elif definition.entity_type == EntityType.TEXT_SENSOR:
|
| 117 |
+
args = {**common_args}
|
| 118 |
+
if definition.value_getter:
|
| 119 |
+
args["value_getter"] = definition.value_getter
|
| 120 |
+
args.update(definition.extra)
|
| 121 |
+
return TextSensorEntity(**args)
|
| 122 |
+
|
| 123 |
+
elif definition.entity_type == EntityType.SWITCH:
|
| 124 |
+
args = {**common_args}
|
| 125 |
+
if definition.value_getter:
|
| 126 |
+
args["value_getter"] = definition.value_getter
|
| 127 |
+
if definition.command_handler:
|
| 128 |
+
args["command_handler"] = definition.command_handler
|
| 129 |
+
args.update(definition.extra)
|
| 130 |
+
return SwitchEntity(**args)
|
| 131 |
+
|
| 132 |
+
elif definition.entity_type == EntityType.SELECT:
|
| 133 |
+
args = {**common_args}
|
| 134 |
+
if definition.options:
|
| 135 |
+
args["options"] = definition.options
|
| 136 |
+
if definition.value_getter:
|
| 137 |
+
args["value_getter"] = definition.value_getter
|
| 138 |
+
if definition.command_handler:
|
| 139 |
+
args["command_handler"] = definition.command_handler
|
| 140 |
+
args.update(definition.extra)
|
| 141 |
+
return SelectEntity(**args)
|
| 142 |
+
|
| 143 |
+
elif definition.entity_type == EntityType.BUTTON:
|
| 144 |
+
args = {**common_args}
|
| 145 |
+
if definition.command_handler:
|
| 146 |
+
args["command_handler"] = definition.command_handler
|
| 147 |
+
args.update(definition.extra)
|
| 148 |
+
return ButtonEntity(**args)
|
| 149 |
+
|
| 150 |
+
elif definition.entity_type == EntityType.NUMBER:
|
| 151 |
+
args = {**common_args}
|
| 152 |
+
if definition.min_value is not None:
|
| 153 |
+
args["min_value"] = definition.min_value
|
| 154 |
+
if definition.max_value is not None:
|
| 155 |
+
args["max_value"] = definition.max_value
|
| 156 |
+
if definition.step is not None:
|
| 157 |
+
args["step"] = definition.step
|
| 158 |
+
if definition.mode is not None:
|
| 159 |
+
args["mode"] = definition.mode
|
| 160 |
+
if definition.unit_of_measurement:
|
| 161 |
+
args["unit_of_measurement"] = definition.unit_of_measurement
|
| 162 |
+
if definition.value_getter:
|
| 163 |
+
args["value_getter"] = definition.value_getter
|
| 164 |
+
if definition.command_handler:
|
| 165 |
+
# NumberEntity uses value_setter instead of command_handler
|
| 166 |
+
args["value_setter"] = definition.command_handler
|
| 167 |
+
args.update(definition.extra)
|
| 168 |
+
return NumberEntity(**args)
|
| 169 |
+
|
| 170 |
+
elif definition.entity_type == EntityType.CAMERA:
|
| 171 |
+
args = {**common_args}
|
| 172 |
+
args.update(definition.extra)
|
| 173 |
+
return CameraEntity(**args)
|
| 174 |
+
|
| 175 |
+
else:
|
| 176 |
+
raise ValueError(f"Unknown entity type: {definition.entity_type}")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def create_entities(server, definitions: list[EntityDefinition]) -> list[Any]:
|
| 180 |
+
"""Create multiple entities from definitions.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
server: The VoiceSatelliteProtocol server instance
|
| 184 |
+
definitions: List of entity definitions
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
List of created entity instances
|
| 188 |
+
"""
|
| 189 |
+
entities = []
|
| 190 |
+
for definition in definitions:
|
| 191 |
+
try:
|
| 192 |
+
entity = create_entity(server, definition)
|
| 193 |
+
entities.append(entity)
|
| 194 |
+
except Exception as e:
|
| 195 |
+
_LOGGER.error("Failed to create entity %s: %s", definition.key_name, e)
|
| 196 |
+
return entities
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ============================================================================
|
| 200 |
+
# Predefined entity definition groups
|
| 201 |
+
# ============================================================================
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def get_diagnostic_sensor_definitions() -> list[EntityDefinition]:
|
| 205 |
+
"""Get definitions for diagnostic sensor entities."""
|
| 206 |
+
return [
|
| 207 |
+
EntityDefinition(
|
| 208 |
+
entity_type=EntityType.SENSOR,
|
| 209 |
+
key_name="sys_cpu_percent",
|
| 210 |
+
name="System CPU Usage",
|
| 211 |
+
object_id="sys_cpu_percent",
|
| 212 |
+
icon="mdi:cpu-64-bit",
|
| 213 |
+
unit_of_measurement="%",
|
| 214 |
+
accuracy_decimals=1,
|
| 215 |
+
state_class="measurement",
|
| 216 |
+
entity_category=2,
|
| 217 |
+
),
|
| 218 |
+
EntityDefinition(
|
| 219 |
+
entity_type=EntityType.SENSOR,
|
| 220 |
+
key_name="sys_cpu_temperature",
|
| 221 |
+
name="CPU Temperature",
|
| 222 |
+
object_id="sys_cpu_temperature",
|
| 223 |
+
icon="mdi:thermometer",
|
| 224 |
+
unit_of_measurement="°C",
|
| 225 |
+
accuracy_decimals=1,
|
| 226 |
+
device_class="temperature",
|
| 227 |
+
state_class="measurement",
|
| 228 |
+
entity_category=2,
|
| 229 |
+
),
|
| 230 |
+
EntityDefinition(
|
| 231 |
+
entity_type=EntityType.SENSOR,
|
| 232 |
+
key_name="sys_memory_percent",
|
| 233 |
+
name="System Memory Usage",
|
| 234 |
+
object_id="sys_memory_percent",
|
| 235 |
+
icon="mdi:memory",
|
| 236 |
+
unit_of_measurement="%",
|
| 237 |
+
accuracy_decimals=1,
|
| 238 |
+
state_class="measurement",
|
| 239 |
+
entity_category=2,
|
| 240 |
+
),
|
| 241 |
+
EntityDefinition(
|
| 242 |
+
entity_type=EntityType.SENSOR,
|
| 243 |
+
key_name="sys_memory_used",
|
| 244 |
+
name="System Memory Used",
|
| 245 |
+
object_id="sys_memory_used",
|
| 246 |
+
icon="mdi:memory",
|
| 247 |
+
unit_of_measurement="GB",
|
| 248 |
+
accuracy_decimals=2,
|
| 249 |
+
state_class="measurement",
|
| 250 |
+
entity_category=2,
|
| 251 |
+
),
|
| 252 |
+
EntityDefinition(
|
| 253 |
+
entity_type=EntityType.SENSOR,
|
| 254 |
+
key_name="sys_disk_percent",
|
| 255 |
+
name="System Disk Usage",
|
| 256 |
+
object_id="sys_disk_percent",
|
| 257 |
+
icon="mdi:harddisk",
|
| 258 |
+
unit_of_measurement="%",
|
| 259 |
+
accuracy_decimals=1,
|
| 260 |
+
state_class="measurement",
|
| 261 |
+
entity_category=2,
|
| 262 |
+
),
|
| 263 |
+
EntityDefinition(
|
| 264 |
+
entity_type=EntityType.SENSOR,
|
| 265 |
+
key_name="sys_disk_free",
|
| 266 |
+
name="System Disk Free",
|
| 267 |
+
object_id="sys_disk_free",
|
| 268 |
+
icon="mdi:harddisk",
|
| 269 |
+
unit_of_measurement="GB",
|
| 270 |
+
accuracy_decimals=1,
|
| 271 |
+
state_class="measurement",
|
| 272 |
+
entity_category=2,
|
| 273 |
+
),
|
| 274 |
+
EntityDefinition(
|
| 275 |
+
entity_type=EntityType.SENSOR,
|
| 276 |
+
key_name="sys_uptime",
|
| 277 |
+
name="System Uptime",
|
| 278 |
+
object_id="sys_uptime",
|
| 279 |
+
icon="mdi:clock-outline",
|
| 280 |
+
unit_of_measurement="h",
|
| 281 |
+
accuracy_decimals=1,
|
| 282 |
+
state_class="measurement",
|
| 283 |
+
entity_category=2,
|
| 284 |
+
),
|
| 285 |
+
EntityDefinition(
|
| 286 |
+
entity_type=EntityType.SENSOR,
|
| 287 |
+
key_name="sys_process_cpu",
|
| 288 |
+
name="App CPU Usage",
|
| 289 |
+
object_id="sys_process_cpu",
|
| 290 |
+
icon="mdi:application-cog",
|
| 291 |
+
unit_of_measurement="%",
|
| 292 |
+
accuracy_decimals=1,
|
| 293 |
+
state_class="measurement",
|
| 294 |
+
entity_category=2,
|
| 295 |
+
),
|
| 296 |
+
EntityDefinition(
|
| 297 |
+
entity_type=EntityType.SENSOR,
|
| 298 |
+
key_name="sys_process_memory",
|
| 299 |
+
name="App Memory Usage",
|
| 300 |
+
object_id="sys_process_memory",
|
| 301 |
+
icon="mdi:application-cog",
|
| 302 |
+
unit_of_measurement="MB",
|
| 303 |
+
accuracy_decimals=1,
|
| 304 |
+
state_class="measurement",
|
| 305 |
+
entity_category=2,
|
| 306 |
+
),
|
| 307 |
+
]
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def get_imu_sensor_definitions() -> list[EntityDefinition]:
|
| 311 |
+
"""Get definitions for IMU sensor entities."""
|
| 312 |
+
definitions = []
|
| 313 |
+
|
| 314 |
+
# Accelerometer
|
| 315 |
+
for axis in ["x", "y", "z"]:
|
| 316 |
+
definitions.append(
|
| 317 |
+
EntityDefinition(
|
| 318 |
+
entity_type=EntityType.SENSOR,
|
| 319 |
+
key_name=f"imu_accel_{axis}",
|
| 320 |
+
name=f"IMU Accel {axis.upper()}",
|
| 321 |
+
object_id=f"imu_accel_{axis}",
|
| 322 |
+
icon=f"mdi:axis-{axis}-arrow",
|
| 323 |
+
unit_of_measurement="m/s²",
|
| 324 |
+
accuracy_decimals=3,
|
| 325 |
+
state_class="measurement",
|
| 326 |
+
)
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# Gyroscope
|
| 330 |
+
for axis in ["x", "y", "z"]:
|
| 331 |
+
definitions.append(
|
| 332 |
+
EntityDefinition(
|
| 333 |
+
entity_type=EntityType.SENSOR,
|
| 334 |
+
key_name=f"imu_gyro_{axis}",
|
| 335 |
+
name=f"IMU Gyro {axis.upper()}",
|
| 336 |
+
object_id=f"imu_gyro_{axis}",
|
| 337 |
+
icon="mdi:rotate-3d-variant",
|
| 338 |
+
unit_of_measurement="rad/s",
|
| 339 |
+
accuracy_decimals=3,
|
| 340 |
+
state_class="measurement",
|
| 341 |
+
)
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Temperature
|
| 345 |
+
definitions.append(
|
| 346 |
+
EntityDefinition(
|
| 347 |
+
entity_type=EntityType.SENSOR,
|
| 348 |
+
key_name="imu_temperature",
|
| 349 |
+
name="IMU Temperature",
|
| 350 |
+
object_id="imu_temperature",
|
| 351 |
+
icon="mdi:thermometer",
|
| 352 |
+
unit_of_measurement="°C",
|
| 353 |
+
accuracy_decimals=1,
|
| 354 |
+
device_class="temperature",
|
| 355 |
+
state_class="measurement",
|
| 356 |
+
)
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return definitions
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def get_robot_info_definitions() -> list[EntityDefinition]:
|
| 363 |
+
"""Get definitions for robot info entities."""
|
| 364 |
+
return [
|
| 365 |
+
EntityDefinition(
|
| 366 |
+
entity_type=EntityType.SENSOR,
|
| 367 |
+
key_name="control_loop_frequency",
|
| 368 |
+
name="Control Loop Frequency",
|
| 369 |
+
object_id="control_loop_frequency",
|
| 370 |
+
icon="mdi:speedometer",
|
| 371 |
+
unit_of_measurement="Hz",
|
| 372 |
+
accuracy_decimals=1,
|
| 373 |
+
state_class="measurement",
|
| 374 |
+
entity_category=2,
|
| 375 |
+
),
|
| 376 |
+
EntityDefinition(
|
| 377 |
+
entity_type=EntityType.TEXT_SENSOR,
|
| 378 |
+
key_name="sdk_version",
|
| 379 |
+
name="SDK Version",
|
| 380 |
+
object_id="sdk_version",
|
| 381 |
+
icon="mdi:information",
|
| 382 |
+
entity_category=2,
|
| 383 |
+
),
|
| 384 |
+
EntityDefinition(
|
| 385 |
+
entity_type=EntityType.TEXT_SENSOR,
|
| 386 |
+
key_name="robot_name",
|
| 387 |
+
name="Robot Name",
|
| 388 |
+
object_id="robot_name",
|
| 389 |
+
icon="mdi:robot",
|
| 390 |
+
entity_category=2,
|
| 391 |
+
),
|
| 392 |
+
EntityDefinition(
|
| 393 |
+
entity_type=EntityType.BINARY_SENSOR,
|
| 394 |
+
key_name="wireless_version",
|
| 395 |
+
name="Wireless Version",
|
| 396 |
+
object_id="wireless_version",
|
| 397 |
+
icon="mdi:wifi",
|
| 398 |
+
device_class="connectivity",
|
| 399 |
+
entity_category=2,
|
| 400 |
+
),
|
| 401 |
+
EntityDefinition(
|
| 402 |
+
entity_type=EntityType.BINARY_SENSOR,
|
| 403 |
+
key_name="simulation_mode",
|
| 404 |
+
name="Simulation Mode",
|
| 405 |
+
object_id="simulation_mode",
|
| 406 |
+
icon="mdi:virtual-reality",
|
| 407 |
+
entity_category=2,
|
| 408 |
+
),
|
| 409 |
+
EntityDefinition(
|
| 410 |
+
entity_type=EntityType.TEXT_SENSOR,
|
| 411 |
+
key_name="wlan_ip",
|
| 412 |
+
name="WLAN IP",
|
| 413 |
+
object_id="wlan_ip",
|
| 414 |
+
icon="mdi:ip-network",
|
| 415 |
+
entity_category=2,
|
| 416 |
+
),
|
| 417 |
+
EntityDefinition(
|
| 418 |
+
entity_type=EntityType.TEXT_SENSOR,
|
| 419 |
+
key_name="error_message",
|
| 420 |
+
name="Error Message",
|
| 421 |
+
object_id="error_message",
|
| 422 |
+
icon="mdi:alert-circle",
|
| 423 |
+
entity_category=2,
|
| 424 |
+
),
|
| 425 |
+
]
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def get_pose_control_definitions() -> list[EntityDefinition]:
|
| 429 |
+
"""Get definitions for pose control entities (Phase 3)."""
|
| 430 |
+
definitions = []
|
| 431 |
+
|
| 432 |
+
# Head position controls (X, Y, Z in mm)
|
| 433 |
+
for axis in ["x", "y", "z"]:
|
| 434 |
+
definitions.append(
|
| 435 |
+
EntityDefinition(
|
| 436 |
+
entity_type=EntityType.NUMBER,
|
| 437 |
+
key_name=f"head_{axis}",
|
| 438 |
+
name=f"Head {axis.upper()} Position",
|
| 439 |
+
object_id=f"head_{axis}",
|
| 440 |
+
icon=f"mdi:axis-{axis}-arrow",
|
| 441 |
+
min_value=-50.0,
|
| 442 |
+
max_value=50.0,
|
| 443 |
+
step=1.0,
|
| 444 |
+
unit_of_measurement="mm",
|
| 445 |
+
mode=2, # slider
|
| 446 |
+
)
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
# Head orientation controls (Roll, Pitch in degrees)
|
| 450 |
+
for orient in ["roll", "pitch"]:
|
| 451 |
+
definitions.append(
|
| 452 |
+
EntityDefinition(
|
| 453 |
+
entity_type=EntityType.NUMBER,
|
| 454 |
+
key_name=f"head_{orient}",
|
| 455 |
+
name=f"Head {orient.capitalize()}",
|
| 456 |
+
object_id=f"head_{orient}",
|
| 457 |
+
icon="mdi:rotate-3d-variant",
|
| 458 |
+
min_value=-40.0,
|
| 459 |
+
max_value=40.0,
|
| 460 |
+
step=1.0,
|
| 461 |
+
unit_of_measurement="°",
|
| 462 |
+
mode=2,
|
| 463 |
+
)
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
# Head yaw (wider range)
|
| 467 |
+
definitions.append(
|
| 468 |
+
EntityDefinition(
|
| 469 |
+
entity_type=EntityType.NUMBER,
|
| 470 |
+
key_name="head_yaw",
|
| 471 |
+
name="Head Yaw",
|
| 472 |
+
object_id="head_yaw",
|
| 473 |
+
icon="mdi:rotate-3d-variant",
|
| 474 |
+
min_value=-180.0,
|
| 475 |
+
max_value=180.0,
|
| 476 |
+
step=1.0,
|
| 477 |
+
unit_of_measurement="°",
|
| 478 |
+
mode=2,
|
| 479 |
+
)
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
# Body yaw control
|
| 483 |
+
definitions.append(
|
| 484 |
+
EntityDefinition(
|
| 485 |
+
entity_type=EntityType.NUMBER,
|
| 486 |
+
key_name="body_yaw",
|
| 487 |
+
name="Body Yaw",
|
| 488 |
+
object_id="body_yaw",
|
| 489 |
+
icon="mdi:rotate-3d-variant",
|
| 490 |
+
min_value=-160.0,
|
| 491 |
+
max_value=160.0,
|
| 492 |
+
step=1.0,
|
| 493 |
+
unit_of_measurement="°",
|
| 494 |
+
mode=2,
|
| 495 |
+
)
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# Antenna controls
|
| 499 |
+
for side, label in [("left", "L"), ("right", "R")]:
|
| 500 |
+
definitions.append(
|
| 501 |
+
EntityDefinition(
|
| 502 |
+
entity_type=EntityType.NUMBER,
|
| 503 |
+
key_name=f"antenna_{side}",
|
| 504 |
+
name=f"Antenna({label})",
|
| 505 |
+
object_id=f"antenna_{side}",
|
| 506 |
+
icon="mdi:antenna",
|
| 507 |
+
min_value=-180.0,
|
| 508 |
+
max_value=180.0,
|
| 509 |
+
step=1.0,
|
| 510 |
+
unit_of_measurement="°",
|
| 511 |
+
mode=2,
|
| 512 |
+
)
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
return definitions
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def get_look_at_definitions() -> list[EntityDefinition]:
|
| 519 |
+
"""Get definitions for look-at control entities (Phase 4)."""
|
| 520 |
+
definitions = []
|
| 521 |
+
|
| 522 |
+
for axis in ["x", "y", "z"]:
|
| 523 |
+
definitions.append(
|
| 524 |
+
EntityDefinition(
|
| 525 |
+
entity_type=EntityType.NUMBER,
|
| 526 |
+
key_name=f"look_at_{axis}",
|
| 527 |
+
name=f"Look At {axis.upper()}",
|
| 528 |
+
object_id=f"look_at_{axis}",
|
| 529 |
+
icon="mdi:crosshairs-gps",
|
| 530 |
+
min_value=-2.0,
|
| 531 |
+
max_value=2.0,
|
| 532 |
+
step=0.1,
|
| 533 |
+
unit_of_measurement="m",
|
| 534 |
+
mode=1, # Box mode for precise input
|
| 535 |
+
)
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
return definitions
|
reachy_mini_home_assistant/entities/entity_keys.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity key definitions for ESPHome entities.
|
| 2 |
+
|
| 3 |
+
This module provides consistent entity key mappings for all HA entities.
|
| 4 |
+
Keys are fixed to ensure consistency across restarts.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Fixed entity key mapping - ensures consistent keys across restarts
|
| 13 |
+
# Keys are based on phase/category organization
|
| 14 |
+
ENTITY_KEYS: dict[str, int] = {
|
| 15 |
+
# Media player (key 0 reserved)
|
| 16 |
+
"reachy_mini_media_player": 0,
|
| 17 |
+
# Phase 1: Basic status and volume (100-199)
|
| 18 |
+
"daemon_state": 100,
|
| 19 |
+
"backend_ready": 101,
|
| 20 |
+
"mute": 102,
|
| 21 |
+
"speaker_volume": 103,
|
| 22 |
+
"idle_behavior_enabled": 104,
|
| 23 |
+
"sendspin_enabled": 105,
|
| 24 |
+
"face_tracking_enabled": 106,
|
| 25 |
+
"gesture_detection_enabled": 107,
|
| 26 |
+
"face_confidence_threshold": 108,
|
| 27 |
+
"camera_disabled": 109,
|
| 28 |
+
# Phase 2: Runtime controls (200-299)
|
| 29 |
+
"motor_mode": 201,
|
| 30 |
+
# Phase 3: Pose control (300-399)
|
| 31 |
+
"head_x": 300,
|
| 32 |
+
"head_y": 301,
|
| 33 |
+
"head_z": 302,
|
| 34 |
+
"head_roll": 303,
|
| 35 |
+
"head_pitch": 304,
|
| 36 |
+
"head_yaw": 305,
|
| 37 |
+
"body_yaw": 306,
|
| 38 |
+
"antenna_left": 307,
|
| 39 |
+
"antenna_right": 308,
|
| 40 |
+
# Phase 4: Look at control (400-499)
|
| 41 |
+
"look_at_x": 400,
|
| 42 |
+
"look_at_y": 401,
|
| 43 |
+
"look_at_z": 402,
|
| 44 |
+
# Phase 5: DOA - Direction of Arrival (500-599)
|
| 45 |
+
"doa_angle": 500,
|
| 46 |
+
"speech_detected": 501,
|
| 47 |
+
# Phase 6: Diagnostic information (600-699)
|
| 48 |
+
"control_loop_frequency": 600,
|
| 49 |
+
"sdk_version": 601,
|
| 50 |
+
"robot_name": 602,
|
| 51 |
+
"wireless_version": 603,
|
| 52 |
+
"simulation_mode": 604,
|
| 53 |
+
"wlan_ip": 605,
|
| 54 |
+
"error_message": 606,
|
| 55 |
+
# Phase 7: IMU sensors (700-799)
|
| 56 |
+
"imu_accel_x": 700,
|
| 57 |
+
"imu_accel_y": 701,
|
| 58 |
+
"imu_accel_z": 702,
|
| 59 |
+
"imu_gyro_x": 703,
|
| 60 |
+
"imu_gyro_y": 704,
|
| 61 |
+
"imu_gyro_z": 705,
|
| 62 |
+
"imu_temperature": 706,
|
| 63 |
+
# Phase 8: Emotion selector (800-899)
|
| 64 |
+
"emotion": 800,
|
| 65 |
+
# Phase 10: Camera (1000-1099)
|
| 66 |
+
"camera_url": 1000,
|
| 67 |
+
"camera": 1001,
|
| 68 |
+
# Phase 21: Continuous conversation (1500-1599)
|
| 69 |
+
"continuous_conversation": 1500,
|
| 70 |
+
# Phase 22: Gesture detection (1600-1699)
|
| 71 |
+
"gesture_detected": 1600,
|
| 72 |
+
"gesture_confidence": 1601,
|
| 73 |
+
# Phase 23: Face detection (1700-1799)
|
| 74 |
+
"face_detected": 1700,
|
| 75 |
+
# Phase 24: System diagnostics (1800-1899)
|
| 76 |
+
"sys_cpu_percent": 1800,
|
| 77 |
+
"sys_cpu_temperature": 1801,
|
| 78 |
+
"sys_memory_percent": 1802,
|
| 79 |
+
"sys_memory_used": 1803,
|
| 80 |
+
"sys_disk_percent": 1804,
|
| 81 |
+
"sys_disk_free": 1805,
|
| 82 |
+
"sys_uptime": 1806,
|
| 83 |
+
"sys_process_cpu": 1807,
|
| 84 |
+
"sys_process_memory": 1808,
|
| 85 |
+
# Phase 25: Runtime service state (1900-1999)
|
| 86 |
+
"services_suspended": 1901,
|
| 87 |
+
# Phase 26: DOA tracking control (2000+)
|
| 88 |
+
"doa_tracking_enabled": 2000,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_entity_key(object_id: str) -> int:
|
| 93 |
+
"""Get a consistent entity key for the given object_id.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
object_id: The entity's object ID
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
Integer key for the entity
|
| 100 |
+
"""
|
| 101 |
+
if object_id in ENTITY_KEYS:
|
| 102 |
+
return ENTITY_KEYS[object_id]
|
| 103 |
+
|
| 104 |
+
# Fallback: generate key from hash (should not happen if all entities are registered)
|
| 105 |
+
logger.warning("Entity key not found for %s, generating from hash", object_id)
|
| 106 |
+
return abs(hash(object_id)) % 10000 + 2000
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def register_entity_key(object_id: str, key: int) -> None:
|
| 110 |
+
"""Register a new entity key.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
object_id: The entity's object ID
|
| 114 |
+
key: The key to assign
|
| 115 |
+
"""
|
| 116 |
+
if object_id in ENTITY_KEYS:
|
| 117 |
+
logger.warning("Overwriting existing key for %s", object_id)
|
| 118 |
+
ENTITY_KEYS[object_id] = key
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def get_next_available_key(phase: int = 2000) -> int:
|
| 122 |
+
"""Get the next available key in a phase range.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
phase: The phase base (e.g., 2000 for phase 26+)
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Next available key in the range
|
| 129 |
+
"""
|
| 130 |
+
phase_keys = [k for k in ENTITY_KEYS.values() if phase <= k < phase + 100]
|
| 131 |
+
if not phase_keys:
|
| 132 |
+
return phase
|
| 133 |
+
return max(phase_keys) + 1
|
reachy_mini_home_assistant/entities/entity_registry.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity registry for ESPHome entities.
|
| 2 |
+
|
| 3 |
+
This module handles the registration and management of all ESPHome entities
|
| 4 |
+
for the Reachy Mini voice assistant.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
+
from typing import TYPE_CHECKING, Optional
|
| 10 |
+
|
| 11 |
+
from ..models import Preferences
|
| 12 |
+
from .entity import BinarySensorEntity, NumberEntity, TextSensorEntity
|
| 13 |
+
from .entity_extensions import SwitchEntity
|
| 14 |
+
from .entity_keys import get_entity_key
|
| 15 |
+
from .runtime_entity_setup import (
|
| 16 |
+
setup_behavior_entities,
|
| 17 |
+
setup_camera_entities,
|
| 18 |
+
setup_runtime_entities,
|
| 19 |
+
setup_service_entities,
|
| 20 |
+
)
|
| 21 |
+
from .sensor_entity_setup import (
|
| 22 |
+
append_defined_entities,
|
| 23 |
+
setup_audio_direction_entities,
|
| 24 |
+
setup_detection_entities,
|
| 25 |
+
setup_diagnostic_entities,
|
| 26 |
+
setup_imu_entities,
|
| 27 |
+
setup_motion_entities,
|
| 28 |
+
setup_robot_info_entities,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
if TYPE_CHECKING:
|
| 32 |
+
from ..reachy_controller import ReachyController
|
| 33 |
+
from ..vision.camera_server import MJPEGCameraServer
|
| 34 |
+
|
| 35 |
+
_LOGGER = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class EntityRegistry:
|
| 39 |
+
"""Registry for managing ESPHome entities."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
server,
|
| 44 |
+
reachy_controller: "ReachyController",
|
| 45 |
+
camera_server: Optional["MJPEGCameraServer"] = None,
|
| 46 |
+
play_emotion_callback: Callable[[str], None] | None = None,
|
| 47 |
+
):
|
| 48 |
+
"""Initialize the entity registry.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
server: The VoiceSatelliteProtocol server instance
|
| 52 |
+
reachy_controller: The ReachyController instance
|
| 53 |
+
camera_server: Optional camera server for camera entity
|
| 54 |
+
play_emotion_callback: Optional callback for playing emotions
|
| 55 |
+
"""
|
| 56 |
+
self.server = server
|
| 57 |
+
self.reachy_controller = reachy_controller
|
| 58 |
+
self.camera_server = camera_server
|
| 59 |
+
self._play_emotion_callback = play_emotion_callback
|
| 60 |
+
|
| 61 |
+
# Runtime state entities
|
| 62 |
+
self._services_suspended_entity: BinarySensorEntity | None = None
|
| 63 |
+
self._face_detected_entity: BinarySensorEntity | None = None
|
| 64 |
+
self._gesture_entity: TextSensorEntity | None = None
|
| 65 |
+
self._gesture_confidence_entity: SensorEntity | None = None
|
| 66 |
+
self._face_tracking_switch_entity: SwitchEntity | None = None
|
| 67 |
+
self._gesture_detection_switch_entity: SwitchEntity | None = None
|
| 68 |
+
|
| 69 |
+
# Gesture detection state
|
| 70 |
+
self._current_gesture = "none"
|
| 71 |
+
self._gesture_confidence = 0.0
|
| 72 |
+
|
| 73 |
+
# Emotion state
|
| 74 |
+
self._current_emotion = "None"
|
| 75 |
+
# Map emotion names to available robot emotions
|
| 76 |
+
# Full list of available emotions from robot
|
| 77 |
+
self._emotion_map = {
|
| 78 |
+
"None": None,
|
| 79 |
+
# Basic emotions
|
| 80 |
+
"Happy": "cheerful1",
|
| 81 |
+
"Sad": "sad1",
|
| 82 |
+
"Angry": "rage1",
|
| 83 |
+
"Fear": "fear1",
|
| 84 |
+
"Surprise": "surprised1",
|
| 85 |
+
"Disgust": "disgusted1",
|
| 86 |
+
# Extended emotions
|
| 87 |
+
"Laughing": "laughing1",
|
| 88 |
+
"Loving": "loving1",
|
| 89 |
+
"Proud": "proud1",
|
| 90 |
+
"Grateful": "grateful1",
|
| 91 |
+
"Enthusiastic": "enthusiastic1",
|
| 92 |
+
"Curious": "curious1",
|
| 93 |
+
"Amazed": "amazed1",
|
| 94 |
+
"Shy": "shy1",
|
| 95 |
+
"Confused": "confused1",
|
| 96 |
+
"Thoughtful": "thoughtful1",
|
| 97 |
+
"Anxious": "anxiety1",
|
| 98 |
+
"Scared": "scared1",
|
| 99 |
+
"Frustrated": "frustrated1",
|
| 100 |
+
"Irritated": "irritated1",
|
| 101 |
+
"Furious": "furious1",
|
| 102 |
+
"Contempt": "contempt1",
|
| 103 |
+
"Bored": "boredom1",
|
| 104 |
+
"Tired": "tired1",
|
| 105 |
+
"Exhausted": "exhausted1",
|
| 106 |
+
"Lonely": "lonely1",
|
| 107 |
+
"Downcast": "downcast1",
|
| 108 |
+
"Resigned": "resigned1",
|
| 109 |
+
"Uncertain": "uncertain1",
|
| 110 |
+
"Uncomfortable": "uncomfortable1",
|
| 111 |
+
"Lost": "lost1",
|
| 112 |
+
"Indifferent": "indifferent1",
|
| 113 |
+
# Positive actions
|
| 114 |
+
"Yes": "yes1",
|
| 115 |
+
"No": "no1",
|
| 116 |
+
"Welcoming": "welcoming1",
|
| 117 |
+
"Helpful": "helpful1",
|
| 118 |
+
"Attentive": "attentive1",
|
| 119 |
+
"Understanding": "understanding1",
|
| 120 |
+
"Calming": "calming1",
|
| 121 |
+
"Relief": "relief1",
|
| 122 |
+
"Success": "success1",
|
| 123 |
+
"Serenity": "serenity1",
|
| 124 |
+
# Negative actions
|
| 125 |
+
"Oops": "oops1",
|
| 126 |
+
"Displeased": "displeased1",
|
| 127 |
+
"Impatient": "impatient1",
|
| 128 |
+
"Reprimand": "reprimand1",
|
| 129 |
+
"GoAway": "go_away1",
|
| 130 |
+
# Special
|
| 131 |
+
"Come": "come1",
|
| 132 |
+
"Inquiring": "inquiring1",
|
| 133 |
+
"Sleep": "sleep1",
|
| 134 |
+
"Dance": "dance1",
|
| 135 |
+
"Electric": "electric1",
|
| 136 |
+
"Dying": "dying1",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def _get_preferences(self) -> Preferences | None:
|
| 140 |
+
return self.server.state.preferences
|
| 141 |
+
|
| 142 |
+
def _get_server_state(self):
|
| 143 |
+
return self.server.state
|
| 144 |
+
|
| 145 |
+
def _save_preferences(self) -> None:
|
| 146 |
+
self.server.state.save_preferences()
|
| 147 |
+
|
| 148 |
+
def _set_preference_and_save(self, key: str, value) -> None:
|
| 149 |
+
prefs = self._get_preferences()
|
| 150 |
+
if prefs is not None:
|
| 151 |
+
setattr(prefs, key, value)
|
| 152 |
+
self._save_preferences()
|
| 153 |
+
|
| 154 |
+
def _idle_behavior_allows_vision(self) -> bool:
|
| 155 |
+
prefs = self._get_preferences()
|
| 156 |
+
return bool(prefs.idle_behavior_enabled) if prefs is not None else False
|
| 157 |
+
|
| 158 |
+
def _apply_vision_runtime_state(self) -> None:
|
| 159 |
+
if self.camera_server is None:
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
prefs = self._get_preferences()
|
| 163 |
+
if prefs is None:
|
| 164 |
+
self.camera_server.apply_runtime_vision_state(
|
| 165 |
+
face_requested=False,
|
| 166 |
+
gesture_requested=False,
|
| 167 |
+
models_allowed=False,
|
| 168 |
+
)
|
| 169 |
+
return
|
| 170 |
+
|
| 171 |
+
self.camera_server.apply_runtime_vision_state(
|
| 172 |
+
face_requested=bool(prefs.face_tracking_enabled),
|
| 173 |
+
gesture_requested=bool(prefs.gesture_detection_enabled),
|
| 174 |
+
models_allowed=self._idle_behavior_allows_vision(),
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
def _get_pref_bool(self, key: str, default: bool = False) -> bool:
|
| 178 |
+
prefs = self._get_preferences()
|
| 179 |
+
return bool(getattr(prefs, key, default)) if prefs is not None else default
|
| 180 |
+
|
| 181 |
+
def _set_pref_bool(self, key: str, enabled: bool) -> None:
|
| 182 |
+
prefs = self._get_preferences()
|
| 183 |
+
if prefs is not None:
|
| 184 |
+
setattr(prefs, key, bool(enabled))
|
| 185 |
+
self._save_preferences()
|
| 186 |
+
|
| 187 |
+
def _get_pref_float(self, key: str, default: float) -> float:
|
| 188 |
+
prefs = self._get_preferences()
|
| 189 |
+
return float(getattr(prefs, key, default)) if prefs is not None else default
|
| 190 |
+
|
| 191 |
+
def _set_pref_float(self, key: str, value: float) -> None:
|
| 192 |
+
prefs = self._get_preferences()
|
| 193 |
+
if prefs is not None:
|
| 194 |
+
setattr(prefs, key, float(value))
|
| 195 |
+
self._save_preferences()
|
| 196 |
+
|
| 197 |
+
def _set_idle_behavior_enabled(self, enabled: bool) -> None:
|
| 198 |
+
self.reachy_controller.set_idle_behavior_enabled(enabled)
|
| 199 |
+
|
| 200 |
+
prefs = self._get_preferences()
|
| 201 |
+
if prefs is not None:
|
| 202 |
+
prefs.set_idle_behavior_enabled(enabled)
|
| 203 |
+
if not enabled:
|
| 204 |
+
prefs.face_tracking_enabled = False
|
| 205 |
+
prefs.gesture_detection_enabled = False
|
| 206 |
+
self._save_preferences()
|
| 207 |
+
|
| 208 |
+
voice_assistant = self.server._voice_assistant_service
|
| 209 |
+
if voice_assistant is not None:
|
| 210 |
+
voice_assistant.set_idle_behavior_enabled(enabled)
|
| 211 |
+
|
| 212 |
+
self._apply_vision_runtime_state()
|
| 213 |
+
|
| 214 |
+
if not enabled:
|
| 215 |
+
if self._face_tracking_switch_entity is not None:
|
| 216 |
+
self._face_tracking_switch_entity._value = False
|
| 217 |
+
self._face_tracking_switch_entity.update_state()
|
| 218 |
+
if self._gesture_detection_switch_entity is not None:
|
| 219 |
+
self._gesture_detection_switch_entity._value = False
|
| 220 |
+
self._gesture_detection_switch_entity.update_state()
|
| 221 |
+
if self._face_detected_entity is not None:
|
| 222 |
+
self._face_detected_entity._state = False
|
| 223 |
+
self._face_detected_entity.update_state()
|
| 224 |
+
if self._gesture_entity is not None:
|
| 225 |
+
self._gesture_entity._value = "none"
|
| 226 |
+
self._gesture_entity.update_state()
|
| 227 |
+
if self._gesture_confidence_entity is not None:
|
| 228 |
+
self._gesture_confidence_entity._state = 0.0
|
| 229 |
+
self._gesture_confidence_entity.update_state()
|
| 230 |
+
|
| 231 |
+
def _make_preference_switch(
|
| 232 |
+
self,
|
| 233 |
+
*,
|
| 234 |
+
key_name: str,
|
| 235 |
+
name: str,
|
| 236 |
+
object_id: str,
|
| 237 |
+
icon: str,
|
| 238 |
+
getter: Callable[[], bool],
|
| 239 |
+
setter: Callable[[bool], None],
|
| 240 |
+
) -> SwitchEntity:
|
| 241 |
+
"""Create a switch entity with the common registry wiring."""
|
| 242 |
+
return SwitchEntity(
|
| 243 |
+
server=self.server,
|
| 244 |
+
key=get_entity_key(key_name),
|
| 245 |
+
name=name,
|
| 246 |
+
object_id=object_id,
|
| 247 |
+
icon=icon,
|
| 248 |
+
entity_category=1,
|
| 249 |
+
value_getter=getter,
|
| 250 |
+
value_setter=setter,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
def _make_stored_switch(
|
| 254 |
+
self,
|
| 255 |
+
*,
|
| 256 |
+
key_name: str,
|
| 257 |
+
name: str,
|
| 258 |
+
object_id: str,
|
| 259 |
+
icon: str,
|
| 260 |
+
pref_key: str,
|
| 261 |
+
getter_transform: Callable[[bool], bool] | None = None,
|
| 262 |
+
setter_transform: Callable[[bool], bool] | None = None,
|
| 263 |
+
after_set: Callable[[], None] | None = None,
|
| 264 |
+
) -> SwitchEntity:
|
| 265 |
+
"""Create a switch backed by preferences with optional transforms/hooks."""
|
| 266 |
+
|
| 267 |
+
def getter() -> bool:
|
| 268 |
+
value = self._get_pref_bool(pref_key)
|
| 269 |
+
return getter_transform(value) if getter_transform is not None else value
|
| 270 |
+
|
| 271 |
+
def setter(enabled: bool) -> None:
|
| 272 |
+
stored = setter_transform(enabled) if setter_transform is not None else enabled
|
| 273 |
+
self._set_pref_bool(pref_key, stored)
|
| 274 |
+
if after_set is not None:
|
| 275 |
+
after_set()
|
| 276 |
+
|
| 277 |
+
return self._make_preference_switch(
|
| 278 |
+
key_name=key_name,
|
| 279 |
+
name=name,
|
| 280 |
+
object_id=object_id,
|
| 281 |
+
icon=icon,
|
| 282 |
+
getter=getter,
|
| 283 |
+
setter=setter,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
def _make_preference_number(
|
| 287 |
+
self,
|
| 288 |
+
*,
|
| 289 |
+
key_name: str,
|
| 290 |
+
name: str,
|
| 291 |
+
object_id: str,
|
| 292 |
+
icon: str,
|
| 293 |
+
getter: Callable[[], float],
|
| 294 |
+
setter: Callable[[float], None],
|
| 295 |
+
min_value: float,
|
| 296 |
+
max_value: float,
|
| 297 |
+
step: float,
|
| 298 |
+
mode: int = 2,
|
| 299 |
+
) -> NumberEntity:
|
| 300 |
+
"""Create a number entity with the common registry wiring."""
|
| 301 |
+
return NumberEntity(
|
| 302 |
+
server=self.server,
|
| 303 |
+
key=get_entity_key(key_name),
|
| 304 |
+
name=name,
|
| 305 |
+
object_id=object_id,
|
| 306 |
+
min_value=min_value,
|
| 307 |
+
max_value=max_value,
|
| 308 |
+
step=step,
|
| 309 |
+
icon=icon,
|
| 310 |
+
mode=mode,
|
| 311 |
+
entity_category=1,
|
| 312 |
+
value_getter=getter,
|
| 313 |
+
value_setter=setter,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
def _append_defined_entities(
|
| 317 |
+
self,
|
| 318 |
+
entities: list,
|
| 319 |
+
definitions: list,
|
| 320 |
+
callback_map: dict[str, tuple[Callable, Callable] | Callable],
|
| 321 |
+
) -> None:
|
| 322 |
+
"""Bind callbacks to declarative definitions and append created entities."""
|
| 323 |
+
append_defined_entities(self, entities, definitions, callback_map)
|
| 324 |
+
|
| 325 |
+
def setup_all_entities(self, entities: list) -> None:
|
| 326 |
+
"""Setup all entity phases."""
|
| 327 |
+
self._setup_phase1_entities(entities)
|
| 328 |
+
self._setup_phase2_entities(entities)
|
| 329 |
+
self._setup_phase3_entities(entities)
|
| 330 |
+
self._setup_phase4_entities(entities)
|
| 331 |
+
self._setup_phase5_entities(entities) # DOA for wakeup turn-to-sound
|
| 332 |
+
self._setup_phase6_entities(entities)
|
| 333 |
+
self._setup_phase7_entities(entities)
|
| 334 |
+
self._setup_phase8_entities(entities)
|
| 335 |
+
self._setup_phase9_entities(entities)
|
| 336 |
+
self._setup_phase10_entities(entities)
|
| 337 |
+
# Phase 11 (LED control) disabled - LEDs are inside the robot and not visible
|
| 338 |
+
self._setup_phase12_entities(entities)
|
| 339 |
+
# Phase 13 (Sendspin) - auto-enabled via mDNS discovery, no user entities
|
| 340 |
+
# Phase 14 (head_joints, passive_joints) removed - not needed
|
| 341 |
+
# Phase 20 (Tap detection) disabled - too many false triggers
|
| 342 |
+
self._setup_phase21_entities(entities)
|
| 343 |
+
self._setup_phase22_entities(entities)
|
| 344 |
+
self._setup_phase23_entities(entities)
|
| 345 |
+
self._setup_phase24_entities(entities) # System diagnostics
|
| 346 |
+
|
| 347 |
+
_LOGGER.info("All entities registered: %d total", len(entities))
|
| 348 |
+
|
| 349 |
+
def _setup_phase1_entities(self, entities: list) -> None:
|
| 350 |
+
setup_runtime_entities(self, entities)
|
| 351 |
+
|
| 352 |
+
def _setup_phase2_entities(self, entities: list) -> None:
|
| 353 |
+
setup_service_entities(self, entities)
|
| 354 |
+
|
| 355 |
+
def _setup_phase3_entities(self, entities: list) -> None:
|
| 356 |
+
setup_motion_entities(self, entities)
|
| 357 |
+
|
| 358 |
+
def _setup_phase4_entities(self, entities: list) -> None:
|
| 359 |
+
pass
|
| 360 |
+
|
| 361 |
+
def _setup_phase5_entities(self, entities: list) -> None:
|
| 362 |
+
setup_audio_direction_entities(self, entities)
|
| 363 |
+
|
| 364 |
+
def _setup_phase6_entities(self, entities: list) -> None:
|
| 365 |
+
setup_robot_info_entities(self, entities)
|
| 366 |
+
|
| 367 |
+
def _setup_phase7_entities(self, entities: list) -> None:
|
| 368 |
+
setup_imu_entities(self, entities)
|
| 369 |
+
|
| 370 |
+
def _setup_phase8_entities(self, entities: list) -> None:
|
| 371 |
+
setup_behavior_entities(self, entities)
|
| 372 |
+
|
| 373 |
+
def _setup_phase9_entities(self, entities: list) -> None:
|
| 374 |
+
"""Setup Phase 9 entities: Audio controls."""
|
| 375 |
+
_LOGGER.debug("Phase 9 entities registered: none")
|
| 376 |
+
|
| 377 |
+
def _setup_phase10_entities(self, entities: list) -> None:
|
| 378 |
+
setup_camera_entities(self, entities)
|
| 379 |
+
|
| 380 |
+
def _setup_phase12_entities(self, entities: list) -> None:
|
| 381 |
+
"""Setup Phase 12 entities: Audio processing parameters."""
|
| 382 |
+
_LOGGER.debug("Phase 12 entities registered: none")
|
| 383 |
+
|
| 384 |
+
def _setup_phase21_entities(self, entities: list) -> None:
|
| 385 |
+
pass
|
| 386 |
+
|
| 387 |
+
def _setup_phase22_entities(self, entities: list) -> None:
|
| 388 |
+
setup_detection_entities(self, entities)
|
| 389 |
+
|
| 390 |
+
def _setup_phase23_entities(self, entities: list) -> None:
|
| 391 |
+
pass
|
| 392 |
+
|
| 393 |
+
def update_face_detected_state(self) -> None:
|
| 394 |
+
"""Push face_detected state update to Home Assistant."""
|
| 395 |
+
if self._face_detected_entity:
|
| 396 |
+
self._face_detected_entity.update_state()
|
| 397 |
+
|
| 398 |
+
def update_gesture_state(self) -> None:
|
| 399 |
+
"""Push gesture state update to Home Assistant."""
|
| 400 |
+
if self._gesture_entity:
|
| 401 |
+
self._gesture_entity.update_state()
|
| 402 |
+
if self._gesture_confidence_entity:
|
| 403 |
+
self._gesture_confidence_entity.update_state()
|
| 404 |
+
|
| 405 |
+
def set_services_suspended(self, is_suspended: bool) -> None:
|
| 406 |
+
"""Update the services suspended state and push to Home Assistant.
|
| 407 |
+
|
| 408 |
+
Args:
|
| 409 |
+
is_suspended: True if services are suspended (ML models unloaded)
|
| 410 |
+
"""
|
| 411 |
+
if self._services_suspended_entity is not None:
|
| 412 |
+
# For "running" device_class, True = running, False = not running
|
| 413 |
+
# So we invert: suspended means NOT running
|
| 414 |
+
self._services_suspended_entity._state = not is_suspended
|
| 415 |
+
self._services_suspended_entity.update_state()
|
| 416 |
+
_LOGGER.debug("Services suspended state updated: suspended=%s", is_suspended)
|
| 417 |
+
|
| 418 |
+
def find_entity_references(self, entities: list) -> None:
|
| 419 |
+
"""Find and store references to special entities from existing list.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
entities: The list of existing entities to search
|
| 423 |
+
"""
|
| 424 |
+
# DOA entities are read-only sensors, no special references needed
|
| 425 |
+
pass
|
| 426 |
+
|
| 427 |
+
def _setup_phase24_entities(self, entities: list) -> None:
|
| 428 |
+
setup_diagnostic_entities(self, entities)
|
reachy_mini_home_assistant/entities/event_emotion_mapper.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Built-in Home Assistant reactions and behavior orchestration for Reachy Mini.
|
| 2 |
+
|
| 3 |
+
This module now mirrors the reference-project separation more closely:
|
| 4 |
+
- `EventEmotionMapper` resolves HA state changes into normalized reactions
|
| 5 |
+
- `BuiltinBehaviorController` executes the default zero-config behavior layer
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from collections.abc import Callable
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from ..animations.animation_config import get_animation_config_section
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
_MODULE_DIR = Path(__file__).parent
|
| 22 |
+
_PACKAGE_DIR = _MODULE_DIR.parent
|
| 23 |
+
_UNIFIED_BEHAVIORS_FILE = _PACKAGE_DIR / "animations" / "conversation_animations.json"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class EventSource(Enum):
|
| 27 |
+
"""Source of HA events."""
|
| 28 |
+
|
| 29 |
+
BINARY_SENSOR = "binary_sensor"
|
| 30 |
+
SENSOR = "sensor"
|
| 31 |
+
SWITCH = "switch"
|
| 32 |
+
INPUT_BOOLEAN = "input_boolean"
|
| 33 |
+
WEATHER = "weather"
|
| 34 |
+
AUTOMATION = "automation"
|
| 35 |
+
CUSTOM = "custom"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class EventEmotionMapping:
|
| 40 |
+
"""Mapping from HA event to robot emotion."""
|
| 41 |
+
|
| 42 |
+
entity_id: str
|
| 43 |
+
state_value: str # The state that triggers the emotion
|
| 44 |
+
emotion: str # Emotion animation name
|
| 45 |
+
cooldown: float = 60.0 # Minimum seconds between triggers
|
| 46 |
+
priority: int = 50 # Higher = more important (0-100)
|
| 47 |
+
description: str | None = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class EventTrigger:
|
| 52 |
+
"""Record of a triggered event."""
|
| 53 |
+
|
| 54 |
+
entity_id: str
|
| 55 |
+
old_state: str
|
| 56 |
+
new_state: str
|
| 57 |
+
timestamp: float
|
| 58 |
+
emotion: str | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
SKILL_PLAY_EMOTION = "play_emotion"
|
| 62 |
+
SKILL_TIMER_ALERT = "timer_alert"
|
| 63 |
+
SKILL_ERROR_REACT = "error_react"
|
| 64 |
+
VOICE_PHASE_LISTENING = "listening"
|
| 65 |
+
VOICE_PHASE_THINKING = "thinking"
|
| 66 |
+
VOICE_PHASE_SPEAKING = "speaking"
|
| 67 |
+
VOICE_PHASE_IDLE = "idle"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Default emotion mappings based on common HA entities
|
| 71 |
+
DEFAULT_EVENT_EMOTION_MAP: dict[str, list[EventEmotionMapping]] = {
|
| 72 |
+
# Door/window sensors
|
| 73 |
+
"binary_sensor.front_door": [
|
| 74 |
+
EventEmotionMapping(
|
| 75 |
+
entity_id="binary_sensor.front_door",
|
| 76 |
+
state_value="on",
|
| 77 |
+
emotion="curious1",
|
| 78 |
+
cooldown=30.0,
|
| 79 |
+
priority=70,
|
| 80 |
+
description="Someone at the door",
|
| 81 |
+
),
|
| 82 |
+
],
|
| 83 |
+
# Motion sensors
|
| 84 |
+
"binary_sensor.living_room_motion": [
|
| 85 |
+
EventEmotionMapping(
|
| 86 |
+
entity_id="binary_sensor.living_room_motion",
|
| 87 |
+
state_value="on",
|
| 88 |
+
emotion="surprised1",
|
| 89 |
+
cooldown=60.0,
|
| 90 |
+
priority=50,
|
| 91 |
+
description="Motion detected",
|
| 92 |
+
),
|
| 93 |
+
],
|
| 94 |
+
# Time-based triggers (via input_boolean)
|
| 95 |
+
"input_boolean.morning_routine": [
|
| 96 |
+
EventEmotionMapping(
|
| 97 |
+
entity_id="input_boolean.morning_routine",
|
| 98 |
+
state_value="on",
|
| 99 |
+
emotion="cheerful1",
|
| 100 |
+
cooldown=3600.0, # Once per hour
|
| 101 |
+
priority=60,
|
| 102 |
+
description="Good morning!",
|
| 103 |
+
),
|
| 104 |
+
],
|
| 105 |
+
"input_boolean.bedtime_routine": [
|
| 106 |
+
EventEmotionMapping(
|
| 107 |
+
entity_id="input_boolean.bedtime_routine",
|
| 108 |
+
state_value="on",
|
| 109 |
+
emotion="sleep1",
|
| 110 |
+
cooldown=3600.0,
|
| 111 |
+
priority=60,
|
| 112 |
+
description="Bedtime",
|
| 113 |
+
),
|
| 114 |
+
],
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class EventEmotionMapper:
|
| 119 |
+
"""Maps Home Assistant state changes to normalized emotion reactions.
|
| 120 |
+
|
| 121 |
+
This class handles:
|
| 122 |
+
- Event to emotion mapping based on configuration
|
| 123 |
+
- Cooldown management to prevent spam
|
| 124 |
+
- Priority handling for conflicting emotions
|
| 125 |
+
|
| 126 |
+
Usage:
|
| 127 |
+
mapper = EventEmotionMapper()
|
| 128 |
+
|
| 129 |
+
# When HA state changes:
|
| 130 |
+
emotion = mapper.handle_state_change("binary_sensor.front_door", "off", "on")
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
def __init__(
|
| 134 |
+
self,
|
| 135 |
+
mappings: dict[str, list[EventEmotionMapping]] | None = None,
|
| 136 |
+
max_triggers_per_minute: int = 3,
|
| 137 |
+
):
|
| 138 |
+
"""Initialize the event emotion mapper.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
mappings: Custom event mappings. Uses defaults if None.
|
| 142 |
+
max_triggers_per_minute: Rate limit for emotion triggers
|
| 143 |
+
"""
|
| 144 |
+
self._mappings: dict[str, list[EventEmotionMapping]] = {}
|
| 145 |
+
self._last_trigger_times: dict[str, float] = {}
|
| 146 |
+
self._trigger_history: list[EventTrigger] = []
|
| 147 |
+
self._max_history = 100
|
| 148 |
+
self._triggers_this_minute = 0
|
| 149 |
+
self._minute_start_time = time.monotonic()
|
| 150 |
+
self._max_triggers_per_minute = max_triggers_per_minute
|
| 151 |
+
self._lock = threading.Lock()
|
| 152 |
+
|
| 153 |
+
# Load default or custom mappings
|
| 154 |
+
if mappings:
|
| 155 |
+
self._mappings = mappings
|
| 156 |
+
else:
|
| 157 |
+
self._mappings = DEFAULT_EVENT_EMOTION_MAP.copy()
|
| 158 |
+
|
| 159 |
+
# Time function (can be overridden for testing)
|
| 160 |
+
self._now = time.monotonic
|
| 161 |
+
|
| 162 |
+
def add_mapping(self, mapping: EventEmotionMapping) -> None:
|
| 163 |
+
"""Add or update an event mapping."""
|
| 164 |
+
entity_id = mapping.entity_id
|
| 165 |
+
with self._lock:
|
| 166 |
+
if entity_id not in self._mappings:
|
| 167 |
+
self._mappings[entity_id] = []
|
| 168 |
+
# Remove existing mapping for same state
|
| 169 |
+
self._mappings[entity_id] = [m for m in self._mappings[entity_id] if m.state_value != mapping.state_value]
|
| 170 |
+
self._mappings[entity_id].append(mapping)
|
| 171 |
+
logger.debug("Added event mapping: %s -> %s", entity_id, mapping.emotion)
|
| 172 |
+
|
| 173 |
+
def remove_mapping(self, entity_id: str, state_value: str | None = None) -> None:
|
| 174 |
+
"""Remove event mapping(s)."""
|
| 175 |
+
with self._lock:
|
| 176 |
+
if entity_id in self._mappings:
|
| 177 |
+
if state_value:
|
| 178 |
+
self._mappings[entity_id] = [m for m in self._mappings[entity_id] if m.state_value != state_value]
|
| 179 |
+
else:
|
| 180 |
+
del self._mappings[entity_id]
|
| 181 |
+
|
| 182 |
+
def handle_state_change(
|
| 183 |
+
self,
|
| 184 |
+
entity_id: str,
|
| 185 |
+
old_state: str,
|
| 186 |
+
new_state: str,
|
| 187 |
+
) -> str | None:
|
| 188 |
+
"""Handle a Home Assistant state change.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
entity_id: Entity ID that changed
|
| 192 |
+
old_state: Previous state value
|
| 193 |
+
new_state: New state value
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
Emotion name if triggered, None otherwise
|
| 197 |
+
"""
|
| 198 |
+
now = self._now()
|
| 199 |
+
|
| 200 |
+
# Rate limiting
|
| 201 |
+
if not self._check_rate_limit(now):
|
| 202 |
+
logger.debug("Rate limit exceeded, skipping event")
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
# Find matching mappings
|
| 206 |
+
with self._lock:
|
| 207 |
+
if entity_id not in self._mappings:
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
mappings = self._mappings[entity_id]
|
| 211 |
+
|
| 212 |
+
# Find mapping for new state
|
| 213 |
+
matching = [m for m in mappings if m.state_value == new_state]
|
| 214 |
+
if not matching:
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
# Get highest priority mapping
|
| 218 |
+
mapping = max(matching, key=lambda m: m.priority)
|
| 219 |
+
|
| 220 |
+
# Check cooldown
|
| 221 |
+
key = f"{entity_id}:{mapping.state_value}"
|
| 222 |
+
last_trigger = self._last_trigger_times.get(key, 0)
|
| 223 |
+
if now - last_trigger < mapping.cooldown:
|
| 224 |
+
logger.debug("Event %s in cooldown (%.0fs remaining)", entity_id, mapping.cooldown - (now - last_trigger))
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
# Update cooldown and trigger
|
| 228 |
+
self._last_trigger_times[key] = now
|
| 229 |
+
self._triggers_this_minute += 1
|
| 230 |
+
|
| 231 |
+
# Record trigger
|
| 232 |
+
trigger = EventTrigger(
|
| 233 |
+
entity_id=entity_id,
|
| 234 |
+
old_state=old_state,
|
| 235 |
+
new_state=new_state,
|
| 236 |
+
timestamp=now,
|
| 237 |
+
emotion=mapping.emotion,
|
| 238 |
+
)
|
| 239 |
+
self._record_trigger(trigger)
|
| 240 |
+
|
| 241 |
+
return mapping.emotion
|
| 242 |
+
|
| 243 |
+
def _check_rate_limit(self, now: float) -> bool:
|
| 244 |
+
"""Check if within rate limit."""
|
| 245 |
+
# Reset counter every minute
|
| 246 |
+
if now - self._minute_start_time >= 60.0:
|
| 247 |
+
self._minute_start_time = now
|
| 248 |
+
self._triggers_this_minute = 0
|
| 249 |
+
|
| 250 |
+
return self._triggers_this_minute < self._max_triggers_per_minute
|
| 251 |
+
|
| 252 |
+
def _record_trigger(self, trigger: EventTrigger) -> None:
|
| 253 |
+
"""Record a trigger in history."""
|
| 254 |
+
self._trigger_history.append(trigger)
|
| 255 |
+
if len(self._trigger_history) > self._max_history:
|
| 256 |
+
self._trigger_history.pop(0)
|
| 257 |
+
|
| 258 |
+
def get_trigger_history(self) -> list[EventTrigger]:
|
| 259 |
+
"""Get recent trigger history."""
|
| 260 |
+
return self._trigger_history.copy()
|
| 261 |
+
|
| 262 |
+
def get_mappings(self) -> dict[str, list[EventEmotionMapping]]:
|
| 263 |
+
"""Get all current mappings."""
|
| 264 |
+
with self._lock:
|
| 265 |
+
return {k: v.copy() for k, v in self._mappings.items()}
|
| 266 |
+
|
| 267 |
+
def load_from_json(self, json_path: Path) -> bool:
|
| 268 |
+
"""Load event mappings from a JSON file.
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
json_path: Path to JSON configuration file
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
True if loaded successfully
|
| 275 |
+
"""
|
| 276 |
+
if not json_path.exists():
|
| 277 |
+
logger.warning("Event mappings file not found: %s", json_path)
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
data = get_animation_config_section(json_path, "ha_event_behaviors") or {}
|
| 282 |
+
|
| 283 |
+
settings = data.get("settings", {})
|
| 284 |
+
self._max_triggers_per_minute = settings.get("max_triggers_per_minute", self._max_triggers_per_minute)
|
| 285 |
+
|
| 286 |
+
mappings_data = data.get("mappings", {})
|
| 287 |
+
for entity_id, states in mappings_data.items():
|
| 288 |
+
for state_config in states:
|
| 289 |
+
mapping = EventEmotionMapping(
|
| 290 |
+
entity_id=entity_id,
|
| 291 |
+
state_value=state_config.get("state", "on"),
|
| 292 |
+
emotion=state_config.get("emotion", ""),
|
| 293 |
+
cooldown=state_config.get("cooldown", 60.0),
|
| 294 |
+
priority=state_config.get("priority", 50),
|
| 295 |
+
description=state_config.get("description"),
|
| 296 |
+
)
|
| 297 |
+
self.add_mapping(mapping)
|
| 298 |
+
|
| 299 |
+
logger.info("Loaded %d event mappings from %s", sum(len(v) for v in self._mappings.values()), json_path)
|
| 300 |
+
return True
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error("Failed to load event mappings: %s", e)
|
| 304 |
+
return False
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def load_event_mappings(json_path: Path | None = None) -> dict[str, list[EventEmotionMapping]]:
|
| 308 |
+
"""Load event mappings from JSON file or return defaults.
|
| 309 |
+
|
| 310 |
+
Args:
|
| 311 |
+
json_path: Path to JSON file. If None, uses default location.
|
| 312 |
+
|
| 313 |
+
Returns:
|
| 314 |
+
Dictionary of entity_id to list of EventEmotionMapping
|
| 315 |
+
"""
|
| 316 |
+
if json_path is None:
|
| 317 |
+
json_path = _UNIFIED_BEHAVIORS_FILE
|
| 318 |
+
|
| 319 |
+
if json_path.exists():
|
| 320 |
+
mapper = EventEmotionMapper()
|
| 321 |
+
if mapper.load_from_json(json_path):
|
| 322 |
+
return mapper.get_mappings()
|
| 323 |
+
|
| 324 |
+
return DEFAULT_EVENT_EMOTION_MAP.copy()
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
class BuiltinBehaviorController:
|
| 328 |
+
"""Execute zero-config built-in reactions.
|
| 329 |
+
|
| 330 |
+
This follows the reference-project separation of concerns:
|
| 331 |
+
protocol layer forwards normalized events here, and this controller
|
| 332 |
+
decides how to execute the default robot behavior.
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
def __init__(
|
| 336 |
+
self,
|
| 337 |
+
*,
|
| 338 |
+
event_mapper: EventEmotionMapper,
|
| 339 |
+
cancel_delayed_idle_return: Callable[[], None],
|
| 340 |
+
set_conversation_mode: Callable[[bool], None],
|
| 341 |
+
enter_motion_state: Callable[[str, str, bool | None], None],
|
| 342 |
+
run_motion_state: Callable[[str, str], None],
|
| 343 |
+
queue_emotion_move: Callable[[str], None],
|
| 344 |
+
) -> None:
|
| 345 |
+
self._event_mapper = event_mapper
|
| 346 |
+
self._cancel_delayed_idle_return = cancel_delayed_idle_return
|
| 347 |
+
self._set_conversation_mode = set_conversation_mode
|
| 348 |
+
self._enter_motion_state = enter_motion_state
|
| 349 |
+
self._run_motion_state = run_motion_state
|
| 350 |
+
self._queue_emotion_move = queue_emotion_move
|
| 351 |
+
|
| 352 |
+
def handle_voice_phase(self, phase: str) -> None:
|
| 353 |
+
"""Run the built-in robot behavior for a voice phase."""
|
| 354 |
+
if phase == VOICE_PHASE_LISTENING:
|
| 355 |
+
self._set_conversation_mode(True)
|
| 356 |
+
self._enter_motion_state(phase, "on_listening", face_tracking=True)
|
| 357 |
+
return
|
| 358 |
+
|
| 359 |
+
if phase == VOICE_PHASE_THINKING:
|
| 360 |
+
self._enter_motion_state(phase, "on_thinking", face_tracking=True)
|
| 361 |
+
return
|
| 362 |
+
|
| 363 |
+
if phase == VOICE_PHASE_SPEAKING:
|
| 364 |
+
self._enter_motion_state(phase, "on_speaking_start", face_tracking=False)
|
| 365 |
+
return
|
| 366 |
+
|
| 367 |
+
if phase == VOICE_PHASE_IDLE:
|
| 368 |
+
self._set_conversation_mode(False)
|
| 369 |
+
self._enter_motion_state(phase, "on_idle", face_tracking=True)
|
| 370 |
+
return
|
| 371 |
+
|
| 372 |
+
logger.debug("Unhandled built-in voice phase: %s", phase)
|
| 373 |
+
|
| 374 |
+
def execute_skill(
|
| 375 |
+
self,
|
| 376 |
+
skill: str,
|
| 377 |
+
*,
|
| 378 |
+
emotion_name: str | None = None,
|
| 379 |
+
event_name: str | None = None,
|
| 380 |
+
context: str | None = None,
|
| 381 |
+
) -> None:
|
| 382 |
+
"""Execute one normalized built-in skill."""
|
| 383 |
+
if skill == SKILL_PLAY_EMOTION:
|
| 384 |
+
if emotion_name:
|
| 385 |
+
self._queue_emotion_move(emotion_name)
|
| 386 |
+
return
|
| 387 |
+
|
| 388 |
+
if skill == SKILL_TIMER_ALERT:
|
| 389 |
+
self._run_motion_state(context or skill, "on_timer_finished")
|
| 390 |
+
return
|
| 391 |
+
|
| 392 |
+
if skill == SKILL_ERROR_REACT:
|
| 393 |
+
self._run_motion_state(context or skill, "on_error")
|
| 394 |
+
return
|
| 395 |
+
|
| 396 |
+
logger.debug("Unhandled built-in skill: %s", skill)
|
| 397 |
+
|
| 398 |
+
def handle_ha_state_change(self, entity_id: str, old_state: str, new_state: str) -> str | None:
|
| 399 |
+
"""Resolve HA state changes into built-in reactions."""
|
| 400 |
+
emotion = self._event_mapper.handle_state_change(entity_id, old_state, new_state)
|
| 401 |
+
if emotion:
|
| 402 |
+
self.execute_skill(SKILL_PLAY_EMOTION, emotion_name=emotion, context=f"ha:{entity_id}")
|
| 403 |
+
return emotion
|
reachy_mini_home_assistant/entities/runtime_entity_setup.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity setup helpers for runtime/control related entities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from .entity import BinarySensorEntity, CameraEntity, NumberEntity, TextSensorEntity
|
| 9 |
+
from .entity_extensions import SelectEntity, SensorEntity, SwitchEntity
|
| 10 |
+
from .entity_keys import get_entity_key
|
| 11 |
+
|
| 12 |
+
if TYPE_CHECKING:
|
| 13 |
+
from .entity_registry import EntityRegistry
|
| 14 |
+
|
| 15 |
+
_LOGGER = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def setup_runtime_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 19 |
+
rc = registry.reachy_controller
|
| 20 |
+
|
| 21 |
+
entities.append(
|
| 22 |
+
TextSensorEntity(
|
| 23 |
+
server=registry.server,
|
| 24 |
+
key=get_entity_key("daemon_state"),
|
| 25 |
+
name="Daemon State",
|
| 26 |
+
object_id="daemon_state",
|
| 27 |
+
icon="mdi:robot",
|
| 28 |
+
value_getter=rc.get_daemon_state,
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
entities.append(
|
| 32 |
+
BinarySensorEntity(
|
| 33 |
+
server=registry.server,
|
| 34 |
+
key=get_entity_key("backend_ready"),
|
| 35 |
+
name="Backend Ready",
|
| 36 |
+
object_id="backend_ready",
|
| 37 |
+
icon="mdi:check-circle",
|
| 38 |
+
device_class="connectivity",
|
| 39 |
+
value_getter=rc.get_backend_ready,
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
entities.append(
|
| 43 |
+
NumberEntity(
|
| 44 |
+
server=registry.server,
|
| 45 |
+
key=get_entity_key("speaker_volume"),
|
| 46 |
+
name="Speaker Volume",
|
| 47 |
+
object_id="speaker_volume",
|
| 48 |
+
min_value=0.0,
|
| 49 |
+
max_value=100.0,
|
| 50 |
+
step=1.0,
|
| 51 |
+
icon="mdi:volume-high",
|
| 52 |
+
unit_of_measurement="%",
|
| 53 |
+
mode=2,
|
| 54 |
+
entity_category=1,
|
| 55 |
+
value_getter=rc.get_speaker_volume,
|
| 56 |
+
value_setter=rc.set_speaker_volume,
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def get_muted() -> bool:
|
| 61 |
+
state = registry._get_server_state()
|
| 62 |
+
return bool(state.is_muted)
|
| 63 |
+
|
| 64 |
+
def set_muted(muted: bool) -> None:
|
| 65 |
+
state = registry._get_server_state()
|
| 66 |
+
state.is_muted = muted
|
| 67 |
+
voice_assistant = registry.server._voice_assistant_service
|
| 68 |
+
if muted:
|
| 69 |
+
voice_assistant._suspend_voice_services(reason="mute")
|
| 70 |
+
else:
|
| 71 |
+
voice_assistant._resume_voice_services(reason="mute")
|
| 72 |
+
|
| 73 |
+
entities.append(
|
| 74 |
+
SwitchEntity(
|
| 75 |
+
server=registry.server,
|
| 76 |
+
key=get_entity_key("mute"),
|
| 77 |
+
name="Mute",
|
| 78 |
+
object_id="mute",
|
| 79 |
+
icon="mdi:microphone-off",
|
| 80 |
+
entity_category=1,
|
| 81 |
+
value_getter=get_muted,
|
| 82 |
+
value_setter=set_muted,
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
def get_camera_disabled() -> bool:
|
| 87 |
+
state = registry._get_server_state()
|
| 88 |
+
return not state.camera_enabled if state is not None else False
|
| 89 |
+
|
| 90 |
+
def set_camera_disabled(disabled: bool) -> None:
|
| 91 |
+
state = registry._get_server_state()
|
| 92 |
+
if state is None:
|
| 93 |
+
return
|
| 94 |
+
state.camera_enabled = not disabled
|
| 95 |
+
if registry.camera_server:
|
| 96 |
+
if disabled:
|
| 97 |
+
registry.camera_server.suspend()
|
| 98 |
+
else:
|
| 99 |
+
registry.camera_server.resume_from_suspend()
|
| 100 |
+
|
| 101 |
+
entities.append(
|
| 102 |
+
SwitchEntity(
|
| 103 |
+
server=registry.server,
|
| 104 |
+
key=get_entity_key("camera_disabled"),
|
| 105 |
+
name="Disable Camera",
|
| 106 |
+
object_id="camera_disabled",
|
| 107 |
+
icon="mdi:camera-off",
|
| 108 |
+
entity_category=1,
|
| 109 |
+
value_getter=get_camera_disabled,
|
| 110 |
+
value_setter=set_camera_disabled,
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
entities.append(
|
| 115 |
+
registry._make_preference_switch(
|
| 116 |
+
key_name="idle_behavior_enabled",
|
| 117 |
+
name="Idle Behavior",
|
| 118 |
+
object_id="idle_behavior_enabled",
|
| 119 |
+
icon="mdi:motion-play",
|
| 120 |
+
getter=lambda: bool(registry._get_preferences().idle_behavior_enabled)
|
| 121 |
+
if registry._get_preferences()
|
| 122 |
+
else False,
|
| 123 |
+
setter=registry._set_idle_behavior_enabled,
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def sync_sendspin() -> None:
|
| 128 |
+
registry.server._voice_assistant_service.set_sendspin_enabled(registry._get_pref_bool("sendspin_enabled"))
|
| 129 |
+
|
| 130 |
+
entities.append(
|
| 131 |
+
registry._make_stored_switch(
|
| 132 |
+
key_name="sendspin_enabled",
|
| 133 |
+
name="Sendspin",
|
| 134 |
+
object_id="sendspin_enabled",
|
| 135 |
+
icon="mdi:speaker-wireless",
|
| 136 |
+
pref_key="sendspin_enabled",
|
| 137 |
+
after_set=sync_sendspin,
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
registry._face_tracking_switch_entity = registry._make_stored_switch(
|
| 141 |
+
key_name="face_tracking_enabled",
|
| 142 |
+
name="Face Tracking",
|
| 143 |
+
object_id="face_tracking_enabled",
|
| 144 |
+
icon="mdi:face-recognition",
|
| 145 |
+
pref_key="face_tracking_enabled",
|
| 146 |
+
after_set=registry._apply_vision_runtime_state,
|
| 147 |
+
)
|
| 148 |
+
entities.append(registry._face_tracking_switch_entity)
|
| 149 |
+
|
| 150 |
+
registry._gesture_detection_switch_entity = registry._make_stored_switch(
|
| 151 |
+
key_name="gesture_detection_enabled",
|
| 152 |
+
name="Gesture Detection",
|
| 153 |
+
object_id="gesture_detection_enabled",
|
| 154 |
+
icon="mdi:hand-wave",
|
| 155 |
+
pref_key="gesture_detection_enabled",
|
| 156 |
+
after_set=registry._apply_vision_runtime_state,
|
| 157 |
+
)
|
| 158 |
+
entities.append(registry._gesture_detection_switch_entity)
|
| 159 |
+
|
| 160 |
+
def get_face_confidence_threshold() -> float:
|
| 161 |
+
return registry._get_pref_float("face_confidence_threshold", 0.5)
|
| 162 |
+
|
| 163 |
+
def set_face_confidence_threshold(value: float) -> None:
|
| 164 |
+
value = max(0.0, min(1.0, float(value)))
|
| 165 |
+
registry._set_pref_float("face_confidence_threshold", value)
|
| 166 |
+
if registry.camera_server is not None:
|
| 167 |
+
registry.camera_server.set_face_confidence_threshold(value)
|
| 168 |
+
|
| 169 |
+
entities.append(
|
| 170 |
+
registry._make_preference_number(
|
| 171 |
+
key_name="face_confidence_threshold",
|
| 172 |
+
name="Face Confidence",
|
| 173 |
+
object_id="face_confidence_threshold",
|
| 174 |
+
icon="mdi:target",
|
| 175 |
+
getter=get_face_confidence_threshold,
|
| 176 |
+
setter=set_face_confidence_threshold,
|
| 177 |
+
min_value=0.0,
|
| 178 |
+
max_value=1.0,
|
| 179 |
+
step=0.01,
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
_LOGGER.debug("Phase 1 entities registered")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def setup_service_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 187 |
+
registry._services_suspended_entity = BinarySensorEntity(
|
| 188 |
+
server=registry.server,
|
| 189 |
+
key=get_entity_key("services_suspended"),
|
| 190 |
+
name="Services Suspended",
|
| 191 |
+
object_id="services_suspended",
|
| 192 |
+
icon="mdi:pause-circle",
|
| 193 |
+
device_class="running",
|
| 194 |
+
)
|
| 195 |
+
entities.append(registry._services_suspended_entity)
|
| 196 |
+
_LOGGER.debug("Service state entities registered")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def setup_behavior_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 200 |
+
def get_emotion() -> str:
|
| 201 |
+
return registry._current_emotion
|
| 202 |
+
|
| 203 |
+
def set_emotion(emotion: str) -> None:
|
| 204 |
+
registry._current_emotion = emotion
|
| 205 |
+
emotion_name = registry._emotion_map.get(emotion)
|
| 206 |
+
if emotion_name and registry._play_emotion_callback:
|
| 207 |
+
registry._play_emotion_callback(emotion_name)
|
| 208 |
+
registry._current_emotion = "None"
|
| 209 |
+
|
| 210 |
+
entities.append(
|
| 211 |
+
SelectEntity(
|
| 212 |
+
server=registry.server,
|
| 213 |
+
key=get_entity_key("emotion"),
|
| 214 |
+
name="Emotion",
|
| 215 |
+
object_id="emotion",
|
| 216 |
+
options=list(registry._emotion_map.keys()),
|
| 217 |
+
icon="mdi:emoticon",
|
| 218 |
+
value_getter=get_emotion,
|
| 219 |
+
value_setter=set_emotion,
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
entities.append(
|
| 224 |
+
SwitchEntity(
|
| 225 |
+
server=registry.server,
|
| 226 |
+
key=get_entity_key("continuous_conversation"),
|
| 227 |
+
name="Continuous Conversation",
|
| 228 |
+
object_id="continuous_conversation",
|
| 229 |
+
icon="mdi:message-reply-text",
|
| 230 |
+
device_class="switch",
|
| 231 |
+
entity_category=1,
|
| 232 |
+
value_getter=lambda: registry._get_pref_bool("continuous_conversation"),
|
| 233 |
+
value_setter=lambda enabled: registry._set_pref_bool("continuous_conversation", enabled),
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
_LOGGER.debug("Behavior entities registered")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def setup_camera_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 240 |
+
def get_camera_image() -> bytes | None:
|
| 241 |
+
if registry.camera_server:
|
| 242 |
+
try:
|
| 243 |
+
return registry.camera_server.get_snapshot()
|
| 244 |
+
except Exception as e:
|
| 245 |
+
_LOGGER.debug("Failed to get camera snapshot: %s", e)
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
entities.append(
|
| 249 |
+
CameraEntity(
|
| 250 |
+
server=registry.server,
|
| 251 |
+
key=get_entity_key("camera"),
|
| 252 |
+
name="Camera",
|
| 253 |
+
object_id="camera",
|
| 254 |
+
icon="mdi:camera",
|
| 255 |
+
image_getter=get_camera_image,
|
| 256 |
+
)
|
| 257 |
+
)
|
reachy_mini_home_assistant/entities/sensor_entity_setup.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity setup helpers for sensors, diagnostics, and motion control entities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from ..core.system_diagnostics import get_system_diagnostics
|
| 9 |
+
from .entity import BinarySensorEntity, TextSensorEntity
|
| 10 |
+
from .entity_extensions import SensorEntity, SwitchEntity
|
| 11 |
+
from .entity_factory import (
|
| 12 |
+
create_entity,
|
| 13 |
+
get_diagnostic_sensor_definitions,
|
| 14 |
+
get_imu_sensor_definitions,
|
| 15 |
+
get_look_at_definitions,
|
| 16 |
+
get_pose_control_definitions,
|
| 17 |
+
get_robot_info_definitions,
|
| 18 |
+
)
|
| 19 |
+
from .entity_keys import get_entity_key
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from .entity_registry import EntityRegistry
|
| 23 |
+
|
| 24 |
+
_LOGGER = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def append_defined_entities(registry: "EntityRegistry", entities: list, definitions: list, callback_map: dict) -> None:
|
| 28 |
+
for definition in definitions:
|
| 29 |
+
callbacks = callback_map.get(definition.key_name)
|
| 30 |
+
if isinstance(callbacks, tuple):
|
| 31 |
+
definition.value_getter = callbacks[0]
|
| 32 |
+
definition.command_handler = callbacks[1]
|
| 33 |
+
elif callbacks is not None:
|
| 34 |
+
definition.value_getter = callbacks
|
| 35 |
+
entities.append(create_entity(registry.server, definition))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def setup_motion_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 39 |
+
rc = registry.reachy_controller
|
| 40 |
+
append_defined_entities(
|
| 41 |
+
registry,
|
| 42 |
+
entities,
|
| 43 |
+
get_pose_control_definitions(),
|
| 44 |
+
{
|
| 45 |
+
"head_x": (rc.get_head_x, rc.set_head_x),
|
| 46 |
+
"head_y": (rc.get_head_y, rc.set_head_y),
|
| 47 |
+
"head_z": (rc.get_head_z, rc.set_head_z),
|
| 48 |
+
"head_roll": (rc.get_head_roll, rc.set_head_roll),
|
| 49 |
+
"head_pitch": (rc.get_head_pitch, rc.set_head_pitch),
|
| 50 |
+
"head_yaw": (rc.get_head_yaw, rc.set_head_yaw),
|
| 51 |
+
"body_yaw": (rc.get_body_yaw, rc.set_body_yaw),
|
| 52 |
+
"antenna_left": (rc.get_antenna_left, rc.set_antenna_left),
|
| 53 |
+
"antenna_right": (rc.get_antenna_right, rc.set_antenna_right),
|
| 54 |
+
},
|
| 55 |
+
)
|
| 56 |
+
append_defined_entities(
|
| 57 |
+
registry,
|
| 58 |
+
entities,
|
| 59 |
+
get_look_at_definitions(),
|
| 60 |
+
{
|
| 61 |
+
"look_at_x": (rc.get_look_at_x, rc.set_look_at_x),
|
| 62 |
+
"look_at_y": (rc.get_look_at_y, rc.set_look_at_y),
|
| 63 |
+
"look_at_z": (rc.get_look_at_z, rc.set_look_at_z),
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
+
_LOGGER.debug("Motion entities registered")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def setup_audio_direction_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 70 |
+
rc = registry.reachy_controller
|
| 71 |
+
entities.append(
|
| 72 |
+
SensorEntity(
|
| 73 |
+
server=registry.server,
|
| 74 |
+
key=get_entity_key("doa_angle"),
|
| 75 |
+
name="DOA Angle",
|
| 76 |
+
object_id="doa_angle",
|
| 77 |
+
icon="mdi:surround-sound",
|
| 78 |
+
unit_of_measurement="°",
|
| 79 |
+
accuracy_decimals=1,
|
| 80 |
+
state_class="measurement",
|
| 81 |
+
value_getter=rc.get_doa_angle_degrees,
|
| 82 |
+
)
|
| 83 |
+
)
|
| 84 |
+
entities.append(
|
| 85 |
+
BinarySensorEntity(
|
| 86 |
+
server=registry.server,
|
| 87 |
+
key=get_entity_key("speech_detected"),
|
| 88 |
+
name="Speech Detected",
|
| 89 |
+
object_id="speech_detected",
|
| 90 |
+
icon="mdi:account-voice",
|
| 91 |
+
device_class="sound",
|
| 92 |
+
value_getter=rc.get_speech_detected,
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
entities.append(
|
| 96 |
+
SwitchEntity(
|
| 97 |
+
server=registry.server,
|
| 98 |
+
key=get_entity_key("doa_tracking_enabled"),
|
| 99 |
+
name="DOA Sound Tracking",
|
| 100 |
+
object_id="doa_tracking_enabled",
|
| 101 |
+
icon="mdi:ear-hearing",
|
| 102 |
+
value_getter=rc.get_doa_enabled,
|
| 103 |
+
value_setter=rc.set_doa_enabled,
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def setup_robot_info_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 109 |
+
rc = registry.reachy_controller
|
| 110 |
+
append_defined_entities(
|
| 111 |
+
registry,
|
| 112 |
+
entities,
|
| 113 |
+
get_robot_info_definitions(),
|
| 114 |
+
{
|
| 115 |
+
"control_loop_frequency": rc.get_control_loop_frequency,
|
| 116 |
+
"sdk_version": rc.get_sdk_version,
|
| 117 |
+
"robot_name": rc.get_robot_name,
|
| 118 |
+
"wireless_version": rc.get_wireless_version,
|
| 119 |
+
"simulation_mode": rc.get_simulation_mode,
|
| 120 |
+
"wlan_ip": rc.get_wlan_ip,
|
| 121 |
+
"error_message": rc.get_error_message,
|
| 122 |
+
},
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def setup_imu_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 127 |
+
rc = registry.reachy_controller
|
| 128 |
+
append_defined_entities(
|
| 129 |
+
registry,
|
| 130 |
+
entities,
|
| 131 |
+
get_imu_sensor_definitions(),
|
| 132 |
+
{
|
| 133 |
+
"imu_accel_x": rc.get_imu_accel_x,
|
| 134 |
+
"imu_accel_y": rc.get_imu_accel_y,
|
| 135 |
+
"imu_accel_z": rc.get_imu_accel_z,
|
| 136 |
+
"imu_gyro_x": rc.get_imu_gyro_x,
|
| 137 |
+
"imu_gyro_y": rc.get_imu_gyro_y,
|
| 138 |
+
"imu_gyro_z": rc.get_imu_gyro_z,
|
| 139 |
+
"imu_temperature": rc.get_imu_temperature,
|
| 140 |
+
},
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def setup_detection_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 145 |
+
def get_gesture() -> str:
|
| 146 |
+
return registry.camera_server.get_current_gesture() if registry.camera_server else "none"
|
| 147 |
+
|
| 148 |
+
def get_gesture_confidence() -> float:
|
| 149 |
+
return registry.camera_server.get_gesture_confidence() if registry.camera_server else 0.0
|
| 150 |
+
|
| 151 |
+
registry._gesture_entity = TextSensorEntity(
|
| 152 |
+
server=registry.server,
|
| 153 |
+
key=get_entity_key("gesture_detected"),
|
| 154 |
+
name="Gesture Detected",
|
| 155 |
+
object_id="gesture_detected",
|
| 156 |
+
icon="mdi:hand-wave",
|
| 157 |
+
value_getter=get_gesture,
|
| 158 |
+
)
|
| 159 |
+
entities.append(registry._gesture_entity)
|
| 160 |
+
|
| 161 |
+
registry._gesture_confidence_entity = SensorEntity(
|
| 162 |
+
server=registry.server,
|
| 163 |
+
key=get_entity_key("gesture_confidence"),
|
| 164 |
+
name="Gesture Confidence",
|
| 165 |
+
object_id="gesture_confidence",
|
| 166 |
+
icon="mdi:percent",
|
| 167 |
+
unit_of_measurement="%",
|
| 168 |
+
accuracy_decimals=1,
|
| 169 |
+
state_class="measurement",
|
| 170 |
+
value_getter=get_gesture_confidence,
|
| 171 |
+
)
|
| 172 |
+
entities.append(registry._gesture_confidence_entity)
|
| 173 |
+
|
| 174 |
+
registry._face_detected_entity = BinarySensorEntity(
|
| 175 |
+
server=registry.server,
|
| 176 |
+
key=get_entity_key("face_detected"),
|
| 177 |
+
name="Face Detected",
|
| 178 |
+
object_id="face_detected",
|
| 179 |
+
icon="mdi:face-recognition",
|
| 180 |
+
device_class="occupancy",
|
| 181 |
+
value_getter=lambda: registry.camera_server.is_face_detected() if registry.camera_server else False,
|
| 182 |
+
)
|
| 183 |
+
entities.append(registry._face_detected_entity)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def setup_diagnostic_entities(registry: "EntityRegistry", entities: list) -> None:
|
| 187 |
+
diag = get_system_diagnostics()
|
| 188 |
+
append_defined_entities(
|
| 189 |
+
registry,
|
| 190 |
+
entities,
|
| 191 |
+
get_diagnostic_sensor_definitions(),
|
| 192 |
+
{
|
| 193 |
+
"sys_cpu_percent": diag.get_cpu_percent,
|
| 194 |
+
"sys_cpu_temperature": diag.get_cpu_temperature,
|
| 195 |
+
"sys_memory_percent": diag.get_memory_percent,
|
| 196 |
+
"sys_memory_used": diag.get_memory_used_gb,
|
| 197 |
+
"sys_disk_percent": diag.get_disk_percent,
|
| 198 |
+
"sys_disk_free": diag.get_disk_free_gb,
|
| 199 |
+
"sys_uptime": diag.get_uptime_hours,
|
| 200 |
+
"sys_process_cpu": diag.get_process_cpu_percent,
|
| 201 |
+
"sys_process_memory": diag.get_process_memory_mb,
|
| 202 |
+
},
|
| 203 |
+
)
|
reachy_mini_home_assistant/main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reachy Mini for Home 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 sys
|
| 11 |
+
import threading
|
| 12 |
+
|
| 13 |
+
from reachy_mini import ReachyMiniApp
|
| 14 |
+
|
| 15 |
+
from .voice_assistant import VoiceAssistantService
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ReachyMiniHaVoice(ReachyMiniApp):
|
| 21 |
+
"""
|
| 22 |
+
Reachy Mini for Home Assistant Application.
|
| 23 |
+
|
| 24 |
+
This app runs an ESPHome-compatible server that connects
|
| 25 |
+
to Home Assistant for STT/TTS processing while providing local
|
| 26 |
+
wake word detection and robot motion feedback.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
# No custom web UI needed - configuration is automatic via Home Assistant
|
| 30 |
+
custom_app_url: str | None = None
|
| 31 |
+
|
| 32 |
+
def __init__(self, *args, **kwargs):
|
| 33 |
+
"""Initialize the app."""
|
| 34 |
+
super().__init__(*args, **kwargs)
|
| 35 |
+
self.stop_event = threading.Event()
|
| 36 |
+
|
| 37 |
+
def wrapped_run(self, *args, **kwargs) -> None:
|
| 38 |
+
"""
|
| 39 |
+
Override wrapped_run to handle Reachy Mini connection failures.
|
| 40 |
+
"""
|
| 41 |
+
logger.info("Starting Reachy Mini HA Voice App...")
|
| 42 |
+
|
| 43 |
+
# Connect to ReachyMini
|
| 44 |
+
try:
|
| 45 |
+
logger.info("Attempting to connect to Reachy Mini...")
|
| 46 |
+
super().wrapped_run(*args, **kwargs)
|
| 47 |
+
except TimeoutError as e:
|
| 48 |
+
logger.error(f"Timeout connecting to Reachy Mini: {e}")
|
| 49 |
+
sys.exit(1)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
error_str = str(e)
|
| 52 |
+
if "Unable to connect" in error_str or "Timeout" in error_str:
|
| 53 |
+
logger.error(f"Failed to connect to Reachy Mini: {e}")
|
| 54 |
+
sys.exit(1)
|
| 55 |
+
else:
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
def run(self, reachy_mini, stop_event: threading.Event) -> None:
|
| 59 |
+
"""
|
| 60 |
+
Main application entry point.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
reachy_mini: The Reachy Mini robot instance (required, cannot be None)
|
| 64 |
+
stop_event: Event to signal graceful shutdown
|
| 65 |
+
"""
|
| 66 |
+
logger.info("Starting Reachy Mini for Home Assistant...")
|
| 67 |
+
|
| 68 |
+
# Create and run the HA service
|
| 69 |
+
service = VoiceAssistantService(reachy_mini)
|
| 70 |
+
|
| 71 |
+
# Always create a new event loop to avoid conflicts with SDK
|
| 72 |
+
loop = asyncio.new_event_loop()
|
| 73 |
+
asyncio.set_event_loop(loop)
|
| 74 |
+
logger.debug("Created new event loop for HA service")
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
loop.run_until_complete(service.start())
|
| 78 |
+
|
| 79 |
+
logger.info("=" * 50)
|
| 80 |
+
logger.info("Reachy Mini for Home Assistant Started!")
|
| 81 |
+
logger.info("=" * 50)
|
| 82 |
+
logger.info("ESPHome Server: 0.0.0.0:6053")
|
| 83 |
+
logger.info("Camera Server: 0.0.0.0:8081")
|
| 84 |
+
logger.info("Wake word: Okay Nabu")
|
| 85 |
+
logger.info("Motion control: enabled")
|
| 86 |
+
logger.info("Camera: enabled (Reachy Mini)")
|
| 87 |
+
logger.info("=" * 50)
|
| 88 |
+
logger.info("To connect from Home Assistant:")
|
| 89 |
+
logger.info(" Settings -> Devices & Services -> Add Integration")
|
| 90 |
+
logger.info(" -> ESPHome -> Enter this device's IP:6053")
|
| 91 |
+
logger.info(" -> Generic Camera -> http://<ip>:8081/stream")
|
| 92 |
+
logger.info("=" * 50)
|
| 93 |
+
|
| 94 |
+
# Wait for stop signal - keep event loop running
|
| 95 |
+
# We need to keep the event loop alive to handle ESPHome connections
|
| 96 |
+
while not stop_event.is_set():
|
| 97 |
+
loop.run_until_complete(asyncio.sleep(0.1))
|
| 98 |
+
|
| 99 |
+
except KeyboardInterrupt:
|
| 100 |
+
logger.info("Keyboard interruption in main thread... closing server.")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error(f"Error running Reachy Mini HA: {e}")
|
| 103 |
+
raise
|
| 104 |
+
finally:
|
| 105 |
+
logger.info("Shutting down Reachy Mini HA...")
|
| 106 |
+
try:
|
| 107 |
+
loop.run_until_complete(service.stop())
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error stopping service: {e}")
|
| 110 |
+
|
| 111 |
+
# Note: Robot connection cleanup is handled by SDK's context manager
|
| 112 |
+
# in wrapped_run(). We only need to close our event loop here.
|
| 113 |
+
|
| 114 |
+
# Close event loop
|
| 115 |
+
try:
|
| 116 |
+
loop.close()
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.debug(f"Error closing event loop: {e}")
|
| 119 |
+
|
| 120 |
+
logger.info("Reachy Mini HA stopped.")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# This is called when running as: python -m reachy_mini_home_assistant.main
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
logging.basicConfig(
|
| 126 |
+
level=logging.INFO,
|
| 127 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Reduce verbosity for some noisy modules
|
| 131 |
+
logging.getLogger("reachy_mini.media.media_manager").setLevel(logging.WARNING)
|
| 132 |
+
logging.getLogger("reachy_mini.media.camera_base").setLevel(logging.WARNING)
|
| 133 |
+
logging.getLogger("reachy_mini.media.audio_base").setLevel(logging.WARNING)
|
| 134 |
+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
| 135 |
+
|
| 136 |
+
app = ReachyMiniHaVoice()
|
| 137 |
+
try:
|
| 138 |
+
app.wrapped_run()
|
| 139 |
+
except KeyboardInterrupt:
|
| 140 |
+
app.stop()
|
reachy_mini_home_assistant/models.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared models for Reachy Mini Voice Assistant."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import asdict, dataclass, field
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import TYPE_CHECKING
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
import threading
|
| 12 |
+
from queue import Queue
|
| 13 |
+
|
| 14 |
+
from pymicro_wakeword import MicroWakeWord
|
| 15 |
+
from pyopen_wakeword import OpenWakeWord
|
| 16 |
+
|
| 17 |
+
from .audio.audio_player import AudioPlayer
|
| 18 |
+
from .entities.entity import ESPHomeEntity, MediaPlayerEntity
|
| 19 |
+
from .protocol.satellite import VoiceSatelliteProtocol
|
| 20 |
+
|
| 21 |
+
_LOGGER = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class WakeWordType(str, Enum):
|
| 25 |
+
MICRO_WAKE_WORD = "micro"
|
| 26 |
+
OPEN_WAKE_WORD = "openWakeWord"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class AvailableWakeWord:
|
| 31 |
+
id: str
|
| 32 |
+
type: WakeWordType
|
| 33 |
+
wake_word: str
|
| 34 |
+
trained_languages: list[str]
|
| 35 |
+
wake_word_path: Path
|
| 36 |
+
probability_cutoff: float = 0.7
|
| 37 |
+
|
| 38 |
+
def load(self) -> "MicroWakeWord | OpenWakeWord":
|
| 39 |
+
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
| 40 |
+
from pymicro_wakeword import MicroWakeWord
|
| 41 |
+
|
| 42 |
+
return MicroWakeWord.from_config(config_path=self.wake_word_path)
|
| 43 |
+
|
| 44 |
+
if self.type == WakeWordType.OPEN_WAKE_WORD:
|
| 45 |
+
from pyopen_wakeword import OpenWakeWord
|
| 46 |
+
|
| 47 |
+
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
|
| 48 |
+
oww_model.wake_word = self.wake_word
|
| 49 |
+
return oww_model
|
| 50 |
+
|
| 51 |
+
raise ValueError(f"Unexpected wake word type: {self.type}")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class Preferences:
|
| 56 |
+
active_wake_words: list[str] = field(default_factory=list)
|
| 57 |
+
# Continuous conversation mode (controlled from Home Assistant)
|
| 58 |
+
continuous_conversation: bool = False
|
| 59 |
+
# Unified idle behavior toggle (controlled from Home Assistant)
|
| 60 |
+
idle_behavior_enabled: bool = False
|
| 61 |
+
# Sendspin discovery and playback toggle (controlled from Home Assistant)
|
| 62 |
+
sendspin_enabled: bool = False
|
| 63 |
+
# Vision toggles and parameters (controlled from Home Assistant)
|
| 64 |
+
face_tracking_enabled: bool = False
|
| 65 |
+
gesture_detection_enabled: bool = False
|
| 66 |
+
face_confidence_threshold: float = 0.5
|
| 67 |
+
|
| 68 |
+
def set_idle_behavior_enabled(self, enabled: bool) -> None:
|
| 69 |
+
"""Update the unified idle behavior toggle."""
|
| 70 |
+
self.idle_behavior_enabled = enabled
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class ServerState:
|
| 75 |
+
"""Global server state."""
|
| 76 |
+
|
| 77 |
+
name: str
|
| 78 |
+
mac_address: str
|
| 79 |
+
audio_queue: "Queue[bytes | None]"
|
| 80 |
+
entities: "list[ESPHomeEntity]"
|
| 81 |
+
available_wake_words: "dict[str, AvailableWakeWord]"
|
| 82 |
+
wake_words: "dict[str, MicroWakeWord | OpenWakeWord]"
|
| 83 |
+
active_wake_words: set[str]
|
| 84 |
+
stop_word: "MicroWakeWord"
|
| 85 |
+
music_player: "AudioPlayer"
|
| 86 |
+
tts_player: "AudioPlayer"
|
| 87 |
+
wakeup_sound: str
|
| 88 |
+
timer_finished_sound: str
|
| 89 |
+
preferences: Preferences
|
| 90 |
+
preferences_path: Path
|
| 91 |
+
download_dir: Path
|
| 92 |
+
|
| 93 |
+
# Reachy Mini specific
|
| 94 |
+
reachy_mini: object
|
| 95 |
+
motion_enabled: bool = True
|
| 96 |
+
motion: object | None = None # ReachyMiniMotion instance
|
| 97 |
+
|
| 98 |
+
media_player_entity: "MediaPlayerEntity | None" = None
|
| 99 |
+
satellite: "VoiceSatelliteProtocol | None" = None
|
| 100 |
+
wake_words_changed: bool = False
|
| 101 |
+
refractory_seconds: float = 2.0
|
| 102 |
+
timer_max_ring_seconds: float = 900.0
|
| 103 |
+
_entities_initialized: bool = False
|
| 104 |
+
|
| 105 |
+
_services_suspended: bool = False
|
| 106 |
+
|
| 107 |
+
# Mute state (controlled from Home Assistant) - thread-safe via properties
|
| 108 |
+
_is_muted: bool = False
|
| 109 |
+
|
| 110 |
+
# Camera state (controlled from Home Assistant) - thread-safe via properties
|
| 111 |
+
_camera_enabled: bool = True
|
| 112 |
+
|
| 113 |
+
# Thread safety
|
| 114 |
+
_state_lock: "threading.Lock | None" = None
|
| 115 |
+
|
| 116 |
+
def __post_init__(self):
|
| 117 |
+
"""Initialize state lock after dataclass creation."""
|
| 118 |
+
import threading
|
| 119 |
+
|
| 120 |
+
object.__setattr__(self, "_state_lock", threading.Lock())
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def services_suspended(self) -> bool:
|
| 124 |
+
"""Thread-safe getter for services_suspended."""
|
| 125 |
+
if self._state_lock is None:
|
| 126 |
+
return self._services_suspended
|
| 127 |
+
with self._state_lock:
|
| 128 |
+
return self._services_suspended
|
| 129 |
+
|
| 130 |
+
@services_suspended.setter
|
| 131 |
+
def services_suspended(self, value: bool) -> None:
|
| 132 |
+
"""Thread-safe setter for services_suspended."""
|
| 133 |
+
if self._state_lock is None:
|
| 134 |
+
object.__setattr__(self, "_services_suspended", value)
|
| 135 |
+
else:
|
| 136 |
+
with self._state_lock:
|
| 137 |
+
object.__setattr__(self, "_services_suspended", value)
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def is_muted(self) -> bool:
|
| 141 |
+
"""Thread-safe getter for is_muted."""
|
| 142 |
+
if self._state_lock is None:
|
| 143 |
+
return self._is_muted
|
| 144 |
+
with self._state_lock:
|
| 145 |
+
return self._is_muted
|
| 146 |
+
|
| 147 |
+
@is_muted.setter
|
| 148 |
+
def is_muted(self, value: bool) -> None:
|
| 149 |
+
"""Thread-safe setter for is_muted."""
|
| 150 |
+
if self._state_lock is None:
|
| 151 |
+
object.__setattr__(self, "_is_muted", value)
|
| 152 |
+
else:
|
| 153 |
+
with self._state_lock:
|
| 154 |
+
object.__setattr__(self, "_is_muted", value)
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def camera_enabled(self) -> bool:
|
| 158 |
+
"""Thread-safe getter for camera_enabled."""
|
| 159 |
+
if self._state_lock is None:
|
| 160 |
+
return self._camera_enabled
|
| 161 |
+
with self._state_lock:
|
| 162 |
+
return self._camera_enabled
|
| 163 |
+
|
| 164 |
+
@camera_enabled.setter
|
| 165 |
+
def camera_enabled(self, value: bool) -> None:
|
| 166 |
+
"""Thread-safe setter for camera_enabled."""
|
| 167 |
+
if self._state_lock is None:
|
| 168 |
+
object.__setattr__(self, "_camera_enabled", value)
|
| 169 |
+
else:
|
| 170 |
+
with self._state_lock:
|
| 171 |
+
object.__setattr__(self, "_camera_enabled", value)
|
| 172 |
+
|
| 173 |
+
def save_preferences(self) -> None:
|
| 174 |
+
"""Save preferences as JSON."""
|
| 175 |
+
_LOGGER.debug("Saving preferences: %s", self.preferences_path)
|
| 176 |
+
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
| 177 |
+
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
|
| 178 |
+
json.dump(asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4)
|
reachy_mini_home_assistant/models/crops_classifier.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:12a02344f63a7c4f2a2ca90f8740ca10a08c17b683b5585d73c3e88323056762
|
| 3 |
+
size 411683
|