152 Commits
Author SHA1 Message Date
imonlinux bd62a3b77c PulseAudio User Mode Unit MWW 2025-10-05 16:55:39 -07:00
imonlinux 0d4f03b1b0 PulseAudio User Mode Unit File
User mode is so much easier than system mode pulseaudio.
2025-10-05 16:53:06 -07:00
imonlinux 50f1e3f92f Update install_pipewire.md 2025-09-30 13:28:14 -07:00
imonlinux 383450a090 Added depencency for Grove 2025-09-29 20:59:38 -07:00
imonlinux 01a40e641f Add support for Grove Interface
Add Grove interface support for APA102 LEDs instead of the built in spi.
2025-09-29 19:41:28 -07:00
imonlinux bf17dfd670 Add packages to support echo cancellation 2025-09-28 20:50:52 -07:00
imonlinux e75fa71d06 Update README.md 2025-09-28 20:44:35 -07:00
imonlinux b06d027ae6 Update README.md 2025-09-28 20:36:14 -07:00
imonlinux ee0cfb68c6 Update gen-esphome-avahi.sh 2025-09-28 20:13:39 -07:00
imonlinux 86edd1134d Update install_pipewire.md 2025-09-28 19:33:43 -07:00
imonlinux 14779b4439 Refactored file with support for PW, PA, ALSA 2025-09-27 22:05:47 -07:00
imonlinux 74032adb9b Update mpv_player.py to support PipeWire
Updated the mpv_player.py to support PipeWire native devices (pipewire/default) instead of using the pipewire-pulse compatibility.
2025-09-27 21:19:06 -07:00
imonlinux 6b9a1e1e28 Change output audio device to pipewire/default
Change to native pipewire instead of the pipewire-pulse compatibility.
2025-09-27 21:16:58 -07:00
imonlinux d74045e349 Change output audio device to pipewire/default
Change to native pipewire instead of the pipewire-pulse compatibility.
2025-09-27 21:15:22 -07:00
imonlinux 2c44521c63 Add PipeWire info remove docker task 2025-09-27 20:53:00 -07:00
imonlinux bfae803279 Update with PipeWire info 2025-09-27 20:51:19 -07:00
imonlinux 6ad23f8319 Update linux-voice-assistant-2mic-install.md 2025-09-27 09:00:59 -07:00
imonlinux 36e353a6df PipeWire, PulseAudio, ALSA, OWW, MWW
Fixed section numbers.
2025-09-27 08:56:53 -07:00
imonlinux 76746e4262 Update linux-voice-assistant-2mic-install.md
Cleanup and collapsible sections to reduce confusion.
2025-09-27 08:52:58 -07:00
imonlinux aee8c56de3 Update linux-voice-assistant-2mic-install.md
Added requirement to enable linger for user mode services
2025-09-27 08:19:24 -07:00
imonlinux ed278ae3a2 Rename install_pipewire to install_pipewire.md 2025-09-26 22:40:13 -07:00
imonlinux 00c8541ed8 Update linux-voice-assistant-2mic-install.md
Add instructions for setting up PipeWire and User Mode Systemd service.
2025-09-26 22:39:43 -07:00
imonlinux 59e993d422 Create install_pipewire 2025-09-26 22:05:10 -07:00
imonlinux 4f8f4e2ebc Add user mode service files for pipewire support 2025-09-26 20:14:58 -07:00
imonlinux 99ecb1f186 Changed avahi service
Changed to Platform to LINUX and pulled in uname -m for the board.

This is cosmetic as it doesn't affect detection.
2025-09-25 09:36:13 -07:00
imonlinux e50f463c58 Avahi service file fixed
HA Discovery working
2025-09-24 21:15:35 -07:00
imonlinux 156b6473d5 Remove OWW requirement 2025-09-24 21:02:53 -07:00
imonlinux 7a54f5ce38 Remove OWW Dependency 2025-09-24 21:01:34 -07:00
imonlinux 1cc4e43ee5 Fix avahi service file 2025-09-24 21:00:11 -07:00
imonlinux 2564d1a745 Add HAL and Marvin Wakewords 2025-09-20 18:28:47 -07:00
imonlinux cd83ee018f Add HAL and Marvin led files 2025-09-20 18:21:21 -07:00
imonlinux b4f736a79b Update install_pulseaudio.md 2025-09-15 20:10:54 -07:00
imonlinux aa83dcc601 Merge pull request #18 from imonlinux/mpv_player.py-disable-watchdog
Disabled watchdog for pulseaudio.
2025-09-14 18:44:40 -07:00
imonlinux b982f9f990 Disabled watchdog for pulseaudio.
Will reintroduce this function at a later date if needed.
2025-09-14 18:37:58 -07:00
imonlinux feb724edd2 Merge pull request #17 from imonlinux/mpv_player.py-revert
Introduced a bug that fires during HA registration
2025-09-14 18:02:33 -07:00
imonlinux 7654d43e09 Introduced a bug that fires during HA registration 2025-09-14 18:02:19 -07:00
imonlinux 8f8a041ecf Add files via upload 2025-09-14 16:48:42 -07:00
imonlinux ef75951ade Update linux-voice-assistant-2mic-install.md 2025-09-14 16:04:44 -07:00
imonlinux 9782c17913 Update README.md 2025-09-14 15:31:29 -07:00
imonlinux 192ca11b1b Update README.md 2025-09-14 15:25:02 -07:00
imonlinux 2773412898 Update linux-voice-assistant-2mic-install.md 2025-09-14 15:22:42 -07:00
imonlinux 368876f59d Update README.md 2025-09-14 15:05:35 -07:00
imonlinux d606364f84 Merge pull request #14 from imonlinux/HA_LVA_Discovery
HA LVA Discovery
2025-09-14 12:23:18 -07:00
imonlinux 0289fd5a4c Update linux-voice-assistant-2mic-install.md
Updated the tutorial to include optional avahi config for HA discovery of the LVA.
2025-09-14 12:17:53 -07:00
imonlinux e17e13c5dd Add LVA service drop-in for avahi
This updates the linux-voice-assistant.service to require and wait for avahi to start. Independent of the systemd unit being used to start LVA.
2025-09-14 12:04:34 -07:00
imonlinux fa82a56831 Add script to create and install avahi service unit
Intended to setup Zeroconf/mDNS for HA discovery
2025-09-14 12:01:14 -07:00
imonlinux 4610c546ce Merge pull request #13 from imonlinux/bug_fix_mpv_player.py
Fix watchdog misfire in mpv_player.py
2025-09-14 11:55:08 -07:00
imonlinux 38fc8227f3 Fix watchdog misfire in mpv_player.py
The current watchdog is a simple “N-seconds since play()” timer, so it can fire during legit playback (longer TTS, slower network). Convert it to a no-progress watchdog that only trips if mpv makes no progress for N seconds.
2025-09-14 11:54:54 -07:00
imonlinux ceb64f89a1 OWW model tester
attempts to find models that would stop OWW from starting
2025-09-13 23:17:31 -07:00
imonlinux 2eb46a494e Delete wyoming-openwakeword/verify_oww_models.sh 2025-09-13 23:16:21 -07:00
imonlinux cda879ebd3 Update linux-voice-assistant-2mic-install.md 2025-09-13 23:14:13 -07:00
imonlinux 2e93e10496 Merge pull request #12 from imonlinux/Pulse_Audio_ALSA_OWW_MWW
Pulse audio alsa oww mww
2025-09-13 21:41:19 -07:00
imonlinux 3d799af40b Debugging utility
Not required by the project.
2025-09-13 21:17:59 -07:00
imonlinux 6e19c4db2d Delete service/pa-linux-voice-assistant.service 2025-09-13 21:13:41 -07:00
imonlinux b685769f37 Delete service/linux-voice-assistant.service 2025-09-13 21:13:23 -07:00
imonlinux 9e6b12d771 Update linux-voice-assistant-2mic-install.md 2025-09-13 21:12:54 -07:00
imonlinux 72dc42a3e2 Update README.md for PA support 2025-09-13 20:16:59 -07:00
imonlinux a0c5af9d4c Modifed mpv_player.py to support PA and ALSA
1. **`linux_voice_assistant/mpv_player.py`**
   - Supports both **PulseAudio** and **ALSA** backends.
   - Device strings now accept:
     - Pulse: `pulse/<PA SINK NAME>` (e.g. `pulse/alsa_output.platform-soc_sound.stereo-fallback`)
     - ALSA: `alsa/plughw:CARD=seeed2micvoicec,DEV=0`
     - `default`: tries Pulse first, then ALSA.
   - Added **fast end-of-playback detection** using **either** `eof-reached` **or** `idle-active` (fixes the “hang until watchdog” after short clips).
   - Added a **watchdog** (default **8s**) to fail fast if playback never starts/finishes.
   - Pulse-friendly defaults: `audio-samplerate=44100`, `audio-channels=stereo`, `keep-open=no`, `network-timeout=7`.
   - mpv logging bridged to Python logging; level set by env **`LVA_MPV_MSG_LEVEL`** (default `all=warn`).
2025-09-13 19:58:15 -07:00
imonlinux 8d179b371c Add files via upload 2025-09-13 19:55:05 -07:00
imonlinux 8c86546065 Update install_pulseaudio.md 2025-09-13 19:50:12 -07:00
imonlinux fe2cdb9316 Update install_pulseaudio.md 2025-09-13 19:45:26 -07:00
imonlinux e545936c48 Update install_pulseaudio.md 2025-09-13 19:03:52 -07:00
imonlinux c487dc307c Update install_pulseaudio.md 2025-09-13 11:36:18 -07:00
imonlinux 58aa39629d Merge pull request #11 from imonlinux/imonlinux-patch-1
Add PA service file
2025-09-13 11:34:17 -07:00
imonlinux dc27b41d08 Add PA service file 2025-09-13 11:33:55 -07:00
imonlinux 72bde398b7 Update install_pulseaudio.md 2025-09-13 11:09:49 -07:00
imonlinux 26d33f7943 Update install_pulseaudio.md 2025-09-13 10:47:11 -07:00
imonlinux 8c9052da41 Update linux-voice-assistant-2mic-install.md 2025-09-12 17:53:32 -07:00
imonlinux 0c1085150d Update pa-linux-voice-assistant.service 2025-09-12 17:42:42 -07:00
imonlinux 49e43e51e1 Update install_pulseaudio.md 2025-09-12 17:30:21 -07:00
imonlinux c8a8cde934 Update install_pulseaudio.md 2025-09-12 17:28:43 -07:00
imonlinux 4d11e252c9 Delete service/pulseaudio.service 2025-09-12 17:25:32 -07:00
imonlinux 8f9cce3f02 Add files via upload 2025-09-12 17:24:52 -07:00
imonlinux a309e5ffea Update pa-linux-voice-assistant.service 2025-09-12 14:18:15 -07:00
imonlinux cb6ae03548 Update linux-voice-assistant-2mic-install.md 2025-09-12 14:02:13 -07:00
imonlinux 1529a02d68 Update linux-voice-assistant-2mic-install.md 2025-09-12 14:00:17 -07:00
imonlinux d159d57b5f Update linux-voice-assistant-2mic-install.md 2025-09-12 13:58:22 -07:00
imonlinux 49f692e6ab Update linux-voice-assistant-2mic-install.md 2025-09-12 13:54:22 -07:00
imonlinux 758436be08 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:53:50 -07:00
imonlinux bf74160528 Merge pull request #10 from imonlinux/imonlinux-patch-1
Add tutorial for PA and alternate service file
2025-09-12 13:33:25 -07:00
imonlinux 734c0b7cf4 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:29:36 -07:00
imonlinux 694251de64 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:27:41 -07:00
imonlinux bf241cfa57 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:25:33 -07:00
imonlinux 6e54a7decd Add files via upload 2025-09-12 13:24:30 -07:00
imonlinux 9579f61c44 Add files via upload 2025-09-12 13:16:23 -07:00
imonlinux 5a4a496322 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:13:28 -07:00
imonlinux ec7de2312a Update linux-voice-assistant-2mic-install.md 2025-09-12 13:11:50 -07:00
imonlinux e3b32579ae Update linux-voice-assistant-2mic-install.md 2025-09-12 13:08:07 -07:00
imonlinux 26a7ecbea2 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:06:16 -07:00
imonlinux bdcfad0e00 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:05:10 -07:00
imonlinux 09fd7f8534 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:04:06 -07:00
imonlinux 5e805b8722 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:01:35 -07:00
imonlinux 4c25739b3c Update linux-voice-assistant-2mic-install.md 2025-09-12 13:00:13 -07:00
imonlinux be61375b43 Update linux-voice-assistant-2mic-install.md 2025-09-12 12:59:15 -07:00
imonlinux 6233987d89 Add files via upload 2025-09-12 12:34:53 -07:00
imonlinux 30683cf718 Update linux-voice-assistant-2mic-install.md 2025-09-12 11:01:11 -07:00
imonlinux 0c21b8d8ab Update linux-voice-assistant-2mic-install.md 2025-09-11 21:23:23 -07:00
imonlinux 55cef9e915 Merge pull request #9 from imonlinux/imonlinux-patch-1
Update linux-voice-assistant.service
2025-09-11 21:22:22 -07:00
imonlinux 8411c09acb Update linux-voice-assistant.service
Changed the default wake word to "alexa" as I'm having issue with "ok_nabu".
2025-09-11 21:22:01 -07:00
imonlinux 7817e7a982 Update linux-voice-assistant-2mic-install.md 2025-09-11 21:20:46 -07:00
imonlinux f2f1be206e Update linux-voice-assistant-2mic-install.md 2025-09-11 21:09:52 -07:00
imonlinux 65212d3999 Update linux-voice-assistant-2mic-install.md 2025-09-11 21:07:25 -07:00
imonlinux 6ca1ee0717 Merge pull request #8 from imonlinux/imonlinux-patch-1
Add OWW wake word list to HA
2025-09-11 20:48:14 -07:00
imonlinux fa1492d410 Add choose wake word in HA
Adds the OWW model names using friendly names
2025-09-11 20:47:16 -07:00
imonlinux fa9867b4ba Add files via upload 2025-09-11 20:45:56 -07:00
imonlinux 07eb4ed3bf Add files via upload 2025-09-11 20:45:18 -07:00
imonlinux e72635978f Merge pull request #7 from imonlinux/imonlinux-patch-1
Imonlinux patch 1
2025-09-10 12:41:04 -07:00
imonlinux 364aef1a74 Delete linux_voice_assistant/wakeword_indicator.py 2025-09-10 12:40:19 -07:00
imonlinux 9ccd186ae4 Revert api_server.py changes 2025-09-10 12:39:10 -07:00
imonlinux 13b05a82f0 Merge pull request #6 from imonlinux/imonlinux-patch-1
Add wakeword engine indicator for HA
2025-09-10 10:04:59 -07:00
imonlinux 0337d28bce Add wakeword engine indicator for HA 2025-09-10 10:04:03 -07:00
imonlinux a07a3aa618 Update linux-voice-assistant-2mic-install.md 2025-09-09 23:09:11 -07:00
imonlinux f2f34a9604 Merge pull request #5 from imonlinux/imonlinux-patch-2
Add OWW model validation tool
2025-09-09 23:06:54 -07:00
imonlinux 15836c20de Add OWW model validation tool 2025-09-09 23:06:38 -07:00
imonlinux 0294c7de20 Update wyoming-openwakeword.service 2025-09-09 22:33:04 -07:00
imonlinux 38539fc260 Update linux-voice-assistant-2mic-install.md 2025-09-09 21:26:24 -07:00
imonlinux f7caecec7a Update linux-voice-assistant-2mic-install.md 2025-09-09 21:19:37 -07:00
imonlinux 8bbd57bca2 Update linux-voice-assistant.service 2025-09-09 21:11:43 -07:00
imonlinux 8308a845cd Update linux-voice-assistant-2mic-install.md 2025-09-09 21:10:59 -07:00
imonlinux 94c61b5dae Update linux-voice-assistant-2mic-install.md 2025-09-09 21:05:55 -07:00
imonlinux a439f9ac60 Update README.md 2025-09-09 21:00:32 -07:00
imonlinux 59ed20bf61 Update README.md 2025-09-09 20:56:48 -07:00
imonlinux d3a020b8f1 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:56:17 -07:00
imonlinux 2c66fa703a Update README.md 2025-09-09 20:53:36 -07:00
imonlinux de6ab73bae Update linux-voice-assistant-2mic-install.md 2025-09-09 20:33:44 -07:00
imonlinux b7e40bf518 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:30:44 -07:00
imonlinux 8254e5fd7e Update linux-voice-assistant-2mic-install.md 2025-09-09 20:30:08 -07:00
imonlinux 7dfe1e1202 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:29:51 -07:00
imonlinux 8540242f8a Update linux-voice-assistant-2mic-install.md 2025-09-09 20:28:50 -07:00
imonlinux c18edd445c Update linux-voice-assistant-2mic-install.md 2025-09-09 20:23:35 -07:00
imonlinux 9b0369df94 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:23:15 -07:00
imonlinux afc0fdf22f Update linux-voice-assistant-2mic-install.md 2025-09-09 20:22:47 -07:00
imonlinux 837d20d73b Update linux-voice-assistant-2mic-install.md 2025-09-09 20:21:48 -07:00
imonlinux cade885191 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:21:27 -07:00
imonlinux 5da4347a77 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:20:21 -07:00
imonlinux 12303da1ce Update linux-voice-assistant-2mic-install.md 2025-09-09 20:20:03 -07:00
imonlinux 49dfc161d5 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:19:22 -07:00
imonlinux 667d77d4db Update linux-voice-assistant-2mic-install.md 2025-09-09 20:16:57 -07:00
imonlinux d08a39aaf7 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:14:20 -07:00
imonlinux f4e8ed705e Update linux-voice-assistant-2mic-install.md 2025-09-09 20:12:26 -07:00
imonlinux bb48e92bc0 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:10:37 -07:00
imonlinux dab8e5bd8d Update linux-voice-assistant-2mic-install.md 2025-09-09 20:10:03 -07:00
imonlinux 643b296a56 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:09:42 -07:00
imonlinux cee6228e11 Merge pull request #4 from imonlinux/imonlinux-patch-1
Add tutorial for install and config with reSpeaker
2025-09-09 20:08:16 -07:00
imonlinux ec5a8391ef Add tutorial for install and config with reSpeaker
Added tutorial and wyoming-openwakeword.service file.
2025-09-09 20:07:57 -07:00
imonlinux ec29ce7727 Merge pull request #3 from imonlinux/imonlinux-patch-1
Fix OWW model to default "ok_nabu"
2025-09-09 19:17:23 -07:00
imonlinux 5ea57ef822 Fix OWW model to default "ok_nabu" 2025-09-09 19:16:55 -07:00
imonlinux c5ddf05bb7 Merge pull request #2 from imonlinux/imonlinux-patch-1
Add reSpeaker2mic installer OWW recommends.txt
2025-09-09 17:57:37 -07:00
imonlinux fe708ffc85 Add reSpeaker2mic installer OWW recommends.txt 2025-09-09 17:51:54 -07:00
imonlinux 3dddc504db fixed pathing error 2025-09-09 17:11:18 -07:00
imonlinux 33220ad263 Merge pull request #1 from imonlinux/imonlinux-patch-1
ChatGPT modified code for the option of using OWW
2025-09-09 16:53:55 -07:00
imonlinux 5d1df036f8 ChatGPT modified code for the option of using OWW
If the arguments for OWW are included, microwakeword will be disabled and OWW will be used for detection. Requires a running instance of OWW on the local machine using port 10400. If the OWW arguments are excluded, microwakeword is used for detection.

--wake-uri 'tcp://127.0.0.1:10400' \
  --wake-word-name 'marvin'
2025-09-09 16:51:44 -07:00
107 changed files with 3599 additions and 7149 deletions
+13 -70
View File
@@ -1,75 +1,18 @@
# Byte-compiled / optimized / DLL files
__pycache__/
.DS_Store
.idea
*.log
tmp/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
build
htmlcov
# PyInstaller
# Usually these files are written by a script, but we don't want them in .git
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Foreign VSCode settings
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Ruff cache
.ruff_cache/
# Pyright cache
.pyright_cache/
# mypy
.mypy_cache/
# venv
venv/
.projectile
.venv/
venv/
.tox/
.mypy_cache/
*.egg-info/
# App-specific
/dist/
/preferences.json
/local/
dist/
-8
View File
@@ -1,13 +1,5 @@
# Changelog
## Unreleased
- Add zeroconf/mDNS discovery
- Support openWakeWord
- Support multiple wake words
- Save active wake words to preferences JSON file
- Refactor main into separate files
## 1.0.0
- Initial release
+17 -51
View File
@@ -1,51 +1,18 @@
# Linux Voice Assistant
Experimental Linux voice assistant (LVA) for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.14, 3.13, and 3.11.
**Confirmed working on Rasberry OS (Trixie), Fedora, and Arch**
Experimental Linux voice assistant (LVA) for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
Supports announcments, start/continue conversation, and timers.
See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satellite using a [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) and a [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0).
The LVA now supports the ReSpeaker XVF3800 4 Mic array via the USB interface. See [this tutorial](docs/linux-voice-assistant-xvf3800.md).
Want to run the satellite on a Linux desktop using a simple tray client? See [this tutorial](docs/lva-desktop.md).
### What's working:
- This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models.
- Refactor: Sync with Upstream architectural changes, includes the 7 commits since the release of v1.0.0.
- **Core Refactor (Nov 2025):** Major architectural cleanup moving audio logic to a dedicated engine.
- **Added ReSpeaker 2-Mic Button Support:** The GPIO button (17) can be enabled via the config.json file. See [the tutorial](docs/linux-voice-assistant-2mic-install.md).
- **Changed Alarm to include a Duration Setting:** Added a MQTT control to make the alarm duration adjustable.
- **Performance Optimization:** Optimized audio threading for lower CPU usage when muted and improved responsiveness (reduced latency) when unmuted.
- **Non-Blocking Operations:** Wake word model downloads are now handled in background threads, preventing the device (LEDs/MQTT) from freezing during configuration updates.
- Updated to support LED Events including GPIO based LED controls. Defaults to the ReSpeaker 2Mic Hat SPI leds, but you can use the Grove port GPIO12/13 by updating the config.json file.
- Updated to support running either APA102 or WS2812B LEDs from the SPI interface using a Micro Connectors 40-pin GPIO 1 to 2 Expansion Board. See the tutorial for instructions.
- You can choose between all MWW and OWW wake word within HA after the VLA is registered. Chosen wake words are saved to preferences.json in the linux-voice-assistant folder.
- The volume control is now persistent between connections and reboots. The volume setting gets stored in preferences.json and loaded when LVA starts.
- **Acoustic Echo Cancellation (AEC) using WebRTC.** See [the tutorial](docs/linux-voice-assistant-2mic-install.md) for instructions on implementation and tuning.
### Add Full MQTT Control for LEDs and Mute
- This branch introduces a comprehensive MQTT integration to bypass limitations in the pinned aioesphomeapi library and provide full remote control over the voice satellite's features and appearance.
- It uses MQTT Discovery to automatically create and configure a device and its associated entities within Home Assistant. This allows for real-time control from the HA interface and enables powerful automations.
#### Key Features:
- A switch entity to mute and unmute the microphone.
- A full suite of select and light entities to customize the effect, color, and brightness for each voice assistant state (Idle, Listening, Thinking, Responding, Error).
- A number entity to configure the number of LEDs in the strip, allowing for use with custom hardware.
- A number entity to configure the duration of the alarm in seconds. A setting of 0 leaves the alarm in (until stopped) mode.
- All settings are persistent, retained by the MQTT broker and re-applied whenever the application restarts.
See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to enable MQTT Controls.
- This fork introduces the ability to use Wyoming OpenWakeWord (OWW) instead of MicroWakeWord (MWW).
- Now supports **ALSA, PulseAudio and PipeWire** playback backends using the updated `linux_voice_assistant/mpv_player.py`.
- You can choose between ALSA, PulseAudio, or PipeWire with either MWW or OWW simply by enabling the matching systemd service file.
- You can change the OWW wake word within HA after the VLA is registered. (defaults to the wake word defind in the systemd config file)
- Home Assistant successfully detects the LVA when Avahi is setup according to the tutorial.
## Installation
@@ -60,7 +27,7 @@ Install system dependencies (`apt-get`):
Clone and install project:
``` sh
git clone https://github.com/imonlinux/linux-voice-assistant.git
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
cd linux-voice-assistant
script/setup
```
@@ -69,9 +36,9 @@ script/setup
Use `script/run` or `python3 -m linux_voice_assistant`
See `linux_voice_assistant/config.json.example` for more options.
See `--help` for more options.
## Connecting to Home Assistant if not auto detected
## Connecting to Home Assistant
1. In Home Assistant, go to "Settings" -> "Device & services"
2. Click the "Add integration" button
@@ -82,17 +49,16 @@ See `linux_voice_assistant/config.json.example` for more options.
## ToDo:
* ~~Implement echo-cancellation filter in PipeWire/PulseAudio.~~ (Taken from upstream and successfully tested)
* ~~Merge jianyu-li's PR from source project to add mute switch function in this branch~~
* ~~Implement MQTT entities to support advanced controls of the LVA.~~
* ~~Configure LVA to advertise on Zeroconf/mDNS via Avahi for HA to auto detect (in progress)~~ (Not needed as Release v1.0.0 implemented in code)
* ~~Implement a single LVA systemd unit file that can be addapted using profiles and drop-ins (in progress)~~
* Implement echo-cancellation filter in PipeWire. (Improves wake word detection when audio is being played)
* Merge jianyu-li's PR from source project to add mute switch function in this branch
* ~~Configure LVA to advertise on Zeroconf/mDNS via Avahi for HA to auto detect (in progress)~~
* Implement a single LVA systemd unit file that can be addapted using profiles and drop-ins (in progress)
* Implement OWW model validation checks and error handling so that a bad model doesn't crash OWW
* ~~Make the selection of the right ALSA ar PA device more scripted~~
* Make the selection of the right ALSA ar PA device more scripted
* ~~Make a Docker of the project~~ Already done in parent repo.
* ~~Add sensor entity that displays which wake word engine is being used for HA~~ (Not needed as you can use both at the same time)
* Add sensor entity that displays which wake word engine is being used for HA
* Could this be an DEB package?
* ~~Implement Stop for OWW~~ (Not needed, Stop with MWW works even when using OWW detection models)
* Implement Stop for OWW
* Stretch goal: create a smart installer full of validation and sanity checks
<!-- Links -->
[homeassistant]: https://www.home-assistant.io/
-5
View File
@@ -1,5 +0,0 @@
# XVF3800 permissions
SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# XVF3800 power policy
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", TEST=="power/control", ATTR{power/control}="on"
+10 -7
View File
@@ -13,8 +13,8 @@ ssh <your_username>@<pi_IP_address>
## 2. If already installed Disable/Stop the entire the Linux Voice Assistant service. If not installed, proceed to step (3.):
```sh
systemctl --user stop linux-voice-assistant.service
systemctl --user disable --now linux-voice-assistant.service
sudo systemctl disable --now linux-voice-assistant.service
sudo rm /etc/systemd/system/linux-voice-assistant.service
```
@@ -62,8 +62,6 @@ pactl set-sink-port alsa_output.platform-soc_sound.stereo-fallback "analog-outpu
pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
```
**Note that if you have a Raspbery Pi 3B or 4B, this may or may not seem to work, but it will output on the hat as intended.**
## 8. Test and make sure you can hear the wav file.:
@@ -71,14 +69,19 @@ pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
mpv --ao=pipewire --audio-device=default --audio-samplerate=44100 /usr/share/sounds/alsa/Front_Center.wav
```
If volume is low, set it to 100%.
If volume is low, set it to 150%.
```sh
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 150%
```
**Note that if you have a Raspbery Pi 3B or 4B, this may or may not seem to work, but it will output on the hat as intended.**
## 9. Done! Return to the tutorial to continue the Linux Voice Assistant install.
## 9. Reboot your Pi: (see step 4)
## 10. Done! Return to the tutorial to continue the Linux Voice Assistant install.
+165 -9
View File
@@ -1,6 +1,6 @@
## Install PulseAudio On Your Wyoming Voice Satellite
> Courtesy of https://github.com/FutureProofHomes/wyoming-enhancements/blob/master/snapcast/docs/2_install_pulseaudio.md with now major modifications
> Courtesy of https://github.com/FutureProofHomes/wyoming-enhancements/blob/master/snapcast/docs/2_install_pulseaudio.md with minor modifications
## 1. Connect to your Pi over SSH using the username/password you configured during flashing:
@@ -37,7 +37,52 @@ sudo reboot -h now
## 5. SSH back in to your Pi. (See step 1.)
## 6. (OPTIONAL) If plugging speakers into the headphone jack of the ReSpeaker, set the correct PulseAudio Sink Port:
## 6. Ensure PulseAudio is runnining in "system-wide mode". Per the [official instructions](https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/SystemWide/) we first need to stop some existing services to be safe:
```sh
sudo systemctl --global disable pulseaudio.service pulseaudio.socket
```
## 7. It is also advisable to set `autospawn = no` in `/etc/pulse/client.conf`:
```sh
sudo sed -i.bak \
-e '$a### Disable autospawn' \
-e '$aautospawn = no' \
/etc/pulse/client.conf
```
## 8. Create the `PulseAudio.service`:
```sh
sudo cp ~/linux-voice-assistant/service/pulseaudio.service /etc/systemd/system/pulseaudio.service
```
## 9. Start the `PulseAudio.service`. This will require you to type in your password a couple times.
```sh
systemctl --system enable pulseaudio.service
```
```sh
systemctl --system start pulseaudio.service
```
## 10. Ensure pi user is in the `pulse-access` group:
***If you aren't using the default "pi" user, put your user in here instead of "pi".***
```sh
sudo sed -i '/^pulse-access:/ s/$/root,pi/' /etc/group
```
## 11. Reboot your Pi: (see step 4)
## 12. (OPTIONAL) If plugging speakers into the headphone jack of the ReSpeaker, set the correct PulseAudio Sink Port:
Run `pactl list sinks` and scroll to the bottom and notice your Active Port is probably "analog-output-speaker". Run the command below to output audio through the 3.5mm headphone jack.
@@ -52,26 +97,137 @@ pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
```
## 7. Test and make sure you can hear the wav file.:
## 13. Test and make sure you can hear the wav file.:
```sh
paplay /usr/share/sounds/alsa/Front_Center.wav
```
**Note that if you have a Raspbery Pi 3B or 4B, this may or may not seem to work, but it will output on the hat as intended.**
## 14. The following modifications are specifically required for LVA's mpv_player.py to work with PA. If you do not have a working PA at this point, do not proceed.:
Backup current PA daemon.conf file.
```bash
sudo cp /etc/pulse/daemon.conf{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/daemon.conf file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*exit-idle-time\s*=.*$|exit-idle-time = -1|' \
-e 's|^[#;[:space:]]*resample-method\s*=.*$|resample-method = trivial|' \
-e 's|^[#;[:space:]]*flat-volumes\s*=.*$|flat-volumes = no|' \
-e 's|^[#;[:space:]]*default-sample-rate\s*=.*$|default-sample-rate = 44100|' \
-e 's|^[#;[:space:]]*alternate-sample-rate\s*=.*$|alternate-sample-rate = 48000|' \
-e 's|^[#;[:space:]]*default-fragment-size-msec\s*=.*$|default-fragment-size-msec = 15|' \
/etc/pulse/daemon.conf
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for kv in \
'exit-idle-time = -1' \
'resample-method = trivial' \
'flat-volumes = no' \
'default-sample-rate = 44100' \
'alternate-sample-rate = 48000' \
'default-fragment-size-msec = 15'; do
k="${kv%% =*}"
sudo grep -Eq "^[[:space:]]*${k}[[:space:]]*=" /etc/pulse/daemon.conf || echo "$kv" | sudo tee -a /etc/pulse/daemon.conf >/dev/null
done
```
Backup current PA system.pa file.
```bash
sudo cp /etc/pulse/system.pa{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/system.pa file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-udev-detect)([[:space:]].*)?$|\1 tsched=0|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-native-protocol-unix.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-default-device-restore.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-always-sink.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-position-event-sounds.*)$|\1|' \
/etc/pulse/system.pa
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for m in \
'load-module module-native-protocol-unix' \
'load-module module-default-device-restore' \
'load-module module-always-sink' \
'load-module module-position-event-sounds'; do
sudo grep -Eq "^[[:space:]]*$m(\s|$)" /etc/pulse/system.pa || echo "$m" | sudo tee -a /etc/pulse/system.pa >/dev/null
done
```
Backup current PA default.pa file.
```bash
sudo cp /etc/pulse/default.pa{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/default.pa file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-udev-detect)([[:space:]].*)?$|\1 tsched=0|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-native-protocol-unix.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-default-device-restore.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-always-sink.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-intended-roles.*)$|\1|' \
/etc/pulse/default.pa
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for m in \
'load-module module-native-protocol-unix' \
'load-module module-default-device-restore' \
'load-module module-always-sink' \
'load-module module-intended-roles'; do
sudo grep -Eq "^[[:space:]]*$m(\s|$)" /etc/pulse/default.pa || echo "$m" | sudo tee -a /etc/pulse/default.pa >/dev/null
done
```
Optional since LVA handles duck/unduck: if you enabled role-ducking explicitly in system.pa, you can comment it out with:
```bash
sudo sed -i -E 's|^([[:space:]]*)load-module[[:space:]]+module-role-ducking\b|# \0|' /etc/pulse/system.pa
```
## 15. Restart PA and verify
```bash
sudo systemctl restart pulseaudio.service
```
Test mpv can ouput to PA with current configs:
```bash
mpv --ao=pulse --audio-device=default --audio-samplerate=44100 /usr/share/sounds/alsa/Front_Center.wav
```
If volume is low, set it to 100%.
If you disabled your Linux Voice Assistant service in step 2., enable it now:
```sh
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
```bash
sudo systemctl enable --now linux-voice-assistant.service
```
## 16. Reboot your Pi: (see step 4)
### 8. Done! Return to the tutorial to continue the Linux Voice Assistant install.
## 17. Done! Return to the tutorial to continue the Linux Voice Assistant install.
+118 -478
View File
@@ -9,10 +9,12 @@
```
> Modeled after the Wyoming Satellite two‑mic tutorial, adapted from actual shell history.
This guide reproduces a working setup of the **linux-voice-assistant** project with **Wyoming OpenWakeWord** and **MicroWakeWord** on a Raspberry PI Zero 2W and a Respeaker 2‑mic HAT (e.g., seeed-2mic-voicecard). It assumes a fresh system with sudo access and the default "pi" user. Included is the option to use PipeWire or PulseAudio instead of ALSA.
This guide reproduces a working setup of the **linux-voice-assistant** project with **Wyoming OpenWakeWord** or **MicroWakeWord** on a Raspberry PI Zero 2W and a Respeaker 2‑mic HAT (e.g., seeed-2mic-voicecard). It assumes a fresh system with sudo access and the default "pi" user. Included is the option to use PipeWire or PulseAudio instead of ALSA.
## Prerequisites
- Raspberry Pi OS Lite (64-bit) (Bookworm or Trixie)
- Raspberry Pi OS Lite (64-bit)
- Linux 6.12.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.34-1+rpt1~bookworm
- (2025-06-26) aarch64 GNU/Linux
- Default Python 3.11+ recommended
- A ReSpeaker 2‑mic sound card or compatable
- Network access to your Home Assistant instance
@@ -23,8 +25,8 @@ This guide reproduces a working setup of the **linux-voice-assistant** project w
```bash
sudo apt update
sudo apt upgrade
sudo apt install build-essential git \
libmpv-dev mpv python3-dev python3-venv
sudo apt install libportaudio2 build-essential git \
libmpv-dev mpv python3.11-dev avahi-daemon avahi-utils
sudo reboot
```
@@ -45,7 +47,21 @@ sudo reboot
```
## 4. Linux Voice Assistant (LVA)
## 4. Wyoming OpenWakeWord (OWW), skip to step 5. if using MicroWakeWord (MWW)
```bash
git clone https://github.com/rhasspy/wyoming-openwakeword.git
```
```bash
cp ~/linux-voice-assistant/wyoming-openwakeword/requirements.txt ~/wyoming-openwakeword/requirements.txt
cd wyoming-openwakeword/
script/setup
```
## 5. Linux Voice Assistant (LVA)
```bash
cd ~/linux-voice-assistant/
@@ -53,14 +69,14 @@ script/setup
```
## 5. Choose your install option "Choose your Adventure!"
## 6. Choose your install option "Choose your Adventure!"
Pick **one** of the following install paths. Expand a section to see the exact steps.
> Tip: All services run in *user* mode (requires `loginctl enable-linger`);
> Tip: PipeWire options run services in *user* mode (requires `loginctl enable-linger`); PulseAudio/ALSA options run services in *system* mode.
<details>
<summary><strong>PipeWire (user-mode services)</strong></summary>
<summary><strong>PipeWire + OpenWakeWord (user-mode services)</strong></summary>
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
@@ -69,44 +85,44 @@ Pick **one** of the following install paths. Expand a section to see the exact s
sudo loginctl enable-linger pi
```
**Install LVA user-mode services:**
**Install user-mode services:**
```bash
mkdir -p ~/.config/systemd/user
```
```bash
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
# LVA
cp ~/linux-voice-assistant/service/user-pw-oww-linux-voice-assistant.service.service ~/.config/systemd/user/linux-voice-assistant.service
# OWW
cp ~/linux-voice-assistant/service/user-wyoming-openwakeword.service.service ~/.config/systemd/user/wyoming-openwakeword.service
```
**Enable & start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now wyoming-openwakeword.service
systemctl --user enable --now linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
systemctl --user status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>PulseAudio (user-mode services)</strong></summary>
<summary><strong>PipeWire + MicroWakeWord (user-mode services)</strong></summary>
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
**Enable linger (required for user services to start after reboot):**
**Enable linger:**
```bash
sudo loginctl enable-linger pi
```
**Install LVA user-mode services:**
**Install user-mode service:**
```bash
mkdir -p ~/.config/systemd/user
```
```bash
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
cp ~/linux-voice-assistant/service/user-pw-mww-linux-voice-assistant.service.service ~/.config/systemd/user/linux-voice-assistant.service
```
**Enable & start:**
@@ -122,7 +138,54 @@ systemctl --user status linux-voice-assistant --no-pager -l
</details>
<details>
<summary><strong>ALSA (user-mode services)</strong></summary>
<summary><strong>PulseAudio + OpenWakeWord (system services)</strong></summary>
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
**Install LVA (system) + tuning drop-in:**
```bash
sudo cp ~/linux-voice-assistant/service/pa-oww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
sudo mkdir -p /etc/systemd/system/linux-voice-assistant.service.d
sudo cp ~/linux-voice-assistant/service/10-tuning.conf /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
```
**Install OWW (system):**
```bash
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wyoming-openwakeword linux-voice-assistant
sudo systemctl status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>PulseAudio + MicroWakeWord (system services)</strong></summary>
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
**Install LVA (system) + tuning drop-in:**
```bash
sudo cp ~/linux-voice-assistant/service/pa-mww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
sudo mkdir -p /etc/systemd/system/linux-voice-assistant.service.d
sudo cp ~/linux-voice-assistant/service/10-tuning.conf /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now linux-voice-assistant
sudo systemctl status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>ALSA + OpenWakeWord (system services)</strong></summary>
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
```bash
@@ -130,451 +193,59 @@ arecord -l
aplay -l
```
**Enable linger (required for user services to start after reboot):**
**Install LVA (system):**
```bash
sudo loginctl enable-linger pi
sudo cp ~/linux-voice-assistant/service/alsa-oww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
```
**Install LVA user-mode services:**
**Install OWW (system):**
```bash
mkdir -p ~/.config/systemd/user
```
```bash
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
```
**Enable & start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
sudo systemctl daemon-reload
sudo systemctl enable --now wyoming-openwakeword linux-voice-assistant
sudo systemctl status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>Optional (MQTT Controls)</strong></summary>
## 🔌 MQTT Controls Overview
The Linux Voice Assistant (LVA) creates several MQTT entities under the Home Assistant MQTT Discovery protocol. Each entity is prefixed by your LVA's unique device ID defined with the --name field in the LVA User-Mode Serviced Unit (e.g., `linux_voice_assistant`).
---
### 🎤 Microphone Mute (`switch` entity)
Controls the microphone mute state.
* **MQTT Discovery Topic:** `homeassistant/switch/<device_id>_mute/config`
* **Name:** `[LVA Name] Mute Microphone`
* **Icon:** `mdi:microphone-off`
* **Functionality:** Toggles the microphone mute. When muted, the LEDs will display a dim red `solid` effect.
---
## 💡 LED Control for Linux Voice Assistant
These are MQTT entities created by the Linux Voice Assistant for controlling its integrated LEDs (e.g., DotStar, NeoPixel). These entities integrate seamlessly with Home Assistant via MQTT Discovery, allowing you to manage LED effects, colors, brightness, and the number of connected LEDs directly from your Home Assistant interface.
---
### 🔢 Number of LEDs (`number` entity)
This entity allows you to specify the physical number of addressable LEDs connected to your device.
* **MQTT Discovery Topic:** `homeassistant/number/<device_id>_num_leds/config`
* **Name:** `[LVA Name] Number of LEDs`
* **Functionality:** Sets the total number of LEDs.
* **Important:** This setting requires a **restart of the LVA service file** to take effect, as the LED hardware driver needs to be re-initialized. The value is persisted in `preferences.json`.
---
### ✨ State-Based LED Controls (`select` and `light` entities)
For each distinct LVA state (Idle, Listening, Thinking, Responding, Error), a `select` entity for choosing an effect and a `light` entity for controlling color and brightness are created.
* **States:**
* `idle` (e.g., `[LVA Name] Idle Effect`, `[LVA Name] Idle Color`)
* `listening` (e.g., `[LVA Name] Listening Effect`, `[LVA Name] Listening Color`)
* `thinking` (e.g., `[LVA Name] Thinking Effect`, `[LVA Name] Thinking Color`)
* `responding` (e.g., `[LVA Name] Responding Effect`, `[LVA Name] Responding Color`)
* `error` (e.g., `[LVA Name] Error Effect`, `[LVA Name] Error Color`)
#### `select` Entities (Effect Selector)
* **MQTT Discovery Topic:** `homeassistant/select/<device_id>_<state_name>_effect/config`
* **Name:** `[LVA Name] [State Name] Effect`
* **Icon:** `mdi:palette-swatch-variant`
* **Functionality:** Allows selection of an animation/effect for the specific LVA state.
#### `light` Entities (Color & Brightness Control)
* **MQTT Discovery Topic:** `homeassistant/light/<device_id>_<state_name>_color/config`
* **Name:** `[LVA Name] [State Name] Color`
* **Functionality:** Controls the color and brightness for the specific LVA state when the selected effect uses color. Supports RGB color mode and brightness.
---
## 🎨 Available LED Effects
The following effects can be selected via the `[State Name] Effect` (select) entities:
| Effect Name | Description |
| :--- | :--- |
| **Off** | All LEDs are turned off. |
| **Solid** | All LEDs display a single, constant color. |
| **Slow Pulse** | LEDs slowly fade in and out. |
| **Medium Pulse** | LEDs fade in and out at a moderate speed. |
| **Fast Pulse** | LEDs rapidly fade in and out. |
| **Slow Blink** | LEDs turn on and off slowly. |
| **Medium Blink** | LEDs turn on and off at a moderate speed. |
| **Fast Blink** | LEDs rapidly turn on and off. |
| **Spin** | A single LED "spins" around the strip. |
**Edit LVA config.json file:**
<summary><strong>ALSA + MicroWakeWord (system services)</strong></summary>
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
```bash
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
arecord -l
aplay -l
```
**Add MQTT configuration entries to LVA config.json file:**
**Install LVA (system):**
```bash
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_username",
"password": "mqtt_password"
}
sudo cp ~/linux-voice-assistant/service/alsa-mww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
```
**Example of complete config.json file**
***Note: Change the MQTT values to match your system!***
```bash
{
"app": {
"name": "Linux Voice Assistant"
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_username",
"password": "mqtt_password"
}
}
```
**Enable & start:**
```bash
systemctl --user restart linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
sudo systemctl daemon-reload
sudo systemctl enable --now linux-voice-assistant
sudo systemctl status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>Optional (Grove Port LEDs)</strong></summary>
This optional configuration support the use of the ReSpeaker 2Mic Grove Port with APA102 LEDs.
| Grove Pigtail | Function (on ReSpeaker Hat) | Solder to LED Strip |
| :--- | :--- | :--- |
| ⚫ **Black Wire** | Ground (GND) | **GND** (Ground) |
| 🔴 **Red Wire** | Power (VCC) | **VCC / 5V** (Power) |
| 🟡 **Yellow Wire** | GPIO12 (Signal 1) | **DI** (Data Input) |
| ⚪ **White Wire** | GPIO13 (Signal 2) | **CI** (Clock Input) |
**Edit LVA config.json file:**
<summary><strong>Optional: Advertise LVA to Home Assistant via Avahi</strong></summary>
**Generate & install ESPHome-style mDNS service:**
```bash
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
```
**Add Grove (GPIO) configuration entries to LVA config.json file:**
```bash
"led": {
"led_type": "dotstar",
"interface": "gpio",
"clock_pin": 13,
"data_pin": 12,
"num_leds": 10
}
```
**Example (LVA config.json file with MQTT and Grove Port enabled)**
***Note: Change the GPIO values to match your system!***
```bash
{
"app": {
"name": "Linux Voice Assistant"
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_username",
"password": "mqtt_password"
},
"led": {
"led_type": "dotstar",
"interface": "gpio",
"clock_pin": 13,
"data_pin": 12,
"num_leds": 10
}
}
```
**Enable & start:**
```bash
systemctl --user restart linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
chmod +x ~/linux-voice-assistant/script/gen-esphome-avahi.sh
sudo ~/linux-voice-assistant/script/gen-esphome-avahi.sh
sudo systemctl restart avahi-daemon.service
```
</details>
<details>
<summary><strong>Optional (Acoustic Echo Cancellation) </strong></summary>
This optional configuration support the use AEC and require either a working PipeWire-Pulse or PulseAudio backend.
Enable the echo cancel PulseAudio module:
```bash
pactl load-module module-echo-cancel \
aec_method=webrtc \
aec_args="analog_gain_control=1 digital_gain_control=1 noise_suppression=1"
```
If successfully loaded you will see an ID presented after the command. Keep this ID as you may need it to "tune" the AEC.
```bash
pactl load-module module-echo-cancel \
aec_method=webrtc \
aec_args="analog_gain_control=1 digital_gain_control=1 noise_suppression=1"
536870916
```
Determine the names of the echo cancellation audio devices. On my system it is "Echo-Cancel Source" and "pipewire/echo-cancel-sink":
```bash
~/linux-voice-assistant/script/run --list-input-devices
Input devices
=============
[0] Built-in Audio Stereo
[1] Echo-Cancel Source
```
```bash
~/linux-voice-assistant/script/run --list-output-devices
Output devices
==============
auto: Autoselect device
pipewire: Default (pipewire)
pipewire/alsa_output.platform-soc_sound.stereo-fallback: Built-in Audio Stereo
pipewire/echo-cancel-sink: Echo-Cancel Sink
pulse/alsa_output.platform-soc_sound.stereo-fallback: Built-in Audio Stereo
pulse/echo-cancel-sink: Echo-Cancel Sink
alsa: Default (alsa)
alsa/sysdefault: Default Audio Device
alsa/lavrate: Rate Converter Plugin Using Libav/FFmpeg Library
alsa/samplerate: Rate Converter Plugin Using Samplerate Library
alsa/speexrate: Rate Converter Plugin Using Speex Resampler
alsa/jack: JACK Audio Connection Kit
alsa/oss: Open Sound System
alsa/pipewire: PipeWire Sound Server
alsa/speex: Plugin using Speex DSP (resample, agc, denoise, echo, dereverb)
alsa/upmix: Plugin for channel upmix (4,6,8)
alsa/vdownmix: Plugin for channel downmix (stereo) with a simple spacialization
alsa/playback: playback
alsa/capture: capture
alsa/dmixed: dmixed
alsa/array: array
alsa/plughw:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/Hardware device with all software conversions
alsa/sysdefault:CARD=vc4hdmi: vc4-hdmi, MAI PCM i2s-hifi-0/Default Audio Device
alsa/hdmi:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/HDMI Audio Output
alsa/dmix:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/Direct sample mixing device
alsa/usbstream:CARD=vc4hdmi: vc4-hdmi/USB Stream Output
alsa/plughw:CARD=seeed2micvoicec,DEV=0: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Hardware device with all software conversions
alsa/sysdefault:CARD=seeed2micvoicec: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Default Audio Device
alsa/dmix:CARD=seeed2micvoicec,DEV=0: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Direct sample mixing device
alsa/usbstream:CARD=seeed2micvoicec: seeed-2mic-voicecard/USB Stream Output
jack: Default (jack)
sdl: Default (sdl)
```
**Edit LVA config.json file:**
```bash
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
```
**Add AEC configuration entries to LVA config.json file:**
```bash
"audio": {
"input_device": "Echo-Cancel Source",
"input_block_size": 1024,
"output_device": "pipewire/echo-cancel-sink"
}
```
**Example (LVA config.json file with MQTT, Grove Port, and AEC enabled)**
***Note: Change the source and sink values to match your system!***
```bash
{
"app": {
"name": "Linux Voice Assistant"
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_username",
"password": "mqtt_password"
},
"led": {
"led_type": "dotstar",
"interface": "gpio",
"clock_pin": 13,
"data_pin": 12,
"num_leds": 10
},
"audio": {
"input_device": "Echo-Cancel Source",
"input_block_size": 1024,
"output_device": "pipewire/echo-cancel-sink"
}
}
```
**AEP Tuning Options (aec_args)**
In order to modify the echo cancellation device setting, you must first remove the previously installed modules.
Remove AEP module (use the module ID listed when AEP module was loaded):
```bash
pactl unload-module 536870916
```
AEP tuning settings:
| Setting | Allowed Values | Typical Value | What It Does | When To Change |
|------------------------|----------------|---------------|-------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| `analog_gain_control` | `0` or `1` | `0` | Lets WebRTC AEC “ride” the hardware/analog mic gain. | Leave `0` when you already tuned mic gain in ALSA/Pulse. Use `1` only if your mic is too quiet and you want auto-leveling at the expense of some consistency. |
| `digital_gain_control` | `0` or `1` | `1` | Software AGC on the captured signal (after the ADC). | Keep `1` for voice assistants so wake-word and STT get a stable level. Turn `0` if you already run separate AGC or notice pumping/breathing. |
| `noise_suppression` | `0` or `1` | `1` | Enables WebRTC noise reduction on the mic signal. | Keep `1` in most cases (fans, room noise). Try `0` if audio sounds “underwater” or dull and your environment is already very quiet. |
| `extended_filter`* | `0` or `1` | `1` (often) | Uses a more robust AEC filter that handles tricky echo paths / long delays. | Use `1` for speaker-in-room setups (like LVA) unless CPU is extremely constrained. |
| `delay_agnostic`* | `0` or `1` | `1` (often) | Makes AEC less sensitive to exact playback/capture latency. | Keep `1` if devices/paths change or Bluetooth is involved. Set `0` only if you know latency is rock-stable and want to shave a bit of CPU. |
| `drift_compensation`* | `0` or `1` | `1` (often) | Compensates for clock drift between capture and playback devices. | Use `1` if mic and speakers are on different hardware (USB mic + HDMI/Bluetooth out). `0` is OK when both share the same clock (onboard codec only). |
| `voice_detection`* | `0` or `1` | `0` or `1` | Simple VAD that can help AEC and noise suppression focus on speech segments. | Try `1` if you see good wake-word hits but noisy STT. Use `0` if it seems to cut off very quiet speech or initial phonemes. |
**Enable & start:**
```bash
systemctl --user restart linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>Optional (GPIO Button)</strong></summary>
Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like:
***Short press***
If TTS or music is playing → stop playback (equivalent to the Stop wake word)
Otherwise → start a new conversation (equivalent to a wake word trigger)
***Long press***
Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync)
The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
**Requires a compatible GPIO board** This has been tested on the ReSpeaker 2-Mic Pi Hat:
**Edit the LVA config.json file:**
```bash
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
```
**Enable the GPIO button support:**
```bash
"button": {
"enabled": true,
"pin": 17,
"long_press_seconds": 1.0
}
```
**Example (LVA config.json file with MQTT, Grove Port, and GPIO Button enabled** ***Note: The GPIO button can be changed from the default (17) on the 2-Mic hat.***
```bash
{
"app": {
"name": "Linux Voice Assistant"
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_username",
"password": "mqtt_password"
},
"led": {
"led_type": "dotstar",
"interface": "gpio",
"clock_pin": 13,
"data_pin": 12,
"num_leds": 10
},
"button": {
"enabled": true,
"pin": 17,
"long_press_seconds": 1.0
}
}
```
**Enable & start:**
```bash
systemctl --user restart linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
```
</details>
## 6. Connect to Home Assistant
## 7. Connect to Home Assistant
### If HA does not discover the new LVA:
@@ -583,12 +254,12 @@ systemctl --user status linux-voice-assistant --no-pager -l
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
4. Enter the IP address of your voice satellite with port 6053
5. Click "Submit"
6. During the registration process, use the wake word that is displayed on the registration page. Default is "OK Nabu".
6. During the registration process, use the wake word that you configured in your linux-voice-assistant.service file. Default is "alexa".
## 7. Verification
- Use "journalctl --user -u linux-voice-assistant.service -f" to check for errors. Debugging is enabled.
## 8. Verification
- Use "journalctl -u linux-voice-assistant.service -f" to check for errors for ALSA and PluseAudio. Debugging is enabled.
- Use "journalctl --user -u linux-voice-assistant.service -f" to check for errors when using PipeWire. Debugging is enabled.
- Expect logs like `Connected to Home Assistant`
- Look for `[OWW] Detection: name=...` followed by re-arming/cycling
- Ask: *“What time is it?”* and confirm TTS reply
@@ -597,7 +268,7 @@ systemctl --user status linux-voice-assistant --no-pager -l
### Settings -> Voice assistants -> Assist (the assistant you configured) -> Text-to-speech -> Voice
## 8. Change OWW detection model
## 9. Change OWW detection model
After the LVA is registered with HA, you can change the Wake Word model used in the ESPHome Voice Assistant entity.
@@ -608,55 +279,24 @@ alexa_v0.1.tflite -> Alexa
hey_jarvis_v0.1.tflite -> Hey Jarvis
hey_mycroft_v0.1.tflite -> Hey Mycroft
hey_rhasspy_v0.1.tflite -> Hey Rhasspy
ok_nabu_v0.1.tflite -> OK Nabu **(I had to say OK Nobu)**
ok_nabu_v0.1.tflite -> OK Nabo **(I had to say OK Nobu)**
```
Additional community provided OWW models available from this repository:
https://github.com/fwartner/home-assistant-wakewords-collection
You just copy the ones you want into the ~/linux-voice-assistant/wakewords/openWakeWord directory. If a model is currupted, the LVA will fail to start.
Each model added will need a corresponding json file. (note the json file names matches the tflite name)
***Example***
Add Model:
```
/linux-voice-assistant/wakewords/openWakeWord/hal_v2.tflite
```
Create Json:
```
/linux-voice-assistant/wakewords/openWakeWord/hal_v2.json
```
Contents:
```
{
"type": "openWakeWord",
"wake_word": "HAL",
"model": "hal_v2.tflite"
}
```
You just copy the ones you want into the ~/wyoming-openwakeword/wyoming-openwakeword/models directory. If a model is currupted, the wyoming-openwakeword.service will fail to start.
**Word of warning. I have had problems with some of the community provided wake words. YMMV**
## 9. Switching between ALSA, PW, or PA see section 5.
## 10. Switching between OWW and MWW. Or ALSA, PW, PA see section 6.
If you intend to switch from PA or PW to ALSA, you must first stop and disable the corresponding user-mode services.
###PulseAudio
If you intend to switch from PA to ALSA, you must first stop the pusleaudio.service.
```bash
sudo systemctl --user stop pulseaudio.service
sudo systemctl stop pulseaudio.service
```
```bash
sudo systemctl --user disable pulseaudio.service
```
###PipeWire
```bash
sudo systemctl --user stop pipewire.service
```
```bash
sudo systemctl --user disable pipewire.service
sudo systemctl disable pulseaudio.service
```
-223
View File
@@ -1,223 +0,0 @@
# XVF3800 Mute Button & USB Control Integration
This document explains how the **ReSpeaker XVF3800 4‑Mic USB Array** integrates its
built‑in mute button and red mute LED with the **Linux Voice Assistant (LVA)**.
The goal is:
- Pressing the XVF3800’s hardware mute button updates the LVA mic state.
- Muting/unmuting the LVA (via Home Assistant, MQTT, or other inputs) updates
the XVF3800’s mic mute circuit and red LED.
- No extra binaries (like the vendor `xvf_host`) are required — LVA talks to
the XVF3800 directly via **USB control transfers** using `pyusb`.
---
## 1. How it Works (High‑Level)
The XVF3800 exposes a vendor control protocol over USB that can read and write
internal GPIO pins (GPO). One of these pins drives both:
- the microphone mute circuit
- the red mute LED on the XVF3800 front panel
LVA’s `XVF3800ButtonController` does three things:
1. **Reads hardware mute state periodically**
It polls the XVF3800’s GPO state (pin X0D30) in a background thread using
`pyusb`. If the value changes, LVA treats that as “the user pressed the
XVF3800 mute button” and publishes a `set_mic_mute` event.
2. **Mirrors LVA mute → XVF3800**
It subscribes to `mic_muted` / `mic_unmuted` events. Whenever LVA’s mute
state changes (for example, via MQTT, HA UI, or a GPIO button), the
controller writes the new state back to X0D30 so that the mic mute circuit
and red LED stay in sync.
3. **Initial sync on startup**
On first successful read, the controller treats the *hardware* mute state
as the source of truth and publishes `set_mic_mute` so LVA starts aligned
with whatever the XVF3800 is currently doing.
No PulseAudio/PipeWire configuration is required for this feature; it only
depends on **USB access to the XVF3800** and the `pyusb` library.
---
## 2. Prerequisites
- You are running a branch of **linux‑voice‑assistant** that includes:
- `linux_voice_assistant/xvf3800_button_controller.py`
- The updated `__main__.py` and `config.py` wiring for `XVF3800ButtonController`
- The ReSpeaker XVF3800 is connected over USB and appears in `lsusb` as:
- `2886:001a Seeed Technology Co., Ltd. reSpeaker XVF3800 4-Mic Array`
- The LVA `.venv` includes `pyusb` (already present in `pyproject.toml`):
```bash
source .venv/bin/activate
pip install pyusb
```
On most systems, user‑mode processes can talk to the XVF3800 over USB without
`sudo` once the device is enumerated. If you see `USBError: [Errno 13] Access denied`,
you may need a udev rule — that’s outside this document but worth noting.
---
## 3. Enabling the XVF3800 Mute Integration
The integration is controlled via the `button` section of `config.json`.
Two “modes” are supported:
- `"gpio"` – legacy Raspberry Pi GPIO button (ReSpeaker 2‑Mic HAT, etc.)
- `"xvf3800"` – USB‑based mute integration for the ReSpeaker XVF3800
### 3.1 Example `config.json` for XVF3800
Edit your LVA configuration file (usually `linux_voice_assistant/config.json`)
and add or adjust the `button` block like this:
```jsonc
{
"app": {
"name": "Linux Voice Assistant"
},
// ... other sections (audio, wake_word, esphome, led, mqtt, etc.) ...
"button": {
"enabled": true,
"mode": "xvf3800",
// Optional: how often to poll the XVF3800 GPO state (seconds)
"poll_interval_seconds": 0.05
}
}
```
Notes:
- `"enabled": true` turns on button support in general.
- `"mode": "xvf3800"` switches from the GPIO‑based button controller to the
USB‑based XVF3800 controller.
- `poll_interval_seconds` is optional and defaults to `0.01` if omitted. Values
between 0.02 and 0.1 are typically fine.
The GPIO‑specific fields (`pin`, `long_press_seconds`) are ignored in
`xvf3800` mode — the XVF3800’s onboard mute button is treated as a
**dedicated mute toggle only**, not a wake/stop button.
---
## 4. Runtime Behaviour
Once enabled and the LVA service is restarted, the mute integration behaves as follows:
### 4.1 Pressing the XVF3800 Mute Button
1. The XVF3800 firmware changes the GPO state of pin X0D30 and lights or clears
the red mute LED.
2. The `XVF3800ButtonController` sees the changed GPO values the next time it
polls USB.
3. It publishes a `set_mic_mute` event on the LVA `EventBus` with the new state.
4. The `MicMuteHandler`:
- Updates `ServerState.mic_muted` and `mic_muted_event` (pausing/resuming the
audio capture thread).
- Publishes `mic_muted` / `mic_unmuted` events used by other components
(LED controller, MQTT, etc.).
- Notifies the MQTT controller so the HA “Mute Microphone” entity stays in sync.
From the user’s perspective:
- The red LED on the XVF3800 tracks mic mute.
- The HA “Mute Microphone” switch also tracks the hardware button.
- The audio engine actually stops capturing when muted.
### 4.2 Toggling Mute from Home Assistant / MQTT / Other Inputs
1. Home Assistant toggles the LVA “Mute Microphone” switch (or another input
publishes a `set_mic_mute` event).
2. The `MicMuteHandler` applies the new mute state, as usual.
3. It emits a `mic_muted` or `mic_unmuted` event.
4. The `XVF3800ButtonController` receives this event and writes the new state
back to X0D30 via USB.
5. The XVF3800’s mic mute circuit and red LED update to match LVA.
This keeps **hardware**, **LVA state**, and **Home Assistant UI** aligned.
---
## 5. Coexistence with Other Buttons
The XVF3800 mute integration is intentionally narrow in scope:
- It does **not** implement short vs. long press behaviour.
- It does **not** trigger wake‑word, stop‑word, or playback controls.
- It is purely “mute on / mute off” with bidirectional sync.
If you also have a GPIO button (e.g. a ReSpeaker 2‑Mic HAT momentary switch)
wired for wake/stop behaviour, you can:
- Leave `button.mode: "gpio"` and **not** use the XVF3800 mute integration, or
- Move wake/stop to the GPIO button controller and leave the XVF3800 button
as mute‑only by setting `mode: "xvf3800"` and handling wake/stop elsewhere.
At the moment, `button.mode` is **single‑valued** — it selects either the
GPIO controller or the XVF3800 controller, not both at once. If you ever want
to support both concurrently, that would be a separate enhancement (e.g. a
list of button backends).
---
## 6. Troubleshooting
### 6.1 No USB Access / Permission Errors
Symptoms in the logs (journalctl or console):
```text
Failed to initialize XVF3800 USB client; mute button integration will be disabled
USBError: [Errno 13] Access denied (insufficient permissions)
```
Check:
- `lsusb` shows `2886:001a` while LVA is running.
- The LVA service is running as the user who owns the `.venv` and has
access to USB devices.
- If necessary, add a udev rule that grants access to `idVendor=0x2886`
and `idProduct=0x001a` for your LVA user, then reload udev and
replug the device.
The controller will keep retrying USB initialization; once permissions are
fixed, it should connect without restarting LVA.
### 6.2 Mute Button Works but HA Entity Doesn’t Update
- Verify MQTT is enabled and configured correctly in `config.json`.
- Confirm that the HA “Mute Microphone” entity exists and belongs to the
correct LVA device.
- Check logs for `MicMuteHandler` and `MqttController` messages when pressing
the XVF3800 mute button.
### 6.3 HA Mute Switch Works but XVF3800 LED Doesn’t Change
- Ensure `button.enabled` is `true` and `"mode": "xvf3800"` in `config.json`.
- Look for log lines like:
- `Initializing XVF3800ButtonController (mode=xvf3800)`
- `Connected to ReSpeaker XVF3800 for mute control`
- If you see only GPIO logs, confirm that the branch you’re running includes
`xvf3800_button_controller.py` and the updated `__main__.py` wiring.
---
## 7. Summary
With `mode: "xvf3800"`, the Linux Voice Assistant treats the ReSpeaker
XVF3800’s onboard mute button and red LED as a **first‑class mute control**:
- Pressing the XVF3800 mute button updates LVA and Home Assistant.
- Muting via Home Assistant or other inputs updates the XVF3800 hardware.
- The audio engine, LEDs, and MQTT entities stay in sync with the device’s
actual hardware mute state.
This integration is fully optional and coexists with existing audio and LED
behaviour — it simply adds a reliable, hardware‑based mute path for the
XVF3800‑based satellites.
-224
View File
@@ -1,224 +0,0 @@
# Linux Voice Assistant with ReSpeaker XVF3800 4‑Mic Array — Installation & Configuration Guide
> Drafted with ChatGPT based on existing `linux-voice-assistant-2mic-install.md` and community test results from the XVF3800 integration Discussion.
> Special thanks to @JenniferHatches and @cknic for making the recommendation and doing so much work already for support of the reSpeaker XVF3800. (https://github.com/cknic/linux-voice-assistant) The addition of XVF3800 support would never have happened without their troubleshooting of the code and fantastic suggestions when working through bugs.
This guide shows how to use the **Seeed ReSpeaker XVF3800 USB 4‑Mic Array** with the **Linux Voice Assistant** (LVA) project on Raspberry Pi or other Linux hosts.
Unlike the ReSpeaker 2‑Mic HAT, the XVF3800 is a **USB Audio Class 2.0** device: it acts as a “smart microphone + speaker” front end with built‑in **AEC, beamforming, noise suppression, AGC, and echo‑canceling speakerphone** processing.
No custom kernel drivers are required. Plug it in, setup the device in LVA’s `config.json`, and it will “just work” as a high‑quality mic and speaker (if connected).
---
## Prerequisites
- A Linux host (e.g., Raspberry Pi 4/5) running one of:
- Raspberry Pi OS (Bookworm or Trixie)
- Debian 13 (trixie) or similar
- Other modern Linux with PipeWire or PulseAudio
- Python 3.11+ recommended
- **ReSpeaker XVF3800 4‑Mic Array**, connected via its **XMOS USB‑C port**
- SSH access to your LVA instance
- Network access to the Home Assistant for registration
## Recommended Configuration
- To use the advanced LED effects, upgrade to the latest XVF3800 firmware (respeaker_xvf3800_usb_dfu_firmware_v2.0.7.bin as of this writing).
-- https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY/blob/master/xmos_firmwares/dfu_guide.md
- Tested and works on a Raspberry Pi Zero 2W, but I don't recommend attempting to flash the firmware with this device.
- PipeWire (or PulseAudio) installed and tested. I haven't tested the XVF3800 with ALSA.
---
## 1. Install system packages and Pipewire (Recommended)
```bash
sudo apt update
sudo apt upgrade
sudo apt install build-essential git \
libmpv-dev mpv python3-dev python3-venv \
pulseaudio-utils pipewire wireplumber \
pipewire-audio libspa-0.2-modules libusb-1.0-0 \
dbus-user-session
sudo reboot
```
---
## 2. Get the code (XVF3800 branch)
If you don’t already have the project cloned:
```bash
cd ~
git clone https://github.com/imonlinux/linux-voice-assistant.git
cd linux-voice-assistant
```
If you already have the repository, you can:
```bash
cp ~/linux-voice-assistant/linux_voice_assistant/config.json ~/config.json
cd ~/linux-voice-assistant
git pull
cp ~/config.json ~/linux-voice-assistant/linux_voice_assistant/config.json
```
Implement the UDEV rule to prevent the XVF3800 from taking naps and to allow user access to the device
```bash
sudo cp ~/linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
Add user to the plugdev group for access to the XVF3800 and reboot
> Tip: If you use a different username than `pi`, adjust user accordingly.
```bash
sudo usermod -aG plugdev pi
sudo reboot
```
Set up the project (creates a virtualenv, installs dependencies, etc.):
```bash
cd ~/linux-voice-assistant
script/setup
```
1. Connect the XVF3800 to your Pi/host using the **XMOS USB‑C port** (the one next to the 3.5 mm audio jack).
2. If you want local audio output, connect the XVF3800’s headphone jack or JST-PH 2.0mm 2 Pin to an external speaker.
---
## 3. Update the config.json file for XVF3800 support and options
Edit the config.json file:
```bash
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
```
Add or update the following sections (Recommended settings):
> Tip: Be sure to change the name if you have more than one LVA!
```json
{
"app": {
"name": "Linux Voice Assistant"
},
"led": {
"enabled": true,
"led_type": "xvf3800",
"interface": "usb"
},
"button": {
"enabled": true,
"mode": "xvf3800",
"poll_interval_seconds": 0.15
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt_server",
"password": "mqtt_password"
},
"audio": {
"volume_sync": true,
"max_volume_percent": 150
}
}
```
> Take a look at ~/linux-voice-assistant/linux_voice_assistant/config.json.example for details on these settings as well as all available options.
---
## 4. Start LVA as a user-mode service
The service setup is identical to other configurations (2‑Mic, etc.). Assuming you want
LVA to run as a user‑mode systemd service:
Enable linger for your user (e.g. `pi`)
```bash
sudo loginctl enable-linger pi
```
Install the systemd user service
> Tip: If you use a different username than `pi`, adjust user accordingly.
```bash
mkdir -p ~/.config/systemd/user
cp ~/linux-voice-assistant/service/linux-voice-assistant_xvf3800.service ~/.config/systemd/user/linux-voice-assistant.service
```
Enable & start
```bash
systemctl --user daemon-reload
systemctl --user enable --now linux-voice-assistant.service
```
> Note: The LVA initiate a XVF3800 reboot at start, this is needed to resolve a "mic not detecting audio" issue. As a result, the LVA will take a little longer to start up and be ready.
Check status:
```bash
systemctl --user status linux-voice-assistant --no-pager -l
```
Look for log lines indicating:
- LVA connected to Home Assistant
- Wake‑word engine initialized
- Satellite streaming started on wake word
---
## 5. Acoustic Echo Cancellation (AEC)
The XVF3800 already implements **hardware AEC / echo‑canceling speakerphone** processing.
---
## 6. Troubleshooting & debug tips
### Check that LVA sees the XVF3800
Make sure the XVF3800 is available to the LVA code:
```bash
cd ~/linux-voice-assistant
script/run --list-input-devices
script/run --list-output-devices
```
If `reSpeaker XVF3800 4-Mic Array Analog Stereo` doesn’t appear:
- Check `arecord -l` / `aplay -l` again.
- Ensure the XVF3800 is connected to the XMOS USB‑C port and powered.
- Check `lsusb` for a device with `2886:001a`:
```bash
lsusb | grep -i 2886
```
### Tail the LVA logs
```bash
journalctl --user -u linux-voice-assistant.service -f
```
Look for:
- “Audio engine started.”
- “Opening audio input device: reSpeaker XVF3800 4-Mic Array Analog Stereo”
- Wake‑word detection lines (MicroWakeWord / OpenWakeWord)
-163
View File
@@ -1,163 +0,0 @@
# Want to run LVA on a Linux Graphical Desktop?
### 1. Install the dependancies:
### Debian based distro:
```bash
sudo apt update
sudo apt install \
libmpv-dev git python3-dev mpv
```
### Fedora based distro:
```bash
sudo dnf install \
mpv-devel python3-devel git mpv
```
### Arch based distro:
```bash
sudo pacman -Syu \
python-pip python-virtualenv mpv
```
### 2. Open Firewall for LVA:
### UFW:
```bash
sudo ufw allow 6053/udp
sudo ufw allow 6053/tcp
sudo ufw reload
```
### Firewall-CMD:
```bash
sudo firewall-cmd --permanent --zone=public --add-port=6053/tcp
sudo firewall-cmd --permanent --zone=public --add-port=6053/udp
sudo firewall-cmd --reload
```
### 3. Get the code:
```bash
git clone https://github.com/imonlinux/linux-voice-assistant.git
```
### 4. Setup Linux Voice Assistant (LVA):
```bash
cd linux-voice-assistant/
chmod +x script/tray
script/setup
source .venv/bin/activate
pip install -e .[tray]
deactivate
```
### 5. Modify config.json File:
```bash
nano linux_voice_assistant/config.json
```
Change the LVA name and add the details for MQTT (MQTT is required for the LVA Tray Client)
***Update the MQTT details to match your system!***
```bash
{
"app": {
"name": "Debian Voice Assistant"
},
"mqtt": {
"host": "192.168.1.2",
"port": 1883,
"username": "mqtt-user",
"password": "mqtt-password"
}
}
```
### 6. Create LVA User Mode Systemd Unit file:
```bash
mkdir -p ~/.config/systemd/user
cp service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
```
### Edit the LVA Unit file for correct path:
```bash
systemctl --user edit --force --full linux-voice-assistant.service
```
### Enter the correct path for WorkingDirectory and ExecStart
```bash
[Unit]
Description=Linux Voice Assistant
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
```
### 7. Create the LVA Tray Client User Mode Systemd Unit file:
```bash
cp service/linux-voice-assistant-tray.service ~/.config/systemd/user/linux-voice-assistant-tray.service
```
### Edit the LVA Tray Unit file for correct path:
```bash
systemctl --user edit --force --full linux-voice-assistant-tray.service
```
### Enter the correct path for WorkingDirectory and ExecStart
```bash
[Unit]
Description=Linux Voice Assistant Tray Client
# Make sure we have a graphical session
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
WorkingDirectory=/home/james/Workbench/linux-voice-assistant
ExecStart=/home/james/Workbench/linux-voice-assistant/script/tray
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
```
### Start only the LVA Tray Application
```bash
systemctl --user daemon-reload
systemctl --user enable --now linux-voice-assistant-tray.service
```
This will add an icon to your taskbar. Click on the icon and select "Start LVA".
Follow the instructions in HA to register the LVA.
Use the MQTT device that is created in HA to adjust the icon color for each of the LVA states.
@@ -1,45 +0,0 @@
# XVF3800 Legacy LED Effects Mapping for Linux Voice Assistant
This document maps the **legacy ReSpeaker XVF3800 LED_EFFECT modes** to the higher-level LED effects used by the **linux-voice-assistant (LVA)**.
## XVF3800 Legacy LED_EFFECT Values
From the Seeed/XMOS documentation:
- `LED_EFFECT 0` → **off**
- `LED_EFFECT 1` → **breath**
- `LED_EFFECT 2` → **rainbow**
- `LED_EFFECT 3` → **single color**
- `LED_EFFECT 4` → **DoA mode**
## LVA Effects → XVF3800 Mapping
LVA exposes higher-level effects such as **Off / Solid / Slow/Medium/Fast Pulse / Slow/Medium/Fast Blink / Spin**.
On XVF3800 (legacy firmware), these are currently mapped onto the limited built-in modes as follows:
| LVA Effect Name | XVF3800 `LED_EFFECT` | Additional Parameters | Behavior on XVF3800 (Legacy) |
|------------------|----------------------|--------------------------------------|-------------------------------------------------------------------------|
| `off` | `0` (off) | – | LED ring completely off. |
| `solid` | `3` (single color) | `LED_COLOR`, `LED_BRIGHTNESS` | Full ring in a single static color. |
| `slow_pulse` | `1` (breath) | `LED_SPEED` = slow | Slow breathing in the configured color. |
| `medium_pulse` | `1` (breath) | `LED_SPEED` = medium | Medium breathing in the configured color. |
| `fast_pulse` | `1` (breath) | `LED_SPEED` = fast | Fast breathing in the configured color. |
| `slow_blink` | `1` (breath) * | `LED_SPEED` = slow | Approximated with slow breath (no true sharp on/off blink). |
| `medium_blink` | `1` (breath) * | `LED_SPEED` = medium | Approximated with medium breath. |
| `fast_blink` | `1` (breath) * | `LED_SPEED` = fast | Approximated with fast breath. |
| `spin` | `3` or `1` ** | Typically treated as solid/pulsed | No native “spin”; currently falls back to a static or pulsed behavior. |
\* There is no dedicated “blink” primitive in the legacy API, so all blink variants are currently rendered as **breathing** with different speeds. They will look like fading rather than a true square‑wave blink until we add a more advanced mapping or per‑LED pattern logic.
\** The legacy firmware does not offer a true “spin” animation. For now, `spin` is effectively treated as either **single color** or a **breath** variant to keep behavior predictable.
## Practical Notes
- Under the legacy backend, **only three real modes** are used:
- `0` → off
- `1` → breath (with speed control)
- `3` → single color
- All the LVA **pulse** and **blink** variants are essentially **different speeds of `LED_EFFECT=1`**.
- The **spin** effect is not yet a genuine rotating animation on XVF3800; it’s a placeholder mapped onto existing firmware modes.
As we move toward **firmware v2.0.7+** and `LED_RING_COLOR` support, we’ll be able to implement richer, per‑LED patterns (including real spin and more accurate blink behaviors) while keeping the same high-level LVA effect names.
+1
View File
@@ -0,0 +1 @@
https://github.com/tphakala/tflite_c/
Binary file not shown.
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
Metadata-Version: 2.4
Name: linux-voice-assistant
Version: 1.0.0
Summary: Linux voice assistant for Home Assistant using the ESPHome protocol
Author-email: The Home Assistant Authors <hello@home-assistant.io>
License: Apache-2.0
Project-URL: Source Code, http://github.com/OHF-Voice/linux-voice-assistant
Keywords: home,assistant,voice,esphome,linux
Platform: any
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Text Processing :: Linguistic
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9.0
Description-Content-Type: text/markdown
License-File: LICENSE.md
Requires-Dist: aioesphomeapi==37.2.1
Requires-Dist: sounddevice<1
Requires-Dist: numpy<3,>=2
Requires-Dist: pymicro-features==1.0.0
Provides-Extra: dev
Requires-Dist: black==24.8.0; extra == "dev"
Requires-Dist: flake8==7.2.0; extra == "dev"
Requires-Dist: mypy==1.14.0; extra == "dev"
Requires-Dist: pylint==3.2.7; extra == "dev"
Requires-Dist: pytest==8.3.5; extra == "dev"
Dynamic: license-file
# Linux Voice Assistant
Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
Supports announcments, start/continue conversation, and timers.
## Installation
Install system dependencies (`apt-get`):
* `libportaudio2` (for `sounddevice`)
* `build-essential` (for `pymicro-features`)
* `libmpv-dev` (for `python-mpv`)
Clone and install project:
``` sh
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
cd linux-voice-assistant
script/setup
```
## Running
Use `script/run` or `python3 -m linux_voice_assistant`
See `--help` for more options.
## Connecting to Home Assistant
1. In Home Assistant, go to "Settings" -> "Device & services"
2. Click the "Add integration" button
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
4. Enter the IP address of your voice satellite with port 6053
5. Click "Submit"
<!-- Links -->
[homeassistant]: https://www.home-assistant.io/
[esphome]: https://esphome.io/
@@ -0,0 +1,19 @@
LICENSE.md
README.md
pyproject.toml
setup.cfg
linux_voice_assistant/__init__.py
linux_voice_assistant/__main__.py
linux_voice_assistant/api_server.py
linux_voice_assistant/entity.py
linux_voice_assistant/event_bus.py
linux_voice_assistant/event_led.py
linux_voice_assistant/microwakeword.py
linux_voice_assistant/mpv_player.py
linux_voice_assistant/util.py
linux_voice_assistant.egg-info/PKG-INFO
linux_voice_assistant.egg-info/SOURCES.txt
linux_voice_assistant.egg-info/dependency_links.txt
linux_voice_assistant.egg-info/requires.txt
linux_voice_assistant.egg-info/top_level.txt
linux_voice_assistant.egg-info/zip-safe
@@ -0,0 +1 @@
@@ -0,0 +1,11 @@
aioesphomeapi==37.2.1
sounddevice<1
numpy<3,>=2
pymicro-features==1.0.0
[dev]
black==24.8.0
flake8==7.2.0
mypy==1.14.0
pylint==3.2.7
pytest==8.3.5
@@ -0,0 +1 @@
linux_voice_assistant
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, List, Optional
# pylint: disable=no-name-in-module
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
AuthenticationRequest, # <-- MODIFIED
AuthenticationResponse, # <-- MODIFIED
ConnectRequest,
ConnectResponse,
DisconnectRequest,
DisconnectResponse,
HelloRequest,
@@ -57,8 +57,8 @@ class APIServer(asyncio.Protocol):
)
return
if isinstance(msg_inst, AuthenticationRequest): # <-- MODIFIED
self.send_messages([AuthenticationResponse()]) # <-- MODIFIED
if isinstance(msg_inst, ConnectRequest):
self.send_messages([ConnectResponse()])
elif isinstance(msg_inst, DisconnectRequest):
self.send_messages([DisconnectResponse()])
_LOGGER.debug("Disconnect requested")
@@ -99,6 +99,7 @@ class APIServer(asyncio.Protocol):
while self._buffer_len >= 3:
self._pos = 0
# Read preamble, which should always 0x00
if (preamble := self._read_varuint()) != 0x00:
_LOGGER.error("Incorrect preamble: %s", preamble)
return
@@ -112,6 +113,7 @@ class APIServer(asyncio.Protocol):
return
if length == 0:
# Empty message (allowed)
self._remove_from_buffer()
self.process_packet(msg_type, b"")
continue
@@ -123,6 +125,7 @@ class APIServer(asyncio.Protocol):
self.process_packet(msg_type, packet_data)
def _read(self, length: int) -> bytes | None:
"""Read exactly length bytes from the buffer or None if all the bytes are not yet available."""
new_pos = self._pos + length
if self._buffer_len < new_pos:
return None
@@ -131,6 +134,8 @@ class APIServer(asyncio.Protocol):
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
cstr = self._buffer
# Important: we must keep the bounds check (self._buffer_len < new_pos)
# above to verify we never try to read past the end of the buffer
return cstr[original_pos:new_pos]
def connection_lost(self, exc):
@@ -138,6 +143,7 @@ class APIServer(asyncio.Protocol):
self._writelines = None
def _read_varuint(self) -> int:
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
if not self._buffer:
return -1
@@ -154,12 +160,21 @@ class APIServer(asyncio.Protocol):
return -1
def _remove_from_buffer(self) -> None:
"""Remove data from the buffer."""
end_of_frame_pos = self._pos
self._buffer_len -= end_of_frame_pos
if self._buffer_len == 0:
# This is the best case scenario, we can just set the buffer to None
# and don't have to copy the data. This is the most common case as well.
self._buffer = None
return
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
# This is the worst case scenario, we have to copy the data
# and can't just use the buffer directly. This should only happen
# when we read multiple frames at once because the event loop
# is blocked and we cannot pull the data out of the buffer fast enough.
cstr = self._buffer
# Important: we must use the explicit length for the slice
# since Cython will stop at any '\0' character if we don't
self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
-265
View File
@@ -1,265 +0,0 @@
"""Audio processing engine."""
import asyncio
import logging
import threading
import time
import warnings
from typing import Dict, List, Optional, Union
import numpy as np
import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# Suppress the "log(0)" warning from pymicro_wakeword
warnings.filterwarnings("ignore", category=RuntimeWarning, module="pymicro_wakeword")
class AudioEngine:
def __init__(self, state: ServerState, mic, block_size: int):
self.state = state
self.mic = mic
self.block_size = block_size
self._thread: Optional[threading.Thread] = None
# CRITICAL FIX: Add lock for thread-safe wake word reload
self._wake_words_lock = threading.Lock()
def start(self):
"""Starts the audio processing thread."""
self._thread = threading.Thread(
target=self._process_audio,
daemon=True,
name="AudioEngineThread",
)
self._thread.start()
def stop(self):
"""Stops the audio processing thread."""
self.state.shutdown = True
# Ensure thread wakes up if waiting
self.state.mic_muted_event.set()
if self._thread:
self._thread.join()
def _process_audio(self):
"""Main audio processing loop."""
_LOGGER.debug("Audio engine started.")
micro_features = MicroWakeWordFeatures()
oww_features: Optional[OpenWakeWordFeatures] = None
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_inputs: List[np.ndarray] = []
oww_inputs: List[np.ndarray] = []
has_oww = False
# Track last activation time per wake word (by id or phrase)
last_active_by_id: Dict[str, float] = {}
try:
while True:
# --- Handle Mute State ---
if not self.state.mic_muted_event.is_set():
_LOGGER.debug("Audio thread muted. Waiting...")
self.state.mic_muted_event.wait()
if self.state.shutdown:
return
# Unmuted: Clear buffers and prepare
_LOGGER.debug("Audio thread unmuted. Flushing...")
micro_features.reset()
if oww_features is not None:
oww_features.reset()
# Allow immediate activation (don't enforce refractory here)
last_active_by_id.clear()
# Flush hardware buffer
try:
with self.mic.recorder(
samplerate=16000,
channels=1,
blocksize=self.block_size,
) as mic_in:
mic_in.flush()
except Exception as e:
_LOGGER.warning("Error flushing mic: %s", e)
if self.state.shutdown:
return
# --- Main Recording Loop ---
_LOGGER.debug("Opening audio input device: %s", self.mic.name)
with self.mic.recorder(
samplerate=16000, channels=1, blocksize=self.block_size
) as mic_in:
while self.state.mic_muted_event.is_set():
if self.state.shutdown:
return
audio_chunk_array = mic_in.record(self.block_size).reshape(-1)
audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2")
.tobytes()
)
if self.state.satellite is None:
time.sleep(0.01)
continue
# CRITICAL FIX: Protect wake word reload with lock
with self._wake_words_lock:
# Update active wake words if changed
if (not wake_words) or (self.state.wake_words_changed):
self.state.wake_words_changed = False
wake_words = [
ww
for ww in self.state.wake_words.values()
if ww.id in self.state.active_wake_words
]
has_oww = any(
isinstance(ww, OpenWakeWord) for ww in wake_words
)
if has_oww and (oww_features is None):
_LOGGER.debug(
"Initializing OpenWakeWord features..."
)
oww_features = OpenWakeWordFeatures.from_builtin()
_LOGGER.debug(
"Rebuilt wake_words list: %s (has_oww=%s, active_ids=%s)",
[
getattr(ww, "wake_word", getattr(ww, "id", "?"))
for ww in wake_words
],
has_oww,
self.state.active_wake_words,
)
try:
# Feature extraction
micro_inputs.clear()
micro_inputs.extend(
micro_features.process_streaming(audio_chunk)
)
if has_oww and oww_features:
oww_inputs.clear()
oww_inputs.extend(
oww_features.process_streaming(audio_chunk)
)
# Wake word detection
for wake_word in wake_words:
activated = False
if isinstance(wake_word, MicroWakeWord):
if any(
wake_word.process_streaming(mi)
for mi in micro_inputs
):
activated = True
elif isinstance(wake_word, OpenWakeWord):
if any(
p > 0.5
for oi in oww_inputs
for p in wake_word.process_streaming(oi)
):
activated = True
if activated:
wake_phrase = getattr(
wake_word, "wake_word", "wake word"
)
wake_id_attr = getattr(wake_word, "id", None)
# Use id if available, otherwise phrase string as key
id_key = wake_id_attr or wake_phrase
_LOGGER.debug(
"Wake word ACTIVATED: %s (id=%s)",
wake_phrase,
wake_id_attr,
)
now = time.monotonic()
last_active = last_active_by_id.get(id_key)
# If refractory_seconds <= 0, disable gating
refractory = max(self.state.refractory_seconds, 0.0)
if (last_active is None) or (
(now - last_active) > refractory
):
_LOGGER.debug(
"Wake word ACCEPTED (phrase=%s); "
"triggering wakeup (Δt=%.2f, refractory=%.2f)",
wake_phrase,
0.0
if last_active is None
else now - last_active,
refractory,
)
last_active_by_id[id_key] = now
self.state.loop.call_soon_threadsafe(
self.state.satellite.wakeup, wake_word
)
else:
_LOGGER.debug(
"Wake word REJECTED by refractory period "
"(phrase=%s, Δt=%.2f < %.2f)",
wake_phrase,
now - last_active,
refractory,
)
# Stop word detection
stopped = False
for micro_input in micro_inputs:
if self.state.stop_word.process_streaming(micro_input):
stopped = True
if stopped:
_LOGGER.debug(
"Stop wake word ACTIVATED (id=%s, active_wake_words=%s)",
getattr(self.state.stop_word, "id", None),
self.state.active_wake_words,
)
if (
stopped
and self.state.stop_word.id
in self.state.active_wake_words
):
_LOGGER.debug(
"Stop wake word ACCEPTED; calling satellite.stop()"
)
self.state.loop.call_soon_threadsafe(
self.state.satellite.stop
)
elif stopped:
_LOGGER.debug(
"Stop wake word TRIGGERED but IGNORED because "
"id=%s not in active_wake_words",
getattr(self.state.stop_word, "id", None),
)
# Pass audio to satellite (for voice streaming)
self.state.loop.call_soon_threadsafe(
self.state.satellite.handle_audio, audio_chunk
)
except Exception:
_LOGGER.exception("Unexpected error handling audio")
except Exception as e:
_LOGGER.critical("A soundcard error occurred: %s", e)
self.state.loop.call_soon_threadsafe(self.state.loop.stop)
-192
View File
@@ -1,192 +0,0 @@
"""System output volume helpers.
LVA historically only adjusted mpv's per-player volume. On PipeWire/PulseAudio
setups where the default sink volume starts low (e.g. 40%), HA shows LVA at
100% while the actual OS sink remains capped.
This module keeps the OS output volume aligned with LVA's persisted
preferences.volume_level (0.0–1.0).
We try backends in this order:
1) PipeWire: wpctl
2) PulseAudio (incl. pipewire-pulse): pactl
3) ALSA: amixer
"""
from __future__ import annotations
import asyncio
import logging
import math
import shutil
import subprocess
from typing import Optional
_LOGGER = logging.getLogger(__name__)
def _clamp01(v: float) -> float:
try:
v = float(v)
except Exception:
return 1.0
if math.isnan(v) or math.isinf(v):
return 1.0
return max(0.0, min(1.0, v))
def _pactl_sink_from_output_device(output_device: Optional[str]) -> str:
"""Best-effort mapping of mpv audio-device -> pactl sink name.
LVA commonly uses:
- "pipewire/<pactl sink name>"
- "pulse/<pactl sink name>"
- "alsa_output...." (already a sink name)
Fallback is @DEFAULT_SINK@.
"""
if not output_device:
return "@DEFAULT_SINK@"
dev = output_device.strip()
for prefix in ("pipewire/", "pulse/"):
if dev.startswith(prefix) and len(dev) > len(prefix):
return dev[len(prefix) :]
if dev.startswith("alsa_output."):
return dev
# Some users pass the raw sink name without a prefix.
if "alsa_output." in dev:
return dev
return "@DEFAULT_SINK@"
def _run_cmd(cmd: list[str], timeout_s: float = 2.0) -> tuple[bool, str]:
try:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout_s,
)
ok = proc.returncode == 0
out = (proc.stdout or "").strip()
return ok, out
except FileNotFoundError:
return False, f"command not found: {cmd[0]}"
except subprocess.TimeoutExpired:
return False, "timeout"
except Exception as e:
return False, repr(e)
def set_output_volume(
volume_0_1: float,
output_device: Optional[str] = None,
max_volume_percent: int = 100,
logger: logging.Logger = _LOGGER,
) -> bool:
"""Set OS output volume to match LVA volume (0.0–1.0).
`max_volume_percent` lets you map LVA's 100% to something above 100% on the
OS sink (e.g. 150 for 1.5x), which can be useful on devices that need
boosted output.
Returns True if any backend succeeded.
"""
vol = _clamp01(volume_0_1)
try:
max_pct = int(max_volume_percent)
except Exception:
max_pct = 100
# Avoid weird values; allow >100 for boosted sinks.
max_pct = max(0, min(200, max_pct))
sink_scalar = vol * (max_pct / 100.0)
# --- 1) PipeWire: wpctl -------------------------------------------------
if shutil.which("wpctl"):
# wpctl accepts @DEFAULT_AUDIO_SINK@ and a linear factor (e.g. 0.40)
ok, out = _run_cmd(
[
"wpctl",
"set-volume",
"@DEFAULT_AUDIO_SINK@",
f"{sink_scalar:.3f}",
]
)
if ok:
logger.debug(
"Set PipeWire sink volume via wpctl: scalar=%.3f (vol=%.3f max_pct=%d)",
sink_scalar,
vol,
max_pct,
)
return True
logger.debug("wpctl set-volume failed: %s", out)
# --- 2) PulseAudio: pactl ----------------------------------------------
if shutil.which("pactl"):
sink = _pactl_sink_from_output_device(output_device)
pct = int(round(sink_scalar * 100.0))
ok, out = _run_cmd(["pactl", "set-sink-volume", sink, f"{pct}%"])
if ok:
logger.debug(
"Set Pulse sink volume via pactl: sink=%s pct=%d (vol=%.3f max_pct=%d)",
sink,
pct,
vol,
max_pct,
)
return True
logger.debug("pactl set-sink-volume failed: %s", out)
# --- 3) ALSA: amixer ----------------------------------------------------
if shutil.which("amixer"):
pct = int(round(sink_scalar * 100.0))
# Common mixer controls across SBC images.
for control in ("Master", "PCM", "Speaker", "Headphone"):
ok, out = _run_cmd(["amixer", "-q", "sset", control, f"{pct}%"])
if ok:
logger.debug(
"Set ALSA volume via amixer: control=%s pct=%d (vol=%.3f max_pct=%d)",
control,
pct,
vol,
max_pct,
)
return True
logger.debug("amixer sset %s failed: %s", control, out)
return False
async def ensure_output_volume(
volume: float,
output_device: Optional[str] = None,
max_volume_percent: int = 100,
attempts: int = 10,
delay_seconds: float = 0.5,
logger: logging.Logger = _LOGGER,
) -> bool:
"""Retry output volume sync during startup.
Useful at boot when PipeWire/Pulse/ALSA might not be fully ready yet.
"""
for i in range(1, max(1, attempts) + 1):
ok = await asyncio.to_thread(
set_output_volume,
volume,
output_device,
max_volume_percent,
logger,
)
if ok:
return True
if i < attempts:
await asyncio.sleep(delay_seconds)
return False
-240
View File
@@ -1,240 +0,0 @@
"""Hardware button controller (e.g. ReSpeaker 2-Mic HAT momentary switch).
This controller:
- Short press:
* If audio is playing (TTS or music): stop playback (like Stop wake word).
* Otherwise: start a new conversation (like a wake word).
- Long press:
* Toggle microphone mute (via the existing set_mic_mute event).
Implementation note:
- Uses a simple polling loop with RPi.GPIO instead of kernel edge detection.
- On non-Raspberry Pi hosts (or where RPi.GPIO is unusable), the controller
cleanly disables itself and logs an info message.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from .event_bus import EventBus
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# Try to import RPi.GPIO, but gracefully handle *any* failure.
# On non-RPi hosts, RPi.GPIO may be installed but will raise RuntimeError
# at import time ("This module can only be run on a Raspberry Pi!").
try: # pragma: no cover - behavior depends on host platform
import RPi.GPIO as GPIO # type: ignore[import]
except Exception: # ImportError, RuntimeError, etc.
GPIO = None # type: ignore[assignment]
@dataclass
class ButtonRuntimeConfig:
"""Runtime-safe config wrapper for the hardware button."""
enabled: bool
pin: int
long_press_seconds: float
# CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz)
# 80% reduction in CPU usage for button monitoring
poll_interval_seconds: float = 0.05 # 20 Hz polling
class ButtonController:
"""
Hardware button handler for the ReSpeaker 2-Mic HAT (or any GPIO button).
This class:
- Uses a background polling thread to watch the GPIO level.
- Interprets level transitions as press/release.
- Schedules actions on the asyncio loop via loop.call_soon_threadsafe().
- Uses the EventBus to toggle mic mute.
On non-RPi hosts (or when RPi.GPIO isn't usable), it is effectively a no-op.
"""
def __init__(
self,
loop,
event_bus: EventBus,
state: ServerState,
config,
) -> None:
"""
:param loop: asyncio event loop (state.loop).
:param event_bus: Global EventBus.
:param state: Global ServerState.
:param config: Config.button (ButtonConfig-like object).
"""
self.loop = loop
self.event_bus = event_bus
self.state = state
# CRITICAL FIX: Default poll_interval_seconds to 0.05 instead of 0.01
default_poll_interval = 0.05
self._cfg = ButtonRuntimeConfig(
enabled=getattr(config, "enabled", False),
pin=getattr(config, "pin", 17),
long_press_seconds=float(
getattr(config, "long_press_seconds", 1.0)
),
poll_interval_seconds=float(
getattr(config, "poll_interval_seconds", default_poll_interval)
),
)
self._thread: threading.Thread | None = None
self._press_time: float | None = None
self._last_level: int | None = None
# If disabled in config, do nothing.
if not self._cfg.enabled:
_LOGGER.info(
"ButtonController disabled in config; not initializing GPIO"
)
return
# If RPi.GPIO is unavailable or not usable on this host, also do nothing.
if GPIO is None:
_LOGGER.info(
"RPi.GPIO not available or not usable on this host; "
"hardware button support disabled"
)
return
try:
GPIO.setmode(GPIO.BCM)
# Assume button wired as active-low with internal pull-up (common for HATs)
GPIO.setup(self._cfg.pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
self._last_level = GPIO.input(self._cfg.pin)
_LOGGER.info(
"ButtonController initialized on GPIO pin %s "
"(long_press_seconds=%.2f, poll_interval=%.3fs)",
self._cfg.pin,
self._cfg.long_press_seconds,
self._cfg.poll_interval_seconds,
)
except Exception:
_LOGGER.exception(
"Failed to configure GPIO pin %s for button; "
"hardware button support disabled",
self._cfg.pin,
)
return
# Start polling thread
self._thread = threading.Thread(
target=self._poll_loop,
name="ButtonControllerThread",
daemon=True,
)
self._thread.start()
# -------------------------------------------------------------------------
# Polling loop
# -------------------------------------------------------------------------
def _poll_loop(self) -> None:
"""Background loop to poll the GPIO level and detect presses."""
_LOGGER.debug("ButtonController polling thread started")
while not getattr(self.state, "shutdown", False):
try:
level = GPIO.input(self._cfg.pin) # type: ignore[call-arg]
except Exception:
_LOGGER.exception("Error reading GPIO pin %s", self._cfg.pin)
break
if self._last_level is None:
self._last_level = level
# Active-low: 0 = pressed, 1 = released
if level != self._last_level:
if level == 0:
self._on_pressed()
else:
self._on_released()
self._last_level = level
time.sleep(self._cfg.poll_interval_seconds)
_LOGGER.debug("ButtonController polling thread exiting")
# Do NOT call GPIO.cleanup() here; other components may use GPIO as well.
# -------------------------------------------------------------------------
# "Interrupt-like" handlers called from polling thread
# -------------------------------------------------------------------------
def _on_pressed(self) -> None:
self._press_time = time.monotonic()
_LOGGER.debug("Button pressed (GPIO %s low)", self._cfg.pin)
def _on_released(self) -> None:
if self._press_time is None:
return
duration = time.monotonic() - self._press_time
self._press_time = None
_LOGGER.debug("Button released after %.3f seconds", duration)
if duration >= self._cfg.long_press_seconds:
self._handle_long_press()
else:
self._handle_short_press()
# -------------------------------------------------------------------------
# High-level behaviors
# -------------------------------------------------------------------------
def _handle_short_press(self) -> None:
"""Short press: wake / stop behavior."""
# Snapshot playing state quickly
tts_playing = bool(getattr(self.state.tts_player, "is_playing", False))
music_playing = bool(getattr(self.state.music_player, "is_playing", False))
if tts_playing or music_playing:
_LOGGER.debug(
"Button short press while audio playing "
"(tts=%s, music=%s) -> stopping playback",
tts_playing,
music_playing,
)
# Stop TTS/timer via the same logic as the Stop wake word.
if self.state.satellite is not None:
self.loop.call_soon_threadsafe(self.state.satellite.stop)
# Also stop any music playback controlled by the LVA.
self.loop.call_soon_threadsafe(self.state.music_player.stop)
else:
_LOGGER.debug(
"Button short press with no audio playing -> manual wakeup"
)
if self.state.satellite is not None:
# Use a dedicated manual wakeup to avoid faking a wake-word object.
self.loop.call_soon_threadsafe(
self.state.satellite.manual_wakeup, "button"
)
def _handle_long_press(self) -> None:
"""Long press: toggle mic mute."""
new_state = not self.state.mic_muted
_LOGGER.debug(
"Button long press: toggling mic mute -> %s", new_state
)
# Use the standard set_mic_mute event so MicMuteHandler can:
# - update ServerState.mic_muted
# - control the mic_muted_event
# - publish MQTT mute state
self.loop.call_soon_threadsafe(
self.event_bus.publish,
"set_mic_mute",
{"state": new_state},
)
-5
View File
@@ -1,5 +0,0 @@
{
"app": {
"name": "Linux Voice Assistant"
}
}
-144
View File
@@ -1,144 +0,0 @@
{
// ---------------------------------------------------------------------------
// Required
// ---------------------------------------------------------------------------
"app": {
// Required
"name": "My Linux Voice Assistant",
// Optional
"wakeup_sound": "sounds/wake_word_triggered.flac",
"timer_finished_sound": "sounds/timer_finished.flac",
"preferences_file": "preferences.json",
"debug": false
},
// ---------------------------------------------------------------------------
// Optional: Audio I/O + Output Volume Sync
// ---------------------------------------------------------------------------
"audio": {
// Optional:
// - null/omit => default microphone
// - string name => exact device name (soundcard backend)
// - string number => index into soundcard device list (e.g. "0", "1", ...)
"input_device": null,
// Optional: audio capture block size
"input_block_size": 1024,
// Optional:
// - null/omit => mpv chooses default output
// - string => mpv audio-device (e.g. "pulse/alsa_output....")
"output_device": null,
// Optional (NEW): Sync the host OS output device volume at startup to match
// the volume stored in preferences.json (LVA MediaPlayer volume).
//
// When enabled, LVA will attempt (in order): wpctl -> pactl -> amixer.
// This helps when PipeWire/Pulse/ALSA boots with a low default volume.
//
// Default: false
"volume_sync": false,
// Optional (NEW): Maximum output volume percent for the underlying sink.
//
// Many PipeWire/PulseAudio sinks support >100% (e.g. 150%) "amplified" volume.
// This sets the ceiling used when mapping LVA volume (0.0–1.0) to the sink.
//
// Example:
// - max_volume_percent: 150
// - LVA volume 1.0 => sink set to 150%
// - LVA volume 0.5 => sink set to 75%
//
// Default: 100
"max_volume_percent": 100
},
// ---------------------------------------------------------------------------
// Optional: Wake word config
// ---------------------------------------------------------------------------
"wake_word": {
// Optional: if omitted/empty, code falls back to defaults:
// ["wakewords", "wakewords/openWakeWord"]
"directories": [],
// Optional: default wake word model id
"model": "okay_nabu",
// Optional: stop model id (micro wake word)
"stop_model": "stop",
// Optional: debounce/refractory window after wake word triggers
"refractory_seconds": 2.0,
// Optional: directory for downloaded models (relative to repo)
"download_dir": "local"
},
// ---------------------------------------------------------------------------
// Optional: ESPHome protocol server settings (the LVA exposes an ESPHome API)
// ---------------------------------------------------------------------------
"esphome": {
"host": "0.0.0.0",
"port": 6053
},
// ---------------------------------------------------------------------------
// Optional: LEDs
// ---------------------------------------------------------------------------
"led": {
// Optional: if false, LED controller runs in no-op mode
"enabled": true,
// "dotstar" | "neopixel" | "xvf3800"
"led_type": "dotstar",
// "spi" | "gpio" | "usb"
// - dotstar: "spi" or "gpio"
// - neopixel: "spi" or "gpio"
// - xvf3800: "usb" (current intended support)
"interface": "spi",
// GPIO pins (BCM-ish via adafruit "board.Dxx" mapping) — used when interface="gpio"
// For DotStar GPIO: both clock_pin + data_pin are used
// For NeoPixel GPIO: only data_pin is used (clock_pin ignored)
// For xvf3800: unused and ignored
"clock_pin": 13,
"data_pin": 12,
// Default number of LEDs (may be overridden by preferences.json after first run)
"num_leds": 3
},
// ---------------------------------------------------------------------------
// Optional: MQTT (enabled automatically when host is set)
// ---------------------------------------------------------------------------
"mqtt": {
// If set, mqtt.enabled becomes true automatically
"host": null,
"port": 1883,
"username": null,
"password": null
},
// ---------------------------------------------------------------------------
// Optional: Button controller
// ---------------------------------------------------------------------------
"button": {
// Master enable
"enabled": false,
// "gpio" | "xvf3800"
// - gpio: short/long press logic (pin + long_press_seconds)
// - xvf3800: uses the XVF3800 built-in mute button as a pure mute toggle
"mode": "gpio",
// gpio mode only
"pin": 17,
"long_press_seconds": 1.0,
// Polling interval (used by both gpio + xvf3800 button controllers)
"poll_interval_seconds": 0.01
}
}
-180
View File
@@ -1,180 +0,0 @@
"""Configuration models for the application."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
_LOGGER = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Configuration Dataclasses
# -----------------------------------------------------------------------------
@dataclass
class AppConfig:
"""General application settings."""
name: str
wakeup_sound: str = "sounds/wake_word_triggered.flac"
timer_finished_sound: str = "sounds/timer_finished.flac"
preferences_file: str = "preferences.json"
debug: bool = False
@dataclass
class AudioConfig:
"""Settings for audio input and output."""
input_device: Optional[str] = None
input_block_size: int = 1024
output_device: Optional[str] = None
# If True, LVA will attempt to set the OS sink volume to match the persisted
# volume in preferences.json on startup (PipeWire/PulseAudio/ALSA best-effort).
# Default is False to avoid surprising changes on systems where users manage
# volume externally.
volume_sync: bool = False
# Maximum sink volume to map to LVA's 100% volume.
#
# Example: if max_volume_percent=150 then when LVA's media player reports
# 100% (preferences.volume_level == 1.0), the underlying sink will be set
# to 150% (or 1.5 for backends that use scalar volume).
#
# This is useful for devices that need >100% gain on PipeWire/Pulse/ALSA.
max_volume_percent: int = 100
@dataclass
class WakeWordConfig:
"""Settings for wake word detection."""
directories: List[str] = field(default_factory=list)
model: str = "okay_nabu"
stop_model: str = "stop"
refractory_seconds: float = 2.0
download_dir: str = "local"
@dataclass
class ESPHomeConfig:
"""Settings for the ESPHome API server."""
host: str = "0.0.0.0"
port: int = 6053
@dataclass
class LedConfig:
"""Settings for LEDs."""
enabled: bool = True
# Supported values include:
# - "dotstar" / "neopixel" for Pi-attached LED strips
# - "xvf3800" for the ReSpeaker XVF3800 USB LED ring backend
led_type: str = "dotstar"
# For dotstar/neopixel: "spi" or "gpio"
# For xvf3800: "usb"
interface: str = "spi"
# GPIO pin numbers used when interface="gpio"
clock_pin: int = 13
data_pin: int = 12
# Note: overridden by 'preferences.json' if it exists
num_leds: int = 3
@dataclass
class MqttConfig:
"""Settings for the MQTT client."""
enabled: bool = False
host: Optional[str] = None
port: int = 1883
username: Optional[str] = None
password: Optional[str] = None
@dataclass
class ButtonConfig:
"""Settings for a hardware momentary button."""
# Overall enable/disable (default: off)
enabled: bool = False
# mode:
# - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT)
# - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800
mode: str = "gpio"
# BCM GPIO pin number for the button input (gpio mode only).
pin: int = 17
# Press duration (in seconds) to be considered a "long press" (gpio mode).
long_press_seconds: float = 1.0
# Poll interval used by both GPIO and XVF3800 controllers.
poll_interval_seconds: float = 0.01
@dataclass
class Config:
"""Main configuration object."""
app: AppConfig
audio: AudioConfig = field(default_factory=AudioConfig)
wake_word: WakeWordConfig = field(default_factory=WakeWordConfig)
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
led: LedConfig = field(default_factory=LedConfig)
mqtt: MqttConfig = field(default_factory=MqttConfig)
button: ButtonConfig = field(default_factory=ButtonConfig)
# -----------------------------------------------------------------------------
# Helper Function
# -----------------------------------------------------------------------------
def load_config_from_json(config_path: Path) -> Config:
"""Loads configuration from a JSON file and populates dataclasses."""
try:
with open(config_path, "r", encoding="utf-8") as f:
raw_data = json.load(f)
except FileNotFoundError:
_LOGGER.critical("Configuration file not found at: %s", config_path)
raise
except json.JSONDecodeError as e:
_LOGGER.critical("Error parsing configuration file: %s", e)
raise
if "app" not in raw_data:
raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
app_config = AppConfig(**raw_data.get("app", {}))
audio_config = AudioConfig(**raw_data.get("audio", {}))
wake_word_config = WakeWordConfig(**raw_data.get("wake_word", {}))
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
led_config = LedConfig(**raw_data.get("led", {}))
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
button_config = ButtonConfig(**raw_data.get("button", {}))
# Back-compat: allow top-level "volume_sync" (preferred location is audio.volume_sync)
if "volume_sync" in raw_data and "volume_sync" not in raw_data.get("audio", {}):
try:
audio_config.volume_sync = bool(raw_data.get("volume_sync"))
except Exception:
# If it's something strange, just leave default.
pass
# Set MQTT 'enabled' flag
if mqtt_config.host:
mqtt_config.enabled = True
return Config(
app=app_config,
audio=audio_config,
wake_word=wake_word_config,
esphome=esphome_config,
led=led_config,
mqtt=mqtt_config,
button=button_config,
)
+16 -33
View File
@@ -1,6 +1,6 @@
from abc import abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Callable, List, Optional, Union
from typing import Callable, List, Optional, Union
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
@@ -17,38 +17,36 @@ from .api_server import APIServer
from .mpv_player import MpvMediaPlayer
from .util import call_all
if TYPE_CHECKING:
from .models import ServerState
class ESPHomeEntity:
def __init__(self, server: APIServer, state: "ServerState") -> None:
def __init__(self, server: APIServer) -> None:
self.server = server
self.state = state
@abstractmethod
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
pass
# -----------------------------------------------------------------------------
class MediaPlayerEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
state: "ServerState",
key: int,
name: str,
object_id: str,
music_player: MpvMediaPlayer,
announce_player: MpvMediaPlayer,
) -> None:
super().__init__(server, state)
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.state_enum = MediaPlayerState.IDLE
self.volume = state.preferences.volume_level # Initialize with saved volume
self.state = MediaPlayerState.IDLE
self.volume = 1.0
self.muted = False
self.music_player = music_player
self.announce_player = announce_player
@@ -99,33 +97,19 @@ class MediaPlayerEntity(ESPHomeEntity):
if msg.has_media_url:
announcement = msg.has_announcement and msg.announcement
yield from self.play(msg.media_url, announcement=announcement)
elif msg.has_command:
if msg.command == MediaPlayerCommand.PAUSE:
self.music_player.pause()
yield self._update_state(MediaPlayerState.PAUSED)
elif msg.command == MediaPlayerCommand.PLAY:
self.music_player.resume()
yield self._update_state(MediaPlayerState.PLAYING)
elif msg.command == MediaPlayerCommand.STOP:
self.music_player.stop()
yield self._update_state(MediaPlayerState.IDLE)
if msg.has_volume:
# This block is called when the volume slider changes in HA
self.volume = msg.volume # HA sends volume as 0.0-1.0
volume_int = int(self.volume * 100)
self.music_player.set_volume(volume_int)
self.announce_player.set_volume(volume_int)
# Save the new volume level to preferences
self.state.preferences.volume_level = self.volume
self.state.save_preferences()
yield self._update_state(self.state_enum)
elif msg.has_volume:
volume = int(msg.volume * 100)
self.music_player.set_volume(volume)
self.announce_player.set_volume(volume)
self.volume = msg.volume
yield self._update_state(self.state)
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesMediaPlayerResponse(
object_id=self.object_id,
@@ -133,18 +117,17 @@ class MediaPlayerEntity(ESPHomeEntity):
name=self.name,
supports_pause=True,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
yield self._get_state_message()
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
self.state_enum = new_state
self.state = new_state
return self._get_state_message()
def _get_state_message(self) -> MediaPlayerStateResponse:
return MediaPlayerStateResponse(
key=self.key,
state=self.state_enum,
state=self.state,
volume=self.volume,
muted=self.muted,
)
+24 -21
View File
@@ -1,5 +1,3 @@
"""A simple synchronous publish/subscribe event bus."""
import logging
from typing import Any, Callable, Dict, List, Optional
@@ -10,34 +8,36 @@ class EventBus:
"""A simple synchronous publish/subscribe event bus."""
def __init__(self):
# A dictionary to hold listeners for specific string topics
self.topics: Dict[str, List[Callable[[Any], None]]] = {}
def subscribe(self, topic: str, listener: Callable[[Any], None]) -> None:
"""Subscribes a listener to a topic."""
"""
Subscribes a listener to a topic.
"""
# _LOGGER.debug(f'EventBus subscribe {topic}')
if topic not in self.topics:
self.topics[topic] = []
self.topics[topic].append(listener)
_LOGGER.debug(f"Subscribed listener to topic '{topic}'")
def publish(self, topic: str, data: Optional[Dict[str, Any]] = None) -> None:
"""Publishes an event to all subscribed listeners."""
if data is None:
data = {}
def publish(self, topic: str, data: [dict, None]) -> None:
"""
Publishes an event to all subscribed listeners.
"""
# _LOGGER.debug(f'EventBus publish {topic}')
data['__topic'] = topic
listeners = self.topics.get(topic, [])
_LOGGER.debug(f"Publishing event to {len(listeners)} listeners on topic '{topic}'")
for listener in listeners:
try:
listener(data)
except Exception:
_LOGGER.exception("Error in event listener for topic %s", topic)
listener(data)
# -----------------------------------------------------------------------------
# Client helpers for subscriptions
# -----------------------------------------------------------------------------
# The decorator to mark methods for subscription.
def subscribe(func: Callable) -> Callable:
"""Decorator to mark a method for event bus subscription."""
func._event_bus_subscribe = True
@@ -47,12 +47,14 @@ class EventHandler:
"""
A base class for components that subscribe to events.
Subclasses must call `self._subscribe_all_methods()` in their __init__.
Subclasses should define event handlers as methods decorated with `@subscribe`.
The method name will automatically be used as the event topic.
"""
def __init__(self, event_bus: EventBus):
self.event_bus = event_bus
# Note: Subclasses must call self._subscribe_all_methods()
# after their own __init__ is complete.
def __init__(self, state: Any):
self.state = state
self._subscribe_all_methods()
_LOGGER.debug(f"EventHandler {self.__class__.__name__} has subscribed to all decorated methods.")
def _subscribe_all_methods(self):
"""Finds and subscribes all methods decorated with @subscribe."""
@@ -61,4 +63,5 @@ class EventHandler:
if hasattr(method, '_event_bus_subscribe'):
# The topic is the name of the method itself.
self.event_bus.subscribe(method_name, method)
self.state.event_bus.subscribe(method_name, method)
_LOGGER.debug(f"Subscribed method '{method_name}' to topic '{method_name}'")
+262
View File
@@ -0,0 +1,262 @@
import logging
from typing import Any, Callable
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
"""Controls the LEDs on the ReSpeaker 2mic HAT."""
from math import ceil
from typing import Tuple
import time
import asyncio
import gpiozero
import spidev
NUM_LEDS = 3
LEDS_GPIO = 12
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
# -----------------------------------------------------------------------------
class APA102:
"""
Driver for APA102 LEDS (aka "DotStar").
(c) Martin Erzberger 2016-2017
"""
# Constants
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
def __init__(
self,
num_led,
global_brightness,
loop: asyncio.AbstractEventLoop,
order="rgb",
bus=0,
device=1,
max_speed_hz=8000000,
):
self.num_led = num_led # The number of LEDs in the Strip
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
# Limit the brightness to the maximum if it's set higher
if global_brightness > self.MAX_BRIGHTNESS:
self.global_brightness = self.MAX_BRIGHTNESS
else:
self.global_brightness = global_brightness
print("LED brightness:", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task = None
self.loop = loop
def clock_start_frame(self):
"""Sends a start frame to the LED strip.
This method clocks out a start frame, telling the receiving LED
that it must update its own color now.
"""
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
"""Sends an end frame to the LED strip.
As explained above, dummy data must be sent after the last real colour
information so that all of the data can reach its destination down the line.
The delay is not as bad as with the human example above.
It is only 1/2 bit per LED. This is because the SPI clock line
needs to be inverted.
Say a bit is ready on the SPI data line. The sender communicates
this by toggling the clock line. The bit is read by the LED
and immediately forwarded to the output data line. When the clock goes
down again on the input side, the LED will toggle the clock up
on the output to tell the next LED that the bit is ready.
After one LED the clock is inverted, and after two LEDs it is in sync
again, but one cycle behind. Therefore, for every two LEDs, one bit
of delay gets accumulated. For 300 LEDs, 150 additional bits must be fed to
the input of LED one so that the data can reach the last LED.
Ultimately, we need to send additional numLEDs/2 arbitrary data bits,
in order to trigger numLEDs/2 additional clock changes. This driver
sends zeroes, which has the benefit of getting LED one partially or
fully ready for the next update to the strip. An optimized version
of the driver could omit the "clockStartFrame" method if enough zeroes have
been sent as part of "clockEndFrame".
"""
self.spi.xfer2([0xFF] * 4)
# Round up num_led/2 bits (or num_led/16 bytes)
# for _ in range((self.num_led + 15) // 16):
# self.spi.xfer2([0x00])
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer. Colors are passed individually.
If brightness is not set the global brightness setting is used.
"""
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
# Calculate pixel brightness as a percentage of the
# defined global_brightness. Round up to nearest integer
# as we expect some brightness unless set to 0
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
# LED startframe is three "1" bits, followed by 5 brightness bits
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer.
Colors are passed combined (3 bytes concatenated)
If brightness is not set the global brightness setting is used.
"""
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent or self.global_brightness,
)
def rotate(self, positions=1):
"""Rotate the LEDs by the specified number of positions.
Treating the internal LED array as a circular buffer, rotate it by
the specified number of positions. The number could be negative,
which means rotating in the opposite direction.
"""
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
"""Sends the content of the pixel buffer to the strip.
Todo: More than 1024 LEDs requires more than one xfer operation.
"""
self.clock_start_frame()
# xfer2 kills the list, unfortunately. So it must be copied first
# SPI takes up to 4096 Integers. So we are fine for up to 1024 LEDs.
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
"""Release the SPI device; Call this method at the end"""
self.spi.close() # Close SPI port
def run_action(self, action_mentod_name: str, *args: Any) -> None:
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(getattr(self, action_mentod_name)(*args), self.loop)
async def color(self, rgb: Tuple[int, int, int], brightness = None) -> None:
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def blink(self, color, count=10000):
for _ in range(count):
await self.color(color)
await asyncio.sleep(0.3)
await self.color(_OFF)
await asyncio.sleep(0.3)
async def pulse(self, color: Tuple[int, int, int], speed: float = 0.009):
"""Asynchronously pulses the LEDs from off to full brightness and back."""
# Fade in
while(1):
for brightness in range(1, self.global_brightness+1):
await self.color(color, brightness)
await asyncio.sleep(speed)
# Fade out
for brightness in range(self.global_brightness+1, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
@subscribe
def ready(self, data: dict):
_LOGGER.debug('ready LED green blink')
self.leds.run_action("blink", _GREEN, 3)
@subscribe
def voice_wakeword(self, data: dict):
self.leds.run_action("pulse", _BLUE)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
self.leds.run_action("pulse", _YELLOW)
@subscribe
def voice_play_tts(self, data: dict):
self.leds.run_action("pulse", _GREEN)
# This event fires long before the TTS is done speaking
# @subscribe
# def voice_VOICE_ASSISTANT_RUN_END(self, data: dict):
# self.leds.run_action("color", _OFF, 0)
@subscribe
def voice__tts_finished(self, data: dict):
self.leds.run_action("color", _OFF, 0)
+128
View File
@@ -0,0 +1,128 @@
# MODIFIED event_led.py
import logging
import asyncio
from typing import Tuple, Any
# --- MODIFICATION: Import new libraries and remove unused ones ---
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- MODIFICATION: Make NUM_LEDS a configurable variable at the top ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 2
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# Define some color constants
_OFF = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
# --- MODIFICATION: The old spidev-based APA102 class has been removed entirely ---
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task = None
# --- MODIFICATION: Initialize LEDs using Adafruit DotStar and GPIO pins ---
# The ReSpeaker 2-Mic HAT Grove connector uses GPIO13 (Clock) and GPIO12 (Data)
# If your wiring is different, change board.D13 and board.D12 accordingly.
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False # We will call show() manually
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
_LOGGER.error("Please ensure you have run 'pip install adafruit-circuitpython-dotstar' and that SPI is enabled if prompted.")
self.leds = None # Prevent crashes if initialization fails
def run_action(self, action_method_name: str, *args: Any) -> None:
if not self.leds:
_LOGGER.warning("LEDs not initialized, skipping action.")
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(getattr(self, action_method_name)(*args), self.loop)
# --- MODIFICATION: Rewritten 'color' method to work with the new library ---
async def color(self, rgb: Tuple[int, int, int], brightness_override: float = None) -> None:
if brightness_override is not None:
self.leds.brightness = brightness_override
else:
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
# --- MODIFICATION: Rewritten 'blink' method to be simpler ---
async def blink(self, rgb: Tuple[int, int, int], count=10000):
try:
for _ in range(count):
self.leds.fill(rgb)
self.leds.show()
await asyncio.sleep(0.3)
self.leds.fill(_OFF)
self.leds.show()
await asyncio.sleep(0.3)
except asyncio.CancelledError:
pass # Task was cancelled, which is normal
# --- MODIFICATION: Rewritten 'pulse' method for the new library ---
async def pulse(self, color: Tuple[int, int, int], speed: float = 0.015):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = i / 100.0
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = i / 100.0
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
except asyncio.CancelledError:
self.leds.brightness = GLOBAL_BRIGHTNESS # Restore brightness on exit
# --- Event subscriptions remain the same ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug('ready LED green blink')
# Blink 3 times and then turn off
self.run_action("blink", _GREEN, 3)
@subscribe
def voice_wakeword(self, data: dict):
self.run_action("pulse", _BLUE)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
self.run_action("pulse", _YELLOW)
@subscribe
def voice_play_tts(self, data: dict):
self.run_action("pulse", _GREEN)
@subscribe
def voice__tts_finished(self, data: dict):
self.run_action("color", _OFF)
@@ -0,0 +1,158 @@
# MODIFIED event_led_hal.py (v2 - with cancellation fix)
import logging
import asyncio
from typing import Any, Tuple, Optional
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- Configuration ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 1
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# --- Color and Timing Constants ---
_OFF = (0, 0, 0)
_RED = (255, 0, 0)
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task: Optional[asyncio.Future] = None
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
self.leds = None
self._base_color = _RED
self._listen_idle_watchdog: Optional[asyncio.Task] = None
self.run_action("color", _OFF)
def run_action(self, action_method_name: str, *args: Any):
if not self.leds:
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
# --- LED Action Methods ---
async def color(self, rgb: Tuple[int, int, int]):
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
# --- MODIFICATION: This function is now more robust to cancellation ---
async def pulse(self, color: Tuple[int, int, int], speed=0.02):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
finally:
# This 'finally' block ensures brightness is restored even
# when the task is cancelled.
self.leds.brightness = GLOBAL_BRIGHTNESS
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times, on_time=0.05, off_time=0.05)
await self.pulse(pulse_color, pulse_speed)
# --- Watchdog Logic ---
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# --- Event Subscriptions ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.run_action("color", _OFF)
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast RED flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.run_action("flash_then_pulse", _RED, 2, _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure RED pulsing (listening)")
self._cancel_watchdog()
self.run_action("pulse", _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@@ -0,0 +1,156 @@
# event_led_marvin.py
# Based on the corrected HAL file, with red changed to green.
import logging
import asyncio
from typing import Any, Tuple, Optional
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- Configuration ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 2
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# --- Color and Timing Constants ---
_OFF = (0, 0, 0)
_GREEN = (0, 255, 0) # Changed from RED to GREEN
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task: Optional[asyncio.Future] = None
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
self.leds = None
self._base_color = _GREEN # Changed from _RED
self._listen_idle_watchdog: Optional[asyncio.Task] = None
self.run_action("color", _OFF)
def run_action(self, action_method_name: str, *args: Any):
if not self.leds:
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
# --- LED Action Methods ---
async def color(self, rgb: Tuple[int, int, int]):
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
async def pulse(self, color: Tuple[int, int, int], speed=0.02):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
finally:
self.leds.brightness = GLOBAL_BRIGHTNESS
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times, on_time=0.05, off_time=0.05)
await self.pulse(pulse_color, pulse_speed)
# --- Watchdog Logic ---
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# --- Event Subscriptions ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.run_action("color", _OFF)
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast GREEN flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.run_action("flash_then_pulse", _GREEN, 2, _GREEN, 0.02) # Changed from _RED
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure GREEN pulsing (listening)")
self._cancel_watchdog()
self.run_action("pulse", _GREEN, 0.02) # Changed from _RED
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
+188
View File
@@ -0,0 +1,188 @@
import logging
from typing import Any, Tuple, Optional
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
from math import ceil
import asyncio
import spidev
NUM_LEDS = 3
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_RED = (255, 0, 0)
# Keep listening pulse alive for up to 15s after wakeword if user doesn't speak
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class APA102:
MAX_BRIGHTNESS = 0b11111
LED_START = 0b11100000
def __init__(
self,
num_led: int,
global_brightness: int,
loop: asyncio.AbstractEventLoop,
order: str = "rgb",
bus: int = 0,
device: int = 1,
max_speed_hz: int = 8000000,
):
self.num_led = num_led
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
self.global_brightness = min(global_brightness, self.MAX_BRIGHTNESS)
_LOGGER.debug("LED brightness: %s", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led
self.spi = spidev.SpiDev()
self.spi.open(bus, device)
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task: Optional[asyncio.Future] = None
self.loop = loop
def clock_start_frame(self):
self.spi.xfer2([0] * 4)
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0 or led_num >= self.num_led:
return
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close()
def run_action(self, action_method_name: str, *args: Any):
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
async def color(self, rgb, brightness=None):
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def pulse(self, color, speed=0.02):
while True:
for brightness in range(1, self.MAX_BRIGHTNESS + 1):
await self.color(color, brightness)
await asyncio.sleep(speed)
for brightness in range(self.MAX_BRIGHTNESS, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times)
await self.pulse(pulse_color, pulse_speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
self._base_color = _RED
self.leds.run_action("color", _OFF)
self._listen_idle_watchdog: Optional[asyncio.Task] = None
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.leds.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# Connectivity ------------------------------------------------------------------
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.leds.run_action("color", _OFF)
# Wakeword ----------------------------------------------------------------------
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast RED flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.leds.run_action("flash_then_pulse", _RED, 2, _RED, 0.02)
# Listening (VAD) ---------------------------------------------------------------
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure RED pulsing (listening)")
self._cancel_watchdog()
self.leds.run_action("pulse", _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
# TTS (speaking) ----------------------------------------------------------------
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
+198
View File
@@ -0,0 +1,198 @@
import logging
from typing import Any, Tuple, Optional
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
from math import ceil
import asyncio
import spidev
NUM_LEDS = 2
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_GREEN = (0, 255, 0)
# How long to keep "listening" after wakeword if user never starts speaking
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class APA102:
MAX_BRIGHTNESS = 0b11111
LED_START = 0b11100000
def __init__(
self,
num_led: int,
global_brightness: int,
loop: asyncio.AbstractEventLoop,
order: str = "rgb",
bus: int = 0,
device: int = 1,
max_speed_hz: int = 8000000,
):
self.num_led = num_led
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
self.global_brightness = min(global_brightness, self.MAX_BRIGHTNESS)
_LOGGER.debug("LED brightness: %s", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led
self.spi = spidev.SpiDev()
self.spi.open(bus, device)
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task: Optional[asyncio.Future] = None
self.loop = loop
def clock_start_frame(self):
self.spi.xfer2([0] * 4)
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0 or led_num >= self.num_led:
return
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close()
def run_action(self, action_method_name: str, *args: Any):
# Cancel any currently running animation task (pulse/blink/etc.)
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
async def color(self, rgb, brightness=None):
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def pulse(self, color, speed=0.02):
"""Pulse continuously until cancelled."""
while True:
for brightness in range(1, self.MAX_BRIGHTNESS + 1):
await self.color(color, brightness)
await asyncio.sleep(speed)
for brightness in range(self.MAX_BRIGHTNESS, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
"""Flash color on/off N times, then leave LEDs OFF (caller decides next state)."""
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
"""Flash N times, then enter continuous pulse."""
await self.flash(flash_color, times)
await self.pulse(pulse_color, pulse_speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
self._base_color = _GREEN
# Keep LEDs off until connected
self.leds.run_action("color", _OFF)
# Watchdog for the "no speech after wakeword" case
self._listen_idle_watchdog: Optional[asyncio.Task] = None
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.leds.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# Connectivity ------------------------------------------------------------------
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.leds.run_action("color", _OFF)
# Wakeword ----------------------------------------------------------------------
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast GREEN flash x2, then enter listening (pulse)")
# Start a watchdog: if user never speaks, stop pulsing after timeout
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
# Flash twice quickly, then pulse
self.leds.run_action("flash_then_pulse", _GREEN, 2, _GREEN, 0.02)
# Listening (VAD) ---------------------------------------------------------------
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure GREEN pulsing (listening)")
# User started speaking: cancel the idle watchdog and ensure pulse
self._cancel_watchdog()
self.leds.run_action("pulse", _GREEN, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
# TTS (speaking) ----------------------------------------------------------------
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
-688
View File
@@ -1,688 +0,0 @@
import asyncio
import logging
from typing import Any, Optional, Tuple
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Optional hardware imports (board-safe)
# ---------------------------------------------------------------------------
try:
import board # type: ignore[import]
except Exception:
board = None # type: ignore[assignment]
_LOGGER.warning(
"Adafruit 'board' module not available or unsupported on this platform; "
"DotStar/NeoPixel GPIO/SPI LED backends will be disabled. "
"XVF3800 USB LED backend is unaffected."
)
from .config import LedConfig
from .event_bus import EventBus, EventHandler, subscribe
from .models import Preferences
# Default Colors
_OFF = (0, 0, 0)
_BLUE = (0, 0, 255)
_YELLOW = (255, 255, 0)
_GREEN = (0, 255, 0)
_DIM_RED = (50, 0, 0)
_ORANGE = (255, 165, 0)
_PURPLE = (128, 0, 255)
class LedController(EventHandler):
def __init__(
self,
loop: asyncio.AbstractEventLoop,
event_bus: EventBus,
config: LedConfig,
preferences: Preferences,
):
super().__init__(event_bus)
self.loop = loop
self.num_leds = preferences.num_leds # Get num_leds from preferences
self.current_task: Optional[asyncio.Future] = None
self._is_ready = False
self.leds = None
# Backend mode:
# "pixels" -> DotStar / NeoPixel via Adafruit drivers
# "xvf3800" -> XVF3800 USB LED ring backend
self._backend_mode: str = "pixels"
self._xvf3800_backend = None
# Configured LED behavior
self.configs = {
"idle": {"effect": "off", "color": _PURPLE, "brightness": 0.5},
"listening": {"effect": "medium_pulse", "color": _BLUE, "brightness": 0.5},
"thinking": {"effect": "spin", "color": _YELLOW, "brightness": 0.8},
"responding": {"effect": "medium_pulse", "color": _GREEN, "brightness": 0.5},
"error": {"effect": "fast_blink", "color": _ORANGE, "brightness": 1.0},
}
# Determine whether hardware LEDs should be used at all
config_enabled = getattr(config, "enabled", True)
# For XVF3800 we do NOT require the Adafruit 'board' module.
if config.led_type == "xvf3800" and config.interface == "usb":
self._enabled = bool(config_enabled)
else:
self._enabled = bool(config_enabled) and (board is not None)
if not config_enabled:
_LOGGER.info(
"LEDs disabled in config (led.enabled = false); "
"LedController will run in no-op mode."
)
elif not self._enabled:
_LOGGER.warning(
"LED hardware libraries not available on this platform for led_type=%s; "
"LedController will run in no-op mode.",
config.led_type,
)
# Always subscribe to events so MQTT + state logic remains intact,
# even when LEDs are effectively disabled.
self._subscribe_all_methods()
# If LEDs are not enabled at all, skip hardware init
if not self._enabled:
return
# -------------------------------------------------------------------
# XVF3800 USB LED backend
# -------------------------------------------------------------------
if config.led_type == "xvf3800" and config.interface == "usb":
try:
from .xvf3800_led_backend import XVF3800LedBackend # type: ignore[import]
self._xvf3800_backend = XVF3800LedBackend()
self._backend_mode = "xvf3800"
self._is_ready = True
# XVF3800 ring has a fixed LED count (typically 12)
self.num_leds = int(getattr(self._xvf3800_backend, "ring_led_count", 12))
_LOGGER.info(
"LED Controller initialized for XVF3800 USB LED ring (num_leds=%d).",
self.num_leds,
)
self.run_action("startup_sequence")
except Exception:
# Any hardware-related failure leaves us in no-op mode
self._is_ready = False
self._enabled = False
self._xvf3800_backend = None
_LOGGER.exception(
"Failed to initialize XVF3800 LED backend. LEDs will be disabled."
)
return
# -------------------------------------------------------------------
# Hardware initialization (DotStar / NeoPixel via Adafruit drivers)
# -------------------------------------------------------------------
try:
if config.led_type == "neopixel":
if config.interface == "spi":
import busio
import neopixel_spi
_LOGGER.debug(
"Initializing %d NeoPixel LEDs on hardware SPI", self.num_leds
)
spi = busio.SPI(board.SCLK, MOSI=board.MOSI)
self.leds = neopixel_spi.NeoPixel_SPI(
spi, self.num_leds, auto_write=False
)
else: # GPIO
import neopixel
_LOGGER.debug(
"Initializing %d NeoPixel LEDs on GPIO data=%s",
self.num_leds,
config.data_pin,
)
pin_object = getattr(board, f"D{config.data_pin}")
self.leds = neopixel.NeoPixel(
pin_object, self.num_leds, auto_write=False
)
else: # dotstar
import adafruit_dotstar
if config.interface == "gpio":
_LOGGER.debug(
"Initializing %d DotStar LEDs on GPIO data=%s, clock=%s",
self.num_leds,
config.data_pin,
config.clock_pin,
)
data_pin_obj = getattr(board, f"D{config.data_pin}")
clock_pin_obj = getattr(board, f"D{config.clock_pin}")
self.leds = adafruit_dotstar.DotStar(
clock_pin_obj, data_pin_obj, self.num_leds, auto_write=False
)
else: # SPI
_LOGGER.debug(
"Initializing %d DotStar LEDs on hardware SPI", self.num_leds
)
self.leds = adafruit_dotstar.DotStar(
board.SCLK, board.MOSI, self.num_leds, auto_write=False
)
self._backend_mode = "pixels"
self._is_ready = True
_LOGGER.info(
"LED Controller initialized for %d %s LEDs.",
self.num_leds,
config.led_type,
)
self.run_action("startup_sequence")
except Exception:
# Any hardware-related failure leaves us in no-op mode
self._is_ready = False
self.leds = None
_LOGGER.exception(
"Failed to initialize LED controller. LEDs will be disabled."
)
# -----------------------------------------------------------------------
# Internal helpers
# -----------------------------------------------------------------------
def run_action(self, action_method_name: str, *args: Any) -> None:
"""Schedule an LED coroutine on the event loop."""
if not (self._enabled and self._is_ready):
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
coro = getattr(self, action_method_name)(*args)
self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop)
def _apply_state_effect(self, state_name: str, publish_state: bool = True):
config = self.configs.get(state_name, self.configs["idle"])
_LOGGER.debug(
"Applying effect for state '%s': %s", state_name, config["effect"]
)
self.run_action(config["effect"], config["color"], config["brightness"])
if publish_state:
config["state_name"] = state_name
self.event_bus.publish("publish_state_to_mqtt", config)
# -----------------------------------------------------------------------
# XVF3800-specific helpers
# -----------------------------------------------------------------------
async def _xvf3800_apply_effect(
self,
effect_name: str,
color: Tuple[int, int, int],
brightness: float,
) -> None:
"""Map generic effect/color/brightness to XVF3800 legacy LED controls."""
if not (self._enabled and self._is_ready and self._xvf3800_backend):
return
# Clamp brightness 0..1 and map to 0..255
brightness = max(0.0, min(1.0, float(brightness)))
brightness_255 = int(brightness * 255)
# Effect mapping to XVF3800 legacy modes
effect_map = {
"off": 0, # LED_EFFECT = off
"solid": 3, # single color
"slow_pulse": 1, # breath
"medium_pulse": 1, # breath
"fast_pulse": 1, # breath
"slow_blink": 1, # approximate with breath
"medium_blink": 1, # approximate with breath
"fast_blink": 1, # approximate with breath
"spin": 2, # rainbow as a stand-in for "spin"
}
speed_map = {
"off": 0,
"solid": 0,
"slow_pulse": 0,
"medium_pulse": 1,
"fast_pulse": 2,
"slow_blink": 0,
"medium_blink": 1,
"fast_blink": 2,
"spin": 1,
}
effect_id = effect_map.get(effect_name, 3)
speed_id = speed_map.get(effect_name, 1)
r, g, b = color
try:
# Apply brightness, color, speed, and effect via USB control transfers.
self._xvf3800_backend.set_brightness(brightness_255)
if effect_name != "off":
self._xvf3800_backend.set_color(r, g, b)
self._xvf3800_backend.set_speed(speed_id)
self._xvf3800_backend.set_effect(effect_id)
except Exception:
_LOGGER.exception(
"Error sending LED effect '%s' to XVF3800 backend", effect_name
)
# Keep coroutine alive until cancelled so that a new effect
# can cancel the previous one consistently.
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
return
# -----------------------------------------------------------------------
# LED effect coroutines
# -----------------------------------------------------------------------
# -----------------------------------------------------------------------
# XVF3800 per-LED helpers (newer firmware)
# -----------------------------------------------------------------------
def _xvf3800_has_per_led(self) -> bool:
return bool(getattr(self._xvf3800_backend, "supports_per_led", False))
def _xvf3800_ring_count(self) -> int:
return int(getattr(self._xvf3800_backend, "ring_led_count", 12))
def _xvf3800_rgb_clamp(self, color):
r, g, b = color
return (
max(0, min(255, int(r))),
max(0, min(255, int(g))),
max(0, min(255, int(b))),
)
def _xvf3800_brightness_255(self, brightness: float) -> int:
b = max(0.0, min(1.0, float(brightness)))
return int(b * 255)
def _xvf3800_apply_ring_solid(self, color, brightness: float) -> None:
"""Per-LED solid ring.
NOTE: Some XVF3800 firmwares do not apply LED_BRIGHTNESS to LED_RING_COLOR output.
To keep behavior consistent, we scale the RGB values by brightness and write the ring
colors directly.
"""
if not self._xvf3800_backend:
return
r, g, b = self._xvf3800_rgb_clamp(color)
brightness = max(0.0, min(1.0, float(brightness)))
# Ensure firmware effects don't override per-LED output.
try:
self._xvf3800_backend.set_effect(0)
except Exception:
pass
rs = int(r * brightness)
gs = int(g * brightness)
bs = int(b * brightness)
self._xvf3800_backend.set_ring_solid(rs, gs, bs)
def _xvf3800_apply_ring_clear(self) -> None:
if not self._xvf3800_backend:
return
try:
self._xvf3800_backend.set_effect(0)
except Exception:
pass
self._xvf3800_backend.clear_ring()
async def startup_sequence(self, color=None, brightness=None):
# Use a simple green blink on startup for all backends.
await self.blink(_GREEN, 1.0)
async def off(self, color, brightness):
if not (self._enabled and self._is_ready):
return
if self._backend_mode == "xvf3800":
# Prefer per-LED ring control when supported by firmware.
if self._xvf3800_has_per_led():
try:
self._xvf3800_apply_ring_clear()
except Exception:
_LOGGER.exception("Error sending per-LED OFF to XVF3800")
return
await self._xvf3800_apply_effect("off", _OFF, 0.0)
return
self.leds.fill(_OFF)
self.leds.show()
async def solid(self, color: Tuple[int, int, int], brightness: float):
if not (self._enabled and self._is_ready):
return
if self._backend_mode == "xvf3800":
if self._xvf3800_has_per_led():
try:
self._xvf3800_apply_ring_solid(color, brightness)
except Exception:
_LOGGER.exception("Error sending per-LED SOLID to XVF3800")
return
await self._xvf3800_apply_effect("solid", color, brightness)
return
r, g, b = color
self.leds.fill((int(r * brightness), int(g * brightness), int(b * brightness)))
self.leds.show()
async def blink(self, color, brightness=1.0):
await self.medium_blink(color, brightness)
async def _base_pulse(
self,
effect_name: str,
color: Tuple[int, int, int],
brightness: float,
speed: float,
):
if not (self._enabled and self._is_ready):
return
if self._backend_mode == "xvf3800":
if self._xvf3800_has_per_led():
try:
r, g, b = self._xvf3800_rgb_clamp(color)
# Pulse by scaling the per-LED RGB values (some firmwares don't apply LED_BRIGHTNESS to LED_RING_COLOR).
self._xvf3800_backend.set_effect(0)
brightness = max(0.0, min(1.0, float(brightness)))
while True:
for i in range(0, 101, 10):
s = (i / 100.0) * brightness
self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s))
await asyncio.sleep(speed)
for i in range(100, -1, -10):
s = (i / 100.0) * brightness
self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s))
await asyncio.sleep(speed)
return
except Exception:
_LOGGER.exception("Error sending per-LED PULSE to XVF3800")
return
await self._xvf3800_apply_effect(effect_name, color, brightness)
return
try:
r, g, b = color
while True:
for i in range(0, 101, 10):
mod = i / 100.0 * brightness
self.leds.fill((int(r * mod), int(g * mod), int(b * mod)))
self.leds.show()
await asyncio.sleep(speed)
for i in range(100, -1, -10):
mod = i / 100.0 * brightness
self.leds.fill((int(r * mod), int(g * mod), int(b * mod)))
self.leds.show()
await asyncio.sleep(speed)
except asyncio.CancelledError:
if self._enabled and self._is_ready and self.leds is not None:
self.leds.fill(_OFF)
self.leds.show()
async def slow_pulse(self, color, brightness):
await self._base_pulse("slow_pulse", color, brightness, 0.05)
async def medium_pulse(self, color, brightness):
await self._base_pulse("medium_pulse", color, brightness, 0.02)
async def fast_pulse(self, color, brightness):
await self._base_pulse("fast_pulse", color, brightness, 0.008)
async def _base_blink(
self,
effect_name: str,
color: Tuple[int, int, int],
brightness: float,
speed: float,
):
if not (self._enabled and self._is_ready):
return
if self._backend_mode == "xvf3800":
if self._xvf3800_has_per_led():
try:
r, g, b = self._xvf3800_rgb_clamp(color)
self._xvf3800_backend.set_effect(0)
brightness = max(0.0, min(1.0, float(brightness)))
while True:
# On
self._xvf3800_backend.set_ring_solid(int(r * brightness), int(g * brightness), int(b * brightness))
await asyncio.sleep(speed)
# Off
self._xvf3800_backend.clear_ring()
await asyncio.sleep(speed)
except asyncio.CancelledError:
try:
self._xvf3800_apply_ring_clear()
except Exception:
pass
return
except Exception:
_LOGGER.exception("Error sending per-LED BLINK to XVF3800")
return
await self._xvf3800_apply_effect(effect_name, color, brightness)
return
try:
r, g, b = color
bright_color = (int(r * brightness), int(g * brightness), int(b * brightness))
while True:
self.leds.fill(bright_color)
self.leds.show()
await asyncio.sleep(speed)
self.leds.fill(_OFF)
self.leds.show()
await asyncio.sleep(speed)
except asyncio.CancelledError:
if self._enabled and self._is_ready and self.leds is not None:
self.leds.fill(_OFF)
self.leds.show()
async def slow_blink(self, color, brightness):
await self._base_blink("slow_blink", color, brightness, 1.0)
async def medium_blink(self, color, brightness):
await self._base_blink("medium_blink", color, brightness, 0.5)
async def fast_blink(self, color, brightness):
await self._base_blink("fast_blink", color, brightness, 0.1)
async def spin(
self, color: Tuple[int, int, int], brightness: float, speed: float = 0.1
):
if not (self._enabled and self._is_ready):
return
if self._backend_mode == "xvf3800":
if self._xvf3800_has_per_led():
try:
r, g, b = self._xvf3800_rgb_clamp(color)
ring_n = self._xvf3800_ring_count()
self._xvf3800_backend.set_effect(0)
self._xvf3800_backend.set_brightness(self._xvf3800_brightness_255(brightness))
i = 0
while True:
colors = [(0, 0, 0)] * ring_n
colors[i % ring_n] = (r, g, b)
self._xvf3800_backend.set_ring_rgb(colors)
i += 1
await asyncio.sleep(speed)
except asyncio.CancelledError:
try:
self._xvf3800_apply_ring_clear()
except Exception:
pass
return
except Exception:
_LOGGER.exception("Error sending per-LED SPIN to XVF3800")
return
await self._xvf3800_apply_effect("spin", color, brightness)
return
try:
i = 0
bright_color = (
int(color[0] * brightness),
int(color[1] * brightness),
int(color[2] * brightness),
)
while True:
self.leds.fill(_OFF)
self.leds[i % self.num_leds] = bright_color
self.leds.show()
i += 1
await asyncio.sleep(speed)
except asyncio.CancelledError:
if self._enabled and self._is_ready and self.leds is not None:
self.leds.fill(_OFF)
self.leds.show()
@subscribe
def voice_idle(self, data: dict):
self._apply_state_effect("idle")
@subscribe
def voice_listen(self, data: dict):
self._apply_state_effect("listening")
@subscribe
def voice_thinking(self, data: dict):
self._apply_state_effect("thinking")
@subscribe
def voice_responding(self, data: dict):
self._apply_state_effect("responding")
@subscribe
def voice_error(self, data: dict):
self._apply_state_effect("error")
@subscribe
def mic_muted(self, data: dict):
# When muted, show a solid dim red on all backends.
self.run_action("solid", _DIM_RED, 1.0)
@subscribe
def mic_unmuted(self, data: dict):
self._apply_state_effect("idle")
# -----------------------------------------------------------------------
# MQTT Config Subscriptions
# -----------------------------------------------------------------------
def _update_config(self, state_name: str, data: dict, apply: bool):
config = self.configs[state_name]
changed = False
is_retained = data.get("retained", False)
new_effect = data.get("effect")
if new_effect is not None and config["effect"] != new_effect:
config["effect"] = new_effect
changed = True
new_color_data = data.get("color")
if new_color_data is not None:
new_color_tuple = (
new_color_data.get("r"),
new_color_data.get("g"),
new_color_data.get("b"),
)
if config["color"] != new_color_tuple:
config["color"] = new_color_tuple
changed = True
new_brightness = data.get("brightness")
if new_brightness is not None:
new_brightness_float = new_brightness / 255.0
if abs(config["brightness"] - new_brightness_float) > 0.001:
config["brightness"] = new_brightness_float
changed = True
if is_retained:
if changed and state_name == "idle":
# Re-apply idle but don't republish back to MQTT
self._apply_state_effect("idle", publish_state=False)
return
if changed:
if apply:
self._apply_state_effect(state_name)
else:
config["state_name"] = state_name
self.event_bus.publish("publish_state_to_mqtt", config)
@subscribe
def set_idle_effect(self, data: dict):
self._update_config("idle", data, True)
@subscribe
def set_idle_color(self, data: dict):
self._update_config(
"idle",
data,
self.configs["idle"]["effect"] != "off",
)
@subscribe
def set_listening_effect(self, data: dict):
self._update_config("listening", data, False)
@subscribe
def set_listening_color(self, data: dict):
self._update_config("listening", data, False)
@subscribe
def set_thinking_effect(self, data: dict):
self._update_config("thinking", data, False)
@subscribe
def set_thinking_color(self, data: dict):
self._update_config("thinking", data, False)
@subscribe
def set_responding_effect(self, data: dict):
self._update_config("responding", data, False)
@subscribe
def set_responding_color(self, data: dict):
self._update_config("responding", data, False)
@subscribe
def set_error_effect(self, data: dict):
self._update_config("error", data, False)
@subscribe
def set_error_color(self, data: dict):
self._update_config("error", data, False)
@subscribe
def set_num_leds(self, data: dict):
num_leds = data.get("num_leds")
if (num_leds is not None) and (self.num_leds != num_leds):
_LOGGER.info(
"Number of LEDs changed to %d. Please restart for change to take effect.",
num_leds,
)
+272
View File
@@ -0,0 +1,272 @@
import ctypes
import json
import statistics
from collections import deque
from pathlib import Path
from typing import Deque, List, Union
import numpy as np
from pymicro_features import MicroFrontend
_SAMPLES_PER_SECOND = 16000
_SAMPLES_PER_CHUNK = 160 # 10ms
_BYTES_PER_SAMPLE = 2 # 16-bit
_BYTES_PER_CHUNK = _SAMPLES_PER_CHUNK * _BYTES_PER_SAMPLE
_SECONDS_PER_CHUNK = _SAMPLES_PER_CHUNK / _SAMPLES_PER_SECOND
_STRIDE = 3
_DEFAULT_REFRACTORY = 2 # seconds
class TfLiteQuantizationParams(ctypes.Structure):
_fields_ = [("scale", ctypes.c_float), ("zero_point", ctypes.c_int32)]
class MicroWakeWord:
def __init__(
self,
id: str, # pylint: disable=redefined-builtin
wake_word: str,
tflite_model: Union[str, Path],
probability_cutoff: float,
sliding_window_size: int,
refractory_seconds: float,
trained_languages: List[str],
libtensorflowlite_c_path: Union[str, Path],
):
self.id = id
self.wake_word = wake_word
self.tflite_model = tflite_model
self.probability_cutoff = probability_cutoff
self.sliding_window_size = sliding_window_size
self.refractory_seconds = refractory_seconds
self.trained_languages = trained_languages
self.is_active = True
# Load the shared library
self.libtensorflowlite_c_path = Path(libtensorflowlite_c_path)
self.lib = ctypes.cdll.LoadLibrary(str(self.libtensorflowlite_c_path.resolve()))
# Define required argument/return types for C API
self.lib.TfLiteModelCreateFromFile.argtypes = [ctypes.c_char_p]
self.lib.TfLiteModelCreateFromFile.restype = ctypes.c_void_p
self.lib.TfLiteInterpreterCreate.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
self.lib.TfLiteInterpreterCreate.restype = ctypes.c_void_p
self.lib.TfLiteInterpreterAllocateTensors.argtypes = [ctypes.c_void_p]
self.lib.TfLiteInterpreterInvoke.argtypes = [ctypes.c_void_p]
self.lib.TfLiteInterpreterGetInputTensor.argtypes = [
ctypes.c_void_p,
ctypes.c_int,
]
self.lib.TfLiteInterpreterGetInputTensor.restype = ctypes.c_void_p
self.lib.TfLiteInterpreterGetOutputTensor.argtypes = [
ctypes.c_void_p,
ctypes.c_int,
]
self.lib.TfLiteInterpreterGetOutputTensor.restype = ctypes.c_void_p
self.lib.TfLiteTensorCopyFromBuffer.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
]
self.lib.TfLiteTensorCopyToBuffer.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
]
self.lib.TfLiteTensorType.restype = ctypes.c_int
self.lib.TfLiteTensorByteSize.argtypes = [ctypes.c_void_p]
self.lib.TfLiteTensorByteSize.restype = ctypes.c_size_t
self.lib.TfLiteTensorQuantizationParams.restype = ctypes.Structure
self.lib.TfLiteTensorQuantizationParams.argtypes = [ctypes.c_void_p]
self.lib.TfLiteTensorQuantizationParams.restype = TfLiteQuantizationParams
# Load the model and create interpreter
self.model_path = str(Path(tflite_model).resolve()).encode("utf-8")
self._load_model()
self._frontend = MicroFrontend()
self._features: List[np.ndarray] = []
self._probabilities: Deque[float] = deque(maxlen=self.sliding_window_size)
self._audio_buffer = bytes()
self._ignore_seconds: float = 0
def _load_model(self) -> None:
self.model = self.lib.TfLiteModelCreateFromFile(self.model_path)
self.interpreter = self.lib.TfLiteInterpreterCreate(self.model, None)
self.lib.TfLiteInterpreterAllocateTensors(self.interpreter)
# Access input and output tensor
self.input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
self.interpreter, 0
)
self.output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
self.interpreter, 0
)
# Get quantization parameters
input_q = self.lib.TfLiteTensorQuantizationParams(self.input_tensor)
output_q = self.lib.TfLiteTensorQuantizationParams(self.output_tensor)
self.input_scale, self.input_zero_point = input_q.scale, input_q.zero_point
self.output_scale, self.output_zero_point = output_q.scale, output_q.zero_point
def reset(self) -> None:
"""Reload model and clear state.
This must be done between audio clips when not streaming.
"""
self._audio_buffer = bytes()
self._features.clear()
self._probabilities.clear()
self._ignore_seconds = 0
# Clear out residual features
self._frontend = MicroFrontend()
# Need to reload model to reset intermediary results
# reset_all_variables() doesn't work.
self._load_model()
@property
def samples_per_chunk(self) -> int:
"""Number of samples in a streaming audio chunk."""
return _SAMPLES_PER_CHUNK
@property
def bytes_per_chunk(self) -> int:
"""Number of bytes in a streaming audio chunk.
Assumes 16-bit mono samples at 16Khz.
"""
return _BYTES_PER_CHUNK
@staticmethod
def from_config(
config_path: Union[str, Path],
libtensorflowlite_c_path: Union[str, Path],
refractory_seconds: float = _DEFAULT_REFRACTORY,
) -> "MicroWakeWord":
"""Load a microWakeWord model from a JSON config file.
Parameters
----------
config_path: str or Path
Path to JSON configuration file
refractory_seconds: float
Number of seconds to ignore after detection
"""
config_path = Path(config_path)
with open(config_path, "r", encoding="utf-8") as config_file:
config = json.load(config_file)
micro_config = config["micro"]
return MicroWakeWord(
id=Path(config["model"]).stem,
wake_word=config["wake_word"],
tflite_model=config_path.parent / config["model"],
probability_cutoff=micro_config["probability_cutoff"],
sliding_window_size=micro_config["sliding_window_size"],
refractory_seconds=refractory_seconds,
trained_languages=micro_config.get("trained_languages", []),
libtensorflowlite_c_path=libtensorflowlite_c_path,
)
def process_streaming(self, audio_bytes: bytes) -> bool:
"""Process a chunk of audio in streaming mode.
Parameters
----------
audio_bytes: bytes
Raw 16-bit mono audio samples at 16Khz
Returns True if the wake word was detected.
"""
self._audio_buffer += audio_bytes
if len(self._audio_buffer) < _BYTES_PER_CHUNK:
# Not enough audio to get features
return False
detected = False
audio_buffer_idx = 0
while (audio_buffer_idx + _BYTES_PER_CHUNK) <= len(self._audio_buffer):
# Process chunk
chunk_bytes = self._audio_buffer[
audio_buffer_idx : audio_buffer_idx + _BYTES_PER_CHUNK
]
frontend_result = self._frontend.ProcessSamples(chunk_bytes)
audio_buffer_idx += frontend_result.samples_read * _BYTES_PER_SAMPLE
self._ignore_seconds = max(0, self._ignore_seconds - _SECONDS_PER_CHUNK)
if not frontend_result.features:
# Not enough audio for a full window
continue
self._features.append(
np.array(frontend_result.features).reshape(
(1, 1, len(frontend_result.features))
)
)
if len(self._features) < _STRIDE:
# Not enough windows
continue
# Allocate and quantize input data
quant_features = np.round(
np.concatenate(self._features, axis=1) / self.input_scale
+ self.input_zero_point
).astype(np.uint8)
# Stride instead of rolling
self._features.clear()
# Set tensor
quant_ptr = quant_features.ctypes.data_as(ctypes.c_void_p)
self.lib.TfLiteTensorCopyFromBuffer(
self.input_tensor, quant_ptr, quant_features.nbytes
)
# Run inference
self.lib.TfLiteInterpreterInvoke(self.interpreter)
# Read output
output_bytes = self.lib.TfLiteTensorByteSize(self.output_tensor)
output_data = np.empty(output_bytes, dtype=np.uint8)
self.lib.TfLiteTensorCopyToBuffer(
self.output_tensor,
output_data.ctypes.data_as(ctypes.c_void_p),
output_bytes,
)
# Dequantize output
result = (
output_data.astype(np.float32) - self.output_zero_point
) * self.output_scale
self._probabilities.append(result.item())
if len(self._probabilities) < self.sliding_window_size:
# Not enough probabilities
continue
if statistics.mean(self._probabilities) > self.probability_cutoff:
if self._ignore_seconds <= 0:
detected = True
self._ignore_seconds = self.refractory_seconds
# Remove processed audio
self._audio_buffer = self._audio_buffer[audio_buffer_idx:]
return detected
-123
View File
@@ -1,123 +0,0 @@
"""Shared models."""
import asyncio
import json
import logging
import threading
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
from .event_bus import EventBus
if TYPE_CHECKING:
from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
from .entity import ESPHomeEntity
from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol
_LOGGER = logging.getLogger(__name__)
class SatelliteState(str, Enum):
"""Voice satellite state."""
STARTING = "starting"
IDLE = "idle"
LISTENING = "listening"
THINKING = "thinking"
RESPONDING = "responding"
ERROR = "error"
class WakeWordType(str, Enum):
MICRO_WAKE_WORD = "micro"
OPEN_WAKE_WORD = "openWakeWord"
@dataclass
class AvailableWakeWord:
id: str
type: WakeWordType
wake_word: str
trained_languages: List[str]
wake_word_path: Path
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
if self.type == WakeWordType.MICRO_WAKE_WORD:
from pymicro_wakeword import MicroWakeWord
return MicroWakeWord.from_config(config_path=self.wake_word_path)
if self.type == WakeWordType.OPEN_WAKE_WORD:
from pyopen_wakeword import OpenWakeWord
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
setattr(oww_model, "wake_word", self.wake_word)
return oww_model
raise ValueError(f"Unexpected wake word type: {self.type}")
@dataclass
class Preferences:
active_wake_words: List[str] = field(default_factory=list)
volume_level: float = 1.0
num_leds: int = 3
# New: configurable alarm duration in seconds.
# 0 = infinite alarm (only Stop/wake word stops it)
# >0 = auto-stop alarm after this many seconds
alarm_duration_seconds: int = 0
@dataclass
class ServerState:
"""A simple dataclass to hold core application state."""
# --- Fields WITHOUT default values ---
name: str
mac_address: str
event_bus: EventBus
loop: asyncio.AbstractEventLoop
entities: "List[ESPHomeEntity]"
music_player: "MpvMediaPlayer"
tts_player: "MpvMediaPlayer"
available_wake_words: "Dict[str, AvailableWakeWord]"
wake_words: "Dict[str, Union[MicroWakeWord, OpenWakeWord]]"
active_wake_words: Set[str]
stop_word: "MicroWakeWord"
wakeup_sound: str
timer_finished_sound: str
preferences_path: Path
download_dir: Path
preferences: Preferences
# --- Fields WITH default values ---
satellite: "Optional[VoiceSatelliteProtocol]" = None
wake_words_changed: bool = False
refractory_seconds: float = 2.0
mic_muted: bool = False
shutdown: bool = False
# Threading event to pause the audio thread efficiently when muted
# set() = Mic is ON (Audio processing running)
# clear() = Mic is OFF (Audio processing paused)
mic_muted_event: threading.Event = field(default_factory=threading.Event)
def __post_init__(self):
"""Ensure the threading event matches the boolean state on init."""
if not self.mic_muted:
self.mic_muted_event.set()
else:
self.mic_muted_event.clear()
def save_preferences(self) -> None:
"""Save preferences as JSON."""
_LOGGER.debug("Saving preferences: %s", self.preferences_path)
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
json.dump(
asdict(self.preferences),
preferences_file,
ensure_ascii=False,
indent=4,
)
+279 -180
View File
@@ -1,277 +1,376 @@
"""Media player using mpv in a subprocess.
# linux_voice_assistant/mpv_player.py
"""
Media player using mpv in a subprocess.
This wrapper focuses on:
- Simple playback control (play / pause / resume / stop)
- Volume control and ducking
- Notifying a done_callback when playback finishes
- Letting mpv choose the best audio backend by default
If a specific audio device is provided, it is passed directly to mpv as
`audio-device`. Otherwise, mpv's own automatic backend/device selection is used.
Note about volume:
- mpv has its own per-player volume (0..100).
- PipeWire/PulseAudio/ALSA also has a system output volume.
LVA persists a single user volume (0.0..1.0) in preferences.json. To avoid
"mystery caps" where the OS sink is stuck at e.g. 40% while LVA shows 100%,
LVA treats the OS output volume as the "master" and keeps mpv at 100% for normal
playback. Ducking still uses mpv's per-player volume.
- Supports ALSA, PulseAudio, and PipeWire (native)
- Env override for backend: LVA_AO=pipewire|pulse|alsa (aliases: native/pw -> pipewire, pulseaudio -> pulse)
- Duck/unduck
- Robust URL handling (str | list/tuple | bytes)
- Watchdog is **disabled by default** (set watchdog_sec > 0 to enable)
- End-of-playback detection uses BOTH `eof-reached` and `idle-active`
- mpv log level configurable via env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
"""
from __future__ import annotations
import asyncio
import logging
import os
from threading import Lock
from typing import Callable, List, Optional, Sequence, Union
from threading import Lock, Timer
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
# Note: python-mpv must be installed; imported at runtime.
from mpv import MPV
from .audio_volume import set_output_volume
_LOGGER = logging.getLogger(__name__)
# ---- constants & helpers -----------------------------------------------------
_DEFAULT_MSG_LEVEL = "all=warn"
# Map friendly env tokens to canonical mpv `ao` names
_AO_ENV_ALIASES = {
"native": "pipewire",
"pw": "pipewire",
"pulseaudio": "pulse",
}
_AO_ALLOWED = {"pipewire", "pulse", "alsa"}
def _normalize_env_ao(value: Optional[str]) -> Optional[str]:
"""Normalize LVA_AO environment override into one of _AO_ALLOWED or None."""
if not value:
return None
v = value.strip().lower()
v = _AO_ENV_ALIASES.get(v, v)
return v if v in _AO_ALLOWED else None
def _set_player_opt(player: MPV, key: str, value) -> None:
"""Set an mpv option with logging on failure."""
try:
player[key] = value
except Exception:
_LOGGER.exception("Failed to set mpv option %r=%r", key, value, exc_info=True)
def _select_backend(
player: MPV,
device: Optional[str],
env_ao: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""
Decide and apply (ao, audio-device) after probing candidates in order.
Returns:
(ao, audio_device) — the values successfully applied (or (None, None)).
"""
ao: Optional[str] = None
norm: Optional[str] = None
def try_candidates(candidates: Iterable[Tuple[str, str]]) -> bool:
nonlocal ao, norm
for cand_ao, cand_norm in candidates:
try:
player["ao"] = cand_ao # probe
ao, norm = cand_ao, cand_norm
return True
except Exception:
# Keep trying next backend
continue
return False
if device:
d = device.strip()
if d.startswith("alsa/"):
ao, norm = "alsa", d
elif d.startswith("pulse/"):
ao, norm = "pulse", d
elif d.startswith(("pipewire/", "pw/")):
ao, norm = "pipewire", d.replace("pw/", "pipewire/", 1)
elif d == "default":
# Preference: env override -> PipeWire -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
if not try_candidates(order):
_LOGGER.warning(
"No suitable audio output (PipeWire/Pulse/ALSA) available for 'default'"
)
else:
# No explicit prefix: respect env override first; otherwise PW -> Pulse -> ALSA
tried = False
if env_ao:
tried = try_candidates(
[(env_ao, f"{env_ao}/{d if env_ao != 'alsa' else 'default'}")]
)
if not tried:
try_candidates(
[
("pipewire", f"pipewire/{d}"),
("pulse", f"pulse/{d}"),
("alsa", "alsa/default"),
]
)
else:
# No device specified: env override first, then PW -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
try_candidates(order)
# Apply selections (safe setters log on failure)
if ao:
_set_player_opt(player, "ao", ao)
if norm:
_set_player_opt(player, "audio-device", norm)
if ao or norm:
_LOGGER.debug("mpv backend selected: ao=%s, audio-device=%s", ao, norm)
return ao, norm
# ---- class -------------------------------------------------------------------
class MpvMediaPlayer:
"""A media player class that wraps the python-mpv library."""
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 0.0) -> None:
# Bridge mpv logs into Python logging
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
def __init__(
self,
loop: Optional[asyncio.AbstractEventLoop],
device: Optional[str] = None,
initial_volume: float = 1.0,
) -> None:
"""Initialize the mpv player.
# mpv defaults (errors are logged, not swallowed)
_set_player_opt(self.player, "audio-samplerate", 44100)
_set_player_opt(self.player, "audio-channels", "stereo")
_set_player_opt(self.player, "keep-open", "no")
_set_player_opt(self.player, "network-timeout", 7)
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", _DEFAULT_MSG_LEVEL)
_set_player_opt(self.player, "msg-level", msg_level)
:param loop: The asyncio loop used to schedule done_callback.
May be None in one-off utility contexts (e.g. listing devices).
:param device: Optional mpv audio device name (e.g.
"pipewire/alsa_output.pci-0000_00_1f.3.analog-stereo").
:param initial_volume: Initial volume as a float 0.0–1.0. This is now
treated as the *master* OS output volume. mpv's
internal volume is set to 100% for normal playback.
"""
self.loop = loop
self.device = device
self.initial_volume = max(0.0, min(1.0, float(initial_volume)))
# Env-driven AO override
env_ao = _normalize_env_ao(os.environ.get("LVA_AO"))
self.player = MPV(
video=False,
terminal=False,
log_handler=self._mpv_log,
audio_samplerate=44100,
audio_channels="stereo",
keep_open="no",
network_timeout=7,
msg_level=os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn"),
)
# Optional: allow forcing ao via environment for power users/debugging.
ao_env = os.environ.get("LVA_AO")
if ao_env:
try:
self.player["ao"] = ao_env
_LOGGER.info("Forcing mpv ao=%r from LVA_AO", ao_env)
except Exception:
_LOGGER.exception("Failed to set mpv ao=%r", ao_env)
# If the caller provided a specific device, honor it directly.
if device:
try:
self.player["audio-device"] = device
_LOGGER.info("Using mpv audio-device=%r", device)
except Exception:
_LOGGER.exception("Failed to set mpv audio-device %r", device)
# Log final backend/device selection and available devices (best-effort)
try:
ao_effective = self.player["ao"]
# python-mpv may expose an unset option as [] – normal for "auto"
if isinstance(ao_effective, list) and not ao_effective:
ao_display = "<unset/auto>"
else:
ao_display = ao_effective
audio_device_effective = self.player["audio-device"]
try:
dev_list = self.player.audio_device_list or []
dev_summary = [
f"{dev.get('name')} ({dev.get('description')})" for dev in dev_list
]
except Exception:
dev_summary = ["<unavailable>"]
_LOGGER.debug(
"mpv audio config: ao=%r, audio-device=%r, devices=%s",
ao_display,
audio_device_effective,
dev_summary,
)
except Exception:
_LOGGER.exception("Failed to query mpv audio properties")
# Keep mpv at 100% for normal playback. Master volume is handled at the OS level.
self.set_volume(100)
# Backend selection
self._ao, self._audio_device = _select_backend(self.player, device, env_ao)
# State
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None
self._pre_duck_volume: Optional[int] = None # stores 0..100 when ducked
self._watchdog: Optional[Timer] = None
self._watchdog_sec: float = float(watchdog_sec)
# When mpv becomes idle, we treat it as end-of-playback.
# Playback end detection (both EOF and idle)
self.player.observe_property("eof-reached", self._on_eof)
self.player.observe_property("idle-active", self._on_idle_active)
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
# -------------------- public API --------------------
def play(
self,
url: Union[str, Sequence[str], bytes],
done_callback: Optional[Callable[[], None]] = None,
stop_first: bool = False,
) -> None:
"""Plays a URL or sequence of URLs.
"""Begin playback of a URL or local file. Replaces any existing playback.
:param url: A single URL (str/bytes) or a sequence of URLs.
:param done_callback: Called once when playback finishes or is stopped.
Accepts a single URL (str/bytes) or a sequence of URLs; if a sequence is given,
the first item is played.
"""
# Ensure player is in a clean state
self.stop()
if stop_first:
try:
self.stop()
except Exception:
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
# Store callback
with self._done_callback_lock:
self._done_callback = done_callback
playlist: List[str] = []
# Normalize URL input
if isinstance(url, (list, tuple)):
playlist = list(url)
elif isinstance(url, bytes):
playlist = [url.decode(errors="ignore")]
elif isinstance(url, str):
playlist = [url]
else:
_LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url))
if not url:
_LOGGER.error("mpv play() received empty URL list")
self._run_done_callback()
return
url = url[0]
if isinstance(url, bytes):
try:
url = url.decode("utf-8")
except Exception:
url = url.decode(errors="ignore")
if not isinstance(url, str):
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
self._run_done_callback()
return
if not playlist:
self._run_done_callback()
return
# Load the full playlist into mpv
self.player.playlist_clear()
for item in playlist:
self.player.playlist_append(item)
# Ensure not muted
try:
self.player["mute"] = "no"
except Exception:
_LOGGER.debug("Failed to clear mute before play()", exc_info=True)
# Begin playback
self.is_playing = True
self.player.playlist_pos = 0 # Start playing from the first item
try:
self.player.play(url)
self._arm_watchdog()
except Exception:
self.is_playing = False
_LOGGER.exception("mpv failed to play %r", url, exc_info=True)
self._run_done_callback()
def pause(self) -> None:
"""Pauses playback."""
try:
self.player.pause = True
except Exception:
_LOGGER.exception("pause() failed")
_LOGGER.exception("mpv pause() failed", exc_info=True)
def resume(self) -> None:
"""Resumes playback."""
try:
self.player.pause = False
except Exception:
_LOGGER.exception("resume() failed")
_LOGGER.exception("mpv resume() failed", exc_info=True)
def stop(self) -> None:
"""Stops playback and clears the playlist."""
if self.is_playing:
self.is_playing = False
"""Stop playback immediately and clear playing state; run done callback."""
try:
try:
self.player.playlist_clear()
self.player.command("stop")
except Exception:
_LOGGER.exception("stop() failed")
finally:
# Fallback: load a null source to replace playback
self.player.command("loadfile", "null://", "replace")
finally:
self._cancel_watchdog()
was_playing = self.is_playing
self.is_playing = False
if was_playing:
self._run_done_callback()
def set_volume(self, volume: int) -> None:
"""Sets the player (mpv) volume from 0 to 100."""
def set_volume(self, *args, **kwargs) -> None:
"""Set volume 0..100. Accepts positional or keyword 'volume' (back-compat)."""
vol_arg = args[0] if args else kwargs.get("volume")
if vol_arg is None:
_LOGGER.error("set_volume() requires an integer (0..100)")
return
try:
self.player.volume = max(0, min(100, volume))
vol = max(0, min(100, int(vol_arg)))
except Exception:
_LOGGER.exception("set_volume() failed")
_LOGGER.exception("set_volume(): invalid value %r", vol_arg, exc_info=True)
return
def set_master_volume(self, volume_level: float) -> bool:
"""Sets the *OS output* volume (PipeWire/PulseAudio/ALSA).
:param volume_level: 0.0–1.0
:returns: True if any backend succeeded.
"""
return set_output_volume(volume_level=volume_level, output_device=self.device)
try:
self.player.volume = vol
except Exception:
_LOGGER.exception("mpv set_volume(%s) failed", vol, exc_info=True)
def duck(self, target_percent: int = 20) -> None:
"""Lowers the mpv volume for an announcement."""
"""Temporarily lower volume for TTS/alerts; call unduck() to restore."""
if self._pre_duck_volume is not None:
return
return # already ducked
try:
self._pre_duck_volume = int(self.player.volume)
self.set_volume(target_percent)
current = int(round(float(self.player.volume)))
except Exception:
_LOGGER.exception("duck() failed")
current = 100
self._pre_duck_volume = max(0, min(100, current))
try:
self.player.volume = max(0, min(100, int(target_percent)))
except Exception:
_LOGGER.exception("duck(): failed to set duck volume", exc_info=True)
def unduck(self) -> None:
"""Restores the mpv volume after an announcement."""
"""Restore volume after duck()."""
if self._pre_duck_volume is None:
return
try:
self.set_volume(self._pre_duck_volume)
self.player.volume = self._pre_duck_volume
except Exception:
_LOGGER.exception("unduck() failed")
_LOGGER.exception("unduck(): failed to restore volume", exc_info=True)
finally:
self._pre_duck_volume = None
# -------------------------------------------------------------------------
# Internal callbacks
# -------------------------------------------------------------------------
# -------------------- callbacks & watchdog --------------------
def _on_eof(self, _name: str, reached: bool) -> None:
if not reached:
return
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _on_idle_active(self, _name: str, active: bool) -> None:
"""Callback triggered when mpv enters or leaves the idle state."""
if active and self.is_playing:
# When playback ends, mpv enters idle; treat that as completion.
if not self.is_playing:
return
if bool(active):
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _arm_watchdog(self) -> None:
self._cancel_watchdog()
if self._watchdog_sec <= 0:
return
self._watchdog = Timer(self._watchdog_sec, self._watchdog_trip)
self._watchdog.daemon = True
self._watchdog.start()
def _cancel_watchdog(self) -> None:
if self._watchdog is not None:
try:
self._watchdog.cancel()
except Exception:
_LOGGER.debug("watchdog cancel raised", exc_info=True)
self._watchdog = None
def _watchdog_trip(self) -> None:
if self.is_playing:
_LOGGER.warning(
"mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec
)
try:
self.player.command("stop")
except Exception:
_LOGGER.debug("watchdog stop raised", exc_info=True)
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
"""Safely runs the done_callback on the main asyncio loop (if any)."""
cb: Optional[Callable[[], None]] = None
with self._done_callback_lock:
cb = self._done_callback
self._done_callback = None
if not cb:
return
# If we have an asyncio loop, schedule callback there.
if self.loop is not None:
try:
self.loop.call_soon_threadsafe(cb)
except Exception:
_LOGGER.exception("Error scheduling done_callback on loop")
else:
# Fallback: call directly (used in contexts where no loop is passed).
if cb is not None:
try:
cb()
except Exception:
_LOGGER.exception("Error running done_callback directly")
_LOGGER.exception("Unexpected error running done callback", exc_info=True)
# -------------------- mpv log bridge --------------------
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
"""Routes mpv's internal logs to our logger."""
msg = f"mpv[{prefix}]: {text}".rstrip()
if level == "error":
msg = f"mpv[{level}] {prefix}: {text}".rstrip()
if level in ("fatal", "error"):
_LOGGER.error(msg)
elif level == "warn":
elif level in ("warn", "warning"):
_LOGGER.warning(msg)
elif level == "info":
elif level in ("info",):
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)
-409
View File
@@ -1,409 +0,0 @@
import asyncio
import json
import logging
from typing import TYPE_CHECKING, List, Optional
import paho.mqtt.client as mqtt
from .config import MqttConfig
from .event_bus import EventBus, EventHandler, subscribe
from .models import Preferences
if TYPE_CHECKING:
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
class MqttController(EventHandler):
def __init__(
self,
loop: asyncio.AbstractEventLoop,
event_bus: EventBus,
config: MqttConfig,
app_name: str,
mac_address: str,
preferences: Preferences,
):
super().__init__(event_bus)
self.loop = loop
self.preferences = preferences
self._host = config.host
self._port = config.port
self._username = config.username
self._password = config.password
self._device_name = app_name
self._device_id = app_name.lower().replace(" ", "_")
self._topic_prefix = f"lva/{self._device_id}"
self._is_muted = False # Internal state
self.CONFIGURABLE_STATES: List[str] = [
"idle",
"listening",
"thinking",
"responding",
"error",
]
self.topics = {
"mute": {
"command": f"{self._topic_prefix}/mute/set",
"state": f"{self._topic_prefix}/mute/state",
},
"num_leds": {
"command": f"{self._topic_prefix}/num_leds/set",
"state": f"{self._topic_prefix}/num_leds/state",
},
# New: alarm duration
"alarm_duration": {
"command": f"{self._topic_prefix}/alarm_duration/set",
"state": f"{self._topic_prefix}/alarm_duration/state",
},
}
for state_name in self.CONFIGURABLE_STATES:
self.topics[state_name] = {
"effect_command": f"{self._topic_prefix}/{state_name}_effect/set",
"effect_state": f"{self._topic_prefix}/{state_name}_effect/state",
"light_command": f"{self._topic_prefix}/{state_name}_light/set",
"light_state": f"{self._topic_prefix}/{state_name}_light/state",
}
# During startup we accept retained */state topics from the broker to restore
# Home Assistant's last-known settings. After bootstrap, we ignore */state topics
# to avoid feedback loops (we only accept */set as commands).
self._bootstrap_state_sync = True
self._bootstrap_ends_at: Optional[float] = None
self._client = mqtt.Client()
self._client.on_connect = self._on_connect
self._client.on_message = self._on_message
# Subscribe to events *after* __init__ is complete
self._subscribe_all_methods()
def start(self):
try:
if self._username:
self._client.username_pw_set(self._username, self._password)
_LOGGER.debug(
"Connecting to MQTT broker at %s:%s", self._host, self._port
)
self._client.connect(self._host, self._port, 60)
self._client.loop_start()
except Exception:
_LOGGER.exception("Failed to connect to MQTT broker")
def stop(self):
self._client.publish(
f"{self._topic_prefix}/availability", "offline", retain=True
)
self._client.loop_stop()
self._client.disconnect()
_LOGGER.debug("Disconnected from MQTT broker")
def _on_connect(self, client, userdata, flags, rc):
if rc == 0:
_LOGGER.info("Connected to MQTT broker")
client.subscribe(f"{self._topic_prefix}/+/set")
client.subscribe(f"{self._topic_prefix}/+/state")
# End bootstrap a moment after subscribe so retained state messages can arrive.
self._bootstrap_ends_at = self.loop.time() + 2.0
self.loop.call_later(2.0, self._end_bootstrap_state_sync)
self._publish_discovery_configs()
else:
_LOGGER.error("Failed to connect to MQTT, return code %d", rc)
def _end_bootstrap_state_sync(self):
# Called on the asyncio loop thread.
self._bootstrap_state_sync = False
# We only needed */state subscriptions to ingest retained settings at startup.
# After bootstrap, unsubscribe to avoid feedback loops and log spam.
try:
self._client.unsubscribe(f"{self._topic_prefix}/+/state")
except Exception:
_LOGGER.debug("Failed to unsubscribe from */state topics", exc_info=True)
_LOGGER.debug(
"MQTT bootstrap state sync complete; unsubscribed from */state and will ignore any state topics"
)
def _on_message(self, client, userdata, msg):
try:
topic = msg.topic
payload = msg.payload.decode()
_LOGGER.debug(
"Received MQTT message on topic %s (from MQTT thread)", msg.topic
)
retain = bool(getattr(msg, "retain", False))
self.loop.call_soon_threadsafe(
self._handle_message_on_loop, topic, payload, retain
)
except Exception:
_LOGGER.exception("Error in _on_message")
def _handle_message_on_loop(self, topic: str, payload: str, retained: bool = False):
_LOGGER.debug("Handling message for topic %s on main loop", topic)
# Prevent feedback loops: we only treat */set as commands.
# However during bootstrap we accept retained */state values to restore HA settings.
if topic.endswith("/state"):
if not (self._bootstrap_state_sync and retained):
_LOGGER.debug("Ignoring MQTT state topic: %s (retained=%s, bootstrap=%s)", topic, retained, self._bootstrap_state_sync)
return
if topic == self.topics["mute"]["command"]:
# Publish event for MicMuteHandler to process
self.event_bus.publish(
"set_mic_mute", {"state": payload.upper() == "ON"}
)
elif topic == self.topics["num_leds"]["command"]:
try:
num_leds = int(payload)
# Publish event for LedController and MicMuteHandler (to save)
self.event_bus.publish("set_num_leds", {"num_leds": num_leds})
self.publish_num_leds_state(num_leds)
except ValueError:
pass
elif topic == self.topics["alarm_duration"]["command"]:
try:
duration = int(payload)
if duration < 0:
raise ValueError
# Publish event for MicMuteHandler (to save preferences)
self.event_bus.publish(
"set_alarm_duration",
{"alarm_duration_seconds": duration},
)
self.publish_alarm_duration_state(duration)
except ValueError:
_LOGGER.warning(
"Invalid alarm_duration payload received: %r", payload
)
for state_name in self.CONFIGURABLE_STATES:
state_topics = self.topics[state_name]
if topic == state_topics["effect_command"]:
effect_id = payload.lower().replace(" ", "_")
self.event_bus.publish(
f"set_{state_name}_effect", {"effect": effect_id}
)
elif topic == state_topics["light_command"]:
try:
data = json.loads(payload)
if "state" in data:
if data["state"].upper() == "OFF":
self.event_bus.publish(
f"set_{state_name}_effect", {"effect": "off"}
)
else: # ON command
self.event_bus.publish(f"turn_on_{state_name}")
if "color" in data or "brightness" in data:
self.event_bus.publish(
f"set_{state_name}_color", data
)
except json.JSONDecodeError:
pass
elif topic == state_topics["effect_state"]:
effect_id = payload.lower().replace(" ", "_")
self.event_bus.publish(
f"set_{state_name}_effect",
{"effect": effect_id, "retained": retained},
)
elif topic == state_topics["light_state"]:
try:
data = json.loads(payload)
data["retained"] = retained
self.event_bus.publish(
f"set_{state_name}_color", data
)
except json.JSONDecodeError:
pass
def _publish_discovery_configs(self):
availability_topic = f"{self._topic_prefix}/availability"
device_info = {
"identifiers": [self._device_id],
"name": self._device_name,
"manufacturer": "LVA Project",
}
options = [
"Off",
"Solid",
"Slow Pulse",
"Medium Pulse",
"Fast Pulse",
"Slow Blink",
"Medium Blink",
"Fast Blink",
"Spin",
]
mute_cfg = {
"name": "Mute Microphone",
"unique_id": f"{self._device_id}_mute",
"command_topic": self.topics["mute"]["command"],
"state_topic": self.topics["mute"]["state"],
"availability_topic": availability_topic,
"icon": "mdi:microphone-off",
"device": device_info,
}
self._client.publish(
f"homeassistant/switch/{self._device_id}_mute/config",
json.dumps(mute_cfg),
retain=True,
)
num_leds_cfg = {
"name": "Number of LEDs",
"unique_id": f"{self._device_id}_num_leds",
"command_topic": self.topics["num_leds"]["command"],
"state_topic": self.topics["num_leds"]["state"],
"availability_topic": availability_topic,
"min": 1,
"max": 256,
"step": 1,
"icon": "mdi:counter",
"device": device_info,
"mode": "box",
"entity_category": "config",
}
self._client.publish(
f"homeassistant/number/{self._device_id}_num_leds/config",
json.dumps(num_leds_cfg),
retain=True,
)
# New: Alarm Duration number (seconds)
alarm_duration_cfg = {
"name": "Alarm Duration",
"unique_id": f"{self._device_id}_alarm_duration",
"command_topic": self.topics["alarm_duration"]["command"],
"state_topic": self.topics["alarm_duration"]["state"],
"availability_topic": availability_topic,
"min": 0,
"max": 3600,
"step": 5,
"unit_of_measurement": "s",
"icon": "mdi:timer",
"device": device_info,
"mode": "box",
"entity_category": "config",
}
self._client.publish(
f"homeassistant/number/{self._device_id}_alarm_duration/config",
json.dumps(alarm_duration_cfg),
retain=True,
)
for state_name in self.CONFIGURABLE_STATES:
capital_name = state_name.title()
select_cfg = {
"name": f"{capital_name} Effect",
"unique_id": f"{self._device_id}_{state_name}_effect",
"command_topic": self.topics[state_name]["effect_command"],
"state_topic": self.topics[state_name]["effect_state"],
"availability_topic": availability_topic,
"options": options,
"icon": "mdi:palette-swatch-variant",
"device": device_info,
"entity_category": "config",
}
self._client.publish(
f"homeassistant/select/{self._device_id}_{state_name}_effect/config",
json.dumps(select_cfg),
retain=True,
)
light_cfg = {
"name": f"{capital_name} Color",
"unique_id": f"{self._device_id}_{state_name}_color",
"schema": "json",
"command_topic": self.topics[state_name]["light_command"],
"state_topic": self.topics[state_name]["light_state"],
"availability_topic": availability_topic,
"brightness": True,
"color_mode": True,
"supported_color_modes": ["rgb"],
"device": device_info,
"entity_category": "config",
}
self._client.publish(
f"homeassistant/light/{self._device_id}_{state_name}_color/config",
json.dumps(light_cfg),
retain=True,
)
_LOGGER.debug("Published all MQTT discovery configs")
self._client.publish(availability_topic, "online", retain=True)
# Publish initial state from internal values
self.publish_mute_state(self._is_muted)
self.publish_num_leds_state(self.preferences.num_leds)
self.publish_alarm_duration_state(
getattr(self.preferences, "alarm_duration_seconds", 0)
)
def publish_mute_state(self, is_muted: bool):
self._is_muted = is_muted
self._client.publish(
self.topics["mute"]["state"],
"ON" if is_muted else "OFF",
retain=True,
)
def publish_num_leds_state(self, num_leds: int):
self._client.publish(
self.topics["num_leds"]["state"], str(num_leds), retain=True
)
def publish_alarm_duration_state(self, duration_seconds: int):
self._client.publish(
self.topics["alarm_duration"]["state"],
str(int(duration_seconds)),
retain=True,
)
@subscribe
def publish_state_to_mqtt(self, data: dict):
state_name = data.get("state_name")
if state_name in self.topics:
state_topics = self.topics[state_name]
effect_name = data.get("effect", "off").replace("_", " ").title()
self._client.publish(
state_topics["effect_state"], effect_name, retain=True
)
light_state = {
"state": "ON" if data.get("effect") != "off" else "OFF",
"color_mode": "rgb",
"brightness": int(data.get("brightness", 0.5) * 255),
"color": {
"r": data.get("color")[0],
"g": data.get("color")[1],
"b": data.get("color")[2],
},
}
self._client.publish(
state_topics["light_state"],
json.dumps(light_state),
retain=True,
)
# --- Listen for state changes from other components ---
@subscribe
def mic_muted(self, data: dict):
self.publish_mute_state(True)
@subscribe
def mic_unmuted(self, data: dict):
self.publish_mute_state(False)
@@ -0,0 +1,364 @@
import json
import logging
import socket
import threading
from typing import Callable, Optional, List, Dict, Any
from urllib.parse import urlparse
from pathlib import Path
import time
# Models that should NOT appear in UI dropdowns
EXCLUDED_OWW_MODELS = {"embedding_model", "melspectrogram"}
# Models that are not actual wake-word detectors and should never be shown
_OWW_DETECT_MODEL_DENYLIST = {"embedding_model", "melspectrogram"}
import re
def _prettify_model_name(name: str) -> str:
"""
Convert 'hey_jarvis_v0.1' -> 'Hey Jarvis', 'ok_nabu_v0.1' -> 'OK Nabu',
'hal_v2' -> 'Hal', 'marvin_v2' -> 'Marvin'.
"""
stem = Path(name).stem
# Strip common version suffixes
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
return " ".join(parts)
def _normalize_model_name(name: str) -> str:
# Accept "foo_v2", "foo_v2.tflite", full paths, etc.
stem = Path(name).stem
return stem
def _filter_detect_models(models: list[str]) -> list[str]:
# Remove helper models and de-duplicate
normalized = {_normalize_model_name(m) for m in models}
return sorted(m for m in normalized if m not in _OWW_DETECT_MODEL_DENYLIST)
_LOGGER = logging.getLogger(__name__)
def _jsonl(obj: dict) -> bytes:
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
class WyomingWakeClient:
"""Minimal client for Wyoming wake detection (openWakeWord)."""
def __init__(self, uri: str, name: str, rate=16000, width=2, channels=1):
self.uri, self.name = uri, name
self.rate, self.width, self.channels = rate, width, channels
self._paused = False
self._suppress_until_ms = 0
self._refractory_ms = 0
self._sock: Optional[socket.socket] = None
self._reader: Optional[threading.Thread] = None
self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None
self._closed = False
# model discovery cache
self._models_cache: List[Dict[str, Any]] = []
self._info_event = threading.Event()
def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None:
self._on_detect = on_detect
parsed = urlparse(self.uri)
if parsed.scheme != "tcp":
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
self._sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 10400))
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Start audio stream
self._send({"type": "audio-start", "data": {"rate": self.rate, "width": self.width, "channels": self.channels}})
# Ask for model info
try:
self._send({"type": "describe"})
except Exception:
pass
# Set initial detect model
if self.name:
try:
self._send({"type": "detect", "data": {"names": [self.name]}})
except Exception:
pass
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
_LOGGER.debug("Connected to Wyoming wake server at %s", self.uri)
def close(self) -> None:
self._closed = True
try:
self._send({"type": "audio-stop"})
except Exception:
pass
try:
if self._sock:
self._sock.close()
finally:
self._sock = None
def send_audio_chunk(self, pcm: bytes, timestamp_ms: Optional[int] = None) -> None:
if self._sock is None or self._closed:
return
hdr = {"type": "audio-chunk",
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
"payload_length": len(pcm)}
if timestamp_ms is not None:
hdr["data"]["timestamp"] = timestamp_ms
self._sock.sendall(_jsonl(hdr))
self._sock.sendall(pcm)
def pause(self) -> None:
self._paused = True
def resume(self) -> None:
self._paused = False
def suppress(self, ms: int) -> None:
try:
now_ms = int(time.time() * 1000)
except Exception:
now_ms = 0
self._suppress_until_ms = max(self._suppress_until_ms, now_ms + int(ms))
def set_refractory(self, ms: int) -> None:
self._refractory_ms = max(0, int(ms))
def list_models(self):
"""Query models using a short-lived connection with timeout; accept 'describe' or 'info'."""
parsed = urlparse(self.uri)
if parsed.scheme != "tcp":
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
host, port = (parsed.hostname or "127.0.0.1", parsed.port or 10400)
s = socket.create_connection((host, port))
try:
s.settimeout(1.5)
s.sendall(_jsonl({"type": "describe"}))
f = s.makefile("rb")
while True:
try:
line = f.readline()
except Exception:
break
if not line:
break
try:
msg = json.loads(line.decode("utf-8"))
except Exception:
continue
mtype = msg.get("type")
if mtype in ("describe", "info"):
data = msg.get("data", {}) or {}
wake = data.get("wake", {}) or {}
models = wake.get("models") or wake.get("names") or []
norm: List[Dict[str, Any]] = []
for m in models:
if isinstance(m, str):
norm.append({"name": m})
elif isinstance(m, dict):
norm.append(m)
out: List[Dict[str, Any]] = []
for m in norm:
name = (m.get('name') or '').strip()
if not name or name in EXCLUDED_OWW_MODELS:
continue
phrase = (m.get('phrase') or '').strip()
desc = (m.get('description') or '').strip()
try:
label = phrase or desc or _prettify_model_name(name) # type: ignore[name-defined]
except NameError:
stem = Path(name).stem
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
label = " ".join(parts)
mm = dict(m)
mm['label'] = label
out.append(mm)
return out
finally:
try:
s.close()
except Exception:
pass
return []
def get_models(self, timeout: float = 1.5):
"""Return cached models after waiting briefly for an 'info'/'describe' update."""
self._info_event.wait(timeout)
return list(self._models_cache)
def set_detect(self, names):
if self._sock is None:
raise RuntimeError("Not connected")
filtered = [n for n in list(names) if n]
if not filtered:
_LOGGER.warning("Ignoring empty detect() request (no model names)")
return
self.name = filtered[0]
_LOGGER.debug("Requesting Wyoming detect models: %s", filtered)
self._send({"type": "detect", "data": {"names": filtered}})
# internals
def _send(self, obj: dict) -> None:
assert self._sock is not None
self._sock.sendall(_jsonl(obj))
# --- paste this to replace the whole _read_loop method ---
def _read_loop(self) -> None:
assert self._sock is not None
f = self._sock.makefile("rb")
buf = "" # text buffer for concatenated JSON objects
def _yield_objects_from_text(text: str):
"""Yield JSON objects from `text` even if they are concatenated like {}{}..."""
start = 0
depth = 0
in_str = False
escape = False
for i, ch in enumerate(text):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
# complete object from start..i
yield text[start : i + 1], i + 1
# return here with any remainder unparsed
return
while not self._closed:
chunk = f.readline()
if not chunk:
break
try:
piece = chunk.decode("utf-8")
except Exception:
try:
_LOGGER.debug("Wyoming raw (non-utf8): %r", chunk[:200])
except Exception:
pass
continue
buf += piece
# Try to peel off as many complete JSON objects as we can
pos = 0
progressed = True
while progressed and pos < len(buf):
progressed = False
for obj_text, end_pos in _yield_objects_from_text(buf[pos:]):
progressed = True
pos += end_pos
try:
msg = json.loads(obj_text)
except Exception:
try:
_LOGGER.debug("Wyoming raw (bad-json): %r", obj_text[:200])
except Exception:
pass
continue
mtype = str(msg.get("type") or "").lower()
try:
_LOGGER.debug("Received Wyoming message type=%s", mtype or "<?>")
if mtype and mtype not in ("info", "describe"):
_LOGGER.debug("Wyoming raw: %r", obj_text[:200])
except Exception:
pass
# ----- model info path -----
if mtype in ("info", "describe"):
data = msg.get("data", {}) or {}
wake = data.get("wake", {}) if isinstance(data, dict) else {}
models = []
if isinstance(wake, dict):
if isinstance(wake.get("models"), list):
models = wake.get("models")
elif isinstance(wake.get("names"), list):
models = wake.get("names")
norm = []
for m in models:
if isinstance(m, str):
norm.append({"name": m})
elif isinstance(m, dict):
norm.append(m)
if norm:
self._models_cache = norm
self._info_event.set()
_LOGGER.debug(
"Wyoming model list updated: %s",
[m.get("name") for m in norm],
)
continue
# ----- detection path (accept several shapes) -----
data = msg.get("data", {}) or {}
is_detection = False
if mtype == "detection":
is_detection = True
elif mtype in ("event", "wake", "hotword"):
ev = str(data.get("event") or "").lower()
if ev in ("detection", "wake", "hotword"):
is_detection = True
if is_detection:
name = (
data.get("name")
or data.get("model")
or data.get("wakeword")
or self.name
)
ts = data.get("timestamp") or data.get("time") or data.get("ts")
_LOGGER.debug("Wake detected: %s ts=%s", name, ts)
if self._on_detect:
try:
now_ms = int(time.time() * 1000)
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
else:
now_ms = int(time.time() * 1000)
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
else:
self._on_detect(name, ts)
if getattr(self, '_refractory_ms', 0):
self.suppress(self._refractory_ms)
if getattr(self, '_refractory_ms', 0):
self.suppress(self._refractory_ms)
except Exception:
_LOGGER.exception("Error in detection callback")
# keep any unconsumed remainder in buffer
buf = buf[pos:]
-702
View File
@@ -1,702 +0,0 @@
"""Voice satellite protocol."""
import asyncio
import functools
import hashlib
import logging
import posixpath
import shutil
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Dict, Optional, Set, Union, List
from urllib.parse import urlparse, urlunparse
from urllib.request import urlopen
from aioesphomeapi.api_pb2 import (
DeviceInfoRequest,
DeviceInfoResponse,
ListEntitiesDoneResponse,
ListEntitiesRequest,
MediaPlayerCommandRequest,
SubscribeHomeAssistantStatesRequest,
VoiceAssistantAnnounceFinished,
VoiceAssistantAnnounceRequest,
VoiceAssistantAudio,
VoiceAssistantConfigurationRequest,
VoiceAssistantConfigurationResponse,
VoiceAssistantEventResponse,
VoiceAssistantExternalWakeWord,
VoiceAssistantRequest,
VoiceAssistantSetConfiguration,
VoiceAssistantTimerEventResponse,
VoiceAssistantWakeWord,
)
from aioesphomeapi.model import (
VoiceAssistantEventType,
VoiceAssistantFeature,
VoiceAssistantTimerEventType,
)
from google.protobuf import message
from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer
from .entity import MediaPlayerEntity
from .models import AvailableWakeWord, ServerState, SatelliteState, WakeWordType
from .util import call_all
_LOGGER = logging.getLogger(__name__)
class VoiceSatelliteProtocol(APIServer):
def __init__(self, state: ServerState) -> None:
super().__init__(state.name)
self.state = state
self.state.satellite = self
# --- Ensure exactly one MediaPlayerEntity with a stable key ---
if self.state.entities:
existing = self.state.entities[0]
if not isinstance(existing, MediaPlayerEntity):
_LOGGER.warning(
"First ESPHome entity is not MediaPlayerEntity (%r). "
"Replacing it with a new MediaPlayerEntity.",
type(existing),
)
self.media_player_entity = MediaPlayerEntity(
server=self,
state=state,
key=0,
name="Media Player",
object_id="linux_voice_assistant_media_player",
music_player=state.music_player,
announce_player=state.tts_player,
)
self.state.entities[0] = self.media_player_entity
else:
# Reuse existing entity but bind it to the current protocol
self.media_player_entity = existing
self.media_player_entity.server = self
self.media_player_entity.key = 0
else:
# First connection in this process: create the media player once
self.media_player_entity = MediaPlayerEntity(
server=self,
state=state,
key=0,
name="Media Player",
object_id="linux_voice_assistant_media_player",
music_player=state.music_player,
announce_player=state.tts_player,
)
self.state.entities.append(self.media_player_entity)
# If more entities somehow accumulated, prune them to avoid confusing HA
if len(self.state.entities) > 1:
_LOGGER.warning(
"Pruning %d extra ESPHome entities; keeping only the first.",
len(self.state.entities) - 1,
)
del self.state.entities[1:]
# State machine
self._state: SatelliteState = SatelliteState.STARTING
self._is_streaming_audio: bool = False
self._tts_url: Optional[str] = None
self._continue_conversation: bool = False
self._timer_finished: bool = False
self._run_end_received: bool = False
self._tts_end_received: bool = False
# Track if current audio is an announcement
self._is_announcement: bool = False
# External wake words announced by Home Assistant
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
# Timer alarm auto-stop handle
self._timer_auto_stop_handle: Optional[asyncio.TimerHandle] = None
# -------------------------------------------------------------------------
# State machine helpers
# -------------------------------------------------------------------------
def _set_state(self, new_state: SatelliteState):
if self._state == new_state:
return
_LOGGER.debug("State transition: %s -> %s", self._state, new_state)
self._state = new_state
if new_state == SatelliteState.IDLE:
self.unduck()
self.state.active_wake_words.discard(self.state.stop_word.id)
self.state.event_bus.publish("voice_idle")
elif new_state == SatelliteState.LISTENING:
self.duck()
self.state.event_bus.publish("voice_listen")
elif new_state == SatelliteState.THINKING:
self.state.event_bus.publish("voice_thinking")
elif new_state == SatelliteState.RESPONDING:
self.state.active_wake_words.add(self.state.stop_word.id)
self.state.event_bus.publish("voice_responding")
elif new_state == SatelliteState.ERROR:
self.state.event_bus.publish("voice_error")
# -------------------------------------------------------------------------
# Voice assistant events
# -------------------------------------------------------------------------
def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
) -> None:
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
self._run_end_received = False
self._tts_end_received = False
self._is_announcement = False
self._tts_url = data.get("url")
self._continue_conversation = False
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START:
self._set_state(SatelliteState.LISTENING)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START:
self.state.event_bus.publish("voice_vad_start", data)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
self._is_streaming_audio = False
self._set_state(SatelliteState.THINKING)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END:
# No-op for now; could be used for LED cues
pass
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
if data.get("continue_conversation") == "1":
self._continue_conversation = True
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
self._set_state(SatelliteState.RESPONDING)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
self._tts_url = data.get("url")
self._tts_end_received = True
self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
self._run_end_received = True
if self._state != SatelliteState.RESPONDING:
self._determine_final_state()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
code = data.get("code")
message = data.get("message")
_LOGGER.debug(
"VoiceAssistant error received: code=%s, message=%s", code, message
)
# Treat "no text recognized" as a benign outcome, not a hard error.
if code == "stt-no-text-recognized":
_LOGGER.debug(
"No text recognized from STT; treating as benign and returning to IDLE."
)
# Ensure we stop streaming audio for this run
self._is_streaming_audio = False
# Go directly back to IDLE (unduck, idle LEDs, etc.)
self._set_state(SatelliteState.IDLE)
return
# All other errors follow the normal error path.
self._set_state(SatelliteState.ERROR)
# After a brief period, return to IDLE automatically.
self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE)
def handle_timer_event(
self,
event_type: VoiceAssistantTimerEventType,
msg: VoiceAssistantTimerEventResponse,
) -> None:
_LOGGER.debug("Timer event: type=%s", event_type.name)
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
if not self._timer_finished:
self.state.active_wake_words.add(self.state.stop_word.id)
self._timer_finished = True
self.duck()
self._play_timer_finished()
# Schedule auto-stop if configured
duration = getattr(
self.state.preferences, "alarm_duration_seconds", 0
)
if duration > 0:
if self._timer_auto_stop_handle is not None:
self._timer_auto_stop_handle.cancel()
_LOGGER.debug(
"Scheduling auto-stop for timer alarm after %s seconds",
duration,
)
self._timer_auto_stop_handle = self.state.loop.call_later(
duration, self._auto_stop_timer_alarm
)
# -------------------------------------------------------------------------
# Main message handler (called by APIServer)
# -------------------------------------------------------------------------
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
"""
Handles incoming messages from Home Assistant.
Note: This method is synchronous. Long-running operations must be offloaded to Tasks.
"""
if isinstance(msg, VoiceAssistantEventResponse):
data: Dict[str, str] = {}
for arg in msg.data:
data[arg.name] = arg.value
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text)
urls: List[str] = []
if msg.preannounce_media_id:
urls.append(msg.preannounce_media_id)
urls.append(msg.media_id)
self._is_announcement = True
self.state.active_wake_words.add(self.state.stop_word.id)
self._continue_conversation = msg.start_conversation
self.duck()
self._set_state(SatelliteState.RESPONDING)
yield from self.media_player_entity.play(
urls, announcement=True, done_callback=self._tts_finished
)
elif isinstance(msg, VoiceAssistantTimerEventResponse):
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
elif isinstance(msg, DeviceInfoRequest):
yield DeviceInfoResponse(
uses_password=False,
name=self.state.name,
mac_address=self.state.mac_address,
voice_assistant_feature_flags=(
VoiceAssistantFeature.VOICE_ASSISTANT
| VoiceAssistantFeature.API_AUDIO
| VoiceAssistantFeature.ANNOUNCE
| VoiceAssistantFeature.START_CONVERSATION
| VoiceAssistantFeature.TIMERS
),
)
elif isinstance(
msg,
(
ListEntitiesRequest,
SubscribeHomeAssistantStatesRequest,
MediaPlayerCommandRequest,
),
):
for entity in self.state.entities:
yield from entity.handle_message(msg)
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest):
# Build list of available wake words (built-in + external)
available_wake_words: List[VoiceAssistantWakeWord] = [
VoiceAssistantWakeWord(
id=ww.id,
wake_word=ww.wake_word,
trained_languages=ww.trained_languages,
)
for ww in self.state.available_wake_words.values()
]
# Reset external wake words cache and add new ones
self._external_wake_words.clear()
for eww in msg.external_wake_words:
if eww.model_type != "micro":
_LOGGER.warning(
"Skipping external wake word %s (type=%s)",
eww.id,
eww.model_type,
)
continue
available_wake_words.append(
VoiceAssistantWakeWord(
id=eww.id,
wake_word=eww.wake_word,
trained_languages=eww.trained_languages,
)
)
self._external_wake_words[eww.id] = eww
_LOGGER.debug(
"VoiceAssistantConfigurationRequest: external_wake_words=%s",
[eww.id for eww in msg.external_wake_words],
)
# IMPORTANT: Use self.state.active_wake_words directly here, instead of
# filtering through self.state.wake_words (which may not yet contain
# newly requested models while they're still loading).
yield VoiceAssistantConfigurationResponse(
available_wake_words=available_wake_words,
active_wake_words=sorted(self.state.active_wake_words),
max_active_wake_words=2,
)
_LOGGER.info("Connected to Home Assistant")
self._set_state(SatelliteState.IDLE)
self.state.event_bus.publish("ha_connected")
elif isinstance(msg, VoiceAssistantSetConfiguration):
requested_ids = list(msg.active_wake_words)
_LOGGER.debug(
"VoiceAssistantSetConfiguration received: active_wake_words=%s",
requested_ids,
)
# Update the active_wake_words set immediately so that the *next*
# VoiceAssistantConfigurationRequest sees the new state, even while
# we are still downloading/loading models in the background.
self.state.active_wake_words = set(requested_ids)
# Offload heavy work (downloads + model loading) to a background task
self.state.loop.create_task(self._handle_set_configuration_task(msg))
# Yield nothing immediately; response to ConfigurationRequest is handled
# separately in the VoiceAssistantConfigurationRequest branch.
# -------------------------------------------------------------------------
# SetConfiguration handler (async, runs in background)
# -------------------------------------------------------------------------
async def _handle_set_configuration_task(
self, msg: VoiceAssistantSetConfiguration
) -> None:
"""Asynchronous handler for SetConfiguration to avoid blocking I/O."""
requested_ids = list(msg.active_wake_words)
_LOGGER.debug(
"Applying SetConfiguration: requested active_wake_words=%s",
requested_ids,
)
active_wake_words: Set[str] = set()
for wake_word_id in requested_ids:
if wake_word_id in self.state.wake_words:
# Already loaded in this process; just mark it active.
active_wake_words.add(wake_word_id)
continue
model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info:
external_wake_word = self._external_wake_words.get(wake_word_id)
if not external_wake_word:
_LOGGER.warning("Unknown wake word: %s", wake_word_id)
continue
# Await the non-blocking download
model_info = await self._download_external_wake_word(
external_wake_word
)
if not model_info:
continue
self.state.available_wake_words[wake_word_id] = model_info
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
self.state.wake_words[wake_word_id] = model_info.load()
_LOGGER.info("Wake word set: %s", wake_word_id)
active_wake_words.add(wake_word_id)
# Do NOT break; we want to process all requested wake words.
# Finalize active wake words with the subset that actually succeeded
self.state.active_wake_words = active_wake_words
_LOGGER.debug(
"Active wake words after SetConfiguration: %s", active_wake_words
)
self.state.preferences.active_wake_words = list(active_wake_words)
self.state.save_preferences()
self.state.wake_words_changed = True
# -------------------------------------------------------------------------
# Audio handling and wake word triggers
# -------------------------------------------------------------------------
def handle_audio(self, audio_chunk: bytes) -> None:
if self._is_streaming_audio:
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def _clear_timer_auto_stop(self) -> None:
"""Cancel any pending auto-stop for the timer alarm."""
if self._timer_auto_stop_handle is not None:
try:
self._timer_auto_stop_handle.cancel()
except Exception:
_LOGGER.exception("Failed to cancel timer auto-stop handle")
finally:
self._timer_auto_stop_handle = None
def _stop_timer_alarm(self, reason: str) -> None:
"""
Stop the repeating timer-finished alarm sound and clean up flags.
This is used for:
- Stop wake word / explicit stop()
- Any wake word while timer is ringing (existing behavior)
- Auto-stop after alarm_duration_seconds
"""
if not self._timer_finished:
return
_LOGGER.debug("Stopping timer finished sound (%s)", reason)
self._timer_finished = False
self._clear_timer_auto_stop()
try:
self.state.tts_player.stop()
except Exception:
_LOGGER.exception("Error stopping timer finished TTS player")
# Ensure we unduck and remove the stop word from active set
self.unduck()
self.state.active_wake_words.discard(self.state.stop_word.id)
def _auto_stop_timer_alarm(self) -> None:
"""Auto-stop callback fired after alarm_duration_seconds."""
if not self._timer_finished:
return
duration = getattr(self.state.preferences, "alarm_duration_seconds", 0)
_LOGGER.debug(
"Auto-stopping timer finished alarm after %s seconds", duration
)
self._stop_timer_alarm("auto_timeout")
def _start_conversation(self, wake_word_phrase: str) -> None:
"""Shared helper to start a new conversation run."""
_LOGGER.debug("Starting conversation: %s", wake_word_phrase)
self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
)
self._set_state(SatelliteState.LISTENING)
self._is_streaming_audio = True
self.state.tts_player.play(self.state.wakeup_sound)
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
# Existing behavior: ignore wakeup in other states.
return
# If a timer alarm is currently ringing, stop it instead of starting
# a new conversation run.
if self._timer_finished:
self._stop_timer_alarm("wakeup")
return
wake_word_phrase = getattr(wake_word, "wake_word", "wake word")
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self._start_conversation(wake_word_phrase)
def manual_wakeup(self, phrase: str = "button") -> None:
"""
Manual wakeup entrypoint (e.g. hardware button) that behaves like a
wake word, but without requiring a wake-word model instance.
"""
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
return
if self._timer_finished:
self._stop_timer_alarm("button")
return
_LOGGER.debug("Manual wakeup triggered: %s", phrase)
self._start_conversation(phrase)
def stop(self) -> None:
"""
Called when the Stop wake word is detected (or equivalent).
For timer alarms:
- Stop the repeating alarm.
For TTS:
- Stop playback and treat as user-aborted response.
"""
self.state.active_wake_words.discard(self.state.stop_word.id)
# If the timer alarm is ringing, stop that instead of a TTS run.
if self._timer_finished:
self._stop_timer_alarm("stop_wake_word")
return
# Otherwise this is stopping a TTS response.
self.state.tts_player.stop()
_LOGGER.debug("TTS response stopped manually")
self._tts_finished()
def play_tts(self) -> None:
if not self._tts_url:
return
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
self.state.active_wake_words.add(self.state.stop_word.id)
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
self._tts_url = None
def duck(self) -> None:
_LOGGER.debug("Ducking music")
self.state.music_player.duck()
def unduck(self) -> None:
_LOGGER.debug("Unducking music")
self.state.music_player.unduck()
def _determine_final_state(self) -> None:
if self._continue_conversation:
self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True
_LOGGER.debug("Continuing conversation")
self._set_state(SatelliteState.LISTENING)
else:
self._set_state(SatelliteState.IDLE)
_LOGGER.debug("Final state determined")
def _tts_finished(self) -> None:
self.state.active_wake_words.discard(self.state.stop_word.id)
self.send_messages([VoiceAssistantAnnounceFinished()])
_LOGGER.debug("TTS audio playback finished")
# If this was just an announcement, we are done,
# or if we received the official Run End.
if self._is_announcement or self._run_end_received:
self._determine_final_state()
self._is_announcement = False
else:
if self._state == SatelliteState.RESPONDING:
self._set_state(SatelliteState.THINKING)
def _play_timer_finished(self) -> None:
"""
Play the timer-finished sound in a loop until either:
- _timer_finished is cleared (Stop/wakeup/auto-timeout), or
- alarm_duration_seconds == 0 and user explicitly stops it.
"""
if not self._timer_finished:
# Alarm has been cleared; restore audio state.
self.unduck()
return
self.state.tts_player.play(
self.state.timer_finished_sound,
done_callback=lambda: call_all(
lambda: time.sleep(1.0), self._play_timer_finished
),
)
# -------------------------------------------------------------------------
# External wake word download helpers
# -------------------------------------------------------------------------
async def _download_external_wake_word(
self, external_wake_word: VoiceAssistantExternalWakeWord
) -> Optional[AvailableWakeWord]:
"""Wrapper to run the blocking download in a thread executor."""
return await self.state.loop.run_in_executor(
None,
functools.partial(
self._download_external_wake_word_sync, external_wake_word
),
)
def _download_external_wake_word_sync(
self, external_wake_word: VoiceAssistantExternalWakeWord
) -> Optional[AvailableWakeWord]:
"""Blocking download logic, intended to run in an executor."""
eww_dir = self.state.download_dir / "external_wake_words"
eww_dir.mkdir(parents=True, exist_ok=True)
config_path = eww_dir / f"{external_wake_word.id}.json"
should_download_config = not config_path.exists()
model_path = eww_dir / f"{external_wake_word.id}.tflite"
should_download_model = True
if model_path.exists():
model_size = model_path.stat().st_size
if model_size == external_wake_word.model_size:
with open(model_path, "rb") as model_file:
model_hash = hashlib.sha256(model_file.read()).hexdigest()
if model_hash == external_wake_word.model_hash:
should_download_model = False
_LOGGER.debug(
"Model size and hash match for %s. Skipping download.",
external_wake_word.id,
)
if should_download_config or should_download_model:
_LOGGER.debug(
"Downloading %s to %s", external_wake_word.url, config_path
)
try:
with urlopen(external_wake_word.url, timeout=10) as request:
if request.status != 200:
_LOGGER.warning(
"Failed to download: %s, status=%s",
external_wake_word.url,
request.status,
)
return None
with open(config_path, "wb") as model_file:
shutil.copyfileobj(request, model_file)
except Exception as exc: # noqa: BLE001
_LOGGER.error("Exception downloading config: %s", exc)
return None
if should_download_model:
parsed_url = urlparse(external_wake_word.url)
parsed_url = parsed_url._replace(
path=posixpath.join(
posixpath.dirname(parsed_url.path), model_path.name
)
)
model_url = urlunparse(parsed_url)
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
try:
with urlopen(model_url, timeout=10) as request:
if request.status != 200:
_LOGGER.warning(
"Failed to download: %s, status=%s",
model_url,
request.status,
)
return None
with open(model_path, "wb") as model_file:
shutil.copyfileobj(request, model_file)
except Exception as exc: # noqa: BLE001
_LOGGER.error("Exception downloading model: %s", exc)
return None
return AvailableWakeWord(
id=external_wake_word.id,
type=WakeWordType.MICRO_WAKE_WORD,
wake_word=external_wake_word.wake_word,
trained_languages=external_wake_word.trained_languages,
wake_word_path=config_path,
)
# -------------------------------------------------------------------------
# Connection lifecycle
# -------------------------------------------------------------------------
def connection_lost(self, exc):
super().connection_lost(exc)
self._set_state(SatelliteState.ERROR)
_LOGGER.info("Disconnected from Home Assistant")
+10 -34
View File
@@ -1,46 +1,22 @@
"""Utility methods."""
import logging
import platform
import uuid
from collections.abc import Callable
from typing import Optional
_LOGGER = logging.getLogger(__name__)
_CACHED_MAC: Optional[str] = None
def get_mac_address() -> str:
"""
Get the MAC address as a hex string (lowercase, no colons).
Example: "b827eb123456"
This is a thin wrapper around uuid.getnode(), cached so we only
compute/log it once per process.
"""
global _CACHED_MAC
if _CACHED_MAC:
return _CACHED_MAC
node = uuid.getnode()
mac_hex = f"{node:012x}"
# If the multicast bit is set, this is probably not a real hardware MAC.
if (node >> 40) & 1:
_LOGGER.warning(
"uuid.getnode() returned a MAC with the multicast bit set; "
"discovery identity may change on reboot."
)
_LOGGER.debug("Using MAC address from uuid.getnode(): %s", mac_hex)
_CACHED_MAC = mac_hex
return _CACHED_MAC
def format_mac(mac: str) -> str:
"""Format a hex MAC string with colons (e.g., aa:bb:cc:dd:ee:ff)."""
return ":".join(mac[i : i + 2] for i in range(0, 12, 2))
def get_mac() -> str:
mac = uuid.getnode()
mac_str = ":".join(f"{(mac >> i) & 0xff:02x}" for i in range(40, -1, -8))
return mac_str
def call_all(*callables: Optional[Callable[[], None]]) -> None:
for item in filter(None, callables):
item()
def is_arm() -> bool:
machine = platform.machine()
return ("arm" in machine) or ("aarch" in machine)
@@ -1,429 +0,0 @@
"""XVF3800 USB-based button & mute controller.
This module integrates the ReSpeaker XVF3800's built-in mute/mic-control
with the Linux Voice Assistant (LVA):
- Observes the XVF3800's GPO state to detect hardware mute changes.
- Publishes "set_mic_mute" events on the EventBus when the user presses
the physical mute button on the XVF3800.
- Listens for "mic_muted"/"mic_unmuted" events and mirrors the LVA's
mute state back to the XVF3800 via its USB control interface.
The XVF3800's GPIO mapping (from Seeed docs) is:
GPI (inputs):
X1D09 - Mute button state (high = released, low = pressed)
X1D13 - Floating
X1D34 - Floating
GPO (outputs):
X0D11 - Floating
X0D30 - Mic mute + red mute LED (high = muted, LED on)
X0D31 - Amplifier enable (low = enabled)
X0D33 - WS2812 LED power enable
X0D39 - Floating
We do NOT try to interpret short/long presses like the generic GPIO
ButtonController; the XVF3800 mute button is treated as "mute toggle only".
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import List, Optional, TYPE_CHECKING
import usb.core # type: ignore[import]
import usb.util # type: ignore[import]
from .event_bus import EventBus, EventHandler, subscribe
if TYPE_CHECKING:
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Low-level USB client (minimal subset of the XMOS/Seeed control protocol)
# ---------------------------------------------------------------------------
class XVF3800USBClient:
"""Minimal USB control client for the ReSpeaker XVF3800.
This wraps the vendor-specific control transfers needed for:
- GPO_READ_VALUES (read mic mute LED / amp / LED power pins)
- GPO_WRITE_VALUE (set mic mute LED pin)
It deliberately does NOT touch interfaces/altsettings so that audio
streaming over USB is unaffected.
"""
VENDOR_ID = 0x2886
PRODUCT_ID = 0x001A
TIMEOUT_MS = 1000
# From Seeed's documentation (GPO control):
# - Resid = 20 (GPO_SERVICER_RESID)
# - CmdId = 0 -> GPO_READ_VALUES (3/5 bytes depending on doc version)
# - CmdId = 1 -> GPO_WRITE_VALUE
#
# We expect GPO_READ_VALUES to return 5 bytes:
# [X0D11, X0D30, X0D31, X0D33, X0D39]
#
GPO_RESID = 20
GPO_READ_CMDID = 0
GPO_WRITE_CMDID = 1
GPO_NUM_PINS = 5
# Index of the mic mute/red LED pin in the GPO_READ_VALUES response
# (X0D30, second element).
GPO_MUTE_INDEX = 1
# Index of the WS2812 LED power enable pin in the response (X0D33).
GPO_WS2812_POWER_INDEX = 3
def __init__(self) -> None:
dev = usb.core.find(idVendor=self.VENDOR_ID, idProduct=self.PRODUCT_ID)
if dev is None:
raise RuntimeError(
"ReSpeaker XVF3800 USB device not found "
f"(VID=0x{self.VENDOR_ID:04X}, PID=0x{self.PRODUCT_ID:04X})"
)
self._dev = dev
_LOGGER.debug(
"Initialized XVF3800USBClient (bus=%s, address=%s)",
getattr(dev, "bus", "?"),
getattr(dev, "address", "?"),
)
# CRITICAL FIX: Add context manager support
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
return False
def close(self) -> None:
"""Dispose of USB resources."""
if hasattr(self, '_dev') and self._dev is not None:
try:
usb.util.dispose_resources(self._dev)
except Exception as e:
_LOGGER.debug("Error disposing XVF3800 USB resources: %s", e)
finally:
self._dev = None
# Internal helpers -----------------------------------------------------
def _ctrl_read(self, resid: int, cmdid: int, length: int) -> List[int]:
"""Perform a vendor-specific control IN transfer and return payload bytes."""
# Per XMOS protocol: read cmdid is (0x80 | cmdid)
wValue = 0x80 | cmdid
wIndex = resid
data = self._dev.ctrl_transfer(
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0,
wValue,
wIndex,
length,
self.TIMEOUT_MS,
)
if not data:
raise RuntimeError("Empty response from XVF3800 vendor control read")
status = data[0]
# 0 = CONTROL_SUCCESS, 64 = SERVICER_COMMAND_RETRY in XMOS docs,
# but for our simple use case we just require success.
if status != 0:
raise RuntimeError(f"Unexpected XVF3800 control status: {status}")
# Return the payload bytes as a Python list, excluding status byte.
return list(data[1:])
def _ctrl_write(self, resid: int, cmdid: int, payload: bytes) -> None:
"""Perform a vendor-specific control OUT transfer."""
wValue = cmdid
wIndex = resid
# Note: ctrl_transfer will raise usb.core.USBError on failure.
self._dev.ctrl_transfer(
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0,
wValue,
wIndex,
payload,
self.TIMEOUT_MS,
)
# Public API -----------------------------------------------------------
def read_gpo_values(self) -> List[int]:
"""Read all GPO pin values.
Returns a list of integers, one per pin:
[X0D11, X0D30, X0D31, X0D33, X0D39]
"""
length = self.GPO_NUM_PINS + 1 # +1 for status byte
values = self._ctrl_read(self.GPO_RESID, self.GPO_READ_CMDID, length)
if len(values) < self.GPO_NUM_PINS:
_LOGGER.warning(
"XVF3800 GPO_READ_VALUES returned %d bytes, expected >= %d",
len(values),
self.GPO_NUM_PINS,
)
return values
def set_gpo_pin(self, pin: int, value: bool) -> bool:
"""Set a single GPO pin high/low.
Args:
pin: GPO pin number (e.g., 30 for X0D30, 33 for X0D33)
value: True -> high, False -> low
Returns:
True on success, False on error.
"""
payload = bytes([pin, 1 if value else 0])
try:
self._ctrl_write(self.GPO_RESID, self.GPO_WRITE_CMDID, payload)
return True
except usb.core.USBError as err:
_LOGGER.error("USBError writing XVF3800 GPO pin %s value: %s", pin, err)
except Exception:
_LOGGER.exception("Unexpected error writing XVF3800 GPO pin %s value", pin)
return False
def get_mute_gpo(self) -> Optional[bool]:
"""Return the current mic-mute output state from X0D30.
Returns:
True -> mics muted / red mute LED on
False -> mics unmuted / red mute LED off
None -> could not read values (USB error)
"""
try:
values = self.read_gpo_values()
if len(values) <= self.GPO_MUTE_INDEX:
_LOGGER.error(
"XVF3800 GPO_READ_VALUES payload too short: %r", values
)
return None
mute_val = values[self.GPO_MUTE_INDEX]
return bool(mute_val)
except usb.core.USBError as err:
_LOGGER.error("USBError reading XVF3800 GPO values: %s", err)
except Exception:
_LOGGER.exception("Unexpected error reading XVF3800 GPO values")
return None
def set_mute_gpo(self, muted: bool) -> bool:
"""Set the mic-mute output (X0D30) high/low."""
return self.set_gpo_pin(30, muted)
# ---------------------------------------------------------------------------
# High-level controller: bridge between XVF3800 and LVA mute state
# ---------------------------------------------------------------------------
@dataclass
class XVF3800ButtonRuntimeConfig:
"""Runtime config for the XVF3800 button/mute controller."""
# CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz)
# 80% reduction in CPU usage for button monitoring
poll_interval_seconds: float = 0.05 # 20 Hz polling
class XVF3800ButtonController(EventHandler):
"""Monitor & synchronize the XVF3800's mute state with LVA."""
def __init__(
self,
loop,
event_bus: EventBus,
state: "ServerState",
button_config,
) -> None:
super().__init__(event_bus)
self.loop = loop
self.state = state
# CRITICAL FIX: Default to 0.05s instead of 0.01s
poll_interval = 0.05
if hasattr(button_config, "poll_interval_seconds"):
try:
poll_interval = float(button_config.poll_interval_seconds)
except Exception:
_LOGGER.warning(
"Invalid poll_interval_seconds in button config; "
"defaulting to %.3fs",
poll_interval,
)
self._cfg = XVF3800ButtonRuntimeConfig(
poll_interval_seconds=poll_interval,
)
self._thread: Optional[threading.Thread] = None
self._shutdown_flag = threading.Event()
self._last_hw_muted: Optional[bool] = None
self._target_mute_state_lock = threading.Lock()
self._target_mute_state: Optional[bool] = None
self._usb_client: Optional[XVF3800USBClient] = None
self._thread = threading.Thread(
target=self._poll_loop,
name="XVF3800ButtonControllerThread",
daemon=True,
)
self._thread.start()
_LOGGER.info(
"XVF3800ButtonController started (poll_interval=%.3fs)",
self._cfg.poll_interval_seconds,
)
self._subscribe_all_methods()
# ------------------------------------------------------------------
# Event handlers from EventBus
# ------------------------------------------------------------------
@subscribe
def mic_muted(self, data: dict) -> None:
self._set_target_mute_state(True)
@subscribe
def mic_unmuted(self, data: dict) -> None:
self._set_target_mute_state(False)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def stop(self) -> None:
self._shutdown_flag.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
if self._usb_client is not None:
self._usb_client.close()
_LOGGER.info("XVF3800ButtonController stopped")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _set_target_mute_state(self, muted: bool) -> None:
with self._target_mute_state_lock:
self._target_mute_state = muted
def _take_target_mute_state(self) -> Optional[bool]:
with self._target_mute_state_lock:
value = self._target_mute_state
self._target_mute_state = None
return value
def _ensure_usb_client(self) -> Optional[XVF3800USBClient]:
if self._usb_client is not None:
return self._usb_client
try:
self._usb_client = XVF3800USBClient()
_LOGGER.info("Connected to ReSpeaker XVF3800 for mute control")
except Exception:
_LOGGER.exception(
"Failed to initialize XVF3800 USB client; "
"mute button integration will be disabled"
)
self._usb_client = None
return self._usb_client
# ------------------------------------------------------------------
# Polling loop
# ------------------------------------------------------------------
def _poll_loop(self) -> None:
_LOGGER.debug("XVF3800ButtonController polling thread started")
while not self._shutdown_flag.is_set() and not getattr(
self.state, "shutdown", False
):
client = self._ensure_usb_client()
if client is None:
time.sleep(2.0)
continue
# 1) Apply any pending target state from LVA -> hardware
target = self._take_target_mute_state()
if target is not None:
if self._last_hw_muted is None or target != self._last_hw_muted:
success = client.set_mute_gpo(target)
if success:
self._last_hw_muted = target
_LOGGER.debug(
"Set XVF3800 hardware mute state -> %s", target
)
# 2) Read current hardware GPO values (mute + WS2812 power)
try:
values = client.read_gpo_values()
hw_muted = None
if len(values) > client.GPO_MUTE_INDEX:
hw_muted = bool(values[client.GPO_MUTE_INDEX])
# If the firmware/button toggles WS2812 power off, re-enable it so
# the ring can still display LVA state (mute, idle effects, etc.)
if len(values) > client.GPO_WS2812_POWER_INDEX:
ws2812_power = bool(values[client.GPO_WS2812_POWER_INDEX])
if not ws2812_power:
if client.set_gpo_pin(33, True):
_LOGGER.debug("Re-enabled XVF3800 WS2812 LED power (X0D33)")
else:
ws2812_power = None
except usb.core.USBError as err:
_LOGGER.error("USBError reading XVF3800 GPO values: %s", err)
hw_muted = None
except Exception:
_LOGGER.exception("Unexpected error reading XVF3800 GPO values")
hw_muted = None
if hw_muted is not None:
if self._last_hw_muted is None:
self._last_hw_muted = hw_muted
_LOGGER.info(
"Initial XVF3800 hardware mute state: %s", hw_muted
)
self.loop.call_soon_threadsafe(
self.state.event_bus.publish,
"set_mic_mute",
{"state": hw_muted, "source": "xvf3800_hw"},
)
elif hw_muted != self._last_hw_muted:
_LOGGER.info(
"Detected XVF3800 mute state change from %s to %s; "
"publishing set_mic_mute event",
self._last_hw_muted,
hw_muted,
)
self._last_hw_muted = hw_muted
self.loop.call_soon_threadsafe(
self.state.event_bus.publish,
"set_mic_mute",
{"state": hw_muted, "source": "xvf3800_hw"},
)
time.sleep(self._cfg.poll_interval_seconds)
_LOGGER.debug("XVF3800ButtonController polling thread exiting")
@@ -1,444 +0,0 @@
"""XVF3800 USB LED ring backend.
This backend supports two generations of LED control:
1) Legacy/global LED control (older firmware):
- LED_EFFECT, LED_BRIGHTNESS, LED_SPEED, LED_COLOR
2) Per-LED ring control (newer firmware):
- LED_RING_COLOR: 12x uint32 values, one per LED (0xRRGGBB)
We intentionally keep the implementation minimal and self-contained so it can be
used by LVA without shelling out to xvf_host.py.
"""
from __future__ import annotations
import logging
import struct
import time
from typing import List, Optional, Sequence, Tuple
import usb.core # type: ignore[import]
import usb.util # type: ignore[import]
_LOGGER = logging.getLogger(__name__)
CONTROL_SUCCESS = 0
SERVICER_COMMAND_RETRY = 64
# name -> (resid, cmdid, count, access, type)
PARAMETERS = {
# ---------------------------------------------------------------------
# APPLICATION_SERVICER_RESID (core info)
# ---------------------------------------------------------------------
"VERSION": (48, 0, 3, "ro", "uint8"),
# Firmware / device control
"REBOOT": (48, 7, 1, "wo", "uint8"),
"SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8"),
# Audio manager output routing (category, source) per channel
"AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8"),
"AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8"),
# ---------------------------------------------------------------------
# GPO_SERVICER_RESID (LED controls)
# ---------------------------------------------------------------------
# Legacy/global controls
"LED_EFFECT": (20, 12, 1, "rw", "uint8"),
"LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8"),
"LED_GAMMIFY": (20, 14, 1, "rw", "uint8"),
"LED_SPEED": (20, 15, 1, "rw", "uint8"),
"LED_COLOR": (20, 16, 1, "rw", "uint32"),
# Newer firmware: per-LED ring control (12 WS2812 LEDs)
"LED_RING_COLOR": (20, 19, 12, "rw", "uint32"),
# GPO control for button monitoring and LED power
"GPO_READ_VALUES": (20, 0, 5, "ro", "uint8"), # [X0D11, X0D30, X0D31, X0D33, X0D39]
"GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8"), # [pin_index, value]
}
class _ReSpeaker:
"""Low-level USB control wrapper for XVF3800 parameters."""
TIMEOUT_MS = 100_000
VID = 0x2886
PID = 0x001A
def __init__(self, dev: "usb.core.Device") -> None: # type: ignore[name-defined]
self.dev = dev
# CRITICAL FIX: Add context manager support
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
return False
def close(self) -> None:
"""Release any underlying libusb resources (best-effort)."""
if hasattr(self, 'dev') and self.dev is not None:
try:
usb.util.dispose_resources(self.dev)
except Exception as e:
_LOGGER.debug("Error disposing USB resources: %s", e)
finally:
self.dev = None
# ------------------------------------------------------------------
# Encoding / decoding helpers
# ------------------------------------------------------------------
def _pack_values(self, data_type: str, values: Sequence[int]) -> bytes:
if data_type == "uint8":
return bytes([int(v) & 0xFF for v in values])
if data_type == "uint32":
return b"".join(struct.pack("<I", int(v) & 0xFFFFFFFF) for v in values)
if data_type == "int32":
return b"".join(struct.pack("<i", int(v)) for v in values)
raise ValueError(f"Unsupported data type '{data_type}'")
def _unpack_values(self, data_type: str, raw: bytes, count: int) -> List[int]:
if data_type == "uint8":
return list(raw[:count])
if data_type == "uint32":
return list(struct.unpack("<" + "I" * count, raw[: count * 4]))
if data_type == "int32":
return list(struct.unpack("<" + "i" * count, raw[: count * 4]))
raise ValueError(f"Unsupported data type '{data_type}'")
def _read_length(self, data_type: str, count: int) -> int:
# +1 for status byte returned by XMOS vendor control read
if data_type == "uint8":
return count + 1
if data_type in ("uint32", "int32"):
return (count * 4) + 1
raise ValueError(f"Unsupported data type '{data_type}'")
# ------------------------------------------------------------------
# Public parameter IO
# ------------------------------------------------------------------
def write(self, name: str, data_list: Sequence[int]) -> None:
try:
resid, cmdid, count, access, data_type = PARAMETERS[name]
except KeyError as exc:
raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc
if access == "ro":
raise ValueError(f"{name} is read-only")
if len(data_list) != count:
raise ValueError(f"{name} expects {count} values, got {len(data_list)}")
payload = self._pack_values(data_type, data_list)
_LOGGER.debug(
"XVF3800 write: name=%s resid=%s cmdid=%s payload_len=%d",
name,
resid,
cmdid,
len(payload),
)
self.dev.ctrl_transfer(
usb.util.CTRL_OUT
| usb.util.CTRL_TYPE_VENDOR
| usb.util.CTRL_RECIPIENT_DEVICE,
0,
cmdid,
resid,
payload,
self.TIMEOUT_MS,
)
def read(self, name: str, max_retries: int = 10) -> List[int]:
try:
resid, cmdid, count, access, data_type = PARAMETERS[name]
except KeyError as exc:
raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc
if access == "wo":
raise ValueError(f"{name} is write-only")
length = self._read_length(data_type, count)
wValue = 0x80 | cmdid # per XMOS protocol: read is (0x80 | cmdid)
attempt = 0
while True:
attempt += 1
resp = self.dev.ctrl_transfer(
usb.util.CTRL_IN
| usb.util.CTRL_TYPE_VENDOR
| usb.util.CTRL_RECIPIENT_DEVICE,
0,
wValue,
resid,
length,
self.TIMEOUT_MS,
)
if not resp:
raise RuntimeError("Empty response from XVF3800 control read")
status = int(resp[0])
if status == CONTROL_SUCCESS:
raw = bytes(resp[1:])
return self._unpack_values(data_type, raw, count)
if status == SERVICER_COMMAND_RETRY and attempt < max_retries:
continue
raise RuntimeError(f"XVF3800 control read failed (status={status}, name={name})")
def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Optional[_ReSpeaker]:
dev = usb.core.find(idVendor=vid, idProduct=pid)
if not dev:
return None
return _ReSpeaker(dev)
class XVF3800USBDevice:
"""Small helper for issuing non-LED XVF3800 control commands via PyUSB.
This is intentionally minimal and does *not* depend on xvf_host.py.
"""
def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID):
self._rsp = _find_device(vid=vid, pid=pid)
if self._rsp is None:
raise RuntimeError(
f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})"
)
def close(self) -> None:
if self._rsp is not None:
try:
self._rsp.close()
except Exception:
pass
# --- Device control -----------------------------------------------------
def reboot(self) -> None:
"""Reboot the XVF3800 firmware (resets parameters to defaults)."""
self._rsp.write("REBOOT", [1])
def save_configuration(self) -> None:
"""Persist current runtime settings to flash."""
self._rsp.write("SAVE_CONFIGURATION", [1])
# --- Audio routing ------------------------------------------------------
def set_audio_mgr_op_l(self, category: int, source: int) -> None:
"""Set the L output channel source selection (category, source)."""
self._rsp.write("AUDIO_MGR_OP_L", [int(category) & 0xFF, int(source) & 0xFF])
def set_audio_mgr_op_r(self, category: int, source: int) -> None:
"""Set the R output channel source selection (category, source)."""
self._rsp.write("AUDIO_MGR_OP_R", [int(category) & 0xFF, int(source) & 0xFF])
# --- Wait helpers -------------------------------------------------------
@staticmethod
def wait_for_reenumeration(
vid: int = _ReSpeaker.VID,
pid: int = _ReSpeaker.PID,
timeout_s: float = 12.0,
settle_s: float = 1.0,
) -> None:
"""Wait for the XVF3800 to disappear and then reappear on USB."""
start = time.time()
# Wait for it to disappear (best effort)
while time.time() - start < timeout_s:
if usb.core.find(idVendor=vid, idProduct=pid) is None:
break
time.sleep(0.1)
# Wait for it to reappear
while time.time() - start < timeout_s:
if usb.core.find(idVendor=vid, idProduct=pid) is not None:
break
time.sleep(0.1)
# Give kernel/userspace audio stack a moment to settle
if settle_s > 0:
time.sleep(settle_s)
class XVF3800LedBackend:
"""High-level LED backend for the XVF3800."""
ring_led_count: int = 12
# GPO indices (from XVF3800 documentation)
GPO_WS2812_POWER_INDEX = 3 # X0D33 in GPO_READ_VALUES response
def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> None:
wrapper = _find_device(vid, pid)
if wrapper is None:
raise RuntimeError(
f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})"
)
self._dev = wrapper
self.supports_per_led: bool = False
# CRITICAL FIX: Ensure WS2812 LED power is enabled BEFORE any LED operations
# This prevents intermittent LED failures caused by X0D33 being low at startup
try:
_LOGGER.debug("Ensuring XVF3800 WS2812 LED power (X0D33) is enabled")
self._dev.write("GPO_WRITE_VALUE", [33, 1]) # X0D33 = high
time.sleep(0.05) # Give firmware time to settle
_LOGGER.info("XVF3800 WS2812 LED power enabled")
except Exception as e:
_LOGGER.warning(
"Could not enable WS2812 LED power, LEDs may not work reliably: %s", e
)
# Best-effort feature detection: if we can read LED_RING_COLOR, we assume
# per-LED control is supported by the current firmware.
try:
_ = self._dev.read("LED_RING_COLOR")
self.supports_per_led = True
except Exception:
self.supports_per_led = False
# Best-effort version read (not fatal)
try:
ver = self._dev.read("VERSION")
_LOGGER.info(
"Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, version=%s, per_led=%s)",
vid,
pid,
".".join(str(int(x)) for x in ver[:3]),
self.supports_per_led,
)
except Exception:
_LOGGER.info(
"Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, per_led=%s)",
vid,
pid,
self.supports_per_led,
)
# ---------------------------------------------------------------------
# Helper: Ensure LED power before critical operations
# ---------------------------------------------------------------------
def _ensure_led_power(self) -> bool:
"""Ensure WS2812 LED power is enabled before operations.
This provides belt-and-suspenders protection against the LED power
being disabled by firmware or button interactions.
Returns:
bool: True if power is confirmed on, False if check failed
"""
try:
# Read GPO values
values = self._dev.read("GPO_READ_VALUES")
if len(values) > self.GPO_WS2812_POWER_INDEX:
ws2812_power = bool(values[self.GPO_WS2812_POWER_INDEX])
if not ws2812_power:
_LOGGER.warning("WS2812 LED power was off, re-enabling")
self._dev.write("GPO_WRITE_VALUE", [33, 1])
time.sleep(0.01) # Brief settle time
return True
return True
except Exception as e:
_LOGGER.debug("Could not verify WS2812 LED power state: %s", e)
return False
return False
# ---------------------------------------------------------------------
# Legacy/global controls
# ---------------------------------------------------------------------
def set_effect(self, effect_id: int) -> None:
"""Set LED effect mode (0=off, 1=breath, 2=rainbow, 3=single color, 4=doa)."""
# Ensure power before effect change
self._ensure_led_power()
self._dev.write("LED_EFFECT", [int(effect_id) & 0xFF])
def set_brightness(self, brightness_0_255: int) -> None:
"""Set LED brightness (0-255)."""
value = max(0, min(255, int(brightness_0_255)))
self._dev.write("LED_BRIGHTNESS", [value])
def set_speed(self, speed_id: int) -> None:
"""Set LED effect speed (0=slow, 1=medium, 2=fast)."""
self._dev.write("LED_SPEED", [int(speed_id) & 0xFF])
def set_color(self, r: int, g: int, b: int) -> None:
"""Set LED color for breath / single color modes (0xRRGGBB)."""
r = max(0, min(255, int(r)))
g = max(0, min(255, int(g)))
b = max(0, min(255, int(b)))
color_value = (r << 16) | (g << 8) | b
self._dev.write("LED_COLOR", [color_value])
# ---------------------------------------------------------------------
# Per-LED ring control (newer firmware)
# ---------------------------------------------------------------------
def set_ring_colors(self, color_values: Sequence[int]) -> None:
"""Set all 12 ring LEDs with 0xRRGGBB values (length must be 12)."""
if not self.supports_per_led:
raise RuntimeError("Per-LED ring control is not supported by this firmware")
if len(color_values) != self.ring_led_count:
raise ValueError(
f"LED_RING_COLOR expects {self.ring_led_count} values, got {len(color_values)}"
)
# Ensure power before writing ring colors
self._ensure_led_power()
self._dev.write("LED_RING_COLOR", [int(v) & 0xFFFFFFFF for v in color_values])
def set_ring_rgb(self, colors: Sequence[Tuple[int, int, int]]) -> None:
"""Set all 12 ring LEDs with (r,g,b) tuples (length must be 12)."""
if len(colors) != self.ring_led_count:
raise ValueError(
f"Ring expects {self.ring_led_count} colors, got {len(colors)}"
)
vals: List[int] = []
for r, g, b in colors:
r = max(0, min(255, int(r)))
g = max(0, min(255, int(g)))
b = max(0, min(255, int(b)))
vals.append((r << 16) | (g << 8) | b)
self.set_ring_colors(vals)
def set_ring_solid(self, r: int, g: int, b: int) -> None:
"""Convenience: set all ring LEDs to the same RGB color."""
self.set_ring_rgb([(r, g, b)] * self.ring_led_count)
def clear_ring(self) -> None:
"""Convenience: turn all ring LEDs off (per-LED mode)."""
if not self.supports_per_led:
# Legacy fallback: just set effect off
self.set_effect(0)
self.set_brightness(0)
return
self.set_ring_colors([0] * self.ring_led_count)
def get_version(self) -> Optional[Tuple[int, int, int]]:
"""Return (major, minor, patch) if readable, else None."""
try:
ver = self._dev.read("VERSION")
if len(ver) >= 3:
return (int(ver[0]), int(ver[1]), int(ver[2]))
except Exception:
return None
return None
def close(self) -> None:
if self._dev is not None:
self._dev.close()
-61
View File
@@ -1,61 +0,0 @@
"""Runs mDNS zeroconf service for Home Assistant discovery."""
import logging
import socket
from typing import Optional
from .util import get_mac_address
_LOGGER = logging.getLogger(__name__)
try:
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
except ImportError:
_LOGGER.fatal("pip install zeroconf")
raise
MDNS_TARGET_IP = "224.0.0.251"
class HomeAssistantZeroconf:
def __init__(
self, port: int, name: Optional[str] = None, host: Optional[str] = None
) -> None:
self.port = port
# Use the stable MAC address
self.mac_address = get_mac_address()
self.name = name or self.mac_address
if not host:
try:
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
test_sock.setblocking(False)
test_sock.connect((MDNS_TARGET_IP, 1))
host = test_sock.getsockname()[0]
test_sock.close()
_LOGGER.debug("Detected IP: %s", host)
except Exception:
_LOGGER.warning("Could not detect IP address, mDNS might fail.")
host = "127.0.0.1"
assert host
self.host = host
self._aiozc = AsyncZeroconf()
async def register_server(self) -> None:
service_info = AsyncServiceInfo(
"_esphomelib._tcp.local.",
f"{self.name}._esphomelib._tcp.local.",
addresses=[socket.inet_aton(self.host)],
port=self.port,
properties={
"version": "2025.9.0",
"mac": self.mac_address,
"board": "host",
"platform": "HOST",
"network": "ethernet",
},
server=f"{self.name}.local.",
)
await self._aiozc.async_register_service(service_info)
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
-499
View File
@@ -1,499 +0,0 @@
#!/usr/bin/env python3
"""
LVA Tray Client
- Reads linux_voice_assistant/config.json to discover:
- app.name -> used for device_id
- mqtt.host / port / username / password
- Subscribes to the LVA's MQTT topics to:
- Track availability (online/offline)
- Track mute state
- Track per-state LED color (idle/listening/thinking/responding/error)
- Shows a system tray icon whose color matches the current LVA state.
- Provides a tray menu to start/stop/restart the LVA systemd --user service
and toggle microphone mute.
Idle state now **also uses the MQTT color**, not a fixed purple.
"""
import argparse
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, Optional
import paho.mqtt.client as mqtt
from PyQt5 import QtCore, QtGui, QtWidgets
_LOGGER = logging.getLogger("lva_tray_client")
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def slugify_device_id(name: str) -> str:
"""Convert display name to a consistent device_id."""
return name.strip().lower().replace(" ", "_")
def load_config(config_path: Optional[Path]) -> dict:
"""Load LVA config.json."""
if config_path is None:
# Default: same layout you’re using now:
# <repo_root>/linux_voice_assistant/config.json
base = Path(__file__).resolve().parent
config_path = base / "linux_voice_assistant" / "config.json"
_LOGGER.info("Using config file: %s", config_path)
with config_path.open("r", encoding="utf-8") as f:
cfg = json.load(f)
app_cfg = cfg.get("app", {})
mqtt_cfg = cfg.get("mqtt", {})
name = app_cfg.get("name", "Linux Voice Assistant")
device_id = slugify_device_id(name)
host = mqtt_cfg.get("host", "127.0.0.1")
port = int(mqtt_cfg.get("port", 1883))
username = mqtt_cfg.get("username") or None
password = mqtt_cfg.get("password") or None
cfg["_resolved"] = {
"name": name,
"device_id": device_id,
"mqtt_enabled": True,
"host": host,
"port": port,
"username": username,
"password": password,
}
_LOGGER.info(
"Loaded config: name=%s device_id=%s mqtt_enabled=%s host=%s port=%s",
name,
device_id,
True,
host,
port,
)
return cfg
# ---------------------------------------------------------------------------
# Tray client
# ---------------------------------------------------------------------------
class LvaTrayClient(QtWidgets.QSystemTrayIcon):
"""System tray integration for the Linux Voice Assistant."""
STATES = ["idle", "listening", "thinking", "responding", "error"]
def __init__(self, app: QtWidgets.QApplication, cfg: dict):
# QSystemTrayIcon init
super().__init__(parent=None)
self._app = app
resolved = cfg["_resolved"]
self._device_name = resolved["name"]
self._device_id = resolved["device_id"]
self._mqtt_host = resolved["host"]
self._mqtt_port = resolved["port"]
self._mqtt_username = resolved["username"]
self._mqtt_password = resolved["password"]
self._topic_prefix = f"lva/{self._device_id}"
# Current state
self._available: bool = False
self._muted: bool = False
self._current_state: str = "idle"
# Default colors per state (fallbacks)
self._default_colors: Dict[str, QtGui.QColor] = {
"idle": QtGui.QColor(128, 0, 255), # purple
"listening": QtGui.QColor(0, 0, 255), # blue
"thinking": QtGui.QColor(255, 255, 0), # yellow
"responding": QtGui.QColor(0, 255, 0), # green
"error": QtGui.QColor(255, 165, 0), # orange
}
# Last MQTT-derived color per state (idle included!)
self._last_color_by_state: Dict[str, QtGui.QColor] = dict(
self._default_colors
)
# Build context menu
self._build_menu()
# Initial icon: offline
self._set_icon_by_key("offline")
# MQTT setup
self._client = mqtt.Client()
if self._mqtt_username:
self._client.username_pw_set(self._mqtt_username, self._mqtt_password)
self._client.on_connect = self._on_connect
self._client.on_message = self._on_message
self._client.on_disconnect = self._on_disconnect
_LOGGER.debug(
"MQTT connecting to %s:%s", self._mqtt_host, self._mqtt_port
)
try:
self._client.connect(self._mqtt_host, self._mqtt_port, 60)
self._client.loop_start()
except Exception: # noqa: BLE001
_LOGGER.exception("Failed to connect to MQTT broker")
# Show tray icon
self.setVisible(True)
_LOGGER.info(
"LVA Tray Client started for device_id=%s", self._device_id
)
# ------------------------------------------------------------------
# Menu / actions
# ------------------------------------------------------------------
def _build_menu(self):
menu = QtWidgets.QMenu()
# Status label
self._status_action = QtWidgets.QAction("LVA Tray Client", self)
self._status_action.setEnabled(False)
menu.addAction(self._status_action)
menu.addSeparator()
# Service control
start_action = QtWidgets.QAction("Start LVA", self)
start_action.triggered.connect(self._start_lva)
menu.addAction(start_action)
stop_action = QtWidgets.QAction("Stop LVA", self)
stop_action.triggered.connect(self._stop_lva)
menu.addAction(stop_action)
restart_action = QtWidgets.QAction("Restart LVA", self)
restart_action.triggered.connect(self._restart_lva)
menu.addAction(restart_action)
menu.addSeparator()
# Mute toggle
self._mute_action = QtWidgets.QAction("Mute Microphone", self)
self._mute_action.setCheckable(True)
self._mute_action.triggered.connect(self._toggle_mute)
menu.addAction(self._mute_action)
menu.addSeparator()
# Quit
quit_action = QtWidgets.QAction("Quit Tray Client", self)
quit_action.triggered.connect(self._quit)
menu.addAction(quit_action)
self.setContextMenu(menu)
def _start_lva(self):
_LOGGER.info("Running: systemctl --user start linux-voice-assistant.service")
subprocess.run(
["systemctl", "--user", "start", "linux-voice-assistant.service"],
check=False,
)
def _stop_lva(self):
_LOGGER.info("Running: systemctl --user stop linux-voice-assistant.service")
subprocess.run(
["systemctl", "--user", "stop", "linux-voice-assistant.service"],
check=False,
)
def _restart_lva(self):
_LOGGER.info("Running: systemctl --user restart linux-voice-assistant.service")
subprocess.run(
["systemctl", "--user", "restart", "linux-voice-assistant.service"],
check=False,
)
def _toggle_mute(self, checked: bool):
self._mute_action.setChecked(checked)
self._muted = checked
# Publish to LVA
topic = f"{self._topic_prefix}/mute/set"
payload = "ON" if checked else "OFF"
_LOGGER.debug("Publishing mute command: %s -> %s", topic, payload)
try:
self._client.publish(topic, payload, retain=False)
except Exception: # noqa: BLE001
_LOGGER.exception("Failed to publish mute command")
# Update local icon
self._update_tray_icon()
def _quit(self):
try:
self._client.loop_stop()
self._client.disconnect()
except Exception: # noqa: BLE001
pass
self._app.quit()
# ------------------------------------------------------------------
# MQTT callbacks
# ------------------------------------------------------------------
def _on_connect(self, client, userdata, flags, rc): # noqa: ARG002
if rc == 0:
_LOGGER.info("Connected to MQTT broker (tray)")
# Subscribe to availability + mute + all LVA topics (for LED states, num_leds, etc.)
client.subscribe(f"{self._topic_prefix}/availability")
client.subscribe(f"{self._topic_prefix}/mute/state")
client.subscribe(f"{self._topic_prefix}/#")
else:
_LOGGER.error("Failed to connect to MQTT, return code %d", rc)
def _on_disconnect(self, client, userdata, rc): # noqa: ARG002
_LOGGER.warning("MQTT disconnected (rc=%s)", rc)
self._available = False
self._update_tray_icon()
def _on_message(self, client, userdata, msg): # noqa: ARG002
try:
topic = msg.topic
payload = msg.payload.decode()
_LOGGER.debug(
"MQTT message: topic=%s payload=%s", topic, payload
)
# availability
if topic == f"{self._topic_prefix}/availability":
self._handle_availability(payload)
return
# mute state
if topic == f"{self._topic_prefix}/mute/state":
self._handle_mute_state(payload)
return
# effect state (we don't currently use it for color, but log it)
if topic.endswith("_effect/state"):
return
# light state
if topic.endswith("_light/state"):
self._handle_light_state(topic, payload)
return
except Exception: # noqa: BLE001
_LOGGER.exception("Error in _on_message")
# ------------------------------------------------------------------
# MQTT handlers
# ------------------------------------------------------------------
def _handle_availability(self, payload: str):
online = payload.strip().lower() == "online"
_LOGGER.debug("Availability update: %s", payload)
self._available = online
self._update_tray_icon()
def _handle_mute_state(self, payload: str):
new_muted = payload.strip().upper() == "ON"
_LOGGER.debug("Mute update: %s", new_muted)
self._muted = new_muted
# Reflect in menu
self._mute_action.setChecked(self._muted)
self._update_tray_icon()
def _handle_light_state(self, topic: str, payload: str):
"""
Handle JSON from .../<state>_light/state
Example payload:
{"state": "ON", "brightness": 127,
"color": {"r": 0, "g": 0, "b": 255}}
"""
try:
data = json.loads(payload)
except json.JSONDecodeError:
_LOGGER.warning("Invalid JSON on light state: %s", payload)
return
# Extract state name from topic
# lva/<device>/<state>_light/state
parts = topic.split("/")
if len(parts) < 4:
return
state_part = parts[-2] # "<state>_light"
if not state_part.endswith("_light"):
return
state_name = state_part[: -len("_light")]
if state_name not in self.STATES:
return
_LOGGER.debug(
"Decoded light state: state_name=%s data=%s", state_name, data
)
state_flag = data.get("state", "OFF").upper()
color_dict = data.get("color", {}) or {}
brightness = int(data.get("brightness", 255))
brightness = max(0, min(brightness, 255))
r = int(color_dict.get("r", 0))
g = int(color_dict.get("g", 0))
b = int(color_dict.get("b", 0))
# Apply brightness scaling to the MQTT color
scale = brightness / 255.0 if brightness > 0 else 0.0
r_scaled = max(0, min(255, int(r * scale)))
g_scaled = max(0, min(255, int(g * scale)))
b_scaled = max(0, min(255, int(b * scale)))
qcolor = QtGui.QColor(r_scaled, g_scaled, b_scaled)
self._last_color_by_state[state_name] = qcolor
# State transitions:
# - For non-idle: treat "ON" as "this is the active state"
# - For idle: **always** treat updates as "idle is the current baseline"
if state_name == "idle":
_LOGGER.debug(
"Tray state_update (idle): data=%s (using MQTT color for idle)",
data,
)
self._current_state = "idle"
else:
if state_flag == "ON":
_LOGGER.debug(
"Tray state_update: state_name=%s data=%s",
state_name,
data,
)
if self._current_state != state_name:
_LOGGER.debug(
"Tray state changing: %s -> %s",
self._current_state,
state_name,
)
self._current_state = state_name
else:
# Ignore OFF for non-idle states
_LOGGER.debug(
"Ignoring OFF for non-idle state %s", state_name
)
self._update_tray_icon()
# ------------------------------------------------------------------
# Icon rendering
# ------------------------------------------------------------------
def _set_icon_by_key(self, key: str):
"""
Convenience wrapper to set icon for:
- 'offline'
- any state name in STATES
"""
if key == "offline":
color = QtGui.QColor(128, 128, 128) # grey
tooltip_state = "offline"
else:
color = self._last_color_by_state.get(
key, self._default_colors.get(key, QtGui.QColor(128, 128, 128))
)
tooltip_state = key
# If muted, tint red (but keep some info from base color)
if self._muted and key != "offline":
base = color
color = QtGui.QColor(
min(255, base.red() + 120),
max(0, int(base.green() * 0.4)),
max(0, int(base.blue() * 0.4)),
)
tooltip_state += " (muted)"
icon = self._make_circle_icon(color)
self.setIcon(icon)
# Update tooltip
tip = f"{self._device_name} – {tooltip_state}"
self.setToolTip(tip)
if self.contextMenu():
self._status_action.setText(tip)
_LOGGER.debug(
"Setting tray icon: key=%s available=%s muted=%s", key, self._available, self._muted
)
def _make_circle_icon(self, color: QtGui.QColor) -> QtGui.QIcon:
size = 20
pixmap = QtGui.QPixmap(size, size)
pixmap.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(pixmap)
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 160))
pen.setWidth(1)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush(color))
radius = size // 2 - 2
painter.drawEllipse(2, 2, radius * 2, radius * 2)
painter.end()
return QtGui.QIcon(pixmap)
def _update_tray_icon(self):
if not self._available:
self._set_icon_by_key("offline")
else:
self._set_icon_by_key(self._current_state)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(description="LVA Tray Client")
parser.add_argument(
"--config",
type=str,
help="Path to LVA config.json (defaults to linux_voice_assistant/config.json)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug logging",
)
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
config_path = Path(args.config) if args.config else None
cfg = load_config(config_path)
# Make Qt app
app = QtWidgets.QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
tray = LvaTrayClient(app, cfg)
# Start event loop
sys.exit(app.exec_())
if __name__ == "__main__":
main()
+6 -38
View File
@@ -1,52 +1,20 @@
[mypy]
python_version = 3.9
warn_return_any = True
warn_unused_configs = True
[mypy-aioesphomeapi._frame_helper.packets.*]
[mypy]
[mypy-setuptools.*]
ignore_missing_imports = True
[mypy-aioesphomeapi.api_pb2.*]
ignore_missing_imports = True
[mypy-pymicro_wakeword.*]
[mypy-pymicro_features.*]
ignore_missing_imports = True
[mypy-pyopen_wakeword.*]
ignore_missing_imports = True
[mypy-soundcard.*]
[mypy-sounddevice.*]
ignore_missing_imports = True
[mypy-mpv.*]
ignore_missing_imports = True
[mypy-zeroconf.*]
ignore_missing_imports = True
[mypy-paho.mqtt.client.*]
ignore_missing_imports = True
[mypy-RPi.*]
ignore_missing_imports = True
[mypy-spidev.*]
ignore_missing_imports = True
[mypy-board.*]
ignore_missing_imports = True
[mypy-busio.*]
ignore_missing_imports = True
[mypy-gpiozero.*]
ignore_missing_imports = True
[mypy-adafruit_dotstar.*]
ignore_missing_imports = True
[mypy-neopixel.*]
ignore_missing_imports = True
[mypy-neopixel_spi.*]
[mypy-google.protobuf.*]
ignore_missing_imports = True
+9 -26
View File
@@ -22,43 +22,26 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.9.0"
dependencies = [
"aioesphomeapi==43.9.1",
"soundcard<1",
"aioesphomeapi==37.2.1",
"sounddevice<1",
"numpy>=2,<3",
"pymicro-wakeword>=2,<3",
"pyopen-wakeword>=1,<2",
"python-mpv>=1,<2",
"zeroconf<1",
"paho-mqtt",
"adafruit-circuitpython-dotstar",
"adafruit-circuitpython-neopixel",
"adafruit-circuitpython-neopixel-spi",
"RPi.GPIO",
"gpiozero",
"spidev",
"pyusb",
"pymicro-features==1.0.0",
]
[project.optional-dependencies]
dev = [
"black",
"flake8",
"mypy",
"pylint",
"pytest",
]
# Optional GUI/tray dependencies. Only needed on desktop installs that use lva_tray_client.
tray = [
"PyQt5>=5.15",
"black==24.8.0",
"flake8==7.2.0",
"mypy==1.14.0",
"pylint==3.2.7",
"pytest==8.3.5",
]
[project.urls]
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
[tool.setuptools]
platforms = ["any"]
+5
View File
@@ -0,0 +1,5 @@
aioesphomeapi
sounddevice
gpiozero
spidev
adafruit-circuitpython-dotstar
+14 -42
View File
@@ -2,54 +2,26 @@
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
# Must be run with sudo.
# Requires: curl, dkms, i2c-tools, libasound2-plugins, alsa-utils,
# and appropriate Raspberry Pi kernel headers.
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
set -eo pipefail
kernel_formatted="$(uname -r | cut -f1,2 -d.)"
driver_url_status="$(curl -ILs "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v${kernel_formatted}.tar.gz" \
| tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
driver_url_status="$(curl -ILs https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz | tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
if [ ! "$driver_url_status" = 200 ]; then
echo "Could not find driver for kernel $kernel_formatted"
exit 1
fi
echo "Detecting appropriate kernel header package..."
header_pkg=""
header_candidates=(
raspberrypi-kernel-headers
linux-headers-rpi-v8
linux-headers-rpi-v7l
linux-headers-rpi-v7
)
for pkg in "${header_candidates[@]}"; do
if apt-cache show "$pkg" >/dev/null 2>&1; then
header_pkg="$pkg"
break
fi
done
if [ -n "$header_pkg" ]; then
echo "Using kernel header package: $header_pkg"
else
echo "WARNING: No known Raspberry Pi kernel header package found."
echo " Tried: ${header_candidates[*]}"
echo " Continuing without installing kernel headers; dkms build may fail."
if [ ! "$driver_url_status" = 200 ]; then
echo "Could not find driver for kernel $kernel_formatted"
exit 1
fi
apt-get update
apt-get install --no-install-recommends --yes \
curl dkms i2c-tools libasound2-plugins alsa-utils \
${header_pkg:+$header_pkg}
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
temp_dir="$(mktemp -d)"
function finish {
rm -rf "${temp_dir}"
rm -rf "${temp_dir}"
}
trap finish EXIT
@@ -59,10 +31,10 @@ pushd "${temp_dir}"
# Download source code to temporary directory
# NOTE: There are different branches in the repo for different kernel versions.
echo 'Downloading source code'
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v${kernel_formatted}.tar.gz" | \
tar -xzf -
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
tar -xzf -
cd "seeed-voicecard-${kernel_formatted}/"
cd seeed-voicecard-"$kernel_formatted"/
# 1. Build kernel module
echo 'Building kernel module'
@@ -72,10 +44,10 @@ src='./'
kernel="$(uname -r)"
marker='0.0.0'
threads="$(getconf _NPROCESSORS_ONLN)"
memory="$(LANG=C free -m | awk '/^Mem:/{print $2}')"
memory="$(LANG=C free -m|awk '/^Mem:/{print $2}')"
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
threads=2
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
threads=2
fi
mkdir -p "/usr/src/${mod}-${ver}"
@@ -83,7 +55,7 @@ cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
dkms add -m "${mod}" -v "${ver}"
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
}
mkdir -p "/var/lib/dkms/${mod}/${ver}/${marker}"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
PORT=6053
NAME="%h"
SERVICE=/etc/avahi/services/esphome-lva.service
# Wait up to ~40s for any global IPv4 (cosmetic: to include ipv4_* TXT)
deadline=$((SECONDS+40))
while [ $SECONDS -lt $deadline ]; do
ip -o -4 addr show up scope global | grep -q . && break || true
sleep 1
done
# Collect all UP, non-loopback ifaces
mapfile -t IFACES < <(ip -o link show up | awk -F': ' '{print $2}' | grep -v '^lo$' | sort -u)
# Detect platform/board from the running system
PLATFORM="LINUX"
ARCH_RAW="$(uname -m)"
case "$ARCH_RAW" in
aarch64|arm64) BOARD="aarch64" ;;
x86_64|amd64) BOARD="x86_64" ;;
*) BOARD="$ARCH_RAW" ;;
esac
TXT=" <txt-record>platform=${PLATFORM}</txt-record>
"
TXT+=" <txt-record>board=${BOARD}</txt-record>
"
LIST=""
for i in "${IFACES[@]}"; do
[ -e "/sys/class/net/$i/address" ] || continue
mac=$(cat "/sys/class/net/$i/address")
mac_nc=${mac//:/} # no colons (ESPHome style)
if [ -d "/sys/class/net/$i/wireless" ] || [[ "$i" == wl* ]]; then net=wifi; else net=ethernet; fi
TXT+=" <txt-record>network=${net}</txt-record>
"
TXT+=" <txt-record>mac=${mac_nc}</txt-record>
"
LIST+="${i},"
done
LIST=${LIST%,}
cat >"$SERVICE" <<XML
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">${NAME}</name>
<service protocol="ipv4">
<type>_esphomelib._tcp</type>
<port>${PORT}</port>
<txt-record>version=2025.9.0</txt-record>
${TXT} </service>
</service-group>
XML
# Avahi will (re)publish on all active ifaces automatically
systemctl restart avahi-daemon || true
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import subprocess
def list_devices(ao="pulse"):
result = subprocess.run(
["mpv", f"--ao={ao}", "--audio-device=help"],
capture_output=True,
text=True
)
print(result.stdout)
if __name__ == "__main__":
print("PulseAudio devices:")
list_devices("pulse")
print("\nALSA devices:")
list_devices("alsa")
+13 -8
View File
@@ -17,14 +17,19 @@ builder = venv.EnvBuilder(with_pip=True)
context = builder.ensure_directories(_VENV_DIR)
builder.create(_VENV_DIR)
# Upgrade pip and setuptools
# Upgrade dependencies
pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip", "setuptools", "wheel"])
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
subprocess.check_call(pip + ["install", "--upgrade", "python-mpv"])
subprocess.check_call(pip + ["install", "--upgrade", "gpiozero"])
subprocess.check_call(pip + ["install", "--upgrade", "spidev"])
# Install requirements
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
# Install the project and all its dependencies from pyproject.toml
if args.dev:
# Install with dev dependencies
subprocess.check_call(pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"])
else:
# Install with regular dependencies
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
# Install dev requirements
subprocess.check_call(
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
)
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_TEST_DIR = _PROGRAM_DIR / "tests"
if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"
subprocess.check_call([python_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Resolve repo root (one level up from this script)
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Activate venv if present
if [[ -d "$ROOT_DIR/.venv" ]]; then
# shellcheck disable=SC1090
source "$ROOT_DIR/.venv/bin/activate"
fi
cd "$ROOT_DIR"
# Run the tray client
exec python lva_tray_client.py "$@"
+3
View File
@@ -0,0 +1,3 @@
[Unit]
Wants=avahi-daemon.service
After=avahi-daemon.service
+8
View File
@@ -0,0 +1,8 @@
# /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
[Service]
# Keep mpv logs at warning by default; override with environment if desired
Environment=LVA_MPV_MSG_LEVEL=all=warn
# Faster, cleaner shutdowns
TimeoutStopSec=5s
KillMode=mixed
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Load PipeWire echo-cancel module for LVA
After=pipewire-pulse.service
Wants=pipewire-pulse.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Use a shell so we can do a little logic:
# 1) Try to unload any existing echo-cancel module (ignore errors)
# 2) Load the WebRTC echo cancel module with our tuning options
ExecStart=/usr/bin/bash -lc '\
pactl unload-module module-echo-cancel || true; \
pactl load-module module-echo-cancel \
aec_method=webrtc \
aec_args="analog_gain_control=0 digital_gain_control=1 voice_detection=1" \
'
[Install]
WantedBy=default.target
@@ -0,0 +1,27 @@
[Unit]
Description=Linux Voice Assistant (ALSA - ReSpeaker 2mic HAT)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
# Use ALSA ReSpeaker 2mic HAT hardware device
Environment="MPV_DEFAULT_DEVICE=alsa/plughw:CARD=seeed2micvoicec,DEV=0"
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite ALSA MWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,31 @@
[Unit]
Description=Linux Voice Assistant (ALSA - ReSpeaker 2mic HAT)
After=network-online.target wyoming-openwakeword.service
Wants=network-online.target
Requires=wyoming-openwakeword.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
# Use ALSA ReSpeaker 2mic HAT hardware device
Environment="MPV_DEFAULT_DEVICE=alsa/plughw:CARD=seeed2micvoicec,DEV=0"
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite ALSA OWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models'
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -1,16 +0,0 @@
[Unit]
Description=Linux Voice Assistant Tray Client
# Make sure we have a graphical session
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
ExecStart=/home/pi/linux-voice-assistant/script/tray
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=Linux Voice Assistant
Wants=pipewire.service wireplumber.service pipewire-pulse.service
After=pipewire.service wireplumber.service pipewire-pulse.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -1,17 +0,0 @@
[Unit]
Description=Linux Voice Assistant
Wants=pipewire.service wireplumber.service pipewire-pulse.service
After=pipewire.service wireplumber.service pipewire-pulse.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,33 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio - Headphone Jack)
After=network-online.target pulseaudio.service
Wants=network-online.target
Requires=pulseaudio.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
# Environment
Environment=PYTHONUNBUFFERED=1
Environment="PULSE_SERVER=unix:/var/run/pulse/native"
Environment="PULSE_LATENCY_MSEC=60"
Environment="MPV_DEFAULT_DEVICE=pulse/alsa_output.platform-soc_sound.stereo-fallback"
# Wait briefly for the Pulse native socket to be ready
ExecStartPre=/bin/sh -c 'for i in $(seq 1 40); do [ -S /var/run/pulse/native ] && exit 0; sleep 0.5; done; echo "PulseAudio socket not found" >&2; exit 1'
# Launch the assistant
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA MWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,36 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio - Headphone Jack)
After=network-online.target pulseaudio.service wyoming-openwakeword.service
Wants=network-online.target
Requires=pulseaudio.service wyoming-openwakeword.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
# Environment
Environment=PYTHONUNBUFFERED=1
Environment="PULSE_SERVER=unix:/var/run/pulse/native"
Environment="PULSE_LATENCY_MSEC=60"
Environment="MPV_DEFAULT_DEVICE=pulse/alsa_output.platform-soc_sound.stereo-fallback"
# Wait briefly for the Pulse native socket to be ready
ExecStartPre=/bin/sh -c 'for i in $(seq 1 40); do [ -S /var/run/pulse/native ] && exit 0; sleep 0.5; done; echo "PulseAudio socket not found" >&2; exit 1'
# Launch the assistant
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA OWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=PulseAudio system server
[Service]
Type=notify
ExecStart=pulseaudio --daemonize=no --system --realtime --log-target=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,21 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio + MWW)
After=pulseaudio.service
Wants=pulseaudio.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA' \
--audio-input-device default \
--audio-output-device pulse/alsa_output.platform-soc_sound.stereo-fallback \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,26 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio + OWW)
After=pulseaudio.service wyoming-openwakeword.service
Wants=pulseaudio.service wyoming-openwakeword.service
Requires=wyoming-openwakeword.service
After=wyoming-openwakeword.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA' \
--audio-input-device default \
--audio-output-device pulse/alsa_output.platform-soc_sound.stereo-fallback \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,21 @@
[Unit]
Description=Linux Voice Assistant (PipeWire + MWW)
After=pipewire.service pipewire-pulse.service
Wants=pipewire-pulse.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PW MWW' \
--audio-input-device default \
--audio-output-device pipewire/default \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,26 @@
[Unit]
Description=Linux Voice Assistant (PipeWire + OWW)
After=pipewire.service pipewire-pulse.service wyoming-openwakeword.service
Wants=pipewire-pulse.service wyoming-openwakeword.service
Requires=wyoming-openwakeword.service
After=wyoming-openwakeword.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite Test PW' \
--audio-input-device default \
--audio-output-device pipewire/default \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,25 @@
[Unit]
Description=Wyoming openWakeWord
[Service]
Type=simple
WorkingDirectory=/home/pi/wyoming-openwakeword
ExecStart=/usr/bin/env bash -lc '\
cd /home/pi/wyoming-openwakeword && \
exec ./script/run \
--uri tcp://0.0.0.0:10400 \
--models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models \
\
--preload-model alexa_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_jarvis_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_mycroft_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_rhasspy_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model ok_nabu_v0.1 --threshold 0.5 --trigger-level 3 \
'
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
+27
View File
@@ -0,0 +1,27 @@
# /etc/systemd/system/wyoming-openwakeword.service
[Unit]
Description=Wyoming openWakeWord
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/wyoming-openwakeword
ExecStart=/usr/bin/env bash -lc '\
cd /home/pi/wyoming-openwakeword && \
exec ./script/run \
--uri tcp://0.0.0.0:10400 \
--models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models \
\
--preload-model alexa_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_jarvis_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_mycroft_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_rhasspy_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model ok_nabu_v0.1 --threshold 0.5 --trigger-level 3 \
'
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
-73
View File
@@ -1,73 +0,0 @@
import argparse
import soundcard as sc
import numpy as np
import wave
DEVICE = "reSpeaker XVF3800 4-Mic Array Analog Stereo"
SECONDS = 5
SR = 16000
BLOCK = 1024
def main():
parser = argparse.ArgumentParser(description="Record from soundcard mic to WAV.")
parser.add_argument(
"--channel",
choices=["mix", "left", "right"],
default="mix",
help="mix=record 1ch (downmix/default). left/right=record 2ch and select.",
)
parser.add_argument("--seconds", type=int, default=SECONDS)
parser.add_argument("--device", type=str, default=DEVICE)
parser.add_argument("--out", type=str, default="/tmp/lva_soundcard.wav")
args = parser.parse_args()
if args.channel == "mix":
in_ch = 1
out_ch = 1
out_path = args.out
else:
in_ch = 2
out_ch = 1
# If user didn't override --out, make filename reflect selection
if args.out == "/tmp/lva_soundcard.wav":
out_path = f"/tmp/lva_soundcard_{args.channel}.wav"
else:
out_path = args.out
mic = sc.get_microphone(args.device, include_loopback=False)
print("Using mic:", mic.name)
print(f"Mode: {args.channel} (request channels={in_ch} -> write channels={out_ch})")
frames = []
with mic.recorder(samplerate=SR, channels=in_ch, blocksize=BLOCK) as r:
for _ in range(int(SR * args.seconds / BLOCK)):
buf = r.record(BLOCK)
if in_ch == 1:
# buf can be (BLOCK,) or (BLOCK, 1) depending on backend
mono = buf.reshape(-1)
sample = mono
else:
# buf should be (BLOCK, 2)
if buf.ndim != 2 or buf.shape[1] < 2:
raise RuntimeError(f"Expected stereo buffer, got shape {buf.shape}")
idx = 0 if args.channel == "left" else 1
sample = buf[:, idx]
rms = float(np.sqrt(np.mean(np.square(sample)))) if sample.size else 0.0
print(f"rms={rms:.6f}")
pcm16 = (np.clip(sample, -1.0, 1.0) * 32767.0).astype("<i2")
frames.append(pcm16.tobytes())
with wave.open(out_path, "wb") as wf:
wf.setnchannels(out_ch)
wf.setsampwidth(2)
wf.setframerate(SR)
wf.writeframes(b"".join(frames))
print("Wrote:", out_path)
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
"""Tests for microWakeWord."""
import wave
from pathlib import Path
from linux_voice_assistant.microwakeword import MicroWakeWord, MicroWakeWordFeatures
from linux_voice_assistant.util import is_arm
_TESTS_DIR = Path(__file__).parent
_REPO_DIR = _TESTS_DIR.parent
_MICRO_DIR = _REPO_DIR / "wakewords"
if is_arm():
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
else:
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
def test_features() -> None:
features = MicroWakeWordFeatures(
libtensorflowlite_c_path=libtensorflowlite_c_path,
)
ww = MicroWakeWord.from_config(
config_path=_MICRO_DIR / "okay_nabu.json",
libtensorflowlite_c_path=libtensorflowlite_c_path,
)
detected = False
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
assert wav_file.getframerate() == 16000
assert wav_file.getsampwidth() == 2
assert wav_file.getnchannels() == 1
for micro_input in features.process_streaming(
wav_file.readframes(wav_file.getnframes())
):
if ww.process_streaming(micro_input):
detected = True
assert detected
-47
View File
@@ -1,47 +0,0 @@
"""Tests for openWakeWord."""
import wave
from pathlib import Path
from linux_voice_assistant.openwakeword import OpenWakeWordFeatures, OpenWakeWord
from linux_voice_assistant.util import is_arm
_TESTS_DIR = Path(__file__).parent
_REPO_DIR = _TESTS_DIR.parent
_OWW_DIR = _REPO_DIR / "wakewords" / "openWakeWord"
if is_arm():
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
else:
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
def test_features() -> None:
features = OpenWakeWordFeatures(
melspectrogram_model=_OWW_DIR / "melspectrogram.tflite",
embedding_model=_OWW_DIR / "embedding_model.tflite",
libtensorflowlite_c_path=libtensorflowlite_c_path,
)
ww = OpenWakeWord(
id="ok_nabu",
wake_word="okay nabu",
tflite_model=_OWW_DIR / "ok_nabu_v0.1.tflite",
libtensorflowlite_c_path=libtensorflowlite_c_path,
)
max_prob = 0.0
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
assert wav_file.getframerate() == 16000
assert wav_file.getsampwidth() == 2
assert wav_file.getnchannels() == 1
for embeddings in features.process_streaming(
wav_file.readframes(wav_file.getnframes())
):
for prob in ww.process_streaming(embeddings):
max_prob = max(max_prob, prob)
assert max_prob > 0.5
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env python3
"""ReSpeaker XVF3800 HID mute/button probe.
This script helps reverse-engineer how the XVF3800 exposes its mute button over USB HID.
It is meant to be run from the linux-voice-assistant virtualenv on a system where the
XVF3800 is connected via USB.
Requirements:
pip install hidapi
Usage examples:
# Basic usage (auto-detect first 2886:001a HID interface)
python tests/xvf3800_hid_mute_probe.py
# Explicit vendor/product (optional, defaults are correct for XVF3800)
python tests/xvf3800_hid_mute_probe.py --vendor-id 0x2886 --product-id 0x001a
Once running, press and release the XVF3800 mute button a few times. The script will
print raw HID reports in hex. Share that output in the Discussion so we can identify
which byte/bit corresponds to the mute state.
"""
import argparse
import binascii
import datetime as dt
import sys
import time
from typing import List, Optional
try:
import hid # type: ignore[import]
except Exception as exc: # pragma: no cover - environment dependent
print("ERROR: Failed to import 'hid' (hidapi).", file=sys.stderr)
print(" Install it with: pip install hidapi", file=sys.stderr)
print(f" Details: {exc}", file=sys.stderr)
sys.exit(1)
def _fmt_vid_pid(vid: int, pid: int) -> str:
return f"0x{vid:04x}:0x{pid:04x}"
def list_matching_devices(vendor_id: int, product_id: int) -> List[dict]:
devices = []
for dev in hid.enumerate():
if dev.get("vendor_id") == vendor_id and dev.get("product_id") == product_id:
devices.append(dev)
return devices
def choose_device(devices: List[dict]) -> Optional[dict]:
"""Pick a device from the list; if only one, return it, else pick the first.
We log all candidates so users can see what's on their system.
"""
if not devices:
return None
print("Found the following matching HID interfaces for XVF3800:")
for idx, dev in enumerate(devices):
path = dev.get("path")
serial = dev.get("serial_number") or "<none>"
mfg = dev.get("manufacturer_string") or "<unknown>"
prod = dev.get("product_string") or "<unknown>"
iface = dev.get("interface_number", -1)
print(
f" [{idx}] path={path!r}, serial={serial!r}, iface={iface}, "
f"manufacturer={mfg!r}, product={prod!r}"
)
print()
print("NOTE: Using index 0 by default. If this is not the HID interface, ")
print(" rerun with --path <exact path> from the list above.\n")
return devices[0]
def open_device(
vendor_id: int,
product_id: int,
path: Optional[bytes] = None,
) -> hid.device:
"""Open a HID device for the given VID/PID (and optional path)."""
dev = hid.device()
if path is not None:
print(f"Opening HID device by path: {path!r}")
dev.open_path(path)
else:
print(f"Opening HID device by VID/PID: {_fmt_vid_pid(vendor_id, product_id)}")
dev.open(vendor_id, product_id)
dev.set_nonblocking(True)
return dev
def main() -> int:
parser = argparse.ArgumentParser(
description="Probe HID reports for the ReSpeaker XVF3800 mute button."
)
parser.add_argument(
"--vendor-id",
type=lambda x: int(x, 0),
default=0x2886,
help="USB vendor ID (default: 0x2886 for Seeed)",
)
parser.add_argument(
"--product-id",
type=lambda x: int(x, 0),
default=0x001a,
help="USB product ID (default: 0x001a for XVF3800)",
)
parser.add_argument(
"--path",
type=str,
default="",
help="Optional exact HID path to open (overrides VID/PID selection)",
)
parser.add_argument(
"--timeout-ms",
type=int,
default=500,
help="Read timeout in milliseconds (default: 500)",
)
args = parser.parse_args()
print("=== XVF3800 HID Mute Probe ===")
print(f"Vendor/Product: {_fmt_vid_pid(args.vendor_id, args.product_id)}")
if args.path:
chosen_path: Optional[bytes] = args.path.encode("utf-8")
else:
devices = list_matching_devices(args.vendor_id, args.product_id)
if not devices:
print(
"No HID interfaces found for XVF3800 (VID/PID). Make sure the board is plugged in.",
file=sys.stderr,
)
return 1
chosen = choose_device(devices)
if chosen is None:
print("No device chosen.", file=sys.stderr)
return 1
chosen_path = chosen.get("path")
print("Using device:")
print(f" path={chosen_path!r}")
print(f" serial={chosen.get('serial_number')!r}")
print(f" interface_number={chosen.get('interface_number')}")
print()
try:
dev = open_device(args.vendor_id, args.product_id, chosen_path)
except Exception as exc: # pragma: no cover - hardware dependent
print(f"ERROR: Failed to open HID device: {exc}", file=sys.stderr)
return 1
print("Listening for HID reports. Press and release the XVF3800 mute button a few times.")
print("Press Ctrl+C to stop.\n")
try:
while True:
data = dev.read(64, timeout_ms=args.timeout_ms)
if data:
now = dt.datetime.now().strftime("%H:%M:%S.%f")[:-3]
raw = bytes(data)
hex_str = binascii.hexlify(raw).decode("ascii")
# Trim trailing zeroes for readability
hex_str = hex_str.rstrip("0") or hex_str
print(f"[{now}] len={len(raw):02d} report={hex_str}")
time.sleep(0.01)
except KeyboardInterrupt:
print("\nStopping probe.")
finally:
try:
dev.close()
except Exception:
pass
return 0
if __name__ == "__main__": # pragma: no cover - script entry
raise SystemExit(main())
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
import argparse
import time
import numpy as np
import sounddevice as sd
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--device",
help="Exact input device name to test (as shown by --list-input-devices)",
required=True,
)
parser.add_argument(
"--samplerate",
type=float,
default=16000.0,
help="Sample rate to test (default: 16000)",
)
parser.add_argument(
"--seconds",
type=float,
default=2.0,
help="Duration to record for debug (default: 2s)",
)
args = parser.parse_args()
print("=== sounddevice.query_devices() (input subset) ===")
devices = sd.query_devices()
for idx, dev in enumerate(devices):
if dev["max_input_channels"] > 0:
flag = "*" if args.device in (dev["name"], str(idx)) else " "
print(
f"{flag} [{idx}] {dev['name']} "
f"(max_input_channels={dev['max_input_channels']}, "
f"default_samplerate={dev['default_samplerate']})"
)
print("\n=== Opening stream ===")
sd.default.samplerate = args.samplerate
sd.default.dtype = "int16"
# Allow using either index or full name
device = args.device
try:
device = int(args.device)
except ValueError:
pass
# First just open/close the stream to see what PortAudio says
with sd.InputStream(device=device, channels=0) as stream:
print(f"Opened stream with device={device}")
print(f" samplerate: {stream.samplerate}")
print(f" channels: {stream.channels}")
print(f" dtype: {stream.dtype}")
print(f" blocksize: {stream.blocksize}")
# Now actually record a short block and inspect the shape
print("\n=== Recording test block ===")
duration = args.seconds
num_frames = int(duration * args.samplerate)
print(f"Recording {duration} seconds ({num_frames} frames)...")
recorded = sd.rec(frames=num_frames, samplerate=args.samplerate,
channels=0, dtype="int16", device=device)
sd.wait()
arr = np.array(recorded)
print(f"Recorded array shape: {arr.shape}, dtype={arr.dtype}")
if arr.ndim == 1:
print("-> Mono (1D) array returned")
elif arr.ndim == 2:
print(f"-> Multichannel: {arr.shape[1]} channels")
else:
print("-> Unexpected ndim:", arr.ndim)
print(f"Sample min/max: {arr.min()} / {arr.max()}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"type": "micro",
"wake_word": "Hey Luna",
"wake_word": "hey_luna",
"author": "adamlonsdale",
"website": "https://github.com/adamlonsdale",
"model": "hey_luna.tflite",
-5
View File
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "Alexa",
"model": "alexa_v0.1.tflite"
}
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "HAL",
"model": "hal_v2.tflite"
}
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "Hey Jarvis",
"model": "hey_jarvis_v0.1.tflite"
}
Binary file not shown.
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "Hey Mycroft",
"model": "hey_mycroft_v0.1.tflite"
}
Binary file not shown.
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "Hey Rhasspy",
"model": "hey_rhasspy_v0.1.tflite"
}
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
{
"type": "openWakeWord",
"wake_word": "Marvin",
"model": "marvin_v2.tflite"
}

Some files were not shown because too many files have changed in this diff Show More