Compare commits

..
Author SHA1 Message Date
Omar Amin db8de2237e Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-01 23:29:45 +03:00
Mark J. Cox b49fd8dc65 Tell peripherals when a timer is cancelled (#431)
Cancel was the only timer event to drop `timer_data` and emit a bare
`idle`. Since `idle` also ends an ordinary conversation, a peripheral
could not tell a cancel from "finished speaking", nor which timer had
gone.

Emit `timer_updated` with the timer's identity and `seconds_left` zeroed,
then go idle as before. Shipped peripherals already read `seconds_left`
from `timer_updated` and none treats zero as ringing, so the countdown
clears with no peripheral changes. The zeroed update has to land before
`idle`, or a peripheral that re-checks on `idle` stays in the timer
state.

Fixes #367
2026-09-29 17:13:40 +03:00
Omar Amin f20952f260 Add an option to disable peripheral buttons (#423)
Adds an option to disable the on-board buttons that control LVA via the [Peripheral Websocket API](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/peripheral_api.md) using a switch from the dashboard device page that's registered using the entity registration.

<img width="396" height="47" alt="Disable button controls" src="https://github.com/user-attachments/assets/3c1c9cad-a13a-49e8-811f-1ff242ff0cdb" />

**This updates these peripheral scripts:**

- [Peripheral Web Console](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Peripheral%20web%20console/DOCS.md)
- [Satellite1 HAT Board](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Satellite1%20HAT%20Board/DOCS.md)
- [ReSpeaker 2Mic HAT](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%202mic%20HAT/DOCS.md)
- [Jabra Speak 410](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Jabra%20Speak%20410/DOCS.md)
2026-09-19 00:58:08 +03:00
nick blackandOmar Amin 7c6fbaa4ee docs: remove obsolete option from pipewire aec (#416)
PipeWire no longer honors the 'method' option for the
echo-cancel module. Trying to use it will generate:

aec.method is not supported anymore use library.name

Remove it from all pipewire examples. It could be
replaced by 'library.name=aec/libspa-aec-webrtc',
but as that's currently the default (and only supported
option), I've elided it.

Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-09-03 22:33:39 +03:00
Omar Amin 99ee08f7b1 Enhance Satellite1 HAT Board volume button functionality with hold-repeat (#415)
Refactor the Satellite1 HAT Board volume button handling to support continuous hold-repeat behavior.
2026-09-02 01:23:16 +03:00
Omar Amin cfae3c55e2 Update peripheral_api.md 2026-08-31 00:46:44 +03:00
Omar Amin b0c53c41c1 Add unit tests for PeripheralAPIServer (#413)
Adds unit tests for the PeripheralAPIServer, covering initialization, state management, command dispatching, and event emission.
2026-08-26 03:54:40 +03:00
Omar Amin 19f85b95a3 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-08-25 18:08:38 +03:00
Omar Amin adcef575b7 Peripheral volume display (#411)
Adds the volume display effect to these boards that have LED Ring, when controlling the volume for LVA:

- Satellite 1 HAT Board

- ReSpeaker 4-Mic Array HAT

- ReSpeaker USB Mic Array v2.0

This animation appears on the LED ring temporarily to display an arc that sweeps clockwise when the volume increases and anti-clockwise when the volume decreases. Triggers by the volume_changed event.
2026-08-23 01:31:37 +03:00
d1f5761f75 Restore stop word sensitivity from preferences on startup (#394)
* Restore stop word sensitivity from preferences on startup

The Home Assistant number entity persists preferences.stop_word_sensitivity
whenever it changes, but nothing ever read it back. main() did not pass
stop_word_threshold when constructing ServerState, so it always took the
dataclass default of 0.5, and lowering the sensitivity in Home Assistant
silently reverted at the next restart.

volume and mic_volume are restored at startup, and the two wake word
sensitivities are restored when wake models load. Only the stop word was
missed, so the block that syncs all three sensitivity entities back to Home
Assistant was pushing a default it had never loaded.

Restored alongside volume and clamped the same way. The resolution lives in a
small helper so it can be covered by tests.

* Move the threshold helper to models.py so tests do not import __main__

CI has no PulseAudio daemon. Importing linux_voice_assistant.__main__ pulls in
`import soundcard as sc`, and soundcard/pulseaudio.py instantiates _PulseAudio()
at module scope, which asserts on a live connection. So the four new tests
failed on the runner while passing locally, where a daemon happens to be
running.

That is also why no other test in the suite imports __main__, and why the
existing tests in test_main.py re-implement the logic they check rather than
calling it.

initial_stop_word_threshold() now lives in models.py, which owns both fields it
resolves between: Preferences.stop_word_sensitivity and
ServerState.stop_word_threshold. It imports cleanly, test_models.py already
imports it, and the reference to the ServerState default becomes local.

Behaviour is unchanged. Verified by reproducing the CI environment locally with
PULSE_SERVER pointed at a nonexistent socket: 4 failed before, 298 pass after.

---------

Co-authored-by: ryan felder <7475484+ryanfelder@users.noreply.github.com>
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-08-18 02:07:33 +03:00
Omar Amin a44292654e Add volume persistence on mute/unmute commands
Persist volume state when muting and unmuting.
2026-08-17 21:39:32 +03:00
Omar Amin 8303a96279 Fix peripheral volume_muted state (#403)
Enable updating the `volume_muted` status when muting/unmuting the media player volume, and improve its tracking.
2026-08-17 20:23:16 +03:00
dovy6 e334aa3fb1 Update README.md (#402)
Fix typo. It's a repository, not a repositroy
2026-08-09 10:30:17 +02:00
florian-asche 1543c5df58 Update changelog for v1.1.15
This commit was created by changelog-from-release in 'Generate changelog' CI workflow
2026-08-02 22:32:31 +00:00
43a183a232 Add --music-output-device to route music separately from voice (#350)
* Add --music-output-device to route music separately from voice

Music/media and TTS both play through --audio-output-device today, with no
way to send them to different sinks. Add an optional --music-output-device
(env MUSIC_OUTPUT_DEVICE) that the music player uses instead, defaulting to
--audio-output-device so existing setups are unchanged. Wired through
docker-entrypoint.sh and documented in the README and install docs.

* docs: fix swapped Default/Description columns in env var table

The MIC_VOLUME, MIC_AUTO_GAIN, MIC_NOISE_SUPPRESSION and
AUDIO_INPUT_CHANNELS rows had their Default and Description columns
swapped relative to the table header; fix the ordering and the
'Suppresion' misspelling. Also repair the UNMUTE_SOUND row, whose
description was garbled and which was missing its closing table pipe.

---------

Co-authored-by: Matthew Davis <fedoraproject@virtual.drop.net>
Co-authored-by: Florian Asche <github@florian-asche.de>
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-08-02 23:49:51 +02:00
Florian Asche a2d0a4e68e fix(docker): correct conditional check for DISABLE_PERIPHERAL_API
Fix the syntax error in the shell script where the comparison was
incorrectly structured, and remove the redundant passing of the
variable value to the flag.
2026-08-02 22:58:16 +02:00
Omar Amin b94a5a4663 Documentation Improvements (#385)
* Enhance README
2026-08-02 02:19:05 +02:00
dependabot[bot] 2b0388f454 Bump docker/login-action from 4.5.1 to 4.6.0 (#386)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 19:42:01 +02:00
mrbubble62 0eafd4f7cd Update Jabra Speak DOCS.md (#387)
Add instructions for installing the Jabra peripheral controller bare metal as a service.
2026-07-31 14:58:12 +03:00
Omar Amin 8a36a74bda Revise Key Components in AGENTS.md
Clarified API server descriptions in AGENTS.md.
2026-07-29 02:38:08 +03:00
florian-asche bf3bf0d942 Update changelog for v1.1.14
This commit was created by changelog-from-release in 'Generate changelog' CI workflow
2026-07-28 00:16:28 +00:00
Omar Amin acda765964 Fix conditional check for DISABLE_PERIPHERAL_API 2026-07-27 13:37:22 +03:00
Omar Amin 3077e7230b Add optional peripheral API configuration options
Added optional configuration settings for peripheral API in .env.example.
2026-07-27 13:35:36 +03:00
dependabot[bot] ea991e026f Bump docker/login-action from 4.5.0 to 4.5.1 (#377)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/06fb636fac595d6fb4b28a5dfcb21a6f5091859c...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 18:46:17 +02:00
SpiliosDmk 69a4fd3098 docs: add realtime scheduling tuning for constrained hardware (#376)
The core problem in #164 (config fully overriding pipewire.conf) was
already fixed by switching to pipewire.conf.d. This adds the
remaining performance-tuning recommendation (libpipewire-module-rt)
from the issue thread that never made it into the docs, to reduce
audio dropouts on hardware like the Raspberry Pi 3B.

Closes #164
2026-07-26 16:01:33 +02:00
dependabot[bot] 126bdf1862 Bump docker/login-action from 4.4.0 to 4.5.0 (#375)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...06fb636fac595d6fb4b28a5dfcb21a6f5091859c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 17:41:26 +02:00
Florian Asche 3b33730105 feat(ai): integrate Kilo agent framework and coding instructions (#366)
* feat(ai): integrate Kilo agent framework and coding instructions

Add configuration files, agent definitions, and skill sets to enable
AI-driven development workflows and standardized agent behavior.
2026-07-24 17:35:34 +02:00
dependabot[bot] ba3a4054ca Bump actions/setup-python from 5 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 23:14:31 +00:00
dependabot[bot] 0f57ca989c Bump actions/checkout from 4 to 7 (#370)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:12:30 +02:00
litinoveweedleandOmar Amin ba7d834d1f fix race condition between LVA registration to the HA and peripheral registering tot the LVA (#373)
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-07-22 01:10:10 +02:00
aryanhasgithubandFlorian Asche 2eb29470f9 Colored Debug Logging and move Voice Event to Info level in logger (#271)
* Added Colored Debug logging and arhument and changes voice event log to info

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-22 01:09:13 +02:00
dependabot[bot] 2378187197 Bump docker/setup-buildx-action from 3 to 4 (#371)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 19:40:21 +02:00
maximilianovermeyer d3874f6a2f Update install.md - Fixed log path (#372)
Fixed a typo in the path of the log dir.
2026-07-21 19:39:52 +02:00
litinoveweedleandFlorian Asche 201896196f fix Respeaker USB Mic Array v2 LEDs (#357)
Fix ReSpeaker USB LED control and suppress stale peripheral disconnect replay

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-21 19:16:10 +03:00
Florian Asche 8f788eeff0 chore(ci): update github actions and docker dependencies (#368)
- Update `actions/checkout` from v3 to v4 in workflows
- Enable `allow-unsafe-pr-checkout` in `docker-build-pr.yml` to support
  PR builds with updated checkout version
2026-07-20 18:48:13 +02:00
dependabot[bot] 305b787dd6 Bump docker/login-action from 3.1.0 to 4.4.0 (#360)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.1.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/e92390c5fb421da1463c202d546fed0ec5c39f20...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:40:20 +02:00
dependabot[bot] e14844a113 Bump docker/setup-qemu-action from 3 to 4 (#361)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:40:11 +02:00
dependabot[bot] 9ed97cd8f4 Bump docker/metadata-action from 5.5.1 to 6.2.0 (#362)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.5.1 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/8e5442c4ef9f78752691e2d8f8d19755c6f78e81...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:57 +02:00
dependabot[bot] e8042ee19d Bump actions/attest-build-provenance from 1 to 4 (#363)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 1 to 4.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v1...v4)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:47 +02:00
dependabot[bot] 802908cb8f Bump docker/build-push-action from 5.3.0 to 7.3.0 (#364)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.3.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/2cdde995de11925a030ce8070c3d77a52ffcf1c0...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:37 +02:00
Florian Asche c37b482b87 docs(github): establish repository governance and automation workflows (#359)
Introduce essential GitHub repository files to standardize community
contributions and automate maintenance tasks.

- Add AI usage policy and contribution guidelines
- Implement issue and pull request templates for consistent reporting
- Configure Dependabot for automated dependency updates
- Add workflow for automated PR labeling
- Include funding information and repository metadata
2026-07-20 17:41:57 +02:00
Florian Asche 486ff4bb53 build(dev): enhance local development environment and setup workflow (#358)
Improve the developer experience by providing structured setup scripts,
VS Code workspace configurations, and detailed documentation.

- Add VS Code workspace configuration and recommended settings
- Update README.md with system requirements and VS Code setup instructions
- Refactor `script/setup` to include Python 3.11+ validation and system dependency checks
- Add option to skip PulseAudio wait in `docker-entrypoint.sh` for local development
- Update `.gitignore` to ensure consistent version file handling
2026-07-20 17:38:52 +02:00
71 changed files with 3231 additions and 278 deletions

No files matched your search

+18
View File
@@ -8,6 +8,9 @@
### Enable debug mode (optional):
# ENABLE_DEBUG="1"
### Enable colored debug mode (optional):
# ENABLE_COLORED_DEBUG="1"
### List audio devices (optional):
# if enabled normal startup is disabled
# LIST_DEVICES="1"
@@ -64,6 +67,21 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
# Start listening immediately after wake word detection, without waiting for the wake sound to finish
# LISTEN_DURING_WAKE_SOUND="1"
### Perpheral api host (optional):
# Bind address for the peripheral WebSocket API
# PERIPHERAL_HOST="0.0.0.0"
### Perpheral api port (optional):
# Port for the peripheral WebSocket API
# PERIPHERAL_PORT="6055"
### Peripheral api volume step (optional):
# Volume change for peripheral api per button press, 0.0–1.0
# PERIPHERAL_VOLUME_STEP="%(default)s"
### Disable the peripheral WebSocket API entirely
# DISABLE_PERIPHERAL_API="1"
### Wake word directory (optional):
# path for custom files in docker is for example "app/wakewords/custom"
# WAKE_WORD_DIR="app/wakewords"
+39
View File
@@ -0,0 +1,39 @@
# Linux Voice Assistant - AI Policy
We support using AI (i.e., LLMs) as tools when contributing to Linux Voice Assistant. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
## Autonomous agents
We do not allow autonomous agents to be used for contributing to our projects. We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
## Communication on issues, pull requests, and code reviews
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
## Non-native English speakers
We understand that AI is useful when communicating as a non-native English speaker. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
## Code and documentation contributions
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to LVA projects.
+22
View File
@@ -0,0 +1,22 @@
# Contributing to Linux Voice Assistant
Everybody is invited and welcome to contribute to Linux Voice Assistant. Even if you are not a developer, perhaps you would like to help with the documentation? If you are a developer and have a feature that isn't supported by LVA yet, why not spend some time and help to integrate it?
The process is straight-forward.
- Fork the LVA repository.
- Write the code for your feature or fix.
- Ensure tests pass and linting succeeds (`./script/lint` and `./script/tests`).
- Create a Pull Request against the `main` branch.
## Feature suggestions
If you want to suggest a new feature for LVA, please open a discussion on GitHub.
## Issue Tracker
If you want to report an issue, please create an issue on the repository.
## AI Policy
Linux Voice Assistant is an [Open Home Foundation](https://www.openhomefoundation.org/) project and follows the [OHF AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md). Please read it before submitting AI-assisted contributions. The TLDR: AI tools are allowed, but a human is responsible and must review and understand every change. Autonomous agents and unreviewed AI output will be rejected.
+1
View File
@@ -0,0 +1 @@
custom: https://www.openhomefoundation.org
+14
View File
@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: "🐛 Report a bug"
url: https://github.com/OHF-Voice/linux-voice-assistant/issues/new?template=bug_report.md
about: Report bugs in the Linux Voice Assistant.
- name: "💬 Discord community & support"
url: https://discord.com/channels/330944238910963714/1464387232201703547
about: Chat with the community and get help from other users.
- name: "❓ Questions & how-to (Q&A)"
url: https://github.com/OHF-Voice/linux-voice-assistant/discussions
about: Ask how-to questions and search previous answers.
- name: "💡 Feature requests & ideas"
url: https://github.com/OHF-Voice/linux-voice-assistant/discussions
about: Suggest and vote on new features and enhancements.
+29
View File
@@ -0,0 +1,29 @@
# What does this implement/fix?
<!-- Quick description and explanation of changes. -->
**Related issue (if applicable):**
- Fixes #<issue number>
## Types of changes
<!--
Tick exactly one box. CI derives the label from the ticked box.
-->
- [ ] Bugfix (non-breaking change which fixes an issue) — `bugfix`
- [ ] New feature (non-breaking change which adds functionality) — `new-feature`
- [ ] Enhancement to an existing feature — `enhancement`
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change`
- [ ] Refactor (no behaviour change) — `refactor`
- [ ] Documentation only — `documentation`
- [ ] Maintenance / chore — `maintenance`
- [ ] CI / workflow change — `ci`
- [ ] Dependencies bump — `dependencies`
## Checklist
- [ ] The code change is tested and works locally.
- [ ] `./script/lint` passes.
- [ ] `./script/tests` passes, and tests have been added/updated under `tests/` where applicable.
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: daily
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: weekly
groups:
dependencies:
patterns:
- "*"
+6 -6
View File
@@ -20,23 +20,23 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set lowercase registry and repository
run: |
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create version files
run: |
@@ -46,7 +46,7 @@ jobs:
- name: Build and push Docker image
id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
@@ -54,7 +54,7 @@ jobs:
tags: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:nightly
labels: "nightly"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1.4.4
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
+7 -6
View File
@@ -21,9 +21,10 @@ jobs:
steps:
- name: Checkout PR code
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
- name: Set variables
run: |
@@ -32,16 +33,16 @@ jobs:
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create version files
run: |
@@ -51,7 +52,7 @@ jobs:
- name: Build and push Docker image
id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
@@ -59,7 +60,7 @@ jobs:
tags: "${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr:${{ env.PR_NUMBER }}"
labels: "${{ env.PR_NUMBER }}"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1.4.4
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
subject-digest: ${{ steps.push.outputs.digest }}
+7 -7
View File
@@ -21,21 +21,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}
# E.g. tag v1.2.0 produces 3 tags: '1.2.0', '1.2', and 'latest'
@@ -54,7 +54,7 @@ jobs:
- name: Build and push Docker image
id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
@@ -62,7 +62,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v7
with:
ref: main
- uses: rhysd/changelog-from-release/action@v3
+10 -10
View File
@@ -14,9 +14,9 @@ jobs:
lint_black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
@@ -29,9 +29,9 @@ jobs:
lint_flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
@@ -44,9 +44,9 @@ jobs:
lint_isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
@@ -59,9 +59,9 @@ jobs:
lint_mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
@@ -75,9 +75,9 @@ jobs:
lint_pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Install dependencies
+87
View File
@@ -0,0 +1,87 @@
name: PR Labels
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- labeled
- unlabeled
- reopened
- ready_for_review
branches:
- main
permissions:
pull-requests: write
jobs:
apply_label:
name: Apply
runs-on: ubuntu-latest
if: github.event.pull_request.user.type != 'Bot'
steps:
- name: 🏷 Apply label from PR description checkbox
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
with:
script: |
const known = [
'breaking-change', 'bugfix', 'refactor',
'new-feature', 'enhancement', 'maintenance',
'ci', 'dependencies', 'documentation',
];
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const current = (context.payload.pull_request.labels || [])
.map(l => l.name);
const alreadyLabelled = current.filter(n => known.includes(n));
if (alreadyLabelled.length === 1) {
core.info(
`Skipping: PR already labelled '${alreadyLabelled[0]}'.`
);
return;
}
const body = context.payload.pull_request.body || '';
const re = /^\s*-\s*\[x\][^`\n]*`([^`]+)`/gim;
const ticked = [];
for (const m of body.matchAll(re)) {
const name = m[1];
if (known.includes(name) && !ticked.includes(name)) {
ticked.push(name);
}
}
if (ticked.length === 0) {
core.setFailed(
'No "Types of changes" checkbox is ticked in the PR ' +
'description. Edit the description and tick exactly ' +
'one box so this PR can be release-noted.'
);
return;
}
if (ticked.length > 1) {
core.setFailed(
`Multiple "Types of changes" checkboxes are ticked ` +
`(${ticked.join(', ')}). Tick exactly one.`
);
return;
}
const desired = ticked[0];
for (const name of current) {
if (known.includes(name) && name !== desired) {
await github.rest.issues.removeLabel({
owner, repo, issue_number, name,
}).catch(() => {});
}
}
if (!current.includes(desired)) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [desired],
});
}
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Python 3.12
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.12"
+1 -1
View File
@@ -23,4 +23,4 @@ venv/
.env
dev.env
version.txt
version_githash.txt
version_githash.txt
+8
View File
@@ -0,0 +1,8 @@
node_modules
package.json
package-lock.json
pnpm-lock.yaml
bun.lock
yarn.lock
worktrees
agent-manager.json
+14
View File
@@ -0,0 +1,14 @@
# Lint Command
Runs all linting checks for the project (black, isort, flake8, mypy, pylint).
## Usage
```
/lint # Run all linting checks
/lint --auto # Auto-fix formatting issues (black, isort)
```
## Command
./script/lint
+14
View File
@@ -0,0 +1,14 @@
# Run Command
Runs the Linux Voice Assistant application.
## Usage
```
/run # Run the application
/run --help # Show CLI help
```
## Command
./script/run
+14
View File
@@ -0,0 +1,14 @@
# Setup Command
Installs project dependencies in a virtual environment.
## Usage
```
/setup # Install dependencies
/setup --dev # Install dev dependencies (linting, testing tools)
```
## Command
./script/setup
+15
View File
@@ -0,0 +1,15 @@
# Test Command
Runs the pytest unit tests for the project.
## Usage
```
/tests # Run all tests
/tests <path> # Run tests in specific path
/tests -k <pattern> # Run tests matching pattern
```
## Command
./script/tests
+70
View File
@@ -0,0 +1,70 @@
# PR Review Standards
## Review Philosophy
- Only comment when confident an issue exists
- Be concise: one sentence per comment when possible
- Focus on actionable feedback, not observations
- When reviewing text, only comment on clarity issues if the text is genuinely confusing or could lead to errors
## What to Analyze
Review all code changes for:
- Code quality and style consistency with the existing codebase
- Potential bugs or issues
- Performance implications
- Blocking IO in async code
- Security concerns
- Test coverage
- Documentation updates if needed
## Project Standards
Respect the project standards as outlined in AGENTS.md. Any deviations must be raised as `[PROBLEM]`.
Key standards for LVA:
- Python 3.11, 3.12, 3.13 supported
- Black formatting with 200 char line length
- isort import sorting with black profile
- mypy type checking with strict settings
- pylint code quality checks (many checks disabled in pyproject.toml)
## Architecture-Specific Checks
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent, verify no assumptions about specific hardware
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`, check for proper error handling
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms need careful review for audio quality
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling must be robust
## CI Context
Lint runs on Python 3.13, tests run on Python 3.12 with libmpv-dev.
### What CI Checks
- **Lint**: `.github/workflows/lint.yml` runs all linting checks
- **Tests**: `.github/workflows/tests.yml` runs pytest unit tests
## Skip These (Low Value)
Do not comment on:
- Style/formatting (pre-commit handles this)
- Test failures (CI catches this)
- Missing dependencies (CI catches this)
- Minor naming suggestions
- Suggestions to add comments
- Logging suggestions unless security-related
## Issue Categories
Categorize every issue as:
- `[CRITICAL]` — must fix before merging (bugs, security issues, broken functionality)
- `[PROBLEM]` — should fix (code quality, bad patterns, missing tests)
- `[SUGGESTION]` — optional improvement (style, minor refactors, nice-to-haves)
## PR Title
The PR title must be a functional description of the change. It must NOT contain conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `chore:`, etc. Labels categorize PRs, not the title. Flag as `[PROBLEM]` if the title uses such prefixes.
+44
View File
@@ -0,0 +1,44 @@
name: review-pr
description: Use when asked to review a GitHub pull request, PR link is shared, or user says /review-pr
# Review GitHub Pull Request
Review the GitHub pull request: $ARGUMENTS.
## Steps
1. Run `gh pr view` to get PR details and description.
2. Run `gh pr diff` to see all changes.
3. Compare the local commit hash with the remote PR head commit.
- Make sure to do your work in a git worktree to avoid affecting the checked out branch.
- If they don't match, run `gh pr checkout` to check out the PR locally.
- **HARD STOP**: If `gh pr checkout` fails for ANY reason, STOP immediately. Do NOT attempt workarounds. Alert about the failure and wait for instructions.
- After checkout, verify the local commit hash matches the remote one. If they still don't match, STOP.
4. Run `gh pr checks` to see CI status.
5. Apply the review standards defined in `REVIEW_STANDARDS.md` (same directory).
6. Output review feedback to the CONSOLE only. DO NOT act on GitHub (no posting comments, no approving, no requesting changes).
## Output Format
List specific comments per file/line that need attention. Do not list things that are already correct.
Each comment:
1. State the severity ([CRITICAL], [PROBLEM], [SUGGESTION])
2. State the problem (1 sentence)
3. Why it matters (1 sentence, if needed)
4. Suggested fix (snippet or specific action)
End with an overall assessment and summary:
```
Overall assessment: <approve | request changes | comment>
- [CRITICAL] <issue>
- [PROBLEM] <issue>
- [SUGGESTION] <improvement>
```
## Constraints
- DO NOT make any changes to the code.
- No need to run tests or linters, just review the code changes.
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"kilo.kilocode"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Linux Voice Assistant",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/linux_voice_assistant/__main__.py",
"console": "integratedTerminal",
"justMyCode": true,
"envFile": "${workspaceFolder}/.env"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests"],
"python.testing.pytestAutoInclude": false,
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.formatting.provider": "none",
"python.terminal.activateEnvironment": true,
"python.terminal.executeInShell": true,
"python.pythonPath": "./.venv/bin/python"
}
+62
View File
@@ -0,0 +1,62 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Setup (incl. dev dependencies)",
"type": "shell",
"command": "./script/setup --dev",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"clear": true
},
"problemMatcher": []
},
{
"label": "Run Linter",
"type": "shell",
"command": "./script/lint",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Tests",
"type": "shell",
"command": "./script/tests",
"group": "test",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Application (Docker)",
"type": "shell",
"command": "docker-compose",
"args": ["-f", "docker-compose.dev.yml", "up"],
"group": "none",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": [],
"options": {
"envFile": "${workspaceFolder}/.env"
}
}
]
}
+130
View File
@@ -0,0 +1,130 @@
# Project Context for AI Agents
## Behaviour
- NEVER automatically reply on Github (PRs or Discussions) without explicit consent from the developer.
- Always run `./script/lint` and `./script/tests` after code changes to ensure standards are met.
## Project Overview
**Linux Voice Assistant (LVA)** - A Linux-based voice satellite software for Home Assistant using the ESPHome protocol.
### Purpose
- Provides voice control and interaction for Home Assistant via the Assist integration
- Runs on x64/ARM64 Linux hardware with PulseAudio/PipeWire audio support
- Performs local wake word detection (OpenWakeWord/MicroWakeWord) and on-device processing
### Architecture
- **Protocol**: ESPHome API via `aioesphomeapi` for Home Assistant integration
- **Wake Words**: Supports both OpenWakeWord and MicroWakeWord engines
- **Audio**: Uses `soundcard` library for input, `python-mpv` for output
- **Peripherals**: WebSocket API for LEDs, buttons, HAT boards
- **Discovery**: mDNS/ZeroConf for auto-discovery by Home Assistant
### Key Components
- `linux_voice_assistant/__main__.py` - Main entry point with CLI argument parsing
- `linux_voice_assistant/satellite.py` - VoiceSatelliteProtocol implementation (ESPHome API)
- `linux_voice_assistant/wake_word.py` - Wake word model loading and detection
- `linux_voice_assistant/webrtc.py` - WebRTC noise gain/NS processing
- `linux_voice_assistant/mpv_player.py` - MPV-based media player
- `linux_voice_assistant/entity.py` - Entity definitions (MediaPlayer, MuteSwitch, etc.)
- `linux_voice_assistant/api_server.py` - ESPHome API server
- `linux_voice_assistant/peripheral_api.py` - Peripheral WebSocket API server
- `linux_voice_assistant/models.py` - Preferences and ServerState dataclasses
## Development Commands
### Setup
```bash
./script/setup --dev # Install dev dependencies in virtual environment
```
### Linting
```bash
./script/lint # Run all linting checks
./script/lint_black # Black formatting check (add --auto to fix)
./script/lint_isort # isort import sorting check (add --auto to fix)
./script/lint_flake8 # flake8 style checks
./script/lint_mypy # mypy type checking
./script/lint_pylint # pylint code quality checks
```
### Testing
```bash
./script/tests # Run pytest unit tests
```
## Code Quality Standards
- **Python**: 3.11, 3.12, 3.13 supported
- **Formatting**: Black (200 char line length, py312/py312 target)
- **Import Sorting**: isort with black profile
- **Type Checking**: mypy with strict settings
- **Linting**: pylint (many checks disabled in pyproject.toml for practical reasons)
## Testing Strategy
- Unit tests in `tests/unit/` using pytest with asyncio mode
- Test helper `make_state()` in `tests/unit/conftest.py` for creating state fixtures
- Mocks used heavily for hardware dependencies (audio devices, MPV, wake words)
## CI/CD
- GitHub Actions for lint checks and unit tests
- Tests run on Python 3.12 with libmpv-dev system dependency
- Lint runs on Python 3.13
## Branching and PRs
- All PRs target `main` (primary development branch). `stable` is for production releases.
- PRs labeled `bugfix` + `backport-to-stable` are automatically backported to `stable` — use only for bugs also present in `stable`.
## Agent Commands
When making code changes, run these commands in order:
1. **Format code**: `./script/lint_black --auto` and `./script/lint_isort --auto`
2. **Run linting**: `./script/lint`
3. **Run tests**: `./script/tests`
## Code Style
### Comments
Only use comments to explain complex, multi-line blocks of code. Do not comment obvious operations. Inline comments are there to explain code parts that need explaining - keep them when editing code.
### Docstring Format
Use Sphinx-style docstrings with `:param:` syntax. For simple functions, a single-line docstring is fine. The docstring should provide clarity to the caller, not explain how it works technically.
```python
def my_function(param1: str, param2: int, param3: bool = False) -> str:
"""
Brief one-line description of the function.
:param param1: Description of what param1 is used for.
:param param2: Description of what param2 is used for.
:param param3: Description of what param3 is used for.
"""
```
Do **not** use Google-style (`Args:`) or bullet-style (`- param:`) docstrings.
### File structure
Private methods should be at the bottom of the file, public at the top.
## Verification Checklist
Before claiming completion:
- [ ] Ran `./script/lint` - all checks passed
- [ ] Ran `./script/tests` - all tests passed
- [ ] For audio-related changes: Note that hardware testing (microphone/speaker) was NOT performed
- [ ] Did NOT claim hardware behavior is verified unless actually exercised
- [ ] Changes are focused on one feature or fix per change
## Areas Requiring Extra Care
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling
+94
View File
@@ -1,3 +1,94 @@
<a id="v1.1.15"></a>
# [v1.1.15](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.15) - 2026-08-02
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Hotfix
* Fix variable handling in docker entrypoint script
### Exciting New Features 🎉
* Add --music-output-device to route music separately from voice by [@matthewdva](https://github.com/matthewdva) in [#350](https://github.com/OHF-Voice/linux-voice-assistant/pull/350)
### Other Changes
* Update Jabra Speak DOCS.md by [@mrbubble62](https://github.com/mrbubble62) in [#387](https://github.com/OHF-Voice/linux-voice-assistant/pull/387)
* Bump docker/login-action from 4.5.1 to 4.6.0 by [@dependabot](https://github.com/dependabot)[bot] in [#386](https://github.com/OHF-Voice/linux-voice-assistant/pull/386)
* Documentation Improvements by [@omaramin-2000](https://github.com/omaramin-2000) in [#385](https://github.com/OHF-Voice/linux-voice-assistant/pull/385)
## New Contributors
* [@mrbubble62](https://github.com/mrbubble62) made their first contribution in [#387](https://github.com/OHF-Voice/linux-voice-assistant/pull/387)
* [@matthewdva](https://github.com/matthewdva) made their first contribution in [#350](https://github.com/OHF-Voice/linux-voice-assistant/pull/350)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.14...v1.1.15
[Changes][v1.1.15]
<a id="v1.1.14"></a>
# [v1.1.14](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.14) - 2026-07-28
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Exciting New Features 🎉
* Colored Debug Logging and move Voice Event to Info level in logger by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#271](https://github.com/OHF-Voice/linux-voice-assistant/pull/271)
### Other Changes
* build(dev): enhance local development environment and setup workflow by [@florian-asche](https://github.com/florian-asche) in [#358](https://github.com/OHF-Voice/linux-voice-assistant/pull/358)
* docs(github): establish repository governance and automation workflows by [@florian-asche](https://github.com/florian-asche) in [#359](https://github.com/OHF-Voice/linux-voice-assistant/pull/359)
* Bump docker/build-push-action from 5.3.0 to 7.3.0 by [@dependabot](https://github.com/dependabot)[bot] in [#364](https://github.com/OHF-Voice/linux-voice-assistant/pull/364)
* Bump actions/attest-build-provenance from 1 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#363](https://github.com/OHF-Voice/linux-voice-assistant/pull/363)
* Bump docker/metadata-action from 5.5.1 to 6.2.0 by [@dependabot](https://github.com/dependabot)[bot] in [#362](https://github.com/OHF-Voice/linux-voice-assistant/pull/362)
* Bump docker/setup-qemu-action from 3 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#361](https://github.com/OHF-Voice/linux-voice-assistant/pull/361)
* Bump docker/login-action from 3.1.0 to 4.4.0 by [@dependabot](https://github.com/dependabot)[bot] in [#360](https://github.com/OHF-Voice/linux-voice-assistant/pull/360)
* chore(ci): update github actions and docker dependencies by [@florian-asche](https://github.com/florian-asche) in [#368](https://github.com/OHF-Voice/linux-voice-assistant/pull/368)
* fix Respeaker USB Mic Array v2 LEDs by [@litinoveweedle](https://github.com/litinoveweedle) in [#357](https://github.com/OHF-Voice/linux-voice-assistant/pull/357)
* Update install.md - Fixed log path by [@maximilianovermeyer](https://github.com/maximilianovermeyer) in [#372](https://github.com/OHF-Voice/linux-voice-assistant/pull/372)
* Bump docker/setup-buildx-action from 3 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#371](https://github.com/OHF-Voice/linux-voice-assistant/pull/371)
* fix race condition between LVA registration to the HA and peripheral … by [@litinoveweedle](https://github.com/litinoveweedle) in [#373](https://github.com/OHF-Voice/linux-voice-assistant/pull/373)
* Bump actions/checkout from 4 to 7 by [@dependabot](https://github.com/dependabot)[bot] in [#370](https://github.com/OHF-Voice/linux-voice-assistant/pull/370)
* feat(ai): integrate Kilo agent framework and coding instructions by [@florian-asche](https://github.com/florian-asche) in [#366](https://github.com/OHF-Voice/linux-voice-assistant/pull/366)
* Bump docker/login-action from 4.4.0 to 4.5.0 by [@dependabot](https://github.com/dependabot)[bot] in [#375](https://github.com/OHF-Voice/linux-voice-assistant/pull/375)
* docs: add realtime scheduling tuning for constrained hardware Closes [#164](https://github.com/OHF-Voice/linux-voice-assistant/issues/164) by [@SpiliosDimakopoulos](https://github.com/SpiliosDimakopoulos) in [#376](https://github.com/OHF-Voice/linux-voice-assistant/pull/376)
* Bump docker/login-action from 4.5.0 to 4.5.1 by [@dependabot](https://github.com/dependabot)[bot] in [#377](https://github.com/OHF-Voice/linux-voice-assistant/pull/377)
## New Contributors
* [@dependabot](https://github.com/dependabot)[bot] made their first contribution in [#364](https://github.com/OHF-Voice/linux-voice-assistant/pull/364)
* [@litinoveweedle](https://github.com/litinoveweedle) made their first contribution in [#357](https://github.com/OHF-Voice/linux-voice-assistant/pull/357)
* [@maximilianovermeyer](https://github.com/maximilianovermeyer) made their first contribution in [#372](https://github.com/OHF-Voice/linux-voice-assistant/pull/372)
* [@SpiliosDimakopoulos](https://github.com/SpiliosDimakopoulos) made their first contribution in [#376](https://github.com/OHF-Voice/linux-voice-assistant/pull/376)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.13...v1.1.14
[Changes][v1.1.14]
<a id="v1.1.13"></a>
# [v1.1.13](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.13) - 2026-07-18
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Exciting New Features 🎉
* (Temporary workaround) Switch between MWW/OWW models from the UI by [@omaramin-2000](https://github.com/omaramin-2000) in [#348](https://github.com/OHF-Voice/linux-voice-assistant/pull/348)
* Leds and buttons events by [@omaramin-2000](https://github.com/omaramin-2000) in [#266](https://github.com/OHF-Voice/linux-voice-assistant/pull/266)
* Add Tests For LVA by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#312](https://github.com/OHF-Voice/linux-voice-assistant/pull/312)
* feat: add --listen-during-wake-sound argument by [@wangwillian0](https://github.com/wangwillian0) in [#273](https://github.com/OHF-Voice/linux-voice-assistant/pull/273)
### Other Changes
* perf(player): limit cache to 32 MiB by [@florian-asche](https://github.com/florian-asche) in [#309](https://github.com/OHF-Voice/linux-voice-assistant/pull/309)
* Allow using python versions above 3.11 by [@omaramin-2000](https://github.com/omaramin-2000) in [#345](https://github.com/OHF-Voice/linux-voice-assistant/pull/345)
* Modify USB control transfer settings in respeaker_usb_mic_array.py by [@omaramin-2000](https://github.com/omaramin-2000) in [#349](https://github.com/OHF-Voice/linux-voice-assistant/pull/349)
* Broadcast entity state to all API clients; don't desync on a second connection by [@JSRossie](https://github.com/JSRossie) in [#329](https://github.com/OHF-Voice/linux-voice-assistant/pull/329)
* Fix lint_mypy by [@omaramin-2000](https://github.com/omaramin-2000) in [#352](https://github.com/OHF-Voice/linux-voice-assistant/pull/352)
## New Contributors
* [@JSRossie](https://github.com/JSRossie) made their first contribution in [#329](https://github.com/OHF-Voice/linux-voice-assistant/pull/329)
* [@wangwillian0](https://github.com/wangwillian0) made their first contribution in [#273](https://github.com/OHF-Voice/linux-voice-assistant/pull/273)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.12...v1.1.13
[Changes][v1.1.13]
<a id="v1.1.12"></a>
# [v1.1.12](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.12) - 2026-06-22
@@ -299,6 +390,9 @@ Initial release
[Changes][v1.0.0]
[v1.1.15]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.14...v1.1.15
[v1.1.14]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.13...v1.1.14
[v1.1.13]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.12...v1.1.13
[v1.1.12]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.11...v1.1.12
[v1.1.11]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
[v1.1.10]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
+36 -4
View File
@@ -19,7 +19,7 @@ Because it runs on a full Linux system and offers access significantly more loca
- Supports multiple wake words and languages
- Supports multiple architectures (linux/amd64 and linux/aarch64)
- Automated builds with artifact attestation for security
- Supports announcments, start/continue conversation, and timers
- Supports [announcments](https://www.home-assistant.io/actions/assist_satellite.announce/), [start](https://www.home-assistant.io/actions/assist_satellite.start_conversation/)/continue conversation, and timers
- Tested and works with Python 3.11 and Python 3.13.
- Prebuild docker image available on [GitHub Container Registry](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
- Prebuild [Raspberry Pi image](https://github.com/florian-asche/PiCompose)
@@ -53,7 +53,7 @@ Alternatively if on a lower budget then suggest could use other microphone-array
For HA OS, we provide a finished [Assist Satellite](https://github.com/OHF-Voice/apps/tree/main/assist_satellite) app (formerly add-on), which uses the Linux Voice Assistant runtime to turn your HA host into a voice satellite.
> [!NOTE]
> For now you first have to add the [OHF-Voice apps](https://github.com/OHF-Voice/apps) repo manually to the App Store repositroy inside Home Assistant before you can install it.
> For now you first have to add the [OHF-Voice apps](https://github.com/OHF-Voice/apps) repo manually to the App Store repository inside Home Assistant before you can install it.
Later you will be able to install it directly from the official add-on repository (but it is not yet published publicly there):
@@ -74,7 +74,7 @@ For all other users, we have different installation methods available (Docker, s
💡 **Note:** There is an [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
```sh
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--music-output-device MUSIC_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--listen-during-wake-sound] [--debug]
```
@@ -85,6 +85,7 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--audio-input-device` | Soundcard name for input device | Autodetected |
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
| `--audio-output-device` | mpv name for output device | Autodetected |
| `--music-output-device` | mpv name for the music/media output device | `--audio-output-device` |
| `--mic-volume` | Control microphone volume | 100 |
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
@@ -113,9 +114,10 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--peripheral-volume-step` | Volume change per button press, 0.0–1.0 | %(default)s |
| `--disable-peripheral-api` | Disable the peripheral WebSocket API entirely | False |
| `--debug` | Print DEBUG messages to console | False |
| `--colored-debug` | Print colored DEBUG messages to console | False |
| `--output-only` | Enable output only mode | False |
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
💡 **Note:** There are detailed explanations on the controlled entities from device page in the [configuration](docs/configuration.md) file.
## Build Information
@@ -131,6 +133,36 @@ The documentation for the build process can be found in the [GitHub Actions Work
## Development
### System Requirements
**System packages (Linux):**
- libmpv-dev (`sudo apt install libmpv-dev`)
- PulseAudio/PipeWire (`sudo apt install pulseaudio pipewire`)
- ALSA utils (`sudo apt install alsa-utils`)
**Python:**
- Python 3.11+ must be installed on your system (`python3 --version` should show 3.11+)
- On Ubuntu/Debian: `sudo apt install python3.11 python3.11-venv python3.11-dev`
### VS Code Setup
VS Code development uses a local Python virtual environment (`.venv/`):
1. Open workspace in VS Code
2. Accept Workspace Trust (bottom-left status bar)
3. Install recommended extensions when prompted:
- `ms-python.python` - Python language support
- `ms-python.vscode-pylance` - Python language server
- `kilo.kilocode` - Kilo AI assistant
4. **Terminal** → **New Terminal** (or `` Ctrl+` ``) - Opens integrated terminal (uses .venv when available)
5. Run `./script/setup --dev` to create `.venv` and install dev dependencies
6. VS Code automatically detects `.venv/bin/python` as the Python interpreter
**Available VS Code Tasks:**
- `Ctrl+Shift+B` - Run Setup or Linter
- `Ctrl+Shift+T` - Run Tests
- `Ctrl+Shift+P` → "Tasks: Run Task" - Show all tasks (Setup, Linter, Tests, Run App)
### Code Quality Checks
The project uses the following tools to ensure code quality:
+30 -20
View File
@@ -9,6 +9,10 @@ if [ "$ENABLE_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--debug" )
fi
if [ "$ENABLE_COLORED_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--colored-debug" )
fi
if [ -n "${CLIENT_NAME}" ]; then
EXTRA_ARGS+=( "--name" "$CLIENT_NAME" )
fi
@@ -40,6 +44,10 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
fi
if [ -n "${MUSIC_OUTPUT_DEVICE}" ]; then
EXTRA_ARGS+=( "--music-output-device" "$MUSIC_OUTPUT_DEVICE" )
fi
if [ -n "${MIC_VOLUME}" ]; then
EXTRA_ARGS+=( "--mic-volume" "$MIC_VOLUME" )
fi
@@ -124,8 +132,8 @@ if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
fi
if [ -n "${DISABLE_PERIPHERAL_API}" ]; then
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
if [ "$DISABLE_PERIPHERAL_API" = "1" ]; then
EXTRA_ARGS+=( "--disable-peripheral-api" )
fi
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
@@ -145,26 +153,28 @@ fi
### Wait for PulseAudio
# Wait for PulseAudio to be available before starting the application
CP_MAX_RETRIES=30
CP_RETRY_DELAY=1
### while maybe besser?
echo "Checking PulseAudio service status..."
for i in $(seq 1 $CP_MAX_RETRIES); do
# Check if PulseAudio is running
if pactl info >/dev/null 2>&1; then
echo "✅ PulseAudio is running"
break
fi
# Skip wait if SKIP_PULSE_AUDIO_WAIT is set (for local development)
if [ "$SKIP_PULSE_AUDIO_WAIT" != "1" ]; then
CP_MAX_RETRIES=30
CP_RETRY_DELAY=1
### while maybe besser?
echo "Checking PulseAudio service status..."
for i in $(seq 1 $CP_MAX_RETRIES); do
# Check if PulseAudio is running
if pactl info >/dev/null 2>&1; then
echo "✅ PulseAudio is running"
break
fi
if [ $i -eq $CP_MAX_RETRIES ]; then
echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds"
exit 2
fi
if [ $i -eq $CP_MAX_RETRIES ]; then
echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds"
exit 2
fi
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
sleep $CP_RETRY_DELAY
done
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
sleep $CP_RETRY_DELAY
done
fi
### Start application
-73
View File
@@ -1,73 +0,0 @@
# Audio Options: Gain, Noise Suppression, and Wake Word Sensitivity
## What do Gain and Noise Suppression do
### Gain
When you change gain, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
- Microphones that are too quiet by default
- Environments where you move around relative to the mic
- Keeping wake word detection consistent regardless of speaking volume
### Noise Suppression
When you change noise suppression, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
- Noisy environments
- Improving STT accuracy by sending cleaner audio to Home Assistant
- Reducing false wake word triggers from background noise
## Using Gain and Noise Suppression with LVA
LVA implements gain and Noise Suppression in two ways:
- [CLI Argument/ENV file](#cli-argumentenv-file)
- [Home Assistant Entity](#home-assistant-entity)
### CLI Argument/ENV file
#### CLI Argument
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
- `--mic-auto-gain`(ranges from 0-31)
- `--mic-noise-suppression`(ranges from 0-4)
#### ENV file
You can add/edit these variables in the .env file to set gain and noise suppression:
- `MIC_AUTO_GAIN="0"`
- `MIC_NOISE_SUPPRESSION="0"`
### Home Assistant Entity
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
## Wake Word Sensitivity
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
| Entity | Description | Default |
|--------|-------------|---------|
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
Values range from `0.0` to `1.0`:
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
**Tuning guide:**
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
+130
View File
@@ -0,0 +1,130 @@
# Dashboard / Home Assistant Entities
Once your satellite is connected, Home Assistant exposes a set of controls on its device page (via the ESPHome integration) that let you change LVA's behavior at runtime, without restarting the container or editing config files. This document walks through each of those entities.
> **Go to [Settings > Devices & services](https://my.home-assistant.io/redirect/integrations/) and select the ESPHome integration.**
![Device page overview](./images/overview.png)
## Wake Word
The device page exposes one or two wake word selects (**Wake Word 1**, **Wake Word 2**), depending on whether your assistant is configured for dual wake words. Changing the selection here takes effect immediately and is persisted to `preferences.json`, so it survives restarts.
![Wake word select](./images/wake_word.png)
💡 **Note:** Models suffixed with **(OWW)** in the dropdown are [openWakeWord](https://github.com/dscripka/openWakeWord) models; everything else is a [microWakeWord](https://github.com/kahrendt/microWakeWord) model. Both engines can be mixed and matched, but keep in mind:
- **microWakeWord** models are lighter-weight and use engine that can run on lower-end devices and are located in the `wakewords` directory.
- **openWakeWord** models use higher engine with more accuracy and are located in the `wakewords/openWakeWord` directory.
See the [Wake Word section](install_application.md#wake-word) of the installation guide for the full list of bundled models and how to add custom ones.
## Mute Switch
The **Mute** switch disables wake word detection and stops the assistant from responding to any wake word or button-triggered listening, without stopping the LVA process itself. While muted, a configurable sound plays to confirm the mute/unmute transition (`--mute-sound` / `--unmute-sound`).
![Mute Switch](./images/mute_switch.png)
## Thinking Sound
The **Thinking Sound** switch toggles whether a short audible chime plays while the assistant is processing your request (between the end of listening and the start of the response). This is the same setting as the `--enable-thinking-sound` CLI flag / `ENABLE_THINKING_SOUND` environment variable, but can be flipped on or off at runtime from this switch. The change is saved to `preferences.json`.
![Thinking Sound](./images/thinking_sound.png)
## Media Player
The **Media Player** entity controls playback (play/pause/stop) and volume for both:
- Music/media played through the satellite, and
- The assist pipeline itself (wake word chime, TTS responses, thinking/processing sound, etc.)
Both share a single volume control — there is no separate volume for the pipeline versus music playback. Adjusting the volume slider on this entity changes both at once, and the value is persisted to `preferences.json` so it's restored on the next startup.
![Media player entity](images/media_player.png)
## Microphone Settings
### Volume
Controls the microphone volume to adjust the area distance from which the microphone can pick up your voice. The volume slider is persisted to `preferences.json` so it's restored on the next startup.
![Mic Volume](./images/mic_volume.png)
### What do Gain and Noise Suppression do
#### Gain
When you change gain, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
- Microphones that are too quiet by default
- Environments where you move around relative to the mic
- Keeping wake word detection consistent regardless of speaking volume
#### Noise Suppression
When you change noise suppression, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
- Noisy environments
- Improving STT accuracy by sending cleaner audio to Home Assistant
- Reducing false wake word triggers from background noise
### Using Gain and Noise Suppression with LVA
LVA implements gain and Noise Suppression in two ways:
- [CLI Argument/ENV file](#cli-argumentenv-file)
- [Home Assistant Entity](#home-assistant-entity)
### CLI Argument/ENV file
#### CLI Argument
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
- `--mic-auto-gain`(ranges from 0-31)
- `--mic-noise-suppression`(ranges from 0-4)
#### ENV file
You can add/edit these variables in the .env file to set gain and noise suppression:
- `MIC_AUTO_GAIN="0"`
- `MIC_NOISE_SUPPRESSION="0"`
### Home Assistant Entity
LVA exposes two entities to change these gain and noise suppression from which you can edit them at runtime.
- ![Mic Gain](./images/mic_gain.png)
- ![Mic Noise Suppresion](./images/mic_nosie_suppression.png)
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
### Wake Word Sensitivity
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
| Entity | Description | Default |
|--------|-------------|---------|
| **Wake Word 1 Sensitivity** ![WW 1 Sensitivity](./images/wake_word_sensitivity.png) | Probability cutoff for the primary wake word | From model manifest |
| **Wake Word 2 Sensitivity** ![WW 2 Sensitivity](./images/wake_word_sensitivity_2.png) | Probability cutoff for the secondary wake word (if active) | From model manifest |
| **Stop Word Sensitivity** ![alt text](./images/stop_word_sensitivity.png) | Probability cutoff for the stop word | From model manifest |
Values range from `0.0` to `1.0`:
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
**Tuning guide:**
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
## Support
If a dashboard control doesn't behave as expected, enable `--debug` (or `ENABLE_DEBUG=1`) and check the logs — every change made from the Home Assistant device page is logged when it's received and persisted.
+3 -6
View File
@@ -16,14 +16,13 @@ load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
### PipeWire
```sh
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic } }'
pw-cli load-module libpipewire-module-echo-cancel '{ source.props={ node.name=aec_mic } }'
```
To make permanent, add the following to the `context.modules` section of `/etc/pipewire/pipewire.conf`:
```
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = { node.name = aec_mic }
}
}
@@ -34,7 +33,6 @@ Or if you are using a standard PipeWire installation without a custom `pipewire.
context.modules = [
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = { node.name = aec_mic }
}
}
@@ -57,14 +55,13 @@ pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc sourc
### PipeWire
```sh
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic node.description="Echo Cancelled Mic" } sink.props={ node.name=aec_sink } capture.props={ node.name=<your-mic-node-name> } playback.props={ node.name=<your-speaker-node-name> } }'
pw-cli load-module libpipewire-module-echo-cancel '{ source.props={ node.name=aec_mic node.description="Echo Cancelled Mic" } sink.props={ node.name=aec_sink } capture.props={ node.name=<your-mic-node-name> } playback.props={ node.name=<your-speaker-node-name> } }'
```
To make permanent, add to `context.modules` in `/etc/pipewire/pipewire.conf` or a file in `pipewire.conf.d/`:
```
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = {
node.name = aec_mic
node.description = "Echo Cancelled Mic"
@@ -80,4 +77,4 @@ To make permanent, add to `context.modules` in `/etc/pipewire/pipewire.conf` or
}
}
}
```
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+1 -1
View File
@@ -77,7 +77,7 @@ The easyiest way to get started is to use the prebuilt image. This image comes w
- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose)
- Change configuration in /compose/ directory if you need to.
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/logs/picompose.log
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/log/picompose.log
- More information within the PiCompose project.
### Option B) Install on your own:
+15 -6
View File
@@ -176,6 +176,7 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
# Environment=PORT="6053"
# Environment=AUDIO_INPUT_DEVICE="default"
# Environment=AUDIO_OUTPUT_DEVICE="default"
# Environment=MUSIC_OUTPUT_DEVICE="default"
# Environment=MIC_VOLUME="1.0"
# Environment=MIC_AUTO_GAIN="0"
# Environment=MIC_NOISE_SUPPRESSION="0"
@@ -260,16 +261,20 @@ The following variables can be configured in the `.env` or in the service file:
| `LVA_PULSE_SERVER` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
| `ENABLE_COLORED_DEBUG` | (optional) | Set to "1" to enable colored debug mode |
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
| `NETWORK_INTERFACE` | Autodetected | network card for server |
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
| `PORT` | `6053` | API server port |
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
| `MIC_VOLUME` | Control microphone volume | 100 |
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
| `AUDIO_INPUT_CHANNELS` | Number of audio input channels | 2 |
| `MUSIC_OUTPUT_DEVICE` | `AUDIO_OUTPUT_DEVICE` | Music/media output device name (defaults to the voice output device) |
| `MIC_VOLUME` | 100 | Control microphone volume |
| `MIC_AUTO_GAIN` | 0 | Add WebRTC Gain to Mic |
| `MIC_NOISE_SUPPRESSION` | 0 | Add WebRTC Noise Suppression to Mic |
| `AUDIO_INPUT_CHANNELS` | 2 | Number of audio input channels |
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
@@ -283,7 +288,7 @@ The following variables can be configured in the `.env` or in the service file:
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
| `LISTEN_DURING_WAKE_SOUND` | false | Set to "1" to start listening immediately after wake word detection, without waiting for the wake sound to finish |
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on |
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for mute off |
| `PERIPHERAL_HOST` | 0.0.0.0 | Host for the peripheral WebSocket API |
| `PERIPHERAL_PORT` | 6055 | Port for the peripheral WebSocket API |
| `PERIPHERAL_VOLUME_STEP` | %(default)s | Volume change per button press |
@@ -311,6 +316,8 @@ If you want to use your own sounds, you can add them to the `sounds/custom` aka
### Wake Word:
💡 **Note:** The active wake word(s) can also be changed at runtime from the Home Assistant device page, without editing `.env` or restarting LVA — see [Wake Word](configuration.md#wake-word) in the dashboard documentation.
#### Available Wake Word Models:
The following wake word models are available:
@@ -323,7 +330,6 @@ The following wake word models are available:
- `hey_morgan` - Morgan wake word
- `hey_luna` - Luna wake word
- `hey_home_assistant` - Home Assistant wake word
- `stop` - Stop wake word
- `okay_computer` - Okay Computer wake word
- `choo_choo_homie` - Choo Choo Homie wake word
@@ -334,6 +340,9 @@ The following wake word models are available:
- `hey_mycroft` - Mycroft wake word
- `hey_rhasspy` - Rhasspy wake word
### Stop Word:
The `stop` wake word is used to stop the current process (response, announcement, or timer). It is available in the `wakewords` directory (using the microWakeWord engine) and can be referenced in the `.env` file with `STOP_MODEL=stop`.
### Custom Wake Word:
+20
View File
@@ -54,11 +54,31 @@ sudo mkdir -p /etc/pipewire/pipewire.conf.d
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
```
💡 **Note:** On constrained hardware like a Raspberry Pi 3B, audio
processing can be interrupted by other system activity, causing
audible glitches or dropped wake-word detections. Loading the
`libpipewire-module-rt` module raises PipeWire's scheduling priority
(`rt.prio`) above normal processes, which reduces these interruptions.
The `flags = [ ifexists nofail ]` ensures this configuration is
silently skipped rather than causing a startup failure on systems
where realtime scheduling isn't available (e.g. some containers or
restricted kernels).
Add the following content:
```
context.properties = {
default.clock.rate = 16000
}
context.modules = [
{ name = libpipewire-module-rt
args = {
nice.level = -11
rt.prio = 88
}
flags = [ ifexists nofail ]
}
]
```
#### Applying the Changes
+5 -2
View File
@@ -65,6 +65,7 @@ Immediately after connecting, LVA sends a snapshot so your client can synchronis
"muted": false,
"volume": 0.8,
"ha_connected": true,
"button_controls_disabled": false,
"last_stt_text": "set a timer for five minutes",
"last_tts_text": "Sure, I've set a timer for five minutes."
}
@@ -124,13 +125,14 @@ These are all the events LVA emits. Your peripheral script receives them and rea
| `disconnected` | — | The TCP connection to Home Assistant was lost. Show a "no connection" animation and keep it until you see `zeroconf` with `status: connected`. Note: if LVA itself is not running, your client will see a WebSocket connection failure instead — treat that the same way. |
| `muted` | `{"muted": bool}` | The microphone mute state changed. `true` = muted (show a muted indicator on your LEDs, e.g. red at mic positions), `false` = unmuted. Emitted on every transition in both directions, so peripherals can track mute state without inferring it from `idle`. |
| `zeroconf` | `{"status": "getting_started" \| "connected"}` | Reports LVA's connection lifecycle. `getting_started` is emitted at startup before HA connects; `connected` is emitted once the HA TCP handshake completes. Use `connected` to clear a "no connection" animation. |
| `button_controls_disabled` | `{"disabled": bool}` | Reflects the "Disable button controls" switch on the Home Assistant device page. `true` = on-board buttons should be ignored by the peripheral; `false` = normal button behaviour. Off by default. Also included directly in the `snapshot` payload for newly-connecting clients. |
### Timer events
| Event | Data | Description |
|-------|------|-------------|
| `timer_ticking` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A timer has been set and is counting down. Show a countdown animation proportional to `seconds_left / total_seconds`. |
| `timer_updated` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A running timer was adjusted. Update your countdown display. |
| `timer_updated` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A running timer was adjusted. Update your countdown display. Also sent with `seconds_left` of `0`, followed by `idle`, when a timer is cancelled: treat zero as the timer being gone and clear it. A timer expiring normally arrives as `timer_ringing`. |
| `timer_ringing` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | The timer has expired and the alarm sound is playing. Show a repeating alert animation. Send `stop_timer_ringing` to dismiss. |
### Media and volume events
@@ -165,6 +167,7 @@ For HA to see your entity, your peripheral must register before HA enumerates th
|---------|------|-------------|
| `register_light` | `{"name": str, "object_id": str, "effects": [str], "supports_rgb": bool, "supports_brightness": bool}` | Register a Light entity for an LED strip, ring, or single LED. HA exposes it as `light.<satellite>_<object_id>` with on/off, brightness, RGB, and a selectable effect from the declared list. Subsequent HA changes are delivered as `light_command` events. Send once after connecting; repeat registrations for the same `object_id` are idempotent (no-op). Example: `{"command": "register_light", "data": {"name": "LEDs", "object_id": "leds", "effects": ["Voice Assistant"], "supports_rgb": true, "supports_brightness": true}}` |
| `register_button` | `{"name": Button Press, "button_press_event": str}` | Register a Button entity for a physical button on your peripheral. When the user presses the button in HA, LVA emits a `button_press` event to all connected peripherals. Send once after connecting; repeat registrations for the same `object_id` are idempotent (no-op). Example: `{"command": "register_button"}` |
| `register_button_lock` | `{"name": Button Lock, "button_lock_event": str}` | Register a Button Lock entity for a "Disable button controls" switch on the HA device page. When the user toggles it, LVA emits a `button_lock_changed` event to all connected peripherals. Send once after connecting; repeat registrations for the same `object_id` are idempotent (no-op). Example: `{"command": "register_button_lock"}` |
### Voice pipeline
@@ -322,7 +325,7 @@ asyncio.run(main())
The repository ships with ready-to-use peripheral controllers for popular hardware boards. Each example is a standalone Docker container that connects to LVA over this API.
- [Peripheral Web Console](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Peripheral%20web%20console/DOCS.md) — browser-based real-time dashboard showing all events and a command palette; useful for debugging your setup or testing automations without any hardware
- [Satellite1 HAT Board](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Satellite1%20HAT%20Board/DOCS.md) — 12-LED SK6812 ring + 4 buttons; animations mirror the Home Assistant Voice PE firmware exactly
- [Satellite1 HAT Board](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Satellite1%20HAT%20Board/DOCS.md) — 24-LED WS2812 ring + 4 buttons; animations mirror the Home Assistant Voice PE firmware exactly
- [ReSpeaker 2-Mic Pi HAT](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%202mic%20HAT/DOCS.md) — 3 APA102 LEDs + 1 onboard button; compact single-button controller
- [ReSpeaker 4-Mic Array HAT](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%204mic%20HAT/DOCS.md) — 12 APA102 LEDs + external GPIO buttons; four-microphone circular array
- [ReSpeaker Mic Array v2.0 (USB)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%20Mic%20Array%20v2.0%20(USB)/DOCS.md) — 12 APA102 LEDs driven over USB HID; plug-and-play, no GPIO required
+52
View File
@@ -67,6 +67,28 @@ Togges microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted.
> **Mute button caveat:** The Jabra Speak 410 does not expose the mute button state over HID when not in an active call. The controller detects mute by reading the raw audio stream from PipeWire — all-zero audio indicates hardware mute has engaged. This requires PipeWire to be exposed to the container (see installation).
### Disabling button controls from Home Assistant
On connect, the controller registers a **Disable button controls** switch
with LVA, which appears on the device page as
`switch.<satellite>_disable_button_controls` (off by default).
When turned **on** in Home Assistant, the call/hang-up button, volume
buttons, and mute button all stop sending commands to LVA (and, when
`VOLUME_CONTROL=pipewire` is set, volume buttons also stop adjusting the
PipeWire sink directly). When turned **off**, normal button behavior
resumes immediately.
> **Note:** Unlike the other example peripherals, the mute button on this
> device is also gated by the lock, since it is a physical button command
> like any other rather than a dedicated hardware mute switch. If you'd
> prefer mute to remain functional while locked, that's a small change in
> `main.py`'s `Telephony.mute` / `Volume.mute` handling.
The lock state is persisted by LVA across restarts. On (re)connect, the
controller reads the current value from the `snapshot` event's
`button_controls_locked` field.
---
## Installation
@@ -153,7 +175,37 @@ export XDG_RUNTIME_DIR=/run/user/1000
export PULSE_SERVER=unix:/run/user/1000/pulse/native
python main.py
```
To make it persistent across reboots
```
mkdir -p ~/.config/systemd/user/
nano ~/.config/systemd/user/jabra-lva.service
```
```
[Unit]
Description=Jabra Speak 410 LVA Controller
After=network.target pipewire.service
[Service]
Type=simple
WorkingDirectory=%h/linux-voice-assistant/examples/Jabra Speak 410
ExecStart=%h/linux-voice-assistant/venv/bin/python main.py
Restart=always
RestartSec=5
Environment="VOLUME_CONTROL=pipewire"
Environment="LVA_WS_URL=ws://localhost:6055"
Environment="XDG_RUNTIME_DIR=/run/user/1000"
Environment="PULSE_SERVER=unix:/run/user/1000/pulse/native"
[Install]
WantedBy=default.target
```
Enable and start service
```
systemctl --user daemon-reload
systemctl --user enable jabra-lva
systemctl --user start jabra-lva
```
---
## Configuration
+15
View File
@@ -200,6 +200,9 @@ class JabraSpeak:
if not vol_ctrl:
print("ignoring volume command, env not set")
continue
if buttons_locked:
print("ignoring volume command, buttons locked")
continue
if event & Volume.vol_up:
print("jabra to lva: volume up detected")
match vol_ctrl:
@@ -250,6 +253,9 @@ async def write_to_jabra(state: LEDs | LEDState):
async def write_to_lva(command: LVACommand, data: dict = None):
if buttons_locked:
print(f"to lva: buttons locked, ignoring {command}")
return
if lva_sock:
global last_lva_write
last_lva_write = command
@@ -299,6 +305,8 @@ current_state: None | LVAEvent = None
muted: bool = False
buttons_locked: bool = False
async def run_cmd(cmd: list[str]):
print("run_cmd ", cmd)
@@ -342,12 +350,19 @@ async def wsloop():
async with websockets.connect(LVA_WS_URL) as websocket:
print(f"Connected to LVA at {LVA_WS_URL}")
lva_sock = websocket
await websocket.send(json.dumps({"command": "register_button_lock"}))
while True:
data = await websocket.recv()
print(f"from lva: {data}")
json_data = json.loads(data)
if json_data["event"] == "snapshot":
await set_mute(json_data["data"]["muted"], write_lva=False)
global buttons_locked
buttons_locked = json_data["data"].get("button_controls_locked", False)
elif json_data["event"] == "button_lock_changed":
buttons_locked = json_data["data"].get("locked", False)
print(f"Button controls {'locked' if buttons_locked else 'unlocked'}")
continue
global current_state
try:
current_state = LVAEvent(json_data["event"])
+20
View File
@@ -126,6 +126,26 @@ Send commands to LVA by clicking buttons:
- **Single press**, **Double press**, **Triple press**, **Long press** - Simulate physical button inputs
- Useful for testing button-based interactions if LVA is connected to hardware buttons
### Button Lock Testing
The console registers a **Disable button controls** switch with LVA
(`register_button_lock`) so it can be used to test and demonstrate the
button-lock feature that hardware peripherals implement.
- The **Buttons Locked** field in the status area reflects the current
switch state, sourced from the `snapshot` event and updated live on
`button_lock_changed` events.
- When the switch is turned **on** from the Home Assistant device page,
every button in the **Commands** section is disabled (greyed out) and
clicking it is a no-op, mirroring how a real peripheral should ignore
its physical buttons while locked.
- Turning the switch **off** re-enables the command buttons immediately.
This is useful for verifying the lock behavior end-to-end without needing
physical hardware: toggle the switch in Home Assistant and confirm the
console's command grid disables/enables accordingly, and that no
commands are sent to LVA while locked.
## Example Workflow
1. **Start LVA server** with WebSocket peripheral API enabled
@@ -117,6 +117,10 @@
<span class="meta-label">HA Connected</span>
<span class="meta-value" id="meta-ha">—</span>
</div>
<div class="meta-item">
<span class="meta-label">Buttons Locked</span>
<span class="meta-value" id="meta-locked">—</span>
</div>
</div>
<!-- Volume bar -->
@@ -311,6 +315,9 @@
// Register the Button entity with LVA so HA can receive button events.
ws.send(JSON.stringify({command: 'register_button'}));
log('sent', '↑ register_button');
ws.send(JSON.stringify({command: 'register_button_lock'}));
log('sent', '↑ register_button_lock');
};
ws.onclose = () => {
@@ -340,6 +347,8 @@
metaMuted.textContent = data.muted !== undefined ? (data.muted ? 'yes' : 'no') : '—';
metaHa.textContent = data.ha_connected !== undefined ? (data.ha_connected ? 'yes' : 'no') : '—';
volBar.style.width = (data.volume !== undefined ? data.volume * 100 : 0).toFixed(0) + '%';
buttonsLocked = !!data.button_controls_locked;
applyButtonLockUI();
// Restore last conversation context
if (data.last_stt_text) {
@@ -361,7 +370,12 @@
volBar.style.width = pct + '%';
}
if (ev === 'volume_muted' && data) {
metaMuted.textContent = data.muted ? 'yes' : 'no';
const isMuted = !!data.muted;
metaMuted.textContent = isMuted ? 'yes' : 'no';
if (isMuted) {
metaVol.textContent = '0%';
volBar.style.width = '0%';
}
}
if (ev === 'zeroconf' && data) {
metaHa.textContent = data.status === 'connected' ? 'yes' : 'connecting…';
@@ -393,6 +407,10 @@
timerBox.classList.remove('hidden');
}
}
if (ev === 'button_lock_changed' && data) {
buttonsLocked = !!data.locked;
applyButtonLockUI();
}
setState(ev);
log('recv', ev, dataStr);
@@ -411,6 +429,10 @@
log('err-item', '✗ not connected — connect first');
return;
}
if (buttonsLocked) {
log('err-item', '✗ button controls locked — ignoring ' + command);
return;
}
const msg = { command };
if (data) msg.data = data;
ws.send(JSON.stringify(msg));
@@ -430,4 +452,15 @@
send('set_volume', { volume: clamped });
volumeInput.value = ''; // Clear after sending
}
let buttonsLocked = false;
function applyButtonLockUI() {
document.getElementById('meta-locked').textContent = buttonsLocked ? 'yes' : 'no';
document.querySelectorAll('.cmd-grid .cmd-btn').forEach((el) => {
el.disabled = buttonsLocked;
el.style.opacity = buttonsLocked ? '0.4' : '';
el.style.cursor = buttonsLocked ? 'not-allowed' : '';
});
}
</script>
+17
View File
@@ -75,6 +75,7 @@ LED 0 sits above MIC_L and LED 2 sits above MIC_R. When the microphone is muted,
| **Timer ringing** | **Blue flash (repeating)** | **All 3** |
| Timer ticking | Dim cyan, brightness ∝ time left | All 3 |
| Media playing | Dim green steady | All 3 |
| **Volume muted** | **Solid red** | **LED 1 only (center led)** |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Thinking yellow, Speaking green, Muted red, Timer cyan/blue) are hardcoded so they remain recognisable across user customisation.
@@ -132,6 +133,22 @@ Multi-press commands are useful for triggering custom Home Assistant automations
These commands are exposed as button press events to Home Assistant (via the registered button sensor), allowing you to create custom automations via `button_press_event` triggers.
### Disabling button controls from Home Assistant
On connect, the controller registers a **Disable button controls** switch
with LVA, which appears on the device page as
`switch.<satellite>_disable_button_controls` (off by default).
When turned **on** in Home Assistant, the onboard button's single-press
context action and all multi-press gestures (double, triple, long press)
stop sending commands to LVA. When turned **off**, normal behavior
resumes immediately.
The lock state is persisted by LVA across restarts. On (re)connect, the
controller reads the current value from the `snapshot` event's
`button_controls_locked` field, so it never has a stale/unknown lock
state after a reconnect.
---
## Installation
@@ -20,7 +20,7 @@ LED behaviours
pipeline_error : red flash on all 3 LEDs
timer_ringing : blue flash on all 3 LEDs (repeating)
timer_ticking : all 3 dim cyan, brightness proportional to time left
media_playing : dim green steady on all 3 LEDs
volume_muted : LED 1 solid red, LED 0 and LED 2 off
not_ready/no_ha : dim red pulse on all 3 LEDs
On connect the script registers an HA Light entity with LVA via the
@@ -130,7 +130,7 @@ RECONNECT_DELAY_S = 3.0
# is used to route incoming light_command events back to this script.
LIGHT_OBJECT_ID = "leds"
LIGHT_NAME = "LEDs"
LIGHT_ICON = "mdi:circle-outline"
LIGHT_ICON = "mdi:led-strip-varient"
# ===========================================================================
@@ -157,6 +157,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# Effect name. Must match the LEDLightEntity effects list the peripheral
@@ -175,8 +176,9 @@ class SharedState:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
self.volume_muted: bool = False # media volume zero
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
@@ -192,6 +194,7 @@ class SharedState:
# Monotonic deadline used by _timer_tick to fade brightness
# smoothly between sparse timer_updated events.
self.timer_ends_at: float = 0.0
self.buttons_locked: bool = False
def update(self, **kwargs) -> None:
with self._lock:
@@ -213,6 +216,7 @@ class SharedState:
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
@@ -221,6 +225,7 @@ class SharedState:
"light_green": self.light_green,
"light_blue": self.light_blue,
"timer_ends_at": self.timer_ends_at,
"buttons_locked": self.buttons_locked,
}
@@ -379,6 +384,9 @@ class LEDAnimator:
# Left and right LEDs red (mic positions), centre off
self._task = asyncio.create_task(self._muted())
elif state == AssistState.VOLUME_MUTED:
self._task = asyncio.create_task(self._volume_muted())
elif state == AssistState.ERROR:
self._task = asyncio.create_task(
self._flash_all(RED, flashes=3, on_ms=150, off_ms=100, then_off=True)
@@ -456,6 +464,13 @@ class LEDAnimator:
self._leds.set(2, RED)
self._leds.show(self._brightness(0.6))
async def _volume_muted(self) -> None:
"""Centre LED red; left and right off."""
self._leds.set(0, OFF)
self._leds.set(1, RED)
self._leds.set(2, OFF)
self._leds.show(self._brightness(0.6))
async def _flash_all(
self,
color: ColorSource,
@@ -588,6 +603,9 @@ class ButtonHandler:
self._on_press()
def _send(self, command: str) -> None:
if self._state.snapshot["buttons_locked"]:
_LOGGER.debug("Button controls locked — ignoring %s", command)
return
_LOGGER.info("Button → %s", command)
asyncio.run_coroutine_threadsafe(
self._queue.put(command), self._loop
@@ -652,6 +670,9 @@ class ButtonMultipressHandler:
self._single_click_action = action
def _send(self, command: str) -> None:
if self._state.snapshot["buttons_locked"]:
_LOGGER.debug("Button controls locked — ignoring %s", command)
return
_LOGGER.info("Button → %s", command)
asyncio.run_coroutine_threadsafe(
self._queue.put(command), self._loop
@@ -792,7 +813,8 @@ class LVAClient:
# Declare that this peripheral has physical buttons so LVA
# creates a Button Press event entity in Home Assistant.
# Idempotent: safe to send on every reconnect.
await ws.send(json.dumps({"command": "register_button"}))
await ws.send(json.dumps({"command": "register_button"}))
await ws.send(json.dumps({"command": "register_button_lock"}))
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
@@ -835,6 +857,7 @@ class LVAClient:
muted=muted,
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
buttons_locked=data.get("button_controls_locked", False),
)
if muted:
self._animator.set_state(AssistState.MUTED)
@@ -844,6 +867,12 @@ class LVAClient:
self._animator.set_state(AssistState.IDLE)
return
elif event == "button_lock_changed":
locked = data.get("locked", False)
self._state.update(buttons_locked=locked)
_LOGGER.info("Button controls %s", "locked" if locked else "unlocked")
return
if event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
@@ -922,11 +951,11 @@ class LVAClient:
self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted":
muted = data.get("muted", False)
self._state.update(muted=muted)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif self._state.assist_state == AssistState.MUTED:
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "zeroconf":
+15 -13
View File
@@ -43,18 +43,20 @@ The controller runs as a separate Docker container on the same Raspberry Pi. It
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
| LVA state | Animation | Description |
|---|---|---|
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
| Idle | Off | All LEDs off |
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval |
| Thinking | Pulsing pair | Two opposing LEDs fade in and out |
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction |
| Muted | Solid ring + red indicators | Full ring on; red at LEDs 1, 4, 7, 10 (mic corners) |
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
| Timer ringing | Pulse + optional red | Full ring pulsing; red at corners if muted |
| LVA state | Animation | Description |
| ---------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------- |
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
| Idle | Off | All LEDs off |
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval |
| Thinking | Pulsing pair | Two opposing LEDs fade in and out |
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction |
| Muted | Solid ring + red indicators | Full ring on; red at LEDs 1, 4, 7, 10 (mic corners) |
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
| Timer ringing | Pulse + optional red | Full ring pulsing; red at corners if muted |
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from LED 0 and sweeping clockwise |
| Volume muted | Solid ring + red indicator | Red at the bottom-mid LED |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
@@ -235,4 +237,4 @@ The seeed-voicecard driver uses I2S — it does **not** use SPI — so there is
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the Pi itself.
3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`.
3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`.
@@ -93,6 +93,22 @@ SPI_DEVICE = 1
# 10 ≈ 32 % (gentle default), 31 = maximum.
LED_BRIGHTNESS = 10
# Volume Display effect
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda. Origin
# is LED 6 (bottom-mid) — the same reference LED already used for the
# volume_muted indicator (VOLUME_MUTED_LED_INDEX) — so the arc grows from
# the same spot that turns solid red when volume is silenced.
VOLUME_DISPLAY_SECONDS = 2.5
# LED that lights up solid red while the media player (speaker) output is
# muted at volume 0 — the `volume_muted` peripheral event. Ring index 6 is
# the bottom-middle position (180°, directly opposite index 0 at the top),
# following the same 0=top / 3=right / 6=bottom / 9=left convention used for
# the mic corner indicators below. Sits between the two bottom mic corners
# (LEDs 4 and 7) so it reads as a distinct "speaker muted" dot rather than
# overlapping a mic indicator.
VOLUME_MUTED_LED_INDEX = 6
# Default ring colour (R, G, B) — matches HA Voice PE default
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
@@ -124,6 +140,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# ---------------------------------------------------------------------------
@@ -135,8 +152,13 @@ class SharedState:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
# Monotonic deadline until which the Volume Display arc takes over
# the ring, set on each volume_changed event. 0 (or in the past)
# means the arc is not showing.
self.volume_display_until: float = 0.0
self.volume_muted: bool = False # media player (speaker) mute
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
@@ -162,6 +184,8 @@ class SharedState:
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_display_until": self.volume_display_until,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
@@ -317,6 +341,19 @@ class LEDRing:
self._set(7, _scale(RED, factor))
self._set(10, _scale(RED, factor))
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red dot on the bottom-middle LED (index
``VOLUME_MUTED_LED_INDEX``) when the media player output is muted.
Applied after the current pipeline animation has already rendered
its frame, so the indicator shows up consistently no matter which
animation is active — mirrors how ``_apply_mic_indicators`` marks
the microphone corners, but for the distinct speaker-mute state.
"""
self._set(VOLUME_MUTED_LED_INDEX, RED)
self._write()
# ------------------------------------------------------------------
# Animation implementations
# ------------------------------------------------------------------
@@ -453,6 +490,32 @@ class LEDRing:
self._write()
return 0.1
def _anim_volume_display(self, color: RGB, volume: float) -> float:
"""
Arc showing the current volume level, originating at LED 6
(bottom-mid — same LED as VOLUME_MUTED_LED_INDEX) and sweeping
clockwise around the ring.
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda —
proportional arc with a partial-brightness leading LED, plus a red
indicator on the origin LED when volume is silenced.
"""
silenced_color = RED
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
for i in range(LED_COUNT):
if i <= volume_ratio:
brightness = min(volume_ratio - i, 1.0)
self._set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, _scale(color, brightness))
else:
self._set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, BLACK)
if volume <= 0.0:
self._set(VOLUME_MUTED_LED_INDEX, silenced_color)
self._write()
return 0.05 # matches the 50ms update_interval of the ESPHome effect
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
@@ -465,10 +528,16 @@ class LEDRing:
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
# Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal
# animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"])
elif anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
@@ -495,6 +564,9 @@ class LEDRing:
else:
sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep)
@@ -687,10 +759,23 @@ class LVAClient:
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
self._state.update(
volume=data.get("volume", 1.0),
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
)
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
# Media player (speaker) mute state, carried as {"muted": bool}
# per the peripheral API — distinct from the microphone `muted`
# event above. Drives the bottom-middle LED indicator rather
# than replacing the whole ring animation, since the assist
# pipeline can still be doing something else at the same time.
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "zeroconf":
status = data.get("status", "")
@@ -793,4 +878,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
@@ -36,6 +36,8 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
| Timer ringing | Pulse + optional red | Full ring pulsing; red at mic positions if muted |
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from LED 9 (bottom, between the micro-USB and AUX jack) and sweeping clockwise |
| Volume muted | Red mic indicator | LED 9 (bottom at the AUX jack corner) solid red to indicate the media player volume is zero; other LEDs off |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
@@ -217,4 +219,4 @@ The script automatically attempts to re-find the device after a USB error (e.g.
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the host itself regardless of where the LVA container runs.
3. If LVA is on a different machine, use its IP address as `--host`.
3. If LVA is on a different machine, use its IP address as `--host`.
@@ -86,6 +86,21 @@ USB_PRODUCT_ID = 0x0018
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
# Volume Display effect
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda. Origin
# is LED 9 (bottom, DOA 270°) — the same reference LED already used for
# the volume_muted indicator (VOLUME_MUTED_LED_INDEX), sitting between the
# micro-USB connector and the 3.5 mm AUX jack — so the arc grows from the
# same spot that turns solid red when volume is silenced.
VOLUME_DISPLAY_SECONDS = 2.5
# LED that lights up solid red while the media player (speaker) output is
# muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the
# board's bottom-middle position (DOA 270°), sitting right between the
# micro-USB connector and the 3.5 mm AUX jack on the board edge — the same
# "bottom" position already used by the mic indicator mapping below.
VOLUME_MUTED_LED_INDEX = 9
RECONNECT_DELAY_S = 3.0
@@ -113,6 +128,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# ---------------------------------------------------------------------------
@@ -124,8 +140,13 @@ class SharedState:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
# Monotonic deadline until which the Volume Display arc takes over
# the ring, set on each volume_changed event. 0 (or in the past)
# means the arc is not showing.
self.volume_display_until: float = 0.0
self.volume_muted: bool = False # media player volume zero
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
@@ -151,6 +172,8 @@ class SharedState:
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_display_until": self.volume_display_until,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
@@ -164,9 +187,9 @@ class SharedState:
# ===========================================================================
# USB LED driver
#
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
# bytes in the order [start_frame | brightness, B, G, R].
# The ReSpeaker Mic Array v2.0 LED firmware exposes a vendor-command USB
# interface. The copied pixel_ring reference uses command 0x06 for custom LED
# frames and command 0x20 for brightness, both addressed to wIndex 0x1C.
# ===========================================================================
RGB = Tuple[int, int, int]
@@ -186,20 +209,17 @@ def _scale(color: RGB, factor: float) -> RGB:
class USBLEDRing:
"""
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
This is the same protocol used by Seeed's pixel_ring library.
Uses the same vendor-command protocol as Seeed's pixel_ring library.
That protocol talks to the device recipient directly and does not require
detaching the kernel audio driver.
"""
# USB control transfer parameters matching the pixel_ring reference implementation.
# _CTRL_TYPE_OUT = 0x40: bmRequestType = CTRL_OUT | CTRL_TYPE_VENDOR | CTRL_RECIPIENT_DEVICE.
# Recipient is "device" (bits 4:0 = 0x00), so no interface needs to be claimed
# and the kernel audio driver must NOT be detached.
_CMD_SHOW = 0x06
_CMD_BRIGHTNESS = 0x20
_CTRL_TIMEOUT = 8000 # ms
_CTRL_REQUEST = 0
_CTRL_VALUE = 0x0001
_CTRL_INDEX = 0x1C
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
@@ -207,6 +227,7 @@ class USBLEDRing:
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._dev = None
self._dev_lock = threading.Lock()
self._last_brightness: Optional[int] = None
if _HAS_USB:
self._find_device()
@@ -220,12 +241,8 @@ class USBLEDRing:
USB_VENDOR_ID, USB_PRODUCT_ID,
)
return
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception: # pylint: disable=broad-except
pass
self._dev = dev
self._last_brightness = None
_LOGGER.info(
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
@@ -237,24 +254,32 @@ class USBLEDRing:
def set_all(self, color: RGB) -> None:
self._pixels = [color] * LED_COUNT
def _write(self, command: int, data: list[int]) -> None:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
command,
self._CTRL_INDEX,
data,
self._CTRL_TIMEOUT,
)
def show(self, brightness: float = 1.0) -> None:
"""
Push the current pixel buffer to the device.
Frame layout (matches pixel_ring / APA102 USB protocol):
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
The USB firmware expects command 0x06 with 4 bytes per LED in RGB0
order, plus command 0x20 for device brightness. Sending raw APA102
frames here causes the firmware to misinterpret the payload.
"""
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
frame: list[int] = []
for r, g, b in self._pixels:
br = max(0, min(255, int(r * brightness)))
bg = max(0, min(255, int(g * brightness)))
bb = max(0, min(255, int(b * brightness)))
frame += [0xE0 | bright5, bb, bg, br]
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
frame += [br, bg, bb, 0]
with self._dev_lock:
if self._dev is None:
@@ -262,17 +287,14 @@ class USBLEDRing:
_LOGGER.debug("LEDs [simulated]: %s", pixels)
return
try:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
self._CTRL_VALUE,
self._CTRL_INDEX,
frame,
self._CTRL_TIMEOUT,
)
if self._last_brightness != bright5:
self._write(self._CMD_BRIGHTNESS, [bright5])
self._last_brightness = bright5
self._write(self._CMD_SHOW, frame)
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
self._dev = None
self._last_brightness = None
# Attempt to re-find the device on next show() call
threading.Thread(
target=self._reconnect, daemon=True
@@ -408,6 +430,48 @@ class LEDRing:
self._leds.set(6, _scale(RED, factor))
self._leds.set(9, _scale(RED, factor))
def _anim_volume_display(self, color: RGB, volume: float) -> float:
"""
Arc showing the current volume level, originating at LED 9
(bottom, between the micro-USB and AUX jack — same LED as
VOLUME_MUTED_LED_INDEX) and sweeping clockwise around the ring.
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda —
proportional arc with a partial-brightness leading LED, plus a red
indicator on the origin LED when volume is silenced.
"""
silenced_color = RED
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
for i in range(LED_COUNT):
if i <= volume_ratio:
brightness = min(volume_ratio - i, 1.0)
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, _scale(color, brightness))
else:
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, BLACK)
if volume <= 0.0:
self._leds.set(VOLUME_MUTED_LED_INDEX, silenced_color)
self._leds.show()
return 0.05 # matches the 50ms update_interval of the ESPHome effect
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red dot on the bottom-middle LED (index
``VOLUME_MUTED_LED_INDEX``, DOA 270°) when the media player output
is muted at volume 0.
Sits right between the board's micro-USB connector and 3.5 mm AUX
jack. Applied after the current pipeline animation has already
rendered its frame, so the indicator shows up consistently no
matter which animation is active — mirrors how
``_apply_mic_indicators`` marks the microphone cardinal points, but
for the distinct speaker-mute state.
"""
self._leds.set(VOLUME_MUTED_LED_INDEX, RED)
self._leds.show()
# ------------------------------------------------------------------
# Animations — each returns sleep time in seconds
# ------------------------------------------------------------------
@@ -556,10 +620,16 @@ class LEDRing:
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
# Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal
# animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"])
elif anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
@@ -586,6 +656,9 @@ class LEDRing:
else:
sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep)
@@ -709,11 +782,19 @@ class LVAClient:
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
muted = data.get("muted", False)
ha_connected = data.get("ha_connected", False)
self._state.update(
muted=data.get("muted", False),
muted=muted,
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
ha_connected=ha_connected,
)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif ha_connected:
self._state.update(assist_state=AssistState.IDLE)
else:
self._state.update(assist_state=AssistState.NOT_READY)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
@@ -731,7 +812,12 @@ class LVAClient:
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
self._state.update(assist_state=AssistState.MUTED, muted=True)
muted = data.get("muted", True)
self._state.update(muted=muted)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif self._state.assist_state == AssistState.MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
@@ -768,15 +854,29 @@ class LVAClient:
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
self._state.update(
volume=data.get("volume", 1.0),
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
)
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
Volume_muted = data.get("muted", True)
self._state.update(volume_muted=Volume_muted)
if Volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
if self._state.assist_state == AssistState.NOT_READY:
self._state.update(
assist_state=(
AssistState.MUTED if self._state.muted else AssistState.IDLE
),
)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
+51 -10
View File
@@ -4,7 +4,7 @@
Peripheral controller for the [Satellite1 HAT Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) by FutureProofHomes, running alongside the Linux Voice Assistant (LVA) container.
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 12-LED SK6812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 24-LED WS2812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
---
@@ -13,7 +13,7 @@ The controller runs as a separate Docker container on the same Raspberry Pi. It
| Component | Details |
|---|---|
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
| LED ring | 12 × SK6812 RGBW NeoPixels |
| LED ring | 24 × WS2812 RGBW NeoPixels |
| Buttons | 4 × tactile (top, bottom, left, right) |
| Audio output | I2S line-out (optional) |
| Interface | Raspberry Pi 40-pin HAT connector |
@@ -68,6 +68,7 @@ In Home Assistant, you can:
- Thinking → Pulse in your color
- Speaking → Reverse spin in your color
- Muted → Solid ring in your color + red at mic positions
- Volume muted → Solid ring in your color + red at AUX jack corner LEDs
- Timer → Countdown arc in your color
3. **Set brightness** — scales all animations by the chosen brightness (0–100%). Useful for adapting to ambient lighting.
@@ -103,10 +104,14 @@ target:
## Button behaviour
### Right button — Volume Up
Sends `volume_up` to LVA. Each press increases volume by one step.
Sends `volume_up` to LVA. A quick press increases volume by one step.
**Hold to adjust gradually:** keep the button held past 1000 ms and the controller keeps sending `volume_up` every ~150 ms — volume rises smoothly for as long as the button stays down, instead of one step per press. Release the button to stop.
### Left button — Volume Down
Sends `volume_down` to LVA. Each press decreases volume by one step.
Sends `volume_down` to LVA. A quick press decreases volume by one step.
**Hold to adjust gradually:** the same behaviour applies in reverse — holding the button past 1000 ms repeats `volume_down` every ~150 ms until released.
### Top button — Mute / Unmute
Toggles microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted. The LED ring switches to the muted indicator pattern immediately.
@@ -126,6 +131,8 @@ Context-aware command based on current assistant state, mirroring the Home Assis
| Music / media playing | `stop_media_player` |
| Any other (idle) | `start_listening` |
**LED feedback while pressed:** whenever the button is physically held down — even for a very short tap — the LED ring lights up at the currently chosen color (or the default cyan if no color has been picked yet), then turns off again the instant the button is released. This only kicks in when the HA Light entity is currently **off**; if the light is already on, the idle animation is already showing the color, so nothing changes.
#### Multi-press gestures
When the action button is released quickly (not held), it detects multiple presses:
@@ -154,6 +161,10 @@ Hold down the action button and use the volume buttons to rotate through the HSV
| **Hold + Volume Up** | Rotate hue +10° (clockwise around color wheel) |
| **Hold + Volume Down** | Rotate hue -10° (counter-clockwise around color wheel) |
**Hold the volume button to keep rotating:** a quick press of a volume button while holding the action button steps the hue by 10° once, same as above. If you keep the volume button held past 1000 ms, the controller keeps stepping the hue every ~150 ms — the color keeps rotating around the wheel for as long as you hold it, and stops the moment you release the volume button (the action button can stay held throughout).
**Real-time preview:** every hue step — whether from a single tap or the continuous hold-rotation — is applied to shared state immediately and shows up on the LED ring right away, without waiting on Home Assistant to echo the change back. This means the ring always reflects exactly the color you're about to select while you're still picking it.
The color wheel provides **36 distinct color stops** (0°–360° in 10° increments):
- **0°** – Red
- **60°** – Yellow
@@ -162,14 +173,30 @@ The color wheel provides **36 distinct color stops** (0°–360° in 10° increm
- **240°** – Blue
- **300°** – Magenta
Each press changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on).
Each press (or hold-repeat step) changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on).
**Example workflow:**
1. Press and hold the action button
2. Press volume up repeatedly to cycle through warm colors (red → orange → yellow)
3. Release action button to confirm and exit color mode
2. Press and hold volume up to rotate continuously through warm colors (red → orange → yellow), or tap it repeatedly for single 10° steps
3. Release the volume button once you like the color, then release the action button to confirm and exit color mode
4. The new color persists in Home Assistant
#### Disabling button controls from Home Assistant
On connect, the controller registers a **Disable button controls** switch
with LVA, which appears on the device page as
`switch.<satellite>_disable_button_controls` (off by default).
When turned **on** in Home Assistant, the action button (context command), volume buttons, mute button, and the hold + volume color-wheel gesture are all ignored — pressing them has no effect on LVA or the LED ring color.
When turned **off**, button behavior resumes immediately.
The switch state is persisted by LVA across restarts, so a lock set from
Home Assistant survives a reboot of the satellite. On reconnect, the
controller reads the current state from the `snapshot` event's
`button_controls_locked` field and applies it immediately, without
waiting for a change.
---
## LED ring animations
@@ -188,6 +215,9 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc length proportional to `seconds_left / total_seconds`, tinted by your color |
| Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted |
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from the AUX jack corner LED (D1, index 0) and sweeping clockwise across all 24 LEDs |
| Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero |
| Action button held (light off) | Solid ring in chosen/default color | Overrides whatever else is showing for as long as the action button is physically held down, only while the HA Light entity is off — gives instant tactile feedback and previews color-wheel changes live |
**Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness.
@@ -202,7 +232,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
Edit `/boot/firmware/config.txt` (or `/boot/config.txt` on older Raspberry Pi OS):
```ini
# Enable PWM on GPIO 12 for the SK6812 LED ring
# Enable PWM on GPIO 12 for the WS2812 LED ring
dtoverlay=pwm,pin=12,func=4
# Disable onboard audio — it shares PWM0 with GPIO 12
@@ -293,13 +323,18 @@ BTN_ACTION = 23
BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds
# Volume button hold-to-repeat (also drives continuous color wheel rotation
# while the action button is held — see "Hold + Volume buttons" above)
VOLUME_HOLD_THRESHOLD_MS = 1000 # Hold time before gradual repeat kicks in
VOLUME_HOLD_REPEAT_S = 0.15 # Seconds between repeated steps while held
# Button multipress timing
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
# LED ring
LED_COUNT = 12
LED_COUNT = 24
LED_BRIGHTNESS = 168 # 0–255, default is 66 % (168)
# HA Light entity registration
@@ -414,7 +449,13 @@ automation:
2. Confirm onboard audio is disabled (`dtparam=audio=on` is commented out).
3. Check the container is running as root (`user: "0:0"` in compose file) — `rpi-ws281x` requires root for DMA access via `/dev/mem`.
4. Run with `--debug` and look for `LED ring initialised` in the logs. If `rpi_ws281x not found` appears, the Python package failed to install — rebuild the image.
5. **If you see only pipeline animations (listening, thinking, etc.) but no idle glow:** The light entity defaults to **off** in Home Assistant to match Voice PE behavior. Turn on the `light.<satellite>_leds` entity in HA to show the idle LED color.
5. **If you see only pipeline animations (listening, thinking, etc.) but no idle glow:** The light entity defaults to **off** in Home Assistant to match Voice PE behavior. Turn on the `light.<satellite>_leds` entity in HA to show the idle LED color, or simply hold the action button — it lights the ring at the current color while held whenever the light is off.
### Volume doesn't change smoothly while holding the button
1. Confirm you're holding the button past 1000 ms (`VOLUME_HOLD_THRESHOLD_MS`) — a shorter press only sends a single step, by design.
2. Run with `--debug` — each repeated step logs `Button → volume_up` / `Button → volume_down` roughly every 150 ms while held.
3. If the repeat feels too fast/slow, adjust `VOLUME_HOLD_REPEAT_S` at the top of `Satellite1_HAT_Board.py`.
### Buttons do not respond
@@ -7,7 +7,7 @@ hardware buttons to LVA peripheral API commands.
Hardware layout (Satellite 1 HAT on Raspberry Pi)
--------------------------------------------------
LED ring : 12 × SK6812 RGBW NeoPixels → GPIO 12 (PWM0)
LED ring : 24 x WS2812 RGBW NeoPixels → GPIO 12 (PWM0)
Right btn : Volume Up → GPIO 17
Left btn : Volume Down → GPIO 27
Top btn : Mute / Unmute mic → GPIO 22
@@ -47,7 +47,7 @@ import sys
import threading
import time
from enum import Enum
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies (gracefully stubbed when not on real Pi hardware so
@@ -90,14 +90,43 @@ BTN_ACTION = 23 # Bottom button
BTN_DEBOUNCE_MS = 30 # Milliseconds
# Continuous hold-repeat behaviour for the volume buttons (and, while the
# action button is held at the same time, the color wheel rotation). Once a
# volume button has been held past VOLUME_HOLD_THRESHOLD_MS, the associated
# step (volume up/down, or hue up/down when in color change mode) repeats
# every VOLUME_HOLD_REPEAT_S seconds until the button is released.
VOLUME_HOLD_THRESHOLD_MS = 1000 # Milliseconds before continuous repeat starts
VOLUME_HOLD_REPEAT_S = 0.15 # Seconds between repeated steps while held
# LED ring
LED_COUNT = 12
LED_COUNT = 24
LED_FREQ_HZ = 800_000
LED_DMA = 10
LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
LED_INVERT = False
LED_CHANNEL = 0
# Four mic positions at the cardinal points (top, right, bottom, left),
# spaced LED_COUNT/4 apart. On the 24-LED ring: LEDs 0, 6, 12, 18.
MIC_LED_POSITIONS = tuple(i * (LED_COUNT // 4) for i in range(4))
# LEDs physically sitting in the AUX (3.5 mm) audio jack corner of the board,
# identified by their silkscreen designators on the Satellite1 HAT (D35, D1,
# D2). These three ring positions light up solid red whenever the media
# player volume is at zero (the `volume_muted` peripheral event), independent
# of whatever pipeline animation is currently running. Scaled to the 24-LED
# ring to cover the same angular span as the original 12-LED mapping —
# verify against your board revision and adjust if it differs.
AUX_JACK_LED_INDICES = (22, 23, 0, 1, 2)
# Volume Display effect
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda, but
# rotated so the arc originates at the AUX jack corner LED (D1, index 0)
# instead of Voice PE's origin — matching this board's physical AUX jack
# placement. Shown as a temporary overlay for this many seconds after
# volume_changed.
VOLUME_DISPLAY_SECONDS = 2.5
# Default ring color (ESPHome default: 9.4 % R, 73.3 % G, 94.9 % B)
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
@@ -223,6 +252,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# ---------------------------------------------------------------------------
@@ -234,8 +264,13 @@ class SharedState:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
# Monotonic deadline until which the Volume Display arc takes over
# the ring, set on each volume_changed event. 0 (or in the past)
# means the arc is not showing.
self.volume_display_until: float = 0.0
self.volume_muted: bool = False # media player volume zero
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
@@ -251,6 +286,12 @@ class SharedState:
self.hsv_hue: int = DEFAULT_HUE
self.hsv_saturation: float = DEFAULT_SATURATION
self.hsv_value: float = DEFAULT_VALUE
# True for as long as the action (bottom) button is physically held
# down, regardless of press duration. Drives the "light up while
# pressed" tactile feedback in LEDRing (only applied when the HA
# Light entity is off — see LEDRing._anim_action_held).
self.action_button_down: bool = False
self.buttons_locked: bool = False
def update(self, **kwargs) -> None:
with self._lock:
@@ -265,6 +306,8 @@ class SharedState:
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_display_until": self.volume_display_until,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
@@ -275,6 +318,8 @@ class SharedState:
"hsv_hue": self.hsv_hue,
"hsv_saturation": self.hsv_saturation,
"hsv_value": self.hsv_value,
"action_button_down": self.action_button_down,
"buttons_locked": self.buttons_locked,
}
@@ -425,6 +470,25 @@ class LEDRing:
self._all_off()
return 0.1
def _anim_action_held(self, color: RGB, brightness: float) -> float:
"""
Force the ring fully on at the current (or default, if none has been
chosen yet) light color while the action button is physically held
down and the HA Light entity is off.
Only takes over when the Light entity is off — if it's already on,
the idle animation is already showing the color and the ring is
left alone. Gives instant visual feedback for every press,
including a very short tap (turns off the moment the button is
released), and refreshes quickly so that rotating the color wheel
(holding action + a volume button) shows up on the ring in real
time.
"""
for i in range(LED_COUNT):
self._set(i, _scale(color, brightness))
self._write()
return 0.05
def _anim_off(self) -> float:
self._all_off()
return 0.1
@@ -487,18 +551,12 @@ class LEDRing:
def _anim_muted(self, color: RGB, muted: bool) -> float:
"""
Solid ring with red indicators at all 4 mic positions when muted.
Satellite 1 HAT has mics at 12, 3, 6 and 9 o'clock → LEDs 0, 3, 6, 9.
"""
for i in range(LED_COUNT):
self._set(i, color)
if muted:
# 4 mic positions: top (0), right (3), bottom (6), left (9)
# Blank the immediate neighbours so the red indicators stand out
self._set(11, BLACK); self._set(0, RED); self._set(1, BLACK)
self._set(2, BLACK); self._set(3, RED); self._set(4, BLACK)
self._set(5, BLACK); self._set(6, RED); self._set(7, BLACK)
self._set(8, BLACK); self._set(9, RED); self._set(10, BLACK)
self._apply_mic_indicators()
self._write()
return 0.016
@@ -555,16 +613,69 @@ class LEDRing:
self._set(i, BLACK)
if muted:
# 4 mic positions: LEDs 0, 3, 6, 9
self._set(11, BLACK); self._set(0, RED); self._set(1, BLACK)
self._set(2, BLACK); self._set(3, RED); self._set(4, BLACK)
self._set(5, BLACK); self._set(6, RED); self._set(7, BLACK)
self._set(8, BLACK); self._set(9, RED); self._set(10, BLACK)
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._write()
return 0.1
def _anim_volume_display(self, color: RGB, volume: float) -> float:
"""
Arc showing the current volume level, originating at the AUX jack
corner LED (D1, index 0) and sweeping clockwise around the ring.
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda
exactly, except the arc's origin is rotated from LED 6 to LED 0 to
match this board's AUX jack corner LED placement.
"""
silenced_color = RED
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
for i in range(LED_COUNT):
if i <= volume_ratio:
brightness = min(volume_ratio - i, 1.0)
self._set(i, _scale(color, brightness))
else:
self._set(i, BLACK)
if volume <= 0.0:
self._set(0, silenced_color)
self._write()
return 0.05 # matches the 50ms update_interval of the ESPHome effect
def _apply_mic_indicators(self) -> None:
"""
Mark all 4 mic positions (cardinal points) red on the already
rendered frame, blanking immediate neighbours so each indicator
reads as a distinct dot. Positions are LED_COUNT/4 apart —
LEDs 0, 6, 12, 18 on the 24-LED ring.
"""
for pos in MIC_LED_POSITIONS:
self._set((pos - 1) % LED_COUNT, BLACK)
self._set(pos, RED)
self._set((pos + 1) % LED_COUNT, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
for pos in MIC_LED_POSITIONS:
self._set(pos, _scale(RED, factor))
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red indicator on the AUX jack corner LEDs
(designators D35, D1, D2 — see ``AUX_JACK_LED_INDICES``) when the
media player output is muted.
Applied as a post-render overlay after the current pipeline
animation has already written its frame, so the indicator shows up
consistently no matter which animation is active, without having to
thread the flag through every individual ``_anim_*`` method.
"""
for i in AUX_JACK_LED_INDICES:
self._set(i, RED)
self._write()
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
@@ -577,11 +688,29 @@ class LEDRing:
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
ha_connected = snap["ha_connected"]
action_button_down = snap["action_button_down"]
if anim == self.ANIM_IDLE:
# Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal
# animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"])
# The action button forces the ring on at the current (or
# default) color while physically held down — but only when the
# HA Light entity is off; if it's already on, the idle animation
# is already showing the color and there's nothing extra to do.
# This also carries live updates while the color wheel is being
# rotated (holding action + a volume button), since that
# rotation writes the new color straight into shared state.
elif action_button_down and not snap["light_is_on"]:
sleep = self._anim_action_held(color, snap["light_brightness"])
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
elif anim == self.ANIM_OFF:
@@ -620,6 +749,9 @@ class LEDRing:
else:
sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep)
@@ -678,11 +810,24 @@ class ButtonHandler:
coroutines onto the asyncio event loop thread-safely.
Supports:
- Volume +/- for volume control
- Mute button for microphone toggle
- Volume +/- for volume control. A short press sends a single step;
holding the button past VOLUME_HOLD_THRESHOLD_MS repeats that step
every VOLUME_HOLD_REPEAT_S seconds until released, so volume moves
gradually while held instead of one step per press.
- Mute button for microphone toggle.
- Action button for context-aware commands or color changing:
* Hold + Volume +: Increase hue by 10°
* Hold + Volume -: Decrease hue by 10°
Holding the volume button past VOLUME_HOLD_THRESHOLD_MS while the
action button is held rotates continuously through the color wheel
using the same repeat mechanism, until the volume button is released.
Color changes are applied to the LED ring immediately so the wheel
rotation previews in real time.
- The action button also lights up the LED ring at the chosen (or
default) color for as long as it's physically held down — even for a
quick tap — turning off again on release. This only happens when the
HA Light entity is off; if it's already on, the idle animation is
already showing the color.
"""
def __init__(
@@ -699,6 +844,15 @@ class ButtonHandler:
self._action_button_held = False
self._action_button_obj = None
# Continuous hold-repeat state for the volume buttons, keyed by
# "up" / "down". `_volume_hold_timers` fires once the button has
# been held past VOLUME_HOLD_THRESHOLD_MS to kick off the repeat
# loop; `_volume_repeat_stop` / `_volume_repeat_threads` drive that
# loop until the button is released.
self._volume_hold_timers: Dict[str, Optional[threading.Timer]] = {"up": None, "down": None}
self._volume_repeat_stop: Dict[str, Optional[threading.Event]] = {"up": None, "down": None}
self._volume_repeat_threads: Dict[str, Optional[threading.Thread]] = {"up": None, "down": None}
def setup(self) -> None:
if not _HAS_GPIO:
_LOGGER.warning("GPIO unavailable – buttons not configured")
@@ -711,12 +865,14 @@ class ButtonHandler:
btn_mute = Button(BTN_MUTE, pull_up=True, bounce_time=debounce)
btn_act = Button(BTN_ACTION, pull_up=True, bounce_time=debounce)
btn_up.when_pressed = self._on_volume_up
btn_down.when_pressed = self._on_volume_down
btn_up.when_pressed = lambda: self._on_volume_button_pressed("up")
btn_up.when_released = lambda: self._on_volume_button_released("up")
btn_down.when_pressed = lambda: self._on_volume_button_pressed("down")
btn_down.when_released = lambda: self._on_volume_button_released("down")
btn_mute.when_pressed = self._on_mute
btn_act.when_pressed = self._on_action
btn_act.when_pressed = self._on_action_pressed
btn_act.when_held = self._on_action_held
btn_act.when_released = self._on_action_released
btn_act.when_released = self._on_action_button_released
btn_act.hold_time = 0.1 # 100ms to detect hold
# Keep references — gpiozero Buttons are released when garbage-collected
@@ -728,6 +884,9 @@ class ButtonHandler:
)
def cleanup(self) -> None:
for which in ("up", "down"):
self._cancel_volume_hold_timer(which)
self._stop_volume_repeat(which)
for btn in self._buttons:
btn.close()
self._buttons.clear()
@@ -749,7 +908,95 @@ class ButtonHandler:
self._on_color_hue_decrease()
else:
self._send("volume_down")
def _on_volume_button_pressed(self, which: str) -> None:
"""
Handle a Volume Up/Down press: fire the immediate single step (volume,
or hue when the action button is held for color changing), then arm a
timer that starts continuous repeat if the button is still held past
VOLUME_HOLD_THRESHOLD_MS.
"""
if which == "up":
self._on_volume_up()
else:
self._on_volume_down()
self._cancel_volume_hold_timer(which)
timer = threading.Timer(
VOLUME_HOLD_THRESHOLD_MS / 1000.0, self._start_volume_repeat, args=(which,)
)
timer.daemon = True
self._volume_hold_timers[which] = timer
timer.start()
def _on_volume_button_released(self, which: str) -> None:
"""Stop any pending hold detection and any active continuous repeat."""
self._cancel_volume_hold_timer(which)
self._stop_volume_repeat(which)
def _cancel_volume_hold_timer(self, which: str) -> None:
timer = self._volume_hold_timers.get(which)
if timer is not None:
timer.cancel()
self._volume_hold_timers[which] = None
def _start_volume_repeat(self, which: str) -> None:
"""
Begin repeating the volume/hue step every VOLUME_HOLD_REPEAT_S while
the button stays held. Runs in its own daemon thread so the GPIO
event-detection thread is never blocked; stopped via a threading.Event
set from `_on_volume_button_released`.
"""
self._stop_volume_repeat(which) # Safety: never overlap two loops
stop_event = threading.Event()
self._volume_repeat_stop[which] = stop_event
def _repeat_loop() -> None:
while not stop_event.wait(VOLUME_HOLD_REPEAT_S):
if which == "up":
self._on_volume_up()
else:
self._on_volume_down()
thread = threading.Thread(
target=_repeat_loop, daemon=True, name=f"volume-hold-repeat-{which}"
)
self._volume_repeat_threads[which] = thread
thread.start()
def _stop_volume_repeat(self, which: str) -> None:
stop_event = self._volume_repeat_stop.get(which)
if stop_event is not None:
stop_event.set()
thread = self._volume_repeat_threads.get(which)
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)
self._volume_repeat_stop[which] = None
self._volume_repeat_threads[which] = None
def _on_action_pressed(self) -> None:
"""
Action button physically pressed down.
Fires the usual context-sensitive command immediately (unchanged
behaviour), and also marks the button as held down so the LED ring
can react instantly — even for a very short tap. The ring itself
only lights up in response to this when the HA Light entity is off
(see LEDRing._anim_action_held); if it's already on, the idle
animation is already showing the color and there's nothing extra
to do.
"""
self._state.update(action_button_down=True)
self._on_action()
def _on_action_button_released(self) -> None:
"""Action button released – exit color-change mode and stop forcing the ring on."""
self._on_action_released()
self._state.update(action_button_down=False)
def _on_action_held(self) -> None:
"""Action button held – enter color change mode."""
self._action_button_held = True
@@ -772,13 +1019,26 @@ class ButtonHandler:
self._send_color_command(new_hue, snap["hsv_saturation"], snap["hsv_value"])
def _send_color_command(self, hue: int, saturation: float, value: float) -> None:
"""Convert HSV to RGB and send light_command to LVA."""
# Update local HSV state
self._state.update(hsv_hue=hue, hsv_saturation=saturation, hsv_value=value)
# Convert HSV to RGB for light command
"""
Convert HSV to RGB, apply it to shared state immediately for a
real-time preview on the LED ring, and notify LVA/Home Assistant.
"""
r, g, b = hsv_to_rgb(hue, saturation, value)
# Update local state right away so the ring reflects the new color
# as it's being picked, without waiting on a light_command
# round-trip through LVA/Home Assistant. This also turns the light
# "on" — picking a color is an explicit, deliberate action.
self._state.update(
hsv_hue=hue,
hsv_saturation=saturation,
hsv_value=value,
light_is_on=True,
light_red=r / 255.0,
light_green=g / 255.0,
light_blue=b / 255.0,
)
# Normalize RGB to 0-1 range
light_cmd = {
"command": "light_command",
@@ -832,8 +1092,12 @@ class ButtonMultipressHandler:
self.button_pin = BTN_ACTION
self.last_press_time = 0
self.press_count = 0
self._state = state
def _send(self, command: str) -> None:
if self._state.snapshot["buttons_locked"]:
_LOGGER.debug("Button controls locked — ignoring %s", command)
return
_LOGGER.info("Button → %s", command)
asyncio.run_coroutine_threadsafe(
self._queue.put(command), self._loop
@@ -931,7 +1195,8 @@ class LVAClient:
# Declare that this peripheral has physical buttons so LVA
# creates a Button Press event entity in Home Assistant.
# Idempotent: safe to send on every reconnect.
await ws.send(json.dumps({"command": "register_button"}))
await ws.send(json.dumps({"command": "register_button"}))
await ws.send(json.dumps({"command": "register_button_lock"}))
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
@@ -986,7 +1251,11 @@ class LVAClient:
self._state.update(
muted=data.get("muted", False),
volume=data.get("volume", 1.0),
# Not currently sent by LVA's snapshot payload, but read it
# defensively in case a future LVA version adds it.
volume_muted=data.get("volume_muted", False),
ha_connected=data.get("ha_connected", False),
buttons_locked=data.get("button_controls_locked", False),
)
snap = self._state.snapshot
anim = state_to_animation(
@@ -995,6 +1264,12 @@ class LVAClient:
self._leds.set_animation(anim)
return
elif event == "button_lock_changed":
locked = data.get("locked", False)
self._state.update(buttons_locked=locked)
_LOGGER.info("Button controls %s", "locked" if locked else "unlocked")
return
# --- Voice pipeline events -----------------------------------------
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
@@ -1069,10 +1344,23 @@ class LVAClient:
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
self._state.update(
volume=data.get("volume", 1.0),
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
)
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
# Media player (speaker) mute state, distinct from the
# microphone `muted` event above. Drives the red indicator on
# the AUX jack corner LEDs (see AUX_JACK_LED_INDICES) rather
# than swapping the whole ring animation, since the assist
# pipeline can still be doing something else at the same time.
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
# --- Zeroconf / connection events ----------------------------------
elif event == "zeroconf":
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://app.kilo.ai/config.json",
"model": "kilo/codegemma-2b:free",
"instructions": ["AGENTS.md"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+67 -5
View File
@@ -18,7 +18,7 @@ from getmac import get_mac_address # type: ignore
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import Preferences, ServerState, WakeWordType
from .models import Preferences, ServerState, WakeWordType, initial_stop_word_threshold
from .mpv_player import MpvMediaPlayer
from .peripheral_api import LVAEvent, PeripheralAPIServer
from .satellite import VoiceSatelliteProtocol
@@ -66,6 +66,10 @@ async def main() -> None:
"--audio-output-device",
help="Name for the audio output device (see --list-output-devices)",
)
parser.add_argument(
"--music-output-device",
help="mpv name for the music/media output device (defaults to --audio-output-device)",
)
parser.add_argument(
"--list-output-devices",
action="store_true",
@@ -234,6 +238,11 @@ async def main() -> None:
action="store_true",
help="Add this to enable debug logging",
)
parser.add_argument(
"--colored-debug",
action="store_true",
help="Add this to enable colored debug logging",
)
parser.add_argument(
"--output-only",
action="store_true",
@@ -241,6 +250,15 @@ async def main() -> None:
)
args = parser.parse_args()
if args.colored_debug:
args.debug = True
_setup_logging(args)
elif args.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
_LOGGER.debug(args)
if args.list_input_devices:
print("Audio Input devices:")
print("=" * 13)
@@ -259,9 +277,6 @@ async def main() -> None:
print(speaker["name"] + ":", speaker["description"])
return
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
# Resolve network interface for mac-address detection
if not args.network_interface:
print("No network interface specified, try to detect default interface")
@@ -364,6 +379,19 @@ async def main() -> None:
initial_volume = max(0.0, min(1.0, float(initial_volume)))
preferences.volume = initial_volume
# Load stop word sensitivity from preferences on startup, and ensure it's between 0.0 and 1.0
initial_threshold = initial_stop_word_threshold(preferences.stop_word_sensitivity)
preferences.stop_word_sensitivity = initial_threshold
# Load button-lock state from preferences on startup. This must happen
# before ServerState is constructed (and therefore before the peripheral
# WebSocket server can accept any connection), because the very first
# snapshot sent to a (re)connecting peripheral must already reflect the
# persisted value — peripherals only learn the lock state from that
# snapshot or a later button_lock_changed event, and nothing re-sends
# a corrected snapshot after the fact.
initial_button_controls_locked = bool(preferences.button_controls_locked)
if args.enable_thinking_sound:
preferences.thinking_sound = 1
@@ -408,7 +436,7 @@ async def main() -> None:
wake_words=wake_models,
active_wake_words=active_wake_words,
stop_word=stop_model,
music_player=MpvMediaPlayer(device=args.audio_output_device),
music_player=MpvMediaPlayer(device=args.music_output_device or args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
wakeup_sound=args.wakeup_sound,
start_listening_sound=args.start_listening_sound,
@@ -426,6 +454,8 @@ async def main() -> None:
output_only=args.output_only,
download_dir=args.download_dir,
volume=initial_volume,
stop_word_threshold=initial_threshold,
button_controls_locked=initial_button_controls_locked,
mic_volume=preferences.mic_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
@@ -575,6 +605,38 @@ async def main() -> None:
_LOGGER.debug("Server stopped")
# -----------------------------------------------------------------------------
def _setup_logging(args: argparse.Namespace) -> None:
COLORS = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[35m",
}
RESET = "\033[0m"
original_format = logging.Formatter.format
def colored_format(self, record: logging.LogRecord) -> str:
color = COLORS.get(record.levelno, RESET)
return f"{color}{original_format(self, record)}{RESET}"
logging.Formatter.format = colored_format # type: ignore
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
)
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
handlers=[handler],
)
# -----------------------------------------------------------------------------
+64
View File
@@ -182,6 +182,8 @@ class MediaPlayerEntity(ESPHomeEntity):
self.music_player.set_volume(0)
self.announce_player.set_volume(0)
self.muted = True
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
self.server.state.persist_volume(self.volume)
yield self._update_state(self.state)
elif command == MediaPlayerCommand.UNMUTE:
@@ -191,6 +193,8 @@ class MediaPlayerEntity(ESPHomeEntity):
self.music_player.set_volume(int(self.volume * 100))
self.announce_player.set_volume(int(self.volume * 100))
self.muted = False
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
self.server.state.persist_volume(self.volume)
yield self._update_state(self.state)
elif msg.has_volume:
@@ -797,6 +801,65 @@ class ButtonEventSensorEntity(ESPHomeEntity):
)
class ButtonLockEntity(ESPHomeEntity):
"""Switch entity for disabling a peripheral's on-board buttons.
Registered by a peripheral via register_button_lock, mirroring the
opt-in pattern used by register_light. LVA only exposes the switch
in Home Assistant and owns its state; it has no opinion on what
"locked" means for the hardware. When Home Assistant changes the
switch, on_changed fires so the peripheral API server can broadcast
a button_lock_changed event back to the peripheral, which decides
whether to keep executing button-triggered commands.
"""
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_locked: Callable[[], bool],
set_locked: Callable[[bool], None],
icon: str = "mdi:button-pointer",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.icon = icon
self._get_locked = get_locked
self._set_locked = set_locked
self._switch_state = self._get_locked() # Sync internal state with actual value on init
def update_get_locked(self, get_locked: Callable[[], bool]) -> None:
self._get_locked = get_locked
def update_set_locked(self, set_locked: Callable[[bool], None]) -> None:
self._set_locked = set_locked
def sync_with_state(self) -> None:
self._switch_state = self._get_locked()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, SwitchCommandRequest) and (msg.key == self.key):
new_state = bool(msg.state)
self._switch_state = new_state
self._set_locked(new_state)
yield SwitchStateResponse(key=self.key, state=self._switch_state)
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesSwitchResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
yield SwitchStateResponse(key=self.key, state=self._switch_state)
# Backward compatibility export aliases
__all__ = [
"ESPHomeEntity",
@@ -805,6 +868,7 @@ __all__ = [
"ThinkingSoundEntity",
"LEDLightEntity",
"ButtonEventSensorEntity",
"ButtonLockEntity",
"WakeWord1SensitivityNumberEntity",
"WakeWord2SensitivityNumberEntity",
"StopWordSensitivityNumberEntity",
+26
View File
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
from .entity import (
ButtonEventSensorEntity,
ButtonLockEntity,
ESPHomeEntity,
LEDLightEntity,
MediaPlayerEntity,
@@ -85,6 +86,7 @@ class Preferences:
active_wake_words: List[Optional[str]] = field(default_factory=list)
volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
button_controls_locked: int = 0 # 0 = buttons enabled (default), 1 = buttons locked
wake_word_1_sensitivity: Optional[float] = None
wake_word_2_sensitivity: Optional[float] = None
stop_word_sensitivity: Optional[float] = None
@@ -146,6 +148,13 @@ class ServerState:
# Survives HA reconnects so the entity is re-registered automatically.
pending_button: bool = False
# True once a peripheral sends register_button_lock. Gates creation of
# ButtonLockEntity so the HA device page only shows the "Disable button
# controls" switch when hardware that actually supports it is present.
# Survives HA reconnects so the entity is re-registered automatically.
pending_button_lock: bool = False
button_lock_entity: "Optional[ButtonLockEntity]" = None
# Optional peripheral WebSocket API (LEDs, buttons, HAT boards).
# Assigned in __main__ before the event loop starts.
peripheral_api: "Optional[Any]" = None # PeripheralAPIServer at runtime
@@ -159,6 +168,7 @@ class ServerState:
wake_words_changed: bool = False
refractory_seconds: float = 2.0
thinking_sound_enabled: bool = False
button_controls_locked: bool = False
output_only: bool = False
muted: bool = False
connected: bool = False
@@ -217,6 +227,7 @@ class ServerState:
_LOGGER.debug("Skipping save - volume unchanged")
return
previous_muted = self.volume == 0.0
self.volume = clamped_volume
self.preferences.volume = clamped_volume
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
@@ -230,6 +241,10 @@ class ServerState:
api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)})
new_muted = clamped_volume == 0.0
if previous_muted != new_muted:
api.emit_event_sync(LVAEvent.VOLUME_MUTED, {"muted": new_muted})
def persist_mic_gain(self, gain: float) -> None:
"""Persist the microphone auto gain value."""
gain_int = int(gain)
@@ -260,3 +275,14 @@ class ServerState:
self.preferences.mic_volume = volume_int
_LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path)
self.save_preferences()
def initial_stop_word_threshold(saved_sensitivity: Optional[float]) -> float:
"""
Resolve the stop word probability cutoff to start from, clamped to 0.0-1.0.
:param saved_sensitivity: Value persisted in preferences, or None if it has never been set.
"""
if saved_sensitivity is None:
return ServerState.stop_word_threshold
return max(0.0, min(1.0, float(saved_sensitivity)))
+141 -8
View File
@@ -54,6 +54,16 @@ Feedback events emitted by LVA
effect names are those the peripheral declared at
registration; e.g. "Voice Assistant" runs the pipeline
animations.
button_lock_changed data: {"locked": bool}
Fires when the user toggles the "Disable button controls"
switch on the Home Assistant device page for a peripheral
that registered it via register_button_lock. The current
value is also included as "button_controls_locked" in the
snapshot payload for newly-connecting/reconnecting clients.
A peripheral should stop executing button-triggered LVA
commands (start_listening, stop_pipeline, mute_mic/
unmute_mic, volume_up/down, button_*_press, etc.) while
locked is true.
Commands accepted from the peripheral container
------------------------------------------------
@@ -92,6 +102,17 @@ Commands accepted from the peripheral container
events to Home Assistant when the corresponding
button_* commands are sent. Send once after connecting;
duplicate registrations are ignored.
register_button_lock
The peripheral declares that it has on-board buttons whose
behavior can be toggled from Home Assistant. LVA creates a
"Disable button controls" switch entity (visible as
switch.<satellite>_disable_button_controls, disabled/off
by default) and routes HA changes back to the peripheral
as button_lock_changed events. The peripheral is
responsible for honoring the lock state — LVA does not
gate anything on its end. Send once after connecting;
duplicate registrations are ignored and preserve the
current lock state.
"""
from __future__ import annotations
@@ -99,6 +120,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
@@ -138,6 +160,7 @@ class LVAEvent(str, Enum):
VOLUME_MUTED = "volume_muted"
ZEROCONF = "zeroconf"
LIGHT_COMMAND = "light_command"
BUTTON_LOCK_CHANGED = "button_lock_changed"
class LVACommand(str, Enum):
@@ -160,6 +183,7 @@ class LVACommand(str, Enum):
BUTTON_LONG_PRESS = "button_long_press"
REGISTER_LIGHT = "register_light"
REGISTER_BUTTON = "register_button"
REGISTER_BUTTON_LOCK = "register_button_lock"
# ---------------------------------------------------------------------------
@@ -180,6 +204,8 @@ class PeripheralAPIServer:
"""
DEFAULT_VOLUME_STEP: float = 0.05
LATE_ENTITY_RECONNECT_DEBOUNCE_S: float = 1.5
LATE_ENTITY_RECONNECT_COOLDOWN_S: float = 60.0
def __init__(
self,
@@ -208,6 +234,10 @@ class PeripheralAPIServer:
self._current_state: Optional[LVAEvent] = None
self._current_state_data: Optional[Dict[str, Any]] = None
# Safeguarded HA reconnect trigger for late entity registrations.
self._pending_entity_reconnect_task: Optional[asyncio.Task] = None
self._last_ha_reconnect_at: float = 0.0
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@@ -230,6 +260,10 @@ class PeripheralAPIServer:
async def stop(self) -> None:
"""Gracefully shut down the server and all client connections."""
if self._pending_entity_reconnect_task is not None:
self._pending_entity_reconnect_task.cancel()
self._pending_entity_reconnect_task = None
if self._server is not None:
self._server.close()
await self._server.wait_closed()
@@ -267,7 +301,9 @@ class PeripheralAPIServer:
"data": {
"muted": state.muted,
"volume": round(state.volume, 3),
"volume_muted": state.volume == 0.0,
"ha_connected": state.connected,
"button_controls_locked": state.button_controls_locked,
"last_stt_text": self._last_stt_text,
"last_tts_text": self._last_tts_text,
},
@@ -281,14 +317,20 @@ class PeripheralAPIServer:
# Replay the current event state so the client immediately shows the
# right animation — e.g. a timer ticking animation when reconnecting
# mid-timer, or the muted indicator when reconnecting while muted.
if self._current_state is not None:
state_payload: Dict[str, Any] = {"event": self._current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
current_state = self._current_state
if current_state is None:
return
if current_state == LVAEvent.DISCONNECTED and state.connected:
return
state_payload: Dict[str, Any] = {"event": current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
# ------------------------------------------------------------------
# Command dispatch
@@ -463,6 +505,9 @@ class PeripheralAPIServer:
elif command == LVACommand.REGISTER_BUTTON:
self._register_button(satellite)
elif command == LVACommand.REGISTER_BUTTON_LOCK:
self._register_button_lock(satellite)
def _register_light(self, data: Dict[str, Any], satellite: Any) -> None:
"""Register a Light declared by a peripheral.
@@ -501,6 +546,8 @@ class PeripheralAPIServer:
if satellite is not None:
satellite.register_pending_lights()
self._schedule_ha_reconnect_for_late_entity("light", object_id)
def _register_button(self, satellite: Any) -> None:
"""Register button press event support declared by a peripheral.
@@ -528,10 +575,95 @@ class PeripheralAPIServer:
if satellite is not None:
satellite.register_pending_button()
self._schedule_ha_reconnect_for_late_entity("button", "button_press_event")
def _register_button_lock(self, satellite: Any) -> None:
"""Register the button-lock switch declared by a peripheral.
Idempotent: repeat registrations from a reconnecting peripheral
are a no-op — the existing entity and its current lock state
are preserved.
When the satellite is already running, the entity is materialised
immediately so a subsequent switch toggle routes correctly. HA
only sees the new entity after the integration is reloaded, but
the same startup-wait window that applies to register_light
applies here too (see --peripheral-startup-wait).
"""
state = self._state
if state is None:
return
if state.pending_button_lock:
# Already registered; keep the existing entity and its state.
return
state.pending_button_lock = True
_LOGGER.info("Button lock switch registered by peripheral")
if satellite is not None:
satellite.register_pending_button_lock()
self._schedule_ha_reconnect_for_late_entity("button_lock", "disable_button_controls")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _schedule_ha_reconnect_for_late_entity(self, entity_kind: str, entity_id: str) -> None:
"""Reconnect HA once for late peripheral entity registrations."""
state = self._state
if state is None or not state.connected or state.satellite is None:
return
if self._loop is None:
return
if self._pending_entity_reconnect_task is not None and not self._pending_entity_reconnect_task.done():
_LOGGER.debug(
"Coalescing late %s registration for '%s' into pending HA reconnect",
entity_kind,
entity_id,
)
return
async def _trigger() -> None:
try:
await asyncio.sleep(self.LATE_ENTITY_RECONNECT_DEBOUNCE_S)
state_now = self._state
if state_now is None or not state_now.connected:
return
satellite_now = state_now.satellite
if satellite_now is None:
return
now = time.monotonic()
if now - self._last_ha_reconnect_at < self.LATE_ENTITY_RECONNECT_COOLDOWN_S:
_LOGGER.info(
"Skipping HA reconnect after late %s '%s': cooldown active",
entity_kind,
entity_id,
)
return
transport = getattr(satellite_now, "_transport", None)
if transport is None:
return
self._last_ha_reconnect_at = now
_LOGGER.info(
"Late %s '%s' registered after HA handshake; forcing one HA reconnect",
entity_kind,
entity_id,
)
transport.close()
except asyncio.CancelledError:
return
self._pending_entity_reconnect_task = self._loop.create_task(_trigger())
async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
"""Reflect a peripheral-triggered mute change to Home Assistant."""
state = self._state
@@ -584,6 +716,7 @@ class PeripheralAPIServer:
LVAEvent.MEDIA_PLAYER_PLAYING,
LVAEvent.DISCONNECTED,
LVAEvent.PIPELINE_ERROR,
LVAEvent.VOLUME_MUTED,
}
if event in _STATE_EVENTS:
self._current_state = event
+64 -1
View File
@@ -52,6 +52,7 @@ from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer
from .entity import (
ButtonEventSensorEntity,
ButtonLockEntity,
LEDLightEntity,
MediaPlayerEntity,
MicSettingEntity,
@@ -335,6 +336,9 @@ class VoiceSatelliteProtocol(APIServer):
# button support before this satellite was constructed (e.g. on an HA
# reconnect while the peripheral container stayed connected to LVA).
self.register_pending_button()
# Materialise ButtonLockEntity if a peripheral already registered
# the button-lock switch before this satellite was constructed.
self.register_pending_button_lock()
# ---- Instance variables ----
@@ -431,6 +435,40 @@ class VoiceSatelliteProtocol(APIServer):
self.state.button_event_sensor_entity = entity
_LOGGER.info("Button event sensor entity materialised")
def register_pending_button_lock(self) -> None:
"""Materialise the button-lock switch once a peripheral has registered it.
Called from __init__ (handles HA reconnects where the peripheral
container stayed connected to LVA and pending_button_lock is already
True) and from PeripheralAPIServer._register_button_lock() when the
command arrives at runtime.
Safe to call multiple times: idempotent — if the entity already
exists it is only reattached to the current satellite server
instance, preserving its current lock state.
"""
if not self.state.pending_button_lock:
return
if self.state.button_lock_entity is not None:
self.state.button_lock_entity.server = self
if self.state.button_lock_entity not in self.state.entities:
self.state.entities.append(self.state.button_lock_entity)
return
entity = ButtonLockEntity(
server=self,
key=len(self.state.entities),
name="Disable button controls",
object_id="disable_button_controls",
get_locked=lambda: self.state.button_controls_locked,
set_locked=self._set_button_controls_locked,
)
self.state.entities.append(entity)
self.state.button_lock_entity = entity
entity.sync_with_state()
_LOGGER.info("Button lock switch entity materialised")
def _on_led_light_changed(self, object_id: str) -> None:
"""Forward an HA Light entity change to peripherals as light_command.
@@ -442,6 +480,10 @@ class VoiceSatelliteProtocol(APIServer):
return
self._emit(LVAEvent.LIGHT_COMMAND, entity.state_dict())
def _on_button_lock_changed(self, is_locked: bool) -> None:
"""Forward a button-lock switch change to peripherals as button_lock_changed."""
self._emit(LVAEvent.BUTTON_LOCK_CHANGED, {"locked": is_locked})
# ------------------------------------------------------------------
# Mute / thinking sound
# ------------------------------------------------------------------
@@ -457,6 +499,21 @@ class VoiceSatelliteProtocol(APIServer):
pass
self.state.save_preferences()
def _set_button_controls_locked(self, new_state: bool) -> None:
self.state.button_controls_locked = bool(new_state)
self.state.preferences.button_controls_locked = 1 if self.state.button_controls_locked else 0
self.state.save_preferences()
if self.state.button_controls_locked:
_LOGGER.debug("On-board button controls locked")
else:
_LOGGER.debug("On-board button controls unlocked")
if self.state.button_lock_entity is not None:
self.state.button_lock_entity.sync_with_state()
self._emit(LVAEvent.BUTTON_LOCK_CHANGED, {"locked": self.state.button_controls_locked})
def _set_sensitivity_1(self, new_value: float) -> None:
self.state.wake_word_1_threshold = float(new_value)
self.state.preferences.wake_word_1_sensitivity = float(new_value)
@@ -507,7 +564,7 @@ class VoiceSatelliteProtocol(APIServer):
# ------------------------------------------------------------------
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)
_LOGGER.info("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
self._tts_url = data.get("url")
@@ -599,6 +656,12 @@ class VoiceSatelliteProtocol(APIServer):
self._emit(LVAEvent.TIMER_UPDATED, timer_data)
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED:
# Report the cancel as a zeroed update before going idle. Every
# shipped peripheral already reads `seconds_left` from
# `timer_updated`, and none of them treats zero as "ringing",
# that is `timer_ringing`, so the countdown clears without any
# peripheral having to learn a new event.
self._emit(LVAEvent.TIMER_UPDATED, {**timer_data, "seconds_left": 0})
self._emit(LVAEvent.IDLE)
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
+45 -7
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
import argparse
import subprocess
import venv
import os
import shutil
import sys
import ctypes.util
from pathlib import Path
_DIR = Path(__file__).parent
@@ -15,10 +17,47 @@ parser.add_argument("--cxxflags", type=str, help="CXXFLAGS for compilation (defa
parser.add_argument("--makeflags", type=str, help="MAKEFLAGS for compilation (default: -j1)")
args = parser.parse_args()
# Find Python 3.11+ interpreter
def find_python_311():
for candidate in ["python3.13", "python3.12", "python3.11"]:
path = shutil.which(candidate)
if path:
result = subprocess.run([path, "--version"], capture_output=True, text=True)
version_str = result.stdout.strip() or result.stderr.strip()
ver_parts = version_str.replace("Python ", "").split(".")
if len(ver_parts) >= 2 and int(ver_parts[0]) == 3 and int(ver_parts[1]) >= 11:
return path
return None
python_311_exe = find_python_311()
if not python_311_exe:
print("ERROR: Python 3.11+ is required but not found on this system.")
print(" Ubuntu/Debian: sudo apt install python3.11 python3.11-venv python3.11-dev")
print(" macOS: brew install python@3.11")
sys.exit(1)
print(f"Using Python: {python_311_exe}")
subprocess.run([python_311_exe, "--version"])
# Check system dependencies (warning only, CI installs them separately)
missing = []
try:
if not ctypes.util.find_library("mpv"):
missing.append("libmpv-dev")
if not ctypes.util.find_library("asound"):
missing.append("libasound2-dev")
except Exception:
pass # Ignore errors in environments without ctypes
if missing:
print(f"\nWARNING: Missing system libraries (required for audio): {', '.join(missing)}")
print("For Docker/local dev: sudo apt install libmpv-dev pulseaudio pipewire alsa-utils")
# Create virtual environment
builder = venv.EnvBuilder(with_pip=True)
context = builder.ensure_directories(_VENV_DIR)
builder.create(_VENV_DIR)
if _VENV_DIR.exists():
shutil.rmtree(_VENV_DIR)
subprocess.check_call([python_311_exe, "-m", "venv", str(_VENV_DIR), "--symlinks"])
# Set environment variables
env = {}
@@ -28,17 +67,16 @@ if args.makeflags:
env["MAKEFLAGS"] = args.makeflags
# Upgrade dependencies
pip = [context.env_exe, "-m", "pip"]
pip = [str(_VENV_DIR / "bin" / "python"), "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
# Install requirements
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
# Install dev requirements
if args.dev:
subprocess.check_call(
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"],
env={**dict(os.environ), **env}
)
)
+39 -1
View File
@@ -1,7 +1,7 @@
"""Unit tests for shared models."""
import json
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
@@ -192,6 +192,17 @@ class TestPersistVolume:
state.persist_volume(1.0)
assert state.volume == 1.0
def test_emits_volume_muted_event_when_volume_zero(self, tmp_path):
from linux_voice_assistant.peripheral_api import LVAEvent
state = make_server_state(tmp_path)
state.peripheral_api = Mock()
state.persist_volume(0.0)
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_CHANGED, {"volume": 0.0})
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_MUTED, {"muted": True})
# ---------------------------------------------------------------------------
# ServerState.persist_mic_gain()
@@ -271,3 +282,30 @@ class TestPersistMicNoise:
with patch.object(state, "save_preferences") as mock_save:
state.persist_mic_noise(4.0)
mock_save.assert_called_once()
# ---------------------------------------------------------------------------
# initial_stop_word_threshold()
# ---------------------------------------------------------------------------
class TestInitialStopWordThreshold:
def test_saved_sensitivity_is_restored(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(0.9) == pytest.approx(0.9)
def test_falls_back_to_server_state_default_when_never_set(self):
from linux_voice_assistant.models import ServerState, initial_stop_word_threshold
assert initial_stop_word_threshold(None) == pytest.approx(ServerState.stop_word_threshold)
def test_clamped_to_one_when_above(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(1.5) == pytest.approx(1.0)
def test_clamped_to_zero_when_below(self):
from linux_voice_assistant.models import initial_stop_word_threshold
assert initial_stop_word_threshold(-0.5) == pytest.approx(0.0)
+915
View File
@@ -0,0 +1,915 @@
"""Unit tests for the peripheral WebSocket API (PeripheralAPIServer)."""
import asyncio
import json
from typing import Any, Dict, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from linux_voice_assistant.peripheral_api import LVACommand, LVAEvent, PeripheralAPIServer
from tests.unit.conftest import make_state
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_server(state=None) -> PeripheralAPIServer:
"""Return a PeripheralAPIServer, optionally attached to a ServerState."""
server = PeripheralAPIServer()
if state is not None:
server.set_state(state)
return server
def make_media_entity(key: int = 1, volume: float = 0.5, muted: bool = False):
entity = MagicMock()
entity.key = key
entity.volume = volume
entity.muted = muted
entity.previous_volume = volume
return entity
async def dispatch(server: PeripheralAPIServer, command: str, data: Optional[dict] = None) -> None:
payload: Dict[str, Any] = {"command": command}
if data is not None:
payload["data"] = data
await server._dispatch_command(json.dumps(payload)) # pylint: disable=protected-access
# ---------------------------------------------------------------------------
# __init__ / set_state
# ---------------------------------------------------------------------------
class TestInit:
def test_default_host_and_port(self):
server = PeripheralAPIServer()
assert server._host == "0.0.0.0"
assert server._port == 6055
def test_custom_host_and_port(self):
server = PeripheralAPIServer(host="127.0.0.1", port=9999)
assert server._host == "127.0.0.1"
assert server._port == 9999
def test_default_volume_step(self):
server = PeripheralAPIServer()
assert server._volume_step == PeripheralAPIServer.DEFAULT_VOLUME_STEP
def test_custom_volume_step(self):
server = PeripheralAPIServer(volume_step=0.1)
assert server._volume_step == pytest.approx(0.1)
def test_no_clients_on_init(self):
server = PeripheralAPIServer()
assert server._clients == set()
def test_no_state_on_init(self):
server = PeripheralAPIServer()
assert server._state is None
def test_no_current_state_on_init(self):
server = PeripheralAPIServer()
assert server._current_state is None
class TestSetState:
def test_attaches_state(self, tmp_path):
state = make_state(tmp_path)
server = make_server()
server.set_state(state)
assert server._state is state
# ---------------------------------------------------------------------------
# start() / stop()
# ---------------------------------------------------------------------------
class TestStartStop:
@pytest.mark.asyncio
async def test_start_creates_server(self):
server = make_server()
fake_server = MagicMock()
with patch("websockets.server.serve", AsyncMock(return_value=fake_server)) as mock_serve:
await server.start()
mock_serve.assert_called_once()
assert server._server is fake_server
@pytest.mark.asyncio
async def test_start_uses_configured_host_and_port(self):
server = make_server()
server._host = "127.0.0.1"
server._port = 1234
with patch("websockets.server.serve", AsyncMock(return_value=MagicMock())) as mock_serve:
await server.start()
_, args, kwargs = mock_serve.mock_calls[0]
assert "127.0.0.1" in args or kwargs.get("host") == "127.0.0.1"
@pytest.mark.asyncio
async def test_start_sets_loop(self):
server = make_server()
with patch("websockets.server.serve", AsyncMock(return_value=MagicMock())):
await server.start()
assert server._loop is asyncio.get_running_loop()
@pytest.mark.asyncio
async def test_stop_closes_server(self):
server = make_server()
fake_server = MagicMock()
fake_server.wait_closed = AsyncMock()
server._server = fake_server
await server.stop()
fake_server.close.assert_called_once()
fake_server.wait_closed.assert_awaited_once()
@pytest.mark.asyncio
async def test_stop_without_server_does_not_raise(self):
server = make_server()
await server.stop() # should not raise
@pytest.mark.asyncio
async def test_stop_cancels_pending_reconnect_task(self):
server = make_server()
pending_task = MagicMock()
server._pending_entity_reconnect_task = pending_task
await server.stop()
pending_task.cancel.assert_called_once()
assert server._pending_entity_reconnect_task is None
# ---------------------------------------------------------------------------
# _send_snapshot()
# ---------------------------------------------------------------------------
class TestSendSnapshot:
@pytest.mark.asyncio
async def test_no_state_does_nothing(self):
server = make_server()
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
websocket.send.assert_not_called()
@pytest.mark.asyncio
async def test_sends_snapshot_payload(self, tmp_path):
state = make_state(tmp_path, volume=0.75, muted=False, connected=True)
server = make_server(state)
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
sent = json.loads(websocket.send.call_args_list[0].args[0])
assert sent["event"] == "snapshot"
assert sent["data"]["volume"] == pytest.approx(0.75)
assert sent["data"]["muted"] is False
assert sent["data"]["ha_connected"] is True
@pytest.mark.asyncio
async def test_snapshot_includes_last_conversation_text(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._last_stt_text = "turn on the lights" # pylint: disable=protected-access
server._last_tts_text = "OK" # pylint: disable=protected-access
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
sent = json.loads(websocket.send.call_args_list[0].args[0])
assert sent["data"]["last_stt_text"] == "turn on the lights"
assert sent["data"]["last_tts_text"] == "OK"
@pytest.mark.asyncio
async def test_no_current_state_skips_replay(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
assert websocket.send.await_count == 1
@pytest.mark.asyncio
async def test_replays_current_state(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._current_state = LVAEvent.THINKING # pylint: disable=protected-access
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
assert websocket.send.await_count == 2
replayed = json.loads(websocket.send.call_args_list[1].args[0])
assert replayed["event"] == "thinking"
@pytest.mark.asyncio
async def test_replays_current_state_with_data(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._current_state = LVAEvent.TIMER_TICKING # pylint: disable=protected-access
server._current_state_data = {"id": "1", "seconds_left": 30} # pylint: disable=protected-access
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
replayed = json.loads(websocket.send.call_args_list[1].args[0])
assert replayed["data"]["seconds_left"] == 30
@pytest.mark.asyncio
async def test_skips_replay_of_disconnected_when_connected(self, tmp_path):
state = make_state(tmp_path, connected=True)
server = make_server(state)
server._current_state = LVAEvent.DISCONNECTED # pylint: disable=protected-access
websocket = AsyncMock()
await server._send_snapshot(websocket) # pylint: disable=protected-access
# Only the snapshot itself should have been sent
assert websocket.send.await_count == 1
# ---------------------------------------------------------------------------
# _dispatch_command() — basic parsing
# ---------------------------------------------------------------------------
class TestDispatchCommandParsing:
@pytest.mark.asyncio
async def test_invalid_json_does_not_raise(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await server._dispatch_command("not json") # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_missing_command_is_noop(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await server._dispatch_command(json.dumps({})) # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_no_state_attached_is_noop(self):
server = make_server()
await dispatch(server, LVACommand.START_LISTENING.value) # should not raise
# ---------------------------------------------------------------------------
# _dispatch_command() — voice pipeline
# ---------------------------------------------------------------------------
class TestDispatchStartListening:
@pytest.mark.asyncio
async def test_calls_satellite_start_listening(self, tmp_path):
state = make_state(tmp_path, muted=False)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.START_LISTENING.value)
satellite.start_listening.assert_called_once()
@pytest.mark.asyncio
async def test_noop_when_muted(self, tmp_path):
state = make_state(tmp_path, muted=True)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.START_LISTENING.value)
satellite.start_listening.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_no_satellite(self, tmp_path):
state = make_state(tmp_path, muted=False)
state.satellite = None
server = make_server(state)
await dispatch(server, LVACommand.START_LISTENING.value) # should not raise
class TestDispatchStopPipeline:
@pytest.mark.asyncio
async def test_calls_satellite_stop(self, tmp_path):
state = make_state(tmp_path)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.STOP_PIPELINE.value)
satellite.stop.assert_called_once()
@pytest.mark.asyncio
async def test_noop_when_no_satellite(self, tmp_path):
state = make_state(tmp_path)
state.satellite = None
server = make_server(state)
await dispatch(server, LVACommand.STOP_PIPELINE.value) # should not raise
# ---------------------------------------------------------------------------
# _dispatch_command() — microphone mute
# ---------------------------------------------------------------------------
class TestDispatchMute:
@pytest.mark.asyncio
async def test_mute_mic_calls_set_muted(self, tmp_path):
state = make_state(tmp_path, muted=False)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.MUTE_MIC.value)
satellite._set_muted.assert_called_once_with(True) # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_mute_mic_noop_when_already_muted(self, tmp_path):
state = make_state(tmp_path, muted=True)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.MUTE_MIC.value)
satellite._set_muted.assert_not_called() # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_unmute_mic_calls_set_muted(self, tmp_path):
state = make_state(tmp_path, muted=True)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.UNMUTE_MIC.value)
satellite._set_muted.assert_called_once_with(False) # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_unmute_mic_noop_when_already_unmuted(self, tmp_path):
state = make_state(tmp_path, muted=False)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.UNMUTE_MIC.value)
satellite._set_muted.assert_not_called() # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_mute_mic_pushes_switch_state_to_ha(self, tmp_path):
state = make_state(tmp_path, muted=False)
state.mute_switch_entity = MagicMock(key=1)
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.MUTE_MIC.value)
satellite.send_messages.assert_called_once()
# ---------------------------------------------------------------------------
# _dispatch_command() — volume
# ---------------------------------------------------------------------------
class TestDispatchVolume:
@pytest.mark.asyncio
async def test_volume_up_increases_volume(self, tmp_path):
state = make_state(tmp_path, volume=0.5)
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_UP.value)
assert state.volume == pytest.approx(0.5 + PeripheralAPIServer.DEFAULT_VOLUME_STEP)
@pytest.mark.asyncio
async def test_volume_down_decreases_volume(self, tmp_path):
state = make_state(tmp_path, volume=0.5)
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_DOWN.value)
assert state.volume == pytest.approx(0.5 - PeripheralAPIServer.DEFAULT_VOLUME_STEP)
@pytest.mark.asyncio
async def test_volume_up_clamped_at_one(self, tmp_path):
state = make_state(tmp_path, volume=1.0)
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_UP.value)
assert state.volume == pytest.approx(1.0)
@pytest.mark.asyncio
async def test_volume_down_clamped_at_zero(self, tmp_path):
state = make_state(tmp_path, volume=0.0)
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_DOWN.value)
assert state.volume == pytest.approx(0.0)
@pytest.mark.asyncio
async def test_volume_up_updates_music_and_tts_players(self, tmp_path):
state = make_state(tmp_path, volume=0.5)
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_UP.value)
state.music_player.set_volume.assert_called_once()
state.tts_player.set_volume.assert_called_once()
@pytest.mark.asyncio
async def test_volume_up_pushes_media_player_state(self, tmp_path):
state = make_state(tmp_path, volume=0.5)
state.media_player_entity = make_media_entity()
satellite = MagicMock()
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.VOLUME_UP.value)
satellite.send_messages.assert_called_once()
@pytest.mark.asyncio
async def test_set_volume_sets_exact_value(self, tmp_path):
state = make_state(tmp_path, volume=0.2)
server = make_server(state)
await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 0.9})
assert state.volume == pytest.approx(0.9)
@pytest.mark.asyncio
async def test_set_volume_clamps_above_one(self, tmp_path):
state = make_state(tmp_path, volume=0.2)
server = make_server(state)
await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 5.0})
assert state.volume == pytest.approx(1.0)
@pytest.mark.asyncio
async def test_set_volume_clamps_below_zero(self, tmp_path):
state = make_state(tmp_path, volume=0.2)
server = make_server(state)
await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": -5.0})
assert state.volume == pytest.approx(0.0)
@pytest.mark.asyncio
async def test_set_volume_ignores_non_numeric(self, tmp_path):
state = make_state(tmp_path, volume=0.2)
server = make_server(state)
await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": "loud"})
assert state.volume == pytest.approx(0.2)
@pytest.mark.asyncio
async def test_set_volume_accepts_int(self, tmp_path):
state = make_state(tmp_path, volume=0.2)
server = make_server(state)
await dispatch(server, LVACommand.SET_VOLUME.value, {"volume": 1})
assert state.volume == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# _dispatch_command() — timer
# ---------------------------------------------------------------------------
class TestDispatchStopTimerRinging:
@pytest.mark.asyncio
async def test_stops_ringing_timer(self, tmp_path):
state = make_state(tmp_path)
satellite = MagicMock()
satellite._timer_finished = True # pylint: disable=protected-access
state.satellite = satellite
state.active_wake_words.add(state.stop_word.id)
server = make_server(state)
await dispatch(server, LVACommand.STOP_TIMER_RINGING.value)
assert satellite._timer_finished is False # pylint: disable=protected-access
assert state.stop_word.id not in state.active_wake_words
state.tts_player.stop.assert_called_once()
satellite.unduck.assert_called_once()
@pytest.mark.asyncio
async def test_noop_when_not_ringing(self, tmp_path):
state = make_state(tmp_path)
satellite = MagicMock()
satellite._timer_finished = False # pylint: disable=protected-access
state.satellite = satellite
server = make_server(state)
await dispatch(server, LVACommand.STOP_TIMER_RINGING.value)
state.tts_player.stop.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_no_satellite(self, tmp_path):
state = make_state(tmp_path)
state.satellite = None
server = make_server(state)
await dispatch(server, LVACommand.STOP_TIMER_RINGING.value) # should not raise
# ---------------------------------------------------------------------------
# _dispatch_command() — media player
# ---------------------------------------------------------------------------
class TestDispatchMediaPlayer:
@pytest.mark.asyncio
async def test_stop_media_player_stops_music(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await dispatch(server, LVACommand.STOP_MEDIA_PLAYER.value)
state.music_player.stop.assert_called_once()
@pytest.mark.asyncio
async def test_pause_media_player_pauses_music(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await dispatch(server, LVACommand.PAUSE_MEDIA_PLAYER.value)
state.music_player.pause.assert_called_once()
@pytest.mark.asyncio
async def test_resume_media_player_resumes_music(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await dispatch(server, LVACommand.RESUME_MEDIA_PLAYER.value)
state.music_player.resume.assert_called_once()
@pytest.mark.asyncio
async def test_stop_media_player_updates_entity_state(self, tmp_path):
from aioesphomeapi.model import MediaPlayerState # pylint: disable=import-outside-toplevel
state = make_state(tmp_path)
state.media_player_entity = make_media_entity()
server = make_server(state)
await dispatch(server, LVACommand.STOP_MEDIA_PLAYER.value)
assert state.media_player_entity.state == MediaPlayerState.IDLE
# ---------------------------------------------------------------------------
# _dispatch_command() — button events
# ---------------------------------------------------------------------------
class TestDispatchButtonPress:
@pytest.mark.asyncio
async def test_single_press_updates_entity(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = MagicMock()
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value)
state.button_event_sensor_entity.update_state.assert_called_once_with("single_press")
@pytest.mark.asyncio
async def test_single_press_does_not_play_sound(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = MagicMock()
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value)
state.tts_player.play.assert_not_called()
@pytest.mark.asyncio
async def test_double_press_plays_sound_and_updates_entity(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = MagicMock()
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_DOUBLE_PRESS.value)
state.tts_player.play.assert_called_once_with(state.button_double_press_sound)
state.button_event_sensor_entity.update_state.assert_called_once_with("double_press")
@pytest.mark.asyncio
async def test_triple_press_plays_sound_and_updates_entity(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = MagicMock()
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_TRIPLE_PRESS.value)
state.tts_player.play.assert_called_once_with(state.button_triple_press_sound)
state.button_event_sensor_entity.update_state.assert_called_once_with("triple_press")
@pytest.mark.asyncio
async def test_long_press_plays_sound_and_updates_entity(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = MagicMock()
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_LONG_PRESS.value)
state.tts_player.play.assert_called_once_with(state.button_long_press_sound)
state.button_event_sensor_entity.update_state.assert_called_once_with("long_press")
@pytest.mark.asyncio
async def test_button_press_noop_when_entity_not_registered(self, tmp_path):
state = make_state(tmp_path)
state.button_event_sensor_entity = None
server = make_server(state)
await dispatch(server, LVACommand.BUTTON_SINGLE_PRESS.value) # should not raise
# ---------------------------------------------------------------------------
# register_light() / register_button()
# ---------------------------------------------------------------------------
class TestRegisterLight:
def test_missing_object_id_is_ignored(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._register_light({}, None) # pylint: disable=protected-access
assert state.pending_lights == []
def test_adds_light_registration(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._register_light( # pylint: disable=protected-access
{"name": "LEDs", "object_id": "leds", "effects": ["Voice Assistant"]},
None,
)
assert len(state.pending_lights) == 1
assert state.pending_lights[0].object_id == "leds"
def test_duplicate_object_id_is_ignored(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
data = {"name": "LEDs", "object_id": "leds"}
server._register_light(data, None) # pylint: disable=protected-access
server._register_light(data, None) # pylint: disable=protected-access
assert len(state.pending_lights) == 1
def test_calls_register_pending_lights_on_satellite(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
satellite = MagicMock()
server._register_light({"object_id": "leds"}, satellite) # pylint: disable=protected-access
satellite.register_pending_lights.assert_called_once()
def test_no_state_is_noop(self):
server = make_server()
server._register_light({"object_id": "leds"}, None) # pylint: disable=protected-access # should not raise
class TestRegisterButton:
def test_sets_pending_button(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
server._register_button(None) # pylint: disable=protected-access
assert state.pending_button is True
def test_idempotent_when_already_registered(self, tmp_path):
state = make_state(tmp_path)
state.pending_button = True
server = make_server(state)
satellite = MagicMock()
server._register_button(satellite) # pylint: disable=protected-access
satellite.register_pending_button.assert_not_called()
def test_calls_register_pending_button_on_satellite(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
satellite = MagicMock()
server._register_button(satellite) # pylint: disable=protected-access
satellite.register_pending_button.assert_called_once()
def test_no_state_is_noop(self):
server = make_server()
server._register_button(None) # pylint: disable=protected-access # should not raise
# ---------------------------------------------------------------------------
# _dispatch_command() — register_light / register_button routing
# ---------------------------------------------------------------------------
class TestDispatchRegistration:
@pytest.mark.asyncio
async def test_register_light_command_routes_correctly(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await dispatch(server, LVACommand.REGISTER_LIGHT.value, {"object_id": "leds"})
assert len(state.pending_lights) == 1
@pytest.mark.asyncio
async def test_register_button_command_routes_correctly(self, tmp_path):
state = make_state(tmp_path)
server = make_server(state)
await dispatch(server, LVACommand.REGISTER_BUTTON.value)
assert state.pending_button is True
# ---------------------------------------------------------------------------
# emit_event()
# ---------------------------------------------------------------------------
class TestEmitEvent:
@pytest.mark.asyncio
async def test_broadcasts_to_all_clients(self):
server = make_server()
ws1, ws2 = AsyncMock(), AsyncMock()
server._clients = {ws1, ws2} # pylint: disable=protected-access
await server.emit_event(LVAEvent.IDLE)
ws1.send.assert_awaited_once()
ws2.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_clients_does_not_raise(self):
server = make_server()
await server.emit_event(LVAEvent.IDLE) # should not raise
@pytest.mark.asyncio
async def test_payload_includes_data(self):
server = make_server()
ws = AsyncMock()
server._clients = {ws} # pylint: disable=protected-access
await server.emit_event(LVAEvent.STT_TEXT, {"text": "hello"})
sent = json.loads(ws.send.call_args.args[0])
assert sent["data"]["text"] == "hello"
@pytest.mark.asyncio
async def test_dead_client_is_removed(self):
server = make_server()
dead_ws = AsyncMock()
dead_ws.send.side_effect = ConnectionError
alive_ws = AsyncMock()
server._clients = {dead_ws, alive_ws} # pylint: disable=protected-access
await server.emit_event(LVAEvent.IDLE)
assert dead_ws not in server._clients # pylint: disable=protected-access
assert alive_ws in server._clients # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_caches_last_stt_text(self):
server = make_server()
await server.emit_event(LVAEvent.STT_TEXT, {"text": "turn on the lights"})
assert server._last_stt_text == "turn on the lights" # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_caches_last_tts_text(self):
server = make_server()
await server.emit_event(LVAEvent.TTS_TEXT, {"text": "OK"})
assert server._last_tts_text == "OK" # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_listening_clears_cached_text(self):
server = make_server()
server._last_stt_text = "old" # pylint: disable=protected-access
server._last_tts_text = "old" # pylint: disable=protected-access
await server.emit_event(LVAEvent.LISTENING)
assert server._last_stt_text is None # pylint: disable=protected-access
assert server._last_tts_text is None # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_tracks_current_state_for_state_events(self):
server = make_server()
await server.emit_event(LVAEvent.TTS_SPEAKING)
assert server._current_state == LVAEvent.TTS_SPEAKING # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_does_not_track_transient_events(self):
server = make_server()
await server.emit_event(LVAEvent.STT_TEXT, {"text": "hi"})
assert server._current_state is None # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_timer_updated_refreshes_ticking_data(self):
server = make_server()
await server.emit_event(LVAEvent.TIMER_TICKING, {"seconds_left": 30})
await server.emit_event(LVAEvent.TIMER_UPDATED, {"seconds_left": 25})
assert server._current_state == LVAEvent.TIMER_TICKING # pylint: disable=protected-access
assert server._current_state_data["seconds_left"] == 25 # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_timer_updated_ignored_when_not_ticking(self):
server = make_server()
await server.emit_event(LVAEvent.IDLE)
await server.emit_event(LVAEvent.TIMER_UPDATED, {"seconds_left": 25})
assert server._current_state == LVAEvent.IDLE # pylint: disable=protected-access
# ---------------------------------------------------------------------------
# emit_event_sync()
# ---------------------------------------------------------------------------
class TestEmitEventSync:
def test_noop_when_no_loop(self):
server = make_server()
server.emit_event_sync(LVAEvent.IDLE) # should not raise
def test_noop_when_loop_closed(self):
server = make_server()
loop = MagicMock()
loop.is_closed.return_value = True
server._loop = loop # pylint: disable=protected-access
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
server.emit_event_sync(LVAEvent.IDLE)
mock_run.assert_not_called()
def test_schedules_coroutine_on_loop(self):
server = make_server()
loop = MagicMock()
loop.is_closed.return_value = False
server._loop = loop # pylint: disable=protected-access
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
server.emit_event_sync(LVAEvent.IDLE, {"foo": "bar"})
mock_run.assert_called_once()
assert mock_run.call_args.args[1] is loop
# ---------------------------------------------------------------------------
# _create_media_player_response()
# ---------------------------------------------------------------------------
class TestCreateMediaPlayerResponse:
def test_returns_response_with_entity_fields(self, tmp_path):
from aioesphomeapi.model import MediaPlayerState # pylint: disable=import-outside-toplevel
state = make_state(tmp_path)
state.media_player_entity = make_media_entity(key=3, volume=0.4, muted=True)
server = make_server(state)
response = server._create_media_player_response(MediaPlayerState.PLAYING) # pylint: disable=protected-access
assert response.key == 3
assert response.volume == pytest.approx(0.4)
assert response.muted is True
assert response.state == MediaPlayerState.PLAYING
+66
View File
@@ -367,3 +367,69 @@ class TestConnectionLost:
sat = make_satellite(tmp_path)
sat.connection_lost(None)
sat.state.tts_player.stop.assert_called()
# ---------------------------------------------------------------------------
# Timer events
# ---------------------------------------------------------------------------
def _timer_msg(seconds_left=60, total_seconds=300, timer_id="t1", name="pasta"):
msg = MagicMock()
msg.timer_id = timer_id
msg.name = name
msg.total_seconds = total_seconds
msg.seconds_left = seconds_left
return msg
class TestHandleTimerEvent:
def _sat(self, tmp_path):
sat = make_satellite(tmp_path)
sat.state.peripheral_api = MagicMock()
return sat
def _emitted(self, sat):
return [c.args for c in sat.state.peripheral_api.emit_event_sync.call_args_list]
def test_started_emits_ticking_with_data(self, tmp_path):
from aioesphomeapi.model import VoiceAssistantTimerEventType
from linux_voice_assistant.peripheral_api import LVAEvent
sat = self._sat(tmp_path)
sat.handle_timer_event(VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED, _timer_msg())
event, data = self._emitted(sat)[0]
assert event == LVAEvent.TIMER_TICKING
assert data["seconds_left"] == 60
def test_cancelled_zeroes_the_countdown(self, tmp_path):
from aioesphomeapi.model import VoiceAssistantTimerEventType
from linux_voice_assistant.peripheral_api import LVAEvent
sat = self._sat(tmp_path)
sat.handle_timer_event(
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED,
_timer_msg(seconds_left=42),
)
emitted = self._emitted(sat)
assert [e for e, *_ in emitted] == [LVAEvent.TIMER_UPDATED, LVAEvent.IDLE]
_, data = emitted[0]
assert data["seconds_left"] == 0
# The identity is preserved, so a peripheral tracking several timers
# knows which one went away.
assert data["id"] == "t1"
assert data["total_seconds"] == 300
# Peripherals that re-check a running timer on `idle` must see the zeroed
# update first, or they stay in the countdown state.
def test_cancelled_still_ends_with_idle(self, tmp_path):
from aioesphomeapi.model import VoiceAssistantTimerEventType
from linux_voice_assistant.peripheral_api import LVAEvent
sat = self._sat(tmp_path)
sat.handle_timer_event(VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED, _timer_msg())
assert self._emitted(sat)[-1][0] == LVAEvent.IDLE