Compare commits

..
Author SHA1 Message Date
Omar Amin acda765964 Fix conditional check for DISABLE_PERIPHERAL_API 2026-07-27 13:37:22 +03:00
Omar Amin 3077e7230b Add optional peripheral API configuration options
Added optional configuration settings for peripheral API in .env.example.
2026-07-27 13:35:36 +03:00
dependabot[bot] ea991e026f Bump docker/login-action from 4.5.0 to 4.5.1 (#377)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/06fb636fac595d6fb4b28a5dfcb21a6f5091859c...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

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

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

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

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

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

Add configuration files, agent definitions, and skill sets to enable
AI-driven development workflows and standardized agent behavior.
2026-07-24 17:35:34 +02:00
dependabot[bot] 0f57ca989c Bump actions/checkout from 4 to 7 (#370)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

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

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

---------

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

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

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

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-21 19:16:10 +03:00
26 changed files with 562 additions and 67 deletions

No files matched your search

+18
View File
@@ -8,6 +8,9 @@
### Enable debug mode (optional):
# ENABLE_DEBUG="1"
### Enable colored debug mode (optional):
# ENABLE_COLORED_DEBUG="1"
### List audio devices (optional):
# if enabled normal startup is disabled
# LIST_DEVICES="1"
@@ -64,6 +67,21 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
# Start listening immediately after wake word detection, without waiting for the wake sound to finish
# LISTEN_DURING_WAKE_SOUND="1"
### Perpheral api host (optional):
# Bind address for the peripheral WebSocket API
# PERIPHERAL_HOST="0.0.0.0"
### Perpheral api port (optional):
# Port for the peripheral WebSocket API
# PERIPHERAL_PORT="6055"
### Peripheral api volume step (optional):
# Volume change for peripheral api per button press, 0.0–1.0
# PERIPHERAL_VOLUME_STEP="%(default)s"
### Disable the peripheral WebSocket API entirely
# DISABLE_PERIPHERAL_API="1"
### Wake word directory (optional):
# path for custom files in docker is for example "app/wakewords/custom"
# WAKE_WORD_DIR="app/wakewords"
+3 -3
View File
@@ -20,14 +20,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set lowercase registry and repository
run: |
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -36,7 +36,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create version files
run: |
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout PR code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
@@ -33,7 +33,7 @@ jobs:
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -42,7 +42,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create version files
run: |
+3 -3
View File
@@ -21,9 +21,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Log in to the Container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -32,7 +32,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
- uses: rhysd/changelog-from-release/action@v3
+5 -5
View File
@@ -14,7 +14,7 @@ jobs:
lint_black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
@@ -29,7 +29,7 @@ jobs:
lint_flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
@@ -44,7 +44,7 @@ jobs:
lint_isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
@@ -59,7 +59,7 @@ jobs:
lint_mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
@@ -75,7 +75,7 @@ jobs:
lint_pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Python 3.12
uses: actions/setup-python@v5
+8
View File
@@ -0,0 +1,8 @@
node_modules
package.json
package-lock.json
pnpm-lock.yaml
bun.lock
yarn.lock
worktrees
agent-manager.json
+14
View File
@@ -0,0 +1,14 @@
# Lint Command
Runs all linting checks for the project (black, isort, flake8, mypy, pylint).
## Usage
```
/lint # Run all linting checks
/lint --auto # Auto-fix formatting issues (black, isort)
```
## Command
./script/lint
+14
View File
@@ -0,0 +1,14 @@
# Run Command
Runs the Linux Voice Assistant application.
## Usage
```
/run # Run the application
/run --help # Show CLI help
```
## Command
./script/run
+14
View File
@@ -0,0 +1,14 @@
# Setup Command
Installs project dependencies in a virtual environment.
## Usage
```
/setup # Install dependencies
/setup --dev # Install dev dependencies (linting, testing tools)
```
## Command
./script/setup
+15
View File
@@ -0,0 +1,15 @@
# Test Command
Runs the pytest unit tests for the project.
## Usage
```
/tests # Run all tests
/tests <path> # Run tests in specific path
/tests -k <pattern> # Run tests matching pattern
```
## Command
./script/tests
+70
View File
@@ -0,0 +1,70 @@
# PR Review Standards
## Review Philosophy
- Only comment when confident an issue exists
- Be concise: one sentence per comment when possible
- Focus on actionable feedback, not observations
- When reviewing text, only comment on clarity issues if the text is genuinely confusing or could lead to errors
## What to Analyze
Review all code changes for:
- Code quality and style consistency with the existing codebase
- Potential bugs or issues
- Performance implications
- Blocking IO in async code
- Security concerns
- Test coverage
- Documentation updates if needed
## Project Standards
Respect the project standards as outlined in AGENTS.md. Any deviations must be raised as `[PROBLEM]`.
Key standards for LVA:
- Python 3.11, 3.12, 3.13 supported
- Black formatting with 200 char line length
- isort import sorting with black profile
- mypy type checking with strict settings
- pylint code quality checks (many checks disabled in pyproject.toml)
## Architecture-Specific Checks
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent, verify no assumptions about specific hardware
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`, check for proper error handling
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms need careful review for audio quality
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling must be robust
## CI Context
Lint runs on Python 3.13, tests run on Python 3.12 with libmpv-dev.
### What CI Checks
- **Lint**: `.github/workflows/lint.yml` runs all linting checks
- **Tests**: `.github/workflows/tests.yml` runs pytest unit tests
## Skip These (Low Value)
Do not comment on:
- Style/formatting (pre-commit handles this)
- Test failures (CI catches this)
- Missing dependencies (CI catches this)
- Minor naming suggestions
- Suggestions to add comments
- Logging suggestions unless security-related
## Issue Categories
Categorize every issue as:
- `[CRITICAL]` — must fix before merging (bugs, security issues, broken functionality)
- `[PROBLEM]` — should fix (code quality, bad patterns, missing tests)
- `[SUGGESTION]` — optional improvement (style, minor refactors, nice-to-haves)
## PR Title
The PR title must be a functional description of the change. It must NOT contain conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `chore:`, etc. Labels categorize PRs, not the title. Flag as `[PROBLEM]` if the title uses such prefixes.
+44
View File
@@ -0,0 +1,44 @@
name: review-pr
description: Use when asked to review a GitHub pull request, PR link is shared, or user says /review-pr
# Review GitHub Pull Request
Review the GitHub pull request: $ARGUMENTS.
## Steps
1. Run `gh pr view` to get PR details and description.
2. Run `gh pr diff` to see all changes.
3. Compare the local commit hash with the remote PR head commit.
- Make sure to do your work in a git worktree to avoid affecting the checked out branch.
- If they don't match, run `gh pr checkout` to check out the PR locally.
- **HARD STOP**: If `gh pr checkout` fails for ANY reason, STOP immediately. Do NOT attempt workarounds. Alert about the failure and wait for instructions.
- After checkout, verify the local commit hash matches the remote one. If they still don't match, STOP.
4. Run `gh pr checks` to see CI status.
5. Apply the review standards defined in `REVIEW_STANDARDS.md` (same directory).
6. Output review feedback to the CONSOLE only. DO NOT act on GitHub (no posting comments, no approving, no requesting changes).
## Output Format
List specific comments per file/line that need attention. Do not list things that are already correct.
Each comment:
1. State the severity ([CRITICAL], [PROBLEM], [SUGGESTION])
2. State the problem (1 sentence)
3. Why it matters (1 sentence, if needed)
4. Suggested fix (snippet or specific action)
End with an overall assessment and summary:
```
Overall assessment: <approve | request changes | comment>
- [CRITICAL] <issue>
- [PROBLEM] <issue>
- [SUGGESTION] <improvement>
```
## Constraints
- DO NOT make any changes to the code.
- No need to run tests or linters, just review the code changes.
+129
View File
@@ -0,0 +1,129 @@
# Project Context for AI Agents
## Behaviour
- NEVER automatically reply on Github (PRs or Discussions) without explicit consent from the developer.
- Always run `./script/lint` and `./script/tests` after code changes to ensure standards are met.
## Project Overview
**Linux Voice Assistant (LVA)** - A Linux-based voice satellite software for Home Assistant using the ESPHome protocol.
### Purpose
- Provides voice control and interaction for Home Assistant via the Assist integration
- Runs on x64/ARM64 Linux hardware with PulseAudio/PipeWire audio support
- Performs local wake word detection (OpenWakeWord/MicroWakeWord) and on-device processing
### Architecture
- **Protocol**: ESPHome API via `aioesphomeapi` for Home Assistant integration
- **Wake Words**: Supports both OpenWakeWord and MicroWakeWord engines
- **Audio**: Uses `soundcard` library for input, `python-mpv` for output
- **Peripherals**: WebSocket API for LEDs, buttons, HAT boards
- **Discovery**: mDNS/ZeroConf for auto-discovery by Home Assistant
### Key Components
- `linux_voice_assistant/__main__.py` - Main entry point with CLI argument parsing
- `linux_voice_assistant/satellite.py` - VoiceSatelliteProtocol implementation (ESPHome API)
- `linux_voice_assistant/wake_word.py` - Wake word model loading and detection
- `linux_voice_assistant/webrtc.py` - WebRTC noise gain/NS processing
- `linux_voice_assistant/mpv_player.py` - MPV-based media player
- `linux_voice_assistant/entity.py` - Entity definitions (MediaPlayer, MuteSwitch, etc.)
- `linux_voice_assistant/api_server.py` - Peripheral WebSocket API server
- `linux_voice_assistant/models.py` - Preferences and ServerState dataclasses
## Development Commands
### Setup
```bash
./script/setup --dev # Install dev dependencies in virtual environment
```
### Linting
```bash
./script/lint # Run all linting checks
./script/lint_black # Black formatting check (add --auto to fix)
./script/lint_isort # isort import sorting check (add --auto to fix)
./script/lint_flake8 # flake8 style checks
./script/lint_mypy # mypy type checking
./script/lint_pylint # pylint code quality checks
```
### Testing
```bash
./script/tests # Run pytest unit tests
```
## Code Quality Standards
- **Python**: 3.11, 3.12, 3.13 supported
- **Formatting**: Black (200 char line length, py312/py312 target)
- **Import Sorting**: isort with black profile
- **Type Checking**: mypy with strict settings
- **Linting**: pylint (many checks disabled in pyproject.toml for practical reasons)
## Testing Strategy
- Unit tests in `tests/unit/` using pytest with asyncio mode
- Test helper `make_state()` in `tests/unit/conftest.py` for creating state fixtures
- Mocks used heavily for hardware dependencies (audio devices, MPV, wake words)
## CI/CD
- GitHub Actions for lint checks and unit tests
- Tests run on Python 3.12 with libmpv-dev system dependency
- Lint runs on Python 3.13
## Branching and PRs
- All PRs target `main` (primary development branch). `stable` is for production releases.
- PRs labeled `bugfix` + `backport-to-stable` are automatically backported to `stable` — use only for bugs also present in `stable`.
## Agent Commands
When making code changes, run these commands in order:
1. **Format code**: `./script/lint_black --auto` and `./script/lint_isort --auto`
2. **Run linting**: `./script/lint`
3. **Run tests**: `./script/tests`
## Code Style
### Comments
Only use comments to explain complex, multi-line blocks of code. Do not comment obvious operations. Inline comments are there to explain code parts that need explaining - keep them when editing code.
### Docstring Format
Use Sphinx-style docstrings with `:param:` syntax. For simple functions, a single-line docstring is fine. The docstring should provide clarity to the caller, not explain how it works technically.
```python
def my_function(param1: str, param2: int, param3: bool = False) -> str:
"""
Brief one-line description of the function.
:param param1: Description of what param1 is used for.
:param param2: Description of what param2 is used for.
:param param3: Description of what param3 is used for.
"""
```
Do **not** use Google-style (`Args:`) or bullet-style (`- param:`) docstrings.
### File structure
Private methods should be at the bottom of the file, public at the top.
## Verification Checklist
Before claiming completion:
- [ ] Ran `./script/lint` - all checks passed
- [ ] Ran `./script/tests` - all tests passed
- [ ] For audio-related changes: Note that hardware testing (microphone/speaker) was NOT performed
- [ ] Did NOT claim hardware behavior is verified unless actually exercised
- [ ] Changes are focused on one feature or fix per change
## Areas Requiring Extra Care
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling
+1
View File
@@ -113,6 +113,7 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--peripheral-volume-step` | Volume change per button press, 0.0–1.0 | %(default)s |
| `--disable-peripheral-api` | Disable the peripheral WebSocket API entirely | False |
| `--debug` | Print DEBUG messages to console | False |
| `--colored-debug` | Print colored DEBUG messages to console | False |
| `--output-only` | Enable output only mode | False |
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
+5 -1
View File
@@ -9,6 +9,10 @@ if [ "$ENABLE_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--debug" )
fi
if [ "$ENABLE_COLORED_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--colored-debug" )
fi
if [ -n "${CLIENT_NAME}" ]; then
EXTRA_ARGS+=( "--name" "$CLIENT_NAME" )
fi
@@ -124,7 +128,7 @@ if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
fi
if [ -n "${DISABLE_PERIPHERAL_API}" ]; then
if [ -n "$DISABLE_PERIPHERAL_API" = "1" ]; then
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
fi
+1 -1
View File
@@ -77,7 +77,7 @@ The easyiest way to get started is to use the prebuilt image. This image comes w
- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose)
- Change configuration in /compose/ directory if you need to.
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/logs/picompose.log
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/log/picompose.log
- More information within the PiCompose project.
### Option B) Install on your own:
+3
View File
@@ -260,6 +260,9 @@ The following variables can be configured in the `.env` or in the service file:
| `LVA_PULSE_SERVER` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
| `ENABLE_COLORED_DEBUG` | (optional) | Set to "1" to enable colored debug mode |
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
| `NETWORK_INTERFACE` | Autodetected | network card for server |
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
+20
View File
@@ -54,11 +54,31 @@ sudo mkdir -p /etc/pipewire/pipewire.conf.d
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
```
💡 **Note:** On constrained hardware like a Raspberry Pi 3B, audio
processing can be interrupted by other system activity, causing
audible glitches or dropped wake-word detections. Loading the
`libpipewire-module-rt` module raises PipeWire's scheduling priority
(`rt.prio`) above normal processes, which reduces these interruptions.
The `flags = [ ifexists nofail ]` ensures this configuration is
silently skipped rather than causing a startup failure on systems
where realtime scheduling isn't available (e.g. some containers or
restricted kernels).
Add the following content:
```
context.properties = {
default.clock.rate = 16000
}
context.modules = [
{ name = libpipewire-module-rt
args = {
nice.level = -11
rt.prio = 88
}
flags = [ ifexists nofail ]
}
]
```
#### Applying the Changes
@@ -164,9 +164,9 @@ class SharedState:
# ===========================================================================
# USB LED driver
#
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
# bytes in the order [start_frame | brightness, B, G, R].
# The ReSpeaker Mic Array v2.0 LED firmware exposes a vendor-command USB
# interface. The copied pixel_ring reference uses command 0x06 for custom LED
# frames and command 0x20 for brightness, both addressed to wIndex 0x1C.
# ===========================================================================
RGB = Tuple[int, int, int]
@@ -186,20 +186,17 @@ def _scale(color: RGB, factor: float) -> RGB:
class USBLEDRing:
"""
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
This is the same protocol used by Seeed's pixel_ring library.
Uses the same vendor-command protocol as Seeed's pixel_ring library.
That protocol talks to the device recipient directly and does not require
detaching the kernel audio driver.
"""
# USB control transfer parameters matching the pixel_ring reference implementation.
# _CTRL_TYPE_OUT = 0x40: bmRequestType = CTRL_OUT | CTRL_TYPE_VENDOR | CTRL_RECIPIENT_DEVICE.
# Recipient is "device" (bits 4:0 = 0x00), so no interface needs to be claimed
# and the kernel audio driver must NOT be detached.
_CMD_SHOW = 0x06
_CMD_BRIGHTNESS = 0x20
_CTRL_TIMEOUT = 8000 # ms
_CTRL_REQUEST = 0
_CTRL_VALUE = 0x0001
_CTRL_INDEX = 0x1C
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
@@ -207,6 +204,7 @@ class USBLEDRing:
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._dev = None
self._dev_lock = threading.Lock()
self._last_brightness: Optional[int] = None
if _HAS_USB:
self._find_device()
@@ -220,12 +218,8 @@ class USBLEDRing:
USB_VENDOR_ID, USB_PRODUCT_ID,
)
return
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception: # pylint: disable=broad-except
pass
self._dev = dev
self._last_brightness = None
_LOGGER.info(
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
@@ -237,24 +231,32 @@ class USBLEDRing:
def set_all(self, color: RGB) -> None:
self._pixels = [color] * LED_COUNT
def _write(self, command: int, data: list[int]) -> None:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
command,
self._CTRL_INDEX,
data,
self._CTRL_TIMEOUT,
)
def show(self, brightness: float = 1.0) -> None:
"""
Push the current pixel buffer to the device.
Frame layout (matches pixel_ring / APA102 USB protocol):
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
The USB firmware expects command 0x06 with 4 bytes per LED in RGB0
order, plus command 0x20 for device brightness. Sending raw APA102
frames here causes the firmware to misinterpret the payload.
"""
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
frame: list[int] = []
for r, g, b in self._pixels:
br = max(0, min(255, int(r * brightness)))
bg = max(0, min(255, int(g * brightness)))
bb = max(0, min(255, int(b * brightness)))
frame += [0xE0 | bright5, bb, bg, br]
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
frame += [br, bg, bb, 0]
with self._dev_lock:
if self._dev is None:
@@ -262,17 +264,14 @@ class USBLEDRing:
_LOGGER.debug("LEDs [simulated]: %s", pixels)
return
try:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
self._CTRL_VALUE,
self._CTRL_INDEX,
frame,
self._CTRL_TIMEOUT,
)
if self._last_brightness != bright5:
self._write(self._CMD_BRIGHTNESS, [bright5])
self._last_brightness = bright5
self._write(self._CMD_SHOW, frame)
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
self._dev = None
self._last_brightness = None
# Attempt to re-find the device on next show() call
threading.Thread(
target=self._reconnect, daemon=True
@@ -709,11 +708,19 @@ class LVAClient:
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
muted = data.get("muted", False)
ha_connected = data.get("ha_connected", False)
self._state.update(
muted=data.get("muted", False),
muted=muted,
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
ha_connected=ha_connected,
)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif ha_connected:
self._state.update(assist_state=AssistState.IDLE)
else:
self._state.update(assist_state=AssistState.NOT_READY)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
@@ -731,7 +738,12 @@ class LVAClient:
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
self._state.update(assist_state=AssistState.MUTED, muted=True)
muted = data.get("muted", True)
self._state.update(muted=muted)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif self._state.assist_state == AssistState.MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
@@ -777,6 +789,12 @@ class LVAClient:
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
if self._state.assist_state == AssistState.NOT_READY:
self._state.update(
assist_state=(
AssistState.MUTED if self._state.muted else AssistState.IDLE
),
)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://app.kilo.ai/config.json",
"model": "kilo/codegemma-2b:free",
"instructions": ["AGENTS.md"]
}
+46 -3
View File
@@ -234,6 +234,11 @@ async def main() -> None:
action="store_true",
help="Add this to enable debug logging",
)
parser.add_argument(
"--colored-debug",
action="store_true",
help="Add this to enable colored debug logging",
)
parser.add_argument(
"--output-only",
action="store_true",
@@ -241,6 +246,15 @@ async def main() -> None:
)
args = parser.parse_args()
if args.colored_debug:
args.debug = True
_setup_logging(args)
elif args.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
_LOGGER.debug(args)
if args.list_input_devices:
print("Audio Input devices:")
print("=" * 13)
@@ -259,9 +273,6 @@ async def main() -> None:
print(speaker["name"] + ":", speaker["description"])
return
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
# Resolve network interface for mac-address detection
if not args.network_interface:
print("No network interface specified, try to detect default interface")
@@ -575,6 +586,38 @@ async def main() -> None:
_LOGGER.debug("Server stopped")
# -----------------------------------------------------------------------------
def _setup_logging(args: argparse.Namespace) -> None:
COLORS = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[35m",
}
RESET = "\033[0m"
original_format = logging.Formatter.format
def colored_format(self, record: logging.LogRecord) -> str:
color = COLORS.get(record.levelno, RESET)
return f"{color}{original_format(self, record)}{RESET}"
logging.Formatter.format = colored_format # type: ignore
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
)
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
handlers=[handler],
)
# -----------------------------------------------------------------------------
+83 -8
View File
@@ -99,6 +99,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
@@ -180,6 +181,8 @@ class PeripheralAPIServer:
"""
DEFAULT_VOLUME_STEP: float = 0.05
LATE_ENTITY_RECONNECT_DEBOUNCE_S: float = 1.5
LATE_ENTITY_RECONNECT_COOLDOWN_S: float = 60.0
def __init__(
self,
@@ -208,6 +211,10 @@ class PeripheralAPIServer:
self._current_state: Optional[LVAEvent] = None
self._current_state_data: Optional[Dict[str, Any]] = None
# Safeguarded HA reconnect trigger for late entity registrations.
self._pending_entity_reconnect_task: Optional[asyncio.Task] = None
self._last_ha_reconnect_at: float = 0.0
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@@ -230,6 +237,10 @@ class PeripheralAPIServer:
async def stop(self) -> None:
"""Gracefully shut down the server and all client connections."""
if self._pending_entity_reconnect_task is not None:
self._pending_entity_reconnect_task.cancel()
self._pending_entity_reconnect_task = None
if self._server is not None:
self._server.close()
await self._server.wait_closed()
@@ -281,14 +292,20 @@ class PeripheralAPIServer:
# Replay the current event state so the client immediately shows the
# right animation — e.g. a timer ticking animation when reconnecting
# mid-timer, or the muted indicator when reconnecting while muted.
if self._current_state is not None:
state_payload: Dict[str, Any] = {"event": self._current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
current_state = self._current_state
if current_state is None:
return
if current_state == LVAEvent.DISCONNECTED and state.connected:
return
state_payload: Dict[str, Any] = {"event": current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
# ------------------------------------------------------------------
# Command dispatch
@@ -501,6 +518,8 @@ class PeripheralAPIServer:
if satellite is not None:
satellite.register_pending_lights()
self._schedule_ha_reconnect_for_late_entity("light", object_id)
def _register_button(self, satellite: Any) -> None:
"""Register button press event support declared by a peripheral.
@@ -528,10 +547,66 @@ class PeripheralAPIServer:
if satellite is not None:
satellite.register_pending_button()
self._schedule_ha_reconnect_for_late_entity("button", "button_press_event")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _schedule_ha_reconnect_for_late_entity(self, entity_kind: str, entity_id: str) -> None:
"""Reconnect HA once for late peripheral entity registrations."""
state = self._state
if state is None or not state.connected or state.satellite is None:
return
if self._loop is None:
return
if self._pending_entity_reconnect_task is not None and not self._pending_entity_reconnect_task.done():
_LOGGER.debug(
"Coalescing late %s registration for '%s' into pending HA reconnect",
entity_kind,
entity_id,
)
return
async def _trigger() -> None:
try:
await asyncio.sleep(self.LATE_ENTITY_RECONNECT_DEBOUNCE_S)
state_now = self._state
if state_now is None or not state_now.connected:
return
satellite_now = state_now.satellite
if satellite_now is None:
return
now = time.monotonic()
if now - self._last_ha_reconnect_at < self.LATE_ENTITY_RECONNECT_COOLDOWN_S:
_LOGGER.info(
"Skipping HA reconnect after late %s '%s': cooldown active",
entity_kind,
entity_id,
)
return
transport = getattr(satellite_now, "_transport", None)
if transport is None:
return
self._last_ha_reconnect_at = now
_LOGGER.info(
"Late %s '%s' registered after HA handshake; forcing one HA reconnect",
entity_kind,
entity_id,
)
transport.close()
except asyncio.CancelledError:
return
self._pending_entity_reconnect_task = self._loop.create_task(_trigger())
async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
"""Reflect a peripheral-triggered mute change to Home Assistant."""
state = self._state
+1 -1
View File
@@ -507,7 +507,7 @@ class VoiceSatelliteProtocol(APIServer):
# ------------------------------------------------------------------
def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
_LOGGER.info("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
self._tts_url = data.get("url")
+2 -2
View File
@@ -22,7 +22,7 @@ classifiers = [
]
requires-python = ">=3.11.0"
dependencies = [
"aioesphomeapi==45.6.2",
"aioesphomeapi==45.3.1",
"netifaces2==0.0.22",
"soundcard<1",
"numpy>=2,<3",
@@ -32,7 +32,7 @@ dependencies = [
"zeroconf<1",
"getmac<1",
"types-protobuf",
"websockets==16.1.1",
"websockets==12",
"webrtc-noise-gain>=1,<2",
]
dynamic = ["version"]