Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51c0eb3aa | ||
|
|
8f788eeff0 | ||
|
|
1af2fa4333 | ||
|
|
305b787dd6 | ||
|
|
e14844a113 | ||
|
|
9ed97cd8f4 | ||
|
|
e8042ee19d | ||
|
|
802908cb8f | ||
|
|
3b18ce23b1 | ||
|
|
c37b482b87 | ||
|
|
486ff4bb53 | ||
|
|
a66c0e4f28 | ||
|
|
9b2a8b4b30 | ||
|
|
4a0695ab57 | ||
|
|
2c9c1d2b8a | ||
|
|
d811ae75d4 | ||
|
|
2ccb1fb597 | ||
|
|
2e6c726bd8 | ||
|
|
2d460b6728 | ||
|
|
e1770d071c | ||
|
|
49b0937a13 | ||
|
|
19c6322c28 | ||
|
|
eb2d3b21b1 | ||
|
|
479c1d1a97 | ||
|
|
a9f0af1287 | ||
|
|
fc66bfbb06 |
No files matched your search
@@ -60,6 +60,10 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
### 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"
|
||||
|
||||
### Wake word directory (optional):
|
||||
# path for custom files in docker is for example "app/wakewords/custom"
|
||||
# WAKE_WORD_DIR="app/wakewords"
|
||||
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -27,14 +27,14 @@ 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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
|
||||
|
||||
@@ -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@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Set variables
|
||||
run: |
|
||||
@@ -32,14 +33,14 @@ 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -23,19 +23,19 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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
|
||||
- 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@v4
|
||||
with:
|
||||
ref: main
|
||||
- uses: rhysd/changelog-from-release/action@v3
|
||||
|
||||
@@ -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@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
<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
|
||||
|
||||
@@ -281,6 +299,7 @@ Initial release
|
||||
[Changes][v1.0.0]
|
||||
|
||||
|
||||
[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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,9 +20,10 @@ Because it runs on a full Linux system and offers access significantly more loca
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -75,38 +76,44 @@ For all other users, we have different installation methods available (Docker, s
|
||||
```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]
|
||||
[--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 |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| 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 |
|
||||
| `--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 |
|
||||
| `--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.
|
||||
|
||||
@@ -124,6 +131,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:
|
||||
@@ -180,9 +217,8 @@ Auto-fix formatting issues (Black + isort):
|
||||
### Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```sh
|
||||
./script/test
|
||||
``` sh
|
||||
./script/tests
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -60,6 +60,10 @@ 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
|
||||
@@ -84,6 +88,10 @@ 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
|
||||
@@ -104,6 +112,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 [ -n "${DISABLE_PERIPHERAL_API}" ]; then
|
||||
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--output-only" )
|
||||
fi
|
||||
@@ -121,26 +145,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
|
||||
|
||||
@@ -179,17 +179,25 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
||||
# 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
|
||||
@@ -261,22 +269,41 @@ The following variables can be configured in the `.env` or in the service file:
|
||||
| `MIC_VOLUME` | Control microphone volume | 100 |
|
||||
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
|
||||
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
|
||||
| `AUDIO_INPUT_CHANNELS` | Number of audio input channels | 2 |
|
||||
| `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
|
||||
| `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.
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# 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,
|
||||
"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. |
|
||||
|
||||
### Timer events
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `timer_ticking` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A timer has been set and is counting down. Show a countdown animation proportional to `seconds_left / total_seconds`. |
|
||||
| `timer_updated` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A running timer was adjusted. Update your countdown display. |
|
||||
| `timer_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"}` |
|
||||
|
||||
### 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) — 12-LED SK6812 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,225 @@
|
||||
# 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).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,476 @@
|
||||
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 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 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
|
||||
|
||||
|
||||
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
|
||||
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 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,223 @@
|
||||
# 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
|
||||
|
||||
## 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,433 @@
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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.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) + '%';
|
||||
|
||||
// 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) {
|
||||
metaMuted.textContent = data.muted ? 'yes' : 'no';
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,332 @@
|
||||
# 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 |
|
||||
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
## 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,238 @@
|
||||
# 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 |
|
||||
|
||||
"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,796 @@
|
||||
#!/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
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
self.volume: float = 1.0
|
||||
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,
|
||||
"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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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"]
|
||||
t_total = snap["timer_total_seconds"]
|
||||
t_left = snap["timer_seconds_left"]
|
||||
|
||||
if 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()
|
||||
|
||||
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))
|
||||
|
||||
elif event == "volume_muted":
|
||||
self._state.update(muted=data.get("muted", False))
|
||||
|
||||
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,220 @@
|
||||
# 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 |
|
||||
|
||||
"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,876 @@
|
||||
#!/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)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
self.volume: float = 1.0
|
||||
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,
|
||||
"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 accepts LED data via a USB vendor control
|
||||
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
|
||||
# bytes in the order [start_frame | brightness, B, G, R].
|
||||
# ===========================================================================
|
||||
|
||||
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 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
||||
|
||||
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
|
||||
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
|
||||
This is the same protocol used by Seeed's pixel_ring library.
|
||||
"""
|
||||
|
||||
# USB control transfer parameters matching the pixel_ring reference implementation.
|
||||
# _CTRL_TYPE_OUT = 0x40: bmRequestType = CTRL_OUT | CTRL_TYPE_VENDOR | CTRL_RECIPIENT_DEVICE.
|
||||
# Recipient is "device" (bits 4:0 = 0x00), so no interface needs to be claimed
|
||||
# and the kernel audio driver must NOT be detached.
|
||||
_CTRL_TIMEOUT = 8000 # ms
|
||||
_CTRL_REQUEST = 0
|
||||
_CTRL_VALUE = 0x0001
|
||||
_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()
|
||||
|
||||
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
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
dev.detach_kernel_driver(0)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
self._dev = dev
|
||||
_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 show(self, brightness: float = 1.0) -> None:
|
||||
"""
|
||||
Push the current pixel buffer to the device.
|
||||
|
||||
Frame layout (matches pixel_ring / APA102 USB protocol):
|
||||
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
|
||||
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
|
||||
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
|
||||
"""
|
||||
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
|
||||
|
||||
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
|
||||
for r, g, b in self._pixels:
|
||||
br = max(0, min(255, int(r * brightness)))
|
||||
bg = max(0, min(255, int(g * brightness)))
|
||||
bb = max(0, min(255, int(b * brightness)))
|
||||
frame += [0xE0 | bright5, bb, bg, br]
|
||||
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
|
||||
|
||||
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:
|
||||
self._dev.ctrl_transfer(
|
||||
self._CTRL_TYPE_OUT,
|
||||
self._CTRL_REQUEST,
|
||||
self._CTRL_VALUE,
|
||||
self._CTRL_INDEX,
|
||||
frame,
|
||||
self._CTRL_TIMEOUT,
|
||||
)
|
||||
except usb.core.USBError as exc:
|
||||
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
|
||||
self._dev = 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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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"]
|
||||
t_total = snap["timer_total_seconds"]
|
||||
t_left = snap["timer_seconds_left"]
|
||||
|
||||
if 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()
|
||||
|
||||
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":
|
||||
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))
|
||||
|
||||
elif event == "volume_muted":
|
||||
self._state.update(muted=data.get("muted", False))
|
||||
|
||||
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()
|
||||
|
||||
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,440 @@
|
||||
# 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 12-LED SK6812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
|
||||
| LED ring | 12 × SK6812 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
|
||||
- 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. Each press increases volume by one step.
|
||||
|
||||
### Left button — Volume Down
|
||||
Sends `volume_down` to LVA. Each press decreases volume by one step.
|
||||
|
||||
### 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` |
|
||||
|
||||
#### 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) |
|
||||
|
||||
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 changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on).
|
||||
|
||||
**Example workflow:**
|
||||
1. Press and hold the action button
|
||||
2. Press volume up repeatedly to cycle through warm colors (red → orange → yellow)
|
||||
3. Release action button to confirm and exit color mode
|
||||
4. The new color persists in Home Assistant
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
**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 SK6812 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
|
||||
|
||||
# Button multipress timing
|
||||
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
|
||||
LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
|
||||
|
||||
|
||||
# LED ring
|
||||
LED_COUNT = 12
|
||||
LED_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.
|
||||
|
||||
### 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,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
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .peripheral_api import LVAEvent, PeripheralAPIServer
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
from .util import (
|
||||
get_default_interface,
|
||||
@@ -60,7 +61,6 @@ async def main() -> None:
|
||||
"--audio-input-block-size",
|
||||
type=int,
|
||||
default=1024,
|
||||
# todo
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-output-device",
|
||||
@@ -119,6 +119,11 @@ async def main() -> None:
|
||||
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"),
|
||||
@@ -139,37 +144,91 @@ 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",
|
||||
@@ -268,6 +327,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)
|
||||
|
||||
@@ -305,7 +383,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)
|
||||
@@ -328,10 +411,14 @@ async def main() -> None:
|
||||
music_player=MpvMediaPlayer(device=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,
|
||||
@@ -344,6 +431,7 @@ async def main() -> None:
|
||||
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:
|
||||
@@ -362,6 +450,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
|
||||
@@ -385,20 +489,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),
|
||||
@@ -407,18 +531,46 @@ 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")
|
||||
|
||||
@@ -628,9 +780,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,
|
||||
),
|
||||
)
|
||||
@@ -612,12 +631,180 @@ 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 "",
|
||||
)
|
||||
|
||||
|
||||
# Backward compatibility export aliases
|
||||
__all__ = [
|
||||
"ESPHomeEntity",
|
||||
"MediaPlayerEntity",
|
||||
"MuteSwitchEntity",
|
||||
"ThinkingSoundEntity",
|
||||
"LEDLightEntity",
|
||||
"ButtonEventSensorEntity",
|
||||
"WakeWord1SensitivityNumberEntity",
|
||||
"WakeWord2SensitivityNumberEntity",
|
||||
"StopWordSensitivityNumberEntity",
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
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,
|
||||
ESPHomeEntity,
|
||||
LEDLightEntity,
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
@@ -59,6 +63,23 @@ 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)
|
||||
@@ -91,10 +112,14 @@ 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
|
||||
@@ -102,8 +127,29 @@ class ServerState:
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -128,6 +174,22 @@ class ServerState:
|
||||
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."""
|
||||
@@ -161,6 +223,13 @@ class ServerState:
|
||||
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)})
|
||||
|
||||
def persist_mic_gain(self, gain: float) -> None:
|
||||
"""Persist the microphone auto gain value."""
|
||||
gain_int = int(gain)
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
"""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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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._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),
|
||||
"ha_connected": state.connected,
|
||||
"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.
|
||||
if self._current_state is not None:
|
||||
state_payload: Dict[str, Any] = {"event": self._current_state.value}
|
||||
if self._current_state_data:
|
||||
state_payload["data"] = self._current_state_data
|
||||
try:
|
||||
await websocket.send(json.dumps(state_payload))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
}
|
||||
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,8 @@ from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import (
|
||||
ButtonEventSensorEntity,
|
||||
LEDLightEntity,
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
@@ -57,6 +62,7 @@ from .entity import (
|
||||
WakeWord2SensitivityNumberEntity,
|
||||
)
|
||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||
from .peripheral_api import LVAEvent
|
||||
from .util import call_all
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -88,19 +94,19 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
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(
|
||||
@@ -143,9 +149,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
|
||||
@@ -163,7 +169,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:
|
||||
@@ -317,6 +323,19 @@ 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()
|
||||
|
||||
# ---- Instance variables ----
|
||||
|
||||
self._is_streaming_audio = False
|
||||
@@ -330,6 +349,103 @@ 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 _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())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -370,6 +486,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
|
||||
@@ -377,14 +494,17 @@ 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)
|
||||
@@ -394,30 +514,49 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
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:
|
||||
@@ -429,7 +568,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,
|
||||
@@ -437,15 +581,40 @@ 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:
|
||||
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] = {}
|
||||
@@ -453,7 +622,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)
|
||||
|
||||
@@ -462,16 +631,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")
|
||||
|
||||
@@ -487,15 +658,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))
|
||||
@@ -612,6 +798,10 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.save_preferences()
|
||||
self.state.wake_words_changed = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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:
|
||||
@@ -621,33 +811,11 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
else:
|
||||
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)
|
||||
@@ -657,22 +825,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)
|
||||
@@ -683,6 +907,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),
|
||||
@@ -690,6 +915,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
|
||||
@@ -698,26 +927,21 @@ 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 duck(self) -> None:
|
||||
_LOGGER.debug("Ducking music")
|
||||
self.state.music_player.duck()
|
||||
|
||||
def unduck(self) -> None:
|
||||
_LOGGER.debug("Unducking music")
|
||||
self.state.music_player.unduck()
|
||||
|
||||
def _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:
|
||||
@@ -734,9 +958,26 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
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()
|
||||
|
||||
def unduck(self) -> None:
|
||||
_LOGGER.debug("Unducking music")
|
||||
self.state.music_player.unduck()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer finished loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _play_timer_finished(self) -> None:
|
||||
if not self._timer_finished:
|
||||
_LOGGER.debug("Timer finished sound stopped")
|
||||
@@ -767,7 +1008,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()
|
||||
@@ -778,19 +1030,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
|
||||
|
||||
@@ -806,6 +1068,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:
|
||||
@@ -814,6 +1079,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))
|
||||
@@ -821,12 +1087,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==45.3.1",
|
||||
"aioesphomeapi==45.6.2",
|
||||
"netifaces2==0.0.22",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
@@ -32,7 +32,8 @@ dependencies = [
|
||||
"zeroconf<1",
|
||||
"getmac<1",
|
||||
"types-protobuf",
|
||||
"webrtc_noise_gain",
|
||||
"websockets==16.1.1",
|
||||
"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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import venv
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||
|
||||
_LINT_DIRS = [_MODULE_DIR]
|
||||
_TEST_DIRS = _PROGRAM_DIR / "tests"
|
||||
_TEST_RCFILE = _TEST_DIRS / ".pylintrc"
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
python_exe = context.env_exe
|
||||
python_exe = str(_VENV_DIR / "bin" / "python3")
|
||||
else:
|
||||
python_exe = "python3"
|
||||
|
||||
# Überprüfen, ob --auto angegeben wurde
|
||||
auto_fix = "--auto" in sys.argv
|
||||
|
||||
if auto_fix:
|
||||
# Automatisch Korrekturen durchführen
|
||||
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS + ["--fix"])
|
||||
else:
|
||||
# Nur prüfen, ob Code den Pylint-Standards entspricht
|
||||
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS)
|
||||
failed = False
|
||||
|
||||
try:
|
||||
subprocess.check_call([python_exe, "-m", "pylint", str(_MODULE_DIR)])
|
||||
except subprocess.CalledProcessError:
|
||||
failed = True
|
||||
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[python_exe, "-m", "pylint", str(_TEST_DIRS), "--rcfile", str(_TEST_RCFILE)]
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
failed = True
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import venv
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import ctypes.util
|
||||
from pathlib import Path
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
@@ -15,10 +17,47 @@ parser.add_argument("--cxxflags", type=str, help="CXXFLAGS for compilation (defa
|
||||
parser.add_argument("--makeflags", type=str, help="MAKEFLAGS for compilation (default: -j1)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find Python 3.11+ interpreter
|
||||
def find_python_311():
|
||||
for candidate in ["python3.13", "python3.12", "python3.11"]:
|
||||
path = shutil.which(candidate)
|
||||
if path:
|
||||
result = subprocess.run([path, "--version"], capture_output=True, text=True)
|
||||
version_str = result.stdout.strip() or result.stderr.strip()
|
||||
ver_parts = version_str.replace("Python ", "").split(".")
|
||||
if len(ver_parts) >= 2 and int(ver_parts[0]) == 3 and int(ver_parts[1]) >= 11:
|
||||
return path
|
||||
return None
|
||||
|
||||
python_311_exe = find_python_311()
|
||||
if not python_311_exe:
|
||||
print("ERROR: Python 3.11+ is required but not found on this system.")
|
||||
print(" Ubuntu/Debian: sudo apt install python3.11 python3.11-venv python3.11-dev")
|
||||
print(" macOS: brew install python@3.11")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Using Python: {python_311_exe}")
|
||||
subprocess.run([python_311_exe, "--version"])
|
||||
|
||||
# Check system dependencies (warning only, CI installs them separately)
|
||||
missing = []
|
||||
try:
|
||||
if not ctypes.util.find_library("mpv"):
|
||||
missing.append("libmpv-dev")
|
||||
if not ctypes.util.find_library("asound"):
|
||||
missing.append("libasound2-dev")
|
||||
except Exception:
|
||||
pass # Ignore errors in environments without ctypes
|
||||
|
||||
if missing:
|
||||
print(f"\nWARNING: Missing system libraries (required for audio): {', '.join(missing)}")
|
||||
print("For Docker/local dev: sudo apt install libmpv-dev pulseaudio pipewire alsa-utils")
|
||||
|
||||
# Create virtual environment
|
||||
builder = venv.EnvBuilder(with_pip=True)
|
||||
context = builder.ensure_directories(_VENV_DIR)
|
||||
builder.create(_VENV_DIR)
|
||||
if _VENV_DIR.exists():
|
||||
shutil.rmtree(_VENV_DIR)
|
||||
|
||||
subprocess.check_call([python_311_exe, "-m", "venv", str(_VENV_DIR), "--symlinks"])
|
||||
|
||||
# Set environment variables
|
||||
env = {}
|
||||
@@ -28,14 +67,13 @@ if args.makeflags:
|
||||
env["MAKEFLAGS"] = args.makeflags
|
||||
|
||||
# Upgrade dependencies
|
||||
pip = [context.env_exe, "-m", "pip"]
|
||||
pip = [str(_VENV_DIR / "bin" / "python"), "-m", "pip"]
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
|
||||
|
||||
# Install requirements
|
||||
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
|
||||
|
||||
|
||||
# Install dev requirements
|
||||
if args.dev:
|
||||
subprocess.check_call(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_TEST_DIR = _PROGRAM_DIR / "tests"
|
||||
_TESTS_DIR = _PROGRAM_DIR / "tests" / "unit"
|
||||
|
||||
_TEST_DIRS = [_TESTS_DIR]
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
@@ -15,4 +17,6 @@ if _VENV_DIR.exists():
|
||||
else:
|
||||
python_exe = "python3"
|
||||
|
||||
subprocess.check_call([python_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
|
||||
|
||||
subprocess.check_call([python_exe, "-m", "pytest"] + _TEST_DIRS)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Less Restrictions for tests
|
||||
[MESSAGES CONTROL]
|
||||
disable=
|
||||
protected-access,
|
||||
redefined-outer-name,
|
||||
no-name-in-module,
|
||||
wrong-import-order,
|
||||
wrong-import-position,
|
||||
attribute-defined-outside-init,
|
||||
no-self-argument,
|
||||
arguments-renamed,
|
||||
reimported,
|
||||
unspecified-encoding,
|
||||
use-implicit-booleaness-not-comparison,
|
||||
unused-variable,
|
||||
consider-using-from-import,
|
||||
superfluous-parens,
|
||||
use-dict-literal,
|
||||
missing-function-docstring,
|
||||
missing-class-docstring,
|
||||
import-outside-toplevel,
|
||||
line-too-long,
|
||||
unused-import,
|
||||
too-many-return-statements,
|
||||
missing-module-docstring,
|
||||
too-few-public-methods,
|
||||
too-many-instance-attributes
|
||||
@@ -1,6 +1,7 @@
|
||||
# Automated testing
|
||||
|
||||
tbd
|
||||
From the root of LVA run:-
|
||||
```./script/tests```
|
||||
|
||||
|
||||
# Manual testing
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Shared test fixtures/helpers for the unit test suite."""
|
||||
|
||||
import tempfile
|
||||
from dataclasses import MISSING, fields
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _auto_value_for(f):
|
||||
"""Best-effort placeholder for a required (no-default) dataclass field."""
|
||||
type_str = f.type if isinstance(f.type, str) else getattr(f.type, "__name__", "")
|
||||
|
||||
if type_str == "str":
|
||||
return f"/mock/{f.name}"
|
||||
if type_str == "Path":
|
||||
return Path(f"/mock/{f.name}")
|
||||
if type_str in ("float",):
|
||||
return 0.0
|
||||
if type_str in ("int",):
|
||||
return 0
|
||||
if type_str in ("bool",):
|
||||
return False
|
||||
if type_str.startswith("Set"):
|
||||
return set()
|
||||
if type_str.startswith(("List", "list")):
|
||||
return []
|
||||
if type_str.startswith(("Dict", "dict")):
|
||||
return {}
|
||||
# Fallback for anything else (players, queues, protobuf-ish types, etc.)
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def make_state(tmp_path=None, **overrides):
|
||||
"""Build a minimal ServerState, auto-filling any required fields
|
||||
that aren't explicitly provided via defaults/overrides.
|
||||
"""
|
||||
from linux_voice_assistant.models import Preferences, ServerState
|
||||
|
||||
if tmp_path is None:
|
||||
tmp_path = Path(tempfile.mkdtemp())
|
||||
|
||||
stop_word = MagicMock()
|
||||
stop_word.id = "stop"
|
||||
stop_word.is_active = False
|
||||
|
||||
values = {
|
||||
"name": "lva-test",
|
||||
"friendly_name": "LVA Test",
|
||||
"mac_address": "aa:bb:cc:dd:ee:ff",
|
||||
"ip_address": "192.168.1.1",
|
||||
"network_interface": "eth0",
|
||||
"version": "1.0.0",
|
||||
"esphome_version": "42.0.0",
|
||||
"audio_queue": Queue(),
|
||||
"entities": [],
|
||||
"available_wake_words": {},
|
||||
"wake_words": {},
|
||||
"active_wake_words": set(),
|
||||
"stop_word": stop_word,
|
||||
"music_player": MagicMock(),
|
||||
"tts_player": MagicMock(),
|
||||
"wakeup_sound": "/sounds/wake.flac",
|
||||
"start_listening_sound": "/sounds/start_listening.flac",
|
||||
"processing_sound": "/sounds/processing.wav",
|
||||
"timer_finished_sound": "/sounds/timer.flac",
|
||||
"mute_sound": "/sounds/mute.flac",
|
||||
"unmute_sound": "/sounds/unmute.flac",
|
||||
"button_double_press_sound": "/sounds/double_press.flac",
|
||||
"button_triple_press_sound": "/sounds/triple_press.flac",
|
||||
"button_long_press_sound": "/sounds/long_press.flac",
|
||||
"preferences": Preferences(),
|
||||
"preferences_path": tmp_path / "preferences.json",
|
||||
"download_dir": tmp_path / "downloads",
|
||||
"volume": 1.0,
|
||||
"mic_volume": 100,
|
||||
"mic_auto_gain": 0,
|
||||
"mic_noise_suppression": 0,
|
||||
}
|
||||
values.update(overrides)
|
||||
|
||||
# Auto-fill any other required (no-default) field we didn't anticipate.
|
||||
for f in fields(ServerState):
|
||||
if f.name in values:
|
||||
continue
|
||||
no_default = f.default is MISSING and f.default_factory is MISSING # type: ignore[misc]
|
||||
if no_default:
|
||||
values[f.name] = _auto_value_for(f)
|
||||
|
||||
return ServerState(**values)
|
||||
|
||||
|
||||
def make_satellite(tmp_path=None, state_overrides=None):
|
||||
"""Build a VoiceSatelliteProtocol with heavy dependencies mocked."""
|
||||
state = make_state(tmp_path, **(state_overrides or {}))
|
||||
|
||||
with (
|
||||
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
|
||||
):
|
||||
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
|
||||
|
||||
satellite = VoiceSatelliteProtocol(state)
|
||||
|
||||
satellite._writelines = MagicMock()
|
||||
satellite._loop = None
|
||||
return satellite
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Unit tests for APIServer packet parsing and message handling."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
AuthenticationRequest,
|
||||
AuthenticationResponse,
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
HelloResponse,
|
||||
PingRequest,
|
||||
PingResponse,
|
||||
)
|
||||
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
||||
|
||||
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_packet(msg) -> bytes:
|
||||
"""Serialize a protobuf message into a plain-text ESPHome packet."""
|
||||
msg_type = PROTO_TO_MESSAGE_TYPE[msg.__class__]
|
||||
data = msg.SerializeToString()
|
||||
packets = make_plain_text_packets([(msg_type, data)])
|
||||
# make_plain_text_packets returns a list of memoryview/bytes — join into one
|
||||
if isinstance(packets, (list, tuple)):
|
||||
return b"".join(bytes(p) for p in packets)
|
||||
return bytes(packets)
|
||||
|
||||
|
||||
class ConcreteAPIServer:
|
||||
"""Concrete subclass of APIServer for testing — records handled messages."""
|
||||
|
||||
def __init__(self):
|
||||
from linux_voice_assistant.api_server import APIServer
|
||||
|
||||
class _Concrete(APIServer):
|
||||
def __init__(self_inner):
|
||||
super().__init__("test-server")
|
||||
self_inner.handled = []
|
||||
|
||||
def handle_message(self_inner, msg):
|
||||
self_inner.handled.append(msg)
|
||||
return []
|
||||
|
||||
self._cls = _Concrete
|
||||
self.instance = _Concrete()
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
return self.instance
|
||||
|
||||
|
||||
def make_server():
|
||||
"""Return a connected APIServer instance with a mock transport."""
|
||||
wrapper = ConcreteAPIServer()
|
||||
server = wrapper.server
|
||||
|
||||
transport = MagicMock()
|
||||
written = []
|
||||
|
||||
def capture_writelines(data):
|
||||
# data may be a list of memoryview/bytes — flatten to a single bytes object
|
||||
if isinstance(data, (list, tuple)):
|
||||
written.append(b"".join(bytes(d) for d in data))
|
||||
else:
|
||||
written.append(bytes(data))
|
||||
|
||||
transport.writelines = capture_writelines
|
||||
server._transport = transport
|
||||
server._writelines = capture_writelines
|
||||
server._written = written
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def get_sent_messages(server):
|
||||
"""Decode all messages the server sent back via writelines."""
|
||||
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
||||
|
||||
messages = []
|
||||
for raw in server._written:
|
||||
# raw is bytes — parse the plain-text framing manually
|
||||
pos = 0
|
||||
while pos < len(raw):
|
||||
# preamble
|
||||
if (raw[pos] if isinstance(raw[pos], int) else raw[pos][0]) != 0x00:
|
||||
break
|
||||
pos += 1
|
||||
|
||||
# length varuint
|
||||
length = 0
|
||||
bitpos = 0
|
||||
while True:
|
||||
b = raw[pos]
|
||||
pos += 1
|
||||
length |= (b & 0x7F) << bitpos
|
||||
if not (b & 0x80):
|
||||
break
|
||||
bitpos += 7
|
||||
|
||||
# msg_type varuint
|
||||
msg_type = 0
|
||||
bitpos = 0
|
||||
while True:
|
||||
b = raw[pos]
|
||||
pos += 1
|
||||
msg_type |= (b & 0x7F) << bitpos
|
||||
if not (b & 0x80):
|
||||
break
|
||||
bitpos += 7
|
||||
|
||||
# payload
|
||||
payload = raw[pos : pos + length]
|
||||
pos += length
|
||||
|
||||
msg_cls = MESSAGE_TYPE_TO_PROTO[msg_type]
|
||||
messages.append(msg_cls.FromString(payload))
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connection_made / connection_lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def test_connection_made_stores_transport(self):
|
||||
from linux_voice_assistant.api_server import APIServer
|
||||
|
||||
class _Concrete(APIServer):
|
||||
def __init__(self):
|
||||
super().__init__("test")
|
||||
|
||||
def handle_message(self, msg):
|
||||
return []
|
||||
|
||||
server = _Concrete()
|
||||
transport = MagicMock()
|
||||
transport.writelines = MagicMock()
|
||||
|
||||
loop = MagicMock()
|
||||
with patch("linux_voice_assistant.api_server.asyncio.get_running_loop", return_value=loop):
|
||||
server.connection_made(transport)
|
||||
|
||||
assert server._transport is transport
|
||||
|
||||
def test_connection_lost_clears_transport(self):
|
||||
server = make_server()
|
||||
server.connection_lost(None)
|
||||
assert server._transport is None
|
||||
assert server._writelines is None
|
||||
|
||||
def test_connection_lost_clears_loop(self):
|
||||
server = make_server()
|
||||
server._loop = MagicMock()
|
||||
server.connection_lost(None)
|
||||
assert server._loop is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HelloRequest → HelloResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelloHandshake:
|
||||
def test_hello_request_yields_hello_response(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(HelloRequest(client_info="test")))
|
||||
msgs = get_sent_messages(server)
|
||||
assert any(isinstance(m, HelloResponse) for m in msgs)
|
||||
|
||||
def test_hello_response_contains_server_name(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(HelloRequest(client_info="test")))
|
||||
msgs = get_sent_messages(server)
|
||||
hello = next(m for m in msgs if isinstance(m, HelloResponse))
|
||||
assert hello.name == "test-server"
|
||||
|
||||
def test_hello_response_has_api_version(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(HelloRequest(client_info="test")))
|
||||
msgs = get_sent_messages(server)
|
||||
hello = next(m for m in msgs if isinstance(m, HelloResponse))
|
||||
assert hello.api_version_major == 1
|
||||
assert hello.api_version_minor == 10
|
||||
|
||||
def test_hello_does_not_call_handle_message(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(HelloRequest(client_info="test")))
|
||||
assert server.handled == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PingRequest → PingResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPing:
|
||||
def test_ping_request_yields_ping_response(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(PingRequest()))
|
||||
msgs = get_sent_messages(server)
|
||||
assert any(isinstance(m, PingResponse) for m in msgs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DisconnectRequest → DisconnectResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
def test_disconnect_request_yields_disconnect_response(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(DisconnectRequest()))
|
||||
msgs = get_sent_messages(server)
|
||||
assert any(isinstance(m, DisconnectResponse) for m in msgs)
|
||||
|
||||
def test_disconnect_closes_transport(self):
|
||||
server = make_server()
|
||||
mock_transport = MagicMock()
|
||||
server._transport = mock_transport
|
||||
server.data_received(make_packet(DisconnectRequest()))
|
||||
mock_transport.close.assert_called_once()
|
||||
|
||||
def test_disconnect_clears_transport_reference(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(DisconnectRequest()))
|
||||
assert server._transport is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthenticationRequest → AuthenticationResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
def test_auth_request_yields_auth_response(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(AuthenticationRequest()))
|
||||
msgs = get_sent_messages(server)
|
||||
assert any(isinstance(m, AuthenticationResponse) for m in msgs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Buffer management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBufferManagement:
|
||||
def test_buffer_is_none_after_complete_packet(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(PingRequest()))
|
||||
assert server._buffer is None
|
||||
|
||||
def test_partial_packet_stays_in_buffer(self):
|
||||
server = make_server()
|
||||
full = make_packet(PingRequest())
|
||||
# Send only half the packet
|
||||
server.data_received(full[: len(full) // 2])
|
||||
assert server._buffer is not None
|
||||
|
||||
def test_split_packet_reassembled_correctly(self):
|
||||
server = make_server()
|
||||
full = make_packet(PingRequest())
|
||||
half = len(full) // 2
|
||||
server.data_received(full[:half])
|
||||
server.data_received(full[half:])
|
||||
msgs = get_sent_messages(server)
|
||||
assert any(isinstance(m, PingResponse) for m in msgs)
|
||||
|
||||
def test_two_packets_in_one_data_received(self):
|
||||
server = make_server()
|
||||
data = make_packet(PingRequest()) + make_packet(PingRequest())
|
||||
server.data_received(data)
|
||||
msgs = get_sent_messages(server)
|
||||
assert sum(1 for m in msgs if isinstance(m, PingResponse)) == 2
|
||||
|
||||
def test_buffer_len_tracks_correctly(self):
|
||||
server = make_server()
|
||||
full = make_packet(PingRequest())
|
||||
server.data_received(full[:2])
|
||||
assert server._buffer_len == 2
|
||||
|
||||
def test_buffer_cleared_after_full_packet(self):
|
||||
server = make_server()
|
||||
server.data_received(make_packet(PingRequest()))
|
||||
assert server._buffer_len == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_varuint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadVarint:
|
||||
def _make_server_with_buffer(self, data: bytes):
|
||||
server = make_server()
|
||||
server._buffer = data
|
||||
server._buffer_len = len(data)
|
||||
server._pos = 0
|
||||
return server
|
||||
|
||||
def test_reads_single_byte_varuint(self):
|
||||
server = self._make_server_with_buffer(bytes([0x05]))
|
||||
assert server._read_varuint() == 5
|
||||
|
||||
def test_reads_two_byte_varuint(self):
|
||||
# 300 encoded as varuint = 0xAC 0x02
|
||||
server = self._make_server_with_buffer(bytes([0xAC, 0x02]))
|
||||
assert server._read_varuint() == 300
|
||||
|
||||
def test_returns_minus_one_on_empty_buffer(self):
|
||||
server = make_server()
|
||||
server._buffer = None
|
||||
assert server._read_varuint() == -1
|
||||
|
||||
def test_returns_zero_for_zero_byte(self):
|
||||
server = self._make_server_with_buffer(bytes([0x00]))
|
||||
assert server._read_varuint() == 0
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Unit tests for ESPHome entity classes."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
ListEntitiesNumberResponse,
|
||||
ListEntitiesRequest,
|
||||
ListEntitiesSelectResponse,
|
||||
ListEntitiesSwitchResponse,
|
||||
MediaPlayerCommandRequest,
|
||||
MediaPlayerStateResponse,
|
||||
NumberCommandRequest,
|
||||
NumberStateResponse,
|
||||
SelectCommandRequest,
|
||||
SelectStateResponse,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
SwitchStateResponse,
|
||||
)
|
||||
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_server():
|
||||
"""Minimal mock APIServer."""
|
||||
server = MagicMock()
|
||||
server.state = MagicMock()
|
||||
return server
|
||||
|
||||
|
||||
def make_media_player(server=None, key=1, initial_volume=1.0):
|
||||
from linux_voice_assistant.entity import MediaPlayerEntity
|
||||
|
||||
server = server or make_server()
|
||||
return MediaPlayerEntity(
|
||||
server=server,
|
||||
key=key,
|
||||
name="Media Player",
|
||||
object_id="test_media_player",
|
||||
music_player=MagicMock(),
|
||||
announce_player=MagicMock(),
|
||||
initial_volume=initial_volume,
|
||||
)
|
||||
|
||||
|
||||
def make_mute_switch(server=None, key=2, initial_muted=False):
|
||||
from linux_voice_assistant.entity import MuteSwitchEntity
|
||||
|
||||
server = server or make_server()
|
||||
get_muted = MagicMock(return_value=initial_muted)
|
||||
set_muted = MagicMock()
|
||||
entity = MuteSwitchEntity(
|
||||
server=server,
|
||||
key=key,
|
||||
name="Mute",
|
||||
object_id="mute",
|
||||
get_muted=get_muted,
|
||||
set_muted=set_muted,
|
||||
)
|
||||
entity._get_muted = get_muted
|
||||
entity._set_muted = set_muted
|
||||
return entity
|
||||
|
||||
|
||||
def make_mic_setting(server=None, key=3, options=None, value=0.0):
|
||||
from linux_voice_assistant.entity import MicSettingEntity
|
||||
|
||||
server = server or make_server()
|
||||
get_value = MagicMock(return_value=value)
|
||||
set_value = MagicMock()
|
||||
entity = MicSettingEntity(
|
||||
server=server,
|
||||
key=key,
|
||||
name="Mic Gain",
|
||||
object_id="mic_gain",
|
||||
get_value=get_value,
|
||||
set_value=set_value,
|
||||
min_value=0.0,
|
||||
max_value=31.0,
|
||||
options=options,
|
||||
)
|
||||
entity._get_value_mock = get_value
|
||||
entity._set_value_mock = set_value
|
||||
return entity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MediaPlayerEntity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMediaPlayerEntityInit:
|
||||
def test_initial_volume_clamped_above_one(self):
|
||||
entity = make_media_player(initial_volume=1.5)
|
||||
assert entity.volume == 1.0
|
||||
|
||||
def test_initial_volume_clamped_below_zero(self):
|
||||
entity = make_media_player(initial_volume=-0.5)
|
||||
assert entity.volume == 0.0
|
||||
|
||||
def test_initial_volume_stored(self):
|
||||
entity = make_media_player(initial_volume=0.7)
|
||||
assert abs(entity.volume - 0.7) < 0.001
|
||||
|
||||
def test_initial_state_is_idle(self):
|
||||
entity = make_media_player()
|
||||
assert entity.state == MediaPlayerState.IDLE
|
||||
|
||||
def test_not_muted_by_default(self):
|
||||
entity = make_media_player()
|
||||
assert entity.muted is False
|
||||
|
||||
|
||||
class TestMediaPlayerEntityListEntities:
|
||||
def test_list_entities_request_yields_response(self):
|
||||
entity = make_media_player(key=5)
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert len(msgs) == 1
|
||||
assert isinstance(msgs[0], ListEntitiesMediaPlayerResponse)
|
||||
|
||||
def test_list_entities_response_has_correct_key(self):
|
||||
entity = make_media_player(key=5)
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert msgs[0].key == 5
|
||||
|
||||
def test_list_entities_response_has_correct_object_id(self):
|
||||
entity = make_media_player()
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert msgs[0].object_id == "test_media_player"
|
||||
|
||||
def test_list_entities_supports_pause(self):
|
||||
entity = make_media_player()
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert msgs[0].supports_pause is True
|
||||
|
||||
|
||||
class TestMediaPlayerEntitySubscribeStates:
|
||||
def test_subscribe_states_yields_state_response(self):
|
||||
entity = make_media_player()
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
assert len(msgs) == 1
|
||||
assert isinstance(msgs[0], MediaPlayerStateResponse)
|
||||
|
||||
def test_subscribe_states_has_correct_key(self):
|
||||
entity = make_media_player(key=7)
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
assert msgs[0].key == 7
|
||||
|
||||
|
||||
class TestMediaPlayerEntityVolume:
|
||||
def test_apply_volume_sets_both_players(self):
|
||||
entity = make_media_player(initial_volume=1.0)
|
||||
entity._apply_volume(0.5, persist=False)
|
||||
entity.music_player.set_volume.assert_called_with(50)
|
||||
entity.announce_player.set_volume.assert_called_with(50)
|
||||
|
||||
def test_apply_volume_clamps_above_one(self):
|
||||
entity = make_media_player()
|
||||
entity._apply_volume(1.5, persist=False)
|
||||
assert entity.volume == 1.0
|
||||
|
||||
def test_apply_volume_clamps_below_zero(self):
|
||||
entity = make_media_player()
|
||||
entity._apply_volume(-0.1, persist=False)
|
||||
assert entity.volume == 0.0
|
||||
|
||||
def test_apply_volume_stores_previous_volume(self):
|
||||
entity = make_media_player(initial_volume=0.8)
|
||||
entity._apply_volume(0.4, persist=False, remember=True)
|
||||
assert abs(entity.previous_volume - 0.4) < 0.001
|
||||
|
||||
def test_apply_volume_persist_calls_callback(self):
|
||||
callback = MagicMock()
|
||||
entity = make_media_player()
|
||||
entity._on_volume_changed = callback
|
||||
entity._apply_volume(0.5, persist=True)
|
||||
callback.assert_called_once_with(0.5)
|
||||
|
||||
def test_apply_volume_no_persist_skips_callback(self):
|
||||
callback = MagicMock()
|
||||
entity = make_media_player()
|
||||
entity._on_volume_changed = callback
|
||||
entity._apply_volume(0.5, persist=False)
|
||||
callback.assert_not_called()
|
||||
|
||||
def test_volume_command_yields_state_response(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_volume=True, volume=0.6)
|
||||
msgs = list(entity.handle_message(msg))
|
||||
assert any(isinstance(m, MediaPlayerStateResponse) for m in msgs)
|
||||
|
||||
def test_volume_command_wrong_key_ignored(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=99, has_volume=True, volume=0.6)
|
||||
msgs = list(entity.handle_message(msg))
|
||||
assert msgs == [] or not any(isinstance(m, MediaPlayerStateResponse) for m in msgs)
|
||||
|
||||
|
||||
class TestMediaPlayerEntityMuteUnmute:
|
||||
def test_mute_sets_volume_to_zero(self):
|
||||
entity = make_media_player(initial_volume=0.8)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
|
||||
list(entity.handle_message(msg))
|
||||
assert entity.volume == 0.0
|
||||
|
||||
def test_mute_saves_previous_volume(self):
|
||||
entity = make_media_player(initial_volume=0.8)
|
||||
entity.volume = 0.8
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
|
||||
list(entity.handle_message(msg))
|
||||
assert abs(entity.previous_volume - 0.8) < 0.001
|
||||
|
||||
def test_mute_sets_muted_flag(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
|
||||
list(entity.handle_message(msg))
|
||||
assert entity.muted is True
|
||||
|
||||
def test_unmute_restores_previous_volume(self):
|
||||
entity = make_media_player(key=1, initial_volume=0.8)
|
||||
entity.previous_volume = 0.8
|
||||
entity.muted = True
|
||||
entity.volume = 0.0
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.UNMUTE)
|
||||
list(entity.handle_message(msg))
|
||||
assert abs(entity.volume - 0.8) < 0.001
|
||||
|
||||
def test_unmute_clears_muted_flag(self):
|
||||
entity = make_media_player(key=1)
|
||||
entity.muted = True
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.UNMUTE)
|
||||
list(entity.handle_message(msg))
|
||||
assert entity.muted is False
|
||||
|
||||
def test_double_mute_does_not_overwrite_previous_volume(self):
|
||||
entity = make_media_player(key=1, initial_volume=0.9)
|
||||
entity.volume = 0.9
|
||||
entity.previous_volume = 0.9
|
||||
|
||||
msg_mute = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.MUTE)
|
||||
list(entity.handle_message(msg_mute))
|
||||
# Mute again — should not overwrite previous_volume with 0
|
||||
list(entity.handle_message(msg_mute))
|
||||
assert abs(entity.previous_volume - 0.9) < 0.001
|
||||
|
||||
|
||||
class TestMediaPlayerEntityPlayback:
|
||||
def test_pause_command_calls_music_player_pause(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.PAUSE)
|
||||
list(entity.handle_message(msg))
|
||||
entity.music_player.pause.assert_called_once()
|
||||
|
||||
def test_stop_command_calls_music_player_stop(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.STOP)
|
||||
list(entity.handle_message(msg))
|
||||
entity.music_player.stop.assert_called_once()
|
||||
|
||||
def test_play_command_calls_music_player_resume(self):
|
||||
entity = make_media_player(key=1)
|
||||
msg = MediaPlayerCommandRequest(key=1, has_command=True, command=MediaPlayerCommand.PLAY)
|
||||
list(entity.handle_message(msg))
|
||||
entity.music_player.resume.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MuteSwitchEntity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMuteSwitchEntity:
|
||||
def test_initial_state_synced_from_get_muted(self):
|
||||
entity = make_mute_switch(initial_muted=True)
|
||||
assert entity._switch_state is True
|
||||
|
||||
def test_switch_command_calls_set_muted(self):
|
||||
entity = make_mute_switch(key=2)
|
||||
msg = SwitchCommandRequest(key=2, state=True)
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_muted.assert_called_once_with(True)
|
||||
|
||||
def test_switch_command_updates_internal_state(self):
|
||||
entity = make_mute_switch(key=2)
|
||||
msg = SwitchCommandRequest(key=2, state=True)
|
||||
list(entity.handle_message(msg))
|
||||
assert entity._switch_state is True
|
||||
|
||||
def test_switch_command_yields_switch_state_response(self):
|
||||
entity = make_mute_switch(key=2)
|
||||
msg = SwitchCommandRequest(key=2, state=True)
|
||||
msgs = list(entity.handle_message(msg))
|
||||
assert any(isinstance(m, SwitchStateResponse) for m in msgs)
|
||||
|
||||
def test_switch_command_wrong_key_ignored(self):
|
||||
entity = make_mute_switch(key=2)
|
||||
msg = SwitchCommandRequest(key=99, state=True)
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_muted.assert_not_called()
|
||||
|
||||
def test_list_entities_request_yields_switch_response(self):
|
||||
entity = make_mute_switch()
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert any(isinstance(m, ListEntitiesSwitchResponse) for m in msgs)
|
||||
|
||||
def test_subscribe_states_yields_switch_state_response(self):
|
||||
entity = make_mute_switch()
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
assert any(isinstance(m, SwitchStateResponse) for m in msgs)
|
||||
|
||||
def test_sync_with_state_updates_switch_state(self):
|
||||
entity = make_mute_switch(initial_muted=False)
|
||||
entity._get_muted.return_value = True
|
||||
entity.sync_with_state()
|
||||
assert entity._switch_state is True
|
||||
|
||||
def test_update_set_muted_replaces_callback(self):
|
||||
entity = make_mute_switch(key=2)
|
||||
new_set_muted = MagicMock()
|
||||
entity.update_set_muted(new_set_muted)
|
||||
msg = SwitchCommandRequest(key=2, state=True)
|
||||
list(entity.handle_message(msg))
|
||||
new_set_muted.assert_called_once_with(True)
|
||||
|
||||
def test_update_get_muted_replaces_callback(self):
|
||||
entity = make_mute_switch()
|
||||
new_get_muted = MagicMock(return_value=True)
|
||||
entity.update_get_muted(new_get_muted)
|
||||
entity.sync_with_state()
|
||||
assert entity._switch_state is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MicSettingEntity — number mode (no options)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMicSettingEntityNumber:
|
||||
def test_list_entities_yields_number_response(self):
|
||||
entity = make_mic_setting(key=3, options=None)
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert any(isinstance(m, ListEntitiesNumberResponse) for m in msgs)
|
||||
|
||||
def test_number_command_calls_set_value(self):
|
||||
entity = make_mic_setting(key=3, options=None)
|
||||
msg = NumberCommandRequest(key=3, state=5.0)
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_value_mock.assert_called_once_with(5.0)
|
||||
|
||||
def test_number_command_yields_number_state_response(self):
|
||||
entity = make_mic_setting(key=3, options=None)
|
||||
msg = NumberCommandRequest(key=3, state=5.0)
|
||||
msgs = list(entity.handle_message(msg))
|
||||
assert any(isinstance(m, NumberStateResponse) for m in msgs)
|
||||
|
||||
def test_number_command_wrong_key_ignored(self):
|
||||
entity = make_mic_setting(key=3, options=None)
|
||||
msg = NumberCommandRequest(key=99, state=5.0)
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_value_mock.assert_not_called()
|
||||
|
||||
def test_subscribe_states_yields_number_state_response(self):
|
||||
entity = make_mic_setting(key=3, options=None, value=2.0)
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
assert any(isinstance(m, NumberStateResponse) for m in msgs)
|
||||
|
||||
def test_sync_with_state_updates_internal_state(self):
|
||||
entity = make_mic_setting(key=3, options=None, value=0.0)
|
||||
entity._get_value_mock.return_value = 7.0
|
||||
entity.sync_with_state()
|
||||
assert entity._state == 7.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MicSettingEntity — select mode (with options)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMicSettingEntitySelect:
|
||||
NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"]
|
||||
|
||||
def test_list_entities_yields_select_response(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
assert any(isinstance(m, ListEntitiesSelectResponse) for m in msgs)
|
||||
|
||||
def test_select_response_has_correct_options(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
|
||||
msgs = list(entity.handle_message(ListEntitiesRequest()))
|
||||
select_msg = next(m for m in msgs if isinstance(m, ListEntitiesSelectResponse))
|
||||
assert list(select_msg.options) == self.NOISE_OPTIONS
|
||||
|
||||
def test_select_command_calls_set_value(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
|
||||
msg = SelectCommandRequest(key=4, state="High")
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_value_mock.assert_called_once_with("High")
|
||||
|
||||
def test_select_command_yields_select_state_response(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
|
||||
msg = SelectCommandRequest(key=4, state="Medium")
|
||||
msgs = list(entity.handle_message(msg))
|
||||
assert any(isinstance(m, SelectStateResponse) for m in msgs)
|
||||
|
||||
def test_select_command_wrong_key_ignored(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS)
|
||||
msg = SelectCommandRequest(key=99, state="Low")
|
||||
list(entity.handle_message(msg))
|
||||
entity._set_value_mock.assert_not_called()
|
||||
|
||||
def test_subscribe_states_yields_select_state_response(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS, value="Off")
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
assert any(isinstance(m, SelectStateResponse) for m in msgs)
|
||||
|
||||
def test_subscribe_states_response_has_string_state(self):
|
||||
entity = make_mic_setting(key=4, options=self.NOISE_OPTIONS, value="Low")
|
||||
msgs = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||
select_msg = next(m for m in msgs if isinstance(m, SelectStateResponse))
|
||||
assert isinstance(select_msg.state, str)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Unit tests for __main__.py — process_audio logic and argument parsing helpers."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.unit.conftest import make_state
|
||||
|
||||
|
||||
def make_audio_chunk(samples: int = 1024, value: float = 0.0) -> np.ndarray:
|
||||
return np.full(samples, value, dtype=np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_audio — mic volume scaling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessAudioMicVolume:
|
||||
def test_mic_volume_scalar_at_100(self, tmp_path):
|
||||
"""mic_volume=100 → scalar=1.0 → no attenuation."""
|
||||
state = make_state(tmp_path)
|
||||
state.mic_volume = 100
|
||||
state.satellite = None # no satellite → loop exits immediately
|
||||
|
||||
# The scalar clamp: max(0.1, min(1.0, 100/100)) = 1.0
|
||||
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
assert scalar == pytest.approx(1.0)
|
||||
|
||||
def test_mic_volume_scalar_at_50(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.mic_volume = 50
|
||||
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
assert scalar == pytest.approx(0.5)
|
||||
|
||||
def test_mic_volume_scalar_clamped_above_100(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.mic_volume = 200
|
||||
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
assert scalar == pytest.approx(1.0)
|
||||
|
||||
def test_mic_volume_scalar_minimum_is_0_1(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.mic_volume = 0
|
||||
scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
assert scalar == pytest.approx(0.1)
|
||||
|
||||
def test_audio_chunk_scaled_by_mic_volume(self):
|
||||
"""Verify the numpy scaling produces correct output."""
|
||||
audio = make_audio_chunk(value=0.5)
|
||||
mic_vol_scalar = 0.5
|
||||
result = np.clip(audio * mic_vol_scalar, -1.0, 1.0)
|
||||
assert np.allclose(result, 0.25)
|
||||
|
||||
def test_audio_chunk_clipped_to_bounds(self):
|
||||
"""Values that exceed [-1, 1] after scaling should be clipped."""
|
||||
audio = make_audio_chunk(value=1.0)
|
||||
mic_vol_scalar = 2.0 # would push to 2.0 without clip
|
||||
result = np.clip(audio * mic_vol_scalar, -1.0, 1.0)
|
||||
assert np.all(result <= 1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_audio — WebRTC integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessAudioWebRTC:
|
||||
def test_webrtc_not_created_when_agc_and_ns_zero(self, tmp_path):
|
||||
"""If both AGC and NS are 0, WebRTCProcessor should never be instantiated."""
|
||||
state = make_state(tmp_path)
|
||||
state.preferences.mic_auto_gain = 0
|
||||
state.preferences.mic_noise_suppression = 0
|
||||
|
||||
agc = state.preferences.mic_auto_gain or 0
|
||||
ns = state.preferences.mic_noise_suppression or 0
|
||||
should_create = agc > 0 or ns > 0
|
||||
assert should_create is False
|
||||
|
||||
def test_webrtc_created_when_agc_nonzero(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.preferences.mic_auto_gain = 5
|
||||
state.preferences.mic_noise_suppression = 0
|
||||
|
||||
agc = state.preferences.mic_auto_gain or 0
|
||||
ns = state.preferences.mic_noise_suppression or 0
|
||||
should_create = agc > 0 or ns > 0
|
||||
assert should_create is True
|
||||
|
||||
def test_webrtc_created_when_ns_nonzero(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.preferences.mic_auto_gain = 0
|
||||
state.preferences.mic_noise_suppression = 2
|
||||
|
||||
agc = state.preferences.mic_auto_gain or 0
|
||||
ns = state.preferences.mic_noise_suppression or 0
|
||||
should_create = agc > 0 or ns > 0
|
||||
assert should_create is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_audio — wake word refractory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWakeWordRefractory:
|
||||
def test_refractory_allows_activation_when_none(self):
|
||||
"""First activation (last_active=None) should always be allowed."""
|
||||
import time
|
||||
|
||||
last_active = None
|
||||
refractory_seconds = 2.0
|
||||
now = time.monotonic()
|
||||
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
|
||||
assert allowed is True
|
||||
|
||||
def test_refractory_blocks_activation_too_soon(self):
|
||||
"""Activation within refractory period should be blocked."""
|
||||
import time
|
||||
|
||||
last_active = time.monotonic() # just activated
|
||||
refractory_seconds = 2.0
|
||||
now = time.monotonic()
|
||||
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
|
||||
assert allowed is False
|
||||
|
||||
def test_refractory_allows_activation_after_period(self):
|
||||
"""Activation after refractory period should be allowed."""
|
||||
import time
|
||||
|
||||
last_active = time.monotonic() - 3.0 # 3 seconds ago
|
||||
refractory_seconds = 2.0
|
||||
now = time.monotonic()
|
||||
allowed = (last_active is None) or ((now - last_active) > refractory_seconds)
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_audio — stop word detection logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopWordLogic:
|
||||
def test_stop_word_only_triggers_when_in_active_set(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
# Stop word not in active set → should not trigger stop
|
||||
state.active_wake_words = set()
|
||||
stopped = True # simulating detection
|
||||
|
||||
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
|
||||
assert should_stop is False
|
||||
|
||||
def test_stop_word_triggers_when_in_active_set_and_not_muted(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.active_wake_words = {state.stop_word.id}
|
||||
state.muted = False
|
||||
stopped = True
|
||||
|
||||
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
|
||||
assert should_stop is True
|
||||
|
||||
def test_stop_word_does_not_trigger_when_muted(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.active_wake_words = {state.stop_word.id}
|
||||
state.muted = True
|
||||
stopped = True
|
||||
|
||||
should_stop = stopped and (state.stop_word.id in state.active_wake_words) and not state.muted
|
||||
assert should_stop is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences loading from args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreferencesFromArgs:
|
||||
def test_agc_stored_in_preferences_when_nonzero(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences()
|
||||
mic_auto_gain = 5
|
||||
if mic_auto_gain > 0:
|
||||
prefs.mic_auto_gain = mic_auto_gain
|
||||
assert prefs.mic_auto_gain == 5
|
||||
|
||||
def test_agc_not_stored_when_zero(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences()
|
||||
mic_auto_gain = 0
|
||||
if mic_auto_gain > 0:
|
||||
prefs.mic_auto_gain = mic_auto_gain
|
||||
assert prefs.mic_auto_gain == 0
|
||||
|
||||
def test_ns_stored_in_preferences_when_nonzero(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences()
|
||||
mic_noise_suppression = 2
|
||||
if mic_noise_suppression > 0:
|
||||
prefs.mic_noise_suppression = mic_noise_suppression
|
||||
assert prefs.mic_noise_suppression == 2
|
||||
|
||||
def test_volume_clamped_to_one_when_above(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=1.5)
|
||||
initial_volume = prefs.volume if prefs.volume is not None else 1.0
|
||||
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||
assert initial_volume == pytest.approx(1.0)
|
||||
|
||||
def test_volume_clamped_to_zero_when_below(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=-0.5)
|
||||
initial_volume = prefs.volume if prefs.volume is not None else 1.0
|
||||
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||
assert initial_volume == pytest.approx(0.0)
|
||||
|
||||
def test_volume_defaults_to_one_when_none(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=None)
|
||||
initial_volume = prefs.volume if prefs.volume is not None else 1.0
|
||||
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||
assert initial_volume == pytest.approx(1.0)
|
||||
|
||||
def test_thinking_sound_enabled_by_flag(self):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences()
|
||||
enable_thinking_sound = True
|
||||
if enable_thinking_sound:
|
||||
prefs.thinking_sound = 1
|
||||
assert prefs.thinking_sound == 1
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for shared models."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.unit.conftest import make_state as make_server_state
|
||||
|
||||
|
||||
def make_preferences(**kwargs):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
return Preferences(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WakeWordType
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWakeWordType:
|
||||
def test_micro_value(self):
|
||||
from linux_voice_assistant.models import WakeWordType
|
||||
|
||||
assert WakeWordType.MICRO_WAKE_WORD == "micro"
|
||||
|
||||
def test_open_wake_word_value(self):
|
||||
from linux_voice_assistant.models import WakeWordType
|
||||
|
||||
assert WakeWordType.OPEN_WAKE_WORD == "openWakeWord"
|
||||
|
||||
def test_is_string_enum(self):
|
||||
from linux_voice_assistant.models import WakeWordType
|
||||
|
||||
assert isinstance(WakeWordType.MICRO_WAKE_WORD, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
def test_default_active_wake_words_is_empty_list(self):
|
||||
p = make_preferences()
|
||||
assert p.active_wake_words == []
|
||||
|
||||
def test_default_volume_is_none(self):
|
||||
p = make_preferences()
|
||||
assert p.volume is None
|
||||
|
||||
def test_default_thinking_sound_is_zero(self):
|
||||
p = make_preferences()
|
||||
assert p.thinking_sound == 0
|
||||
|
||||
def test_default_mic_auto_gain_is_zero(self):
|
||||
p = make_preferences()
|
||||
assert p.mic_auto_gain == 0
|
||||
|
||||
def test_default_mic_noise_suppression_is_zero(self):
|
||||
p = make_preferences()
|
||||
assert p.mic_noise_suppression == 0
|
||||
|
||||
def test_custom_values_stored(self):
|
||||
p = make_preferences(
|
||||
volume=0.8,
|
||||
mic_auto_gain=5,
|
||||
mic_noise_suppression=2,
|
||||
thinking_sound=1,
|
||||
)
|
||||
assert p.volume == 0.8
|
||||
assert p.mic_auto_gain == 5
|
||||
assert p.mic_noise_suppression == 2
|
||||
assert p.thinking_sound == 1
|
||||
|
||||
def test_active_wake_words_are_independent_per_instance(self):
|
||||
"""Mutable default must not be shared between instances."""
|
||||
p1 = make_preferences()
|
||||
p2 = make_preferences()
|
||||
p1.active_wake_words.append("okay_nabu")
|
||||
assert p2.active_wake_words == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerState.save_preferences()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSavePreferences:
|
||||
def test_creates_preferences_file(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.save_preferences()
|
||||
assert state.preferences_path.exists()
|
||||
|
||||
def test_saved_json_is_valid(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.save_preferences()
|
||||
with open(state.preferences_path) as f:
|
||||
data = json.load(f)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
def test_saved_json_contains_expected_keys(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.save_preferences()
|
||||
with open(state.preferences_path) as f:
|
||||
data = json.load(f)
|
||||
assert "mic_auto_gain" in data
|
||||
assert "mic_noise_suppression" in data
|
||||
assert "volume" in data
|
||||
assert "thinking_sound" in data
|
||||
|
||||
def test_saved_values_match_preferences(self, tmp_path):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=0.75, mic_auto_gain=3, mic_noise_suppression=1)
|
||||
state = make_server_state(tmp_path, preferences=prefs)
|
||||
state.save_preferences()
|
||||
with open(state.preferences_path) as f:
|
||||
data = json.load(f)
|
||||
assert data["volume"] == 0.75
|
||||
assert data["mic_auto_gain"] == 3
|
||||
assert data["mic_noise_suppression"] == 1
|
||||
|
||||
def test_creates_parent_directory_if_missing(self, tmp_path):
|
||||
nested_path = tmp_path / "nested" / "dir" / "preferences.json"
|
||||
state = make_server_state(tmp_path, preferences_path=nested_path)
|
||||
state.save_preferences()
|
||||
assert nested_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerState.persist_volume()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistVolume:
|
||||
def test_updates_volume_on_state(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(0.5)
|
||||
assert state.volume == 0.5
|
||||
|
||||
def test_updates_volume_on_preferences(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(0.5)
|
||||
assert state.preferences.volume == 0.5
|
||||
|
||||
def test_clamps_above_one(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(1.5)
|
||||
assert state.volume == 1.0
|
||||
|
||||
def test_clamps_below_zero(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(-0.5)
|
||||
assert state.volume == 0.0
|
||||
|
||||
def test_saves_to_file(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(0.6)
|
||||
with open(state.preferences_path) as f:
|
||||
data = json.load(f)
|
||||
assert data["volume"] == pytest.approx(0.6)
|
||||
|
||||
def test_skips_save_when_volume_unchanged(self, tmp_path):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=0.5)
|
||||
state = make_server_state(tmp_path, preferences=prefs, volume=0.5)
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_volume(0.5)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_saves_when_volume_changed(self, tmp_path):
|
||||
from linux_voice_assistant.models import Preferences
|
||||
|
||||
prefs = Preferences(volume=0.5)
|
||||
state = make_server_state(tmp_path, preferences=prefs, volume=0.5)
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_volume(0.8)
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_boundary_value_zero(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(0.0)
|
||||
assert state.volume == 0.0
|
||||
|
||||
def test_boundary_value_one(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_volume(1.0)
|
||||
assert state.volume == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerState.persist_mic_gain()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistMicGain:
|
||||
def test_updates_mic_auto_gain_on_state(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_gain(5.0)
|
||||
assert state.mic_auto_gain == 5
|
||||
|
||||
def test_updates_mic_auto_gain_on_preferences(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_gain(5.0)
|
||||
assert state.preferences.mic_auto_gain == 5
|
||||
|
||||
def test_converts_float_to_int(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_gain(7.9)
|
||||
assert state.mic_auto_gain == 7
|
||||
|
||||
def test_skips_save_when_gain_unchanged(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.mic_auto_gain = 3
|
||||
state.preferences.mic_auto_gain = 3
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_mic_gain(3.0)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_saves_when_gain_changed(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.mic_auto_gain = 3
|
||||
state.preferences.mic_auto_gain = 3
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_mic_gain(10.0)
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerState.persist_mic_noise()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistMicNoise:
|
||||
def test_updates_mic_noise_suppression_on_state(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_noise(2.0)
|
||||
assert state.mic_noise_suppression == 2
|
||||
|
||||
def test_updates_mic_noise_suppression_on_preferences(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_noise(2.0)
|
||||
assert state.preferences.mic_noise_suppression == 2
|
||||
|
||||
def test_converts_float_to_int(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.persist_mic_noise(3.9)
|
||||
assert state.mic_noise_suppression == 3
|
||||
|
||||
def test_skips_save_when_noise_unchanged(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.mic_noise_suppression = 2
|
||||
state.preferences.mic_noise_suppression = 2
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_mic_noise(2.0)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_saves_when_noise_changed(self, tmp_path):
|
||||
state = make_server_state(tmp_path)
|
||||
state.mic_noise_suppression = 2
|
||||
state.preferences.mic_noise_suppression = 2
|
||||
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_mic_noise(4.0)
|
||||
mock_save.assert_called_once()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Unit tests for MpvMediaPlayer."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_player(device=None):
|
||||
"""Return an MpvMediaPlayer with the internal LibMpvPlayer mocked out."""
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
mock_lib_player = MagicMock()
|
||||
mock_lib_player.state.return_value = PlayerState.IDLE
|
||||
|
||||
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer", return_value=mock_lib_player):
|
||||
from linux_voice_assistant.mpv_player import MpvMediaPlayer
|
||||
|
||||
player = MpvMediaPlayer(device=device)
|
||||
player._mock = mock_lib_player
|
||||
return player
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_player_initialized_with_device(self):
|
||||
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
from linux_voice_assistant.mpv_player import MpvMediaPlayer
|
||||
|
||||
MpvMediaPlayer(device="hw:1,0")
|
||||
mock_cls.assert_called_once_with(device="hw:1,0")
|
||||
|
||||
def test_player_initialized_with_none_device(self):
|
||||
with patch("linux_voice_assistant.mpv_player.LibMpvPlayer") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
from linux_voice_assistant.mpv_player import MpvMediaPlayer
|
||||
|
||||
MpvMediaPlayer(device=None)
|
||||
mock_cls.assert_called_once_with(device=None)
|
||||
|
||||
def test_done_callback_starts_none(self):
|
||||
player = make_player()
|
||||
assert player._done_callback is None
|
||||
|
||||
def test_playlist_starts_empty(self):
|
||||
player = make_player()
|
||||
assert player._playlist == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# play()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlay:
|
||||
def test_play_single_url_calls_lib_player(self):
|
||||
player = make_player()
|
||||
player.play("http://example.com/audio.mp3")
|
||||
player._mock.play.assert_called_once()
|
||||
|
||||
def test_play_single_url_passes_correct_url(self):
|
||||
player = make_player()
|
||||
player.play("http://example.com/audio.mp3")
|
||||
args, kwargs = player._mock.play.call_args
|
||||
assert args[0] == "http://example.com/audio.mp3"
|
||||
|
||||
def test_play_list_plays_first_url(self):
|
||||
player = make_player()
|
||||
player.play(["http://a.com/1.mp3", "http://a.com/2.mp3"])
|
||||
args, kwargs = player._mock.play.call_args
|
||||
assert args[0] == "http://a.com/1.mp3"
|
||||
|
||||
def test_play_list_stores_remaining_in_playlist(self):
|
||||
player = make_player()
|
||||
player.play(["http://a.com/1.mp3", "http://a.com/2.mp3", "http://a.com/3.mp3"])
|
||||
assert player._playlist == ["http://a.com/2.mp3", "http://a.com/3.mp3"]
|
||||
|
||||
def test_play_single_url_playlist_is_empty(self):
|
||||
player = make_player()
|
||||
player.play("http://example.com/audio.mp3")
|
||||
assert player._playlist == []
|
||||
|
||||
def test_play_stores_done_callback(self):
|
||||
player = make_player()
|
||||
cb = MagicMock()
|
||||
player.play("http://example.com/audio.mp3", done_callback=cb)
|
||||
assert player._done_callback is cb
|
||||
|
||||
def test_play_empty_list_does_not_call_lib_player(self):
|
||||
player = make_player()
|
||||
player.play([])
|
||||
player._mock.play.assert_not_called()
|
||||
|
||||
def test_play_while_active_stops_previous(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._done_callback = MagicMock() # simulate active playback
|
||||
player._mock.state.return_value = PlayerState.PLAYING
|
||||
|
||||
player.play("http://example.com/new.mp3")
|
||||
player._mock.stop.assert_called_once()
|
||||
|
||||
def test_play_while_idle_does_not_stop(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._mock.state.return_value = PlayerState.IDLE
|
||||
player._done_callback = None
|
||||
|
||||
player.play("http://example.com/new.mp3")
|
||||
player._mock.stop.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _on_track_finished()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnTrackFinished:
|
||||
def test_plays_next_url_when_playlist_has_items(self):
|
||||
player = make_player()
|
||||
player._playlist = ["http://a.com/2.mp3"]
|
||||
player._on_track_finished()
|
||||
args, _ = player._mock.play.call_args
|
||||
assert args[0] == "http://a.com/2.mp3"
|
||||
|
||||
def test_invokes_done_callback_when_playlist_empty(self):
|
||||
player = make_player()
|
||||
cb = MagicMock()
|
||||
player._done_callback = cb
|
||||
player._playlist = []
|
||||
player._on_track_finished()
|
||||
cb.assert_called_once()
|
||||
|
||||
def test_clears_done_callback_after_invoking(self):
|
||||
player = make_player()
|
||||
player._done_callback = MagicMock()
|
||||
player._playlist = []
|
||||
player._on_track_finished()
|
||||
assert player._done_callback is None
|
||||
|
||||
def test_no_error_when_done_callback_is_none(self):
|
||||
player = make_player()
|
||||
player._done_callback = None
|
||||
player._playlist = []
|
||||
player._on_track_finished() # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pause() / resume() / stop()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPauseResumeStop:
|
||||
def test_pause_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.pause()
|
||||
player._mock.pause.assert_called_once()
|
||||
|
||||
def test_resume_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.resume()
|
||||
player._mock.resume.assert_called_once()
|
||||
|
||||
def test_stop_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.stop()
|
||||
player._mock.stop.assert_called_once()
|
||||
|
||||
def test_stop_invokes_done_callback(self):
|
||||
player = make_player()
|
||||
cb = MagicMock()
|
||||
player._done_callback = cb
|
||||
player.stop()
|
||||
cb.assert_called_once()
|
||||
|
||||
def test_stop_clears_done_callback(self):
|
||||
player = make_player()
|
||||
player._done_callback = MagicMock()
|
||||
player.stop()
|
||||
assert player._done_callback is None
|
||||
|
||||
def test_stop_no_error_when_no_callback(self):
|
||||
player = make_player()
|
||||
player._done_callback = None
|
||||
player.stop() # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_playing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsPlaying:
|
||||
def test_true_when_playing(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._mock.state.return_value = PlayerState.PLAYING
|
||||
assert player.is_playing is True
|
||||
|
||||
def test_true_when_paused(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._mock.state.return_value = PlayerState.PAUSED
|
||||
assert player.is_playing is True
|
||||
|
||||
def test_true_when_loading(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._mock.state.return_value = PlayerState.LOADING
|
||||
assert player.is_playing is True
|
||||
|
||||
def test_false_when_idle(self):
|
||||
from linux_voice_assistant.player.state import PlayerState
|
||||
|
||||
player = make_player()
|
||||
player._mock.state.return_value = PlayerState.IDLE
|
||||
assert player.is_playing is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_volume() / duck() / unduck()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVolume:
|
||||
def test_set_volume_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.set_volume(75.0)
|
||||
player._mock.set_volume.assert_called_once_with(75.0)
|
||||
|
||||
def test_duck_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.duck(factor=0.3)
|
||||
player._mock.duck.assert_called_once_with(0.3)
|
||||
|
||||
def test_duck_default_factor(self):
|
||||
player = make_player()
|
||||
player.duck()
|
||||
player._mock.duck.assert_called_once_with(0.5)
|
||||
|
||||
def test_unduck_delegates_to_lib_player(self):
|
||||
player = make_player()
|
||||
player.unduck()
|
||||
player._mock.unduck.assert_called_once()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Unit tests for VoiceSatelliteProtocol logic."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.unit.conftest import make_satellite, make_state
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_satellite_stored_on_state(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.satellite is sat
|
||||
|
||||
def test_connected_starts_false(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.connected is False
|
||||
|
||||
def test_media_player_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import MediaPlayerEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.media_player_entity is not None
|
||||
assert isinstance(sat.state.media_player_entity, MediaPlayerEntity)
|
||||
|
||||
def test_mute_switch_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import MuteSwitchEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.mute_switch_entity is not None
|
||||
assert isinstance(sat.state.mute_switch_entity, MuteSwitchEntity)
|
||||
|
||||
def test_thinking_sound_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import ThinkingSoundEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.thinking_sound_entity is not None
|
||||
assert isinstance(sat.state.thinking_sound_entity, ThinkingSoundEntity)
|
||||
|
||||
def test_mic_gain_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import MicSettingEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.mic_gain_entity is not None
|
||||
assert isinstance(sat.state.mic_gain_entity, MicSettingEntity)
|
||||
|
||||
def test_mic_noise_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import MicSettingEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.mic_noise_suppression_entity is not None
|
||||
assert isinstance(sat.state.mic_noise_suppression_entity, MicSettingEntity)
|
||||
|
||||
def test_mic_volume_entity_created(self, tmp_path):
|
||||
from linux_voice_assistant.entity import MicSettingEntity
|
||||
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.mic_volume_entity is not None
|
||||
assert isinstance(sat.state.mic_volume_entity, MicSettingEntity)
|
||||
|
||||
def test_pipeline_not_active_on_start(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat._pipeline_active is False
|
||||
|
||||
def test_not_streaming_audio_on_start(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat._is_streaming_audio is False
|
||||
|
||||
def test_not_muted_on_start(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
assert sat.state.muted is False
|
||||
|
||||
def test_thinking_sound_loaded_from_preferences(self, tmp_path):
|
||||
state = make_state(tmp_path)
|
||||
state.preferences.thinking_sound = 1
|
||||
|
||||
with (
|
||||
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
|
||||
):
|
||||
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
|
||||
|
||||
sat = VoiceSatelliteProtocol(state)
|
||||
|
||||
assert sat.state.thinking_sound_enabled is True
|
||||
|
||||
def test_output_only_sets_limited_features(self, tmp_path):
|
||||
from aioesphomeapi.model import VoiceAssistantFeature
|
||||
|
||||
state = make_state(tmp_path)
|
||||
state.output_only = True
|
||||
|
||||
with (
|
||||
patch("linux_voice_assistant.satellite.WakeWord1SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.WakeWord2SensitivityNumberEntity", MagicMock()),
|
||||
patch("linux_voice_assistant.satellite.StopWordSensitivityNumberEntity", MagicMock()),
|
||||
):
|
||||
from linux_voice_assistant.satellite import VoiceSatelliteProtocol
|
||||
|
||||
sat = VoiceSatelliteProtocol(state)
|
||||
|
||||
assert sat.supported_features & VoiceAssistantFeature.VOICE_ASSISTANT == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _set_muted()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetMuted:
|
||||
def test_muting_sets_muted_flag(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_muted(True)
|
||||
assert sat.state.muted is True
|
||||
|
||||
def test_unmuting_clears_muted_flag(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.state.muted = True
|
||||
sat._set_muted(False)
|
||||
assert sat.state.muted is False
|
||||
|
||||
def test_muting_stops_tts_player(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_muted(True)
|
||||
sat.state.tts_player.stop.assert_called()
|
||||
|
||||
def test_muting_plays_mute_sound(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_muted(True)
|
||||
sat.state.tts_player.play.assert_called_with(sat.state.mute_sound)
|
||||
|
||||
def test_unmuting_plays_unmute_sound(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_muted(False)
|
||||
sat.state.tts_player.play.assert_called_with(sat.state.unmute_sound)
|
||||
|
||||
def test_muting_stops_audio_streaming(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._is_streaming_audio = True
|
||||
sat._set_muted(True)
|
||||
assert sat._is_streaming_audio is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _set_thinking_sound_enabled()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetThinkingSoundEnabled:
|
||||
def test_enables_thinking_sound(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_thinking_sound_enabled(True)
|
||||
assert sat.state.thinking_sound_enabled is True
|
||||
|
||||
def test_disables_thinking_sound(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.state.thinking_sound_enabled = True
|
||||
sat._set_thinking_sound_enabled(False)
|
||||
assert sat.state.thinking_sound_enabled is False
|
||||
|
||||
def test_updates_preferences(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_thinking_sound_enabled(True)
|
||||
assert sat.state.preferences.thinking_sound == 1
|
||||
|
||||
def test_saves_preferences_to_file(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_thinking_sound_enabled(True)
|
||||
assert sat.state.preferences_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _set_sensitivity_1/2 and _set_stop_sensitivity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSensitivitySetters:
|
||||
def test_set_sensitivity_1_updates_threshold(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_sensitivity_1(0.85)
|
||||
assert sat.state.wake_word_1_threshold == pytest.approx(0.85)
|
||||
|
||||
def test_set_sensitivity_1_updates_preferences(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_sensitivity_1(0.85)
|
||||
assert sat.state.preferences.wake_word_1_sensitivity == pytest.approx(0.85)
|
||||
|
||||
def test_set_sensitivity_2_updates_threshold(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_sensitivity_2(0.6)
|
||||
assert sat.state.wake_word_2_threshold == pytest.approx(0.6)
|
||||
|
||||
def test_set_sensitivity_2_updates_preferences(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_sensitivity_2(0.6)
|
||||
assert sat.state.preferences.wake_word_2_sensitivity == pytest.approx(0.6)
|
||||
|
||||
def test_set_stop_sensitivity_updates_threshold(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_stop_sensitivity(0.5)
|
||||
assert sat.state.stop_word_threshold == pytest.approx(0.5)
|
||||
|
||||
def test_set_stop_sensitivity_updates_preferences(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_stop_sensitivity(0.5)
|
||||
assert sat.state.preferences.stop_word_sensitivity == pytest.approx(0.5)
|
||||
|
||||
def test_sensitivity_saves_preferences(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._set_sensitivity_1(0.9)
|
||||
assert sat.state.preferences_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_audio()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleAudio:
|
||||
def test_does_not_send_when_not_streaming(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._is_streaming_audio = False
|
||||
sat.handle_audio(b"\x00" * 320)
|
||||
sat._writelines.assert_not_called()
|
||||
|
||||
def test_does_not_send_when_muted(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._is_streaming_audio = True
|
||||
sat.state.muted = True
|
||||
sat.handle_audio(b"\x00" * 320)
|
||||
sat._writelines.assert_not_called()
|
||||
|
||||
def test_sends_when_streaming_and_not_muted(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._is_streaming_audio = True
|
||||
sat.state.muted = False
|
||||
sat._loop = None
|
||||
sat.handle_audio(b"\x00" * 320)
|
||||
sat._writelines.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# play_tts()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlayTts:
|
||||
def test_does_not_play_when_no_url(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._tts_url = None
|
||||
sat.play_tts()
|
||||
sat.state.tts_player.play.assert_not_called()
|
||||
|
||||
def test_does_not_play_when_already_played(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._tts_url = "http://example.com/tts.mp3"
|
||||
sat._tts_played = True
|
||||
sat.play_tts()
|
||||
sat.state.tts_player.play.assert_not_called()
|
||||
|
||||
def test_plays_tts_url(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._tts_url = "http://example.com/tts.mp3"
|
||||
sat._tts_played = False
|
||||
sat.play_tts()
|
||||
sat.state.tts_player.play.assert_called_once()
|
||||
args, _ = sat.state.tts_player.play.call_args
|
||||
assert args[0] == "http://example.com/tts.mp3"
|
||||
|
||||
def test_sets_tts_played_flag(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._tts_url = "http://example.com/tts.mp3"
|
||||
sat._tts_played = False
|
||||
sat.play_tts()
|
||||
assert sat._tts_played is True
|
||||
|
||||
def test_adds_stop_word_to_active_wake_words(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._tts_url = "http://example.com/tts.mp3"
|
||||
sat._tts_played = False
|
||||
sat.play_tts()
|
||||
assert sat.state.stop_word.id in sat.state.active_wake_words
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_stop_clears_pipeline_active(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._pipeline_active = True
|
||||
sat.stop()
|
||||
assert sat._pipeline_active is False
|
||||
|
||||
def test_stop_discards_stop_word_from_active(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.state.active_wake_words.add(sat.state.stop_word.id)
|
||||
sat.stop()
|
||||
assert sat.state.stop_word.id not in sat.state.active_wake_words
|
||||
|
||||
def test_stop_calls_tts_player_stop(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._timer_finished = False
|
||||
sat.stop()
|
||||
sat.state.tts_player.stop.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# duck() / unduck()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDuckUnduck:
|
||||
def test_duck_calls_music_player_duck(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.duck()
|
||||
sat.state.music_player.duck.assert_called_once()
|
||||
|
||||
def test_unduck_calls_music_player_unduck(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.unduck()
|
||||
sat.state.music_player.unduck.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connection_lost()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnectionLost:
|
||||
def test_connection_lost_clears_connected_flag(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.state.connected = True
|
||||
sat.connection_lost(None)
|
||||
assert sat.state.connected is False
|
||||
|
||||
def test_connection_lost_clears_satellite_reference(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.connection_lost(None)
|
||||
assert sat.state.satellite is None
|
||||
|
||||
def test_connection_lost_stops_streaming(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._is_streaming_audio = True
|
||||
sat.connection_lost(None)
|
||||
assert sat._is_streaming_audio is False
|
||||
|
||||
def test_connection_lost_clears_pipeline_active(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat._pipeline_active = True
|
||||
sat.connection_lost(None)
|
||||
assert sat._pipeline_active is False
|
||||
|
||||
def test_connection_lost_stops_music_player(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.connection_lost(None)
|
||||
sat.state.music_player.stop.assert_called()
|
||||
|
||||
def test_connection_lost_stops_tts_player(self, tmp_path):
|
||||
sat = make_satellite(tmp_path)
|
||||
sat.connection_lost(None)
|
||||
sat.state.tts_player.stop.assert_called()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Unit tests for utility functions."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetVersion:
|
||||
def setup_method(self):
|
||||
"""Reset the version cache before each test."""
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
def test_returns_unknown_when_file_missing(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", side_effect=FileNotFoundError):
|
||||
result = util.get_version()
|
||||
assert result == "unknown"
|
||||
|
||||
def test_returns_version_string_from_file(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", return_value="1.2.3\n"):
|
||||
result = util.get_version()
|
||||
assert result == "1.2.3"
|
||||
|
||||
def test_strips_whitespace_from_version(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", return_value=" 2.0.0 \n"):
|
||||
result = util.get_version()
|
||||
assert result == "2.0.0"
|
||||
|
||||
def test_returns_unknown_for_empty_file(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", return_value=" "):
|
||||
result = util.get_version()
|
||||
assert result == "unknown"
|
||||
|
||||
def test_caches_result_after_first_call(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", return_value="3.0.0") as mock_read:
|
||||
util.get_version()
|
||||
util.get_version()
|
||||
mock_read.assert_called_once()
|
||||
|
||||
def test_returns_cached_value_on_second_call(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", return_value="4.0.0"):
|
||||
first = util.get_version()
|
||||
|
||||
second = util.get_version()
|
||||
assert first == second == "4.0.0"
|
||||
|
||||
def test_returns_unknown_on_permission_error(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._version_cache = None
|
||||
|
||||
with patch.object(Path, "read_text", side_effect=PermissionError):
|
||||
result = util.get_version()
|
||||
assert result == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_esphome_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetEsphomeVersion:
|
||||
def setup_method(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
util._esphome_version_cache = None
|
||||
|
||||
def test_returns_version_when_package_installed(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.version", return_value="42.7.0"):
|
||||
result = util.get_esphome_version()
|
||||
assert result == "42.7.0"
|
||||
|
||||
def test_returns_unknown_when_package_not_installed(self):
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.version", side_effect=PackageNotFoundError):
|
||||
result = util.get_esphome_version()
|
||||
assert result == "unknown"
|
||||
|
||||
def test_caches_result_after_first_call(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.version", return_value="1.0.0") as mock_ver:
|
||||
util.get_esphome_version()
|
||||
util.get_esphome_version()
|
||||
mock_ver.assert_called_once()
|
||||
|
||||
def test_returns_cached_value_on_second_call(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.version", return_value="5.0.0"):
|
||||
first = util.get_esphome_version()
|
||||
|
||||
second = util.get_esphome_version()
|
||||
assert first == second == "5.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# call_all()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCallAll:
|
||||
def test_calls_single_callable(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
mock_fn = MagicMock()
|
||||
call_all(mock_fn)
|
||||
mock_fn.assert_called_once()
|
||||
|
||||
def test_calls_multiple_callables_in_order(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
calls = []
|
||||
call_all(
|
||||
lambda: calls.append(1),
|
||||
lambda: calls.append(2),
|
||||
lambda: calls.append(3),
|
||||
)
|
||||
assert calls == [1, 2, 3]
|
||||
|
||||
def test_skips_none_entries(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
mock_fn = MagicMock()
|
||||
call_all(None, mock_fn, None)
|
||||
mock_fn.assert_called_once()
|
||||
|
||||
def test_all_none_does_nothing(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
call_all(None, None, None)
|
||||
|
||||
def test_empty_args_does_nothing(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
call_all()
|
||||
|
||||
def test_none_mixed_with_callables_calls_only_non_none(self):
|
||||
from linux_voice_assistant.util import call_all
|
||||
|
||||
results = []
|
||||
call_all(None, lambda: results.append("a"), None, lambda: results.append("b"))
|
||||
assert results == ["a", "b"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_default_interface() and get_default_ipv4()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetDefaultInterface:
|
||||
def test_returns_interface_name_from_gateway(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
|
||||
# Set AF_INET before building the dict so the key matches
|
||||
mock_netifaces.AF_INET = 2
|
||||
mock_netifaces.default_gateway.return_value = {2: ("192.168.1.1", "eth0")}
|
||||
result = util.get_default_interface()
|
||||
assert result == "eth0"
|
||||
|
||||
def test_returns_none_when_no_gateway(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
|
||||
mock_netifaces.default_gateway.return_value = {}
|
||||
result = util.get_default_interface()
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_ipv4_gateway(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
|
||||
mock_netifaces.AF_INET = 2
|
||||
# Only a non-IPv4 gateway present
|
||||
mock_netifaces.default_gateway.return_value = {99: ("10.0.0.1", "eth1")}
|
||||
result = util.get_default_interface()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetDefaultIpv4:
|
||||
def test_returns_ip_for_interface(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
|
||||
mock_netifaces.AF_INET = 2
|
||||
mock_netifaces.ifaddresses.return_value = {2: [{"addr": "192.168.1.50"}]}
|
||||
result = util.get_default_ipv4("eth0")
|
||||
assert result == "192.168.1.50"
|
||||
|
||||
def test_returns_none_for_empty_interface(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
result = util.get_default_ipv4("")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_none_interface(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
result = util.get_default_ipv4(None)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_ipv4_address(self):
|
||||
import linux_voice_assistant.util as util
|
||||
|
||||
with patch("linux_voice_assistant.util.netifaces") as mock_netifaces:
|
||||
mock_netifaces.AF_INET = 2
|
||||
mock_netifaces.ifaddresses.return_value = {}
|
||||
result = util.get_default_ipv4("eth0")
|
||||
assert result is None
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Unit tests for wake_word.py — find_available_wake_words, load_wake_models, load_stop_model."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_micro_json(wake_word: str = "okay nabu", probability_cutoff: float = 0.7) -> dict:
|
||||
return {
|
||||
"type": "micro",
|
||||
"wake_word": wake_word,
|
||||
"trained_languages": ["en"],
|
||||
"micro": {"probability_cutoff": probability_cutoff},
|
||||
}
|
||||
|
||||
|
||||
def make_oww_json(wake_word: str = "hey jarvis", model_file: str = "hey_jarvis.tflite") -> dict:
|
||||
return {
|
||||
"type": "openWakeWord",
|
||||
"wake_word": wake_word,
|
||||
"trained_languages": ["en"],
|
||||
"model": model_file,
|
||||
"openWakeWord": {"probability_cutoff": 0.5},
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> None:
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_available_wake_words()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindAvailableWakeWords:
|
||||
def test_finds_micro_wake_word(self, tmp_path):
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json())
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert "okay_nabu" in result
|
||||
|
||||
def test_skips_stop_model(self, tmp_path):
|
||||
write_json(tmp_path / "stop.json", make_micro_json("stop"))
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json())
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert "stop" not in result
|
||||
assert "okay_nabu" in result
|
||||
|
||||
def test_returns_empty_for_empty_directory(self, tmp_path):
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result == {}
|
||||
|
||||
def test_returns_empty_for_nonexistent_directory(self, tmp_path):
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path / "does_not_exist"], stop_model_id="stop")
|
||||
assert result == {}
|
||||
|
||||
def test_micro_wake_word_path_is_config_path(self, tmp_path):
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json())
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].wake_word_path == tmp_path / "okay_nabu.json"
|
||||
|
||||
def test_oww_wake_word_path_is_model_file(self, tmp_path):
|
||||
write_json(tmp_path / "hey_jarvis.json", make_oww_json(model_file="hey_jarvis.tflite"))
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["hey_jarvis"].wake_word_path == tmp_path / "hey_jarvis.tflite"
|
||||
|
||||
def test_probability_cutoff_loaded_from_config(self, tmp_path):
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json(probability_cutoff=0.85))
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].probability_cutoff == pytest.approx(0.85)
|
||||
|
||||
def test_default_probability_cutoff_when_missing(self, tmp_path):
|
||||
data = {"type": "micro", "wake_word": "okay nabu", "trained_languages": ["en"]}
|
||||
write_json(tmp_path / "okay_nabu.json", data)
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].probability_cutoff == pytest.approx(0.7)
|
||||
|
||||
def test_trained_languages_stored(self, tmp_path):
|
||||
data = make_micro_json()
|
||||
data["trained_languages"] = ["en", "de"]
|
||||
write_json(tmp_path / "okay_nabu.json", data)
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].trained_languages == ["en", "de"]
|
||||
|
||||
def test_searches_multiple_directories(self, tmp_path):
|
||||
dir1 = tmp_path / "dir1"
|
||||
dir2 = tmp_path / "dir2"
|
||||
dir1.mkdir()
|
||||
dir2.mkdir()
|
||||
write_json(dir1 / "okay_nabu.json", make_micro_json())
|
||||
write_json(dir2 / "hey_jarvis.json", make_oww_json())
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([dir1, dir2], stop_model_id="stop")
|
||||
assert "okay_nabu" in result
|
||||
assert "hey_jarvis" in result
|
||||
|
||||
def test_wake_word_text_stored(self, tmp_path):
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json(wake_word="okay nabu"))
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].wake_word == "okay nabu"
|
||||
|
||||
def test_type_stored_correctly(self, tmp_path):
|
||||
from linux_voice_assistant.models import WakeWordType
|
||||
|
||||
write_json(tmp_path / "okay_nabu.json", make_micro_json())
|
||||
from linux_voice_assistant.wake_word import find_available_wake_words
|
||||
|
||||
result = find_available_wake_words([tmp_path], stop_model_id="stop")
|
||||
assert result["okay_nabu"].type == WakeWordType.MICRO_WAKE_WORD
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_wake_models()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadWakeModels:
|
||||
def _make_available(self, tmp_path, model_id="okay_nabu"):
|
||||
from linux_voice_assistant.models import AvailableWakeWord, WakeWordType
|
||||
|
||||
config_path = tmp_path / f"{model_id}.json"
|
||||
write_json(config_path, make_micro_json())
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = model_id
|
||||
available = MagicMock(spec=AvailableWakeWord)
|
||||
available.id = model_id
|
||||
available.type = WakeWordType.MICRO_WAKE_WORD
|
||||
available.wake_word = "okay nabu"
|
||||
available.trained_languages = ["en"]
|
||||
available.wake_word_path = config_path
|
||||
available.probability_cutoff = 0.7
|
||||
available.load.return_value = mock_model
|
||||
return available, mock_model
|
||||
|
||||
def test_loads_requested_wake_word(self, tmp_path):
|
||||
available, mock_model = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, fallback = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
|
||||
assert "okay_nabu" in models
|
||||
assert "okay_nabu" in active
|
||||
|
||||
def test_falls_back_to_default_when_no_active(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, fallback = load_wake_models({"okay_nabu": available}, [], "okay_nabu")
|
||||
assert "okay_nabu" in models
|
||||
assert fallback is True
|
||||
|
||||
def test_fallback_used_is_false_when_requested_loaded(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
_, _, fallback = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
|
||||
assert fallback is False
|
||||
|
||||
def test_skips_unknown_wake_word_id(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, _ = load_wake_models({"okay_nabu": available}, ["unknown_word"], "okay_nabu")
|
||||
assert "unknown_word" not in models
|
||||
|
||||
def test_falls_back_to_okay_nabu_when_default_missing(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path, "okay_nabu")
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, _ = load_wake_models({"okay_nabu": available}, [], "nonexistent_default")
|
||||
assert "okay_nabu" in models
|
||||
|
||||
def test_raises_when_no_wake_words_available(self):
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
with pytest.raises(RuntimeError, match="No wake word models available"):
|
||||
load_wake_models({}, [], "okay_nabu")
|
||||
|
||||
def test_loads_multiple_requested_models(self, tmp_path):
|
||||
available1, _ = self._make_available(tmp_path, "okay_nabu")
|
||||
available2, _ = self._make_available(tmp_path, "hey_jarvis")
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, _ = load_wake_models(
|
||||
{"okay_nabu": available1, "hey_jarvis": available2},
|
||||
["okay_nabu", "hey_jarvis"],
|
||||
"okay_nabu",
|
||||
)
|
||||
assert "okay_nabu" in models
|
||||
assert "hey_jarvis" in models
|
||||
|
||||
def test_active_set_matches_loaded_models(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
models, active, _ = load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
|
||||
assert set(models.keys()) == active
|
||||
|
||||
def test_load_called_once_per_model(self, tmp_path):
|
||||
available, _ = self._make_available(tmp_path)
|
||||
from linux_voice_assistant.wake_word import load_wake_models
|
||||
|
||||
load_wake_models({"okay_nabu": available}, ["okay_nabu"], "okay_nabu")
|
||||
available.load.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_stop_model()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadStopModel:
|
||||
def test_returns_model_when_found(self, tmp_path):
|
||||
write_json(tmp_path / "stop.json", make_micro_json("stop"))
|
||||
mock_model = MagicMock()
|
||||
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model):
|
||||
from linux_voice_assistant.wake_word import load_stop_model
|
||||
|
||||
result = load_stop_model([tmp_path], "stop")
|
||||
assert result is mock_model
|
||||
|
||||
def test_returns_none_when_not_found(self, tmp_path):
|
||||
from linux_voice_assistant.wake_word import load_stop_model
|
||||
|
||||
result = load_stop_model([tmp_path], "stop")
|
||||
assert result is None
|
||||
|
||||
def test_searches_multiple_directories(self, tmp_path):
|
||||
dir1 = tmp_path / "dir1"
|
||||
dir2 = tmp_path / "dir2"
|
||||
dir1.mkdir()
|
||||
dir2.mkdir()
|
||||
write_json(dir2 / "stop.json", make_micro_json("stop"))
|
||||
mock_model = MagicMock()
|
||||
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model):
|
||||
from linux_voice_assistant.wake_word import load_stop_model
|
||||
|
||||
result = load_stop_model([dir1, dir2], "stop")
|
||||
assert result is mock_model
|
||||
|
||||
def test_returns_none_when_load_fails(self, tmp_path):
|
||||
write_json(tmp_path / "stop.json", make_micro_json("stop"))
|
||||
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", side_effect=Exception("load error")):
|
||||
from linux_voice_assistant.wake_word import load_stop_model
|
||||
|
||||
result = load_stop_model([tmp_path], "stop")
|
||||
assert result is None
|
||||
|
||||
def test_stops_at_first_found(self, tmp_path):
|
||||
dir1 = tmp_path / "dir1"
|
||||
dir2 = tmp_path / "dir2"
|
||||
dir1.mkdir()
|
||||
dir2.mkdir()
|
||||
write_json(dir1 / "stop.json", make_micro_json("stop"))
|
||||
write_json(dir2 / "stop.json", make_micro_json("stop"))
|
||||
mock_model = MagicMock()
|
||||
with patch("linux_voice_assistant.wake_word.MicroWakeWord.from_config", return_value=mock_model) as mock_load:
|
||||
from linux_voice_assistant.wake_word import load_stop_model
|
||||
|
||||
load_stop_model([dir1, dir2], "stop")
|
||||
mock_load.assert_called_once()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Unit tests for WebRTCProcessor."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRAME_SIZE = 320 # 160 samples * 2 bytes (16-bit PCM)
|
||||
PATCH_TARGET = "webrtc_noise_gain.AudioProcessor"
|
||||
|
||||
|
||||
def make_audio(n_bytes: int, fill: int = 0xAB) -> bytes:
|
||||
"""Return n_bytes of dummy PCM data."""
|
||||
return bytes([fill]) * n_bytes
|
||||
|
||||
|
||||
def make_mock_apm(output_fill: int = 0x00):
|
||||
"""Return a mock AudioProcessor whose Process10ms returns a frame-sized result."""
|
||||
mock_apm = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.audio = bytes([output_fill]) * FRAME_SIZE
|
||||
mock_apm.Process10ms.return_value = mock_result
|
||||
return mock_apm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
"""WebRTCProcessor with a mocked AudioProcessor so no C extension is needed."""
|
||||
mock_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=mock_apm):
|
||||
from linux_voice_assistant.webrtc import WebRTCProcessor
|
||||
|
||||
proc = WebRTCProcessor(agc_level=3, ns_level=2)
|
||||
proc._mock_apm = mock_apm
|
||||
yield proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_frame_size_is_320(self, processor):
|
||||
assert processor.FRAME_SIZE_BYTES == FRAME_SIZE
|
||||
|
||||
def test_agc_stored(self, processor):
|
||||
assert processor.agc_level == 3
|
||||
|
||||
def test_ns_stored(self, processor):
|
||||
assert processor.ns_level == 2
|
||||
|
||||
def test_buffer_starts_empty(self, processor):
|
||||
assert len(processor._buffer) == 0
|
||||
|
||||
def test_audio_processor_constructed_with_correct_levels(self):
|
||||
mock_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=mock_apm) as mock_cls:
|
||||
from linux_voice_assistant.webrtc import WebRTCProcessor
|
||||
|
||||
WebRTCProcessor(agc_level=5, ns_level=1)
|
||||
mock_cls.assert_called_once_with(5, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process() — buffering behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessBuffering:
|
||||
def test_exact_one_frame_returns_processed_bytes(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE))
|
||||
assert len(result) == FRAME_SIZE
|
||||
processor._mock_apm.Process10ms.assert_called_once()
|
||||
|
||||
def test_less_than_one_frame_returns_empty(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE - 1))
|
||||
assert result == b""
|
||||
processor._mock_apm.Process10ms.assert_not_called()
|
||||
|
||||
def test_two_frames_returns_two_processed_chunks(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE * 2))
|
||||
assert len(result) == FRAME_SIZE * 2
|
||||
assert processor._mock_apm.Process10ms.call_count == 2
|
||||
|
||||
def test_partial_input_buffers_remainder(self, processor):
|
||||
# 500 bytes = 1 full frame (320) + 180 leftover
|
||||
result = processor.process(make_audio(500))
|
||||
assert len(result) == FRAME_SIZE
|
||||
assert len(processor._buffer) == 500 - FRAME_SIZE
|
||||
|
||||
def test_accumulated_calls_eventually_flush(self, processor):
|
||||
# Two calls of 160 bytes each should flush one frame total
|
||||
processor.process(make_audio(160))
|
||||
assert processor._mock_apm.Process10ms.call_count == 0
|
||||
processor.process(make_audio(160))
|
||||
assert processor._mock_apm.Process10ms.call_count == 1
|
||||
|
||||
def test_buffer_drains_in_place(self, processor):
|
||||
"""After processing, only the remainder stays in the buffer."""
|
||||
processor.process(make_audio(500))
|
||||
assert len(processor._buffer) == 180
|
||||
|
||||
def test_empty_input_returns_empty(self, processor):
|
||||
result = processor.process(b"")
|
||||
assert result == b""
|
||||
processor._mock_apm.Process10ms.assert_not_called()
|
||||
|
||||
def test_output_is_concatenation_of_processed_chunks(self, processor):
|
||||
"""Each frame gets processed independently and results are joined."""
|
||||
apm = processor._mock_apm
|
||||
apm.Process10ms.side_effect = [
|
||||
MagicMock(audio=b"\x01" * FRAME_SIZE),
|
||||
MagicMock(audio=b"\x02" * FRAME_SIZE),
|
||||
]
|
||||
result = processor.process(make_audio(FRAME_SIZE * 2))
|
||||
assert result == b"\x01" * FRAME_SIZE + b"\x02" * FRAME_SIZE
|
||||
|
||||
def test_multiple_process_calls_accumulate_buffer(self, processor):
|
||||
"""Remainder from first call is used in second call."""
|
||||
processor.process(make_audio(200)) # 200 buffered, no flush
|
||||
processor.process(make_audio(200)) # 400 total, 1 flush, 80 remain
|
||||
assert processor._mock_apm.Process10ms.call_count == 1
|
||||
assert len(processor._buffer) == 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_settings()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
def test_reinitializes_when_agc_changes(self, processor):
|
||||
new_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
assert processor.apm is new_apm
|
||||
assert processor.agc_level == 10
|
||||
|
||||
def test_reinitializes_when_ns_changes(self, processor):
|
||||
new_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=3, ns_level=4)
|
||||
assert processor.apm is new_apm
|
||||
assert processor.ns_level == 4
|
||||
|
||||
def test_no_reinitialize_when_settings_unchanged(self, processor):
|
||||
original_apm = processor.apm
|
||||
with patch(PATCH_TARGET) as mock_cls:
|
||||
processor.update_settings(agc_level=3, ns_level=2)
|
||||
mock_cls.assert_not_called()
|
||||
assert processor.apm is original_apm
|
||||
|
||||
def test_stores_new_agc_level(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=15, ns_level=2)
|
||||
assert processor.agc_level == 15
|
||||
|
||||
def test_stores_new_ns_level(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=3, ns_level=3)
|
||||
assert processor.ns_level == 3
|
||||
|
||||
def test_new_apm_called_with_updated_levels(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()) as mock_cls:
|
||||
processor.update_settings(agc_level=7, ns_level=1)
|
||||
mock_cls.assert_called_once_with(7, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process() after update_settings()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessAfterUpdate:
|
||||
def test_buffer_preserved_across_settings_update(self, processor):
|
||||
"""Buffered bytes should survive a settings change."""
|
||||
processor.process(make_audio(160)) # half frame, stays buffered
|
||||
assert len(processor._buffer) == 160
|
||||
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
|
||||
assert len(processor._buffer) == 160
|
||||
|
||||
def test_process_uses_new_apm_after_update(self, processor):
|
||||
new_apm = make_mock_apm(output_fill=0xFF)
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
|
||||
processor.process(make_audio(FRAME_SIZE))
|
||||
new_apm.Process10ms.assert_called_once()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Unit tests for HomeAssistantZeroconf."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_zeroconf(**kwargs):
|
||||
defaults = dict(
|
||||
port=6053,
|
||||
mac_address="aa:bb:cc:dd:ee:ff",
|
||||
host_ip_address="192.168.1.100",
|
||||
name="lva-test",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf") as mock_zc_cls:
|
||||
mock_zc = MagicMock()
|
||||
mock_zc_cls.return_value = mock_zc
|
||||
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
|
||||
|
||||
instance = HomeAssistantZeroconf(**defaults)
|
||||
instance._mock_zc = mock_zc
|
||||
instance._mock_zc_cls = mock_zc_cls
|
||||
return instance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_name_stored(self):
|
||||
zc = make_zeroconf(name="my-lva")
|
||||
assert zc.name == "my-lva"
|
||||
|
||||
def test_port_stored(self):
|
||||
zc = make_zeroconf(port=1234)
|
||||
assert zc.port == 1234
|
||||
|
||||
def test_mac_address_stored(self):
|
||||
zc = make_zeroconf(mac_address="11:22:33:44:55:66")
|
||||
assert zc.mac_address == "11:22:33:44:55:66"
|
||||
|
||||
def test_host_ip_stored(self):
|
||||
zc = make_zeroconf(host_ip_address="10.0.0.1")
|
||||
assert zc.host_ip_address == "10.0.0.1"
|
||||
|
||||
def test_name_defaults_to_mac_when_not_provided(self):
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf"):
|
||||
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
|
||||
|
||||
zc = HomeAssistantZeroconf(
|
||||
port=6053,
|
||||
mac_address="aa:bb:cc:dd:ee:ff",
|
||||
host_ip_address="192.168.1.1",
|
||||
name=None,
|
||||
)
|
||||
assert zc.name == "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
def test_async_zeroconf_instantiated(self):
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncZeroconf") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
from linux_voice_assistant.zeroconf import HomeAssistantZeroconf
|
||||
|
||||
HomeAssistantZeroconf(
|
||||
port=6053,
|
||||
mac_address="aa:bb:cc:dd:ee:ff",
|
||||
host_ip_address="192.168.1.1",
|
||||
)
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register_server()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegisterServer:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_service_called(self):
|
||||
zc = make_zeroconf()
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo"):
|
||||
await zc.register_server()
|
||||
|
||||
zc._mock_zc.async_register_service.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_name_contains_device_name(self):
|
||||
zc = make_zeroconf(name="lva-aabbccddee")
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
# First positional arg is service type, second is full service name
|
||||
assert "lva-aabbccddee" in captured["args"][1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_type_is_esphomelib(self):
|
||||
zc = make_zeroconf()
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
assert captured["args"][0] == "_esphomelib._tcp.local."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_properties_contain_mac(self):
|
||||
zc = make_zeroconf(mac_address="aa:bb:cc:dd:ee:ff")
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
props = captured["kwargs"].get("properties", {})
|
||||
assert "mac" in props
|
||||
assert props["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_properties_contain_version(self):
|
||||
zc = make_zeroconf()
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
props = captured["kwargs"].get("properties", {})
|
||||
assert "version" in props
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_port_matches(self):
|
||||
zc = make_zeroconf(port=9999)
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
assert captured["kwargs"].get("port") == 9999
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_address_matches_host_ip(self):
|
||||
import socket
|
||||
|
||||
zc = make_zeroconf(host_ip_address="10.0.0.5")
|
||||
zc._mock_zc.async_register_service = AsyncMock()
|
||||
|
||||
captured = {}
|
||||
|
||||
with patch("linux_voice_assistant.zeroconf.AsyncServiceInfo") as mock_info_cls:
|
||||
mock_info_cls.side_effect = lambda *a, **kw: captured.update({"args": a, "kwargs": kw}) or MagicMock()
|
||||
await zc.register_server()
|
||||
|
||||
addresses = captured["kwargs"].get("addresses", [])
|
||||
assert socket.inet_aton("10.0.0.5") in addresses
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Alexa",
|
||||
"wake_word": "Alexa (OWW)",
|
||||
"model": "alexa_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Jarvis",
|
||||
"wake_word": "Hey Jarvis (OWW)",
|
||||
"model": "hey_jarvis_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Mycroft",
|
||||
"wake_word": "Hey Mycroft (OWW)",
|
||||
"model": "hey_mycroft_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Rhasspy",
|
||||
"wake_word": "Hey Rhasspy (OWW)",
|
||||
"model": "hey_rhasspy_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
|
||||