Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51c0eb3aa | ||
|
|
1af2fa4333 | ||
|
|
3b18ce23b1 |
No files matched your search
@@ -8,9 +8,6 @@
|
|||||||
### 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"
|
||||||
@@ -67,21 +64,6 @@ 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@v7
|
uses: actions/checkout@v4
|
||||||
- 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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
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@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- 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@v7
|
uses: actions/checkout@v4
|
||||||
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
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@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- 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@v7
|
uses: actions/checkout@v4
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
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@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
- 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@v7
|
- uses: actions/checkout@v4
|
||||||
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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python 3.12
|
- name: Set up Python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
node_modules
|
|
||||||
package.json
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
bun.lock
|
|
||||||
yarn.lock
|
|
||||||
worktrees
|
|
||||||
agent-manager.json
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Run Command
|
|
||||||
|
|
||||||
Runs the Linux Voice Assistant application.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/run # Run the application
|
|
||||||
/run --help # Show CLI help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
./script/run
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Setup Command
|
|
||||||
|
|
||||||
Installs project dependencies in a virtual environment.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/setup # Install dependencies
|
|
||||||
/setup --dev # Install dev dependencies (linting, testing tools)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
./script/setup
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 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,7 +113,6 @@ 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,10 +9,6 @@ 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
|
||||||
@@ -128,7 +124,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" = "1" ]; then
|
if [ -n "${DISABLE_PERIPHERAL_API}" ]; 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/log/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/logs/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,9 +260,6 @@ 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,31 +54,11 @@ 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 LED firmware exposes a vendor-command USB
|
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
|
||||||
# interface. The copied pixel_ring reference uses command 0x06 for custom LED
|
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
|
||||||
# frames and command 0x20 for brightness, both addressed to wIndex 0x1C.
|
# bytes in the order [start_frame | brightness, B, G, R].
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
RGB = Tuple[int, int, int]
|
RGB = Tuple[int, int, int]
|
||||||
@@ -186,17 +186,20 @@ def _scale(color: RGB, factor: float) -> RGB:
|
|||||||
|
|
||||||
class USBLEDRing:
|
class USBLEDRing:
|
||||||
"""
|
"""
|
||||||
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
||||||
|
|
||||||
Uses the same vendor-command protocol as Seeed's pixel_ring library.
|
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
|
||||||
That protocol talks to the device recipient directly and does not require
|
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
|
||||||
detaching the kernel audio driver.
|
This is the same protocol used by Seeed's pixel_ring library.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CMD_SHOW = 0x06
|
# USB control transfer parameters matching the pixel_ring reference implementation.
|
||||||
_CMD_BRIGHTNESS = 0x20
|
# _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_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
|
||||||
|
|
||||||
@@ -204,7 +207,6 @@ 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()
|
||||||
@@ -218,8 +220,12 @@ 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,
|
||||||
@@ -231,32 +237,24 @@ 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.
|
||||||
|
|
||||||
The USB firmware expects command 0x06 with 4 bytes per LED in RGB0
|
Frame layout (matches pixel_ring / APA102 USB protocol):
|
||||||
order, plus command 0x20 for device brightness. Sending raw APA102
|
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
|
||||||
frames here causes the firmware to misinterpret the payload.
|
[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)))
|
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
|
||||||
|
|
||||||
frame: list[int] = []
|
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
|
||||||
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 += [br, bg, bb, 0]
|
frame += [0xE0 | bright5, bb, bg, br]
|
||||||
|
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:
|
||||||
@@ -264,14 +262,17 @@ class USBLEDRing:
|
|||||||
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if self._last_brightness != bright5:
|
self._dev.ctrl_transfer(
|
||||||
self._write(self._CMD_BRIGHTNESS, [bright5])
|
self._CTRL_TYPE_OUT,
|
||||||
self._last_brightness = bright5
|
self._CTRL_REQUEST,
|
||||||
self._write(self._CMD_SHOW, frame)
|
self._CTRL_VALUE,
|
||||||
|
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
|
||||||
@@ -708,19 +709,11 @@ 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=muted,
|
muted=data.get("muted", False),
|
||||||
volume=data.get("volume", 1.0),
|
volume=data.get("volume", 1.0),
|
||||||
ha_connected=ha_connected,
|
ha_connected=data.get("ha_connected", False),
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -738,12 +731,7 @@ class LVAClient:
|
|||||||
self._state.update(assist_state=AssistState.IDLE)
|
self._state.update(assist_state=AssistState.IDLE)
|
||||||
|
|
||||||
elif event == "muted":
|
elif event == "muted":
|
||||||
muted = data.get("muted", True)
|
self._state.update(assist_state=AssistState.MUTED, 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", ""))
|
||||||
@@ -789,12 +777,6 @@ 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 …")
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://app.kilo.ai/config.json",
|
|
||||||
"model": "kilo/codegemma-2b:free",
|
|
||||||
"instructions": ["AGENTS.md"]
|
|
||||||
}
|
|
||||||
@@ -234,11 +234,6 @@ 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",
|
||||||
@@ -246,15 +241,6 @@ 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)
|
||||||
@@ -273,6 +259,9 @@ 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")
|
||||||
@@ -586,38 +575,6 @@ 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,7 +99,6 @@ 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
|
||||||
|
|
||||||
@@ -181,8 +180,6 @@ 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,
|
||||||
@@ -211,10 +208,6 @@ 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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -237,10 +230,6 @@ 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()
|
||||||
@@ -292,20 +281,14 @@ 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.
|
||||||
current_state = self._current_state
|
if self._current_state is not None:
|
||||||
if current_state is None:
|
state_payload: Dict[str, Any] = {"event": self._current_state.value}
|
||||||
return
|
if self._current_state_data:
|
||||||
|
state_payload["data"] = self._current_state_data
|
||||||
if current_state == LVAEvent.DISCONNECTED and state.connected:
|
try:
|
||||||
return
|
await websocket.send(json.dumps(state_payload))
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
state_payload: Dict[str, Any] = {"event": current_state.value}
|
pass
|
||||||
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
|
||||||
@@ -518,8 +501,6 @@ 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.
|
||||||
|
|
||||||
@@ -547,66 +528,10 @@ 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.info("Voice event: type=%s, data=%s", event_type.name, data)
|
_LOGGER.debug("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.3.1",
|
"aioesphomeapi==45.6.2",
|
||||||
"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==12",
|
"websockets==16.1.1",
|
||||||
"webrtc-noise-gain>=1,<2",
|
"webrtc-noise-gain>=1,<2",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|||||||
Reference in new issue
Block a user