Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acda765964 | ||
|
|
3077e7230b | ||
|
|
ea991e026f | ||
|
|
69a4fd3098 | ||
|
|
126bdf1862 | ||
|
|
3b33730105 | ||
|
|
0f57ca989c | ||
|
|
ba7d834d1f | ||
|
|
2eb29470f9 | ||
|
|
2378187197 | ||
|
|
d3874f6a2f | ||
|
|
201896196f |
No files matched your search
@@ -8,6 +8,9 @@
|
|||||||
### Enable debug mode (optional):
|
### Enable debug mode (optional):
|
||||||
# ENABLE_DEBUG="1"
|
# ENABLE_DEBUG="1"
|
||||||
|
|
||||||
|
### Enable colored debug mode (optional):
|
||||||
|
# ENABLE_COLORED_DEBUG="1"
|
||||||
|
|
||||||
### List audio devices (optional):
|
### List audio devices (optional):
|
||||||
# if enabled normal startup is disabled
|
# if enabled normal startup is disabled
|
||||||
# LIST_DEVICES="1"
|
# 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
|
# Start listening immediately after wake word detection, without waiting for the wake sound to finish
|
||||||
# LISTEN_DURING_WAKE_SOUND="1"
|
# 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):
|
### Wake word directory (optional):
|
||||||
# path for custom files in docker is for example "app/wakewords/custom"
|
# path for custom files in docker is for example "app/wakewords/custom"
|
||||||
# WAKE_WORD_DIR="app/wakewords"
|
# WAKE_WORD_DIR="app/wakewords"
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
- name: Set lowercase registry and repository
|
- name: Set lowercase registry and repository
|
||||||
run: |
|
run: |
|
||||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Create version files
|
- name: Create version files
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout PR code
|
- name: Checkout PR code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
allow-unsafe-pr-checkout: true
|
allow-unsafe-pr-checkout: true
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Create version files
|
- name: Create version files
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Extract metadata for Docker
|
- name: Extract metadata for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
- uses: rhysd/changelog-from-release/action@v3
|
- uses: rhysd/changelog-from-release/action@v3
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
lint_black:
|
lint_black:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
lint_flake8:
|
lint_flake8:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
lint_isort:
|
lint_isort:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
lint_mypy:
|
lint_mypy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
lint_pylint:
|
lint_pylint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python 3.12
|
- name: Set up Python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
bun.lock
|
||||||
|
yarn.lock
|
||||||
|
worktrees
|
||||||
|
agent-manager.json
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Lint Command
|
||||||
|
|
||||||
|
Runs all linting checks for the project (black, isort, flake8, mypy, pylint).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/lint # Run all linting checks
|
||||||
|
/lint --auto # Auto-fix formatting issues (black, isort)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
./script/lint
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Run Command
|
||||||
|
|
||||||
|
Runs the Linux Voice Assistant application.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/run # Run the application
|
||||||
|
/run --help # Show CLI help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
./script/run
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Setup Command
|
||||||
|
|
||||||
|
Installs project dependencies in a virtual environment.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/setup # Install dependencies
|
||||||
|
/setup --dev # Install dev dependencies (linting, testing tools)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
./script/setup
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Test Command
|
||||||
|
|
||||||
|
Runs the pytest unit tests for the project.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/tests # Run all tests
|
||||||
|
/tests <path> # Run tests in specific path
|
||||||
|
/tests -k <pattern> # Run tests matching pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
./script/tests
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# PR Review Standards
|
||||||
|
|
||||||
|
## Review Philosophy
|
||||||
|
|
||||||
|
- Only comment when confident an issue exists
|
||||||
|
- Be concise: one sentence per comment when possible
|
||||||
|
- Focus on actionable feedback, not observations
|
||||||
|
- When reviewing text, only comment on clarity issues if the text is genuinely confusing or could lead to errors
|
||||||
|
|
||||||
|
## What to Analyze
|
||||||
|
|
||||||
|
Review all code changes for:
|
||||||
|
|
||||||
|
- Code quality and style consistency with the existing codebase
|
||||||
|
- Potential bugs or issues
|
||||||
|
- Performance implications
|
||||||
|
- Blocking IO in async code
|
||||||
|
- Security concerns
|
||||||
|
- Test coverage
|
||||||
|
- Documentation updates if needed
|
||||||
|
|
||||||
|
## Project Standards
|
||||||
|
|
||||||
|
Respect the project standards as outlined in AGENTS.md. Any deviations must be raised as `[PROBLEM]`.
|
||||||
|
|
||||||
|
Key standards for LVA:
|
||||||
|
- Python 3.11, 3.12, 3.13 supported
|
||||||
|
- Black formatting with 200 char line length
|
||||||
|
- isort import sorting with black profile
|
||||||
|
- mypy type checking with strict settings
|
||||||
|
- pylint code quality checks (many checks disabled in pyproject.toml)
|
||||||
|
|
||||||
|
## Architecture-Specific Checks
|
||||||
|
|
||||||
|
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent, verify no assumptions about specific hardware
|
||||||
|
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`, check for proper error handling
|
||||||
|
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms need careful review for audio quality
|
||||||
|
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling must be robust
|
||||||
|
|
||||||
|
## CI Context
|
||||||
|
|
||||||
|
Lint runs on Python 3.13, tests run on Python 3.12 with libmpv-dev.
|
||||||
|
|
||||||
|
### What CI Checks
|
||||||
|
|
||||||
|
- **Lint**: `.github/workflows/lint.yml` runs all linting checks
|
||||||
|
- **Tests**: `.github/workflows/tests.yml` runs pytest unit tests
|
||||||
|
|
||||||
|
## Skip These (Low Value)
|
||||||
|
|
||||||
|
Do not comment on:
|
||||||
|
|
||||||
|
- Style/formatting (pre-commit handles this)
|
||||||
|
- Test failures (CI catches this)
|
||||||
|
- Missing dependencies (CI catches this)
|
||||||
|
- Minor naming suggestions
|
||||||
|
- Suggestions to add comments
|
||||||
|
- Logging suggestions unless security-related
|
||||||
|
|
||||||
|
## Issue Categories
|
||||||
|
|
||||||
|
Categorize every issue as:
|
||||||
|
|
||||||
|
- `[CRITICAL]` — must fix before merging (bugs, security issues, broken functionality)
|
||||||
|
- `[PROBLEM]` — should fix (code quality, bad patterns, missing tests)
|
||||||
|
- `[SUGGESTION]` — optional improvement (style, minor refactors, nice-to-haves)
|
||||||
|
|
||||||
|
## PR Title
|
||||||
|
|
||||||
|
The PR title must be a functional description of the change. It must NOT contain conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `chore:`, etc. Labels categorize PRs, not the title. Flag as `[PROBLEM]` if the title uses such prefixes.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: review-pr
|
||||||
|
description: Use when asked to review a GitHub pull request, PR link is shared, or user says /review-pr
|
||||||
|
|
||||||
|
# Review GitHub Pull Request
|
||||||
|
|
||||||
|
Review the GitHub pull request: $ARGUMENTS.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Run `gh pr view` to get PR details and description.
|
||||||
|
2. Run `gh pr diff` to see all changes.
|
||||||
|
3. Compare the local commit hash with the remote PR head commit.
|
||||||
|
- Make sure to do your work in a git worktree to avoid affecting the checked out branch.
|
||||||
|
- If they don't match, run `gh pr checkout` to check out the PR locally.
|
||||||
|
- **HARD STOP**: If `gh pr checkout` fails for ANY reason, STOP immediately. Do NOT attempt workarounds. Alert about the failure and wait for instructions.
|
||||||
|
- After checkout, verify the local commit hash matches the remote one. If they still don't match, STOP.
|
||||||
|
4. Run `gh pr checks` to see CI status.
|
||||||
|
5. Apply the review standards defined in `REVIEW_STANDARDS.md` (same directory).
|
||||||
|
6. Output review feedback to the CONSOLE only. DO NOT act on GitHub (no posting comments, no approving, no requesting changes).
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
List specific comments per file/line that need attention. Do not list things that are already correct.
|
||||||
|
|
||||||
|
Each comment:
|
||||||
|
|
||||||
|
1. State the severity ([CRITICAL], [PROBLEM], [SUGGESTION])
|
||||||
|
2. State the problem (1 sentence)
|
||||||
|
3. Why it matters (1 sentence, if needed)
|
||||||
|
4. Suggested fix (snippet or specific action)
|
||||||
|
|
||||||
|
End with an overall assessment and summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
Overall assessment: <approve | request changes | comment>
|
||||||
|
- [CRITICAL] <issue>
|
||||||
|
- [PROBLEM] <issue>
|
||||||
|
- [SUGGESTION] <improvement>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- DO NOT make any changes to the code.
|
||||||
|
- No need to run tests or linters, just review the code changes.
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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 |
|
| `--peripheral-volume-step` | Volume change per button press, 0.0–1.0 | %(default)s |
|
||||||
| `--disable-peripheral-api` | Disable the peripheral WebSocket API entirely | False |
|
| `--disable-peripheral-api` | Disable the peripheral WebSocket API entirely | False |
|
||||||
| `--debug` | Print DEBUG messages to console | False |
|
| `--debug` | Print DEBUG messages to console | False |
|
||||||
|
| `--colored-debug` | Print colored DEBUG messages to console | False |
|
||||||
| `--output-only` | Enable output only mode | False |
|
| `--output-only` | Enable output only mode | False |
|
||||||
|
|
||||||
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ if [ "$ENABLE_DEBUG" = "1" ]; then
|
|||||||
EXTRA_ARGS+=( "--debug" )
|
EXTRA_ARGS+=( "--debug" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$ENABLE_COLORED_DEBUG" = "1" ]; then
|
||||||
|
EXTRA_ARGS+=( "--colored-debug" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "${CLIENT_NAME}" ]; then
|
if [ -n "${CLIENT_NAME}" ]; then
|
||||||
EXTRA_ARGS+=( "--name" "$CLIENT_NAME" )
|
EXTRA_ARGS+=( "--name" "$CLIENT_NAME" )
|
||||||
fi
|
fi
|
||||||
@@ -124,7 +128,7 @@ if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
|
|||||||
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
|
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "${DISABLE_PERIPHERAL_API}" ]; then
|
if [ -n "$DISABLE_PERIPHERAL_API" = "1" ]; then
|
||||||
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
|
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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)
|
- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose)
|
||||||
- Change configuration in /compose/ directory if you need to.
|
- 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.
|
- More information within the PiCompose project.
|
||||||
|
|
||||||
### Option B) Install on your own:
|
### Option B) Install on your own:
|
||||||
|
|||||||
@@ -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_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_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 |
|
| `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 |
|
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
|
||||||
| `NETWORK_INTERFACE` | Autodetected | network card for server |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -54,11 +54,31 @@ sudo mkdir -p /etc/pipewire/pipewire.conf.d
|
|||||||
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
|
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:
|
Add the following content:
|
||||||
```
|
```
|
||||||
context.properties = {
|
context.properties = {
|
||||||
default.clock.rate = 16000
|
default.clock.rate = 16000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.modules = [
|
||||||
|
{ name = libpipewire-module-rt
|
||||||
|
args = {
|
||||||
|
nice.level = -11
|
||||||
|
rt.prio = 88
|
||||||
|
}
|
||||||
|
flags = [ ifexists nofail ]
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Applying the Changes
|
#### Applying the Changes
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ class SharedState:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# USB LED driver
|
# USB LED driver
|
||||||
#
|
#
|
||||||
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
|
# The ReSpeaker Mic Array v2.0 LED firmware exposes a vendor-command USB
|
||||||
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
|
# interface. The copied pixel_ring reference uses command 0x06 for custom LED
|
||||||
# bytes in the order [start_frame | brightness, B, G, R].
|
# frames and command 0x20 for brightness, both addressed to wIndex 0x1C.
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
RGB = Tuple[int, int, int]
|
RGB = Tuple[int, int, int]
|
||||||
@@ -186,20 +186,17 @@ def _scale(color: RGB, factor: float) -> RGB:
|
|||||||
|
|
||||||
class USBLEDRing:
|
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,
|
Uses the same vendor-command protocol as Seeed's pixel_ring library.
|
||||||
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
|
That protocol talks to the device recipient directly and does not require
|
||||||
This is the same protocol used by Seeed's pixel_ring library.
|
detaching the kernel audio driver.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# USB control transfer parameters matching the pixel_ring reference implementation.
|
_CMD_SHOW = 0x06
|
||||||
# _CTRL_TYPE_OUT = 0x40: bmRequestType = CTRL_OUT | CTRL_TYPE_VENDOR | CTRL_RECIPIENT_DEVICE.
|
_CMD_BRIGHTNESS = 0x20
|
||||||
# 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_TIMEOUT = 8000 # ms
|
||||||
_CTRL_REQUEST = 0
|
_CTRL_REQUEST = 0
|
||||||
_CTRL_VALUE = 0x0001
|
|
||||||
_CTRL_INDEX = 0x1C
|
_CTRL_INDEX = 0x1C
|
||||||
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
|
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
|
||||||
|
|
||||||
@@ -207,6 +204,7 @@ class USBLEDRing:
|
|||||||
self._pixels: list[RGB] = [BLACK] * LED_COUNT
|
self._pixels: list[RGB] = [BLACK] * LED_COUNT
|
||||||
self._dev = None
|
self._dev = None
|
||||||
self._dev_lock = threading.Lock()
|
self._dev_lock = threading.Lock()
|
||||||
|
self._last_brightness: Optional[int] = None
|
||||||
|
|
||||||
if _HAS_USB:
|
if _HAS_USB:
|
||||||
self._find_device()
|
self._find_device()
|
||||||
@@ -220,12 +218,8 @@ class USBLEDRing:
|
|||||||
USB_VENDOR_ID, USB_PRODUCT_ID,
|
USB_VENDOR_ID, USB_PRODUCT_ID,
|
||||||
)
|
)
|
||||||
return
|
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._dev = dev
|
||||||
|
self._last_brightness = None
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
|
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
|
||||||
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
|
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
|
||||||
@@ -237,24 +231,32 @@ class USBLEDRing:
|
|||||||
def set_all(self, color: RGB) -> None:
|
def set_all(self, color: RGB) -> None:
|
||||||
self._pixels = [color] * LED_COUNT
|
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:
|
def show(self, brightness: float = 1.0) -> None:
|
||||||
"""
|
"""
|
||||||
Push the current pixel buffer to the device.
|
Push the current pixel buffer to the device.
|
||||||
|
|
||||||
Frame layout (matches pixel_ring / APA102 USB protocol):
|
The USB firmware expects command 0x06 with 4 bytes per LED in RGB0
|
||||||
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
|
order, plus command 0x20 for device brightness. Sending raw APA102
|
||||||
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
|
frames here causes the firmware to misinterpret the payload.
|
||||||
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
|
|
||||||
"""
|
"""
|
||||||
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
|
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:
|
for r, g, b in self._pixels:
|
||||||
br = max(0, min(255, int(r * brightness)))
|
br = max(0, min(255, int(r * brightness)))
|
||||||
bg = max(0, min(255, int(g * brightness)))
|
bg = max(0, min(255, int(g * brightness)))
|
||||||
bb = max(0, min(255, int(b * brightness)))
|
bb = max(0, min(255, int(b * brightness)))
|
||||||
frame += [0xE0 | bright5, bb, bg, br]
|
frame += [br, bg, bb, 0]
|
||||||
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
|
|
||||||
|
|
||||||
with self._dev_lock:
|
with self._dev_lock:
|
||||||
if self._dev is None:
|
if self._dev is None:
|
||||||
@@ -262,17 +264,14 @@ class USBLEDRing:
|
|||||||
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._dev.ctrl_transfer(
|
if self._last_brightness != bright5:
|
||||||
self._CTRL_TYPE_OUT,
|
self._write(self._CMD_BRIGHTNESS, [bright5])
|
||||||
self._CTRL_REQUEST,
|
self._last_brightness = bright5
|
||||||
self._CTRL_VALUE,
|
self._write(self._CMD_SHOW, frame)
|
||||||
self._CTRL_INDEX,
|
|
||||||
frame,
|
|
||||||
self._CTRL_TIMEOUT,
|
|
||||||
)
|
|
||||||
except usb.core.USBError as exc:
|
except usb.core.USBError as exc:
|
||||||
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
|
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
|
||||||
self._dev = None
|
self._dev = None
|
||||||
|
self._last_brightness = None
|
||||||
# Attempt to re-find the device on next show() call
|
# Attempt to re-find the device on next show() call
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._reconnect, daemon=True
|
target=self._reconnect, daemon=True
|
||||||
@@ -709,11 +708,19 @@ class LVAClient:
|
|||||||
_LOGGER.debug("Event: %s data=%s", event, data)
|
_LOGGER.debug("Event: %s data=%s", event, data)
|
||||||
|
|
||||||
if event == "snapshot":
|
if event == "snapshot":
|
||||||
|
muted = data.get("muted", False)
|
||||||
|
ha_connected = data.get("ha_connected", False)
|
||||||
self._state.update(
|
self._state.update(
|
||||||
muted=data.get("muted", False),
|
muted=muted,
|
||||||
volume=data.get("volume", 1.0),
|
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":
|
elif event == "wake_word_detected":
|
||||||
self._state.update(assist_state=AssistState.WAKE_WORD)
|
self._state.update(assist_state=AssistState.WAKE_WORD)
|
||||||
@@ -731,7 +738,12 @@ class LVAClient:
|
|||||||
self._state.update(assist_state=AssistState.IDLE)
|
self._state.update(assist_state=AssistState.IDLE)
|
||||||
|
|
||||||
elif event == "muted":
|
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":
|
elif event == "pipeline_error":
|
||||||
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
|
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
|
||||||
@@ -777,6 +789,12 @@ class LVAClient:
|
|||||||
status = data.get("status", "")
|
status = data.get("status", "")
|
||||||
if status == "connected":
|
if status == "connected":
|
||||||
self._state.update(ha_connected=True)
|
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")
|
_LOGGER.info("Home Assistant connected")
|
||||||
elif status == "getting_started":
|
elif status == "getting_started":
|
||||||
_LOGGER.info("LVA starting up, waiting for HA …")
|
_LOGGER.info("LVA starting up, waiting for HA …")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://app.kilo.ai/config.json",
|
||||||
|
"model": "kilo/codegemma-2b:free",
|
||||||
|
"instructions": ["AGENTS.md"]
|
||||||
|
}
|
||||||
@@ -234,6 +234,11 @@ async def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Add this to enable debug logging",
|
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(
|
parser.add_argument(
|
||||||
"--output-only",
|
"--output-only",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -241,6 +246,15 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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:
|
if args.list_input_devices:
|
||||||
print("Audio Input devices:")
|
print("Audio Input devices:")
|
||||||
print("=" * 13)
|
print("=" * 13)
|
||||||
@@ -259,9 +273,6 @@ async def main() -> None:
|
|||||||
print(speaker["name"] + ":", speaker["description"])
|
print(speaker["name"] + ":", speaker["description"])
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
|
|
||||||
# Resolve network interface for mac-address detection
|
# Resolve network interface for mac-address detection
|
||||||
if not args.network_interface:
|
if not args.network_interface:
|
||||||
print("No network interface specified, try to detect default interface")
|
print("No network interface specified, try to detect default interface")
|
||||||
@@ -575,6 +586,38 @@ async def main() -> None:
|
|||||||
_LOGGER.debug("Server stopped")
|
_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],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
|
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
|
||||||
|
|
||||||
@@ -180,6 +181,8 @@ class PeripheralAPIServer:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_VOLUME_STEP: float = 0.05
|
DEFAULT_VOLUME_STEP: float = 0.05
|
||||||
|
LATE_ENTITY_RECONNECT_DEBOUNCE_S: float = 1.5
|
||||||
|
LATE_ENTITY_RECONNECT_COOLDOWN_S: float = 60.0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -208,6 +211,10 @@ class PeripheralAPIServer:
|
|||||||
self._current_state: Optional[LVAEvent] = None
|
self._current_state: Optional[LVAEvent] = None
|
||||||
self._current_state_data: Optional[Dict[str, Any]] = 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
|
# Lifecycle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -230,6 +237,10 @@ class PeripheralAPIServer:
|
|||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Gracefully shut down the server and all client connections."""
|
"""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:
|
if self._server is not None:
|
||||||
self._server.close()
|
self._server.close()
|
||||||
await self._server.wait_closed()
|
await self._server.wait_closed()
|
||||||
@@ -281,14 +292,20 @@ class PeripheralAPIServer:
|
|||||||
# Replay the current event state so the client immediately shows the
|
# Replay the current event state so the client immediately shows the
|
||||||
# right animation — e.g. a timer ticking animation when reconnecting
|
# right animation — e.g. a timer ticking animation when reconnecting
|
||||||
# mid-timer, or the muted indicator when reconnecting while muted.
|
# mid-timer, or the muted indicator when reconnecting while muted.
|
||||||
if self._current_state is not None:
|
current_state = self._current_state
|
||||||
state_payload: Dict[str, Any] = {"event": self._current_state.value}
|
if current_state is None:
|
||||||
if self._current_state_data:
|
return
|
||||||
state_payload["data"] = self._current_state_data
|
|
||||||
try:
|
if current_state == LVAEvent.DISCONNECTED and state.connected:
|
||||||
await websocket.send(json.dumps(state_payload))
|
return
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
pass
|
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
|
# Command dispatch
|
||||||
@@ -501,6 +518,8 @@ class PeripheralAPIServer:
|
|||||||
if satellite is not None:
|
if satellite is not None:
|
||||||
satellite.register_pending_lights()
|
satellite.register_pending_lights()
|
||||||
|
|
||||||
|
self._schedule_ha_reconnect_for_late_entity("light", object_id)
|
||||||
|
|
||||||
def _register_button(self, satellite: Any) -> None:
|
def _register_button(self, satellite: Any) -> None:
|
||||||
"""Register button press event support declared by a peripheral.
|
"""Register button press event support declared by a peripheral.
|
||||||
|
|
||||||
@@ -528,10 +547,66 @@ class PeripheralAPIServer:
|
|||||||
if satellite is not None:
|
if satellite is not None:
|
||||||
satellite.register_pending_button()
|
satellite.register_pending_button()
|
||||||
|
|
||||||
|
self._schedule_ha_reconnect_for_late_entity("button", "button_press_event")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# 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:
|
async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
|
||||||
"""Reflect a peripheral-triggered mute change to Home Assistant."""
|
"""Reflect a peripheral-triggered mute change to Home Assistant."""
|
||||||
state = self._state
|
state = self._state
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
|
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:
|
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||||
self._tts_url = data.get("url")
|
self._tts_url = data.get("url")
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
requires-python = ">=3.11.0"
|
requires-python = ">=3.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aioesphomeapi==45.6.2",
|
"aioesphomeapi==45.3.1",
|
||||||
"netifaces2==0.0.22",
|
"netifaces2==0.0.22",
|
||||||
"soundcard<1",
|
"soundcard<1",
|
||||||
"numpy>=2,<3",
|
"numpy>=2,<3",
|
||||||
@@ -32,7 +32,7 @@ dependencies = [
|
|||||||
"zeroconf<1",
|
"zeroconf<1",
|
||||||
"getmac<1",
|
"getmac<1",
|
||||||
"types-protobuf",
|
"types-protobuf",
|
||||||
"websockets==16.1.1",
|
"websockets==12",
|
||||||
"webrtc-noise-gain>=1,<2",
|
"webrtc-noise-gain>=1,<2",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|||||||
Reference in new issue
Block a user