Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d844f8807 | ||
|
|
0d6b87739a | ||
|
|
68c7930405 | ||
|
|
7abceb946e | ||
|
|
5b1ef20656 | ||
|
|
c527f60681 | ||
|
|
4b6b6dd380 | ||
|
|
67abca1111 | ||
|
|
d10cf87eef | ||
|
|
1da9ac1fac | ||
|
|
fed867936a | ||
|
|
90fa3a9df7 | ||
|
|
05817ceb58 | ||
|
|
010e5447df | ||
|
|
6866358dfe | ||
|
|
7891997392 | ||
|
|
cc2a7a6206 | ||
|
|
1a1c15f836 | ||
|
|
cfc24f6b73 | ||
|
|
4da78c7435 | ||
|
|
8a9939cf62 | ||
|
|
eb4c4c26c8 | ||
|
|
a1b460391d | ||
|
|
0f3c7d6c15 | ||
|
|
b8a3ce2c39 | ||
|
|
3ba4e748ee | ||
|
|
5932f945e9 | ||
|
|
5315c56556 | ||
|
|
a7a4f47f52 | ||
|
|
4f05312e16 | ||
|
|
7bbe0ed96f | ||
|
|
4bea3f63d2 | ||
|
|
77c2299453 | ||
|
|
ae9b834ee3 | ||
|
|
bddbbbe899 | ||
|
|
661e50921e | ||
|
|
670cd52725 | ||
|
|
22e94fc92b | ||
|
|
99157b9a8a | ||
|
|
46c39c6ae5 | ||
|
|
ab99019d42 | ||
|
|
7abce1060d | ||
|
|
56572d4b33 | ||
|
|
8187760b39 | ||
|
|
3f05bcfc56 | ||
|
|
31b6d70910 | ||
|
|
1cd861454a | ||
|
|
f1d2700fac | ||
|
|
70079d6a61 | ||
|
|
957d3f2839 | ||
|
|
ae8f1c5c3a | ||
|
|
3d7cc49466 | ||
|
|
b15848a97d | ||
|
|
59d8071384 | ||
|
|
e845c5c811 | ||
|
|
a52b503cdb | ||
|
|
13aad43c29 | ||
|
|
ebe93da7cb | ||
|
|
6c65903425 | ||
|
|
8d135dc51b | ||
|
|
7305a289a0 | ||
|
|
44091bbc8b | ||
|
|
97da7f2d0f | ||
|
|
471ecea811 | ||
|
|
186f33f1a1 | ||
|
|
57b20a1ef1 | ||
|
|
5ef680d734 | ||
|
|
5773f3a35a | ||
|
|
6260bf1dca | ||
|
|
fa3889805e | ||
|
|
a9cf822136 | ||
|
|
0073dc6a23 | ||
|
|
dc9049e899 | ||
|
|
edc66183c3 | ||
|
|
93bdb60fc0 | ||
|
|
912468703a | ||
|
|
17f1671f1b | ||
|
|
0e928bd7b3 | ||
|
|
0025fc781f | ||
|
|
5da1dd4bc4 | ||
|
|
b43b036198 | ||
|
|
5931370c63 | ||
|
|
53d143fb35 | ||
|
|
be41c876b0 | ||
|
|
48a6ec38a5 | ||
|
|
b43cd95ec1 | ||
|
|
e1fd1ccd4d | ||
|
|
5ce9085fd9 | ||
|
|
d0a74a2d3b | ||
|
|
85560b8126 | ||
|
|
0a2c227cad | ||
|
|
11c0e4fbb6 | ||
|
|
e13cc31e5b | ||
|
|
e3f9757bee | ||
|
|
f38c89fb3b | ||
|
|
c6c34b60e5 | ||
|
|
62164e0142 | ||
|
|
bd488f555e | ||
|
|
a9989f0c25 | ||
|
|
baa6f9080f | ||
|
|
f3ae02da0d | ||
|
|
14d10b351f | ||
|
|
d2a133191b | ||
|
|
8d474acdab | ||
|
|
18e9a49adf | ||
|
|
61e02f9adb | ||
|
|
93ceffebae | ||
|
|
9285337a5e | ||
|
|
c7af116904 | ||
|
|
25d341927c | ||
|
|
adbb37e731 | ||
|
|
0151b64810 | ||
|
|
8d928c6b02 | ||
|
|
0b3c3e7b42 | ||
|
|
f34bc90edd | ||
|
|
196d5273f0 | ||
|
|
d3d60e1c99 | ||
|
|
4616645c40 | ||
|
|
6eb3f46cbe | ||
|
|
6f5444237c | ||
|
|
cf82a3158d | ||
|
|
e63bda7e76 | ||
|
|
592d5eb0c3 | ||
|
|
360fe06d96 | ||
|
|
efe2b4ff4e | ||
|
|
e3216d3e68 | ||
|
|
5eb1489947 | ||
|
|
2808877895 | ||
|
|
2fdef7d6ce | ||
|
|
2ccda5ac4a | ||
|
|
fd5cd13f72 | ||
|
|
ada83bdfbb | ||
|
|
b62d07be7d | ||
|
|
f39df8a40a | ||
|
|
37922351f6 | ||
|
|
64e2657824 | ||
|
|
8c6df9b245 | ||
|
|
52e5fb374e | ||
|
|
fc6d750257 | ||
|
|
4d8f5c97b7 | ||
|
|
c9be5fc026 | ||
|
|
a4fa7b8631 | ||
|
|
005900b2ae | ||
|
|
324881ca8b | ||
|
|
f033595649 | ||
|
|
d2a9057dde | ||
|
|
d95c15bf40 | ||
|
|
8d6b1849bd | ||
|
|
19677dfedd | ||
|
|
e0bb8e7793 | ||
|
|
82c02e9db9 | ||
|
|
9bc2830a2d | ||
|
|
3507074698 | ||
|
|
bf39055441 | ||
|
|
3587d8329f | ||
|
|
d07e1be653 | ||
|
|
28c4ade611 | ||
|
|
90f37df4e8 | ||
|
|
8e4480716a | ||
|
|
531e6500d0 | ||
|
|
70e51148bf | ||
|
|
9d27b016ca | ||
|
|
b209c1300e | ||
|
|
e073be7978 | ||
|
|
75d02225b0 | ||
|
|
77c2530657 | ||
|
|
bd24e543b9 | ||
|
|
ebd74c1e80 | ||
|
|
ce5418a27a | ||
|
|
94e13f2c13 | ||
|
|
bd21743c49 | ||
|
|
474ddfbaa4 | ||
|
|
208e7baec6 | ||
|
|
899130c0d5 | ||
|
|
2ab4ae7b22 | ||
|
|
4d153d41cd | ||
|
|
ab4bc2d027 | ||
|
|
a1c1336ba2 | ||
|
|
f1137232e0 | ||
|
|
0f1ceefc5c | ||
|
|
2cdabdad30 | ||
|
|
64c46fce2d | ||
|
|
943a8a0d5c | ||
|
|
61bf13ca98 | ||
|
|
111f99983e | ||
|
|
a74bf2ca1d | ||
|
|
676591a75d | ||
|
|
4cbfc760f3 | ||
|
|
8b30c3cecb | ||
|
|
c4f632a48f | ||
|
|
8bef7e0f7a | ||
|
|
d427d138e6 | ||
|
|
85f4d9150b | ||
|
|
9547a8eea6 | ||
|
|
ce4486896a | ||
|
|
2dc8e146af | ||
|
|
31ea2762a3 | ||
|
|
d98ed168a4 | ||
|
|
8cd9ffc0d5 | ||
|
|
6f63586ec5 | ||
|
|
57ac85b044 | ||
|
|
505f53315d | ||
|
|
957a56c0b5 | ||
|
|
523bdd74c4 | ||
|
|
24c0d30540 | ||
|
|
c067f6de99 | ||
|
|
bfef126e0d | ||
|
|
49e75d0b2f | ||
|
|
8b8de94c76 | ||
|
|
5c14c6c782 | ||
|
|
f707f3232a | ||
|
|
6077a31baa | ||
|
|
dbc371ce8f | ||
|
|
798c371943 | ||
|
|
26cdd5159b | ||
|
|
37898c8241 | ||
|
|
939dd8963c | ||
|
|
58be335eb7 | ||
|
|
a26e91eb70 | ||
|
|
7e4686b8f6 | ||
|
|
7d4edbf369 | ||
|
|
6958b09f4d | ||
|
|
1eef4ba917 | ||
|
|
226dc7f11d | ||
|
|
a435cc97e7 | ||
|
|
402a6d1855 | ||
|
|
e0bef20398 | ||
|
|
53b8fc4da9 | ||
|
|
824ac111ff | ||
|
|
bad38a3ece | ||
|
|
f90ecdcda3 | ||
|
|
01c49d4888 | ||
|
|
515ce2c5b2 | ||
|
|
3fb4ee8066 | ||
|
|
524ad02228 | ||
|
|
d071803257 | ||
|
|
6868651823 | ||
|
|
834a15beb9 | ||
|
|
e78675aec2 | ||
|
|
ccf5af8875 | ||
|
|
13c64687fe | ||
|
|
82290688e2 | ||
|
|
bb4556a2ec | ||
|
|
98c8585ae3 | ||
|
|
aecf118983 | ||
|
|
63862487fb | ||
|
|
3d07872f49 | ||
|
|
ca34354948 | ||
|
|
6a4163d32a | ||
|
|
085e944a85 | ||
|
|
eda9ac7d0d | ||
|
|
b3a853b94b | ||
|
|
7049ec969e | ||
|
|
541496e852 | ||
|
|
8ccfbdec17 | ||
|
|
02494e3bbd | ||
|
|
7b74e25759 | ||
|
|
1ccd9b35c8 | ||
|
|
81f3cb5425 | ||
|
|
cd4ad4bdfc | ||
|
|
3e99e651f1 | ||
|
|
d5b1507379 | ||
|
|
355f8c8150 | ||
|
|
5fcb8b2623 | ||
|
|
1a5cc3005f | ||
|
|
002c22c622 | ||
|
|
a7c797991b | ||
|
|
d90943fe8e | ||
|
|
39d6d3e010 | ||
|
|
4d12149dae | ||
|
|
415130aeb4 | ||
|
|
bceb7640ef | ||
|
|
3e52720925 | ||
|
|
507fe66032 | ||
|
|
9811328182 | ||
|
|
aa3ebff8f4 | ||
|
|
081ebd4103 | ||
|
|
18f29c52da | ||
|
|
92867db2b2 | ||
|
|
efbcbc599f | ||
|
|
6e63111e63 | ||
|
|
d5eef16235 | ||
|
|
164e01682f | ||
|
|
2c5a881736 | ||
|
|
e7c9b58ef4 | ||
|
|
b195cfde56 | ||
|
|
688cc97d7d | ||
|
|
6ce1c5f2b7 | ||
|
|
abcc7aeaef | ||
|
|
7f8bdefa39 | ||
|
|
de30740dce | ||
|
|
dfe992ec35 | ||
|
|
fd54c25303 | ||
|
|
710610035c | ||
|
|
70ca927617 | ||
|
|
21a0108a82 | ||
|
|
a463709ef2 | ||
|
|
abcbae1b8f | ||
|
|
6a66bd7128 | ||
|
|
0a44238655 | ||
|
|
ec51b0b11e | ||
|
|
bc8e6e0ef7 | ||
|
|
87a7cfa7b2 | ||
|
|
1baae464d1 | ||
|
|
853e25cf60 | ||
|
|
eb73cb5baa | ||
|
|
ab8f09048e | ||
|
|
68992feb9a | ||
|
|
7a4b3e661a | ||
|
|
ed16301767 | ||
|
|
d5300ae694 | ||
|
|
f520459022 | ||
|
|
e26864a191 | ||
|
|
2c937e3cb3 | ||
|
|
ea901a1c14 |
+71
-14
@@ -1,18 +1,75 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg
|
||||
build
|
||||
htmlcov
|
||||
*$py.class
|
||||
|
||||
.projectile
|
||||
.venv/
|
||||
venv/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
# 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
|
||||
|
||||
# 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/
|
||||
.venv/
|
||||
|
||||
# App-specific
|
||||
/dist/
|
||||
/preferences.json
|
||||
/local/
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
# Linux Voice Assistant
|
||||
|
||||
Experimental Linux voice assistant (LVA) for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
|
||||
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**
|
||||
|
||||
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 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.
|
||||
- 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.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -27,7 +60,7 @@ Install system dependencies (`apt-get`):
|
||||
Clone and install project:
|
||||
|
||||
``` sh
|
||||
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
|
||||
git clone https://github.com/imonlinux/linux-voice-assistant.git
|
||||
cd linux-voice-assistant
|
||||
script/setup
|
||||
```
|
||||
@@ -36,9 +69,9 @@ script/setup
|
||||
|
||||
Use `script/run` or `python3 -m linux_voice_assistant`
|
||||
|
||||
See `--help` for more options.
|
||||
See `linux_voice_assistant/config.json.example` for more options.
|
||||
|
||||
## Connecting to Home Assistant
|
||||
## Connecting to Home Assistant if not auto detected
|
||||
|
||||
1. In Home Assistant, go to "Settings" -> "Device & services"
|
||||
2. Click the "Add integration" button
|
||||
@@ -49,16 +82,17 @@ See `--help` for more options.
|
||||
|
||||
## ToDo:
|
||||
|
||||
* 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 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 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
|
||||
* ~~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)
|
||||
* Could this be an DEB package?
|
||||
* Implement Stop for OWW
|
||||
* ~~Implement Stop for OWW~~ (Not needed, Stop with MWW works even when using OWW detection models)
|
||||
* Stretch goal: create a smart installer full of validation and sanity checks
|
||||
<!-- Links -->
|
||||
[homeassistant]: https://www.home-assistant.io/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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"
|
||||
@@ -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
|
||||
sudo systemctl disable --now linux-voice-assistant.service
|
||||
sudo rm /etc/systemd/system/linux-voice-assistant.service
|
||||
systemctl --user stop linux-voice-assistant.service
|
||||
systemctl --user disable --now linux-voice-assistant.service
|
||||
```
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ 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.:
|
||||
|
||||
@@ -69,19 +71,14 @@ 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 150%.
|
||||
If volume is low, set it to 100%.
|
||||
|
||||
```sh
|
||||
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 150%
|
||||
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
|
||||
```
|
||||
|
||||
**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. Reboot your Pi: (see step 4)
|
||||
|
||||
|
||||
## 10. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
## 9. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
|
||||
|
||||
|
||||
|
||||
+9
-165
@@ -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 minor modifications
|
||||
> Courtesy of https://github.com/FutureProofHomes/wyoming-enhancements/blob/master/snapcast/docs/2_install_pulseaudio.md with now major modifications
|
||||
|
||||
## 1. Connect to your Pi over SSH using the username/password you configured during flashing:
|
||||
|
||||
@@ -37,52 +37,7 @@ sudo reboot -h now
|
||||
## 5. SSH back in to your Pi. (See step 1.)
|
||||
|
||||
|
||||
## 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:
|
||||
## 6. (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.
|
||||
|
||||
@@ -97,137 +52,26 @@ pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
|
||||
```
|
||||
|
||||
|
||||
## 13. Test and make sure you can hear the wav file.:
|
||||
|
||||
```sh
|
||||
paplay /usr/share/sounds/alsa/Front_Center.wav
|
||||
```
|
||||
## 7. Test and make sure you can hear the wav file.:
|
||||
|
||||
**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 you disabled your Linux Voice Assistant service in step 2., enable it now:
|
||||
If volume is low, set it to 100%.
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now linux-voice-assistant.service
|
||||
```sh
|
||||
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
|
||||
```
|
||||
|
||||
## 16. Reboot your Pi: (see step 4)
|
||||
|
||||
|
||||
## 17. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
### 8. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
```
|
||||
> 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** 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.
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
- 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
|
||||
- Raspberry Pi OS Lite (64-bit) (Bookworm or Trixie)
|
||||
- Default Python 3.11+ recommended
|
||||
- A ReSpeaker 2‑mic sound card or compatable
|
||||
- Network access to your Home Assistant instance
|
||||
@@ -25,8 +23,8 @@ This guide reproduces a working setup of the **linux-voice-assistant** project w
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade
|
||||
sudo apt install libportaudio2 build-essential git \
|
||||
libmpv-dev mpv python3.11-dev avahi-daemon avahi-utils
|
||||
sudo apt install build-essential git \
|
||||
libmpv-dev mpv python3-dev python3-venv
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
@@ -47,21 +45,7 @@ sudo reboot
|
||||
```
|
||||
|
||||
|
||||
## 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)
|
||||
## 4. Linux Voice Assistant (LVA)
|
||||
|
||||
```bash
|
||||
cd ~/linux-voice-assistant/
|
||||
@@ -69,14 +53,14 @@ script/setup
|
||||
```
|
||||
|
||||
|
||||
## 6. Choose your install option "Choose your Adventure!"
|
||||
## 5. Choose your install option "Choose your Adventure!"
|
||||
|
||||
Pick **one** of the following install paths. Expand a section to see the exact steps.
|
||||
|
||||
> Tip: PipeWire options run services in *user* mode (requires `loginctl enable-linger`); PulseAudio/ALSA options run services in *system* mode.
|
||||
> Tip: All services run in *user* mode (requires `loginctl enable-linger`);
|
||||
|
||||
<details>
|
||||
<summary><strong>PipeWire + OpenWakeWord (user-mode services)</strong></summary>
|
||||
<summary><strong>PipeWire (user-mode services)</strong></summary>
|
||||
|
||||
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
|
||||
|
||||
@@ -85,44 +69,13 @@ Pick **one** of the following install paths. Expand a section to see the exact s
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install user-mode services:**
|
||||
**Install LVA user-mode services:**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
# 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 wyoming-openwakeword --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>PipeWire + MicroWakeWord (user-mode services)</strong></summary>
|
||||
|
||||
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
|
||||
|
||||
**Enable linger:**
|
||||
```bash
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install user-mode service:**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp ~/linux-voice-assistant/service/user-pw-mww-linux-voice-assistant.service.service ~/.config/systemd/user/linux-voice-assistant.service
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Enable & start:**
|
||||
@@ -138,54 +91,38 @@ systemctl --user status linux-voice-assistant --no-pager -l
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>PulseAudio + OpenWakeWord (system services)</strong></summary>
|
||||
<summary><strong>PulseAudio (user-mode services)</strong></summary>
|
||||
|
||||
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
|
||||
|
||||
**Install LVA (system) + tuning drop-in:**
|
||||
**Enable linger (required for user services to start after reboot):**
|
||||
```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
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install OWW (system):**
|
||||
**Install LVA user-mode services:**
|
||||
```bash
|
||||
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
|
||||
mkdir -p ~/.config/systemd/user
|
||||
```
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.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
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --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>
|
||||
<summary><strong>ALSA (user-mode services)</strong></summary>
|
||||
|
||||
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
|
||||
```bash
|
||||
@@ -193,59 +130,451 @@ arecord -l
|
||||
aplay -l
|
||||
```
|
||||
|
||||
**Install LVA (system):**
|
||||
**Enable linger (required for user services to start after reboot):**
|
||||
```bash
|
||||
sudo cp ~/linux-voice-assistant/service/alsa-oww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install OWW (system):**
|
||||
**Install LVA user-mode services:**
|
||||
|
||||
```bash
|
||||
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
|
||||
mkdir -p ~/.config/systemd/user
|
||||
```
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.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
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>ALSA + MicroWakeWord (system services)</strong></summary>
|
||||
<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:**
|
||||
|
||||
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
|
||||
```bash
|
||||
arecord -l
|
||||
aplay -l
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
**Install LVA (system):**
|
||||
**Add MQTT configuration entries to LVA config.json file:**
|
||||
|
||||
```bash
|
||||
sudo cp ~/linux-voice-assistant/service/alsa-mww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now linux-voice-assistant
|
||||
sudo systemctl status linux-voice-assistant --no-pager -l
|
||||
systemctl --user restart linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Optional: Advertise LVA to Home Assistant via Avahi</strong></summary>
|
||||
<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:**
|
||||
|
||||
**Generate & install ESPHome-style mDNS service:**
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Optional (Acoustic Echo Cancellation) </strong></summary>
|
||||
|
||||
## 7. Connect to Home Assistant
|
||||
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
|
||||
|
||||
### If HA does not discover the new LVA:
|
||||
|
||||
@@ -254,12 +583,12 @@ sudo systemctl restart avahi-daemon.service
|
||||
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 you configured in your linux-voice-assistant.service file. Default is "alexa".
|
||||
6. During the registration process, use the wake word that is displayed on the registration page. Default is "OK Nabu".
|
||||
|
||||
|
||||
## 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.
|
||||
## 7. Verification
|
||||
|
||||
- Use "journalctl --user -u linux-voice-assistant.service -f" to check for errors. 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
|
||||
@@ -268,7 +597,7 @@ sudo systemctl restart avahi-daemon.service
|
||||
### Settings -> Voice assistants -> Assist (the assistant you configured) -> Text-to-speech -> Voice
|
||||
|
||||
|
||||
## 9. Change OWW detection model
|
||||
## 8. 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.
|
||||
|
||||
@@ -279,24 +608,55 @@ 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 Nabo **(I had to say OK Nobu)**
|
||||
ok_nabu_v0.1.tflite -> OK Nabu **(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 ~/wyoming-openwakeword/wyoming-openwakeword/models directory. If a model is currupted, the wyoming-openwakeword.service will fail to start.
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
**Word of warning. I have had problems with some of the community provided wake words. YMMV**
|
||||
|
||||
|
||||
## 10. Switching between OWW and MWW. Or ALSA, PW, PA see section 6.
|
||||
## 9. Switching between ALSA, PW, or PA see section 5.
|
||||
|
||||
If you intend to switch from PA to ALSA, you must first stop the pusleaudio.service.
|
||||
If you intend to switch from PA or PW to ALSA, you must first stop and disable the corresponding user-mode services.
|
||||
|
||||
###PulseAudio
|
||||
|
||||
```bash
|
||||
sudo systemctl stop pulseaudio.service
|
||||
sudo systemctl --user stop pulseaudio.service
|
||||
```
|
||||
```bash
|
||||
sudo systemctl disable pulseaudio.service
|
||||
sudo systemctl --user disable pulseaudio.service
|
||||
```
|
||||
###PipeWire
|
||||
|
||||
```bash
|
||||
sudo systemctl --user stop pipewire.service
|
||||
```
|
||||
```bash
|
||||
sudo systemctl --user disable pipewire.service
|
||||
```
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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 +0,0 @@
|
||||
https://github.com/tphakala/tflite_c/
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,73 +0,0 @@
|
||||
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/
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
linux_voice_assistant
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
+542
-594
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
ConnectRequest,
|
||||
ConnectResponse,
|
||||
AuthenticationRequest, # <-- MODIFIED
|
||||
AuthenticationResponse, # <-- MODIFIED
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
@@ -57,8 +57,8 @@ class APIServer(asyncio.Protocol):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(msg_inst, ConnectRequest):
|
||||
self.send_messages([ConnectResponse()])
|
||||
if isinstance(msg_inst, AuthenticationRequest): # <-- MODIFIED
|
||||
self.send_messages([AuthenticationResponse()]) # <-- MODIFIED
|
||||
elif isinstance(msg_inst, DisconnectRequest):
|
||||
self.send_messages([DisconnectResponse()])
|
||||
_LOGGER.debug("Disconnect requested")
|
||||
@@ -99,7 +99,6 @@ 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
|
||||
@@ -113,7 +112,6 @@ class APIServer(asyncio.Protocol):
|
||||
return
|
||||
|
||||
if length == 0:
|
||||
# Empty message (allowed)
|
||||
self._remove_from_buffer()
|
||||
self.process_packet(msg_type, b"")
|
||||
continue
|
||||
@@ -125,7 +123,6 @@ 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
|
||||
@@ -134,8 +131,6 @@ 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):
|
||||
@@ -143,7 +138,6 @@ 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
|
||||
|
||||
@@ -160,21 +154,12 @@ 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]
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import Callable, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
@@ -17,36 +17,38 @@ 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) -> None:
|
||||
def __init__(self, server: APIServer, state: "ServerState") -> 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:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
super().__init__(server, state)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self.state = MediaPlayerState.IDLE
|
||||
self.volume = 1.0
|
||||
self.state_enum = MediaPlayerState.IDLE
|
||||
self.volume = state.preferences.volume_level # Initialize with saved volume
|
||||
self.muted = False
|
||||
self.music_player = music_player
|
||||
self.announce_player = announce_player
|
||||
@@ -97,19 +99,33 @@ 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.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 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 isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesMediaPlayerResponse(
|
||||
object_id=self.object_id,
|
||||
@@ -117,17 +133,18 @@ 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 = new_state
|
||||
self.state_enum = new_state
|
||||
return self._get_state_message()
|
||||
|
||||
def _get_state_message(self) -> MediaPlayerStateResponse:
|
||||
return MediaPlayerStateResponse(
|
||||
key=self.key,
|
||||
state=self.state,
|
||||
state=self.state_enum,
|
||||
volume=self.volume,
|
||||
muted=self.muted,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""A simple synchronous publish/subscribe event bus."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
@@ -8,36 +10,34 @@ 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.
|
||||
"""
|
||||
|
||||
# _LOGGER.debug(f'EventBus subscribe {topic}')
|
||||
|
||||
"""Subscribes a listener to a 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: [dict, None]) -> None:
|
||||
"""
|
||||
Publishes an event to all subscribed listeners.
|
||||
"""
|
||||
|
||||
# _LOGGER.debug(f'EventBus publish {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 = {}
|
||||
|
||||
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)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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,14 +47,12 @@ class EventHandler:
|
||||
"""
|
||||
A base class for components that subscribe to events.
|
||||
|
||||
Subclasses should define event handlers as methods decorated with `@subscribe`.
|
||||
The method name will automatically be used as the event topic.
|
||||
Subclasses must call `self._subscribe_all_methods()` in their __init__.
|
||||
"""
|
||||
|
||||
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 __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 _subscribe_all_methods(self):
|
||||
"""Finds and subscribes all methods decorated with @subscribe."""
|
||||
@@ -63,5 +61,4 @@ class EventHandler:
|
||||
|
||||
if hasattr(method, '_event_bus_subscribe'):
|
||||
# The topic is the name of the method itself.
|
||||
self.state.event_bus.subscribe(method_name, method)
|
||||
_LOGGER.debug(f"Subscribed method '{method_name}' to topic '{method_name}'")
|
||||
self.event_bus.subscribe(method_name, method)
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
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)
|
||||
@@ -1,128 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,158 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,156 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,188 +0,0 @@
|
||||
|
||||
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)
|
||||
@@ -1,198 +0,0 @@
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,272 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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,
|
||||
)
|
||||
+178
-277
@@ -1,376 +1,277 @@
|
||||
# linux_voice_assistant/mpv_player.py
|
||||
"""
|
||||
Media player using mpv in a subprocess.
|
||||
"""Media player using mpv in a subprocess.
|
||||
|
||||
- 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")
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from threading import Lock, Timer
|
||||
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
|
||||
from threading import Lock
|
||||
from typing import Callable, List, Optional, Sequence, 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:
|
||||
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)
|
||||
"""A media player class that wraps the python-mpv library."""
|
||||
|
||||
# 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)
|
||||
def __init__(
|
||||
self,
|
||||
loop: Optional[asyncio.AbstractEventLoop],
|
||||
device: Optional[str] = None,
|
||||
initial_volume: float = 1.0,
|
||||
) -> None:
|
||||
"""Initialize the mpv player.
|
||||
|
||||
# Env-driven AO override
|
||||
env_ao = _normalize_env_ao(os.environ.get("LVA_AO"))
|
||||
: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)))
|
||||
|
||||
# Backend selection
|
||||
self._ao, self._audio_device = _select_backend(self.player, device, env_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)
|
||||
|
||||
# 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 # stores 0..100 when ducked
|
||||
self._watchdog: Optional[Timer] = None
|
||||
self._watchdog_sec: float = float(watchdog_sec)
|
||||
self._pre_duck_volume: Optional[int] = None
|
||||
|
||||
# Playback end detection (both EOF and idle)
|
||||
self.player.observe_property("eof-reached", self._on_eof)
|
||||
# When mpv becomes idle, we treat it as end-of-playback.
|
||||
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:
|
||||
"""Begin playback of a URL or local file. Replaces any existing playback.
|
||||
"""Plays a URL or sequence of URLs.
|
||||
|
||||
Accepts a single URL (str/bytes) or a sequence of URLs; if a sequence is given,
|
||||
the first item is played.
|
||||
:param url: A single URL (str/bytes) or a sequence of URLs.
|
||||
:param done_callback: Called once when playback finishes or is stopped.
|
||||
"""
|
||||
if stop_first:
|
||||
try:
|
||||
# Ensure player is in a clean state
|
||||
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
|
||||
|
||||
# Normalize URL input
|
||||
playlist: List[str] = []
|
||||
if isinstance(url, (list, tuple)):
|
||||
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))
|
||||
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))
|
||||
self._run_done_callback()
|
||||
return
|
||||
|
||||
# Ensure not muted
|
||||
try:
|
||||
self.player["mute"] = "no"
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to clear mute before play()", exc_info=True)
|
||||
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)
|
||||
|
||||
# Begin playback
|
||||
self.is_playing = True
|
||||
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()
|
||||
self.player.playlist_pos = 0 # Start playing from the first item
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pauses playback."""
|
||||
try:
|
||||
self.player.pause = True
|
||||
except Exception:
|
||||
_LOGGER.exception("mpv pause() failed", exc_info=True)
|
||||
_LOGGER.exception("pause() failed")
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resumes playback."""
|
||||
try:
|
||||
self.player.pause = False
|
||||
except Exception:
|
||||
_LOGGER.exception("mpv resume() failed", exc_info=True)
|
||||
_LOGGER.exception("resume() failed")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop playback immediately and clear playing state; run done callback."""
|
||||
try:
|
||||
"""Stops playback and clears the playlist."""
|
||||
if self.is_playing:
|
||||
self.is_playing = False
|
||||
try:
|
||||
self.player.playlist_clear()
|
||||
self.player.command("stop")
|
||||
except Exception:
|
||||
# Fallback: load a null source to replace playback
|
||||
self.player.command("loadfile", "null://", "replace")
|
||||
_LOGGER.exception("stop() failed")
|
||||
finally:
|
||||
self._cancel_watchdog()
|
||||
was_playing = self.is_playing
|
||||
self.is_playing = False
|
||||
if was_playing:
|
||||
self._run_done_callback()
|
||||
|
||||
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
|
||||
|
||||
def set_volume(self, volume: int) -> None:
|
||||
"""Sets the player (mpv) volume from 0 to 100."""
|
||||
try:
|
||||
vol = max(0, min(100, int(vol_arg)))
|
||||
self.player.volume = max(0, min(100, volume))
|
||||
except Exception:
|
||||
_LOGGER.exception("set_volume(): invalid value %r", vol_arg, exc_info=True)
|
||||
return
|
||||
_LOGGER.exception("set_volume() failed")
|
||||
|
||||
try:
|
||||
self.player.volume = vol
|
||||
except Exception:
|
||||
_LOGGER.exception("mpv set_volume(%s) failed", vol, exc_info=True)
|
||||
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)
|
||||
|
||||
def duck(self, target_percent: int = 20) -> None:
|
||||
"""Temporarily lower volume for TTS/alerts; call unduck() to restore."""
|
||||
"""Lowers the mpv volume for an announcement."""
|
||||
if self._pre_duck_volume is not None:
|
||||
return # already ducked
|
||||
|
||||
return
|
||||
try:
|
||||
current = int(round(float(self.player.volume)))
|
||||
self._pre_duck_volume = int(self.player.volume)
|
||||
self.set_volume(target_percent)
|
||||
except Exception:
|
||||
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)
|
||||
_LOGGER.exception("duck() failed")
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restore volume after duck()."""
|
||||
"""Restores the mpv volume after an announcement."""
|
||||
if self._pre_duck_volume is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.player.volume = self._pre_duck_volume
|
||||
self.set_volume(self._pre_duck_volume)
|
||||
except Exception:
|
||||
_LOGGER.exception("unduck(): failed to restore volume", exc_info=True)
|
||||
_LOGGER.exception("unduck() failed")
|
||||
finally:
|
||||
self._pre_duck_volume = None
|
||||
|
||||
# -------------------- 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()
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal callbacks
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _on_idle_active(self, _name: str, active: bool) -> None:
|
||||
# When playback ends, mpv enters idle; treat that as completion.
|
||||
if not self.is_playing:
|
||||
return
|
||||
if bool(active):
|
||||
"""Callback triggered when mpv enters or leaves the idle state."""
|
||||
if active and self.is_playing:
|
||||
_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:
|
||||
cb: Optional[Callable[[], None]] = None
|
||||
"""Safely runs the done_callback on the main asyncio loop (if any)."""
|
||||
with self._done_callback_lock:
|
||||
cb = self._done_callback
|
||||
self._done_callback = None
|
||||
|
||||
if cb is not 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).
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error running done callback", exc_info=True)
|
||||
_LOGGER.exception("Error running done_callback directly")
|
||||
|
||||
# -------------------- mpv log bridge --------------------
|
||||
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
msg = f"mpv[{level}] {prefix}: {text}".rstrip()
|
||||
if level in ("fatal", "error"):
|
||||
"""Routes mpv's internal logs to our logger."""
|
||||
msg = f"mpv[{prefix}]: {text}".rstrip()
|
||||
if level == "error":
|
||||
_LOGGER.error(msg)
|
||||
elif level in ("warn", "warning"):
|
||||
elif level == "warn":
|
||||
_LOGGER.warning(msg)
|
||||
elif level in ("info",):
|
||||
elif level == "info":
|
||||
_LOGGER.info(msg)
|
||||
else:
|
||||
_LOGGER.debug(msg)
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
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)
|
||||
@@ -1,364 +0,0 @@
|
||||
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:]
|
||||
@@ -0,0 +1,702 @@
|
||||
"""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")
|
||||
@@ -1,22 +1,46 @@
|
||||
"""Utility methods."""
|
||||
|
||||
import platform
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_CACHED_MAC: Optional[str] = None
|
||||
|
||||
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 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 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)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,444 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,499 @@
|
||||
#!/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()
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
|
||||
[mypy]
|
||||
python_version = 3.9
|
||||
warn_return_any = True
|
||||
warn_unused_configs = True
|
||||
|
||||
[mypy-setuptools.*]
|
||||
[mypy-aioesphomeapi._frame_helper.packets.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-aioesphomeapi.api_pb2.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pymicro_features.*]
|
||||
[mypy-pymicro_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sounddevice.*]
|
||||
[mypy-pyopen_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-soundcard.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-mpv.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-google.protobuf.*]
|
||||
[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.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
+26
-9
@@ -22,26 +22,43 @@ 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==37.2.1",
|
||||
"sounddevice<1",
|
||||
"aioesphomeapi==43.9.1",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
"pymicro-features==1.0.0",
|
||||
"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",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black==24.8.0",
|
||||
"flake8==7.2.0",
|
||||
"mypy==1.14.0",
|
||||
"pylint==3.2.7",
|
||||
"pytest==8.3.5",
|
||||
"black",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"pylint",
|
||||
"pytest",
|
||||
]
|
||||
|
||||
# Optional GUI/tray dependencies. Only needed on desktop installs that use lva_tray_client.
|
||||
tray = [
|
||||
"PyQt5>=5.15",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
|
||||
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"
|
||||
|
||||
[tool.setuptools]
|
||||
platforms = ["any"]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
aioesphomeapi
|
||||
sounddevice
|
||||
gpiozero
|
||||
spidev
|
||||
adafruit-circuitpython-dotstar
|
||||
@@ -2,21 +2,49 @@
|
||||
|
||||
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
|
||||
# Must be run with sudo.
|
||||
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
||||
# Requires: curl, dkms, i2c-tools, libasound2-plugins, alsa-utils,
|
||||
# and appropriate Raspberry Pi kernel headers.
|
||||
|
||||
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."
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install --no-install-recommends --yes \
|
||||
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
||||
curl dkms i2c-tools libasound2-plugins alsa-utils \
|
||||
${header_pkg:+$header_pkg}
|
||||
|
||||
temp_dir="$(mktemp -d)"
|
||||
|
||||
@@ -31,10 +59,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" | \
|
||||
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'
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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")
|
||||
|
||||
+8
-13
@@ -17,19 +17,14 @@ builder = venv.EnvBuilder(with_pip=True)
|
||||
context = builder.ensure_directories(_VENV_DIR)
|
||||
builder.create(_VENV_DIR)
|
||||
|
||||
# Upgrade dependencies
|
||||
# Upgrade pip and setuptools
|
||||
pip = [context.env_exe, "-m", "pip"]
|
||||
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)])
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "pip", "setuptools", "wheel"])
|
||||
|
||||
# Install the project and all its dependencies from pyproject.toml
|
||||
if args.dev:
|
||||
# Install dev requirements
|
||||
subprocess.check_call(
|
||||
pip + ["install", "-e", f"{_PROGRAM_DIR}[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)])
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/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
@@ -0,0 +1,17 @@
|
||||
#!/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 "$@"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[Unit]
|
||||
Wants=avahi-daemon.service
|
||||
After=avahi-daemon.service
|
||||
@@ -1,8 +0,0 @@
|
||||
# /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
|
||||
@@ -0,0 +1,21 @@
|
||||
[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
|
||||
@@ -1,27 +0,0 @@
|
||||
[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
|
||||
@@ -1,31 +0,0 @@
|
||||
[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
|
||||
@@ -0,0 +1,16 @@
|
||||
[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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[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,17 @@
|
||||
[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,33 +0,0 @@
|
||||
[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
|
||||
@@ -1,36 +0,0 @@
|
||||
[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
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=PulseAudio system server
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=pulseaudio --daemonize=no --system --realtime --log-target=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,21 +0,0 @@
|
||||
[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
|
||||
@@ -1,26 +0,0 @@
|
||||
[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
|
||||
@@ -1,21 +0,0 @@
|
||||
[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
|
||||
@@ -1,26 +0,0 @@
|
||||
[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
|
||||
@@ -1,25 +0,0 @@
|
||||
[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
|
||||
@@ -1,27 +0,0 @@
|
||||
# /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
|
||||
@@ -0,0 +1,73 @@
|
||||
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()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
"""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
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/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,6 +1,6 @@
|
||||
{
|
||||
"type": "micro",
|
||||
"wake_word": "hey_luna",
|
||||
"wake_word": "Hey Luna",
|
||||
"author": "adamlonsdale",
|
||||
"website": "https://github.com/adamlonsdale",
|
||||
"model": "hey_luna.tflite",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Alexa",
|
||||
"model": "alexa_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "HAL",
|
||||
"model": "hal_v2.tflite"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Jarvis",
|
||||
"model": "hey_jarvis_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Mycroft",
|
||||
"model": "hey_mycroft_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Rhasspy",
|
||||
"model": "hey_rhasspy_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user