Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8de2237e | ||
|
|
b49fd8dc65 | ||
|
|
f20952f260 | ||
|
|
7c6fbaa4ee | ||
|
|
99ee08f7b1 | ||
|
|
cfae3c55e2 | ||
|
|
b0c53c41c1 | ||
|
|
19f85b95a3 | ||
|
|
adcef575b7 | ||
|
|
d1f5761f75 | ||
|
|
a44292654e | ||
|
|
8303a96279 | ||
|
|
e334aa3fb1 | ||
|
|
1543c5df58 | ||
|
|
43a183a232 | ||
|
|
a2d0a4e68e | ||
|
|
b94a5a4663 | ||
|
|
2b0388f454 | ||
|
|
0eafd4f7cd | ||
|
|
8a36a74bda | ||
|
|
bf3bf0d942 | ||
|
|
acda765964 | ||
|
|
3077e7230b | ||
|
|
ea991e026f | ||
|
|
69a4fd3098 | ||
|
|
126bdf1862 | ||
|
|
3b33730105 | ||
|
|
ba3a4054ca | ||
|
|
0f57ca989c | ||
|
|
ba7d834d1f | ||
|
|
2eb29470f9 | ||
|
|
2378187197 | ||
|
|
d3874f6a2f | ||
|
|
201896196f | ||
|
|
8f788eeff0 | ||
|
|
305b787dd6 | ||
|
|
e14844a113 | ||
|
|
9ed97cd8f4 | ||
|
|
e8042ee19d | ||
|
|
802908cb8f | ||
|
|
c37b482b87 | ||
|
|
486ff4bb53 | ||
|
|
a66c0e4f28 | ||
|
|
9b2a8b4b30 | ||
|
|
4a0695ab57 | ||
|
|
2c9c1d2b8a | ||
|
|
d811ae75d4 | ||
|
|
2ccb1fb597 | ||
|
|
2e6c726bd8 | ||
|
|
2d460b6728 | ||
|
|
e1770d071c | ||
|
|
49b0937a13 | ||
|
|
19c6322c28 | ||
|
|
eb2d3b21b1 | ||
|
|
479c1d1a97 | ||
|
|
a9f0af1287 | ||
|
|
fc66bfbb06 | ||
|
|
1d84fa0898 | ||
|
|
eb29a00386 | ||
|
|
06f7256ebf | ||
|
|
17e3ee061b | ||
|
|
fc2780d92e |
No files matched your search
@@ -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"
|
||||
@@ -54,9 +57,31 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant:
|
||||
# MIC_NOISE_SUPPRESSION="0"
|
||||
|
||||
### Number of mic channels to stream
|
||||
# AUDIO_INPUT_CHANNELS=2
|
||||
|
||||
### Enable thinking sound (optional):
|
||||
# ENABLE_THINKING_SOUND="1"
|
||||
|
||||
### Start listening during wake sound (optional):
|
||||
# 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"
|
||||
@@ -73,6 +98,9 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
### Refactory seconds (optional):
|
||||
# REFACTORY_SECONDS="2"
|
||||
|
||||
### Delay before mic opens for continued conversation (optional):
|
||||
# CONTINUE_CONVERSATION_DELAY="0.5"
|
||||
|
||||
### Sound files (optional):
|
||||
# path for custom files in docker is for example "sounds/custom/your_soundfile.flac"
|
||||
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
custom: https://www.openhomefoundation.org
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+" # For releases, e.g. v1.2.0
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+" # For prereleases, e.g. v1.3.0-alpha1
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: Pytest Unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev mpv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
./script/setup --dev
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./script/tests
|
||||
@@ -23,4 +23,4 @@ venv/
|
||||
.env
|
||||
dev.env
|
||||
version.txt
|
||||
version_githash.txt
|
||||
version_githash.txt
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
yarn.lock
|
||||
worktrees
|
||||
agent-manager.json
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"kilo.kilocode"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,3 +1,130 @@
|
||||
<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
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Exciting New Features 🎉
|
||||
* Enable dual audio input channel by [@omaramin-2000](https://github.com/omaramin-2000) in [#334](https://github.com/OHF-Voice/linux-voice-assistant/pull/334)
|
||||
* Add delay to continue conversation before listening by [@omaramin-2000](https://github.com/omaramin-2000) in [#342](https://github.com/OHF-Voice/linux-voice-assistant/pull/342)
|
||||
### Other Changes
|
||||
* Revise Assist Satellite installation instructions by [@Hedda](https://github.com/Hedda) in [#338](https://github.com/OHF-Voice/linux-voice-assistant/pull/338)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.11...v1.1.12
|
||||
|
||||
[Changes][v1.1.12]
|
||||
|
||||
|
||||
<a id="v1.1.11"></a>
|
||||
# [v1.1.11](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.11) - 2026-06-11
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Other Changes
|
||||
* docs(readme): restructure requirements and hardware section by [@florian-asche](https://github.com/florian-asche) in [#321](https://github.com/OHF-Voice/linux-voice-assistant/pull/321)
|
||||
* App for HAOS by [@omaramin-2000](https://github.com/omaramin-2000) in [#325](https://github.com/OHF-Voice/linux-voice-assistant/pull/325)
|
||||
* Add volume arg to parser by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#335](https://github.com/OHF-Voice/linux-voice-assistant/pull/335)
|
||||
* Enhance wake word documentation by [@omaramin-2000](https://github.com/omaramin-2000) in [#336](https://github.com/OHF-Voice/linux-voice-assistant/pull/336)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
|
||||
|
||||
[Changes][v1.1.11]
|
||||
|
||||
|
||||
<a id="v1.1.10"></a>
|
||||
# [v1.1.10](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.10) - 2026-04-23
|
||||
|
||||
@@ -263,6 +390,11 @@ 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
|
||||
[v1.1.9]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
|
||||
[v1.1.8]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim-trixie
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -19,10 +19,11 @@ 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
|
||||
- Tested and works with Python 3.11 and Python 3.12.
|
||||
- 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)
|
||||
- Supports [Websocket API](./docs/peripheral_api.md) for peripherals (e.g. buttons, LEDs, etc.) to integrate with the voice assistant
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -49,7 +50,12 @@ Alternatively if on a lower budget then suggest could use other microphone-array
|
||||
|
||||
**Assist Satellite app for Home Assistant OS**
|
||||
|
||||
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. Install it directly from the official add-on repository:
|
||||
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 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):
|
||||
|
||||
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/home-assistant/addons)
|
||||
|
||||
@@ -68,41 +74,50 @@ 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] [--debug]
|
||||
[--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]
|
||||
```
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
||||
| `--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 |
|
||||
| `--audio-input-channels` | Number of microphone audio channels to stream | 2 |
|
||||
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
||||
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
||||
| `--stop-model` | ID of stop model | `stop` |
|
||||
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
||||
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
||||
| `--continue-conversation-delay` | Delay before mic opens for continued conversation | 0.5 |
|
||||
| `--timer-max-ring-seconds` | Seconds after which the timer stops ringing | 900.0 |
|
||||
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
||||
| `--start-listening-sound` | Sound file played when button is pressed to start listening | `sounds/start_listening_button.flac` |
|
||||
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
||||
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
||||
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
|
||||
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
|
||||
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
|
||||
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
|
||||
| `--network-interface` | Network interface for ESPHome server | Autodetected |
|
||||
| `--port` | Port for ESPHome server | 6053 |
|
||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||
| `--listen-during-wake-sound` | Start listening while the wake sound is still playing | False |
|
||||
| `--peripheral-host` | Bind address for the peripheral WebSocket API | 0.0.0.0 |
|
||||
| `--peripheral-port` | Port for the peripheral WebSocket API | 6055 |
|
||||
| `--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 |
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ----------------------------------- |
|
||||
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
||||
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
||||
| `--stop-model` | ID of stop model | `stop` |
|
||||
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
||||
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
||||
| `--timer-max-ring-seconds` | Seconds after which the timer stops ringing | 900.0 |
|
||||
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
||||
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
||||
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
||||
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
|
||||
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
|
||||
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
|
||||
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
|
||||
| `--network-interface` | Network interface for ESPHome server | Autodetected |
|
||||
| `--port` | Port for ESPHome server | 6053 |
|
||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||
| `--debug` | Print 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
|
||||
|
||||
@@ -118,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:
|
||||
@@ -174,9 +219,8 @@ Auto-fix formatting issues (Black + isort):
|
||||
### Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```sh
|
||||
./script/test
|
||||
``` sh
|
||||
./script/tests
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -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
|
||||
@@ -52,10 +60,18 @@ if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then
|
||||
EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" )
|
||||
fi
|
||||
|
||||
if [ -n "${AUDIO_INPUT_CHANNELS}" ]; then
|
||||
EXTRA_ARGS+=( "--audio-input-channels" "$AUDIO_INPUT_CHANNELS" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--enable-thinking-sound" )
|
||||
fi
|
||||
|
||||
if [ "$LISTEN_DURING_WAKE_SOUND" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--listen-during-wake-sound" )
|
||||
fi
|
||||
|
||||
if [ -n "${WAKE_WORD_DIR}" ]; then
|
||||
EXTRA_ARGS+=( "--wake-word-dir" "$WAKE_WORD_DIR" )
|
||||
fi
|
||||
@@ -72,10 +88,18 @@ if [ -n "${REFACTORY_SECONDS}" ]; then
|
||||
EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" )
|
||||
fi
|
||||
|
||||
if [ -n "${CONTINUE_CONVERSATION_DELAY}" ]; then
|
||||
EXTRA_ARGS+=( "--continue-conversation-delay" "$CONTINUE_CONVERSATION_DELAY" )
|
||||
fi
|
||||
|
||||
if [ -n "${WAKEUP_SOUND}" ]; then
|
||||
EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" )
|
||||
fi
|
||||
|
||||
if [ -n "${START_LISTENING_SOUND}" ]; then
|
||||
EXTRA_ARGS+=( "--start-listening-sound" "$START_LISTENING_SOUND" )
|
||||
fi
|
||||
|
||||
if [ -n "${TIMER_FINISHED_SOUND}" ]; then
|
||||
EXTRA_ARGS+=( "--timer-finished-sound" "$TIMER_FINISHED_SOUND" )
|
||||
fi
|
||||
@@ -96,6 +120,22 @@ if [ -n "${TIMER_MAX_RING_SECONDS}" ]; then
|
||||
EXTRA_ARGS+=( "--timer-max-ring-seconds" "$TIMER_MAX_RING_SECONDS" )
|
||||
fi
|
||||
|
||||
if [ -n "${PERIPHERAL_HOST}" ]; then
|
||||
EXTRA_ARGS+=( "--peripheral-host" "$PERIPHERAL_HOST" )
|
||||
fi
|
||||
|
||||
if [ -n "${PERIPHERAL_PORT}" ]; then
|
||||
EXTRA_ARGS+=( "--peripheral-port" "$PERIPHERAL_PORT" )
|
||||
fi
|
||||
|
||||
if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
|
||||
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
|
||||
fi
|
||||
|
||||
if [ "$DISABLE_PERIPHERAL_API" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--disable-peripheral-api" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--output-only" )
|
||||
fi
|
||||
@@ -113,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
|
||||
|
||||
@@ -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
|
||||
@@ -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.**
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
💡 **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`).
|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
- 
|
||||
- 
|
||||
|
||||
💡 **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
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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:
|
||||
|
||||
@@ -176,20 +176,29 @@ 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"
|
||||
# Environment=AUDIO_INPUT_CHANNELS=2
|
||||
# Environment=ENABLE_THINKING_SOUND="1"
|
||||
# Environment=WAKE_WORD_DIR="app/wakewords"
|
||||
# Environment=WAKE-MODEL="okay_nabu"
|
||||
# Environment=STOP_MODEL="stop"
|
||||
# Environment=TIMER_MAX_RING_SECONDS="900"
|
||||
# Environment=REFACTORY_SECONDS="2"
|
||||
# Environment=CONTINUE_CONVERSATION_DELAY="0.5"
|
||||
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||
# Environment=START_LISTENING_SOUND="sounds/start_listening_button.flac"
|
||||
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||
# Environment=PROCESSING_SOUND="sounds/processing.wav"
|
||||
# Environment=LISTEN_DURING_WAKE_SOUND="0"
|
||||
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||
# Environment=PERIPHERAL_HOST="0.0.0.0"
|
||||
# Environment=PERIPHERAL_PORT="6055"
|
||||
# Environment=PERIPHERAL_VOLUME_STEP="%(default)s"
|
||||
# Environment=DISABLE_PERIPHERAL_API="1"
|
||||
# Environment=ENABLE_OUTPUT_ONLY="1"
|
||||
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
|
||||
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
|
||||
@@ -252,31 +261,54 @@ 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 |
|
||||
| `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 |
|
||||
| `STOP_MODEL` | `stop` | Stop model to use |
|
||||
| `TIMER_MAX_RING_SECONDS` | `900` | Seconds after which the timer stops ringing |
|
||||
| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
|
||||
| `CONTINUE_CONVERSATION_DELAY` | `0.5` | Delay before mic opens for continued conversation |
|
||||
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered |
|
||||
| `START_LISTENING_SOUND` | `sounds/start_listening_button.flac` | Sound file for pressing button to talk |
|
||||
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
|
||||
| `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 |
|
||||
| `DISABLE_PERIPHERAL_API` | false | Disable the peripheral WebSocket API |
|
||||
| `ENABLE_OUTPUT_ONLY` | (optional) | Set to "1" to enable output-only mode |
|
||||
|
||||
|
||||
💡 **Note:** For the systemd installation some variables set in the service need to be without `LVA_` prefix.
|
||||
|
||||
### Feature: Listen During Wake Sound
|
||||
|
||||
By default, LVA waits until the wake sound has finished playing before it starts listening for your spoken command. This can force a short pause between saying the wake word and the rest of your request. For example: `Okay Nabu <pause until wake sound finishes> turn on the kitchen lights`.
|
||||
|
||||
Enable `LISTEN_DURING_WAKE_SOUND="1"` to let LVA begin listening immediately after the wake word is detected, even while the wake sound is still playing. This allows you to say the wake word and the full command in one go.
|
||||
|
||||
This feature is especially useful when you want a more natural interaction flow and do not want to wait to confirm that the wake word was detected before speaking the command.
|
||||
|
||||
Keep in mind that listening while the wake sound is playing can also make the microphone pick up some of that wake sound as echo, depending on your speaker and microphone setup. This echo can interfere with speech-to-text and make commands less accurate. If you run into this, see [Enabling Acoustic Echo Cancellation (AEC)](enabling_aec.md).
|
||||
|
||||
|
||||
### Use own soundfiles:
|
||||
|
||||
If you want to use your own sounds, you can add them to the `sounds/custom` aka `/var/lib/docker/volumes/lva_sounds_custom/_data` directory and reference them in the `.env` file.
|
||||
@@ -284,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:
|
||||
@@ -296,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
|
||||
|
||||
@@ -307,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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# Peripheral WebSocket API
|
||||
|
||||
Linux Voice Assistant exposes a WebSocket API on port 6055 that lets external scripts and containers integrate with LVA in real time. This is how LED rings, button boards, displays, and other peripherals communicate with the voice assistant without modifying LVA itself.
|
||||
|
||||
The peripheral API is what allows hardware like the Satellite1 HAT Board or the ReSpeaker series to light up when you say the wake word, show a thinking animation while the assistant processes your request, and let physical buttons start a conversation or dismiss a timer — all from a completely separate process or container.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
LVA acts as the WebSocket **server**. Your peripheral script or container connects as a **client**. Once connected, LVA immediately sends a snapshot of its current state, and from that point on all communication is bidirectional:
|
||||
|
||||
- **Events** flow from LVA to your client, describing what the assistant is doing right now (listening, thinking, speaking, etc.).
|
||||
- **Commands** flow from your client to LVA, telling it what to do (start listening, adjust volume, fire a button press event to Home Assistant, etc.).
|
||||
|
||||
All messages are JSON objects sent over a plain WebSocket connection. No authentication is required.
|
||||
|
||||
```
|
||||
Your peripheral script LVA
|
||||
│ │
|
||||
│──── WebSocket connect ──────────►│
|
||||
│◄─── snapshot (current state) ────│
|
||||
│ │
|
||||
│◄─── event: wake_word_detected ───│ (user said the wake word)
|
||||
│◄─── event: listening ────────────│ (STT streaming started)
|
||||
│◄─── event: thinking ─────────────│ (HA is processing)
|
||||
│◄─── event: tts_speaking ─────────│ (assistant is replying)
|
||||
│◄─── event: tts_finished ─────────│
|
||||
│◄─── event: idle ─────────────────│
|
||||
│ │
|
||||
│──── command: volume_up ─────────►│ (user pressed a button)
|
||||
│──── command: start_listening ───►│ (user pressed action button)
|
||||
```
|
||||
|
||||
### Connection
|
||||
|
||||
```
|
||||
ws://<lva-host>:6055
|
||||
```
|
||||
|
||||
By default LVA binds to `0.0.0.0:6055`. You can change the host and port with `--peripheral-host` and `--peripheral-port`, or disable the API entirely with `--disable-peripheral-api`.
|
||||
|
||||
### Message format
|
||||
|
||||
Events from LVA:
|
||||
```json
|
||||
{"event": "<event_name>", "data": {"key": "value"}}
|
||||
```
|
||||
|
||||
Commands to LVA:
|
||||
```json
|
||||
{"command": "<command_name>", "data": {"key": "value"}}
|
||||
```
|
||||
|
||||
The `data` field is omitted when there is no payload.
|
||||
|
||||
### Snapshot on connect
|
||||
|
||||
Immediately after connecting, LVA sends a snapshot so your client can synchronise its state without waiting for the next event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "snapshot",
|
||||
"data": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`last_stt_text` and `last_tts_text` are the most recent conversation exchange, or `null` if no conversation has happened yet since startup. Both are cleared when a new pipeline run starts.
|
||||
|
||||
---
|
||||
|
||||
## Python quick-start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
|
||||
async def main():
|
||||
async with websockets.connect("ws://localhost:6055") as ws:
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
event = msg.get("event")
|
||||
data = msg.get("data", {})
|
||||
print(f"Event: {event} data={data}")
|
||||
|
||||
# Example: send a command in response to an event
|
||||
if event == "idle":
|
||||
await ws.send(json.dumps({"command": "volume_up"}))
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Events (LVA → your script)
|
||||
|
||||
These are all the events LVA emits. Your peripheral script receives them and reacts — for example by changing LED colours, updating a display, or playing a sound.
|
||||
|
||||
### Voice pipeline events
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `wake_word_detected` | — | The configured wake word was detected. The wakeup chime is now playing. Start your "wake" animation here. |
|
||||
| `listening` | — | The wakeup chime has finished and LVA is now streaming audio to Home Assistant for speech-to-text. Show a "listening" animation. |
|
||||
| `stt_text` | `{"text": str}` | Home Assistant returned the recognised speech transcript. Use this to show what the user said on a display or LED ticker. |
|
||||
| `thinking` | — | LVA has stopped streaming audio and Home Assistant is processing the intent. Show a "thinking" animation. |
|
||||
| `tts_text` | `{"text": str}` | Home Assistant returned the assistant's text response, just before TTS audio begins playing. Use this to display the reply on a screen. |
|
||||
| `tts_speaking` | — | The TTS audio response is now playing. Show a "speaking" animation. |
|
||||
| `tts_finished` | — | TTS playback has finished. Transition back to idle, unless a new pipeline run is about to start. |
|
||||
| `idle` | — | The assistant is idle and ready for the next wake word. Turn off active animations. |
|
||||
|
||||
### Error and connection events
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `pipeline_error` | `{"reason": str}` | The voice pipeline failed — for example STT failure or an intent error. Show a brief red error animation (3 flashes, then off). NOT emitted when HA disconnects; see `disconnected` below. |
|
||||
| `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. 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
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `media_player_playing` | — | HA started streaming music or media to the background music player (non-announcement). Show a "media playing" indicator. Not emitted for TTS or announcements — those use `tts_speaking`. |
|
||||
| `volume_changed` | `{"volume": float}` | The speaker volume changed (0.0–1.0). Update any volume display or indicator ring. |
|
||||
| `volume_muted` | `{"muted": bool}` | The media player mute state changed. Distinct from microphone mute (`muted` event). |
|
||||
|
||||
### HA driven entity events
|
||||
|
||||
These events fire when a user changes a peripheral registered HA entity from Home Assistant. Use them to apply the user's preferences to your hardware in real time.
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `light_command` | `{"object_id": str, "state": bool, "brightness": float, "red": float, "green": float, "blue": float, "effect": str}` | An HA Light entity that a peripheral registered via `register_light` was changed. The event is broadcast to every connected peripheral, so filter on `object_id` to route it to the right hardware. `brightness` and RGB values are 0.0 to 1.0. `effect` is one of the strings declared when the Light was registered. |
|
||||
|
||||
---
|
||||
|
||||
## Commands (your script → LVA)
|
||||
|
||||
Send these to control LVA from your peripheral hardware.
|
||||
|
||||
### Entity registration
|
||||
|
||||
A peripheral that wants Home Assistant to control its hardware declares its entities at connect time. LVA materialises matching ESPHome entities, HA enumerates and shows them as it would any other ESPHome device, and user changes flow back as events.
|
||||
|
||||
For HA to see your entity, your peripheral must register before HA enumerates the LVA API. LVA waits briefly at startup (see `--peripheral-startup-wait`, default 2 seconds) so peripherals have a window to connect and register. Peripherals that connect later still work, but the new entities only appear in HA after the integration is reloaded.
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `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
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `start_listening` | — | Play the start-listening chime and begin a voice pipeline run, as if the user pressed the action button. No-op if already muted or pipeline already active. |
|
||||
| `stop_pipeline` | — | Abort the active voice pipeline at any phase (listening, thinking, or TTS speaking). Cleans up STT streaming, sends `AnnounceFinished` to HA, unducking music, and emits `idle`. |
|
||||
|
||||
### Microphone
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `mute_mic` | — | Mute the microphone. Stops any active pipeline, plays the mute sound, and emits `muted` with `{"muted": true}` to all clients. No-op if already muted. |
|
||||
| `unmute_mic` | — | Unmute the microphone. Plays the unmute sound and emits `muted` with `{"muted": false}` followed by `idle`. No-op if already unmuted. |
|
||||
|
||||
### Volume
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `volume_up` | — | Increase speaker volume by one step (default 5 %, configurable with `--peripheral-volume-step`). Updates HA, persists the value, and emits `volume_changed`. |
|
||||
| `volume_down` | — | Decrease speaker volume by one step. |
|
||||
| `set_volume` | `{"volume": float}` | Set the speaker volume to an exact value between 0.0 and 1.0. Values outside this range are clamped. Updates HA, persists, and emits `volume_changed`. Example: `{"command": "set_volume", "data": {"volume": 0.6}}` |
|
||||
|
||||
### Timer
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `stop_timer_ringing` | — | Dismiss a ringing timer. Stops the alarm sound, clears the timer state, unducking music, and emits `idle`. No-op if no timer is ringing. |
|
||||
|
||||
### Media player
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `stop_media_player` | — | Stop background music / media playback. Updates the HA media player entity state to Idle. |
|
||||
| `pause_media_player` | — | Pause background music. Updates HA state to Paused. |
|
||||
| `resume_media_player` | — | Resume paused music. Updates HA state to Playing. |
|
||||
|
||||
### Button events (sent to Home Assistant)
|
||||
|
||||
These commands fire event entities on the LVA device page in Home Assistant, which you can use in automations. They are intended for any of the buttons on your hardware that are available for these multipress functions and don't handle other context-sensitive commands. **The button must be registered with `register_button` command to appear as a HA sensor and be able to send these events.**
|
||||
|
||||
| Command | Data | Description |
|
||||
|---------|------|-------------|
|
||||
| `button_single_press` | — | Fire a `single_press` event to HA. No sound is played. |
|
||||
| `button_double_press` | — | Fire a `double_press` event to HA. Plays the configured double-press sound. |
|
||||
| `button_triple_press` | — | Fire a `triple_press` event to HA. Plays the configured triple-press sound. |
|
||||
| `button_long_press` | — | Fire a `long_press` event to HA. Plays the configured long-press sound. |
|
||||
|
||||
> **Important:** `button_single_press` must be sent from a **different physical button** than the button that trigger other commands. Like the action button's single press that's reserved for triggering the voice pipeline (`start_listening` on first press, then context-sensitive commands on subsequent presses). Using the same button for both would create ambiguous behavior.
|
||||
|
||||
The press type is determined by your peripheral script using timing logic that matches the Home Assistant Voice PE centre button behaviour:
|
||||
|
||||
```
|
||||
Single press : one press, then >250 ms silence
|
||||
Double press : press → <250 ms gap → press → >250 ms silence
|
||||
Triple press : press → <250 ms gap → press → <250 ms gap → press → >250 ms silence
|
||||
Long press : button held for ≥1 s
|
||||
```
|
||||
|
||||
Your script is responsible for detecting these patterns and sending the appropriate command. See the board-specific examples below for reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Minimal example script
|
||||
|
||||
The following self-contained script connects to LVA, prints every event with a timestamp, and maps a single GPIO button (Raspberry Pi GPIO 17) to context-sensitive commands:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal LVA peripheral — prints events, GPIO button → context command."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import websockets
|
||||
|
||||
LVA_URI = "ws://localhost:6055"
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
|
||||
assist_state = "idle"
|
||||
muted = False
|
||||
|
||||
|
||||
async def main():
|
||||
global assist_state, muted
|
||||
command_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
# Optional: wire up a GPIO button with gpiozero
|
||||
try:
|
||||
from gpiozero import Button
|
||||
loop = asyncio.get_running_loop()
|
||||
btn = Button(17, pull_up=True, bounce_time=0.15)
|
||||
btn.when_pressed = lambda: asyncio.run_coroutine_threadsafe(
|
||||
command_queue.put(_context_command()), loop
|
||||
)
|
||||
except ImportError:
|
||||
pass # Run without GPIO on non-Pi hardware
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(LVA_URI) as ws:
|
||||
recv = asyncio.create_task(_recv(ws, command_queue))
|
||||
send = asyncio.create_task(_send(ws, command_queue))
|
||||
done, pending = await asyncio.wait([recv, send], return_when=asyncio.FIRST_EXCEPTION)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
except Exception as exc:
|
||||
logging.warning("Disconnected: %s — retrying in 3s", exc)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
def _context_command() -> str:
|
||||
if assist_state == "timer_ringing":
|
||||
return "stop_timer_ringing"
|
||||
if assist_state in ("wake_word_detected", "listening", "thinking", "tts_speaking"):
|
||||
return "stop_pipeline"
|
||||
if assist_state == "media_player_playing":
|
||||
return "stop_media_player"
|
||||
return "start_listening"
|
||||
|
||||
|
||||
async def _recv(ws, queue):
|
||||
global assist_state, muted
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
event = msg.get("event", "")
|
||||
data = msg.get("data", {})
|
||||
logging.info("← %s %s", event, data)
|
||||
if event in ("snapshot", "idle", "tts_finished", "wake_word_detected",
|
||||
"listening", "thinking", "tts_speaking", "muted",
|
||||
"timer_ticking", "timer_ringing", "media_player_playing"):
|
||||
if event in ("snapshot", "muted"):
|
||||
# Both carry the mute state; "muted" defaults to true when
|
||||
# sent without data.
|
||||
muted = data.get("muted", event == "muted")
|
||||
assist_state = "muted" if muted else "idle"
|
||||
else:
|
||||
assist_state = event
|
||||
|
||||
|
||||
async def _send(ws, queue):
|
||||
while True:
|
||||
command = await queue.get()
|
||||
logging.info("→ %s", command)
|
||||
await ws.send(json.dumps({"command": command}))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-included examples
|
||||
|
||||
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) — 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
|
||||
- [Jabra Speak 410](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Jabra%20Speak%20410/DOCS.md) — USB speakerphone with hardware button integration
|
||||
|
||||
---
|
||||
|
||||
## Adding your own example
|
||||
|
||||
Have you built a peripheral controller for a board not listed above? Contributions are very welcome. Open a pull request at [github.com/OHF-Voice/linux-voice-assistant/pulls](https://github.com/OHF-Voice/linux-voice-assistant/pulls) with:
|
||||
|
||||
- A folder under `examples/<Your Board Name>/`
|
||||
- The controller script (Python or other language)
|
||||
- A `Dockerfile` and `compose.yml` so others can run it with one command
|
||||
- A `DOCS.md` following the same structure as the existing examples (hardware layout, GPIO mapping, LED animations, installation steps, troubleshooting)
|
||||
|
||||
Please make sure your example handles reconnection gracefully (retry on WebSocket failure) and shows a "not ready" effect when LVA is not reachable or HA is disconnected.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Jabra Speak 410
|
||||
|
||||
<img src="image.png" width="300">
|
||||
|
||||
Peripheral controller for the [Jabra Speak 410](https://www.jabra.com/business/speakerphones/jabra-speak-series/jabra-speak-410) USB speakerphone, running alongside the Linux Voice Assistant (LVA) container.
|
||||
|
||||
The controller connects to the Jabra Speak 410 via USB HID, drives its LED ring to reflect the current assistant state, and maps its hardware buttons to LVA commands. It also optionally integrates with PipeWire for volume control and hardware mute detection.
|
||||
|
||||
**Credit:** This integration was written by [@machineonamission](https://github.com/machineonamission). Source repository: [machineonamission/lva-jabra-speak-410](https://github.com/machineonamission/lva-jabra-speak-410).
|
||||
|
||||
> **This board connects over USB** — no GPIO, no SPI, no HAT required. It works on any Linux host.
|
||||
|
||||
[Demo Video](./demo.mp4)
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Connection | USB (Vendor `0x0b0e`) |
|
||||
| Microphone | 360° omni, enumerates as USB audio |
|
||||
| Speaker | Built-in 360° speaker |
|
||||
| LED ring | Integrated ring (state-driven via USB HID) |
|
||||
| Buttons | Call / hang-up, mute, volume up, volume down |
|
||||
|
||||
---
|
||||
|
||||
## LED states
|
||||
|
||||
The Jabra Speak 410 has an internal telephony state machine that maps LED patterns to button behaviour. The controller uses this to mirror the assistant state:
|
||||
|
||||
| LVA state | LED pattern | Description |
|
||||
|---|---|---|
|
||||
| Idle | Off | Default state |
|
||||
| Wake word detected | Flashing | Ring flashes |
|
||||
| Listening | 3 green | Ring solid green |
|
||||
| Thinking | Flashing | Ring flashes |
|
||||
| TTS speaking | Partial flash | Ring partially flashing |
|
||||
| TTS finished | Off | Returns to default |
|
||||
| Muted | All red | Entire ring solid red |
|
||||
| Pipeline error | Red flash (×4) | Ring rapidly flashes red then returns to off |
|
||||
| Timer ringing | Flashing | Ring flashes |
|
||||
| No HA connection | Flashing | Ring flashes |
|
||||
|
||||
> **Note:** The Jabra Speak 410 is a telephony device with a fixed firmware state machine. The LED patterns are constrained by its HID protocol — custom per-pixel colours are not possible.
|
||||
|
||||
---
|
||||
|
||||
## Button behaviour
|
||||
|
||||
### Call button
|
||||
Context action — starts listening when idle; dismissed by hang-up gesture during active pipeline.
|
||||
|
||||
### Hang-up / flash button
|
||||
| Current state | Command sent |
|
||||
|---|---|
|
||||
| Timer ringing | `stop_timer_ringing` |
|
||||
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
|
||||
| Music / media playing | `stop_media_player` |
|
||||
|
||||
### Volume Up/Down buttons
|
||||
Controlled by `VOLUME_CONTROL` setting (see configuration). The controller can either adjust the PipeWire sink volume directly, or send `volume_up` / `volume_down` commands to LVA.
|
||||
|
||||
### Mute button
|
||||
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
|
||||
|
||||
### Step 1 — Add udev rule
|
||||
|
||||
Run once on the **host**:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/udev/rules.d/99-jabra.rules << 'EOF'
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0b0e", MODE="0666", GROUP="plugdev"
|
||||
EOF
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
Verify the device is visible:
|
||||
|
||||
```bash
|
||||
lsusb | grep 0b0e
|
||||
# Example: Bus 001 Device 007: ID 0b0e:0412 GN Netcom ...
|
||||
```
|
||||
|
||||
### Step 2 — Expose PipeWire to the container
|
||||
|
||||
The mute button detection requires PipeWire. Follow the [LVA audio server documentation](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/install_audioserver.md) to expose the PipeWire socket.
|
||||
|
||||
Find your runtime directory:
|
||||
|
||||
```bash
|
||||
echo $XDG_RUNTIME_DIR
|
||||
# Typically: /run/user/1000
|
||||
```
|
||||
|
||||
Update the volume mounts in `docker-compose.yml` if your path is different from `/run/user/1000`.
|
||||
|
||||
### Step 3 — Configure LVA to use the Jabra microphone
|
||||
|
||||
The Jabra Speak 410 enumerates as a standard USB audio device. Pass the device to LVA:
|
||||
|
||||
```bash
|
||||
--audio-input-device "Jabra SPEAK 410"
|
||||
```
|
||||
|
||||
Or in LVA's compose environment:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- AUDIO_INPUT_DEVICE=Jabra SPEAK 410
|
||||
```
|
||||
|
||||
### Step 4 — File structure
|
||||
|
||||
```
|
||||
Jabra Speak 410/
|
||||
├── docker-compose.yml
|
||||
└── (no build required — uses pre-built image)
|
||||
```
|
||||
|
||||
### Step 5 — Start
|
||||
|
||||
```bash
|
||||
cd jabra_speak_410
|
||||
```
|
||||
|
||||
#### Option A — Run with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
#### Option B — Run directly with Python
|
||||
|
||||
```bash
|
||||
pip install hidapi websocket-client pycaw pycaw[pipewire]
|
||||
export LVA_WS_URL=ws://localhost:6055
|
||||
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
|
||||
|
||||
All options are set via environment variables in `docker-compose.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LVA_WS_URL` | `ws://localhost:6055` | LVA peripheral API WebSocket URL |
|
||||
| `XDG_RUNTIME_DIR` | `/run/user/1000` | PipeWire runtime directory (must match volume mount) |
|
||||
| `PULSE_SERVER` | `unix:/run/user/1000/pulse/native` | PulseAudio / PipeWire-Pulse socket path |
|
||||
| `PW_SINK` | `@DEFAULT_SINK@` | PipeWire sink for volume control |
|
||||
| `VOLUME_CONTROL` | *(unset)* | Volume button mode — see below |
|
||||
|
||||
### Volume control modes
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `pipewire` | Adjusts PipeWire sink volume directly via `wpctl` on each button press |
|
||||
| `lva` | Sends `volume_up` / `volume_down` commands to LVA |
|
||||
| *(unset)* | Volume button presses are ignored |
|
||||
|
||||
> **Warning:** Do not use `VOLUME_CONTROL=lva` if your host OS is also processing HID volume events from the Jabra — this will result in double volume changes.
|
||||
|
||||
---
|
||||
|
||||
## Drivers summary
|
||||
|
||||
| Component | Driver needed | How |
|
||||
|---|---|---|
|
||||
| Jabra HID (LEDs + buttons) | udev rule | `99-jabra.rules` on host |
|
||||
| Microphone (USB audio) | **None** | Enumerates as USB audio device automatically |
|
||||
| Mute detection | PipeWire socket | Expose `$XDG_RUNTIME_DIR` to container |
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
**Mute button detection** requires PipeWire to be exposed to the container. Without it, the mute button will not work. This is a firmware limitation of the Jabra Speak 410 — the device does not report the mute button state over HID unless it believes it is in an active call.
|
||||
|
||||
**Volume button exclusivity** — `hidapi` takes full ownership of the USB HID interface. External tools that rely on HID volume events (such as `alsa_volume_from_usb_hid`) will not function while this container is running.
|
||||
|
||||
**LED pattern constraints** — The Jabra Speak 410 firmware enforces a telephony state machine. LED patterns are limited to what the device's HID protocol supports; custom animations or per-pixel colour control are not possible.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device not found at startup
|
||||
|
||||
1. Confirm `lsusb | grep 0b0e` shows the device.
|
||||
2. Check the udev rule is applied: `ls -la /dev/bus/usb/<bus>/<device>` should show group `plugdev` with mode `0666`.
|
||||
3. Restart the container after plugging in the device.
|
||||
|
||||
### Mute button has no effect
|
||||
|
||||
1. Confirm the PipeWire socket volume mount is correct and the path exists on the host: `ls $XDG_RUNTIME_DIR/pipewire-0`.
|
||||
2. Run with debug logging and look for `pw-record not found` — this means `wireplumber` is not installed in the container. The pre-built image includes it.
|
||||
3. Check that `XDG_RUNTIME_DIR` in the container environment matches the volume mount path.
|
||||
|
||||
### Double volume changes
|
||||
|
||||
Set `VOLUME_CONTROL` to `pipewire` instead of `lva`, or leave it unset. The `lva` mode conflicts with OS-level HID volume processing.
|
||||
|
||||
### LVA not reachable
|
||||
|
||||
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.
|
||||
3. If LVA is on a different machine, change `LVA_WS_URL` to its IP address.
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y wireplumber libhidapi-dev python3-hidapi python3-hid wireplumber
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN uv sync --locked
|
||||
|
||||
CMD uv run main.py
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
# Jabra Speak 410 – peripheral controller
|
||||
#
|
||||
# Runs alongside the linux-voice-assistant container.
|
||||
# Controls the Jabra Speak 410's LED ring and buttons via USB HID,
|
||||
# and optionally integrates with PipeWire for volume control and mute detection.
|
||||
#
|
||||
# Credit: https://github.com/machineonamission/lva-jabra-speak-410
|
||||
#
|
||||
# See hardware notes at the bottom of this file before first run.
|
||||
|
||||
services:
|
||||
jabra-peripheral:
|
||||
image: ghcr.io/machineonamission/lva-jabra-speak-410:main
|
||||
container_name: jabra-peripheral
|
||||
restart: unless-stopped
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# USB device access — required for HID LED and button control
|
||||
# -----------------------------------------------------------------------
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PipeWire socket — required for mute detection and optional volume control
|
||||
#
|
||||
# The mute detect bodge reads from the microphone stream via pw-record to
|
||||
# detect when the hardware mute engages (all-zero audio = hardware muted).
|
||||
# Without this, mute/unmute from the Jabra button will not work.
|
||||
#
|
||||
# Replace /run/user/1000 with your actual XDG_RUNTIME_DIR if different.
|
||||
# For the correct path run: echo $XDG_RUNTIME_DIR
|
||||
# -----------------------------------------------------------------------
|
||||
volumes:
|
||||
- /run/user/1000/pipewire-0:/run/user/1000/pipewire-0:ro
|
||||
- /run/user/1000/pulse:/run/user/1000/pulse:ro
|
||||
|
||||
group_add:
|
||||
- audio
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Environment variables
|
||||
# -----------------------------------------------------------------------
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# LVA peripheral API WebSocket URL.
|
||||
# If LVA runs on the same host with network_mode: host this default works.
|
||||
- LVA_WS_URL=ws://localhost:6055
|
||||
|
||||
# PipeWire runtime directory — must match the volume mount path above.
|
||||
- XDG_RUNTIME_DIR=/run/user/1000
|
||||
- PULSE_SERVER=unix:/run/user/1000/pulse/native
|
||||
|
||||
# PipeWire sink for volume control.
|
||||
# Leave as @DEFAULT_SINK@ or set to a specific sink name.
|
||||
# Find sink names with: wpctl status
|
||||
- PW_SINK=@DEFAULT_SINK@
|
||||
|
||||
# Volume control mode. Options:
|
||||
# pipewire – adjust PipeWire volume directly on button press
|
||||
# (recommended with LVA pipewire volume control PR #272)
|
||||
# lva – send volume_up / volume_down commands to LVA
|
||||
# (WARNING: may double-trigger if system also handles HID)
|
||||
# (unset) – ignore volume button presses entirely
|
||||
# - VOLUME_CONTROL=pipewire
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Network — host mode so "localhost" reaches the LVA container directly
|
||||
# -----------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "main.py"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HARDWARE NOTES
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. USB ACCESS — udev rule (recommended)
|
||||
#
|
||||
# Add a udev rule on the HOST so the container can access the Jabra device
|
||||
# without needing additional privileges:
|
||||
#
|
||||
# sudo tee /etc/udev/rules.d/99-jabra.rules << 'EOF'
|
||||
# SUBSYSTEM=="usb", ATTR{idVendor}=="0b0e", MODE="0666", GROUP="plugdev"
|
||||
# EOF
|
||||
# sudo udevadm control --reload-rules
|
||||
# sudo udevadm trigger
|
||||
#
|
||||
# Verify the device is visible:
|
||||
# lsusb | grep 0b0e
|
||||
#
|
||||
# 2. PIPEWIRE — required for mute detection
|
||||
#
|
||||
# The Jabra Speak 410 does not expose its mute button state over HID when
|
||||
# not in an active call. As a workaround, the controller reads raw audio
|
||||
# from PipeWire and detects hardware mute by observing all-zero audio chunks
|
||||
# (digitally muted audio is impossible in real conditions).
|
||||
#
|
||||
# Expose the PipeWire socket from the host to the container as shown in the
|
||||
# volumes section above. See the LVA audio server documentation for the
|
||||
# full procedure:
|
||||
# https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/install_audioserver.md
|
||||
#
|
||||
# If pw-record is not available inside the container, install wireplumber:
|
||||
# apt-get install wireplumber
|
||||
# (already included in the pre-built image)
|
||||
#
|
||||
# 3. VOLUME CONTROL
|
||||
#
|
||||
# The hidapi library takes full control of the USB HID interface, which
|
||||
# means external tools like alsa_volume_from_usb_hid will stop working
|
||||
# while this container is running.
|
||||
#
|
||||
# Set VOLUME_CONTROL=pipewire to handle volume buttons via wpctl.
|
||||
# Set VOLUME_CONTROL=lva to send volume commands to LVA instead.
|
||||
# Leave unset to ignore volume buttons entirely.
|
||||
#
|
||||
# Avoid VOLUME_CONTROL=lva if your system is also processing HID volume
|
||||
# events at the OS level — this will result in double volume changes.
|
||||
#
|
||||
# 4. MICROPHONE INPUT
|
||||
#
|
||||
# The Jabra Speak 410 enumerates as a standard USB audio device.
|
||||
# No driver install is required. Pass it to LVA via:
|
||||
#
|
||||
# --audio-input-device "Jabra SPEAK 410"
|
||||
#
|
||||
# Or in LVA's compose environment:
|
||||
# AUDIO_INPUT_DEVICE=Jabra SPEAK 410
|
||||
#
|
||||
# =============================================================================
|
||||
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,17 @@
|
||||
[Container]
|
||||
# container stuff
|
||||
ContainerName=jabra
|
||||
Image=ghcr.io/machineonamission/lva-jabra-speak-410:main
|
||||
# auto update
|
||||
AutoUpdate=registry
|
||||
Pull=newer
|
||||
|
||||
# usb communication
|
||||
AddDevice=/dev/bus/usb
|
||||
|
||||
# pipewire (see LVA docs)
|
||||
Volume=/run/pipewire:/run/pipewire:ro
|
||||
SecurityLabelDisable=true
|
||||
GroupAdd=audio
|
||||
Environment=XDG_RUNTIME_DIR=/run/pipewire
|
||||
Environment=PULSE_SERVER=unix:/run/pipewire/pulse/native
|
||||
@@ -0,0 +1,491 @@
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from enum import IntFlag, Enum
|
||||
|
||||
import websockets
|
||||
import json
|
||||
import hid
|
||||
import os
|
||||
|
||||
from websockets import ClientConnection
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
# Default to localhost if running on host network, or use LVA container name/IP
|
||||
LVA_WS_URL = os.getenv("LVA_WS_URL", "ws://0.0.0.0:6055")
|
||||
|
||||
JABRA_VENDOR = 0x0b0e
|
||||
# JABRA_PRODUCT = 0x0412
|
||||
|
||||
starttime = time.time()
|
||||
|
||||
pw_sink = os.getenv("PW_SINK", "@DEFAULT_SINK@")
|
||||
|
||||
vol_ctrl = os.getenv("VOLUME_CONTROL", None)
|
||||
|
||||
if vol_ctrl not in ["pipewire", "lva"]:
|
||||
print("invalid volume control")
|
||||
vol_ctrl = None
|
||||
|
||||
print(hid.enumerate())
|
||||
# USAGE_PAGE = 11
|
||||
|
||||
devices = []
|
||||
for device in hid.enumerate(JABRA_VENDOR):
|
||||
serial = device['serial_number']
|
||||
print(f"Found {device['product_string']} serial number: {serial}")
|
||||
devices.append(device['path'])
|
||||
|
||||
if len(devices) == 0:
|
||||
# print("NO JABRA ALERT WAAAAA")
|
||||
raise Exception("no jabra speak 410 found!")
|
||||
|
||||
|
||||
class Telephony(IntFlag):
|
||||
hook_switch = 1 << 0
|
||||
line_busy_tone = 1 << 1
|
||||
speaker_phone = 1 << 2
|
||||
mute = 1 << 3
|
||||
flash = 1 << 4
|
||||
redial = 1 << 5
|
||||
speed_dial = 1 << 6
|
||||
phone_key_bit_0 = 1 << 7
|
||||
phone_key_bit_1 = 1 << 8
|
||||
phone_key_bit_2 = 1 << 9
|
||||
phone_key_bit_3 = 1 << 10
|
||||
# no clue
|
||||
button_7 = 1 << 11
|
||||
|
||||
|
||||
class LEDs(IntFlag):
|
||||
off_hook = 1 << 0
|
||||
speaker = 1 << 1
|
||||
mute = 1 << 2
|
||||
ring = 1 << 3
|
||||
hold = 1 << 4
|
||||
microphone = 1 << 5
|
||||
# Marked telephony and not LED, probably why it's a dupe of ring
|
||||
ringer = 1 << 6
|
||||
|
||||
|
||||
class Volume(IntFlag):
|
||||
vol_down = 1 << 0
|
||||
vol_up = 1 << 1
|
||||
# Does this ever trigger? might be a host -> device only thing?
|
||||
mute = 1 << 2
|
||||
|
||||
|
||||
class LEDState(IntFlag, Enum):
|
||||
default = 0
|
||||
three_green = LEDs.off_hook
|
||||
all_red = LEDs.mute | LEDs.off_hook
|
||||
ringing_and_flashing = LEDs.ring
|
||||
flashing = LEDs.ring | LEDs.off_hook
|
||||
partial_flash = LEDs.hold
|
||||
|
||||
|
||||
class LVAEvent(str, Enum):
|
||||
"""Events broadcast from LVA to peripheral clients."""
|
||||
|
||||
WAKE_WORD_DETECTED = "wake_word_detected"
|
||||
LISTENING = "listening"
|
||||
THINKING = "thinking"
|
||||
TTS_SPEAKING = "tts_speaking"
|
||||
TTS_FINISHED = "tts_finished"
|
||||
PIPELINE_ERROR = "pipeline_error"
|
||||
IDLE = "idle"
|
||||
MUTED = "muted"
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_UPDATED = "timer_updated"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYER_PLAYING = "media_player_playing"
|
||||
VOLUME_CHANGED = "volume_changed"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
ZEROCONF = "zeroconf"
|
||||
DISCONNECTED = "disconnected"
|
||||
|
||||
|
||||
class LVACommand(str, Enum):
|
||||
"""Commands accepted from peripheral clients."""
|
||||
|
||||
START_LISTENING = "start_listening"
|
||||
MUTE_MIC = "mute_mic"
|
||||
UNMUTE_MIC = "unmute_mic"
|
||||
VOLUME_UP = "volume_up"
|
||||
VOLUME_DOWN = "volume_down"
|
||||
STOP_TIMER_RINGING = "stop_timer_ringing"
|
||||
STOP_MEDIA_PLAYER = "stop_media_player"
|
||||
STOP_PIPELINE = "stop_pipeline"
|
||||
|
||||
|
||||
class JabraSpeak:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.device = hid.device()
|
||||
self.device.open_path(self.path)
|
||||
# we can do thread fuckery to make this work
|
||||
self.device.set_nonblocking(True)
|
||||
|
||||
async def read(self):
|
||||
while True:
|
||||
try:
|
||||
read_info = self.device.read(8)
|
||||
if read_info:
|
||||
match read_info[0]:
|
||||
case 0x03:
|
||||
return Telephony(read_info[1] | read_info[2] << 8)
|
||||
case 0x01:
|
||||
return Volume(read_info[1])
|
||||
case _:
|
||||
print(f"{round(time.time() - starttime, 1)}s: Packet {bytelist(read_info)} of unknown type")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print("fatal error in jabra loop: ", e)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def write(self, button_state: LEDs):
|
||||
return await asyncio.to_thread(self.device.write, [0x03, button_state & 0xff, (button_state & 0xff00) >> 8])
|
||||
|
||||
async def readloop(self):
|
||||
global last_mute, muted
|
||||
|
||||
while True:
|
||||
event = await self.read()
|
||||
print(f"from jabra: {event.__class__.__name__} {event.name} 0b{int(event):b}")
|
||||
# print(f"last event: {last_jabra_write.name}")
|
||||
if isinstance(event, Telephony):
|
||||
if (
|
||||
# Hangup pressed during TTS playback
|
||||
((event & Telephony.flash) and last_jabra_write == LEDState.partial_flash)
|
||||
# Hangup pressed during active listening
|
||||
or (event is None and last_jabra_write == LEDState.three_green)
|
||||
# button_7 (bit 11, undocumented in HID spec) fires when hangup is pressed
|
||||
# while LEDs are flashing — covers wake word detected, thinking, and timer
|
||||
# ringing states; acts as a catch-all hangup for active pipeline phases
|
||||
or (event & Telephony.button_7)
|
||||
):
|
||||
print("jabra to lva: hangup detected")
|
||||
if current_state == LVAEvent.MEDIA_PLAYER_PLAYING:
|
||||
await write_to_lva(LVACommand.STOP_MEDIA_PLAYER)
|
||||
else:
|
||||
await write_to_lva(LVACommand.STOP_TIMER_RINGING)
|
||||
await write_to_lva(LVACommand.STOP_PIPELINE)
|
||||
# Mute switch
|
||||
elif event & Telephony.mute:
|
||||
print("jabra to lva: mute toggle detected")
|
||||
global muted
|
||||
if muted:
|
||||
await set_mute(False)
|
||||
else:
|
||||
await set_mute(True)
|
||||
# Call button pressed while LEDs are in default (idle) state.
|
||||
# Guard against the device's known hardware quirk: it spuriously fires a
|
||||
# hook_switch event immediately after unmuting, so off_mute_cooldown()
|
||||
# gates the action on at least 1 second having elapsed since the last
|
||||
# mute toggle before treating this as a genuine button press.
|
||||
elif event & Telephony.hook_switch and last_jabra_write == LEDState.default:
|
||||
if off_mute_cooldown():
|
||||
print("jabra to lva: call button detected")
|
||||
# If lva is glitched and i dont update the state machine, it will absolutely crap out
|
||||
await write_to_jabra(LEDState.flashing)
|
||||
|
||||
await write_to_lva(LVACommand.START_LISTENING)
|
||||
|
||||
await asyncio.create_task(listening_bodge())
|
||||
elif isinstance(event, Volume):
|
||||
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:
|
||||
case "lva":
|
||||
await write_to_lva(LVACommand.VOLUME_UP)
|
||||
case "pipewire":
|
||||
await wpctl_vol("10%+")
|
||||
elif event & Volume.vol_down:
|
||||
print("jabra to lva: volume down detected")
|
||||
match vol_ctrl:
|
||||
case "lva":
|
||||
await write_to_lva(LVACommand.VOLUME_DOWN)
|
||||
case "pipewire":
|
||||
await wpctl_vol("10%-")
|
||||
elif event & Volume.mute:
|
||||
print("jabra to lva: consumer control mute toggle detected")
|
||||
if muted:
|
||||
await set_mute(False)
|
||||
print("unmute cooldown")
|
||||
else:
|
||||
await set_mute(True)
|
||||
|
||||
|
||||
# Fixes a bug where it can get stuck on wakeword detected
|
||||
async def listening_bodge():
|
||||
await asyncio.sleep(0.5)
|
||||
if current_state == LVAEvent.WAKE_WORD_DETECTED:
|
||||
await write_to_lva(LVACommand.STOP_PIPELINE)
|
||||
await write_to_jabra(LEDState.three_green)
|
||||
|
||||
|
||||
last_jabra_write: LEDs | LEDState = LEDState.default
|
||||
last_lva_write: LVACommand | None = None
|
||||
devices = [JabraSpeak(d) for d in devices]
|
||||
|
||||
last_mute = 0
|
||||
|
||||
|
||||
def bytelist(l: list[int]):
|
||||
return "[" + ', '.join(f"0x{b:02x}" for b in l) + "]"
|
||||
|
||||
|
||||
async def write_to_jabra(state: LEDs | LEDState):
|
||||
print(f"to jabra: {state.name} {int(state):b}")
|
||||
global last_jabra_write
|
||||
last_jabra_write = state
|
||||
return await asyncio.gather(*[d.write(state) for d in devices])
|
||||
|
||||
|
||||
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
|
||||
message = json.dumps({"command": command} | ({"data": data} if data else {}))
|
||||
print(f"to lva: {message}")
|
||||
await lva_sock.send(message)
|
||||
else:
|
||||
print("to lva: failed, no websocket")
|
||||
|
||||
|
||||
lva_sock: None | ClientConnection = None
|
||||
|
||||
|
||||
async def set_mute(m: bool, write_lva: bool = True):
|
||||
global muted, last_mute
|
||||
muted = m
|
||||
mute_cooldown()
|
||||
if m:
|
||||
commands = ([write_to_jabra(LEDState.all_red)] +
|
||||
([write_to_lva(LVACommand.MUTE_MIC)] if write_lva else []))
|
||||
|
||||
await asyncio.gather(*commands)
|
||||
|
||||
else:
|
||||
commands = ([write_to_jabra(LEDState.default)] +
|
||||
([write_to_lva(LVACommand.UNMUTE_MIC)] if write_lva else []))
|
||||
|
||||
await asyncio.gather(*commands)
|
||||
|
||||
|
||||
async def pipeline_error():
|
||||
for _ in range(4):
|
||||
await write_to_jabra(LEDState.all_red)
|
||||
await asyncio.sleep(0.1)
|
||||
await write_to_jabra(LEDState.default)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def ha_disconnected():
|
||||
"""All surround leds blink repeatedly with red color to indicate disconnected state from LVA."""
|
||||
while True:
|
||||
await write_to_jabra(LEDState.all_red)
|
||||
await asyncio.sleep(0.5)
|
||||
await write_to_jabra(LEDState.default)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
current_state: None | LVAEvent = None
|
||||
|
||||
muted: bool = False
|
||||
|
||||
buttons_locked: bool = False
|
||||
|
||||
|
||||
async def run_cmd(cmd: list[str]):
|
||||
print("run_cmd ", cmd)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
try:
|
||||
stdout = stdout.decode().strip()
|
||||
stderr = stderr.decode().strip()
|
||||
except UnicodeDecodeError:
|
||||
stdout = stdout.decode("ascii", 'ignore').strip()
|
||||
stderr = stderr.decode("ascii", 'ignore').strip()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise Exception("wpctl failed ", cmd, stdout, stderr)
|
||||
else:
|
||||
return stdout, stderr
|
||||
|
||||
|
||||
async def wpctl_vol(vol_mod: str):
|
||||
await run_cmd(["wpctl", "set-volume", "-l", "1.0", pw_sink, vol_mod])
|
||||
|
||||
vol = await pw_vol()
|
||||
if vol <= 0:
|
||||
await run_cmd(["wpctl", "set-mute", pw_sink, "1"])
|
||||
else:
|
||||
await run_cmd(["wpctl", "set-mute", pw_sink, "0"])
|
||||
|
||||
|
||||
async def wsloop():
|
||||
global lva_sock
|
||||
while True:
|
||||
try:
|
||||
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"])
|
||||
except ValueError:
|
||||
print(f"current state is not a valid event: {json_data['event']}")
|
||||
match current_state:
|
||||
case LVAEvent.WAKE_WORD_DETECTED:
|
||||
await write_to_jabra(LEDState.flashing)
|
||||
case LVAEvent.LISTENING:
|
||||
await write_to_jabra(LEDState.three_green)
|
||||
case LVAEvent.THINKING:
|
||||
await write_to_jabra(LEDState.flashing)
|
||||
case LVAEvent.TTS_SPEAKING:
|
||||
await write_to_jabra(LEDState.partial_flash)
|
||||
case LVAEvent.TTS_FINISHED:
|
||||
await write_to_jabra(LEDState.default)
|
||||
case LVAEvent.PIPELINE_ERROR:
|
||||
asyncio.create_task(pipeline_error())
|
||||
case LVAEvent.IDLE:
|
||||
await set_mute(False, write_lva=False)
|
||||
case LVAEvent.MUTED:
|
||||
await set_mute(True, write_lva=False)
|
||||
case LVAEvent.TIMER_TICKING:
|
||||
pass
|
||||
case LVAEvent.TIMER_UPDATED:
|
||||
pass
|
||||
case LVAEvent.TIMER_RINGING:
|
||||
await write_to_jabra(LEDState.flashing)
|
||||
case LVAEvent.MEDIA_PLAYER_PLAYING:
|
||||
pass
|
||||
case LVAEvent.VOLUME_CHANGED:
|
||||
pass
|
||||
case LVAEvent.VOLUME_MUTED:
|
||||
pass
|
||||
case LVAEvent.ZEROCONF:
|
||||
pass
|
||||
case LVAEvent.DISCONNECTED:
|
||||
await write_to_jabra(ha_disconnected())
|
||||
case event:
|
||||
print(f"Unknown event: {event}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print("fatal error in lva loop: ", e)
|
||||
lva_sock = None
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def pw_vol():
|
||||
stdout, stderr = await run_cmd(["wpctl", "get-volume", pw_sink])
|
||||
|
||||
# Extract the float value
|
||||
match = re.search(r'Volume:\s*([0-9.]+)', stdout)
|
||||
return float(match.group(1)) # Returns exactly 0.5
|
||||
|
||||
|
||||
def off_mute_cooldown():
|
||||
global last_mute
|
||||
ret = time.perf_counter() - last_mute >= 1
|
||||
print("off mute cooldown: ", ret)
|
||||
return ret
|
||||
|
||||
|
||||
def mute_cooldown():
|
||||
global last_mute
|
||||
print("set mute cooldown")
|
||||
last_mute = time.perf_counter()
|
||||
|
||||
|
||||
async def mute_detect_bodge():
|
||||
if shutil.which("pw-record") is None:
|
||||
print("pw-record not found. make sure wireplumber is installed.")
|
||||
return
|
||||
while True:
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"pw-record", "--rate", "16000", "--channels", "1", "--format", "s16", "-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
while True:
|
||||
rate = 16_000
|
||||
read_dur = 0.2
|
||||
chunk = await proc.stdout.readexactly(int(rate * read_dur))
|
||||
global muted, last_mute
|
||||
# all zeroes, mic has been muted
|
||||
if not any(chunk) and not muted and off_mute_cooldown():
|
||||
await set_mute(True)
|
||||
if any(chunk) and muted and off_mute_cooldown():
|
||||
await set_mute(False)
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except asyncio.IncompleteReadError as e:
|
||||
# This happens when pw-record is killed while waiting for data. Just exit the loop and respawn it.
|
||||
print("pw-record process ended ", e)
|
||||
print(await proc.communicate())
|
||||
print(proc.returncode)
|
||||
except Exception as e:
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
print("fatal error in mute_detect_bodge: ", e)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def main():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
# Spawn your infinite loops here
|
||||
tg.create_task(wsloop())
|
||||
tg.create_task(mute_detect_bodge())
|
||||
|
||||
# tg.create_task(block_test())
|
||||
for d in devices:
|
||||
tg.create_task(d.readloop())
|
||||
|
||||
# await asyncio.sleep(0.5)
|
||||
# await write_to_jabra(LEDs.microphone | LEDs.speaker)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "lva-jabra-speak-410"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"hidapi>=0.15.0",
|
||||
"websockets>=16.0",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/f6/caad9ed701fbb9223eb9e0b41a5514390769b4cb3084a2704ab69e9df0fe/hidapi-0.15.0.tar.gz", hash = "sha256:ecbc265cbe8b7b88755f421e0ba25f084091ec550c2b90ff9e8ddd4fcd540311", size = 184995, upload-time = "2025-12-09T09:48:54.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/65/e9c70deb396f5baea92cd2ea7803914a55d455c3982367bae4390483e6cc/hidapi-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a6bb079bcd5152dd8968893aecdcbde3954b849896078f54df2254c0d1f301e", size = 69532, upload-time = "2025-12-09T09:45:55.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/eb/d006a7e28ec63b5db76340e4aca6ff077ad336f8fbe64ac804b968927011/hidapi-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d80c745d285dfc9889e856226daa8eee6f9e83025ef2cb97e5fe0b1396f7818a", size = 69350, upload-time = "2025-12-09T09:45:56.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2b/95e7595eb61982c651ed2b3ecd9bae05a0298b668c801a992e4e814b22f2/hidapi-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0f4411fd479345ca86742d9dda77200476a902749d8e47ed438f31ec98af418", size = 1065210, upload-time = "2025-12-09T09:46:02.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a5/0344a7c223c3610510ec7f91f7220b0e5852c92d52aff23377f1c2888880/hidapi-0.15.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d93f7173dcbec61b928f4cf800bf26535f81ef3b7bd89199237207f6ca7e4f43", size = 1610362, upload-time = "2025-12-09T09:45:58.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/96/781212cf852705bc1db26edd4340b01b9ce04cadd5d327c00b64f7324978/hidapi-0.15.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:dacf0b9e5d776fde69b92d68c09ab878a09486629e1fe8e6ae851252b0f33ce5", size = 1680942, upload-time = "2025-12-09T09:46:00.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/48/6d51a37d3a81355740a82cd265d4fc8485a0a279e4b545cbf790bd106f51/hidapi-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda6ce04a77662e77a4b6b2598211935ca281e1cf663248d54bce75bd684fed8", size = 707550, upload-time = "2025-12-09T09:46:03.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/175efa84ecea161c70948dc67b412b7444c2a196e4def8b305cb756077c6/hidapi-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74900b5c7e884e0e4316d4deb746e5dd294b5aaaf90eac056c4f9f19cf2c8097", size = 689865, upload-time = "2025-12-09T09:46:05.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/29/0e513390a8d686448672fabd30385f9cda6e01663765851d0cef88e6f0b1/hidapi-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:76236468509dfcb256fa0fbd5a73708c598b3819695b7d08499ae489c82116b6", size = 706809, upload-time = "2025-12-09T09:46:07.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d6/c0b9d8568fdb38eebb9caaa56643a81d92cccb5d59a5b44e0d975a0f3808/hidapi-0.15.0-cp314-cp314-win32.whl", hash = "sha256:901c03817306eac7edd589f09e0e07467871b45f665949cc4caa645b54abc97b", size = 61089, upload-time = "2025-12-09T09:46:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/14/3b27516b2867e53545164ebd8ef45d0c36857d2a062036e788e56e2e34ce/hidapi-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:df0dd435562654e27ad0c7d045074eb1e1e016b09167dd48a258dec1ce4b9127", size = 67710, upload-time = "2025-12-09T09:46:08.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8f/ff034661faf99acaabe8954fb7db87c5242c79bf763dd972b23b1be5f104/hidapi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2f16e9ea8a1d24ac7a6bf316905823eafd58f78ec815a93118d4710faf95a224", size = 70893, upload-time = "2025-12-09T09:46:10.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/44/617666a0919d06521d732ef07998306b8ddcbd96038e19348c831acd76e8/hidapi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77f0965ca81a9be44694e84aa18e501d5cda49e372202a38a52d22acc38deadd", size = 71430, upload-time = "2025-12-09T09:46:11.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/64/129aed49fa45b6a5e2fd955618eebe93ff7e3889f05237da6b45051c77bb/hidapi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541c85b8225ce19b38ceacd404283ca1857345ccfa7ca12f54131203ff2d7f75", size = 1094257, upload-time = "2025-12-09T09:46:17.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/266ca521ff643957823a7f04a67030f6d2917e86e31c60c3d4878cd6b913/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:89f909a6783307c2f067e8e7948f38ec43386f15d04759d11e5b32e3b2fdf3d8", size = 1663756, upload-time = "2025-12-09T09:46:13.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/eb9d0fe6d9ccd2645e993930cc7fdeb6e12ddcdec086f3ef7dec947286ca/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:fd32f311633fad21a52e750775cd4ea23da912199c63d1331f4d8ae00ab0874b", size = 1710998, upload-time = "2025-12-09T09:46:15.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/43/e5c0cd80df4a65a468b7a9fc6891ea77435a75e64bb273104c37f12e05b3/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f7ca38699557ab057333f1628a346073ae6088c09897b2cd9fbc68f4896780c", size = 751383, upload-time = "2025-12-09T09:46:19.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/8f/033e19857bcc3ad1b9880b5c0dfbaa7c945e577b7dee18919c1b2478484d/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1c4e707c82629637a311fa1a7adc1ae1386de7b539e84b4e37125e52e449318d", size = 722837, upload-time = "2025-12-09T09:46:20.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/f0/2b4e7ff42cdc6a6bf7def0591dfd3854af81038a1925ac1ed7a96578e834/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc99bb16b359023190bf857297f6492a1c8441ccacff66dac881c9e278f5c262", size = 750923, upload-time = "2025-12-09T09:46:23.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/eb/c0676ff1f7e9ec9d99eaa428c66745cdab5b1742808f0c16061c85bd0b18/hidapi-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:005c84c74c940a314b211674edc763a931ace0b63685ace88148496f563e25f9", size = 66288, upload-time = "2025-12-09T09:46:25.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/5b/f22dbf886824559d3e66d84710c5fb48c09856e6816885da97f2f51700eb/hidapi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e384430363d400c4ffb33fe65f22a61c9f288a870158dc5a9f5c9d917b7250c7", size = 74723, upload-time = "2025-12-09T09:46:24.36Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lva-jabra-speak-410"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "hidapi" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "hidapi", specifier = ">=0.15.0" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
@@ -0,0 +1,243 @@
|
||||
# Peripheral Web Console
|
||||
|
||||
The Peripheral Web Console is a web-based interface for testing and interacting with the Linux Voice Assistant WebSocket API. It allows you to monitor LVA state and send commands to control the voice assistant from a browser.
|
||||
|
||||
## Overview
|
||||
|
||||
This HTML console provides:
|
||||
- Real-time connection status to the LVA WebSocket server
|
||||
- Live event feed showing all LVA events (wake word detected, listening, TTS, timers, etc.)
|
||||
- Control buttons to send commands to LVA (mute/unmute, volume control, start listening, etc.)
|
||||
- Visual indicators for volume, mute state, and Home Assistant connection status
|
||||
- Audio level visualization
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1: Download the HTML File
|
||||
|
||||
Download [peripheral_web_console.html](peripheral_web_console.html)
|
||||
|
||||
|
||||
### Step 2: Open the HTML File in Your Browser
|
||||
|
||||
Open the file using any modern web browser (Chrome, Firefox, Safari, Edge):
|
||||
|
||||
<img src="image.png" width="300">
|
||||
|
||||
**Option A: Direct File Open**
|
||||
- Double-click the `peripheral_web_console.html` file
|
||||
- Or drag it into your browser window
|
||||
- The file will open as a local file URL (e.g., `file:///path/to/peripheral_web_console.html`)
|
||||
|
||||
**Option B: Using a Local Web Server**
|
||||
```bash
|
||||
# Using Python 3
|
||||
python -m http.server 8000
|
||||
|
||||
# Using Python 2
|
||||
python -m SimpleHTTPServer 8000
|
||||
|
||||
# Then navigate to: http://localhost:8000/peripheral_web_console.html
|
||||
```
|
||||
|
||||
### Step 3: Configure the WebSocket Connection URL
|
||||
|
||||
When the page loads, you'll see the **CONNECTION & STATUS** section at the top with:
|
||||
- Current WebSocket URL (default: `ws://localhost:6055`)
|
||||
- A "connect" button
|
||||
|
||||
**To modify the connection URL:**
|
||||
1. Look at the input field showing the WebSocket address
|
||||
2. By default, it points to `ws://localhost:6055`
|
||||
3. Change this if your LVA server is running on a different host or port:
|
||||
- Local development: `ws://localhost:6055`
|
||||
- Remote server: `ws://your-server-ip:6055`
|
||||
- Docker container: `ws://lva-container:6055` (if using Docker networking)
|
||||
|
||||
### Step 4: Connect to LVA
|
||||
|
||||
<img src="image_2.png" width="300">
|
||||
|
||||
1. Once you've configured the URL, click the **"connect"** button
|
||||
2. The page will attempt to establish a WebSocket connection to the LVA server
|
||||
3. On successful connection:
|
||||
- The status indicator changes from "disconnected" (gray dot) to "connected" (green dot)
|
||||
- You'll receive a "snapshot" event showing the current state (volume, mute status, last recognized text, etc.)
|
||||
- Event feed will show: `connecting to ws://...`, `connected`, `snapshot`
|
||||
|
||||
4. If connection fails:
|
||||
- Ensure LVA is running and the WebSocket server is active
|
||||
- Verify the URL is correct (check host and port)
|
||||
- Check browser console (F12 → Console tab) for error messages
|
||||
- Verify network connectivity and firewall rules
|
||||
|
||||
## Using the Web Console
|
||||
|
||||
### Connection & Status Section
|
||||
|
||||
Displays real-time information:
|
||||
- **Status Indicator**: Gray (disconnected) or Green (connected)
|
||||
- **WebSocket URL**: The connection endpoint
|
||||
- **STATE**: Current LVA state (idle, listening, thinking, etc.)
|
||||
- **VOLUME**: Current volume level (0-100%)
|
||||
- **MUTED**: Microphone mute status (yes/no)
|
||||
- **HA CONNECTED**: Home Assistant connection status
|
||||
- **AUDIO LEVEL**: Visual bar showing real-time audio input level
|
||||
|
||||
### Event Feed
|
||||
|
||||
Shows a chronological log of all events received from LVA:
|
||||
|
||||
**Example events:**
|
||||
- `connecting to ws://localhost:6055`
|
||||
- `connected`
|
||||
- `snapshot` - Initial state when connected
|
||||
- `wake_word_detected` - Wake word detected
|
||||
- `listening` - Microphone is recording
|
||||
- `stt_text: "set a timer for 10 seconds"` - Recognized speech
|
||||
- `thinking` - Processing the command
|
||||
- `tts_text: "OK, timer set."` - Assistant's response
|
||||
- `tts_speaking` - Playing the response audio
|
||||
- `idle` - Returned to waiting state
|
||||
- `timer_ticking`, `timer_ringing` - Timer events
|
||||
- `volume_changed`, `volume_muted` - Volume events
|
||||
- `disconnected` - Connection lost
|
||||
|
||||
Click "clear" to remove all events from the feed.
|
||||
|
||||
### Commands Section
|
||||
|
||||
Send commands to LVA by clicking buttons:
|
||||
|
||||
**Listening & Pipeline:**
|
||||
- **Start listening** - Manually trigger listening (as if wake word was detected)
|
||||
- **Stop pipeline** - Abort the current voice pipeline (stops listening, thinking, speaking)
|
||||
|
||||
**Audio Control:**
|
||||
- **Volume up** / **Volume down** - Adjust volume by small increments
|
||||
- **Set volume** - Set exact volume level using slider (0.0-1.0)
|
||||
- **Mute microphone** / **Unmute microphone** - Toggle microphone mute
|
||||
|
||||
**Media & Timers:**
|
||||
- **Pause media player** / **Resume media player** / **Stop media player** - Control music playback
|
||||
- **Stop timer ringing** - Silence an active timer alarm
|
||||
|
||||
**Button Presses:**
|
||||
- **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
|
||||
2. **Open** `peripheral_web_console.html` in browser
|
||||
3. **Click "connect"** to establish WebSocket connection
|
||||
4. **Observe events** as you interact with LVA via voice (e.g., "Hey Home Assistant, what time is it?")
|
||||
5. **Send commands** from the console (e.g., click "Mute microphone" to test mute functionality)
|
||||
6. **Watch feedback** in the event feed and status indicators
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Won't Establish
|
||||
- **Problem**: "disconnected" status persists
|
||||
- **Solutions**:
|
||||
- Verify LVA is running: `docker ps` (if using Docker) or check if the service started
|
||||
- Check the WebSocket URL is correct
|
||||
- Ensure firewall allows traffic on port 6055
|
||||
- Check browser console (F12) for detailed error messages
|
||||
|
||||
### No Events Appearing
|
||||
- **Problem**: Connected but no events in feed
|
||||
- **Solutions**:
|
||||
- Trigger an event manually (speak a wake word, click a button)
|
||||
- Check that the LVA peripheral API server is properly initialized
|
||||
- Verify the WebSocket connection is truly active (status should show "connected")
|
||||
|
||||
### Volume/State Not Updating
|
||||
- **Problem**: Controls don't seem to work
|
||||
- **Solutions**:
|
||||
- Confirm "connected" status is shown
|
||||
- Check that LVA is not muted at the system level
|
||||
- Try stopping and reconnecting
|
||||
- Check LVA logs for errors
|
||||
|
||||
### CORS or Mixed Content Errors
|
||||
- **Problem**: Console shows security errors
|
||||
- **Solutions**:
|
||||
- Use `http://` with `ws://` (not `https://` with `wss://`)
|
||||
- Serve the HTML over HTTP if testing locally
|
||||
- Ensure your LVA server is configured for WebSocket connections
|
||||
|
||||
## Protocol Details
|
||||
|
||||
The console communicates with LVA using JSON over WebSocket:
|
||||
|
||||
**Messages from LVA (Events):**
|
||||
```json
|
||||
{"event": "event_name", "data": {...}}
|
||||
```
|
||||
|
||||
**Messages to LVA (Commands):**
|
||||
```json
|
||||
{"command": "command_name", "data": {...}}
|
||||
```
|
||||
|
||||
**Initial Snapshot (on connect):**
|
||||
```json
|
||||
{
|
||||
"event": "snapshot",
|
||||
"data": {
|
||||
"muted": false,
|
||||
"volume": 1.0,
|
||||
"ha_connected": true,
|
||||
"last_stt_text": "...",
|
||||
"last_tts_text": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For detailed protocol documentation, see `peripheral_api.py` in the Linux Voice Assistant repository.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Accessing Remotely
|
||||
To access the console from another computer:
|
||||
|
||||
1. **Expose LVA server** to your network (modify firewall/port forwarding)
|
||||
2. **Open console** from remote machine
|
||||
3. **Update WebSocket URL** to your LVA server's network address:
|
||||
```
|
||||
ws://your-lva-server-ip:6055
|
||||
```
|
||||
|
||||
### Integration with Peripherals
|
||||
This console mirrors the interface used by physical peripherals (LED boards, buttons, etc.):
|
||||
- Hardware peripherals use the same WebSocket API
|
||||
- Testing here helps debug peripheral integrations
|
||||
- Both console and hardware can be connected simultaneously
|
||||
|
||||
### Development & Debugging
|
||||
Use browser Developer Tools (F12) to:
|
||||
- Monitor WebSocket messages in Network tab
|
||||
- Check console for JavaScript errors
|
||||
- Inspect HTML structure and styling
|
||||
- Test custom commands before deploying to hardware
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,466 @@
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--font-mono, monospace); font-size: 13px; background: var(--color-background-secondary, #f5f5f5); }
|
||||
.root { display: flex; flex-direction: column; gap: 12px; padding: 1rem; max-width: 1200px; margin: 0 auto; }
|
||||
.row { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; }
|
||||
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 1.2rem; flex: 1; min-width: 260px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.card-title { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 12px; }
|
||||
|
||||
/* Connection section */
|
||||
.conn-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.conn-row input { flex: 1; font-family: var(--font-mono); font-size: 12px; padding: 6px 8px; border: 0.5px solid var(--color-border-secondary); border-radius: 4px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; transition: background 0.2s; }
|
||||
.dot.off { background: #888780; }
|
||||
.dot.on { background: #1D9E75; }
|
||||
.dot.err { background: #E24B4A; }
|
||||
.conn-label { font-size: 12px; color: var(--color-text-secondary); min-width: 70px; font-weight: 500; }
|
||||
|
||||
/* State display */
|
||||
.state-section { display: flex; gap: 12px; align-items: center; margin: 12px 0; padding: 10px; background: var(--color-background-secondary); border-radius: 6px; }
|
||||
.state-display { display: flex; gap: 8px; align-items: center; flex: 1; }
|
||||
.state-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; background: var(--color-background-secondary); color: var(--color-text-primary); border: 1px solid var(--color-border-tertiary); transition: all 0.2s; min-width: 80px; text-align: center; }
|
||||
.state-badge.wake_word_detected { background: #EEEDFE; color: #3C3489; border-color: #AFA9EC; }
|
||||
.state-badge.listening { background: #E1F5EE; color: #085041; border-color: #5DCAA5; }
|
||||
.state-badge.thinking { background: #FAEEDA; color: #633806; border-color: #EF9F27; }
|
||||
.state-badge.tts_speaking { background: #E6F1FB; color: #0C447C; border-color: #85B7EB; }
|
||||
.state-badge.muted { background: #FCEBEB; color: #791F1F; border-color: #F09595; }
|
||||
.state-badge.pipeline_error { background: #FCEBEB; color: #A32D2D; border-color: #E24B4A; animation: pulse 0.5s ease-out; }
|
||||
.state-badge.timer_ringing { background: #FAECE7; color: #712B13; border-color: #F0997B; }
|
||||
.state-badge.media_player_playing { background: #EAF3DE; color: #27500A; border-color: #97C459; }
|
||||
@keyframes pulse { 0% { transform: scale(1.05); } 100% { transform: scale(1); } }
|
||||
|
||||
/* Meta info grid */
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 10px; }
|
||||
.meta-item { display: flex; flex-direction: column; }
|
||||
.meta-label { font-size: 10px; font-weight: 600; color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|
||||
.meta-value { font-size: 13px; font-weight: 500; color: var(--color-text-primary); font-family: var(--font-mono); }
|
||||
|
||||
/* Timer display - prominent */
|
||||
.timer-box { background: linear-gradient(135deg, #0C447C 0%, #085041 100%); color: white; padding: 12px; border-radius: 8px; text-align: center; margin-top: 10px; }
|
||||
.timer-box.hidden { display: none; }
|
||||
.timer-name { font-size: 11px; font-weight: 500; opacity: 0.9; }
|
||||
.timer-countdown { font-size: 24px; font-weight: 700; font-family: var(--font-mono); letter-spacing: 2px; }
|
||||
|
||||
/* Volume bar */
|
||||
.vol-section { margin-top: 10px; }
|
||||
.vol-label { font-size: 10px; font-weight: 600; color: var(--color-text-tertiary); text-transform: uppercase; margin-bottom: 4px; }
|
||||
.vol-bar-wrap { height: 8px; background: var(--color-background-secondary); border-radius: 4px; overflow: hidden; }
|
||||
.vol-bar { height: 100%; background: #1D9E75; border-radius: 4px; transition: width 0.3s; }
|
||||
|
||||
/* Feed */
|
||||
.feed { height: 280px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||
.feed-item { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px; border-radius: 4px; font-size: 11px; }
|
||||
.feed-item:hover { background: var(--color-background-secondary); }
|
||||
.feed-ts { color: var(--color-text-tertiary); font-size: 10px; flex-shrink: 0; min-width: 55px; font-weight: 500; }
|
||||
.feed-event { color: var(--color-text-primary); font-weight: 500; }
|
||||
.feed-data { color: var(--color-text-secondary); font-size: 10px; }
|
||||
.feed-item.sent .feed-event { color: #185FA5; }
|
||||
.feed-item.recv .feed-event { color: #3B6D11; }
|
||||
.feed-item.system .feed-event { color: #888780; font-style: italic; }
|
||||
.feed-item.err-item .feed-event { color: #A32D2D; }
|
||||
|
||||
/* Last conversation context */
|
||||
.context-box { background: #f9f9f9; border-left: 3px solid #85B7EB; padding: 8px 10px; margin-top: 8px; border-radius: 4px; font-size: 11px; }
|
||||
.context-label { color: var(--color-text-tertiary); font-weight: 600; text-transform: uppercase; font-size: 9px; letter-spacing: 0.05em; }
|
||||
.context-text { color: var(--color-text-primary); margin-top: 4px; font-style: italic; }
|
||||
.context-box.hidden { display: none; }
|
||||
|
||||
/* Commands */
|
||||
.cmd-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
|
||||
.cmd-btn { font-family: var(--font-mono); font-size: 11px; padding: 8px 10px; text-align: center; cursor: pointer; border-radius: 4px; background: var(--color-background-primary); border: 0.5px solid var(--color-border-secondary); color: var(--color-text-primary); transition: all 0.1s; font-weight: 500; }
|
||||
.cmd-btn:hover { background: var(--color-background-secondary); }
|
||||
.cmd-btn:active { transform: scale(0.97); }
|
||||
.cmd-btn.danger { border-color: #F09595; color: #A32D2D; }
|
||||
.cmd-btn.danger:hover { background: #FCEBEB; }
|
||||
.cmd-btn.primary { border-color: #85B7EB; color: #0C447C; }
|
||||
.cmd-btn.primary:hover { background: #E6F1FB; }
|
||||
|
||||
/* Volume input row */
|
||||
.vol-input-row { display: flex; gap: 6px; margin-top: 8px; }
|
||||
#volume-input { flex: 1; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; border: 0.5px solid var(--color-border-secondary); border-radius: 4px; }
|
||||
|
||||
.clear-btn { font-size: 10px; padding: 4px 8px; cursor: pointer; background: none; border: 0.5px solid var(--color-border-tertiary); border-radius: 3px; color: var(--color-text-secondary); float: right; margin-top: -2px; transition: all 0.1s; }
|
||||
.clear-btn:hover { background: var(--color-background-secondary); }
|
||||
</style>
|
||||
|
||||
<div class="root">
|
||||
<h2 class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;">LVA peripheral API web console — connect to a running LVA instance to view events and send commands</h2>
|
||||
|
||||
<!-- Connection & Status Card -->
|
||||
<div class="card">
|
||||
<div class="card-title">Connection & Status</div>
|
||||
|
||||
<div class="conn-row">
|
||||
<input type="text" id="url" value="ws://localhost:6055" placeholder="ws://host:6055" />
|
||||
<button id="conn-btn" class="cmd-btn primary" style="min-width:75px;">connect</button>
|
||||
</div>
|
||||
|
||||
<div class="state-section">
|
||||
<div class="dot off" id="dot"></div>
|
||||
<span class="conn-label" id="conn-label">disconnected</span>
|
||||
<span style="margin-left:auto; font-size:10px; color:var(--color-text-secondary); text-transform: uppercase; font-weight: 600;">state</span>
|
||||
<span class="state-badge" id="state-badge">idle</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Volume</span>
|
||||
<span class="meta-value" id="meta-vol">—</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Muted</span>
|
||||
<span class="meta-value" id="meta-muted">—</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<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 -->
|
||||
<div class="vol-section">
|
||||
<div class="vol-label">Audio Level</div>
|
||||
<div class="vol-bar-wrap"><div class="vol-bar" id="vol-bar" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Timer display (prominent) -->
|
||||
<div class="timer-box hidden" id="timer-box">
|
||||
<div class="timer-name" id="timer-name"></div>
|
||||
<div class="timer-countdown" id="timer-countdown">0:00</div>
|
||||
</div>
|
||||
|
||||
<!-- Last conversation context -->
|
||||
<div class="context-box hidden" id="stt-context">
|
||||
<div class="context-label">You said:</div>
|
||||
<div class="context-text" id="stt-text"></div>
|
||||
</div>
|
||||
<div class="context-box hidden" id="tts-context">
|
||||
<div class="context-label">Assistant said:</div>
|
||||
<div class="context-text" id="tts-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main layout: Feed + Commands -->
|
||||
<div class="row">
|
||||
<div class="card" style="flex:2; min-width:340px;">
|
||||
<div class="card-title">Event Feed <button class="clear-btn" id="clear-btn">clear</button></div>
|
||||
<div class="feed" id="feed"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="flex:1; min-width:280px;">
|
||||
<div class="card-title">Commands</div>
|
||||
<div class="cmd-grid">
|
||||
<button class="cmd-btn primary" onclick="send('start_listening')">Start listening</button>
|
||||
<button class="cmd-btn danger" onclick="send('stop_pipeline')">■ Stop pipeline</button>
|
||||
<button class="cmd-btn danger" onclick="send('stop_timer_ringing')">■ Stop timer ringing</button>
|
||||
<button class="cmd-btn" onclick="send('mute_mic')">Mute microphone</button>
|
||||
<button class="cmd-btn" onclick="send('unmute_mic')">Unmute microphone</button>
|
||||
<button class="cmd-btn" onclick="send('volume_up')">Volume up</button>
|
||||
<button class="cmd-btn" onclick="send('volume_down')">Volume down</button>
|
||||
<button class="cmd-btn" onclick="send('pause_media_player')">⏸ Pause media player</button>
|
||||
<button class="cmd-btn" onclick="send('resume_media_player')">▶ Resume media player</button>
|
||||
<button class="cmd-btn" onclick="send('stop_media_player')">■ Stop media player</button>
|
||||
<button class="cmd-btn" onclick="send('button_single_press')">◉ Single press</button>
|
||||
<button class="cmd-btn" onclick="send('button_double_press')">◉ Double press</button>
|
||||
<button class="cmd-btn" onclick="send('button_triple_press')">◉ Triple press</button>
|
||||
<button class="cmd-btn" onclick="send('button_long_press')">◉ Long press</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume input -->
|
||||
<div class="vol-input-row">
|
||||
<button class="cmd-btn primary" style="flex: 0 0 auto;" onclick="sendSetVolume()">Set volume</button>
|
||||
<input type="text" id="volume-input" placeholder="0.0–1.0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let timerInterval = null;
|
||||
let errorTimeout = null;
|
||||
let currentTimer = null;
|
||||
let previousState = null;
|
||||
|
||||
const feed = document.getElementById('feed');
|
||||
const dot = document.getElementById('dot');
|
||||
const label = document.getElementById('conn-label');
|
||||
const badge = document.getElementById('state-badge');
|
||||
const btn = document.getElementById('conn-btn');
|
||||
const volBar = document.getElementById('vol-bar');
|
||||
const metaVol = document.getElementById('meta-vol');
|
||||
const metaMuted = document.getElementById('meta-muted');
|
||||
const metaHa = document.getElementById('meta-ha');
|
||||
|
||||
const timerBox = document.getElementById('timer-box');
|
||||
const timerName = document.getElementById('timer-name');
|
||||
const timerCountdown = document.getElementById('timer-countdown');
|
||||
|
||||
const sttContext = document.getElementById('stt-context');
|
||||
const sttText = document.getElementById('stt-text');
|
||||
const ttsContext = document.getElementById('tts-context');
|
||||
const ttsText = document.getElementById('tts-text');
|
||||
|
||||
const STATES = ['wake_word_detected','listening','thinking','tts_speaking','muted','pipeline_error','timer_ringing','media_player_playing'];
|
||||
|
||||
// LED Light entity registration constants
|
||||
const LIGHT_NAME = 'Web Console LEDs';
|
||||
const LIGHT_OBJECT_ID = 'web_console_leds';
|
||||
const EFFECT_VOICE_ASSISTANT = 'Voice Assistant';
|
||||
const EFFECT_LOOP = 'Loop';
|
||||
const EFFECT_NONE = 'None';
|
||||
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
return d.getHours().toString().padStart(2,'0') + ':' +
|
||||
d.getMinutes().toString().padStart(2,'0') + ':' +
|
||||
d.getSeconds().toString().padStart(2,'0');
|
||||
}
|
||||
|
||||
function log(cls, eventStr, dataStr) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'feed-item ' + cls;
|
||||
item.innerHTML =
|
||||
'<span class="feed-ts">' + ts() + '</span>' +
|
||||
'<div><div class="feed-event">' + eventStr + '</div>' +
|
||||
(dataStr ? '<div class="feed-data">' + dataStr + '</div>' : '') +
|
||||
'</div>';
|
||||
feed.appendChild(item);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function setState(ev) {
|
||||
// Temporary error display: show for 4 seconds then clear
|
||||
if (ev === 'pipeline_error') {
|
||||
badge.textContent = ev;
|
||||
badge.className = 'state-badge ' + ev;
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
badge.textContent = previousState || 'idle';
|
||||
badge.className = 'state-badge ' + (previousState || 'idle');
|
||||
}, 3000);
|
||||
} else {
|
||||
previousState = ev;
|
||||
badge.textContent = ev;
|
||||
badge.className = 'state-badge ' + (STATES.includes(ev) ? ev : '');
|
||||
}
|
||||
}
|
||||
|
||||
function setConnected(ok, err) {
|
||||
dot.className = 'dot ' + (ok ? 'on' : err ? 'err' : 'off');
|
||||
label.textContent = ok ? 'connected' : err ? 'error' : 'disconnected';
|
||||
btn.textContent = ok ? 'disconnect' : 'connect';
|
||||
}
|
||||
|
||||
function updateTimerDisplay() {
|
||||
if (!currentTimer) {
|
||||
clearInterval(timerInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = currentTimer.seconds_left;
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = remaining % 60;
|
||||
timerCountdown.textContent = m + ':' + s.toString().padStart(2, '0');
|
||||
timerName.textContent = currentTimer.name || 'timer';
|
||||
|
||||
timerBox.classList.remove('hidden');
|
||||
|
||||
// Only decrement if still counting (not ringing)
|
||||
if (!currentTimer.ringing) {
|
||||
currentTimer.seconds_left -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function startTimerCountdown() {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(updateTimerDisplay, 1000);
|
||||
updateTimerDisplay(); // Immediate update
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const url = document.getElementById('url').value.trim();
|
||||
log('system', '→ connecting to ' + url);
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true, false);
|
||||
log('system', '→ connected');
|
||||
|
||||
// Register the LED Light entity with LVA so HA can control it.
|
||||
// LVA treats repeat registrations for the same object_id as a
|
||||
// no-op, so it's safe to send this on every connect.
|
||||
ws.send(JSON.stringify({
|
||||
command: 'register_light',
|
||||
data: {
|
||||
name: LIGHT_NAME,
|
||||
object_id: LIGHT_OBJECT_ID,
|
||||
effects: [EFFECT_VOICE_ASSISTANT, EFFECT_LOOP, EFFECT_NONE],
|
||||
supports_rgb: true,
|
||||
supports_brightness: true,
|
||||
},
|
||||
}));
|
||||
log('sent', '↑ register_light', JSON.stringify({
|
||||
name: LIGHT_NAME,
|
||||
object_id: LIGHT_OBJECT_ID,
|
||||
effects: [EFFECT_VOICE_ASSISTANT],
|
||||
}));
|
||||
|
||||
// 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 = () => {
|
||||
setConnected(false, false);
|
||||
setState('idle');
|
||||
log('system', '→ disconnected');
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false, true);
|
||||
log('err-item', '→ connection error — check host/port');
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(e.data); } catch { log('recv', e.data); return; }
|
||||
|
||||
const ev = msg.event || '?';
|
||||
const data = msg.data;
|
||||
|
||||
let dataStr = data ? JSON.stringify(data) : '';
|
||||
|
||||
if (ev === 'snapshot') {
|
||||
metaVol.textContent = data.volume !== undefined ? (data.volume * 100).toFixed(0) + '%' : '—';
|
||||
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) {
|
||||
sttText.textContent = data.last_stt_text;
|
||||
sttContext.classList.remove('hidden');
|
||||
}
|
||||
if (data.last_tts_text) {
|
||||
ttsText.textContent = data.last_tts_text;
|
||||
ttsContext.classList.remove('hidden');
|
||||
}
|
||||
|
||||
log('recv', 'snapshot', dataStr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev === 'volume_changed' && data) {
|
||||
const pct = (data.volume * 100).toFixed(0);
|
||||
metaVol.textContent = pct + '%';
|
||||
volBar.style.width = pct + '%';
|
||||
}
|
||||
if (ev === 'volume_muted' && data) {
|
||||
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…';
|
||||
}
|
||||
|
||||
if (ev === 'stt_text' && data) {
|
||||
sttText.textContent = data.text;
|
||||
sttContext.classList.remove('hidden');
|
||||
}
|
||||
if (ev === 'tts_text' && data) {
|
||||
ttsText.textContent = data.text;
|
||||
ttsContext.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if ((ev === 'timer_ticking' || ev === 'timer_updated') && data) {
|
||||
currentTimer = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
seconds_left: data.seconds_left,
|
||||
ringing: false
|
||||
};
|
||||
startTimerCountdown();
|
||||
}
|
||||
if (ev === 'timer_ringing' && data) {
|
||||
if (currentTimer) {
|
||||
currentTimer.ringing = true;
|
||||
timerName.textContent = (data && data.name) ? data.name + ' 🔔' : 'ringing';
|
||||
timerCountdown.textContent = '0:00';
|
||||
timerBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
if (ev === 'button_lock_changed' && data) {
|
||||
buttonsLocked = !!data.locked;
|
||||
applyButtonLockUI();
|
||||
}
|
||||
|
||||
setState(ev);
|
||||
log('recv', ev, dataStr);
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
}
|
||||
|
||||
btn.onclick = () => ws ? disconnect() : connect();
|
||||
document.getElementById('clear-btn').onclick = () => { feed.innerHTML = ''; };
|
||||
|
||||
function send(command, data) {
|
||||
if (!ws || ws.readyState !== 1) {
|
||||
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));
|
||||
log('sent', '↑ ' + command, data ? JSON.stringify(data) : '');
|
||||
}
|
||||
|
||||
function sendSetVolume() {
|
||||
const volumeInput = document.getElementById('volume-input');
|
||||
const volume = parseFloat(volumeInput.value);
|
||||
|
||||
if (isNaN(volume)) {
|
||||
log('err-item', '✗ invalid volume — enter 0.0–1.0');
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0.0, Math.min(1.0, volume));
|
||||
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>
|
||||
@@ -0,0 +1,349 @@
|
||||
# ReSpeaker 2-Mic Pi HAT
|
||||
|
||||
<img src="image.png" width="300">
|
||||
|
||||
Peripheral controller for the [ReSpeaker 2-Mic Pi HAT](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) by Seeed Studio, 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 3 APA102 RGB LEDs, and maps the single onboard button to context-aware LVA commands with multipress support.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Microphone array | 2 × MEMS mics — MIC_L (left), MIC_R (right) — WM8960 codec, I2S |
|
||||
| LED strip | 3 × APA102 RGB LEDs in a row |
|
||||
| Speaker output | JST 2.0 connector + 3.5 mm audio jack (WM8960 codec) |
|
||||
| Button | 1 × onboard tactile button (GPIO 17) |
|
||||
| Interface | Raspberry Pi 40-pin HAT connector |
|
||||
|
||||
### Compatible hardware
|
||||
|
||||
| Board | Notes |
|
||||
|---|---|
|
||||
| Raspberry Pi Zero 2 W | Recommended for compact builds |
|
||||
| Raspberry Pi 3 B / B+ | Fully supported |
|
||||
| Raspberry Pi 4 B | Fully supported |
|
||||
| Raspberry Pi 5 | Requires seeed-voicecard driver compatible with kernel 6.6+ — see Step 1 |
|
||||
|
||||
---
|
||||
|
||||
## GPIO pin mapping
|
||||
|
||||
### LED strip (APA102)
|
||||
|
||||
| Signal | GPIO (BCM) | Notes |
|
||||
|---|---|---|
|
||||
| MOSI (data) | GPIO 10 | SPI0 MOSI |
|
||||
| SCLK (clock) | GPIO 11 | SPI0 SCLK |
|
||||
| CE | GPIO 8 (CE0) | /dev/spidev0.0 |
|
||||
|
||||
### Button
|
||||
|
||||
| Function | GPIO (BCM) | Notes |
|
||||
|---|---|---|
|
||||
| Context action | GPIO 17 | Active low, internal pull-up enabled |
|
||||
|
||||
---
|
||||
|
||||
## LED positions
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ [LED 0] [LED 1] [LED 2] │
|
||||
│ MIC_L centre MIC_R │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
LED 0 sits above MIC_L and LED 2 sits above MIC_R. When the microphone is muted, these two LEDs turn red to visually indicate the mic positions are disabled. The centre LED (1) is used for general status animations.
|
||||
|
||||
---
|
||||
|
||||
## LED animations
|
||||
|
||||
| LVA state | Animation | LEDs |
|
||||
|---|---|---|
|
||||
| Not ready / no HA connection | Dim red pulse | All 3 |
|
||||
| Idle | Solid user color when the LED light is on, else off | All 3 |
|
||||
| Wake word detected | Flash (×2) in user color | All 3 |
|
||||
| Listening | Chase (bouncing left ↔ right) in user color | 1 at a time |
|
||||
| Thinking | Yellow pulse | All 3 |
|
||||
| TTS speaking | Green breathe (slow sine) | All 3 |
|
||||
| **Muted** | **Solid red** | **LEDs 0 & 2 only (mic positions)** |
|
||||
| Error | Red flash (×3), then off | All 3 |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Light entity
|
||||
|
||||
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.<satellite>_leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
|
||||
|
||||
### Effects
|
||||
|
||||
| Effect | Behaviour |
|
||||
|---|---|
|
||||
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Wake word and Listening are tinted with the HA color; brightness scales every animation. |
|
||||
|
||||
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
|
||||
|
||||
### Brightness, on/off, and color
|
||||
|
||||
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Wake word / Listening animations.
|
||||
|
||||
---
|
||||
|
||||
## Button behaviour
|
||||
|
||||
The single onboard button supports both single-press context actions and multi-press gestures:
|
||||
|
||||
### Single press (< 1000 ms hold time)
|
||||
|
||||
Context-aware command based on current state, mirroring the Home Assistant Voice PE centre button priority:
|
||||
|
||||
| Current state | Command sent |
|
||||
|---|---|
|
||||
| Timer ringing | `stop_timer_ringing` |
|
||||
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
|
||||
| Music / media playing | `stop_media_player` |
|
||||
| Any other (idle) | `start_listening` |
|
||||
|
||||
### Multi-press gestures
|
||||
|
||||
Detected via press timing within a detection window (250 ms between releases, matching the HAVPE centre button):
|
||||
|
||||
| Gesture | Timing | Command sent |
|
||||
|---|---|---|
|
||||
| **Double press** | 2 presses < 250 ms apart | `button_double_press` |
|
||||
| **Triple press** | 3 presses < 250 ms apart | `button_triple_press` |
|
||||
| **Long press** | Single press held > 1000 ms | `button_long_press` |
|
||||
|
||||
Each gesture plays its own short confirmation sound on the satellite speaker so the user knows it was detected. Sound paths are configurable via `--button-double-press-sound`, `--button-triple-press-sound`, and `--button-long-press-sound`.
|
||||
|
||||
Multi-press commands are useful for triggering custom Home Assistant automations. For example:
|
||||
- Double press → start a specific routine or mode
|
||||
- Triple press → access a menu or configuration option
|
||||
- Long press → toggle do-not-disturb or night mode
|
||||
|
||||
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
|
||||
|
||||
### Step 1 — Install the seeed-voicecard audio driver
|
||||
|
||||
> **This must be done on the host Raspberry Pi, not inside Docker.**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/respeaker/seeed-voicecard
|
||||
cd seeed-voicecard
|
||||
sudo ./install.sh
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After rebooting, verify the microphone and speaker appear:
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
# Should list: seeded-2mic-voicecard
|
||||
|
||||
aplay -l
|
||||
# Should list: seeded-2mic-voicecard
|
||||
```
|
||||
|
||||
> **Note:** The seeed-voicecard installer also enables SPI automatically. Check `/boot/firmware/config.txt` after running `install.sh` before proceeding to step 2.
|
||||
|
||||
> **Pi 5 note:** The standard seeed-voicecard repository may not support the Pi 5 kernel (6.6+). Check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch or fork before proceeding.
|
||||
|
||||
### Step 2 — Enable SPI in config.txt
|
||||
|
||||
If `dtparam=spi=on` is not already present in `/boot/firmware/config.txt`, add it:
|
||||
|
||||
```ini
|
||||
dtparam=spi=on
|
||||
|
||||
# Also disable onboard audio if you see conflicts:
|
||||
# dtparam=audio=on ← comment this out
|
||||
```
|
||||
|
||||
Reboot and verify:
|
||||
|
||||
```bash
|
||||
ls /dev/spidev*
|
||||
# Should show: /dev/spidev0.0
|
||||
```
|
||||
|
||||
### Step 3 — Add user to GPIO and SPI groups
|
||||
|
||||
```bash
|
||||
sudo usermod -aG gpio,spi $USER
|
||||
```
|
||||
|
||||
Log out and back in. Check your UID:
|
||||
|
||||
```bash
|
||||
id -u $USER
|
||||
```
|
||||
|
||||
If it is not `1000`, update the `user:` field in `compose.yml` to match.
|
||||
|
||||
> **Pi 5 note:** On Pi 5, GPIO is exposed as `/dev/gpiochip4` not `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
|
||||
> ```yaml
|
||||
> devices:
|
||||
> - /dev/spidev0.0:/dev/spidev0.0
|
||||
> - /dev/gpiochip4:/dev/gpiochip4
|
||||
> ```
|
||||
|
||||
### Step 4 — File structure
|
||||
|
||||
```
|
||||
ReSpeaker 2mic HAT/
|
||||
├── Dockerfile
|
||||
├── compose.yml
|
||||
├── requirements.txt
|
||||
└── respeaker_2mic_hat.py
|
||||
```
|
||||
|
||||
### Step 5 — Build and start
|
||||
|
||||
#### Option A — Run with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
#### Option B — Run directly with Python
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python respeaker_2mic_hat.py --host localhost --port 6055
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the speaker output with LVA
|
||||
|
||||
The WM8960 codec on the HAT also drives the speaker. After installing seeed-voicecard, pass the device name to LVA:
|
||||
|
||||
```bash
|
||||
--audio-output-device "seeed-2mic-voicecard"
|
||||
```
|
||||
|
||||
Or set the environment variable in LVA's compose file:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- AUDIO_OUTPUT_DEVICE=seeed-2mic-voicecard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is at the top of `respeaker_2mic_hat.py`:
|
||||
|
||||
```python
|
||||
# LVA connection
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# APA102 SPI
|
||||
SPI_BUS = 0
|
||||
SPI_DEVICE = 0 # /dev/spidev0.0
|
||||
SPI_SPEED_HZ = 8_000_000
|
||||
LED_COUNT = 3
|
||||
LED_BRIGHTNESS = 0.6 # 0.0–1.0
|
||||
|
||||
# GPIO button
|
||||
BTN_ACTION = 17
|
||||
BTN_DEBOUNCE_MS = 150
|
||||
|
||||
# Button multipress timing
|
||||
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
|
||||
LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
|
||||
```
|
||||
|
||||
### Command-line arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host` | `localhost` | LVA container hostname or IP |
|
||||
| `--port` | `6055` | LVA peripheral API port |
|
||||
| `--debug` | off | Enable verbose debug logging |
|
||||
|
||||
---
|
||||
|
||||
## Drivers summary
|
||||
|
||||
| Component | Driver needed | Where to install | How |
|
||||
|---|---|---|---|
|
||||
| Microphones + speaker (WM8960) | **seeed-voicecard** | **Host Pi** | `git clone` + `sudo ./install.sh`, then reboot |
|
||||
| LED strip (APA102) | SPI overlay | **Host Pi** | `dtparam=spi=on` in `config.txt`, then reboot |
|
||||
| Button | None | — | `gpiozero` + `lgpio` installed inside container via pip |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Microphone or speaker not detected by LVA
|
||||
|
||||
1. Confirm `arecord -l` and `aplay -l` show `seeed-2mic-voicecard` on the host.
|
||||
2. If not, re-run `sudo ./install.sh` and reboot.
|
||||
3. On Raspberry Pi 5 with kernel 6.6+, check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch.
|
||||
|
||||
### LEDs do not light up
|
||||
|
||||
1. Confirm `dtparam=spi=on` is in `/boot/firmware/config.txt` and the Pi has been rebooted.
|
||||
2. Check `/dev/spidev0.0` exists: `ls /dev/spidev*`.
|
||||
3. Confirm the container user is in the `spi` group: `groups $USER`.
|
||||
4. Run with `--debug` and look for `APA102 SPI driver opened` in the logs. If `spidev not found` appears, the Python package failed to install — rebuild the image.
|
||||
|
||||
### Button does not respond
|
||||
|
||||
1. Confirm `/dev/gpiochip0` (or `/dev/gpiochip4` on Pi 5) is mapped in the compose `devices` section.
|
||||
2. Run with `--debug` — each button press logs `Button → <command>`.
|
||||
|
||||
### Pi 5 — button not working
|
||||
|
||||
On Pi 5, GPIO is on `/dev/gpiochip4` not `/dev/gpiochip0`. Update `compose.yml`:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/spidev0.0:/dev/spidev0.0
|
||||
- /dev/gpiochip4:/dev/gpiochip4
|
||||
```
|
||||
|
||||
### LVA not reachable
|
||||
|
||||
1. Confirm LVA is running and `--disable-peripheral-api` was not passed.
|
||||
2. With `network_mode: host`, `localhost` resolves to the Pi itself.
|
||||
3. Check port 6055 is not blocked: `nc -zv localhost 6055`.
|
||||
@@ -0,0 +1,65 @@
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.title="ReSpeaker 2-Mic Pi HAT Peripheral Controller" \
|
||||
org.opencontainers.image.description="LED and button controller for ReSpeaker 2-Mic Pi HAT, communicating with Linux Voice Assistant" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# System packages
|
||||
#
|
||||
# The lgpio Python package wraps the system liblgpio C library, which is
|
||||
# not in Debian's repos (Raspberry Pi OS ships it, upstream Debian does
|
||||
# not). On aarch64 with no prebuilt wheel we compile the C library from
|
||||
# source and then build the Python bindings against it.
|
||||
#
|
||||
# build-essential compiles the lgpio C library, spidev, and the
|
||||
# Python lgpio extension
|
||||
# python3-dev Python C headers for the Python lgpio extension
|
||||
# swig generates the Python lgpio bindings at build time
|
||||
# git clones the lgpio C library source
|
||||
# ----------------------------------------------------------------------------
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
swig \
|
||||
git \
|
||||
ca-certificates && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build and install the lgpio C library so the lgpio Python package can
|
||||
# link against it. Pinned to a tagged release (v0.2.2) for reproducibility.
|
||||
RUN git clone --depth 1 --branch v0.2.2 https://github.com/joan2937/lg.git /tmp/lg && \
|
||||
cd /tmp/lg && make && make install && ldconfig && \
|
||||
rm -rf /tmp/lg
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Python dependencies
|
||||
#
|
||||
# websockets – WebSocket client for LVA peripheral API
|
||||
# spidev – low-level SPI access for APA102 LEDs
|
||||
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
|
||||
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
|
||||
# ----------------------------------------------------------------------------
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY respeaker_2mic_hat.py ./
|
||||
|
||||
# Make /app writable by the runtime user. lgpio creates notification
|
||||
# files (.lgd-nfy-N) in the process working directory at startup, and
|
||||
# compose runs this container as a non root user for device access.
|
||||
# The default root owned /app blocks lgpio from initialising, which
|
||||
# makes gpiozero fall through every pin factory.
|
||||
RUN chmod a+w /app
|
||||
|
||||
ENTRYPOINT ["python3", "respeaker_2mic_hat.py"]
|
||||
CMD []
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
# ReSpeaker 2-Mic Pi HAT – peripheral controller
|
||||
#
|
||||
# Runs alongside the linux-voice-assistant container.
|
||||
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
|
||||
# 3 APA102 RGB LEDs and the single onboard button on the ReSpeaker 2-Mic HAT.
|
||||
#
|
||||
# See hardware notes at the bottom of this file before first run.
|
||||
|
||||
services:
|
||||
respeaker2mic-peripheral:
|
||||
build: .
|
||||
image: ohf/respeaker2mic-peripheral:local
|
||||
container_name: respeaker2mic-peripheral
|
||||
restart: unless-stopped
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Device access
|
||||
#
|
||||
# /dev/spidev0.0 – SPI0 CE0, used by spidev for the APA102 LED strip
|
||||
# /dev/gpiomem – allows RPi.GPIO to access the button pin without root
|
||||
# -----------------------------------------------------------------------
|
||||
devices:
|
||||
- /dev/spidev0.0:/dev/spidev0.0
|
||||
- /dev/gpiomem:/dev/gpiomem
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Run as normal user — no privileged mode required.
|
||||
# Update UID:GID if your host user is not 1000:1000.
|
||||
# -----------------------------------------------------------------------
|
||||
user: "1000:1000"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SPI / GPIO group membership
|
||||
#
|
||||
# docker compose's group_add resolves names against /etc/group inside
|
||||
# the container, but the python:slim base does not define gpio or
|
||||
# spi. Pass numeric GIDs instead so they match the host's groups.
|
||||
# The defaults below match a stock Raspberry Pi OS install. Check
|
||||
# yours with "getent group gpio spi" and override in .env if they
|
||||
# differ.
|
||||
# -----------------------------------------------------------------------
|
||||
group_add:
|
||||
- "${GPIO_GID:-986}"
|
||||
- "${SPI_GID:-989}"
|
||||
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Host network so "localhost" reaches the LVA container directly.
|
||||
# Change --host if LVA is on a different machine.
|
||||
# -----------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
command:
|
||||
- "--host"
|
||||
- "localhost"
|
||||
- "--port"
|
||||
- "6055"
|
||||
# Uncomment for verbose logging during setup:
|
||||
# - "--debug"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "respeaker_2mic_hat.py"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HARDWARE NOTES
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. AUDIO DRIVER — seeed-voicecard (REQUIRED for microphone input)
|
||||
#
|
||||
# The ReSpeaker 2-Mic HAT uses a WM8960 I2S codec for both the
|
||||
# microphones and the speaker output. Install the driver on the HOST Pi:
|
||||
#
|
||||
# git clone https://github.com/respeaker/seeed-voicecard
|
||||
# cd seeed-voicecard
|
||||
# sudo ./install.sh
|
||||
# sudo reboot
|
||||
#
|
||||
# Verify after reboot:
|
||||
# arecord -l
|
||||
# # Should list: seeed-2mic-voicecard
|
||||
#
|
||||
# The seeed-voicecard installer also enables SPI automatically.
|
||||
# Check /boot/firmware/config.txt before proceeding to step 2.
|
||||
#
|
||||
# 2. SPI OVERLAY (REQUIRED for LED ring)
|
||||
#
|
||||
# The APA102 LEDs use SPI0 CE0 (/dev/spidev0.0).
|
||||
# If not already added by seeed-voicecard, add to /boot/firmware/config.txt:
|
||||
#
|
||||
# dtparam=spi=on
|
||||
#
|
||||
# Then reboot and verify:
|
||||
# ls /dev/spidev* # Should show /dev/spidev0.0
|
||||
#
|
||||
# 3. GPIO GROUP (REQUIRED for button input)
|
||||
#
|
||||
# Add the host user to the gpio and spi groups:
|
||||
#
|
||||
# sudo usermod -aG gpio,spi $USER
|
||||
#
|
||||
# Then log out and back in. Check your UID:
|
||||
# id -u $USER # Update user: field above if not 1000
|
||||
#
|
||||
# 4. SPEAKER OUTPUT
|
||||
#
|
||||
# The WM8960 codec also drives the JST 2.0 speaker connector and the
|
||||
# 3.5 mm audio jack. After installing seeed-voicecard, the speaker
|
||||
# appears as an ALSA playback device. Pass it to LVA via:
|
||||
#
|
||||
# --audio-output-device "seeed-2mic-voicecard"
|
||||
#
|
||||
# 5. ONBOARD AUDIO CONFLICT
|
||||
#
|
||||
# The ReSpeaker 2-Mic HAT replaces the Pi's onboard audio.
|
||||
# Disable it in /boot/firmware/config.txt if you see conflicts:
|
||||
#
|
||||
# # dtparam=audio=on ← comment this out
|
||||
#
|
||||
# =============================================================================
|
||||
|
After Width: | Height: | Size: 824 KiB |
@@ -0,0 +1,4 @@
|
||||
websockets>=12.0
|
||||
spidev>=3.6
|
||||
gpiozero>=2.0
|
||||
lgpio>=0.2
|
||||
@@ -0,0 +1,240 @@
|
||||
# ReSpeaker 4-Mic Array HAT
|
||||
|
||||
<img src="image.png" width="300">
|
||||
|
||||
Peripheral controller for the [ReSpeaker 4-Mic Array](https://wiki.seeedstudio.com/ReSpeaker_4_Mic_Array_for_Raspberry_Pi/) by Seeed Studio, 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 and drives the 12-LED APA102 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice_control/voice_remote_local_assistant/) LED behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Microphone array | 4 × MEMS mics at board corners (AC108 codec, I2S) |
|
||||
| LED ring | 12 × APA102 RGB LEDs |
|
||||
| Interface | Raspberry Pi 40-pin HAT connector |
|
||||
|
||||
### Compatible hardware
|
||||
|
||||
| Board | Notes |
|
||||
|---|---|
|
||||
| Raspberry Pi Zero 2 W | Recommended for compact builds |
|
||||
| Raspberry Pi 3 B / B+ | Fully supported |
|
||||
| Raspberry Pi 4 B | Fully supported |
|
||||
| Raspberry Pi 5 | Requires seeed-voicecard driver compatible with kernel 6.6+ — see Step 1 |
|
||||
|
||||
---
|
||||
|
||||
## GPIO pin mapping
|
||||
|
||||
### LED ring (APA102)
|
||||
|
||||
| Signal | GPIO (BCM) | Notes |
|
||||
|---|---|---|
|
||||
| MOSI (data) | GPIO 10 | SPI0 MOSI |
|
||||
| SCLK (clock) | GPIO 11 | SPI0 SCLK |
|
||||
| CE (bus select) | GPIO 7 (CE1) | /dev/spidev0.1 |
|
||||
|
||||
---
|
||||
|
||||
## LED ring animations
|
||||
|
||||
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 |
|
||||
| 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.
|
||||
|
||||
### Mic indicator positions
|
||||
|
||||
The four mics sit at the corners of the square board. On the 12-LED ring (30° per step), the corners land at 45°, 135°, 225°, 315° → **LEDs 1, 4, 7, 10**.
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Light entity
|
||||
|
||||
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
|
||||
|
||||
### Effects
|
||||
|
||||
| Effect | Behaviour |
|
||||
|---|---|
|
||||
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Waiting/Listening animations are tinted with the HA color; brightness scales every animation. |
|
||||
|
||||
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
|
||||
|
||||
### Brightness, on/off, and color
|
||||
|
||||
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Waiting/Listening animations.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1 — Install the seeed-voicecard audio driver
|
||||
|
||||
> **This must be done on the host Raspberry Pi, not inside Docker.**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/respeaker/seeed-voicecard
|
||||
cd seeed-voicecard
|
||||
sudo ./install.sh
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After rebooting, verify the microphone appears:
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
# Expected: card X: seeed4micvoicec [seeed-4mic-voicecard]
|
||||
```
|
||||
|
||||
> **Note:** The seeed-voicecard installer modifies `/boot/firmware/config.txt` automatically. Check the file after running `install.sh` — SPI may already be enabled.
|
||||
|
||||
> **Pi 5 note:** The standard seeed-voicecard repository may not support the Pi 5 kernel (6.6+). Check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch or fork before proceeding.
|
||||
|
||||
### Step 2 — Enable SPI in config.txt
|
||||
|
||||
If the seeed-voicecard installer did not already enable SPI, add to `/boot/firmware/config.txt`:
|
||||
|
||||
```ini
|
||||
dtparam=spi=on
|
||||
```
|
||||
|
||||
Reboot and verify:
|
||||
|
||||
```bash
|
||||
ls /dev/spidev*
|
||||
# Should show: /dev/spidev0.0 /dev/spidev0.1
|
||||
```
|
||||
|
||||
### Step 3 — Add user to GPIO and SPI groups
|
||||
|
||||
```bash
|
||||
sudo usermod -aG gpio,spi $USER
|
||||
```
|
||||
|
||||
Log out and back in. Check your UID:
|
||||
|
||||
```bash
|
||||
id -u $USER
|
||||
```
|
||||
|
||||
If it is not `1000`, update the `user:` field in `compose.yml` to match.
|
||||
|
||||
> **Pi 5 note:** On Pi 5, GPIO is exposed as `/dev/gpiochip4` not `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
|
||||
> ```yaml
|
||||
> devices:
|
||||
> - /dev/spidev0.1:/dev/spidev0.1
|
||||
> - /dev/gpiochip4:/dev/gpiochip4
|
||||
> ```
|
||||
|
||||
### Step 4 — File structure
|
||||
|
||||
```
|
||||
ReSpeaker 4mic HAT/
|
||||
├── Dockerfile
|
||||
├── compose.yml
|
||||
├── requirements.txt
|
||||
└── respeaker_4mic_hat.py
|
||||
```
|
||||
|
||||
### Step 5 — Build and start
|
||||
|
||||
#### Option A — Run with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
#### Option B — Run directly with Python
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python respeaker_4mic_hat.py --host localhost --port 6055
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is at the top of `respeaker_4mic_hat.py`:
|
||||
|
||||
```python
|
||||
# LVA connection
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# APA102 LED ring
|
||||
LED_COUNT = 12
|
||||
SPI_BUS = 0
|
||||
SPI_DEVICE = 1 # 1 = CE1 (/dev/spidev0.1), 0 = CE0 (/dev/spidev0.0)
|
||||
LED_BRIGHTNESS = 10 # 0–31 (APA102 global brightness register)
|
||||
|
||||
# Default ring colour (R, G, B) — matches HA Voice PE default
|
||||
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
||||
```
|
||||
|
||||
### Command-line arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host` | `localhost` | LVA container hostname or IP |
|
||||
| `--port` | `6055` | LVA peripheral API port |
|
||||
| `--debug` | off | Enable verbose debug logging |
|
||||
|
||||
---
|
||||
|
||||
## Drivers summary
|
||||
|
||||
| Component | Driver needed | Where to install | How |
|
||||
|---|---|---|---|
|
||||
| Microphone array (AC108) | **seeed-voicecard** | **Host Pi** | `git clone` + `sudo ./install.sh`, then reboot |
|
||||
| LED ring (APA102) | SPI overlay | **Host Pi** | `dtparam=spi=on` in `config.txt`, then reboot |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Microphone not detected by LVA
|
||||
|
||||
1. Confirm `arecord -l` shows `seeed-4mic-voicecard` on the host.
|
||||
2. If not, re-run `sudo ./install.sh` from the seeed-voicecard repository and reboot.
|
||||
3. On Raspberry Pi 5 with kernel 6.6+, check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch.
|
||||
|
||||
### LEDs do not light up
|
||||
|
||||
1. Confirm `dtparam=spi=on` is in `/boot/firmware/config.txt` and the Pi has been rebooted.
|
||||
2. Check `/dev/spidev0.1` exists: `ls /dev/spidev*`. If only `spidev0.0` exists, change `SPI_DEVICE = 0` in the script and update the device mapping in `compose.yml` to `/dev/spidev0.0`.
|
||||
3. Confirm the container user is in the `spi` group: `groups $USER`.
|
||||
4. Run with `--debug` and look for `APA102 LED ring initialised` in the logs.
|
||||
|
||||
### SPI and seeed-voicecard conflict
|
||||
|
||||
The seeed-voicecard driver uses I2S — it does **not** use SPI — so there is no conflict with the APA102 LED ring. Both can run simultaneously without any special configuration.
|
||||
|
||||
### LVA not reachable
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.title="ReSpeaker 4-Mic Array Peripheral Controller" \
|
||||
org.opencontainers.image.description="LED ring and button controller for ReSpeaker 4-Mic Array HAT, communicating with Linux Voice Assistant" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# System packages
|
||||
#
|
||||
# build-essential – compiles spidev / apa102-pi C extensions
|
||||
# python3-dev – Python C headers
|
||||
# git – some pip packages pull from git during install
|
||||
# ----------------------------------------------------------------------------
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
git \
|
||||
ca-certificates && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Python dependencies
|
||||
#
|
||||
# websockets – WebSocket client for LVA peripheral API
|
||||
# apa102-pi – APA102 LED ring driver (SPI)
|
||||
# spidev – low-level SPI access (used by apa102-pi)
|
||||
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
|
||||
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
|
||||
# ----------------------------------------------------------------------------
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY respeaker_4mic_hat.py ./
|
||||
|
||||
ENTRYPOINT ["python3", "respeaker_4mic_hat.py"]
|
||||
CMD []
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
# ReSpeaker 4-Mic Array HAT – peripheral controller
|
||||
#
|
||||
# Runs alongside the linux-voice-assistant container.
|
||||
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
|
||||
#
|
||||
# Key differences from Satellite 1 HAT:
|
||||
# - LEDs use SPI (not PWM/DMA), so no privileged mode is required
|
||||
# - Audio uses I2S via the seeed-voicecard driver — see hardware notes
|
||||
|
||||
services:
|
||||
respeaker4mic-peripheral:
|
||||
build: .
|
||||
image: lva/respeaker-4mic-hat:local
|
||||
container_name: respeaker-4mic-hat
|
||||
restart: unless-stopped
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Device access
|
||||
#
|
||||
# SPI access for APA102 LEDs:
|
||||
# /dev/spidev0.1 – SPI0 CE1 (used by apa102-pi for the LED ring)
|
||||
# Change to /dev/spidev0.0 if you wired CE0 instead.
|
||||
# -----------------------------------------------------------------------
|
||||
devices:
|
||||
- /dev/spidev0.1:/dev/spidev0.1
|
||||
- /dev/gpiomem:/dev/gpiomem
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Run as the gpio/spi group member — no root required.
|
||||
# The host user must be in the gpio and spi groups.
|
||||
# (see hardware notes below)
|
||||
# -----------------------------------------------------------------------
|
||||
user: "1000:1000"
|
||||
|
||||
group_add:
|
||||
- gpio
|
||||
- spi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Environment
|
||||
# -----------------------------------------------------------------------
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Network — host mode so "localhost" reaches the LVA container directly
|
||||
# -----------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Command-line arguments
|
||||
# -----------------------------------------------------------------------
|
||||
command:
|
||||
- "--host"
|
||||
- "localhost" # Change to LVA container IP if on a different host
|
||||
- "--port"
|
||||
- "6055"
|
||||
# Uncomment for verbose logging during setup / troubleshooting:
|
||||
# - "--debug"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Health check
|
||||
# -----------------------------------------------------------------------
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "respeaker4mic_peripheral.py"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HARDWARE NOTES
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. AUDIO DRIVER — seeed-voicecard (REQUIRED for microphone input)
|
||||
#
|
||||
# The ReSpeaker 4-Mic Array uses an AC108 I2S codec for the microphones.
|
||||
# The standard Raspberry Pi kernel does not include this driver.
|
||||
# Install it on the HOST Raspberry Pi (not inside Docker):
|
||||
#
|
||||
# git clone https://github.com/respeaker/seeed-voicecard
|
||||
# cd seeed-voicecard
|
||||
# sudo ./install.sh
|
||||
# sudo reboot
|
||||
#
|
||||
# After reboot, the microphone array should appear as an ALSA device.
|
||||
# LVA will automatically detect it via soundcard. Verify with:
|
||||
#
|
||||
# arecord -l
|
||||
# # Should list: seeed-4mic-voicecard
|
||||
#
|
||||
# Note: the seeed-voicecard installer adds its own dtoverlay to
|
||||
# /boot/firmware/config.txt. Do not add it manually.
|
||||
#
|
||||
# 2. SPI OVERLAY (REQUIRED for LED ring)
|
||||
#
|
||||
# The APA102 LED ring uses SPI. Enable it in /boot/firmware/config.txt:
|
||||
#
|
||||
# dtparam=spi=on
|
||||
#
|
||||
# Then reboot. Verify SPI devices are present:
|
||||
#
|
||||
# ls /dev/spidev*
|
||||
# # Should show: /dev/spidev0.0 /dev/spidev0.1
|
||||
#
|
||||
# The seeed-voicecard installer sometimes enables SPI automatically.
|
||||
# Check /boot/firmware/config.txt after running install.sh before
|
||||
# adding it manually.
|
||||
#
|
||||
# 3. GPIO GROUP (REQUIRED for button input)
|
||||
#
|
||||
# Add the host user to the gpio and spi groups:
|
||||
#
|
||||
# sudo usermod -aG gpio,spi $USER
|
||||
#
|
||||
# Log out and back in. Also update the user field in this compose file
|
||||
# if your UID is not 1000:
|
||||
#
|
||||
# id -u $USER # check your UID
|
||||
#
|
||||
# 4. SPI CONFLICT WITH seeed-voicecard
|
||||
#
|
||||
# The seeed-voicecard driver uses I2S (not SPI), so there is no conflict
|
||||
# with the APA102 SPI LED ring. Both can run simultaneously.
|
||||
#
|
||||
# 5. SPI CE SELECTION
|
||||
#
|
||||
# The APA102 ring does not use a chip enable (CE) line electrically, but
|
||||
# the apa102-pi library requires CE0 or CE1 to open the SPI bus.
|
||||
# This compose file maps /dev/spidev0.1 (CE1). If you prefer CE0, change:
|
||||
# - device: /dev/spidev0.0:/dev/spidev0.0
|
||||
# - SPI_DEVICE = 0 in respeaker_4mic_hat.py
|
||||
#
|
||||
# =============================================================================
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,5 @@
|
||||
websockets>=12.0
|
||||
apa102-pi>=2.0.0
|
||||
spidev>=3.6
|
||||
gpiozero>=2.0
|
||||
lgpio>=0.2
|
||||
@@ -0,0 +1,881 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ReSpeaker 4-Mic Array HAT – Linux Voice Assistant peripheral controller.
|
||||
|
||||
Mirrors the Home Assistant Voice PE LED ring animations and maps four
|
||||
external GPIO buttons to LVA peripheral API commands.
|
||||
|
||||
Hardware layout (ReSpeaker 4-Mic HAT on Raspberry Pi)
|
||||
------------------------------------------------------
|
||||
LED ring : 12 × APA102 RGB LEDs → SPI0 (MOSI GPIO 10, SCLK GPIO 11)
|
||||
Microphones: 4 × MEMS mics → I2S (AC108 codec, seeed-voicecard driver)
|
||||
|
||||
The ReSpeaker 4-Mic HAT has no onboard buttons. Connect your own momentary
|
||||
tactile switches between the GPIO pins above and GND.
|
||||
|
||||
Install dependencies
|
||||
---------------------
|
||||
pip install websockets apa102-pi gpiozero lgpio
|
||||
|
||||
Note: gpiozero with the lgpio backend works on Pi 3/4/5 without RPi.GPIO.
|
||||
Requires /dev/gpiochip0 (Pi 1–4) or /dev/gpiochip4 (Pi 5).
|
||||
|
||||
Enable SPI on the Pi (required for APA102 LEDs):
|
||||
Add dtparam=spi=on to /boot/firmware/config.txt and reboot.
|
||||
|
||||
Run
|
||||
---
|
||||
python3 respeaker_4mic_hat.py
|
||||
python3 respeaker_4mic_hat.py --host 127.0.0.1 --port 6055 --debug
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware dependencies — gracefully stubbed when not on real Pi hardware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
from gpiozero import Button as GPIOButton # type: ignore[import]
|
||||
_HAS_GPIO = True
|
||||
except ImportError:
|
||||
_HAS_GPIO = False
|
||||
logging.warning("gpiozero not found – button input disabled")
|
||||
|
||||
try:
|
||||
from apa102_pi.driver import apa102 as _apa102_driver # type: ignore[import]
|
||||
_HAS_APA102 = True
|
||||
except ImportError:
|
||||
_HAS_APA102 = False
|
||||
logging.warning("apa102-pi not found – LED output disabled")
|
||||
|
||||
try:
|
||||
import websockets # type: ignore[import]
|
||||
except ImportError:
|
||||
sys.exit("websockets not installed. Run: pip install websockets")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Configuration – edit these to match your wiring
|
||||
# ===========================================================================
|
||||
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# HA Light entity registered with LVA via register_light. The same object_id
|
||||
# is used to route incoming light_command events back to this script.
|
||||
LIGHT_OBJECT_ID = "led_ring"
|
||||
LIGHT_NAME = "LED Ring"
|
||||
LIGHT_ICON = "mdi:circle-outline"
|
||||
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
|
||||
|
||||
BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds
|
||||
|
||||
# APA102 LED ring
|
||||
LED_COUNT = 12
|
||||
# SPI bus and device (SPI0, CE1 — CE0 is also valid if CE1 conflicts)
|
||||
SPI_BUS = 0
|
||||
SPI_DEVICE = 1
|
||||
# Global brightness: 0–31. APA102 has a separate 5-bit brightness register.
|
||||
# 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
|
||||
|
||||
# Reconnect delay when WebSocket connection to LVA is lost
|
||||
RECONNECT_DELAY_S = 3.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Logging
|
||||
# ===========================================================================
|
||||
|
||||
_LOGGER = logging.getLogger("respeaker4mic")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Assistant state
|
||||
# ===========================================================================
|
||||
|
||||
class AssistState(str, Enum):
|
||||
NOT_READY = "not_ready"
|
||||
IDLE = "idle"
|
||||
WAKE_WORD = "wake_word_detected"
|
||||
LISTENING = "listening"
|
||||
THINKING = "thinking"
|
||||
SPEAKING = "tts_speaking"
|
||||
TTS_FINISHED = "tts_finished"
|
||||
ERROR = "error"
|
||||
MUTED = "muted"
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared state (written by WS thread, read by LED thread + button callbacks)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SharedState:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: 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.
|
||||
# Defaults match the LEDLightEntity in LVA core so the script
|
||||
# behaves sensibly before the first light_command arrives: off by
|
||||
# default, so idle stays dark until the user turns the light on.
|
||||
self.light_is_on: bool = False
|
||||
self.light_brightness: float = 0.66
|
||||
self.light_red: float = 0.094
|
||||
self.light_green: float = 0.733
|
||||
self.light_blue: float = 0.949
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
with self._lock:
|
||||
for key, val in kwargs.items():
|
||||
setattr(self, key, val)
|
||||
|
||||
@property
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"assist_state": self.assist_state,
|
||||
"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,
|
||||
"light_brightness": self.light_brightness,
|
||||
"light_red": self.light_red,
|
||||
"light_green": self.light_green,
|
||||
"light_blue": self.light_blue,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LED ring controller — APA102 via SPI
|
||||
#
|
||||
# Mirrors every animation from the ESPHome home-assistant-voice.yaml exactly.
|
||||
# Key difference from SK6812: APA102 uses SPI (MOSI + SCLK) instead of PWM.
|
||||
# ===========================================================================
|
||||
|
||||
RGB = Tuple[int, int, int]
|
||||
|
||||
BLACK: RGB = (0, 0, 0)
|
||||
RED: RGB = (255, 0, 0)
|
||||
|
||||
|
||||
def _scale(color: RGB, factor: float) -> RGB:
|
||||
f = max(0.0, min(1.0, factor))
|
||||
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
|
||||
|
||||
|
||||
class LEDRing:
|
||||
"""
|
||||
Manages the 12-LED APA102 ring via the apa102-pi SPI driver.
|
||||
|
||||
All animations run in a dedicated daemon thread. Calling
|
||||
``set_animation()`` switches cleanly to the new pattern.
|
||||
"""
|
||||
|
||||
ANIM_OFF = "off"
|
||||
ANIM_IDLE = "idle"
|
||||
ANIM_TWINKLE = "twinkle"
|
||||
ANIM_TWINKLE_BLUE = "twinkle_blue"
|
||||
ANIM_WAIT_CMD = "waiting"
|
||||
ANIM_LISTENING = "listening"
|
||||
ANIM_THINKING = "thinking"
|
||||
ANIM_REPLYING = "replying"
|
||||
ANIM_MUTED = "muted"
|
||||
ANIM_ERROR = "error"
|
||||
ANIM_TIMER_TICK = "timer_tick"
|
||||
ANIM_TIMER_RING = "timer_ring"
|
||||
|
||||
def __init__(self, state: SharedState) -> None:
|
||||
self._state = state
|
||||
self._pixels: list[RGB] = [BLACK] * LED_COUNT
|
||||
self._strip = None
|
||||
self._animation = self.ANIM_OFF
|
||||
self._anim_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, daemon=True, name="led-ring"
|
||||
)
|
||||
|
||||
self._index: int = 0
|
||||
self._brightness_step: int = 0
|
||||
self._brightness_dec: bool = True
|
||||
self._twinkle_state: list[float] = [0.0] * LED_COUNT
|
||||
|
||||
if _HAS_APA102:
|
||||
self._strip = _apa102_driver.APA102(
|
||||
num_led=LED_COUNT,
|
||||
global_brightness=LED_BRIGHTNESS,
|
||||
mosi=10,
|
||||
sclk=11,
|
||||
ce=SPI_DEVICE,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"APA102 LED ring initialised (%d LEDs, SPI0 CE%d)",
|
||||
LED_COUNT, SPI_DEVICE,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=2)
|
||||
self._all_off()
|
||||
if _HAS_APA102 and self._strip is not None:
|
||||
self._strip.cleanup()
|
||||
|
||||
def set_animation(self, name: str) -> None:
|
||||
with self._anim_lock:
|
||||
if self._animation != name:
|
||||
_LOGGER.debug("LED animation → %s", name)
|
||||
self._animation = name
|
||||
self._index = 0
|
||||
self._brightness_step = 0
|
||||
self._brightness_dec = True
|
||||
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write(self) -> None:
|
||||
if not _HAS_APA102 or self._strip is None:
|
||||
return
|
||||
for i, (r, g, b) in enumerate(self._pixels):
|
||||
self._strip.set_pixel(i, r, g, b)
|
||||
self._strip.show()
|
||||
|
||||
def _all_off(self) -> None:
|
||||
self._pixels = [BLACK] * LED_COUNT
|
||||
self._write()
|
||||
|
||||
def _set(self, i: int, color: RGB) -> None:
|
||||
self._pixels[i % LED_COUNT] = color
|
||||
|
||||
def _color(self) -> RGB:
|
||||
"""Return the current user-configured ring colour from light entity state."""
|
||||
snap = self._state.snapshot
|
||||
r = int(max(0.0, min(1.0, snap["light_red"])) * 255)
|
||||
g = int(max(0.0, min(1.0, snap["light_green"])) * 255)
|
||||
b = int(max(0.0, min(1.0, snap["light_blue"])) * 255)
|
||||
return (r, g, b)
|
||||
|
||||
def _pulse_step(self, steps: int = 10) -> float:
|
||||
factor = (steps - self._brightness_step) / steps
|
||||
if self._brightness_dec:
|
||||
self._brightness_step += 1
|
||||
else:
|
||||
self._brightness_step -= 1
|
||||
if self._brightness_step <= 0 or self._brightness_step >= steps:
|
||||
self._brightness_dec = not self._brightness_dec
|
||||
return factor
|
||||
|
||||
def _apply_mic_indicators(self) -> None:
|
||||
"""
|
||||
Mark all 4 mic positions red on the already-rendered frame.
|
||||
|
||||
ReSpeaker 4-Mic Array has mics at the four corners of the board.
|
||||
On a 12-LED ring (30° per step) the corners land at 45°, 135°,
|
||||
225°, 315° → LEDs 1, 4, 7, 10.
|
||||
Neighbours are blanked so each indicator reads as a distinct dot.
|
||||
"""
|
||||
self._set(0, BLACK); self._set(1, RED); self._set(2, BLACK)
|
||||
self._set(3, BLACK); self._set(4, RED); self._set(5, BLACK)
|
||||
self._set(6, BLACK); self._set(7, RED); self._set(8, BLACK)
|
||||
self._set(9, BLACK); self._set(10, RED); self._set(11, BLACK)
|
||||
|
||||
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
|
||||
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
||||
self._set(1, _scale(RED, factor))
|
||||
self._set(4, _scale(RED, factor))
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _anim_off(self) -> float:
|
||||
self._all_off()
|
||||
return 0.1
|
||||
|
||||
def _anim_idle(self) -> float:
|
||||
"""Resting state with user color when light is on, else dark.
|
||||
|
||||
Matches the HA Voice PE LED Ring behavior where the light defaults
|
||||
off, so idle stays dark until the user turns the light on in HA.
|
||||
Pipeline animations always run regardless.
|
||||
"""
|
||||
snap = self._state.snapshot
|
||||
if snap["light_is_on"]:
|
||||
color = self._color()
|
||||
brightness = snap["light_brightness"]
|
||||
for i in range(LED_COUNT):
|
||||
self._set(i, _scale(color, brightness))
|
||||
self._write()
|
||||
else:
|
||||
self._all_off()
|
||||
return 0.1
|
||||
|
||||
def _anim_twinkle(self, color: RGB) -> float:
|
||||
FADE = 0.85
|
||||
SPARK_PROB = 0.15
|
||||
for i in range(LED_COUNT):
|
||||
if random.random() < SPARK_PROB:
|
||||
self._twinkle_state[i] = 1.0
|
||||
else:
|
||||
self._twinkle_state[i] *= FADE
|
||||
self._set(i, _scale(color, self._twinkle_state[i]))
|
||||
self._write()
|
||||
return 0.05
|
||||
|
||||
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
|
||||
"""
|
||||
Shared spin for Waiting / Listening / Replying.
|
||||
Mirrors ESPHome "Waiting for Command", "Listening For Command",
|
||||
and "Replying" effects.
|
||||
"""
|
||||
if reverse:
|
||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
|
||||
(6, 1.0), (7, 192/255), (8, 128/255)]
|
||||
else:
|
||||
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
|
||||
(6, 1.0), (5, 192/255), (4, 128/255)]
|
||||
|
||||
for i in range(LED_COUNT):
|
||||
self._set(i, BLACK)
|
||||
for offset, brightness in offsets:
|
||||
self._set((self._index + offset) % LED_COUNT, _scale(color, brightness))
|
||||
if not reverse:
|
||||
self._index = (self._index + 1) % LED_COUNT
|
||||
|
||||
self._write()
|
||||
return interval
|
||||
|
||||
def _anim_thinking(self, color: RGB) -> float:
|
||||
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
|
||||
self._set(i, _scale(color, factor))
|
||||
else:
|
||||
self._set(i, BLACK)
|
||||
self._write()
|
||||
return 0.01
|
||||
|
||||
def _anim_muted(self, color: RGB) -> float:
|
||||
"""
|
||||
Solid ring with red at all 4 mic positions.
|
||||
ReSpeaker mics are at the board corners → LEDs 1, 4, 7, 10
|
||||
(45°, 135°, 225°, 315° on the 12-LED ring).
|
||||
Mirrors ESPHome "Muted or Silent" effect.
|
||||
"""
|
||||
for i in range(LED_COUNT):
|
||||
self._set(i, color)
|
||||
self._apply_mic_indicators()
|
||||
self._write()
|
||||
return 0.016
|
||||
|
||||
def _anim_error(self) -> float:
|
||||
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
self._set(i, _scale(RED, factor))
|
||||
self._write()
|
||||
return 0.01
|
||||
|
||||
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
|
||||
"""
|
||||
Full ring pulsing; red at all 4 mic positions if muted.
|
||||
Mirrors ESPHome "Timer Ring" effect.
|
||||
"""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
self._set(i, _scale(color, factor))
|
||||
if muted:
|
||||
self._apply_mic_indicators_pulsed(factor)
|
||||
self._write()
|
||||
return 0.01
|
||||
|
||||
def _anim_timer_tick(
|
||||
self, color: RGB, muted: bool,
|
||||
seconds_left: int, total_seconds: int,
|
||||
) -> float:
|
||||
"""
|
||||
Arc proportional to remaining time with sweep marker dip.
|
||||
Mirrors ESPHome "Timer Tick" effect exactly.
|
||||
"""
|
||||
total = max(total_seconds, 1)
|
||||
timer_ratio = LED_COUNT * seconds_left / total
|
||||
last_led_on = max(0, math.ceil(timer_ratio) - 1)
|
||||
|
||||
for i in range(LED_COUNT):
|
||||
brightness_dip = (
|
||||
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
|
||||
)
|
||||
if i <= timer_ratio:
|
||||
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
|
||||
self._set(i, _scale(color, brightness))
|
||||
else:
|
||||
self._set(i, BLACK)
|
||||
|
||||
if muted:
|
||||
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 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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
with self._anim_lock:
|
||||
anim = self._animation
|
||||
|
||||
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"]
|
||||
|
||||
# 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()
|
||||
elif anim == self.ANIM_TWINKLE:
|
||||
sleep = self._anim_twinkle(RED)
|
||||
elif anim == self.ANIM_TWINKLE_BLUE:
|
||||
sleep = self._anim_twinkle(color)
|
||||
elif anim == self.ANIM_WAIT_CMD:
|
||||
sleep = self._anim_spin(color, interval=0.1, reverse=False)
|
||||
elif anim == self.ANIM_LISTENING:
|
||||
sleep = self._anim_spin(color, interval=0.05, reverse=False)
|
||||
elif anim == self.ANIM_THINKING:
|
||||
sleep = self._anim_thinking(color)
|
||||
elif anim == self.ANIM_REPLYING:
|
||||
sleep = self._anim_spin(color, interval=0.05, reverse=True)
|
||||
elif anim == self.ANIM_MUTED:
|
||||
sleep = self._anim_muted(color)
|
||||
elif anim == self.ANIM_ERROR:
|
||||
sleep = self._anim_error()
|
||||
elif anim == self.ANIM_TIMER_RING:
|
||||
sleep = self._anim_timer_ring(color, muted)
|
||||
elif anim == self.ANIM_TIMER_TICK:
|
||||
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
|
||||
else:
|
||||
sleep = self._anim_off()
|
||||
|
||||
if volume_muted:
|
||||
self._apply_volume_muted_indicator()
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# State → animation mapping
|
||||
# ===========================================================================
|
||||
|
||||
def state_to_animation(
|
||||
state: AssistState, ha_connected: bool, muted: bool
|
||||
) -> str:
|
||||
if not ha_connected:
|
||||
return LEDRing.ANIM_TWINKLE
|
||||
|
||||
if state == AssistState.NOT_READY:
|
||||
return LEDRing.ANIM_TWINKLE
|
||||
|
||||
if state == AssistState.TIMER_RINGING:
|
||||
return LEDRing.ANIM_TIMER_RING
|
||||
|
||||
if state == AssistState.WAKE_WORD:
|
||||
return LEDRing.ANIM_WAIT_CMD
|
||||
|
||||
if state == AssistState.LISTENING:
|
||||
return LEDRing.ANIM_LISTENING
|
||||
|
||||
if state == AssistState.THINKING:
|
||||
return LEDRing.ANIM_THINKING
|
||||
|
||||
if state == AssistState.SPEAKING:
|
||||
return LEDRing.ANIM_REPLYING
|
||||
|
||||
if state == AssistState.ERROR:
|
||||
return LEDRing.ANIM_ERROR
|
||||
|
||||
if state == AssistState.MUTED or muted:
|
||||
return LEDRing.ANIM_MUTED
|
||||
|
||||
if state == AssistState.TIMER_TICKING:
|
||||
return LEDRing.ANIM_TIMER_TICK
|
||||
|
||||
return LEDRing.ANIM_IDLE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# WebSocket client
|
||||
# ===========================================================================
|
||||
|
||||
class LVAClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
state: SharedState,
|
||||
leds: LEDRing,
|
||||
command_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
self._uri = f"ws://{host}:{port}"
|
||||
self._state = state
|
||||
self._leds = leds
|
||||
self._queue = command_queue
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._connect()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.warning(
|
||||
"LVA connection lost (%s) – retrying in %.0fs",
|
||||
exc, RECONNECT_DELAY_S,
|
||||
)
|
||||
self._state.update(
|
||||
ha_connected=False,
|
||||
assist_state=AssistState.NOT_READY,
|
||||
)
|
||||
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||
await asyncio.sleep(RECONNECT_DELAY_S)
|
||||
|
||||
async def _connect(self) -> None:
|
||||
_LOGGER.info("Connecting to LVA at %s …", self._uri)
|
||||
async with websockets.connect(self._uri) as ws:
|
||||
_LOGGER.info("Connected to LVA peripheral API")
|
||||
# Register the LED Light entity with LVA so HA can control it.
|
||||
# LVA treats repeat registrations for the same object_id as a
|
||||
# no-op, so it's safe to send this on every connect.
|
||||
await ws.send(json.dumps({
|
||||
"command": "register_light",
|
||||
"data": {
|
||||
"name": LIGHT_NAME,
|
||||
"object_id": LIGHT_OBJECT_ID,
|
||||
"icon": LIGHT_ICON,
|
||||
"effects": [EFFECT_VOICE_ASSISTANT],
|
||||
"supports_rgb": True,
|
||||
"supports_brightness": True,
|
||||
},
|
||||
}))
|
||||
recv_task = asyncio.create_task(self._recv_loop(ws))
|
||||
send_task = asyncio.create_task(self._send_loop(ws))
|
||||
done, pending = await asyncio.wait(
|
||||
[recv_task, send_task],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
if task.exception():
|
||||
raise task.exception()
|
||||
|
||||
async def _recv_loop(self, ws) -> None:
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
await self._handle_event(msg)
|
||||
|
||||
async def _send_loop(self, ws) -> None:
|
||||
while True:
|
||||
command = await self._queue.get()
|
||||
try:
|
||||
await ws.send(json.dumps({"command": command}))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.warning("Failed to send command %s: %s", command, exc)
|
||||
self._queue.put_nowait(command)
|
||||
raise
|
||||
|
||||
async def _handle_event(self, msg: dict) -> None:
|
||||
event = msg.get("event", "")
|
||||
data = msg.get("data") or {}
|
||||
|
||||
_LOGGER.debug("Event: %s data=%s", event, data)
|
||||
|
||||
if event == "snapshot":
|
||||
self._state.update(
|
||||
muted=data.get("muted", False),
|
||||
volume=data.get("volume", 1.0),
|
||||
ha_connected=data.get("ha_connected", False),
|
||||
)
|
||||
|
||||
elif event == "wake_word_detected":
|
||||
self._state.update(assist_state=AssistState.WAKE_WORD)
|
||||
|
||||
elif event == "listening":
|
||||
self._state.update(assist_state=AssistState.LISTENING)
|
||||
|
||||
elif event == "thinking":
|
||||
self._state.update(assist_state=AssistState.THINKING)
|
||||
|
||||
elif event == "tts_speaking":
|
||||
self._state.update(assist_state=AssistState.SPEAKING)
|
||||
|
||||
elif event in ("tts_finished", "idle"):
|
||||
self._state.update(assist_state=AssistState.IDLE)
|
||||
|
||||
elif event == "muted":
|
||||
self._state.update(assist_state=AssistState.MUTED, muted=True)
|
||||
|
||||
elif event == "pipeline_error":
|
||||
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
|
||||
self._state.update(assist_state=AssistState.ERROR)
|
||||
|
||||
elif event == "disconnected":
|
||||
_LOGGER.warning("Home Assistant disconnected")
|
||||
self._state.update(
|
||||
ha_connected=False,
|
||||
assist_state=AssistState.NOT_READY,
|
||||
)
|
||||
|
||||
elif event == "timer_ticking":
|
||||
self._state.update(
|
||||
assist_state=AssistState.TIMER_TICKING,
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "timer_updated":
|
||||
self._state.update(
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "timer_ringing":
|
||||
self._state.update(
|
||||
assist_state=AssistState.TIMER_RINGING,
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "media_player_playing":
|
||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
||||
|
||||
elif event == "volume_changed":
|
||||
self._state.update(
|
||||
volume=data.get("volume", 1.0),
|
||||
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
|
||||
)
|
||||
|
||||
elif event == "volume_muted":
|
||||
# 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", "")
|
||||
if status == "connected":
|
||||
self._state.update(ha_connected=True)
|
||||
_LOGGER.info("Home Assistant connected")
|
||||
elif status == "getting_started":
|
||||
_LOGGER.info("LVA starting up, waiting for HA …")
|
||||
|
||||
# --- Light command events ------------------------------------------
|
||||
elif event == "light_command":
|
||||
# LVA broadcasts to every connected peripheral; only act on
|
||||
# commands targeting our registered Light. The Light exposes a
|
||||
# single "Voice Assistant" effect, so there is no effect to
|
||||
# switch on: we apply on/off, brightness, and color and let the
|
||||
# pipeline animations run.
|
||||
if data.get("object_id") != LIGHT_OBJECT_ID:
|
||||
return
|
||||
self._state.update(
|
||||
light_is_on=bool(data.get("state", True)),
|
||||
light_brightness=float(data.get("brightness", 0.66)),
|
||||
light_red=float(data.get("red", 0.094)),
|
||||
light_green=float(data.get("green", 0.733)),
|
||||
light_blue=float(data.get("blue", 0.949)),
|
||||
)
|
||||
# Force re-render animation with new light state
|
||||
snap = self._state.snapshot
|
||||
anim = state_to_animation(
|
||||
snap["assist_state"], snap["ha_connected"], snap["muted"]
|
||||
)
|
||||
self._leds.set_animation(anim)
|
||||
return
|
||||
|
||||
# Recompute LED animation after every event
|
||||
snap = self._state.snapshot
|
||||
anim = state_to_animation(
|
||||
snap["assist_state"], snap["ha_connected"], snap["muted"]
|
||||
)
|
||||
self._leds.set_animation(anim)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
|
||||
async def async_main(host: str, port: int) -> None:
|
||||
state = SharedState()
|
||||
command_queue: asyncio.Queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
leds = LEDRing(state)
|
||||
client = LVAClient(host, port, state, leds, command_queue)
|
||||
|
||||
leds.start()
|
||||
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||
|
||||
def _shutdown(signum, frame) -> None:
|
||||
_LOGGER.info("Shutdown requested")
|
||||
leds.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
_LOGGER.info(
|
||||
"ReSpeaker 4-Mic controller started – connecting to ws://%s:%d",
|
||||
host, port,
|
||||
)
|
||||
|
||||
try:
|
||||
await client.run_forever()
|
||||
finally:
|
||||
leds.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ReSpeaker 4-Mic Array HAT controller for Linux Voice Assistant"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default=DEFAULT_LVA_HOST,
|
||||
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=DEFAULT_LVA_PORT,
|
||||
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true",
|
||||
help="Enable DEBUG logging",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
asyncio.run(async_main(args.host, args.port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
# ReSpeaker Mic Array v2.0 (USB)
|
||||
|
||||
<img src="image.png" width="300">
|
||||
|
||||
Peripheral controller for the [ReSpeaker Mic Array v2.0](https://wiki.seeedstudio.com/ReSpeaker_Mic_Array_v2.0/) by Seeed Studio, running alongside the Linux Voice Assistant (LVA) container.
|
||||
|
||||
The controller runs as a separate Docker container and connects to LVA's peripheral WebSocket API. It drives the 12-LED APA102 ring via a USB HID vendor command to the device's onboard XMOS XVF3000 firmware.
|
||||
|
||||
> **This board connects over USB** — no GPIO, no SPI, no Raspberry Pi HAT required. It works on any Linux host including Raspberry Pi, desktop PCs, and mini-PCs.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Microphone array | 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000) |
|
||||
| LED ring | 12 × APA102 RGB LEDs |
|
||||
| Connection | USB (Vendor `0x2886`, Product `0x0018`) |
|
||||
|
||||
---
|
||||
|
||||
## LED ring animations
|
||||
|
||||
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 | Solid user color (if light on) or off | All LEDs show user color and brightness when light is on, else dark |
|
||||
| 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 | Red mic indicators | LEDs 0, 3, 6, 9 red; neighbours blanked (4 mic cardinal positions) |
|
||||
| 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.
|
||||
|
||||
### Mic indicator positions
|
||||
|
||||
The v2.0 has 4 microphones at DOA 0°, 90°, 180°, 270°. On the 12-LED ring (30° per step) this maps to **LEDs 0, 3, 6, 9** — the four cardinal points. When muted, these four LEDs turn red and their immediate neighbours are blanked so each indicator stands out as a distinct dot.
|
||||
|
||||
```
|
||||
LED 3 ← MIC (DOA 90°)
|
||||
2 · · 4
|
||||
1 · · 5
|
||||
0 · · 6
|
||||
↑MIC MIC↑
|
||||
(DOA 0°) (DOA 180°)
|
||||
11 · · 7
|
||||
10 · · 8
|
||||
LED 9 ← MIC (DOA 270°)
|
||||
|
||||
Mic LEDs: 0, 3, 6, 9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Light entity
|
||||
|
||||
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
|
||||
|
||||
### Effects
|
||||
|
||||
| Effect | Behaviour |
|
||||
|---|---|
|
||||
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Waiting/Listening animations are tinted with the HA color; brightness scales every animation. |
|
||||
|
||||
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
|
||||
|
||||
### Brightness, on/off, and color
|
||||
|
||||
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Waiting/Listening animations.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1 — Install udev rule (required for non-root USB access)
|
||||
|
||||
Run once on the **host**:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/udev/rules.d/99-respeaker.rules << 'EOF'
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
|
||||
MODE="0666", GROUP="plugdev", SYMLINK+="respeaker"
|
||||
EOF
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
This creates a stable symlink at `/dev/respeaker` and makes the device accessible to the `plugdev` group. After this you can tighten Docker's device access — see the note in `docker-compose.yml`.
|
||||
|
||||
Verify the device is visible:
|
||||
|
||||
```bash
|
||||
lsusb | grep 2886:0018
|
||||
# Example: Bus 001 Device 005: ID 2886:0018 SEEED Technology...
|
||||
```
|
||||
|
||||
### Step 2 — Verify microphone
|
||||
|
||||
The device enumerates as a standard USB audio device — no driver install needed:
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
# Should list: SEEED ReSpeaker Mic Array v2.0
|
||||
```
|
||||
|
||||
Pass the device to LVA:
|
||||
|
||||
```bash
|
||||
--audio-input-device "SEEED ReSpeaker Mic Array v2.0"
|
||||
```
|
||||
|
||||
Or in LVA's compose environment:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- AUDIO_INPUT_DEVICE=SEEED ReSpeaker Mic Array v2.0
|
||||
```
|
||||
|
||||
### Step 3 — File structure
|
||||
|
||||
```
|
||||
ReSpeaker Mic Array v2.0 (USB)/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
└── respeaker_usb_mic_array.py
|
||||
```
|
||||
|
||||
### Step 4 — Build and start
|
||||
|
||||
#### Option A — Run with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
cd respeaker_usb
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
#### Option B — Run directly with Python
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python respeaker_usb_mic_array.py --host localhost --port 6055
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is at the top of `respeaker_usb_mic_array.py`:
|
||||
|
||||
```python
|
||||
# LVA connection
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# USB device
|
||||
USB_VENDOR_ID = 0x2886
|
||||
USB_PRODUCT_ID = 0x0018
|
||||
|
||||
# LED ring
|
||||
LED_COUNT = 12
|
||||
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
|
||||
```
|
||||
|
||||
### Command-line arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host` | `localhost` | LVA container hostname or IP |
|
||||
| `--port` | `6055` | LVA peripheral API port |
|
||||
| `--debug` | off | Enable verbose debug logging |
|
||||
|
||||
---
|
||||
|
||||
## Drivers summary
|
||||
|
||||
| Component | Driver needed | How |
|
||||
|---|---|---|
|
||||
| Microphone array (XMOS XVF3000) | **None** | Enumerates as USB audio automatically |
|
||||
| LED ring (APA102 via USB HID) | udev rule | `99-respeaker.rules` on host |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Microphone not detected by LVA
|
||||
|
||||
1. Confirm `arecord -l` lists the device on the host.
|
||||
2. Check the USB cable — the device uses micro-USB and requires a data cable, not a charge-only cable.
|
||||
3. Try a different USB port. The v2.0 draws up to 900 mA; use a powered USB hub or a USB 3.0 port on Pi 4/5.
|
||||
|
||||
### LEDs do not light up
|
||||
|
||||
1. Confirm the device appears in `lsusb | grep 2886:0018`.
|
||||
2. Check the udev rule is applied: `ls -la /dev/respeaker` should show the symlink.
|
||||
3. Run with `--debug` and look for `ReSpeaker Mic Array v2.0 found` in the logs. If you see `not found`, check the USB connection and udev rule.
|
||||
4. If you see a USB permission error, confirm `privileged: true` is set in `docker-compose.yml`, or that the udev rule is active and the container's user is in the `plugdev` group.
|
||||
|
||||
### USB error after reconnect
|
||||
|
||||
The script automatically attempts to re-find the device after a USB error (e.g. cable unplug/replug). Watch the logs for `Attempting to reconnect to ReSpeaker USB device`. If the device does not re-enumerate within a few seconds, restart the container.
|
||||
|
||||
### LVA not reachable
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,38 @@
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.title="ReSpeaker Mic Array v2.0 Peripheral Controller" \
|
||||
org.opencontainers.image.description="LED controller for ReSpeaker Mic Array v2.0 (USB), communicating with Linux Voice Assistant" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# System packages
|
||||
#
|
||||
# libusb-1.0-0 – required by pyusb for USB device access
|
||||
# ----------------------------------------------------------------------------
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends \
|
||||
libusb-1.0-0 \
|
||||
ca-certificates && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Python dependencies
|
||||
#
|
||||
# websockets – WebSocket client for LVA peripheral API
|
||||
# pyusb – USB vendor control transfer for LED control
|
||||
# ----------------------------------------------------------------------------
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY respeaker_usb_mic_array.py ./
|
||||
|
||||
ENTRYPOINT ["python3", "respeaker_usb_mic_array.py"]
|
||||
CMD []
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
# ReSpeaker Mic Array v2.0 (USB) – peripheral controller
|
||||
#
|
||||
# Runs alongside the linux-voice-assistant container.
|
||||
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
|
||||
# 12-LED APA102 ring on the ReSpeaker Mic Array v2.0 via USB HID.
|
||||
#
|
||||
# This board connects over USB — no GPIO, no SPI, no HAT header required.
|
||||
# It works on any Linux host (Raspberry Pi, desktop, mini-PC, etc.)
|
||||
#
|
||||
# See hardware notes at the bottom of this file before first run.
|
||||
|
||||
services:
|
||||
respeaker-usb-peripheral:
|
||||
build: .
|
||||
image: ohf/respeaker-usb-peripheral:local
|
||||
container_name: respeaker-usb-peripheral
|
||||
restart: unless-stopped
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# USB device access
|
||||
#
|
||||
# Option A (recommended): pass only the ReSpeaker device.
|
||||
#
|
||||
# Find the bus and device numbers on the host:
|
||||
# lsusb | grep 2886:0018
|
||||
# # Example output: Bus 001 Device 005: ID 2886:0018 ...
|
||||
# Then set the path below:
|
||||
# - /dev/bus/usb/001/005:/dev/bus/usb/001/005
|
||||
#
|
||||
# NOTE: the device number changes on every reconnect. The udev rule
|
||||
# in the hardware notes section creates a stable symlink at
|
||||
# /dev/respeaker that you can use instead:
|
||||
# - /dev/respeaker:/dev/respeaker
|
||||
#
|
||||
# Option B (simpler but broader): pass the entire USB bus.
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# -----------------------------------------------------------------------
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb # Option B — change to specific device after setup
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Privileges
|
||||
#
|
||||
# USB vendor control transfers require elevated access.
|
||||
# The udev rule (hardware notes step 1) makes the device accessible to
|
||||
# the plugdev group; if that rule is in place you can remove privileged
|
||||
# and add group_add: [plugdev] instead.
|
||||
# -----------------------------------------------------------------------
|
||||
privileged: true
|
||||
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Network — host mode so "localhost" reaches the LVA container directly
|
||||
# -----------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
command:
|
||||
- "--host"
|
||||
- "localhost"
|
||||
- "--port"
|
||||
- "6055"
|
||||
# Uncomment for verbose logging during setup:
|
||||
# - "--debug"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "respeaker_usb_mic_array.py"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HARDWARE NOTES
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. UDEV RULE (REQUIRED for non-root USB access)
|
||||
#
|
||||
# Create the udev rule on the HOST so the container can access the device
|
||||
# without needing privileged mode long-term:
|
||||
#
|
||||
# sudo tee /etc/udev/rules.d/99-respeaker.rules << 'EOF'
|
||||
# SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
|
||||
# MODE="0666", GROUP="plugdev", SYMLINK+="respeaker"
|
||||
# EOF
|
||||
# sudo udevadm control --reload-rules
|
||||
# sudo udevadm trigger
|
||||
#
|
||||
# After this, /dev/respeaker is a stable symlink to the device.
|
||||
# You can then replace the broad /dev/bus/usb mount with:
|
||||
#
|
||||
# devices:
|
||||
# - /dev/respeaker:/dev/respeaker
|
||||
#
|
||||
# And replace privileged: true with:
|
||||
#
|
||||
# privileged: false
|
||||
# group_add:
|
||||
# - plugdev
|
||||
#
|
||||
# 2. MICROPHONE — NO DRIVER NEEDED
|
||||
#
|
||||
# The ReSpeaker Mic Array v2.0 runs a XMOS XVF3000 firmware and enumerates
|
||||
# as a standard USB audio device. No kernel driver or seeed-voicecard
|
||||
# install is required.
|
||||
#
|
||||
# Verify on the host:
|
||||
# arecord -l
|
||||
# # Should list: SEEED ReSpeaker Mic Array v2.0
|
||||
#
|
||||
# LVA detects it automatically, or specify it explicitly:
|
||||
# --audio-input-device "SEEED ReSpeaker Mic Array v2.0"
|
||||
#
|
||||
# Or in LVA's compose environment:
|
||||
# AUDIO_INPUT_DEVICE=SEEED ReSpeaker Mic Array v2.0
|
||||
#
|
||||
# 3. LED CONTROL — USB HID VENDOR COMMAND
|
||||
#
|
||||
# The LEDs are controlled via a USB vendor control transfer to the XMOS
|
||||
# firmware (bmRequestType=0x40, bRequest=0, wValue=0x0001, wIndex=0x8001).
|
||||
# This is the same protocol used by Seeed's pixel_ring library.
|
||||
# No SPI, GPIO, or kernel overlay is required.
|
||||
#
|
||||
# 4. POWER
|
||||
#
|
||||
# The device draws up to 900 mA at 5 V when all LEDs are on at full
|
||||
# brightness. Use a powered USB hub or a Pi 4/5 with a quality 5 V/3 A
|
||||
# power supply to avoid brown-outs.
|
||||
#
|
||||
# =============================================================================
|
||||
|
After Width: | Height: | Size: 933 KiB |
@@ -0,0 +1,2 @@
|
||||
websockets>=12.0
|
||||
pyusb>=1.2.1
|
||||
@@ -0,0 +1,976 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ReSpeaker Mic Array v2.0 (USB) – Linux Voice Assistant peripheral controller.
|
||||
|
||||
Connects to the device via USB HID and drives its 12 APA102 LEDs with
|
||||
animations that mirror the Home Assistant Voice PE LED ring behaviour.
|
||||
|
||||
Hardware
|
||||
--------
|
||||
Connection : USB (Vendor 0x2886, Product 0x0018)
|
||||
LED ring : 12 × APA102 RGB LEDs (controlled via USB HID)
|
||||
Microphones: 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000, USB audio)
|
||||
|
||||
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
|
||||
|
||||
Muted indicator: LEDs at the 4 mic positions light red.
|
||||
The v2.0 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
|
||||
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
|
||||
|
||||
Install dependencies
|
||||
--------------------
|
||||
pip install websockets pyusb pixel-ring
|
||||
|
||||
Host udev rule (run once on the Pi/host, then reboot or run udevadm trigger):
|
||||
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
|
||||
MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
|
||||
sudo udevadm trigger
|
||||
|
||||
Run
|
||||
---
|
||||
python3 respeaker_usb_mic_array.py
|
||||
python3 respeaker_usb_mic_array.py --host 127.0.0.1 --port 6055 --debug
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware dependencies — gracefully stubbed when not on real hardware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import usb.core # type: ignore[import]
|
||||
import usb.util # type: ignore[import]
|
||||
_HAS_USB = True
|
||||
except ImportError:
|
||||
_HAS_USB = False
|
||||
logging.warning("pyusb not found – LED output will be simulated")
|
||||
|
||||
try:
|
||||
import websockets # type: ignore[import]
|
||||
except ImportError:
|
||||
sys.exit("websockets not installed. Run: pip install websockets")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Configuration
|
||||
# ===========================================================================
|
||||
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# HA Light entity registered with LVA via register_light. The same object_id
|
||||
# is used to route incoming light_command events back to this script.
|
||||
LIGHT_OBJECT_ID = "led_ring"
|
||||
LIGHT_NAME = "LED Ring"
|
||||
LIGHT_ICON = "mdi:circle-outline"
|
||||
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
|
||||
|
||||
# USB device identifiers
|
||||
USB_VENDOR_ID = 0x2886
|
||||
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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Logging
|
||||
# ===========================================================================
|
||||
|
||||
_LOGGER = logging.getLogger("respeaker-usb")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Assistant state
|
||||
# ===========================================================================
|
||||
|
||||
class AssistState(str, Enum):
|
||||
NOT_READY = "not_ready"
|
||||
IDLE = "idle"
|
||||
WAKE_WORD = "wake_word_detected"
|
||||
LISTENING = "listening"
|
||||
THINKING = "thinking"
|
||||
SPEAKING = "tts_speaking"
|
||||
TTS_FINISHED = "tts_finished"
|
||||
ERROR = "error"
|
||||
MUTED = "muted"
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SharedState:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: 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.
|
||||
# Defaults match the LEDLightEntity in LVA core so the script
|
||||
# behaves sensibly before the first light_command arrives: off by
|
||||
# default, so idle stays dark until the user turns the light on.
|
||||
self.light_is_on: bool = False
|
||||
self.light_brightness: float = 0.66
|
||||
self.light_red: float = 0.094
|
||||
self.light_green: float = 0.733
|
||||
self.light_blue: float = 0.949
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
with self._lock:
|
||||
for key, val in kwargs.items():
|
||||
setattr(self, key, val)
|
||||
|
||||
@property
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"assist_state": self.assist_state,
|
||||
"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,
|
||||
"light_brightness": self.light_brightness,
|
||||
"light_red": self.light_red,
|
||||
"light_green": self.light_green,
|
||||
"light_blue": self.light_blue,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# USB LED driver
|
||||
#
|
||||
# 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]
|
||||
|
||||
BLACK: RGB = (0, 0, 0)
|
||||
RED: RGB = (255, 0, 0)
|
||||
BLUE: RGB = (0, 0, 255)
|
||||
CYAN: RGB = (0, 200, 200)
|
||||
GREEN: RGB = (0, 200, 50)
|
||||
YELLOW: RGB = (220, 180, 0)
|
||||
|
||||
|
||||
def _scale(color: RGB, factor: float) -> RGB:
|
||||
f = max(0.0, min(1.0, factor))
|
||||
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
|
||||
|
||||
|
||||
class USBLEDRing:
|
||||
"""
|
||||
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
_CMD_SHOW = 0x06
|
||||
_CMD_BRIGHTNESS = 0x20
|
||||
_CTRL_TIMEOUT = 8000 # ms
|
||||
_CTRL_REQUEST = 0
|
||||
_CTRL_INDEX = 0x1C
|
||||
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
|
||||
|
||||
def __init__(self) -> None:
|
||||
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()
|
||||
|
||||
def _find_device(self) -> None:
|
||||
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=USB_PRODUCT_ID)
|
||||
if dev is None:
|
||||
_LOGGER.error(
|
||||
"ReSpeaker Mic Array v2.0 not found (USB %04x:%04x). "
|
||||
"Check connection and udev rules.",
|
||||
USB_VENDOR_ID, USB_PRODUCT_ID,
|
||||
)
|
||||
return
|
||||
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,
|
||||
)
|
||||
|
||||
def set(self, index: int, color: RGB) -> None:
|
||||
self._pixels[index % LED_COUNT] = color
|
||||
|
||||
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.
|
||||
|
||||
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: 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 += [br, bg, bb, 0]
|
||||
|
||||
with self._dev_lock:
|
||||
if self._dev is None:
|
||||
pixels = ", ".join(f"rgb{p}" for p in self._pixels)
|
||||
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
||||
return
|
||||
try:
|
||||
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
|
||||
).start()
|
||||
|
||||
def _reconnect(self) -> None:
|
||||
time.sleep(2)
|
||||
_LOGGER.info("Attempting to reconnect to ReSpeaker USB device …")
|
||||
self._find_device()
|
||||
|
||||
def off(self) -> None:
|
||||
self._pixels = [BLACK] * LED_COUNT
|
||||
self.show(0)
|
||||
|
||||
def close(self) -> None:
|
||||
self.off()
|
||||
with self._dev_lock:
|
||||
if self._dev is not None:
|
||||
usb.util.dispose_resources(self._dev)
|
||||
self._dev = None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LED ring animator
|
||||
#
|
||||
# All animations mirror the ESPHome home-assistant-voice.yaml effects.
|
||||
# Muted indicator uses LEDs 0, 2, 4, 6, 8, 10 — the 6 outer mic positions
|
||||
# on the v2.0 (0°, 60°, 120°, 180°, 240°, 300° on the ring).
|
||||
# ===========================================================================
|
||||
|
||||
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
|
||||
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
|
||||
# 180° → LED 6, 270° → LED 9
|
||||
MIC_LEDS = (0, 3, 6, 9)
|
||||
|
||||
|
||||
class LEDRing:
|
||||
"""
|
||||
Manages the 12-LED ring animations for the ReSpeaker Mic Array v2.0.
|
||||
All animations run in a dedicated daemon thread.
|
||||
"""
|
||||
|
||||
ANIM_OFF = "off"
|
||||
ANIM_IDLE = "idle"
|
||||
ANIM_TWINKLE = "twinkle"
|
||||
ANIM_TWINKLE_BLUE = "twinkle_blue"
|
||||
ANIM_WAIT_CMD = "waiting"
|
||||
ANIM_LISTENING = "listening"
|
||||
ANIM_THINKING = "thinking"
|
||||
ANIM_REPLYING = "replying"
|
||||
ANIM_MUTED = "muted"
|
||||
ANIM_ERROR = "error"
|
||||
ANIM_TIMER_TICK = "timer_tick"
|
||||
ANIM_TIMER_RING = "timer_ring"
|
||||
|
||||
def __init__(self, state: SharedState) -> None:
|
||||
self._state = state
|
||||
self._leds = USBLEDRing()
|
||||
self._animation = self.ANIM_OFF
|
||||
self._anim_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, daemon=True, name="led-ring"
|
||||
)
|
||||
|
||||
self._index: int = 0
|
||||
self._brightness_step: int = 0
|
||||
self._brightness_dec: bool = True
|
||||
self._twinkle_state: list[float] = [0.0] * LED_COUNT
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=2)
|
||||
self._leds.off()
|
||||
self._leds.close()
|
||||
|
||||
def set_animation(self, name: str) -> None:
|
||||
with self._anim_lock:
|
||||
if self._animation != name:
|
||||
_LOGGER.debug("LED animation → %s", name)
|
||||
self._animation = name
|
||||
self._index = 0
|
||||
self._brightness_step = 0
|
||||
self._brightness_dec = True
|
||||
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _color(self) -> RGB:
|
||||
"""Return the current user-configured ring colour from light entity state."""
|
||||
snap = self._state.snapshot
|
||||
r = int(max(0.0, min(1.0, snap["light_red"])) * 255)
|
||||
g = int(max(0.0, min(1.0, snap["light_green"])) * 255)
|
||||
b = int(max(0.0, min(1.0, snap["light_blue"])) * 255)
|
||||
return (r, g, b)
|
||||
|
||||
def _pulse_step(self, steps: int = 10) -> float:
|
||||
factor = (steps - self._brightness_step) / steps
|
||||
if self._brightness_dec:
|
||||
self._brightness_step += 1
|
||||
else:
|
||||
self._brightness_step -= 1
|
||||
if self._brightness_step <= 0 or self._brightness_step >= steps:
|
||||
self._brightness_dec = not self._brightness_dec
|
||||
return factor
|
||||
|
||||
def _apply_mic_indicators(self) -> None:
|
||||
"""
|
||||
Mark all 4 mic positions red on the already-rendered frame.
|
||||
|
||||
ReSpeaker Mic Array v2.0 has 4 mics at DOA 0°, 90°, 180°, 270°.
|
||||
On the 12-LED ring (30° per step):
|
||||
DOA 0° → LED 0 (right)
|
||||
DOA 90° → LED 3 (top)
|
||||
DOA 180° → LED 6 (left)
|
||||
DOA 270° → LED 9 (bottom)
|
||||
Neighbours are blanked so each indicator reads as a distinct dot.
|
||||
"""
|
||||
self._leds.set(11, BLACK); self._leds.set(0, RED); self._leds.set(1, BLACK)
|
||||
self._leds.set(2, BLACK); self._leds.set(3, RED); self._leds.set(4, BLACK)
|
||||
self._leds.set(5, BLACK); self._leds.set(6, RED); self._leds.set(7, BLACK)
|
||||
self._leds.set(8, BLACK); self._leds.set(9, RED); self._leds.set(10, BLACK)
|
||||
|
||||
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
|
||||
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
||||
self._leds.set(0, _scale(RED, factor))
|
||||
self._leds.set(3, _scale(RED, factor))
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _anim_off(self) -> float:
|
||||
self._leds.off()
|
||||
return 0.1
|
||||
|
||||
def _anim_idle(self) -> float:
|
||||
"""Resting state with user color when light is on, else dark.
|
||||
|
||||
Matches the HA Voice PE LED Ring behavior where the light defaults
|
||||
off, so idle stays dark until the user turns the light on in HA.
|
||||
Pipeline animations always run regardless.
|
||||
"""
|
||||
snap = self._state.snapshot
|
||||
if snap["light_is_on"]:
|
||||
color = self._color()
|
||||
brightness = snap["light_brightness"]
|
||||
for i in range(LED_COUNT):
|
||||
self._leds.set(i, _scale(color, brightness))
|
||||
self._leds.show()
|
||||
else:
|
||||
self._leds.off()
|
||||
return 0.1
|
||||
|
||||
def _anim_twinkle(self, color: RGB) -> float:
|
||||
FADE = 0.85
|
||||
SPARK_PROB = 0.15
|
||||
for i in range(LED_COUNT):
|
||||
if random.random() < SPARK_PROB:
|
||||
self._twinkle_state[i] = 1.0
|
||||
else:
|
||||
self._twinkle_state[i] *= FADE
|
||||
self._leds.set(i, _scale(color, self._twinkle_state[i]))
|
||||
self._leds.show()
|
||||
return 0.05
|
||||
|
||||
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
|
||||
"""
|
||||
Shared spin for Waiting / Listening / Replying.
|
||||
Mirrors ESPHome "Waiting for Command", "Listening For Command",
|
||||
and "Replying" effects.
|
||||
"""
|
||||
if reverse:
|
||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
|
||||
(6, 1.0), (7, 192/255), (8, 128/255)]
|
||||
else:
|
||||
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
|
||||
(6, 1.0), (5, 192/255), (4, 128/255)]
|
||||
|
||||
for i in range(LED_COUNT):
|
||||
self._leds.set(i, BLACK)
|
||||
for offset, brightness in offsets:
|
||||
self._leds.set((self._index + offset) % LED_COUNT, _scale(color, brightness))
|
||||
if not reverse:
|
||||
self._index = (self._index + 1) % LED_COUNT
|
||||
|
||||
self._leds.show()
|
||||
return interval
|
||||
|
||||
def _anim_thinking(self, color: RGB) -> float:
|
||||
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
|
||||
self._leds.set(i, _scale(color, factor))
|
||||
else:
|
||||
self._leds.set(i, BLACK)
|
||||
self._leds.show()
|
||||
return 0.01
|
||||
|
||||
def _anim_muted(self, color: RGB) -> float:
|
||||
"""
|
||||
Solid ring with red at all 4 mic positions (cardinal points).
|
||||
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
|
||||
Neighbours are blanked so each indicator stands out clearly.
|
||||
Mirrors ESPHome "Muted or Silent" effect.
|
||||
"""
|
||||
for i in range(LED_COUNT):
|
||||
self._leds.set(i, color)
|
||||
self._apply_mic_indicators()
|
||||
self._leds.show()
|
||||
return 0.016
|
||||
|
||||
def _anim_error(self) -> float:
|
||||
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
self._leds.set(i, _scale(RED, factor))
|
||||
self._leds.show()
|
||||
return 0.01
|
||||
|
||||
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
|
||||
"""
|
||||
Full ring pulsing; red at all 6 mic positions if muted.
|
||||
Mirrors ESPHome "Timer Ring" effect.
|
||||
"""
|
||||
factor = self._pulse_step(10)
|
||||
for i in range(LED_COUNT):
|
||||
self._leds.set(i, _scale(color, factor))
|
||||
if muted:
|
||||
self._apply_mic_indicators_pulsed(factor)
|
||||
self._leds.show()
|
||||
return 0.01
|
||||
|
||||
def _anim_timer_tick(
|
||||
self, color: RGB, muted: bool,
|
||||
seconds_left: int, total_seconds: int,
|
||||
) -> float:
|
||||
"""
|
||||
Arc proportional to remaining time with sweep marker dip.
|
||||
Mirrors ESPHome "Timer Tick" effect exactly.
|
||||
"""
|
||||
total = max(total_seconds, 1)
|
||||
timer_ratio = LED_COUNT * seconds_left / total
|
||||
last_led_on = max(0, math.ceil(timer_ratio) - 1)
|
||||
|
||||
for i in range(LED_COUNT):
|
||||
brightness_dip = (
|
||||
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
|
||||
)
|
||||
if i <= timer_ratio:
|
||||
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
|
||||
self._leds.set(i, _scale(color, brightness))
|
||||
else:
|
||||
self._leds.set(i, BLACK)
|
||||
|
||||
if muted:
|
||||
self._apply_mic_indicators()
|
||||
|
||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||
self._leds.show()
|
||||
return 0.1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Animation loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
with self._anim_lock:
|
||||
anim = self._animation
|
||||
|
||||
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"]
|
||||
|
||||
# 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()
|
||||
elif anim == self.ANIM_TWINKLE:
|
||||
sleep = self._anim_twinkle(RED)
|
||||
elif anim == self.ANIM_TWINKLE_BLUE:
|
||||
sleep = self._anim_twinkle(color)
|
||||
elif anim == self.ANIM_WAIT_CMD:
|
||||
sleep = self._anim_spin(color, interval=0.1, reverse=False)
|
||||
elif anim == self.ANIM_LISTENING:
|
||||
sleep = self._anim_spin(color, interval=0.05, reverse=False)
|
||||
elif anim == self.ANIM_THINKING:
|
||||
sleep = self._anim_thinking(color)
|
||||
elif anim == self.ANIM_REPLYING:
|
||||
sleep = self._anim_spin(color, interval=0.05, reverse=True)
|
||||
elif anim == self.ANIM_MUTED:
|
||||
sleep = self._anim_muted(color)
|
||||
elif anim == self.ANIM_ERROR:
|
||||
sleep = self._anim_error()
|
||||
elif anim == self.ANIM_TIMER_RING:
|
||||
sleep = self._anim_timer_ring(color, muted)
|
||||
elif anim == self.ANIM_TIMER_TICK:
|
||||
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
|
||||
else:
|
||||
sleep = self._anim_off()
|
||||
|
||||
if volume_muted:
|
||||
self._apply_volume_muted_indicator()
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# State → animation mapping (identical to 4-Mic HAT)
|
||||
# ===========================================================================
|
||||
|
||||
def state_to_animation(
|
||||
state: AssistState, ha_connected: bool, muted: bool
|
||||
) -> str:
|
||||
if not ha_connected:
|
||||
return LEDRing.ANIM_TWINKLE
|
||||
if state == AssistState.NOT_READY:
|
||||
return LEDRing.ANIM_TWINKLE
|
||||
if state == AssistState.TIMER_RINGING:
|
||||
return LEDRing.ANIM_TIMER_RING
|
||||
if state == AssistState.WAKE_WORD:
|
||||
return LEDRing.ANIM_WAIT_CMD
|
||||
if state == AssistState.LISTENING:
|
||||
return LEDRing.ANIM_LISTENING
|
||||
if state == AssistState.THINKING:
|
||||
return LEDRing.ANIM_THINKING
|
||||
if state == AssistState.SPEAKING:
|
||||
return LEDRing.ANIM_REPLYING
|
||||
if state == AssistState.ERROR:
|
||||
return LEDRing.ANIM_ERROR
|
||||
if state == AssistState.MUTED or muted:
|
||||
return LEDRing.ANIM_MUTED
|
||||
if state == AssistState.TIMER_TICKING:
|
||||
return LEDRing.ANIM_TIMER_TICK
|
||||
return LEDRing.ANIM_IDLE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# WebSocket client
|
||||
# ===========================================================================
|
||||
|
||||
class LVAClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
state: SharedState,
|
||||
leds: LEDRing,
|
||||
command_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
self._uri = f"ws://{host}:{port}"
|
||||
self._state = state
|
||||
self._leds = leds
|
||||
self._queue = command_queue
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._connect()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.warning(
|
||||
"LVA connection lost (%s) – retrying in %.0fs",
|
||||
exc, RECONNECT_DELAY_S,
|
||||
)
|
||||
self._state.update(
|
||||
ha_connected=False,
|
||||
assist_state=AssistState.NOT_READY,
|
||||
)
|
||||
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||
await asyncio.sleep(RECONNECT_DELAY_S)
|
||||
|
||||
async def _connect(self) -> None:
|
||||
_LOGGER.info("Connecting to LVA at %s …", self._uri)
|
||||
async with websockets.connect(self._uri) as ws:
|
||||
_LOGGER.info("Connected to LVA peripheral API")
|
||||
# Register the LED Light entity with LVA so HA can control it.
|
||||
# LVA treats repeat registrations for the same object_id as a
|
||||
# no-op, so it's safe to send this on every connect.
|
||||
await ws.send(json.dumps({
|
||||
"command": "register_light",
|
||||
"data": {
|
||||
"name": LIGHT_NAME,
|
||||
"object_id": LIGHT_OBJECT_ID,
|
||||
"icon": LIGHT_ICON,
|
||||
"effects": [EFFECT_VOICE_ASSISTANT],
|
||||
"supports_rgb": True,
|
||||
"supports_brightness": True,
|
||||
},
|
||||
}))
|
||||
recv_task = asyncio.create_task(self._recv_loop(ws))
|
||||
send_task = asyncio.create_task(self._send_loop(ws))
|
||||
done, pending = await asyncio.wait(
|
||||
[recv_task, send_task],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
if task.exception():
|
||||
raise task.exception()
|
||||
|
||||
async def _recv_loop(self, ws) -> None:
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
await self._handle_event(msg)
|
||||
|
||||
async def _send_loop(self, ws) -> None:
|
||||
while True:
|
||||
command = await self._queue.get()
|
||||
try:
|
||||
await ws.send(json.dumps({"command": command}))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.warning("Failed to send command %s: %s", command, exc)
|
||||
self._queue.put_nowait(command)
|
||||
raise
|
||||
|
||||
async def _handle_event(self, msg: dict) -> None:
|
||||
event = msg.get("event", "")
|
||||
data = msg.get("data") or {}
|
||||
|
||||
_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=muted,
|
||||
volume=data.get("volume", 1.0),
|
||||
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)
|
||||
|
||||
elif event == "listening":
|
||||
self._state.update(assist_state=AssistState.LISTENING)
|
||||
|
||||
elif event == "thinking":
|
||||
self._state.update(assist_state=AssistState.THINKING)
|
||||
|
||||
elif event == "tts_speaking":
|
||||
self._state.update(assist_state=AssistState.SPEAKING)
|
||||
|
||||
elif event in ("tts_finished", "idle"):
|
||||
self._state.update(assist_state=AssistState.IDLE)
|
||||
|
||||
elif event == "muted":
|
||||
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", ""))
|
||||
self._state.update(assist_state=AssistState.ERROR)
|
||||
|
||||
elif event == "disconnected":
|
||||
_LOGGER.warning("Home Assistant disconnected")
|
||||
self._state.update(
|
||||
ha_connected=False,
|
||||
assist_state=AssistState.NOT_READY,
|
||||
)
|
||||
|
||||
elif event == "timer_ticking":
|
||||
self._state.update(
|
||||
assist_state=AssistState.TIMER_TICKING,
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "timer_updated":
|
||||
self._state.update(
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "timer_ringing":
|
||||
self._state.update(
|
||||
assist_state=AssistState.TIMER_RINGING,
|
||||
timer_total_seconds=data.get("total_seconds", 0),
|
||||
timer_seconds_left=data.get("seconds_left", 0),
|
||||
)
|
||||
|
||||
elif event == "media_player_playing":
|
||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
||||
|
||||
elif event == "volume_changed":
|
||||
self._state.update(
|
||||
volume=data.get("volume", 1.0),
|
||||
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
|
||||
)
|
||||
|
||||
elif event == "volume_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":
|
||||
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 …")
|
||||
|
||||
# --- Light command events ------------------------------------------
|
||||
elif event == "light_command":
|
||||
# LVA broadcasts to every connected peripheral; only act on
|
||||
# commands targeting our registered Light. The Light exposes a
|
||||
# single "Voice Assistant" effect, so there is no effect to
|
||||
# switch on: we apply on/off, brightness, and color and let the
|
||||
# pipeline animations run.
|
||||
if data.get("object_id") != LIGHT_OBJECT_ID:
|
||||
return
|
||||
self._state.update(
|
||||
light_is_on=bool(data.get("state", True)),
|
||||
light_brightness=float(data.get("brightness", 0.66)),
|
||||
light_red=float(data.get("red", 0.094)),
|
||||
light_green=float(data.get("green", 0.733)),
|
||||
light_blue=float(data.get("blue", 0.949)),
|
||||
)
|
||||
# Force re-render animation with new light state
|
||||
snap = self._state.snapshot
|
||||
anim = state_to_animation(
|
||||
snap["assist_state"], snap["ha_connected"], snap["muted"]
|
||||
)
|
||||
self._leds.set_animation(anim)
|
||||
return
|
||||
|
||||
# Recompute LED animation after every event
|
||||
snap = self._state.snapshot
|
||||
anim = state_to_animation(
|
||||
snap["assist_state"], snap["ha_connected"], snap["muted"]
|
||||
)
|
||||
self._leds.set_animation(anim)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
|
||||
async def async_main(host: str, port: int) -> None:
|
||||
state = SharedState()
|
||||
command_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
leds = LEDRing(state)
|
||||
client = LVAClient(host, port, state, leds, command_queue)
|
||||
|
||||
leds.start()
|
||||
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
||||
|
||||
def _shutdown(signum, frame) -> None:
|
||||
_LOGGER.info("Shutdown requested")
|
||||
leds.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
_LOGGER.info(
|
||||
"ReSpeaker Mic Array v2.0 controller started – connecting to ws://%s:%d",
|
||||
host, port,
|
||||
)
|
||||
|
||||
try:
|
||||
await client.run_forever()
|
||||
finally:
|
||||
leds.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ReSpeaker Mic Array v2.0 (USB) controller for Linux Voice Assistant"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default=DEFAULT_LVA_HOST,
|
||||
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=DEFAULT_LVA_PORT,
|
||||
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true",
|
||||
help="Enable DEBUG logging",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
asyncio.run(async_main(args.host, args.port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,481 @@
|
||||
# Satellite 1 HAT Board
|
||||
|
||||
<img src="image.png" width="400" height="200">
|
||||
|
||||
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 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.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
|
||||
| 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 |
|
||||
|
||||
### Compatible hardware
|
||||
|
||||
The Satellite 1 HAT works on any Raspberry Pi with a 40-pin GPIO header. Tested targets:
|
||||
|
||||
- Raspberry Pi Zero 2 W
|
||||
- Raspberry Pi 3 B / B+
|
||||
- Raspberry Pi 4 B
|
||||
- Raspberry Pi 5
|
||||
|
||||
---
|
||||
|
||||
## GPIO pin mapping
|
||||
|
||||
| Function | GPIO (BCM) | Notes |
|
||||
|---|---|---|
|
||||
| LED ring data | 12 | PWM0, channel 0 |
|
||||
| Right button (Volume Up) | 17 | Active low, internal pull-up |
|
||||
| Left button (Volume Down) | 27 | Active low, internal pull-up |
|
||||
| Top button (Mute / Unmute) | 22 | Active low, internal pull-up |
|
||||
| Bottom button (Context action) | 23 | Active low, internal pull-up | — **supports multipress** |
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Light Entity
|
||||
|
||||
The Satellite1 peripheral controller automatically registers an LED light entity with Home Assistant via LVA. This allows you to control the LED ring color and brightness directly from HA.
|
||||
|
||||
### Light entity details
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Entity ID | `light.<satellite>_leds` |
|
||||
| Supported features | On/Off, RGB color, brightness, effect |
|
||||
| Default effect | "Voice Assistant" |
|
||||
| RGB default | Cyan (9.4% R, 73.3% G, 94.9% B) |
|
||||
| Brightness default | 66% |
|
||||
| On/Off default | **Off** (idle stays dark until activated) |
|
||||
|
||||
### Controlling from Home Assistant
|
||||
|
||||
In Home Assistant, you can:
|
||||
|
||||
1. **Turn the light on/off** — toggles the idle LED glow. When off, only active pipeline animations show (listening, thinking, speaking, etc.). When on, the ring displays your chosen color during idle/standby.
|
||||
|
||||
2. **Set color** — pick any RGB color. All pipeline animations are tinted by your chosen color (matching Home Assistant Voice PE behavior):
|
||||
- Wake word → Flash in your color
|
||||
- Listening → Chase in your color
|
||||
- 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.
|
||||
|
||||
4. **Use automations** — e.g., dim the LEDs at night, change color based on assistant state, or flash for alerts.
|
||||
|
||||
### Service calls example (YAML)
|
||||
|
||||
```yaml
|
||||
# Turn on and set to purple
|
||||
service: light.turn_on
|
||||
target:
|
||||
entity_id: light.satellite1_leds
|
||||
data:
|
||||
rgb_color: [200, 0, 255]
|
||||
brightness: 200
|
||||
|
||||
# Dim for night mode
|
||||
service: light.turn_on
|
||||
target:
|
||||
entity_id: light.satellite1_leds
|
||||
data:
|
||||
brightness: 80
|
||||
|
||||
# Turn off (hides idle glow, shows only pipeline animations)
|
||||
service: light.turn_off
|
||||
target:
|
||||
entity_id: light.satellite1_leds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button behaviour
|
||||
|
||||
### Right button — Volume Up
|
||||
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. 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.
|
||||
|
||||
### Bottom button — Context action (with multipress and color change support)
|
||||
|
||||
This button supports single-press context actions, multi-press gestures, **and color changing via the HSV hue wheel** (matching Home Assistant Voice PE):
|
||||
|
||||
#### Single press (< 100 ms hold time)
|
||||
|
||||
Context-aware command based on current assistant state, mirroring the Home Assistant Voice PE centre button priority:
|
||||
|
||||
| Current state | Command sent |
|
||||
|---|---|
|
||||
| Timer ringing | `stop_timer_ringing` |
|
||||
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
|
||||
| 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:
|
||||
|
||||
| Gesture | Timing | Command sent | Use case |
|
||||
|---|---|---|---|
|
||||
| **Double press** | 2 presses < 250 ms apart | `button_double_press` | Trigger custom HA automations, mode toggles, etc. |
|
||||
| **Triple press** | 3 presses < 250 ms apart | `button_triple_press` | Access menu, configuration, advanced features |
|
||||
| **Long press** | Single press held > 1000 ms | `button_long_press` | Scene activation, do-not-disturb, night mode, etc. |
|
||||
|
||||
**Examples:**
|
||||
- Double press → start a specific routine or mode
|
||||
- Triple press → access a menu or configuration option
|
||||
- Long press → toggle do-not-disturb or night mode
|
||||
|
||||
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.
|
||||
|
||||
**Note:** The color change mode (hold + volume) activates after ~100 ms of holding, allowing quick single presses to still trigger context actions.
|
||||
|
||||
#### Hold + Volume buttons — Color wheel rotation
|
||||
|
||||
Hold down the action button and use the volume buttons to rotate through the HSV color wheel (matching Voice PE's rotary dial):
|
||||
|
||||
| Action | Effect |
|
||||
|---|---|
|
||||
| **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
|
||||
- **120°** – Green
|
||||
- **180°** – Cyan
|
||||
- **240°** – Blue
|
||||
- **300°** – Magenta
|
||||
|
||||
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 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
|
||||
|
||||
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The ring color is driven by the HA Light entity (see [Home Assistant Light Entity](#home-assistant-light-entity) above).
|
||||
|
||||
| LVA state | Animation | Description |
|
||||
|---|---|---|
|
||||
| No HA connection / error | Red twinkle | Random red sparkle across all LEDs |
|
||||
| Idle | User color (when light on) | Ring shows your HA light color at configured brightness, or off if light is disabled |
|
||||
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions, tinted by your color |
|
||||
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval, tinted by your color |
|
||||
| Thinking | Pulsing pair | Two opposing LEDs fade in and out, tinted by your color |
|
||||
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction, tinted by your color |
|
||||
| Muted | Solid ring + red indicators | Full ring tinted by your color; red at positions 0, 3, 6 & 9 (all 4 mic locations) |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1 — Host kernel configuration
|
||||
|
||||
> **This must be done on the host Raspberry Pi, not inside Docker.**
|
||||
|
||||
Edit `/boot/firmware/config.txt` (or `/boot/config.txt` on older Raspberry Pi OS):
|
||||
|
||||
```ini
|
||||
# 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
|
||||
# dtparam=audio=on ← comment this out
|
||||
|
||||
# Optional: I2S line-out if using the HAT's audio output
|
||||
# dtoverlay=hifiberry-dac
|
||||
|
||||
# Optional: increase GPU memory on Pi Zero 2 W
|
||||
# gpu_mem=64
|
||||
```
|
||||
|
||||
Reboot after saving:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
> **Why disable onboard audio?** The Raspberry Pi's 3.5 mm headphone jack uses the same PWM0 hardware peripheral as GPIO 12. They cannot run simultaneously. The Satellite 1 HAT's XMOS microphone array connects over USB and is unaffected.
|
||||
|
||||
> **Pi 5 note:** On the Raspberry Pi 5, GPIO is exposed as `/dev/gpiochip4` instead of `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
|
||||
> ```yaml
|
||||
> devices:
|
||||
> - /dev/gpiochip4:/dev/gpiochip4
|
||||
> - /dev/mem:/dev/mem
|
||||
> - /dev/vcio:/dev/vcio
|
||||
> ```
|
||||
|
||||
### Step 2 — Add user to GPIO group
|
||||
|
||||
```bash
|
||||
sudo usermod -aG gpio,spi $USER
|
||||
```
|
||||
|
||||
Log out and back in for the group change to take effect.
|
||||
|
||||
### Step 3 — File structure
|
||||
|
||||
Place all files in the same directory:
|
||||
|
||||
```
|
||||
Satellite1 HAT Board/
|
||||
├── Dockerfile
|
||||
├── compose.yml
|
||||
├── requirements.txt
|
||||
└── Satellite1_HAT_Board.py
|
||||
```
|
||||
|
||||
### Step 4 — Build and start
|
||||
|
||||
#### Option A — Run with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
|
||||
#### Option B — Run directly with Python
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python Satellite1_HAT_Board.py --host localhost --port 6055
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is at the top of `Satellite1_HAT_Board.py`:
|
||||
|
||||
```python
|
||||
# LVA connection
|
||||
DEFAULT_LVA_HOST = "localhost"
|
||||
DEFAULT_LVA_PORT = 6055
|
||||
|
||||
# GPIO pins (BCM numbering)
|
||||
LED_GPIO_PIN = 12
|
||||
BTN_VOLUME_UP = 17
|
||||
BTN_VOLUME_DOWN = 27
|
||||
BTN_MUTE = 22
|
||||
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 = 24
|
||||
LED_BRIGHTNESS = 168 # 0–255, default is 66 % (168)
|
||||
|
||||
# HA Light entity registration
|
||||
LIGHT_OBJECT_ID = "leds"
|
||||
LIGHT_NAME = "LEDs"
|
||||
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
|
||||
|
||||
# Default ring colour — matches HA Voice PE default (cyan)
|
||||
# These are used before the first light_command from HA arrives
|
||||
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
||||
```
|
||||
|
||||
### Light entity configuration
|
||||
|
||||
The light entity is automatically registered with LVA on startup and will appear in Home Assistant as `light.<satellite>_leds`. You can customize it in HA's UI:
|
||||
|
||||
- **Entity name** — rename to your preference in HA
|
||||
- **Color** — adjust in HA's light picker (RGB mode)
|
||||
- **Brightness** — scale all animations
|
||||
- **Toggle on/off** — show/hide idle glow
|
||||
- **Automations** — react to time of day, other entities, etc.
|
||||
|
||||
For details, see [Home Assistant Light Entity](#home-assistant-light-entity) above.
|
||||
|
||||
### Command-line arguments
|
||||
|
||||
The container command in `compose.yml` accepts:
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host` | `localhost` | LVA container hostname or IP |
|
||||
| `--port` | `6055` | LVA peripheral API port |
|
||||
| `--debug` | off | Enable verbose debug logging |
|
||||
|
||||
To connect to LVA running on a different host:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- "--host"
|
||||
- "192.168.1.50" # Use your actual device ip address
|
||||
- "--port"
|
||||
- "6055"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection to LVA
|
||||
|
||||
The controller connects to LVA's peripheral WebSocket API at `ws://<host>:6055`. The LVA container must have the peripheral API enabled (it is on by default) and port 6055 must be reachable.
|
||||
|
||||
On startup the controller:
|
||||
1. Connects to LVA and registers a Light entity for the LED ring with Home Assistant
|
||||
2. Receives a state snapshot (current mute state, volume, HA connection status, and light state)
|
||||
3. Sets the LED ring to the appropriate animation immediately
|
||||
4. Begins listening for events from LVA and button presses from the hardware
|
||||
|
||||
If the connection is lost (LVA restarted, network drop), the controller shows a red twinkle animation and automatically reconnects every 3 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Using multipress events in Home Assistant
|
||||
|
||||
The multipress commands (`button_double_press`, `button_triple_press`, `button_long_press`) are sent to LVA's peripheral API and exposed as button press events. You can trigger Home Assistant automations based on these events.
|
||||
|
||||
### Example Home Assistant automation
|
||||
|
||||
Create an automation file or use the automation UI:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Double press context button for Movie Time"
|
||||
trigger:
|
||||
platform: event
|
||||
event_type: button_press_event
|
||||
event_data:
|
||||
press_type: double_press
|
||||
action:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.movie_time
|
||||
|
||||
- alias: "Long press context button for Do Not Disturb"
|
||||
trigger:
|
||||
platform: event
|
||||
event_type: button_press_event
|
||||
event_data:
|
||||
press_type: long_press
|
||||
action:
|
||||
- service: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.do_not_disturb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drivers summary
|
||||
|
||||
| Component | Driver needed | Where to install | How |
|
||||
|---|---|---|---|
|
||||
| LED ring (SK6812) | PWM kernel overlay | **Host Pi** | `dtoverlay=pwm,pin=12,func=4` in `config.txt`, then reboot |
|
||||
| Buttons | None | — | `gpiozero` + `lgpio` installed inside container via pip |
|
||||
| Microphone (XMOS XVF3800) | None | — | Enumerates as USB audio device automatically |
|
||||
| I2S line-out (optional) | HiFiBerry DAC overlay | **Host Pi** | `dtoverlay=hifiberry-dac` in `config.txt`, then reboot |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### LEDs do not light up
|
||||
|
||||
1. Confirm `dtoverlay=pwm,pin=12,func=4` is in `config.txt` and the Pi has been rebooted.
|
||||
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, 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
|
||||
|
||||
1. Confirm `/dev/gpiochip0` (or `/dev/gpiochip4` on Pi 5) is mapped in the compose `devices` section.
|
||||
2. Run with `--debug` — each button press logs `Button → <command>`.
|
||||
|
||||
### Light entity not appearing in Home Assistant
|
||||
|
||||
1. Confirm the Satellite1 container is connected to LVA: check `docker compose logs` for `Connected to LVA peripheral API`.
|
||||
2. Confirm LVA is connected to Home Assistant: the LVA logs should show `Home Assistant connected` and the satellite integration should be visible in HA's integrations.
|
||||
3. Restart Home Assistant or refresh the Devices page — the new light entity should appear under the satellite integration.
|
||||
4. Entity ID will be `light.<satellite>_leds` where `<satellite>` is your satellite name in HA (e.g., `light.kitchen_satellite_leds`).
|
||||
|
||||
### Container exits immediately
|
||||
|
||||
1. Run `docker compose logs` — a missing `/dev/mem` or `/dev/vcio` device produces a clear error at startup.
|
||||
2. Ensure `privileged: true` is set in the compose file.
|
||||
|
||||
### LVA not reachable
|
||||
|
||||
1. Confirm LVA is running and `--disable-peripheral-api` was not passed.
|
||||
2. With `network_mode: host`, `localhost` resolves to the Pi itself. If LVA is in a separate Docker network (not host mode), use its container IP or service name instead.
|
||||
3. Check that port 6055 is not blocked: `nc -zv localhost 6055`.
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.title="Satellite 1 HAT Peripheral Controller" \
|
||||
org.opencontainers.image.description="LED ring and button controller for Satellite 1 HAT Board, communicating with Linux Voice Assistant" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# System packages
|
||||
#
|
||||
# build-essential – compiles rpi-ws281x C extension
|
||||
# python3-dev – Python C headers for native extensions
|
||||
# git – rpi-ws281x sometimes pulls from git during install
|
||||
#
|
||||
# Note: libgpiod-dev is NOT needed — gpiozero uses the lgpio backend
|
||||
# (installed via pip) which works on Pi 3/4/5 without RPi.GPIO.
|
||||
# ----------------------------------------------------------------------------
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
git \
|
||||
ca-certificates && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Python dependencies
|
||||
#
|
||||
# websockets – WebSocket client to talk to LVA peripheral API
|
||||
# rpi-ws281x – SK6812 / WS2812 LED ring driver (PWM + DMA)
|
||||
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
|
||||
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
|
||||
# ----------------------------------------------------------------------------
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY Satellite1_HAT_Board.py ./
|
||||
|
||||
ENTRYPOINT ["python3", "Satellite1_HAT_Board.py"]
|
||||
CMD []
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
# Satellite 1 HAT Board – peripheral controller
|
||||
#
|
||||
# Runs alongside the linux-voice-assistant container.
|
||||
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
|
||||
# 12-LED SK6812 ring and four hardware buttons on the Satellite 1 HAT.
|
||||
#
|
||||
# IMPORTANT – read the hardware notes at the bottom of this file before
|
||||
# starting the container for the first time.
|
||||
|
||||
services:
|
||||
satellite1-peripheral:
|
||||
build: .
|
||||
image: lva/satellite1_hat_board:local
|
||||
container_name: satellite1_hat_board
|
||||
restart: unless-stopped
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PWM + DMA access (required by rpi-ws281x for LED ring control)
|
||||
#
|
||||
# rpi-ws281x drives the SK6812 LEDs via hardware PWM (GPIO 12, channel 0)
|
||||
# and DMA. This requires access to /dev/mem for DMA buffer allocation.
|
||||
# There is no narrower capability; privileged is the practical requirement
|
||||
# on Raspberry Pi OS when using PWM mode.
|
||||
# -----------------------------------------------------------------------
|
||||
privileged: true
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Device access
|
||||
# -----------------------------------------------------------------------
|
||||
devices:
|
||||
# GPIO memory map – needed by RPi.GPIO for button input
|
||||
- /dev/gpiomem:/dev/gpiomem
|
||||
# Full physical memory map – needed by rpi-ws281x DMA engine
|
||||
- /dev/mem:/dev/mem
|
||||
# Video core mailbox – used by rpi-ws281x to allocate DMA buffers
|
||||
- /dev/vcio:/dev/vcio
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Run as root inside the container.
|
||||
# rpi-ws281x's DMA allocator requires root access to /dev/mem and /dev/vcio.
|
||||
# -----------------------------------------------------------------------
|
||||
user: "0:0"
|
||||
|
||||
group_add:
|
||||
# gpio group – allows RPi.GPIO to open /dev/gpiomem without root
|
||||
# (redundant when user is root but good practice to keep for non-root builds)
|
||||
- gpio
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Environment variables
|
||||
# -----------------------------------------------------------------------
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Network
|
||||
#
|
||||
# Uses the host network so "localhost" resolves to the Pi itself and
|
||||
# the LVA container's port 6055 is directly reachable.
|
||||
# If LVA is on a different host, change --host below to its IP address
|
||||
# and remove network_mode: host.
|
||||
# -----------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Command-line arguments passed to Satellite1_HAT_Board.py
|
||||
# -----------------------------------------------------------------------
|
||||
command:
|
||||
- "--host"
|
||||
- "localhost" # Change to LVA container IP if on a different host
|
||||
- "--port"
|
||||
- "6055"
|
||||
# Uncomment for verbose logging during setup / troubleshooting:
|
||||
# - "--debug"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Health check – verifies the process is still running
|
||||
# -----------------------------------------------------------------------
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "Satellite1_HAT_Board.py"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Logging – keep last 10 MB across 3 rotated files
|
||||
# -----------------------------------------------------------------------
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HARDWARE NOTES – read before first run
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. SPI / PWM KERNEL OVERLAY
|
||||
# rpi-ws281x can drive LEDs via PWM (recommended), PCM, or SPI.
|
||||
# This compose file uses PWM on GPIO 12 (PWM0, channel 0).
|
||||
# Add the following line to /boot/firmware/config.txt (or /boot/config.txt
|
||||
# on older Raspberry Pi OS):
|
||||
#
|
||||
# dtoverlay=pwm,pin=12,func=4
|
||||
#
|
||||
# Then reboot. Without this overlay the PWM pin is not mapped and the
|
||||
# LED ring will not light up.
|
||||
#
|
||||
# Alternatively, SPI mode can be used (GPIO 10, SPI0 MOSI). If you prefer
|
||||
# SPI, enable it via:
|
||||
# dtoverlay=spi0-1cs
|
||||
# and change LED_GPIO_PIN to 10 and LED_CHANNEL to 0 in satellite1_peripheral.py,
|
||||
# then also set LED_DMA to 10 and remove the need for /dev/vcio.
|
||||
#
|
||||
# 2. AUDIO CONFLICT WITH PWM
|
||||
# The Raspberry Pi's onboard audio also uses PWM. If you use the 3.5 mm
|
||||
# headphone jack, it conflicts with PWM0. Disable onboard audio in
|
||||
# /boot/firmware/config.txt:
|
||||
#
|
||||
# # dtparam=audio=on ← comment this out
|
||||
#
|
||||
# The Satellite 1 HAT has its own I2S audio codec (XMOS XVF3800), so
|
||||
# onboard audio is not needed.
|
||||
#
|
||||
# 3. SATELLITE 1 HAT I2S AUDIO DRIVER
|
||||
# The HAT's microphone array is driven by the XMOS XVF3800 over USB.
|
||||
# No additional kernel driver is required — it enumerates as a standard
|
||||
# USB audio device. LVA picks it up automatically via soundcard.
|
||||
# If you use the Satellite 1 HAT's line-out, add to config.txt:
|
||||
#
|
||||
# dtoverlay=hifiberry-dac
|
||||
#
|
||||
# 4. GPIO GROUP
|
||||
# Make sure the user running Docker belongs to the gpio and spi groups
|
||||
# on the host:
|
||||
#
|
||||
# sudo usermod -aG gpio,spi $USER
|
||||
#
|
||||
# 5. MEMORY SPLIT (optional)
|
||||
# On Pi Zero 2 W, increase GPU memory to at least 64 MB so the VideoCore
|
||||
# mailbox has enough address space for the DMA allocator:
|
||||
#
|
||||
# gpu_mem=64 (in /boot/firmware/config.txt)
|
||||
#
|
||||
# =============================================================================
|
||||
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,4 @@
|
||||
websockets>=12.0
|
||||
rpi-ws281x>=5.0.0
|
||||
gpiozero>=2.0
|
||||
lgpio>=0.2
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json",
|
||||
"model": "kilo/codegemma-2b:free",
|
||||
"instructions": ["AGENTS.md"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,9 @@ 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
|
||||
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
|
||||
from .util import (
|
||||
get_default_interface,
|
||||
@@ -60,12 +61,15 @@ async def main() -> None:
|
||||
"--audio-input-block-size",
|
||||
type=int,
|
||||
default=1024,
|
||||
# todo
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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",
|
||||
@@ -74,6 +78,13 @@ async def main() -> None:
|
||||
parser.add_argument("--mic-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)")
|
||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
|
||||
parser.add_argument(
|
||||
"--audio-input-channels",
|
||||
type=int,
|
||||
default=1,
|
||||
choices=(1, 2),
|
||||
help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wake-word-dir",
|
||||
default=[_WAKEWORDS_DIR],
|
||||
@@ -101,11 +112,22 @@ async def main() -> None:
|
||||
type=float,
|
||||
help="Seconds before wake word can be activated again",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue-conversation-delay",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to wait after TTS finishes before opening the mic for continued conversation (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wakeup-sound",
|
||||
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
|
||||
help="Directory and file name for wake sound (when you say the wake word)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-listening-sound",
|
||||
default=str(_SOUNDS_DIR / "start_listening_button.flac"),
|
||||
help="Directory and file name and sound for start listening button (when you press button to talk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-sound",
|
||||
default=str(_SOUNDS_DIR / "timer_finished.flac"),
|
||||
@@ -126,42 +148,101 @@ async def main() -> None:
|
||||
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
|
||||
help="Sound to play when unmuting the assistant",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--button-double-press-sound",
|
||||
default=str(_SOUNDS_DIR / "button_double_press.flac"),
|
||||
help="Sound to play for button double press",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--button-triple-press-sound",
|
||||
default=str(_SOUNDS_DIR / "button_triple_press.flac"),
|
||||
help="Sound to play for button triple press",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--button-long-press-sound",
|
||||
default=str(_SOUNDS_DIR / "button_long_press.flac"),
|
||||
help="Sound to play for button long press",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preferences-file",
|
||||
default=_REPO_DIR / "preferences.json",
|
||||
help="Directory and file name for the file where the preferences are stored in JSON format",
|
||||
help="Directory and file name for the preferences JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
help="Optional host IP address to bind to (default: Autodetected by network interface)", # 0.0.0.0 is IPv4, None is all interfaces
|
||||
help="Optional host IP address to bind to (default: auto-detected by network interface)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--network-interface",
|
||||
help="Network interface the application will be listening on (default: will be automatically detected by gateway)",
|
||||
help="Network interface the application listens on (default: auto-detected by gateway)",
|
||||
)
|
||||
# Note that default port is also set in docker-entrypoint.sh
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=6053,
|
||||
help="Port the application is listenening on (default: 6053)",
|
||||
help="Port the application is listening on (default: 6053)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-thinking-sound",
|
||||
action="store_true",
|
||||
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
|
||||
help="Enable thinking sound on startup",
|
||||
)
|
||||
# ------------------------------------------------------------------
|
||||
# Peripheral API (LEDs, buttons, HAT boards)
|
||||
# ------------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--peripheral-host",
|
||||
default="0.0.0.0",
|
||||
help="Bind address for the peripheral WebSocket API (default: 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peripheral-port",
|
||||
type=int,
|
||||
default=6055,
|
||||
help="Port for the peripheral WebSocket API (default: 6055)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peripheral-volume-step",
|
||||
type=float,
|
||||
default=PeripheralAPIServer.DEFAULT_VOLUME_STEP,
|
||||
metavar="STEP",
|
||||
help="Volume change per button press, 0.0–1.0 (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-peripheral-api",
|
||||
action="store_true",
|
||||
help="Disable the peripheral WebSocket API entirely",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--peripheral-startup-wait",
|
||||
type=float,
|
||||
default=2.0,
|
||||
metavar="SECONDS",
|
||||
help="Seconds to wait for peripherals to connect and register their entities before HA enumerates the ESPHome API (default: %(default)s; set 0 to skip).",
|
||||
)
|
||||
# ------------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--timer-max-ring-seconds",
|
||||
type=float,
|
||||
default=900.0, # 15 minutes
|
||||
help="Seconds before a ringing timer auto-stops (default: 900)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--listen-during-wake-sound",
|
||||
action="store_true",
|
||||
help="Start listening immediately after wake word detection, without waiting for the wake sound to finish",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
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",
|
||||
@@ -169,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)
|
||||
@@ -187,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")
|
||||
@@ -255,6 +342,25 @@ async def main() -> None:
|
||||
|
||||
# Load available wake words
|
||||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
||||
|
||||
# If the operator explicitly pointed --wake-word-dir (or the WAKE_WORD_DIR
|
||||
# env var) at the openWakeWord subdirectory, prefer resolving --wake-model
|
||||
# to an openWakeWord model of the same name instead of a same-named
|
||||
# microWakeWord one. Checked before the automatic dirs below are appended,
|
||||
# since those always include the openWakeWord path and would otherwise
|
||||
# make every configuration look like an openWakeWord preference.
|
||||
preferred_wake_word_type = WakeWordType.OPEN_WAKE_WORD if any("openwakeword" in str(ww_dir).lower() for ww_dir in wake_word_dirs) else None
|
||||
|
||||
# openWakeWord models ship in their own subdirectory under the default
|
||||
# wakewords dir. find_available_wake_words() only globs the top level of
|
||||
# each directory it's given, so this must be added explicitly or the OWW
|
||||
# models never get discovered (and never show up in the HA dropdown).
|
||||
# Appended after the user-specified dirs so OWW entries are inserted
|
||||
# (and therefore displayed) after the microWakeWord ones.
|
||||
oww_dir = _WAKEWORDS_DIR / "openWakeWord"
|
||||
if oww_dir not in wake_word_dirs:
|
||||
wake_word_dirs.append(oww_dir)
|
||||
|
||||
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||||
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
|
||||
|
||||
@@ -273,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
|
||||
|
||||
@@ -292,7 +411,12 @@ async def main() -> None:
|
||||
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||||
|
||||
# Load wake/stop models
|
||||
wake_models, active_wake_words, fallback_used = load_wake_models(available_wake_words, [word for word in preferences.active_wake_words if word is not None], args.wake_model)
|
||||
wake_models, active_wake_words, fallback_used = load_wake_models(
|
||||
available_wake_words,
|
||||
[word for word in preferences.active_wake_words if word is not None],
|
||||
args.wake_model,
|
||||
preferred_type=preferred_wake_word_type,
|
||||
)
|
||||
|
||||
# TODO: allow openWakeWord for "stop"
|
||||
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
||||
@@ -312,23 +436,32 @@ 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,
|
||||
timer_finished_sound=args.timer_finished_sound,
|
||||
processing_sound=args.processing_sound,
|
||||
mute_sound=args.mute_sound,
|
||||
unmute_sound=args.unmute_sound,
|
||||
button_double_press_sound=args.button_double_press_sound,
|
||||
button_triple_press_sound=args.button_triple_press_sound,
|
||||
button_long_press_sound=args.button_long_press_sound,
|
||||
preferences=preferences,
|
||||
preferences_path=preferences_path,
|
||||
refractory_seconds=args.refractory_seconds,
|
||||
continue_conversation_delay=args.continue_conversation_delay,
|
||||
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,
|
||||
audio_input_channels=args.audio_input_channels,
|
||||
timer_max_ring_seconds=args.timer_max_ring_seconds,
|
||||
listen_during_wake_sound=args.listen_during_wake_sound,
|
||||
)
|
||||
|
||||
if fallback_used:
|
||||
@@ -347,6 +480,22 @@ async def main() -> None:
|
||||
state.music_player.set_volume(initial_volume_percent)
|
||||
state.tts_player.set_volume(initial_volume_percent)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Peripheral API (optional – LEDs, buttons, HAT boards)
|
||||
# ------------------------------------------------------------------
|
||||
peripheral_api: Optional[PeripheralAPIServer] = None
|
||||
if not args.disable_peripheral_api:
|
||||
peripheral_api = PeripheralAPIServer(
|
||||
host=args.peripheral_host,
|
||||
port=args.peripheral_port,
|
||||
volume_step=args.peripheral_volume_step,
|
||||
)
|
||||
peripheral_api.set_state(state)
|
||||
state.peripheral_api = peripheral_api
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ESPHome TCP server (with retry on EADDRINUSE)
|
||||
# ------------------------------------------------------------------
|
||||
loop = asyncio.get_running_loop()
|
||||
max_attempts = 15
|
||||
attempt = 1
|
||||
@@ -370,20 +519,40 @@ async def main() -> None:
|
||||
|
||||
while attempt <= max_attempts:
|
||||
try:
|
||||
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
|
||||
break # connect successful, exit the loop
|
||||
server = await loop.create_server(
|
||||
lambda: VoiceSatelliteProtocol(state),
|
||||
host=host_ip_address,
|
||||
port=args.port,
|
||||
)
|
||||
break # connection successful, exit the loop
|
||||
except OSError as err:
|
||||
message = err.strerror or str(err)
|
||||
if err.errno == errno.EADDRINUSE:
|
||||
message = "address already in use"
|
||||
if attempt < max_attempts:
|
||||
_LOGGER.warning("Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...", attempt, max_attempts, host_ip_address, args.port, message)
|
||||
_LOGGER.warning(
|
||||
"Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...",
|
||||
attempt,
|
||||
max_attempts,
|
||||
host_ip_address,
|
||||
args.port,
|
||||
message,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
attempt += 1
|
||||
else:
|
||||
_LOGGER.exception("All %d attempts failed to bind on address (%s, %s): %s", max_attempts, host_ip_address, args.port, message)
|
||||
_LOGGER.exception(
|
||||
"All %d attempts failed to bind on address (%s, %s): %s",
|
||||
max_attempts,
|
||||
host_ip_address,
|
||||
args.port,
|
||||
message,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Audio processing thread
|
||||
# ------------------------------------------------------------------
|
||||
process_audio_thread = threading.Thread(
|
||||
target=process_audio,
|
||||
args=(state, mic, args.audio_input_block_size),
|
||||
@@ -392,27 +561,88 @@ async def main() -> None:
|
||||
process_audio_thread.start()
|
||||
|
||||
# Auto discovery (zeroconf, mDNS)
|
||||
discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address)
|
||||
discovery = HomeAssistantZeroconf(
|
||||
port=args.port,
|
||||
name=state.name,
|
||||
mac_address=state.mac_address,
|
||||
host_ip_address=host_ip_address,
|
||||
)
|
||||
await discovery.register_server()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Start peripheral API and signal "getting started" to peripherals
|
||||
# ------------------------------------------------------------------
|
||||
if peripheral_api is not None:
|
||||
await peripheral_api.start()
|
||||
await peripheral_api.emit_event(LVAEvent.ZEROCONF, {"status": "getting_started"})
|
||||
|
||||
# Give peripherals a window to connect and register their Light
|
||||
# entities before HA enumerates over the ESPHome native API. The
|
||||
# ESPHome server is bound but not yet serving (serve_forever runs
|
||||
# below), so any HA connection sits queued in the kernel for the
|
||||
# duration of this wait. Peripherals that register later still
|
||||
# work, but the new entities only show up in HA after the
|
||||
# integration reconnects.
|
||||
if args.peripheral_startup_wait > 0:
|
||||
_LOGGER.info(
|
||||
"Waiting %.1fs for peripherals to register entities…",
|
||||
args.peripheral_startup_wait,
|
||||
)
|
||||
await asyncio.sleep(args.peripheral_startup_wait)
|
||||
|
||||
try:
|
||||
async with server: # type: ignore
|
||||
async with server: # type: ignore[union-attr]
|
||||
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
|
||||
await server.serve_forever() # type: ignore
|
||||
await server.serve_forever() # type: ignore[union-attr]
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
state.audio_queue.put_nowait(None)
|
||||
process_audio_thread.join()
|
||||
if peripheral_api is not None:
|
||||
await peripheral_api.stop()
|
||||
|
||||
_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],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def process_audio(state: ServerState, mic, block_size: int):
|
||||
"""Process audio chunks from the microphone."""
|
||||
n_channels = state.audio_input_channels
|
||||
|
||||
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
||||
micro_features: Optional[MicroWakeWordFeatures] = None
|
||||
@@ -427,12 +657,23 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Opening audio input device: %s", mic.name)
|
||||
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
|
||||
with mic.recorder(samplerate=16000, channels=n_channels, blocksize=block_size) as mic_in:
|
||||
while True:
|
||||
audio_chunk_array = mic_in.record(block_size).reshape(-1)
|
||||
# little-endian 16-bit signed
|
||||
# Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono.
|
||||
raw = mic_in.record(block_size) # float32, range [-1, 1]
|
||||
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||||
|
||||
# Build per-channel byte arrays. Channel 0 is the primary
|
||||
# microphone; channel 1 (when present) is the reference/speaker
|
||||
# feed used for server-side AEC.
|
||||
channel_chunks: list[bytes] = []
|
||||
for ch in range(n_channels):
|
||||
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
|
||||
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||||
channel_chunks.append(chunk)
|
||||
|
||||
# Primary channel drives WebRTC and wake-word detection.
|
||||
audio_chunk = channel_chunks[0]
|
||||
agc = state.preferences.mic_auto_gain or 0
|
||||
ns = state.preferences.mic_noise_suppression or 0
|
||||
|
||||
@@ -532,7 +773,9 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
oww_features = OpenWakeWordFeatures.from_builtin()
|
||||
|
||||
try:
|
||||
state.satellite.handle_audio(audio_chunk)
|
||||
# Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
|
||||
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
|
||||
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
|
||||
|
||||
assert micro_features is not None
|
||||
micro_inputs.clear()
|
||||
@@ -599,9 +842,9 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
|
||||
_LOGGER.debug("Stop word detected")
|
||||
state.satellite.stop()
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error handling audio")
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error processing audio")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ from typing import Callable, List, Optional, Union
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
EventResponse,
|
||||
LightCommandRequest,
|
||||
LightStateResponse,
|
||||
ListEntitiesEventResponse,
|
||||
ListEntitiesLightResponse,
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
ListEntitiesNumberResponse,
|
||||
ListEntitiesRequest,
|
||||
@@ -21,6 +26,7 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
SwitchStateResponse,
|
||||
)
|
||||
from aioesphomeapi.model import (
|
||||
ColorMode,
|
||||
EntityCategory,
|
||||
MediaPlayerCommand,
|
||||
MediaPlayerEntityFeature,
|
||||
@@ -83,6 +89,19 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.apply_volume_from_state(initial_volume)
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def _broadcast_state(self, msgs: Iterable[message.Message]) -> None:
|
||||
"""Push an asynchronous state change to all connected clients.
|
||||
|
||||
Playback-completion callbacks fire outside any request, so the update
|
||||
must reach every subscribed client rather than the single connection in
|
||||
``self.server`` (which may belong to another client, or be closed).
|
||||
"""
|
||||
state = getattr(self.server, "state", None)
|
||||
if state is not None:
|
||||
state.broadcast(msgs)
|
||||
else: # pragma: no cover - no ServerState (e.g. a bare APIServer)
|
||||
self.server.send_messages(msgs)
|
||||
|
||||
def play(
|
||||
self,
|
||||
url: Union[str, List[str]],
|
||||
@@ -103,7 +122,7 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.announce_player.play(
|
||||
url,
|
||||
done_callback=lambda: call_all(
|
||||
self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
||||
lambda: self._broadcast_state([self._update_state(MediaPlayerState.IDLE)]),
|
||||
done_callback,
|
||||
),
|
||||
)
|
||||
@@ -113,7 +132,7 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.music_player.play(
|
||||
url,
|
||||
done_callback=lambda: call_all(
|
||||
self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
||||
lambda: self._broadcast_state([self._update_state(MediaPlayerState.IDLE)]),
|
||||
done_callback,
|
||||
),
|
||||
)
|
||||
@@ -163,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:
|
||||
@@ -172,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:
|
||||
@@ -612,12 +635,240 @@ class StopWordSensitivityNumberEntity(ESPHomeEntity):
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
|
||||
|
||||
class LEDLightEntity(ESPHomeEntity):
|
||||
"""RGB Light entity for peripheral LEDs.
|
||||
|
||||
The peripheral declares its capabilities (effects, RGB, brightness)
|
||||
via the register_light command. When Home Assistant changes the
|
||||
entity, on_changed fires so the peripheral API server can broadcast
|
||||
a light_command event back to the peripheral, which applies the
|
||||
new state to its hardware.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
effects: Optional[List[str]] = None,
|
||||
supports_rgb: bool = True,
|
||||
supports_brightness: bool = True,
|
||||
on_changed: Optional[Callable[[], None]] = None,
|
||||
icon: str = "mdi:led-strip-variant",
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self.icon = icon
|
||||
self._on_changed = on_changed
|
||||
self.effects_list: List[str] = list(effects) if effects else []
|
||||
self._supports_rgb = supports_rgb
|
||||
self._supports_brightness = supports_brightness
|
||||
|
||||
# Off by default, matching the HA Voice PE LED Ring
|
||||
# (restore_mode RESTORE_DEFAULT_OFF): the resting LEDs stay dark
|
||||
# until the user turns the light on. Voice animations are driven
|
||||
# separately by the peripheral and play regardless.
|
||||
self.is_on: bool = False
|
||||
# Match the HA Voice PE LED Ring initial state: a light blue at 66%
|
||||
# brightness (red 9.4%, green 73.3%, blue 94.9%).
|
||||
self.brightness: float = 0.66
|
||||
self.red: float = 0.094
|
||||
self.green: float = 0.733
|
||||
self.blue: float = 0.949
|
||||
# Default effect: first declared, or empty if none.
|
||||
self.effect: str = self.effects_list[0] if self.effects_list else ""
|
||||
|
||||
def update_on_changed(self, on_changed: Optional[Callable[[], None]]) -> None:
|
||||
self._on_changed = on_changed
|
||||
|
||||
def _color_mode(self) -> ColorMode:
|
||||
if self._supports_rgb:
|
||||
return ColorMode.RGB
|
||||
if self._supports_brightness:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ON_OFF
|
||||
|
||||
def state_dict(self) -> dict:
|
||||
"""Payload for the light_command event.
|
||||
|
||||
Includes object_id so a peripheral that registered more than one
|
||||
Light can route the event to the right hardware.
|
||||
"""
|
||||
return {
|
||||
"object_id": self.object_id,
|
||||
"state": self.is_on,
|
||||
"brightness": self.brightness,
|
||||
"red": self.red,
|
||||
"green": self.green,
|
||||
"blue": self.blue,
|
||||
"effect": self.effect,
|
||||
}
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, LightCommandRequest) and msg.key == self.key:
|
||||
changed = False
|
||||
if msg.has_state:
|
||||
self.is_on = bool(msg.state)
|
||||
changed = True
|
||||
if msg.has_brightness and self._supports_brightness:
|
||||
self.brightness = max(0.0, min(1.0, float(msg.brightness)))
|
||||
changed = True
|
||||
if msg.has_rgb and self._supports_rgb:
|
||||
self.red = max(0.0, min(1.0, float(msg.red)))
|
||||
self.green = max(0.0, min(1.0, float(msg.green)))
|
||||
self.blue = max(0.0, min(1.0, float(msg.blue)))
|
||||
changed = True
|
||||
if msg.has_effect:
|
||||
requested = str(msg.effect)
|
||||
if requested in self.effects_list:
|
||||
self.effect = requested
|
||||
changed = True
|
||||
if changed and self._on_changed is not None:
|
||||
self._on_changed()
|
||||
yield self._state_response()
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesLightResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
supported_color_modes=[int(self._color_mode())],
|
||||
effects=self.effects_list,
|
||||
icon=self.icon,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
yield self._state_response()
|
||||
|
||||
def _state_response(self) -> LightStateResponse:
|
||||
return LightStateResponse(
|
||||
key=self.key,
|
||||
state=self.is_on,
|
||||
brightness=self.brightness,
|
||||
color_mode=int(self._color_mode()),
|
||||
color_brightness=self.brightness,
|
||||
red=self.red,
|
||||
green=self.green,
|
||||
blue=self.blue,
|
||||
effect=self.effect,
|
||||
)
|
||||
|
||||
|
||||
class ButtonEventSensorEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self.event_types = ["single_press", "double_press", "triple_press", "long_press"]
|
||||
self._current_event: Optional[str] = None
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def update_state(self, event_type: str) -> None:
|
||||
"""Update the event state with a button press event."""
|
||||
self._current_event = event_type
|
||||
self._log.debug("Button event state updated: %s", event_type)
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesEventResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
device_class="button",
|
||||
event_types=self.event_types,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
# Wait until a press fires: yielding with an empty
|
||||
# event_type makes HA reject the state and fail the
|
||||
# whole ESPHome config entry to load.
|
||||
if self._current_event:
|
||||
yield self._get_state_message()
|
||||
|
||||
def _get_state_message(self) -> EventResponse:
|
||||
return EventResponse(
|
||||
key=self.key,
|
||||
event_type=self._current_event or "",
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
"MediaPlayerEntity",
|
||||
"MuteSwitchEntity",
|
||||
"ThinkingSoundEntity",
|
||||
"LEDLightEntity",
|
||||
"ButtonEventSensorEntity",
|
||||
"ButtonLockEntity",
|
||||
"WakeWord1SensitivityNumberEntity",
|
||||
"WakeWord2SensitivityNumberEntity",
|
||||
"StopWordSensitivityNumberEntity",
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from google.protobuf import message
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .entity import (
|
||||
ButtonEventSensorEntity,
|
||||
ButtonLockEntity,
|
||||
ESPHomeEntity,
|
||||
LEDLightEntity,
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
@@ -59,11 +64,29 @@ class AvailableWakeWord:
|
||||
raise ValueError(f"Unexpected wake word type: {self.type}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightRegistration:
|
||||
"""Capabilities a peripheral declares for one of its Light entities.
|
||||
|
||||
The peripheral sends this with the register_light command after
|
||||
connecting. LVA materialises a matching LEDLightEntity so HA can
|
||||
control it.
|
||||
"""
|
||||
|
||||
name: str
|
||||
object_id: str
|
||||
icon: str = "mdi:led-strip-variant"
|
||||
effects: List[str] = field(default_factory=list)
|
||||
supports_rgb: bool = True
|
||||
supports_brightness: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
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
|
||||
@@ -91,18 +114,51 @@ class ServerState:
|
||||
music_player: "MpvMediaPlayer"
|
||||
tts_player: "MpvMediaPlayer"
|
||||
wakeup_sound: str
|
||||
start_listening_sound: str
|
||||
processing_sound: str
|
||||
timer_finished_sound: str
|
||||
mute_sound: str
|
||||
unmute_sound: str
|
||||
button_double_press_sound: str
|
||||
button_triple_press_sound: str
|
||||
button_long_press_sound: str
|
||||
preferences: Preferences
|
||||
preferences_path: Path
|
||||
download_dir: Path
|
||||
continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic
|
||||
|
||||
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
connections: "List[VoiceSatelliteProtocol]" = field(default_factory=list)
|
||||
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
|
||||
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
|
||||
button_event_sensor_entity: "Optional[ButtonEventSensorEntity]" = None
|
||||
|
||||
# Lights declared by peripherals via register_light. Survives HA
|
||||
# reconnects so the satellite can rebuild its entities whenever it
|
||||
# is constructed again.
|
||||
pending_lights: "List[LightRegistration]" = field(default_factory=list)
|
||||
# Materialised LightEntities keyed by object_id, so light_command
|
||||
# events can be routed back to the right peripheral hardware.
|
||||
led_light_entities: "Dict[str, LEDLightEntity]" = field(default_factory=dict)
|
||||
|
||||
# True once a peripheral sends register_button. Gates creation of
|
||||
# ButtonEventSensorEntity so the HA device page only shows the button
|
||||
# entity when hardware that actually supports button presses is present.
|
||||
# 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
|
||||
|
||||
sensitivity_1_number_entity: "Optional[WakeWord1SensitivityNumberEntity]" = None
|
||||
sensitivity_2_number_entity: "Optional[WakeWord2SensitivityNumberEntity]" = None
|
||||
stop_sensitivity_number_entity: "Optional[StopWordSensitivityNumberEntity]" = None
|
||||
@@ -112,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
|
||||
@@ -125,7 +182,24 @@ class ServerState:
|
||||
mic_auto_gain: int = 0
|
||||
mic_noise_suppression: int = 0
|
||||
mic_volume: int = 100 # 1–100, default maximum
|
||||
audio_input_channels: int = 2 # number of mic channels to stream
|
||||
timer_max_ring_seconds: float = 900.0
|
||||
listen_during_wake_sound: bool = False
|
||||
|
||||
def broadcast(self, msgs: "Iterable[message.Message]") -> None:
|
||||
"""Send messages to every connected API client.
|
||||
|
||||
Entity state changes that happen asynchronously (not in response to a
|
||||
request) must reach *all* subscribed clients, not just whichever
|
||||
connection happens to be referenced by an entity's ``server``. Without
|
||||
this fan-out a second API client leaves Home Assistant stuck on a stale
|
||||
state (e.g. ``playing`` after playback has finished).
|
||||
"""
|
||||
messages = list(msgs)
|
||||
if not messages:
|
||||
return
|
||||
for connection in list(self.connections):
|
||||
connection.send_messages(messages)
|
||||
|
||||
def save_preferences(self) -> None:
|
||||
"""Save preferences as JSON."""
|
||||
@@ -153,12 +227,24 @@ 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)
|
||||
self.save_preferences()
|
||||
_LOGGER.info("Volume saved successfully")
|
||||
|
||||
# Notify peripheral container (thread-safe; may be called from mpv callbacks)
|
||||
api = self.peripheral_api
|
||||
if api is not None:
|
||||
from .peripheral_api import LVAEvent # local import avoids circular dep
|
||||
|
||||
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)
|
||||
@@ -189,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)))
|
||||
@@ -0,0 +1,777 @@
|
||||
"""WebSocket peripheral API.
|
||||
|
||||
Bridges LVA state to a separate peripheral container (LEDs, buttons, HAT boards).
|
||||
|
||||
Protocol (JSON over WebSocket):
|
||||
Events (LVA → peripheral): {"event": "<name>", "data": {...}}
|
||||
Commands (peripheral → LVA): {"command": "<name>", "data": {...}}
|
||||
Snapshot (on connect): {"event": "snapshot", "data": {...}}
|
||||
|
||||
Feedback events emitted by LVA
|
||||
-------------------------------
|
||||
wake_word_detected
|
||||
listening
|
||||
stt_text data: {"text": str}
|
||||
Emitted when HA returns the recognised speech transcript.
|
||||
Use this to display what the user said on a screen or LED ticker.
|
||||
thinking
|
||||
tts_text data: {"text": str}
|
||||
Emitted when HA returns the assistant's response text, just
|
||||
before TTS audio begins playing.
|
||||
Use this to display the assistant's reply on a screen.
|
||||
tts_speaking
|
||||
tts_finished
|
||||
pipeline_error data: {"reason": <str>}
|
||||
Emitted when the voice pipeline reports an error (STT failure,
|
||||
intent error, etc.). NOT emitted when HA disconnects — that
|
||||
uses the separate ``disconnected`` event below.
|
||||
Peripheral containers should show a brief red error animation
|
||||
(e.g. 3 red flashes then off) and then return to idle.
|
||||
disconnected
|
||||
Emitted when the HA TCP connection is lost.
|
||||
Connected leds with peripheral containers should
|
||||
show a red twinkle / "no connection" animation and keep retrying
|
||||
until they see a ``zeroconf`` event with status "connected".
|
||||
NOTE: if LVA itself is not running the peripheral container will
|
||||
see a WebSocket connection failure on its end — that is also
|
||||
a "disconnected" condition to handle with the same animation.
|
||||
idle
|
||||
muted data: {"muted": true/false}
|
||||
timer_ticking data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
|
||||
timer_updated data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
|
||||
timer_ringing data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
|
||||
media_player_playing Emitted when HA sends music/media to the music_player
|
||||
(non-announcement playback). Not emitted for TTS or
|
||||
voice pipeline announcements — those use tts_speaking.
|
||||
volume_changed data: {"volume": 0.0–1.0}
|
||||
volume_muted data: {"muted": true/false}
|
||||
zeroconf data: {"status": "getting_started" | "connected"}
|
||||
light_command data: {"object_id": str, "state": bool, "brightness": float,
|
||||
"red": float, "green": float, "blue": float, "effect": str}
|
||||
Fires when HA changes a Light entity that a peripheral
|
||||
previously registered via register_light. The peripheral
|
||||
matches on object_id and applies the new state. The
|
||||
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
|
||||
------------------------------------------------
|
||||
start_listening
|
||||
stop_pipeline Abort the active voice pipeline at any phase — listening,
|
||||
thinking, speaking or wake word active. Calls satellite.stop() which
|
||||
cleans up STT streaming, sends VoiceAssistantAnnounceFinished
|
||||
to HA, unducking music, and emits idle to peripherals.
|
||||
mute_mic
|
||||
unmute_mic
|
||||
volume_up
|
||||
volume_down
|
||||
set_volume data: {"volume": 0.0–1.0}
|
||||
stop_timer_ringing
|
||||
pause_media_player
|
||||
resume_media_player
|
||||
stop_media_player
|
||||
button_single_press
|
||||
button_double_press
|
||||
button_triple_press
|
||||
button_long_press
|
||||
register_light data: {"name": str, "object_id": str, "effects": [str],
|
||||
"supports_rgb": bool, "supports_brightness": bool}
|
||||
The peripheral declares an LED Light it wants exposed in
|
||||
HA. LVA creates a matching ESPHome Light entity (visible
|
||||
as light.<satellite>_<object_id>) and routes HA changes
|
||||
back to the peripheral as light_command events. Send
|
||||
once after connecting; duplicate registrations for the
|
||||
same object_id are ignored.
|
||||
register_button
|
||||
The peripheral declares that it has physical buttons and
|
||||
wants a Button Press event entity exposed in HA. LVA
|
||||
creates a ButtonEventSensorEntity (visible as
|
||||
event.<satellite>_button_press_event) that fires
|
||||
single_press, double_press, triple_press, and long_press
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
|
||||
|
||||
from aioesphomeapi.api_pb2 import MediaPlayerStateResponse # type: ignore[attr-defined] # pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.model import MediaPlayerState # type: ignore[import]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public enumerations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LVAEvent(str, Enum):
|
||||
"""Events broadcast from LVA to peripheral clients."""
|
||||
|
||||
WAKE_WORD_DETECTED = "wake_word_detected"
|
||||
LISTENING = "listening"
|
||||
STT_TEXT = "stt_text"
|
||||
THINKING = "thinking"
|
||||
TTS_TEXT = "tts_text"
|
||||
TTS_SPEAKING = "tts_speaking"
|
||||
TTS_FINISHED = "tts_finished"
|
||||
PIPELINE_ERROR = "pipeline_error"
|
||||
DISCONNECTED = "disconnected"
|
||||
IDLE = "idle"
|
||||
MUTED = "muted"
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_UPDATED = "timer_updated"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYER_PLAYING = "media_player_playing"
|
||||
VOLUME_CHANGED = "volume_changed"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
ZEROCONF = "zeroconf"
|
||||
LIGHT_COMMAND = "light_command"
|
||||
BUTTON_LOCK_CHANGED = "button_lock_changed"
|
||||
|
||||
|
||||
class LVACommand(str, Enum):
|
||||
"""Commands accepted from peripheral clients."""
|
||||
|
||||
START_LISTENING = "start_listening"
|
||||
STOP_PIPELINE = "stop_pipeline"
|
||||
MUTE_MIC = "mute_mic"
|
||||
UNMUTE_MIC = "unmute_mic"
|
||||
VOLUME_UP = "volume_up"
|
||||
VOLUME_DOWN = "volume_down"
|
||||
SET_VOLUME = "set_volume"
|
||||
STOP_TIMER_RINGING = "stop_timer_ringing"
|
||||
STOP_MEDIA_PLAYER = "stop_media_player"
|
||||
PAUSE_MEDIA_PLAYER = "pause_media_player"
|
||||
RESUME_MEDIA_PLAYER = "resume_media_player"
|
||||
BUTTON_SINGLE_PRESS = "button_single_press"
|
||||
BUTTON_DOUBLE_PRESS = "button_double_press"
|
||||
BUTTON_TRIPLE_PRESS = "button_triple_press"
|
||||
BUTTON_LONG_PRESS = "button_long_press"
|
||||
REGISTER_LIGHT = "register_light"
|
||||
REGISTER_BUTTON = "register_button"
|
||||
REGISTER_BUTTON_LOCK = "register_button_lock"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PeripheralAPIServer:
|
||||
"""
|
||||
WebSocket server that bridges LVA state to a peripheral container.
|
||||
|
||||
Usage
|
||||
-----
|
||||
1. Construct in ``__main__.main()``.
|
||||
2. ``peripheral_api.set_state(state)`` once ``ServerState`` exists.
|
||||
3. ``await peripheral_api.start()`` inside the running event loop.
|
||||
4. Call ``emit_event_sync()`` from any thread (mpv callbacks, audio thread).
|
||||
"""
|
||||
|
||||
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,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 6055,
|
||||
volume_step: float = DEFAULT_VOLUME_STEP,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._volume_step = volume_step
|
||||
|
||||
self._clients: Set[Any] = set()
|
||||
self._state: Optional[ServerState] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._server: Any = None
|
||||
|
||||
# Last conversation exchange — sent in the snapshot to newly-connecting clients
|
||||
self._last_stt_text: Optional[str] = None
|
||||
self._last_tts_text: Optional[str] = None
|
||||
|
||||
# Current event state — replayed to newly-connecting clients so they can
|
||||
# show the correct animation immediately without waiting for the next event.
|
||||
# Only "state" events are tracked (pipeline, timer, media, muted, idle).
|
||||
# Transient/informational events (stt_text, tts_text, volume_changed, etc.)
|
||||
# are not tracked because they carry no ongoing visual state.
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_state(self, state: "ServerState") -> None:
|
||||
"""Attach the shared ``ServerState`` so commands can read/mutate it."""
|
||||
self._state = state
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the WebSocket server inside the running event loop."""
|
||||
try:
|
||||
from websockets.server import serve # type: ignore[import]
|
||||
except ImportError:
|
||||
_LOGGER.error("websockets package not installed – peripheral API disabled. Install with: pip install websockets")
|
||||
return
|
||||
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._server = await serve(self._handle_client, self._host, self._port)
|
||||
_LOGGER.info("Peripheral API listening at ws://%s:%d", self._host, self._port)
|
||||
|
||||
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()
|
||||
_LOGGER.info("Peripheral API stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Client handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_client(self, websocket: Any) -> None:
|
||||
addr = getattr(websocket, "remote_address", "unknown")
|
||||
self._clients.add(websocket)
|
||||
_LOGGER.info("Peripheral client connected: %s", addr)
|
||||
|
||||
await self._send_snapshot(websocket)
|
||||
|
||||
try:
|
||||
async for raw in websocket:
|
||||
await self._dispatch_command(raw)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Peripheral client %s error: %s", addr, exc)
|
||||
finally:
|
||||
self._clients.discard(websocket)
|
||||
_LOGGER.info("Peripheral client disconnected: %s", addr)
|
||||
|
||||
async def _send_snapshot(self, websocket: Any) -> None:
|
||||
"""Push current LVA state to a newly connected peripheral client."""
|
||||
state = self._state
|
||||
if state is None:
|
||||
return
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"event": "snapshot",
|
||||
"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,
|
||||
},
|
||||
}
|
||||
)
|
||||
try:
|
||||
await websocket.send(payload)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return
|
||||
|
||||
# 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.
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _dispatch_command(self, raw: str) -> None:
|
||||
"""Parse and execute a JSON command from the peripheral container."""
|
||||
try:
|
||||
msg: Dict[str, Any] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.warning("Peripheral: invalid JSON: %.200s", raw)
|
||||
return
|
||||
|
||||
command: str = msg.get("command", "")
|
||||
if not command:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Peripheral command received: %s", command)
|
||||
|
||||
state = self._state
|
||||
if state is None:
|
||||
return
|
||||
|
||||
satellite = state.satellite
|
||||
|
||||
if command == LVACommand.START_LISTENING:
|
||||
if satellite is None or state.muted:
|
||||
return
|
||||
satellite.start_listening() # plays sound, then starts pipeline
|
||||
|
||||
elif command == LVACommand.STOP_PIPELINE:
|
||||
# Stops the voice pipeline at any active phase:
|
||||
# listening, thinking, speaking or wake word active.
|
||||
if satellite is not None:
|
||||
satellite.stop()
|
||||
|
||||
elif command == LVACommand.MUTE_MIC:
|
||||
if satellite is not None and not state.muted:
|
||||
satellite._set_muted(True) # pylint: disable=protected-access
|
||||
await self._push_mute_switch(satellite, muted=True)
|
||||
|
||||
elif command == LVACommand.UNMUTE_MIC:
|
||||
if satellite is not None and state.muted:
|
||||
satellite._set_muted(False) # pylint: disable=protected-access
|
||||
await self._push_mute_switch(satellite, muted=False)
|
||||
|
||||
elif command in (LVACommand.VOLUME_UP, LVACommand.VOLUME_DOWN):
|
||||
delta = self._volume_step if command == LVACommand.VOLUME_UP else -self._volume_step
|
||||
new_vol = max(0.0, min(1.0, state.volume + delta))
|
||||
vol_pct = int(round(new_vol * 100))
|
||||
|
||||
state.music_player.set_volume(vol_pct)
|
||||
state.tts_player.set_volume(vol_pct)
|
||||
|
||||
if state.media_player_entity is not None:
|
||||
state.media_player_entity.volume = new_vol
|
||||
state.media_player_entity.previous_volume = new_vol
|
||||
|
||||
# Push the new volume to HA so its media player entity updates in real time
|
||||
if satellite is not None:
|
||||
|
||||
satellite.send_messages(
|
||||
[
|
||||
MediaPlayerStateResponse(
|
||||
key=state.media_player_entity.key,
|
||||
state=state.media_player_entity.state,
|
||||
volume=new_vol,
|
||||
muted=state.media_player_entity.muted,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# persist_volume also emits VOLUME_CHANGED via models.py
|
||||
state.persist_volume(new_vol)
|
||||
|
||||
elif command == LVACommand.SET_VOLUME:
|
||||
data = msg.get("data", {})
|
||||
volume = data.get("volume")
|
||||
if not isinstance(volume, (int, float)):
|
||||
_LOGGER.warning("Peripheral: invalid volume in set_volume command: %s", volume)
|
||||
return
|
||||
|
||||
new_vol = max(0.0, min(1.0, float(volume)))
|
||||
vol_pct = int(round(new_vol * 100))
|
||||
|
||||
state.music_player.set_volume(vol_pct)
|
||||
state.tts_player.set_volume(vol_pct)
|
||||
|
||||
if state.media_player_entity is not None:
|
||||
state.media_player_entity.volume = new_vol
|
||||
state.media_player_entity.previous_volume = new_vol
|
||||
|
||||
# Push the new volume to HA so its media player entity updates in real time
|
||||
if satellite is not None:
|
||||
|
||||
satellite.send_messages(
|
||||
[
|
||||
MediaPlayerStateResponse(
|
||||
key=state.media_player_entity.key,
|
||||
state=state.media_player_entity.state,
|
||||
volume=new_vol,
|
||||
muted=state.media_player_entity.muted,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# persist_volume also emits VOLUME_CHANGED via models.py
|
||||
state.persist_volume(new_vol)
|
||||
|
||||
elif command == LVACommand.STOP_TIMER_RINGING:
|
||||
if satellite is None:
|
||||
return
|
||||
if getattr(satellite, "_timer_finished", False):
|
||||
satellite._timer_finished = False # pylint: disable=protected-access
|
||||
state.active_wake_words.discard(state.stop_word.id)
|
||||
state.tts_player.stop()
|
||||
satellite.unduck()
|
||||
await self.emit_event(LVAEvent.IDLE)
|
||||
|
||||
elif command == LVACommand.STOP_MEDIA_PLAYER:
|
||||
state.music_player.stop()
|
||||
if state.media_player_entity is not None:
|
||||
|
||||
state.media_player_entity.state = MediaPlayerState.IDLE
|
||||
if satellite is not None:
|
||||
satellite.send_messages([self._create_media_player_response(MediaPlayerState.IDLE)])
|
||||
|
||||
elif command == LVACommand.PAUSE_MEDIA_PLAYER:
|
||||
state.music_player.pause()
|
||||
if state.media_player_entity is not None:
|
||||
state.media_player_entity.state = MediaPlayerState.PAUSED
|
||||
if satellite is not None:
|
||||
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PAUSED)])
|
||||
|
||||
elif command == LVACommand.RESUME_MEDIA_PLAYER:
|
||||
state.music_player.resume()
|
||||
if state.media_player_entity is not None:
|
||||
state.media_player_entity.state = MediaPlayerState.PLAYING
|
||||
if satellite is not None:
|
||||
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PLAYING)])
|
||||
|
||||
elif command == LVACommand.BUTTON_SINGLE_PRESS:
|
||||
if state.button_event_sensor_entity is not None:
|
||||
state.button_event_sensor_entity.update_state("single_press")
|
||||
if satellite is not None:
|
||||
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
|
||||
|
||||
elif command == LVACommand.BUTTON_DOUBLE_PRESS:
|
||||
state.tts_player.play(state.button_double_press_sound)
|
||||
if state.button_event_sensor_entity is not None:
|
||||
state.button_event_sensor_entity.update_state("double_press")
|
||||
if satellite is not None:
|
||||
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
|
||||
|
||||
elif command == LVACommand.BUTTON_TRIPLE_PRESS:
|
||||
state.tts_player.play(state.button_triple_press_sound)
|
||||
if state.button_event_sensor_entity is not None:
|
||||
state.button_event_sensor_entity.update_state("triple_press")
|
||||
if satellite is not None:
|
||||
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
|
||||
|
||||
elif command == LVACommand.BUTTON_LONG_PRESS:
|
||||
state.tts_player.play(state.button_long_press_sound)
|
||||
if state.button_event_sensor_entity is not None:
|
||||
state.button_event_sensor_entity.update_state("long_press")
|
||||
if satellite is not None:
|
||||
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
|
||||
|
||||
elif command == LVACommand.REGISTER_LIGHT:
|
||||
self._register_light(msg.get("data") or {}, satellite)
|
||||
|
||||
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.
|
||||
|
||||
Idempotent on object_id: repeat registrations (e.g. after a
|
||||
peripheral reconnect) keep the existing entity and its state.
|
||||
"""
|
||||
from .models import LightRegistration # local import to avoid a cycle
|
||||
|
||||
object_id = str(data.get("object_id", "")).strip()
|
||||
if not object_id:
|
||||
_LOGGER.warning("register_light without object_id; ignoring")
|
||||
return
|
||||
|
||||
state = self._state
|
||||
if state is None:
|
||||
return
|
||||
|
||||
if any(spec.object_id == object_id for spec in state.pending_lights):
|
||||
# Same light already on file; nothing to do.
|
||||
return
|
||||
|
||||
spec = LightRegistration(
|
||||
name=str(data.get("name", "LEDs")),
|
||||
object_id=object_id,
|
||||
icon=str(data.get("icon", "mdi:led-strip-variant")),
|
||||
effects=[str(e) for e in data.get("effects", []) if e],
|
||||
supports_rgb=bool(data.get("supports_rgb", True)),
|
||||
supports_brightness=bool(data.get("supports_brightness", True)),
|
||||
)
|
||||
state.pending_lights.append(spec)
|
||||
_LOGGER.info("Light registered: %s (effects=%s)", object_id, spec.effects)
|
||||
|
||||
# If the satellite is already running, materialise the entity
|
||||
# now so future messages route correctly. HA only sees it
|
||||
# after the integration reconnects, but LVA stays consistent.
|
||||
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.
|
||||
|
||||
Idempotent: repeat registrations from a reconnecting peripheral
|
||||
are a no-op — the existing entity and its accumulated event state
|
||||
are preserved.
|
||||
|
||||
When the satellite is already running, the entity is materialised
|
||||
immediately so subsequent button_* commands route 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:
|
||||
# Already registered; keep the existing entity.
|
||||
return
|
||||
|
||||
state.pending_button = True
|
||||
_LOGGER.info("Button event sensor registered by peripheral")
|
||||
|
||||
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
|
||||
if state is None or state.mute_switch_entity is None:
|
||||
return
|
||||
|
||||
entity = state.mute_switch_entity
|
||||
entity._switch_state = muted # pylint: disable=protected-access
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import SwitchStateResponse # type: ignore[attr-defined]
|
||||
|
||||
satellite.send_messages([SwitchStateResponse(key=entity.key, state=muted)])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event emission
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def emit_event(
|
||||
self,
|
||||
event: LVAEvent,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Broadcast an event to all connected peripheral clients."""
|
||||
# Cache the last conversation text so newly-connecting clients get it in the snapshot
|
||||
if event == LVAEvent.STT_TEXT and data:
|
||||
self._last_stt_text = data.get("text")
|
||||
elif event == LVAEvent.TTS_TEXT and data:
|
||||
self._last_tts_text = data.get("text")
|
||||
# Clear both sides at the start of a new pipeline run
|
||||
elif event == LVAEvent.LISTENING:
|
||||
self._last_stt_text = None
|
||||
self._last_tts_text = None
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Track current "state" so newly-connecting clients receive it on
|
||||
# connect via _send_snapshot. Only persistent/visual states are
|
||||
# stored — transient informational events are skipped.
|
||||
# ----------------------------------------------------------------
|
||||
_STATE_EVENTS = {
|
||||
LVAEvent.WAKE_WORD_DETECTED,
|
||||
LVAEvent.LISTENING,
|
||||
LVAEvent.THINKING,
|
||||
LVAEvent.TTS_SPEAKING,
|
||||
LVAEvent.TTS_FINISHED,
|
||||
LVAEvent.IDLE,
|
||||
LVAEvent.MUTED,
|
||||
LVAEvent.TIMER_TICKING,
|
||||
LVAEvent.TIMER_RINGING,
|
||||
LVAEvent.MEDIA_PLAYER_PLAYING,
|
||||
LVAEvent.DISCONNECTED,
|
||||
LVAEvent.PIPELINE_ERROR,
|
||||
LVAEvent.VOLUME_MUTED,
|
||||
}
|
||||
if event in _STATE_EVENTS:
|
||||
self._current_state = event
|
||||
self._current_state_data = data or None
|
||||
elif event == LVAEvent.TIMER_UPDATED and self._current_state == LVAEvent.TIMER_TICKING:
|
||||
# Keep state as TIMER_TICKING but refresh the countdown data
|
||||
self._current_state_data = data or None
|
||||
|
||||
if not self._clients:
|
||||
return
|
||||
|
||||
payload: Dict[str, Any] = {"event": event.value}
|
||||
if data:
|
||||
payload["data"] = data
|
||||
|
||||
raw_msg = json.dumps(payload)
|
||||
dead: Set[Any] = set()
|
||||
|
||||
for ws in list(self._clients):
|
||||
try:
|
||||
await ws.send(raw_msg)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
dead.add(ws)
|
||||
|
||||
self._clients -= dead
|
||||
_LOGGER.debug(
|
||||
"Peripheral event %-25s → %d client(s)",
|
||||
event.value,
|
||||
len(self._clients),
|
||||
)
|
||||
|
||||
def emit_event_sync(
|
||||
self,
|
||||
event: LVAEvent,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Thread-safe fire-and-forget event emission.
|
||||
|
||||
Safe to call from mpv callbacks, the audio processing thread, or any
|
||||
non-async context while the asyncio event loop is running.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None or loop.is_closed():
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(self.emit_event(event, data), loop)
|
||||
|
||||
def _create_media_player_response(self, state: MediaPlayerState) -> MediaPlayerStateResponse:
|
||||
"""Create a MediaPlayerStateResponse with current entity state."""
|
||||
assert self._state is not None
|
||||
media_entity = self._state.media_player_entity
|
||||
assert media_entity is not None
|
||||
return MediaPlayerStateResponse(
|
||||
key=media_entity.key,
|
||||
state=state,
|
||||
volume=media_entity.volume,
|
||||
muted=media_entity.muted,
|
||||
)
|
||||
@@ -32,6 +32,9 @@ class LibMpvPlayer(AudioPlayer):
|
||||
audio_display=False,
|
||||
log_handler=self._on_mpv_log,
|
||||
loglevel="error",
|
||||
cache="yes",
|
||||
demuxer_max_bytes="32MiB",
|
||||
cache_secs="20",
|
||||
)
|
||||
|
||||
if device:
|
||||
|
||||
@@ -8,7 +8,8 @@ import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
@@ -17,12 +18,14 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
AuthenticationRequest,
|
||||
DeviceInfoRequest,
|
||||
DeviceInfoResponse,
|
||||
LightCommandRequest,
|
||||
ListEntitiesDoneResponse,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
NumberCommandRequest,
|
||||
SelectCommandRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SubscribeStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
VoiceAssistantAnnounceFinished,
|
||||
VoiceAssistantAnnounceRequest,
|
||||
@@ -48,6 +51,9 @@ from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import (
|
||||
ButtonEventSensorEntity,
|
||||
ButtonLockEntity,
|
||||
LEDLightEntity,
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
@@ -57,12 +63,15 @@ from .entity import (
|
||||
WakeWord2SensitivityNumberEntity,
|
||||
)
|
||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||
from .peripheral_api import LVAEvent
|
||||
from .util import call_all
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
||||
|
||||
_HAS_AUDIO_DATA2 = "data2" in {f.name for f in VoiceAssistantAudio.DESCRIPTOR.fields}
|
||||
|
||||
|
||||
class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
@@ -82,20 +91,23 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.supported_features = (
|
||||
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
|
||||
)
|
||||
# Channel 1 carries echo-reference audio; advertise SPEAKER so HA knows to use it for server-side AEC.
|
||||
if state.audio_input_channels >= 2:
|
||||
self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member
|
||||
|
||||
existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
|
||||
existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)]
|
||||
|
||||
if existing_media_players:
|
||||
# Keep the first instance and remove any extras.
|
||||
self.state.media_player_entity = existing_media_players[0]
|
||||
for extra in existing_media_players[1:]:
|
||||
self.state.entities.remove(extra)
|
||||
for extra_player in existing_media_players[1:]:
|
||||
self.state.entities.remove(extra_player)
|
||||
|
||||
existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
|
||||
if existing_mute_switches:
|
||||
self.state.mute_switch_entity = existing_mute_switches[0] # type: ignore
|
||||
for extra in existing_mute_switches[1:]: # type: ignore
|
||||
self.state.entities.remove(extra)
|
||||
self.state.mute_switch_entity = existing_mute_switches[0]
|
||||
for extra_mute in existing_mute_switches[1:]:
|
||||
self.state.entities.remove(extra_mute)
|
||||
|
||||
if self.state.media_player_entity is None:
|
||||
self.state.media_player_entity = MediaPlayerEntity(
|
||||
@@ -138,9 +150,9 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
existing_thinking_sound_switches = [entity for entity in self.state.entities if isinstance(entity, ThinkingSoundEntity)]
|
||||
if existing_thinking_sound_switches:
|
||||
self.state.thinking_sound_entity = existing_thinking_sound_switches[0] # type: ignore
|
||||
for extra in existing_thinking_sound_switches[1:]: # type: ignore
|
||||
self.state.entities.remove(extra)
|
||||
self.state.thinking_sound_entity = existing_thinking_sound_switches[0]
|
||||
for extra_thinking in existing_thinking_sound_switches[1:]:
|
||||
self.state.entities.remove(extra_thinking)
|
||||
|
||||
# Add/update thinking sound entity
|
||||
thinking_sound_switch = self.state.thinking_sound_entity
|
||||
@@ -158,7 +170,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
elif thinking_sound_switch not in self.state.entities:
|
||||
self.state.entities.append(thinking_sound_switch)
|
||||
|
||||
# Load thinking sound enabled state from preferences (default to False if not set or unknown)
|
||||
# Load thinking sound enabled state from preferences
|
||||
if hasattr(self.state.preferences, "thinking_sound") and self.state.preferences.thinking_sound in (0, 1):
|
||||
self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound)
|
||||
else:
|
||||
@@ -312,6 +324,22 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.mic_volume_entity.update_get_value(lambda: float(self.state.mic_volume))
|
||||
self.state.mic_volume_entity.update_set_value(lambda val: self.state.persist_mic_volume(float(val)))
|
||||
|
||||
# NOTE: ButtonEventSensorEntity is NOT created here unconditionally.
|
||||
# It is only materialised when a peripheral sends the register_button
|
||||
# command (see register_pending_button below), mirroring the same
|
||||
# opt-in pattern used by register_light for LEDLightEntity.
|
||||
|
||||
# Materialise the Light entities peripherals registered before
|
||||
# this satellite was constructed (or reattach existing ones).
|
||||
self.register_pending_lights()
|
||||
# Materialise ButtonEventSensorEntity if a peripheral already registered
|
||||
# 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 ----
|
||||
|
||||
self._is_streaming_audio = False
|
||||
@@ -325,6 +353,141 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
self._disconnect_event = asyncio.Event()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Peripheral API helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _emit(
|
||||
self,
|
||||
event: LVAEvent,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit a peripheral LED/button event.
|
||||
|
||||
Thread-safe: delegates to ``emit_event_sync`` which uses
|
||||
``run_coroutine_threadsafe`` when called from outside the event loop.
|
||||
"""
|
||||
api = self.state.peripheral_api
|
||||
if api is not None:
|
||||
api.emit_event_sync(event, data)
|
||||
|
||||
def register_pending_lights(self) -> None:
|
||||
"""Materialise LightEntities for peripheral registered lights.
|
||||
|
||||
Called from __init__ so entities exist by the time HA enumerates,
|
||||
and again from the peripheral_api dispatcher when a light arrives
|
||||
after the satellite is already running. HA only sees a late
|
||||
registration after its next reconnect, but LVA stays consistent.
|
||||
"""
|
||||
for spec in self.state.pending_lights:
|
||||
if spec.object_id in self.state.led_light_entities:
|
||||
# Already materialised. Reattach the server in case the
|
||||
# satellite has been reconstructed (HA reconnect).
|
||||
self.state.led_light_entities[spec.object_id].server = self
|
||||
if self.state.led_light_entities[spec.object_id] not in self.state.entities:
|
||||
self.state.entities.append(self.state.led_light_entities[spec.object_id])
|
||||
continue
|
||||
|
||||
object_id = spec.object_id
|
||||
entity = LEDLightEntity(
|
||||
server=self,
|
||||
key=len(self.state.entities),
|
||||
name=spec.name,
|
||||
object_id=object_id,
|
||||
icon=spec.icon,
|
||||
effects=spec.effects,
|
||||
supports_rgb=spec.supports_rgb,
|
||||
supports_brightness=spec.supports_brightness,
|
||||
on_changed=partial(self._on_led_light_changed, object_id),
|
||||
)
|
||||
self.state.entities.append(entity)
|
||||
self.state.led_light_entities[object_id] = entity
|
||||
|
||||
def register_pending_button(self) -> None:
|
||||
"""Materialise ButtonEventSensorEntity once a peripheral has registered button support.
|
||||
|
||||
Called from __init__ (handles HA reconnects where the peripheral container
|
||||
stayed connected to LVA and pending_button is already True) and from
|
||||
PeripheralAPIServer._register_button() 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.
|
||||
"""
|
||||
if not self.state.pending_button:
|
||||
return
|
||||
|
||||
if self.state.button_event_sensor_entity is not None:
|
||||
# Already materialised — reattach the server in case the satellite
|
||||
# has been reconstructed for an HA reconnect.
|
||||
self.state.button_event_sensor_entity.server = self
|
||||
if self.state.button_event_sensor_entity not in self.state.entities:
|
||||
self.state.entities.append(self.state.button_event_sensor_entity)
|
||||
return
|
||||
|
||||
entity = ButtonEventSensorEntity(
|
||||
server=self,
|
||||
key=len(self.state.entities),
|
||||
name="Button Press",
|
||||
object_id="button_press_event",
|
||||
)
|
||||
self.state.entities.append(entity)
|
||||
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.
|
||||
|
||||
The event carries object_id so a peripheral that registered more
|
||||
than one light can route it to the correct hardware.
|
||||
"""
|
||||
entity = self.state.led_light_entities.get(object_id)
|
||||
if entity is None:
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
|
||||
self.state.thinking_sound_enabled = bool(new_state)
|
||||
self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0
|
||||
@@ -336,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)
|
||||
@@ -365,6 +543,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
def _set_muted(self, new_state: bool) -> None:
|
||||
self.state.muted = bool(new_state)
|
||||
self._emit(LVAEvent.MUTED, {"muted": self.state.muted})
|
||||
|
||||
if self.state.muted:
|
||||
# voice_assistant.stop behavior
|
||||
@@ -372,47 +551,69 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._is_streaming_audio = False
|
||||
self.state.tts_player.stop()
|
||||
# Stop any ongoing voice processing
|
||||
self.state.stop_word.is_active = False # type: ignore
|
||||
self.state.stop_word.is_active = False # type: ignore[attr-defined]
|
||||
self.state.tts_player.play(self.state.mute_sound)
|
||||
else:
|
||||
# voice_assistant.start_continuous behavior
|
||||
_LOGGER.debug("Unmuting voice assistant (voice_assistant.start_continuous)")
|
||||
self.state.tts_player.play(self.state.unmute_sound)
|
||||
# Resume normal operation - wake word detection will be active again
|
||||
pass
|
||||
self._emit(LVAEvent.IDLE)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Voice pipeline event handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
self._tts_played = False
|
||||
self._continue_conversation = False
|
||||
self._pipeline_active = True
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled:
|
||||
# Play short "thinking/processing" sound if configured
|
||||
processing = getattr(self.state, "processing_sound", None)
|
||||
if processing:
|
||||
_LOGGER.debug("Playing processing sound: %s", processing)
|
||||
self.state.stop_word.is_active = True # type: ignore
|
||||
self._processing = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(self.state.processing_sound)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START:
|
||||
self._emit(LVAEvent.THINKING)
|
||||
# Play optional audible thinking sound
|
||||
if self.state.thinking_sound_enabled:
|
||||
processing = getattr(self.state, "processing_sound", None)
|
||||
if processing:
|
||||
_LOGGER.debug("Playing processing sound: %s", processing)
|
||||
self.state.stop_word.is_active = True # type: ignore[attr-defined]
|
||||
self._processing = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(self.state.processing_sound)
|
||||
|
||||
elif event_type in (
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
|
||||
):
|
||||
self._is_streaming_audio = False
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
stt_text = data.get("text", "").strip()
|
||||
if stt_text:
|
||||
self._emit(LVAEvent.STT_TEXT, {"text": stt_text})
|
||||
_LOGGER.debug("STT transcript: %s", stt_text)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
|
||||
if data.get("tts_start_streaming") == "1":
|
||||
# Start streaming early
|
||||
self.play_tts()
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
if data.get("continue_conversation") == "1":
|
||||
self._continue_conversation = True
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
tts_text = data.get("text", "").strip()
|
||||
if tts_text:
|
||||
self._emit(LVAEvent.TTS_TEXT, {"text": tts_text})
|
||||
_LOGGER.debug("TTS response text: %s", tts_text)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
self._tts_url = data.get("url")
|
||||
self.play_tts()
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
||||
self._is_streaming_audio = False
|
||||
if not self._tts_played:
|
||||
@@ -424,7 +625,12 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
self._tts_played = False
|
||||
|
||||
# TODO: handle error
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
self._emit(LVAEvent.PIPELINE_ERROR)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer event handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_timer_event(
|
||||
self,
|
||||
@@ -432,15 +638,46 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
msg: VoiceAssistantTimerEventResponse,
|
||||
) -> None:
|
||||
_LOGGER.debug("Timer event: type=%s", event_type.name)
|
||||
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
|
||||
|
||||
# Build countdown data from the protobuf message fields.
|
||||
# total_seconds: the original timer duration.
|
||||
# seconds_left: remaining seconds at the time of this event.
|
||||
timer_data = {
|
||||
"id": msg.timer_id,
|
||||
"name": msg.name,
|
||||
"total_seconds": msg.total_seconds,
|
||||
"seconds_left": msg.seconds_left,
|
||||
}
|
||||
|
||||
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED:
|
||||
self._emit(LVAEvent.TIMER_TICKING, timer_data)
|
||||
|
||||
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED:
|
||||
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:
|
||||
if not self._timer_finished:
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._timer_finished = True
|
||||
self._timer_ring_start = time.monotonic()
|
||||
self.duck()
|
||||
self._emit(LVAEvent.TIMER_RINGING, timer_data)
|
||||
self._play_timer_finished()
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
# ------------------------------------------------------------------
|
||||
# Message routing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: # noqa: C901 (acceptable complexity for a message router)
|
||||
if isinstance(msg, VoiceAssistantEventResponse):
|
||||
# Pipeline event
|
||||
data: Dict[str, str] = {}
|
||||
@@ -448,7 +685,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
data[arg.name] = arg.value
|
||||
|
||||
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
|
||||
# assist_satellite.announce HERE
|
||||
|
||||
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
||||
_LOGGER.debug("Announcing: %s", msg.text)
|
||||
|
||||
@@ -457,16 +694,18 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
urls = []
|
||||
if msg.preannounce_media_id:
|
||||
urls.append(msg.preannounce_media_id)
|
||||
|
||||
urls.append(msg.media_id)
|
||||
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._continue_conversation = msg.start_conversation
|
||||
|
||||
self.duck()
|
||||
self._emit(LVAEvent.TTS_SPEAKING)
|
||||
self.state.tts_player.play(urls, done_callback=self._tts_finished)
|
||||
|
||||
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
||||
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
||||
|
||||
elif isinstance(msg, DeviceInfoRequest):
|
||||
_LOGGER.debug("Device info request")
|
||||
|
||||
@@ -482,15 +721,30 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
model="Linux Voice Assistant",
|
||||
voice_assistant_feature_flags=self.supported_features,
|
||||
)
|
||||
elif isinstance(msg, SubscribeStatesRequest):
|
||||
# Standard ESPHome state subscription. Replay current entity state to
|
||||
# the subscribing client. (Entities answer SubscribeHomeAssistantStatesRequest;
|
||||
# initial state was previously only sent as a side effect of auth.)
|
||||
for entity in self.state.entities:
|
||||
yield from entity.handle_message(SubscribeHomeAssistantStatesRequest())
|
||||
elif isinstance(
|
||||
msg,
|
||||
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
|
||||
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest, LightCommandRequest),
|
||||
):
|
||||
for entity in self.state.entities:
|
||||
yield from entity.handle_message(msg)
|
||||
|
||||
# Emit peripheral event when background music starts playing.
|
||||
# Announcements (TTS) are explicitly excluded — those are covered
|
||||
# by TTS_SPEAKING.
|
||||
if isinstance(msg, MediaPlayerCommandRequest) and msg.has_media_url:
|
||||
is_announcement = msg.has_announcement and msg.announcement
|
||||
if not is_announcement:
|
||||
self._emit(LVAEvent.MEDIA_PLAYER_PLAYING)
|
||||
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesDoneResponse()
|
||||
|
||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||
_LOGGER.debug("✅ Received VoiceAssistantConfigurationRequest from Home Assistant")
|
||||
_LOGGER.debug(" -> Request contains %d external wake words", len(msg.external_wake_words))
|
||||
@@ -607,40 +861,24 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.save_preferences()
|
||||
self.state.wake_words_changed = True
|
||||
|
||||
def handle_audio(self, audio_chunk: bytes) -> None:
|
||||
# ------------------------------------------------------------------
|
||||
# Audio streaming
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# handle_audio — both channels in ONE message
|
||||
def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None:
|
||||
if not self._is_streaming_audio or self.state.muted:
|
||||
return
|
||||
if _HAS_AUDIO_DATA2 and audio_chunk_2 is not None:
|
||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk, data2=audio_chunk_2)])
|
||||
else:
|
||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
||||
|
||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
||||
# ------------------------------------------------------------------
|
||||
# Wake word / stop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
||||
if self._timer_finished:
|
||||
# Stop the ringing timer, then start a normal wake-up after a short
|
||||
# delay so the transition doesn't feel abrupt.
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.unduck()
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("Stopping timer finished sound; will wake up in 1 s")
|
||||
|
||||
wake_word_phrase = wake_word.wake_word # type: ignore
|
||||
|
||||
def _delayed_wakeup() -> None:
|
||||
if self.state.muted or self._pipeline_active:
|
||||
_LOGGER.debug("Delayed wakeup skipped (muted=%s, pipeline_active=%s)", self.state.muted, self._pipeline_active)
|
||||
return
|
||||
_LOGGER.debug("Delayed wakeup: playing wakeup sound for %s", wake_word_phrase)
|
||||
self._pipeline_active = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(
|
||||
self.state.wakeup_sound,
|
||||
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||
)
|
||||
|
||||
threading.Timer(0.1, _delayed_wakeup).start()
|
||||
return
|
||||
|
||||
if self.state.muted:
|
||||
# Don't respond to wake words when muted (voice_assistant.stop behavior)
|
||||
@@ -650,22 +888,78 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Ignoring wake word - pipeline already active")
|
||||
return
|
||||
|
||||
wake_word_phrase = wake_word.wake_word # type: ignore
|
||||
wake_word_phrase = wake_word.wake_word # type: ignore[union-attr]
|
||||
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
||||
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
self._pipeline_active = True
|
||||
self._emit(LVAEvent.WAKE_WORD_DETECTED)
|
||||
self.duck()
|
||||
if self.state.listen_during_wake_sound:
|
||||
_LOGGER.debug("Starting audio streaming immediately (listen_during_wake_sound enabled)")
|
||||
self.state.tts_player.play(self.state.wakeup_sound)
|
||||
self._start_audio_streaming(wake_word_phrase)
|
||||
else:
|
||||
self.state.tts_player.play(
|
||||
self.state.wakeup_sound,
|
||||
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||
)
|
||||
|
||||
def _start_audio_streaming(self, wake_word_phrase: str) -> None:
|
||||
"""Start streaming audio during wake sound detection."""
|
||||
_LOGGER.debug(
|
||||
"Starting audio streaming for: %s",
|
||||
wake_word_phrase,
|
||||
)
|
||||
self.send_messages([VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)])
|
||||
self._is_streaming_audio = True
|
||||
self._emit(LVAEvent.LISTENING)
|
||||
|
||||
def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None:
|
||||
"""Callback invoked when the wakeup chime finishes; begin STT streaming."""
|
||||
_LOGGER.debug(
|
||||
"Wakeup sound finished, starting audio streaming for: %s",
|
||||
wake_word_phrase,
|
||||
)
|
||||
self.send_messages([VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)])
|
||||
self._is_streaming_audio = True
|
||||
self._emit(LVAEvent.LISTENING)
|
||||
|
||||
def start_listening(self) -> None:
|
||||
"""
|
||||
Manually start the voice pipeline from a button press.
|
||||
|
||||
Plays ``start_listening_sound`` first, then sends
|
||||
``VoiceAssistantRequest`` and begins streaming audio — identical flow
|
||||
to ``wakeup()`` but without a wake-word phrase and using the dedicated
|
||||
button-press sound instead of the wake-word chime. Also stops ringing timer.
|
||||
"""
|
||||
if self.state.muted:
|
||||
return
|
||||
|
||||
if self._pipeline_active:
|
||||
_LOGGER.debug("Ignoring start_listening - pipeline already active")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Button start_listening triggered")
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
self._pipeline_active = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(
|
||||
self.state.wakeup_sound,
|
||||
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||
self.state.start_listening_sound,
|
||||
done_callback=self._on_start_listening_sound_finished,
|
||||
)
|
||||
|
||||
def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None:
|
||||
"""Callback invoked when the wakeup sound finishes playing."""
|
||||
_LOGGER.debug("Wakeup sound finished, starting audio streaming with wake word: %s", wake_word_phrase)
|
||||
self.send_messages(
|
||||
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)],
|
||||
)
|
||||
def _on_start_listening_sound_finished(self) -> None:
|
||||
"""Callback invoked when the start-listening chime finishes; begin STT streaming."""
|
||||
_LOGGER.debug("Start-listening sound finished, starting audio streaming")
|
||||
self.send_messages([VoiceAssistantRequest(start=True, wake_word_phrase="")])
|
||||
self._is_streaming_audio = True
|
||||
self._emit(LVAEvent.LISTENING)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
@@ -676,6 +970,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._timer_ring_start = None
|
||||
self.unduck()
|
||||
self.state.tts_player.stop()
|
||||
self._emit(LVAEvent.IDLE)
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
else:
|
||||
# tts_player.stop() invokes the done_callback (_tts_finished),
|
||||
@@ -683,6 +978,10 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("TTS response stopped manually")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TTS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def play_tts(self) -> None:
|
||||
if (not self._tts_url) or self._tts_played:
|
||||
return
|
||||
@@ -691,8 +990,45 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
|
||||
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._emit(LVAEvent.TTS_SPEAKING)
|
||||
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
|
||||
|
||||
def _tts_finished(self) -> None:
|
||||
self._pipeline_active = False
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||
self._emit(LVAEvent.TTS_FINISHED)
|
||||
|
||||
if self._continue_conversation:
|
||||
self._continue_conversation = False
|
||||
# Keep pipeline active during the settle delay so the mic stays closed
|
||||
# and does not capture the tail end of the TTS audio from the speaker.
|
||||
self._pipeline_active = True
|
||||
self._emit(LVAEvent.LISTENING)
|
||||
_LOGGER.debug("Continuing conversation after %.2fs settle delay", self.state.continue_conversation_delay)
|
||||
|
||||
def _start_continued_conversation() -> None:
|
||||
if self.state.muted:
|
||||
_LOGGER.debug("Skipping continued conversation: muted")
|
||||
self._pipeline_active = False
|
||||
self.unduck()
|
||||
return
|
||||
self.send_messages([VoiceAssistantRequest(start=True)])
|
||||
self._is_streaming_audio = True
|
||||
_LOGGER.debug("Continued conversation started")
|
||||
|
||||
threading.Timer(self.state.continue_conversation_delay, _start_continued_conversation).start()
|
||||
else:
|
||||
self._continue_conversation = False
|
||||
self.unduck()
|
||||
self._emit(LVAEvent.IDLE)
|
||||
|
||||
_LOGGER.debug("TTS response finished")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ducking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def duck(self) -> None:
|
||||
_LOGGER.debug("Ducking music")
|
||||
self.state.music_player.duck()
|
||||
@@ -701,20 +1037,9 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Unducking music")
|
||||
self.state.music_player.unduck()
|
||||
|
||||
def _tts_finished(self) -> None:
|
||||
self._pipeline_active = False
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||
|
||||
if self._continue_conversation:
|
||||
self.send_messages([VoiceAssistantRequest(start=True)])
|
||||
self._is_streaming_audio = True
|
||||
self._pipeline_active = True
|
||||
_LOGGER.debug("Continuing conversation")
|
||||
else:
|
||||
self.unduck()
|
||||
|
||||
_LOGGER.debug("TTS response finished")
|
||||
# ------------------------------------------------------------------
|
||||
# Timer finished loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _play_timer_finished(self) -> None:
|
||||
if not self._timer_finished:
|
||||
@@ -746,7 +1071,18 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
),
|
||||
)
|
||||
|
||||
def connection_lost(self, exc):
|
||||
def connection_made(self, transport) -> None:
|
||||
super().connection_made(transport)
|
||||
# Track every live connection so asynchronous entity-state changes can be
|
||||
# broadcast to all of them (see ServerState.broadcast).
|
||||
if self not in self.state.connections:
|
||||
self.state.connections.append(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||
super().connection_lost(exc)
|
||||
|
||||
self._disconnect_event.set()
|
||||
@@ -757,19 +1093,29 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._timer_finished = False
|
||||
self._pipeline_active = False
|
||||
|
||||
# Stop any ongoing audio playback and wake/stop word processing.
|
||||
try:
|
||||
self.state.music_player.stop()
|
||||
except Exception: # pragma: no cover - defensive safety net
|
||||
_LOGGER.exception("Failed to stop music player during disconnect")
|
||||
# Deregister this connection.
|
||||
if self in self.state.connections:
|
||||
self.state.connections.remove(self)
|
||||
|
||||
try:
|
||||
self.state.tts_player.stop()
|
||||
except Exception: # pragma: no cover - defensive safety net
|
||||
_LOGGER.exception("Failed to stop TTS player during disconnect")
|
||||
# Only tear down shared playback/state when the LAST client disconnects.
|
||||
# Otherwise a secondary client (a diagnostic tool, a second dashboard, or
|
||||
# Home Assistant's own overlapping reconnect) dropping would stop audio
|
||||
# that belongs to a client still connected.
|
||||
if not self.state.connections:
|
||||
# Stop any ongoing audio playback and wake/stop word processing.
|
||||
try:
|
||||
self.state.music_player.stop()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Failed to stop music player during disconnect")
|
||||
|
||||
try:
|
||||
self.state.tts_player.stop()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Failed to stop TTS player during disconnect")
|
||||
|
||||
self.state.stop_word.is_active = False # type: ignore[attr-defined]
|
||||
self.state.connected = False
|
||||
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.connected = False
|
||||
if self.state.satellite is self:
|
||||
self.state.satellite = None
|
||||
|
||||
@@ -785,6 +1131,9 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if self.state.mic_volume_entity is not None:
|
||||
self.state.mic_volume_entity.sync_with_state()
|
||||
|
||||
# Notify peripheral container that HA is no longer reachable
|
||||
self._emit(LVAEvent.DISCONNECTED)
|
||||
|
||||
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
|
||||
|
||||
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
||||
@@ -793,6 +1142,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
|
||||
self.state.connected = True
|
||||
_LOGGER.debug("Authentication successful, connected to Home Assistant")
|
||||
|
||||
# Send states after connect
|
||||
states: List[message.Message] = []
|
||||
_LOGGER.debug("Found %d entities in state", len(self.state.entities))
|
||||
@@ -800,12 +1150,20 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
entity_states = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
states.extend(entity_states)
|
||||
_LOGGER.debug("Entity %d (%s) returned %d state messages", i, type(entity).__name__, len(entity_states))
|
||||
|
||||
_LOGGER.debug("Total state messages to send: %d", len(states))
|
||||
self.send_messages(states)
|
||||
for i, msg in enumerate(states):
|
||||
_LOGGER.debug("Sent state message %d: %s", i, type(msg).__name__)
|
||||
_LOGGER.debug("All entity states sent after connect")
|
||||
|
||||
# Notify peripherals that Home Assistant is now connected
|
||||
self._emit(LVAEvent.ZEROCONF, {"status": "connected"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External wake word download
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _download_external_wake_word(self, external_wake_word: VoiceAssistantExternalWakeWord) -> Optional[AvailableWakeWord]:
|
||||
eww_dir = self.state.download_dir / "external_wake_words"
|
||||
eww_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -76,8 +76,43 @@ def find_available_wake_words(wake_word_dirs: List[Path], stop_model_id: str) ->
|
||||
return available_wake_words
|
||||
|
||||
|
||||
def _find_matching_wake_word_id(
|
||||
available_wake_words: Dict[str, AvailableWakeWord],
|
||||
requested_id: str,
|
||||
wake_word_type: Optional[WakeWordType] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Finds a wake word id matching requested_id, optionally restricted to a single type.
|
||||
|
||||
Tries, in order:
|
||||
1. An exact id match.
|
||||
2. An id that starts with "{requested_id}_", which covers openWakeWord's
|
||||
versioned filenames (e.g. "hey_jarvis" -> "hey_jarvis_v0.1").
|
||||
|
||||
Args:
|
||||
available_wake_words: Dictionary with all available wake words
|
||||
requested_id: ID (or base name) to look for
|
||||
wake_word_type: If given, only consider wake words of this type
|
||||
|
||||
Returns:
|
||||
The matching id, or None if no match was found
|
||||
"""
|
||||
prefix = f"{requested_id}_"
|
||||
for candidate_id, wake_word in available_wake_words.items():
|
||||
if wake_word_type is not None and wake_word.type != wake_word_type:
|
||||
continue
|
||||
|
||||
if candidate_id == requested_id or candidate_id.startswith(prefix):
|
||||
return candidate_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_wake_models(
|
||||
available_wake_words: Dict[str, AvailableWakeWord], active_wake_word_ids: Optional[List[str]], default_wake_word_id: str
|
||||
available_wake_words: Dict[str, AvailableWakeWord],
|
||||
active_wake_word_ids: Optional[List[str]],
|
||||
default_wake_word_id: str,
|
||||
preferred_type: Optional[WakeWordType] = None,
|
||||
) -> tuple[Dict[str, Union[MicroWakeWord, OpenWakeWord]], Set[str], bool]:
|
||||
"""
|
||||
Loads the specified wake word models.
|
||||
@@ -88,6 +123,12 @@ def load_wake_models(
|
||||
available_wake_words: Dictionary with all available wake words
|
||||
active_wake_word_ids: List of IDs of wake words to load (may be None)
|
||||
default_wake_word_id: ID of the default model which is loaded if no others are specified
|
||||
preferred_type: If given, prefer resolving default_wake_word_id (and the
|
||||
"okay_nabu" fallback) to a wake word of this type before falling back
|
||||
to a type-agnostic match. Lets the operator's --wake-word-dir choice
|
||||
(e.g. pointing at the openWakeWord subdirectory) decide which model
|
||||
variant gets activated when the requested id exists for more than
|
||||
one wake word engine.
|
||||
|
||||
Returns:
|
||||
Tuple with (Dictionary of loaded models, Set of active wake word IDs)
|
||||
@@ -120,14 +161,40 @@ def load_wake_models(
|
||||
# No models loaded, fall back to default model
|
||||
_LOGGER.debug("No wake models loaded, falling back to default model")
|
||||
wake_word_id = default_wake_word_id
|
||||
wake_word = None
|
||||
|
||||
# If the operator's --wake-word-dir choice implies a preferred engine
|
||||
# (e.g. pointing at the openWakeWord subdirectory), try to resolve the
|
||||
# requested id to a model of that type first, even if a same-named
|
||||
# model of a different type also exists.
|
||||
if preferred_type is not None:
|
||||
matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id, preferred_type)
|
||||
if matched_id is not None:
|
||||
wake_word_id = matched_id
|
||||
wake_word = available_wake_words[wake_word_id]
|
||||
_LOGGER.debug("Resolved default wake word '%s' to '%s' (preferred type %s)", default_wake_word_id, wake_word_id, preferred_type)
|
||||
|
||||
if wake_word is None:
|
||||
# Fall back to a type-agnostic match (covers exact ids and
|
||||
# versioned openWakeWord filenames like "hey_jarvis_v0.1")
|
||||
matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id)
|
||||
if matched_id is not None:
|
||||
wake_word_id = matched_id
|
||||
wake_word = available_wake_words[wake_word_id]
|
||||
|
||||
# Check if default wake word exists
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
if wake_word is None:
|
||||
_LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id)
|
||||
|
||||
# Try fallback to 'okay_nabu'
|
||||
# Try fallback to 'okay_nabu', respecting the same type preference
|
||||
wake_word_id = "okay_nabu"
|
||||
matched_id = None
|
||||
if preferred_type is not None:
|
||||
matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id, preferred_type)
|
||||
if matched_id is None:
|
||||
matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id)
|
||||
if matched_id is not None:
|
||||
wake_word_id = matched_id
|
||||
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
|
||||
if wake_word is None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
python_version >= 3.11
|
||||
warn_return_any = True
|
||||
warn_unused_configs = True
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
]
|
||||
requires-python = ">=3.11.0"
|
||||
dependencies = [
|
||||
"aioesphomeapi==42.7.0",
|
||||
"aioesphomeapi==45.3.1",
|
||||
"netifaces2==0.0.22",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
@@ -32,7 +32,8 @@ dependencies = [
|
||||
"zeroconf<1",
|
||||
"getmac<1",
|
||||
"types-protobuf",
|
||||
"webrtc_noise_gain",
|
||||
"websockets==12",
|
||||
"webrtc-noise-gain>=1,<2",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
@@ -45,6 +46,7 @@ dev = [
|
||||
"pytest",
|
||||
"isort",
|
||||
"autopep8",
|
||||
"pytest-asyncio",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -131,3 +133,7 @@ max-args = 50
|
||||
|
||||
[tool.pylint.format]
|
||||
expected-line-ending-format = "LF"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
@@ -8,8 +8,9 @@ _DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||
_TEST_DIRS = _PROGRAM_DIR / "tests"
|
||||
|
||||
_LINT_DIRS = [_MODULE_DIR]
|
||||
_LINT_DIRS = [_MODULE_DIR, _TEST_DIRS]
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
|
||||
@@ -8,8 +8,9 @@ _DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||
_TEST_DIRS = _PROGRAM_DIR / "tests"
|
||||
|
||||
_LINT_DIRS = [_MODULE_DIR]
|
||||
_LINT_DIRS = [_MODULE_DIR, _TEST_DIRS]
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
|
||||
@@ -8,8 +8,9 @@ _DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||
_TEST_DIRS = _PROGRAM_DIR / "tests"
|
||||
|
||||
_LINT_DIRS = [_MODULE_DIR]
|
||||
_LINT_DIRS = [_MODULE_DIR, _TEST_DIRS]
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
|
||||