GitHub Action commited on
Commit
c5fd1f5
·
0 Parent(s):

Fresh sync: 2026-05-05 13:12:05

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/settings.local.json +55 -0
  2. .gitattributes +5 -0
  3. .github/dependabot.yml +13 -0
  4. .github/workflows/sync_develop_to_hf_edge.yml +86 -0
  5. .github/workflows/sync_to_hf.yml +36 -0
  6. .gitignore +83 -0
  7. .pre-commit-config.yaml +20 -0
  8. CHANGELOG.md +713 -0
  9. Project_Summary.md +1439 -0
  10. README.md +15 -0
  11. changelog.json +666 -0
  12. docs/USER_MANUAL_CN.md +244 -0
  13. docs/USER_MANUAL_EN.md +244 -0
  14. home_assistant_blueprints/reachy_mini_presence_companion.yaml +246 -0
  15. index.html +301 -0
  16. pyproject.toml +179 -0
  17. reachy_mini_home_assistant/__init__.py +29 -0
  18. reachy_mini_home_assistant/__main__.py +121 -0
  19. reachy_mini_home_assistant/animations/animation_config.py +100 -0
  20. reachy_mini_home_assistant/animations/conversation_animations.json +0 -0
  21. reachy_mini_home_assistant/audio/__init__.py +15 -0
  22. reachy_mini_home_assistant/audio/audio_player.py +79 -0
  23. reachy_mini_home_assistant/audio/audio_player_local.py +144 -0
  24. reachy_mini_home_assistant/audio/audio_player_playback.py +198 -0
  25. reachy_mini_home_assistant/audio/audio_player_sendspin.py +643 -0
  26. reachy_mini_home_assistant/audio/audio_player_shared.py +125 -0
  27. reachy_mini_home_assistant/audio/audio_player_stream_decoded.py +243 -0
  28. reachy_mini_home_assistant/audio/audio_player_stream_pcm.py +102 -0
  29. reachy_mini_home_assistant/audio/audio_player_wobble.py +7 -0
  30. reachy_mini_home_assistant/audio/doa_tracker.py +198 -0
  31. reachy_mini_home_assistant/audio/local_audio_player.py +39 -0
  32. reachy_mini_home_assistant/core/__init__.py +47 -0
  33. reachy_mini_home_assistant/core/config.py +435 -0
  34. reachy_mini_home_assistant/core/exceptions.py +72 -0
  35. reachy_mini_home_assistant/core/service_base.py +551 -0
  36. reachy_mini_home_assistant/core/system_diagnostics.py +207 -0
  37. reachy_mini_home_assistant/core/util.py +26 -0
  38. reachy_mini_home_assistant/entities/__init__.py +74 -0
  39. reachy_mini_home_assistant/entities/emotion_detector.py +115 -0
  40. reachy_mini_home_assistant/entities/entity.py +409 -0
  41. reachy_mini_home_assistant/entities/entity_extensions.py +300 -0
  42. reachy_mini_home_assistant/entities/entity_factory.py +538 -0
  43. reachy_mini_home_assistant/entities/entity_keys.py +133 -0
  44. reachy_mini_home_assistant/entities/entity_registry.py +428 -0
  45. reachy_mini_home_assistant/entities/event_emotion_mapper.py +403 -0
  46. reachy_mini_home_assistant/entities/runtime_entity_setup.py +257 -0
  47. reachy_mini_home_assistant/entities/sensor_entity_setup.py +203 -0
  48. reachy_mini_home_assistant/main.py +140 -0
  49. reachy_mini_home_assistant/models.py +178 -0
  50. 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