Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66af4d1200 | ||
|
|
4cceda3ed6 |
Executable → Regular
+169
-27
@@ -1,34 +1,176 @@
|
|||||||
.DS_Store
|
# ---> Python
|
||||||
.idea
|
# Byte-compiled / optimized / DLL files
|
||||||
*.log
|
|
||||||
tmp/
|
|
||||||
|
|
||||||
*.py[cod]
|
|
||||||
*.egg
|
|
||||||
*.egg-info/
|
|
||||||
build
|
|
||||||
htmlcov
|
|
||||||
|
|
||||||
.venv/
|
|
||||||
.mypy_cache/
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
/dist/
|
# C extensions
|
||||||
/local/
|
*.so
|
||||||
|
|
||||||
# LED brightness config (auto-generated, user-specific)
|
# Distribution / packaging
|
||||||
examples/led_brightness_config.json
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
# Qwen Code settings
|
# PyInstaller
|
||||||
.qwen/settings.json.orig
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
# Backup files
|
# Installer logs
|
||||||
*.old
|
pip-log.txt
|
||||||
*.bak
|
pip-delete-this-directory.txt
|
||||||
*~
|
|
||||||
|
|
||||||
# Archives
|
# Unit test / coverage reports
|
||||||
*.zip
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
# Empty files
|
|
||||||
*.lgd-*
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[settings]
|
|
||||||
multi_line_output=3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
use_parentheses=True
|
|
||||||
line_length=88
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Инструкции по генерации файлов
|
|
||||||
- Всегда пиши файлы README.md, CHANGELOG.md и сопутствующую документацию на русском языке.
|
|
||||||
- Комментарии в коде также должны быть на русском языке.
|
|
||||||
- Используй технический, но понятный стиль.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(/home/sirmrak/projects/.venv_satellite/bin/pip *)",
|
|
||||||
"Bash(find *)",
|
|
||||||
"Bash(ls *)",
|
|
||||||
"Bash(git commit *)",
|
|
||||||
"Bash(git push)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"$version": 4
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 1.4.1
|
|
||||||
|
|
||||||
- Send `played` event to server
|
|
||||||
|
|
||||||
## 1.4.0
|
|
||||||
|
|
||||||
- Migrate to pyproject.toml
|
|
||||||
|
|
||||||
## 1.3.1
|
|
||||||
|
|
||||||
- Send audio-start to wake service after detect
|
|
||||||
|
|
||||||
## 1.3.0
|
|
||||||
|
|
||||||
- Bump to wyoming 1.5.4 (timers)
|
|
||||||
- Add support for voice timers
|
|
||||||
- Add `--timer-finished-wav` and `--timer-finished-wav-repeat`
|
|
||||||
- Add `--timer-started-command`
|
|
||||||
- Add `--timer-updated-command`
|
|
||||||
- Add `--timer-cancelled-command`
|
|
||||||
- Add `--timer-finished-command`
|
|
||||||
|
|
||||||
## 1.2.0
|
|
||||||
|
|
||||||
- Add `--tts-played-command`
|
|
||||||
- Add `--mic-seconds-to-mute-after-awake-wav` and `--mic-no-mute-during-awake-wav`
|
|
||||||
- Send preferred sound format to server
|
|
||||||
|
|
||||||
## 1.1.1
|
|
||||||
|
|
||||||
- Bump to wyoming 1.5.2 (package fix)
|
|
||||||
|
|
||||||
## 1.1.0
|
|
||||||
|
|
||||||
- Bump to wyoming 1.5.1
|
|
||||||
- Send wyoming-satellite version in `info` message
|
|
||||||
- Ping server if supported (faster awareness of disconnection)
|
|
||||||
- Support `pause-satellite` message
|
|
||||||
- Stop streaming/wake word detection as soon as `pause-satellite` is received or server disconnects
|
|
||||||
- Mute microphone when awake WAV is playing
|
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
- Initial release
|
|
||||||
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Руководство по развёртыванию
|
|
||||||
|
|
||||||
Быстрый старт для развёртывания Wyoming Satellite.
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
### 1. Клонирование и установка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url> ~/projects/Satellite
|
|
||||||
cd ~/projects/Satellite
|
|
||||||
|
|
||||||
# Создание виртуального окружения
|
|
||||||
python3 -m venv ~/.venv_satellite
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
|
|
||||||
# Установка зависимостей
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Установка пакета в режиме разработки (для доступа к wyoming_satellite.xvf_usb)
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Установка systemd сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install_systemd_services.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Важно:** После установки пакета в режиме editable (`pip install -e .`), новый модуль `wyoming_satellite.xvf_usb` будет автоматически доступен. Если вы добавляете новые модули в `wyoming_satellite/`, убедитесь, что они видны:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка доступности модуля
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; print('OK')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запуск сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# LED управление (опционально, требуется ReSpeaker)
|
|
||||||
sudo systemctl enable --now wyoming-event.service
|
|
||||||
|
|
||||||
# Wyoming Satellite
|
|
||||||
sudo systemctl enable --now wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
# Детекция wake word (Vosk)
|
|
||||||
sudo systemctl enable --now wyoming-wakeword-vosk.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Проверка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status wyoming-satelite-vosk.service
|
|
||||||
journalctl -u wyoming-satelite-vosk.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
В Home Assistant: **Настройки → Устройства и сервисы → Найдено → Wyoming Satellite → Настроить**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полная документация
|
|
||||||
|
|
||||||
| Руководство | Описание |
|
|
||||||
|-------------|----------|
|
|
||||||
| [**systemd сервисы**](docs/deployment/systemd-services.md) | Детальная настройка сервисов |
|
|
||||||
| [**Tutorials**](docs/tutorials/) | Пошаговые руководства для разного оборудования |
|
|
||||||
| [**Архитектура**](docs/architecture/overview.md) | Как работает Wyoming Satellite |
|
|
||||||
| [**MQTT LED**](docs/integration/mqtt-led.md) | LED управление с Home Assistant |
|
|
||||||
| [**Настройки**](docs/settings-cheatsheet.md) | Шпаргалка по всем CLI флагам |
|
|
||||||
|
|
||||||
### Tutorials
|
|
||||||
|
|
||||||
- [**2-MIC HAT**](docs/tutorials/2mic-hat.md) — Raspberry Pi + ReSpeaker 2-MIC HAT
|
|
||||||
- [**Установщик**](docs/tutorials/installer.md) — Руководство по интерактивному установщику
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- **Python 3.9+**
|
|
||||||
- **Linux** (протестировано на Raspberry Pi OS)
|
|
||||||
- **Микрофон** (USB или HAT)
|
|
||||||
- **Опционально:** ReSpeaker XVF3800 USB 4-MIC Array (для LED управления)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обзор сервисов
|
|
||||||
|
|
||||||
| Сервис | Порт | Описание |
|
|
||||||
|--------|------|----------|
|
|
||||||
| `wyoming-satelite-vosk.service` | 10700 | Основной Wyoming спутник |
|
|
||||||
| `wyoming-wakeword-vosk.service` | 10400 | Детекция wake word (Vosk) |
|
|
||||||
| `wyoming-event.service` | 10500 | LED управление + MQTT события |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## LED управление (ReSpeaker XVF3800)
|
|
||||||
|
|
||||||
Сервис `wyoming-event.service` использует прямой USB доступ к XVF3800 через модуль `wyoming_satellite.xvf_usb`.
|
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- **ReSpeaker XVF3800 USB 4-MIC Array** (VID=0x2886, PID=0x0018 или 0x001A)
|
|
||||||
- **udev правила** для доступа к USB:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
|
|
||||||
sudo udevadm trigger
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка подключения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка USB устройства
|
|
||||||
lsusb | grep -i 2886
|
|
||||||
|
|
||||||
# Тест контроллера
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; c = XVF3800Controller(); print('OK' if c.is_connected() else 'Not found')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройка яркости
|
|
||||||
|
|
||||||
Яркость для каждого состояния настраивается в `examples/led_brightness_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"idle": 80,
|
|
||||||
"listening": 120,
|
|
||||||
"wake": 200,
|
|
||||||
"thinking": 150,
|
|
||||||
"speaking": 140,
|
|
||||||
"paused": 40,
|
|
||||||
"error": 255,
|
|
||||||
"global_multiplier": 1.0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Файл создаётся автоматически при первом запуске. Исключите его из git tracking:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git update-index --assume-unchanged examples/led_brightness_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Диагностика LED сервиса
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Статус сервиса
|
|
||||||
sudo systemctl status wyoming-event.service
|
|
||||||
|
|
||||||
# Логи
|
|
||||||
journalctl -u wyoming-event.service -f
|
|
||||||
|
|
||||||
# Тест вручную
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
cd ~/projects/Satellite/examples
|
|
||||||
python3 wyoming-led.py --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Преимущества прямого USB доступа
|
|
||||||
|
|
||||||
Начиная с версии 2026.05, `wyoming-led.py` использует прямой USB доступ вместо subprocess вызовов:
|
|
||||||
|
|
||||||
- ✅ **Быстрее:** ~1ms на команду (нет накладных расходов subprocess)
|
|
||||||
- ✅ **Надёжнее:** контроль USB подключения с авто-переподключением
|
|
||||||
- ✅ **Чище:** нет процессов subprocess и временных файлов
|
|
||||||
|
|
||||||
Подробности в [документации MQTT LED](docs/integration/mqtt-led.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
Файлы сервисов используют плейсхолдеры, которые автоматически заменяются при установке:
|
|
||||||
|
|
||||||
| Плейсхолдер | Описание |
|
|
||||||
|-------------|----------|
|
|
||||||
| `__SAT_BASE_DIR__` | Путь к репозиторию Satellite |
|
|
||||||
| `__SAT_VENV__` | Путь к виртуальному окружению |
|
|
||||||
| `__CURRENT_USER__` | Имя текущего пользователя |
|
|
||||||
| `__HOME_DIR__` | Домашняя директория пользователя |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wake words (русские)
|
|
||||||
|
|
||||||
Отредактируйте `start_satellite.sh`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--wake-word-name "привет дроид"
|
|
||||||
--wake-word-name "привет придурок"
|
|
||||||
--wake-word-name "привет жопа с ушами"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Внешние ресурсы
|
|
||||||
|
|
||||||
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
|
|
||||||
- [Home Assistant Integration](https://www.home-assistant.io/integrations/wyoming)
|
|
||||||
- [Vosk](https://alphacephei.com/vosk/)
|
|
||||||
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2023 Michael Hansen
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# VoskWyoming Satellite — Project Context
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**VoskWyoming Satellite** is a remote voice satellite implementation using the [Wyoming protocol](https://github.com/rhasspy/wyoming) for Home Assistant voice control. It provides:
|
|
||||||
|
|
||||||
- **Home Assistant integration** via Wyoming protocol
|
|
||||||
- **Local wake word detection** using Vosk (Russian wake words: "привет дроид", etc.)
|
|
||||||
- **Audio enhancements** via WebRTC noise suppression and gain control
|
|
||||||
- **MQTT integration** for LED control (ReSpeaker XVF3800 4-MIC array) and Home Assistant discovery
|
|
||||||
- **Voice Activity Detection (VAD)** using Silero or WebRTC
|
|
||||||
|
|
||||||
**Key technologies:** Python 3.9+, Wyoming protocol, Vosk, WebRTC, MQTT, systemd
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building and Running
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get install python3-venv python3-pip alsa-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone and setup
|
|
||||||
cd ~/projects/Satellite
|
|
||||||
python3 -m venv ~/.venv_satellite
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run (Development)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From project root
|
|
||||||
./start_satellite.sh # Uses systemd-style config
|
|
||||||
./start-dev.sh # Development mode
|
|
||||||
python3 -m wyoming_satellite # Direct module run
|
|
||||||
```
|
|
||||||
|
|
||||||
**Main script:** `wyoming_satellite/__main__.py` — argparse-based CLI with extensive options for mic, sound, wake word, VAD, and event handling.
|
|
||||||
|
|
||||||
### Run (Production — systemd)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install_systemd_services.sh # Auto-installs services
|
|
||||||
|
|
||||||
# Start services
|
|
||||||
sudo systemctl enable --now wyoming-event.service
|
|
||||||
sudo systemctl enable --now wyoming-satelite-vosk.service
|
|
||||||
sudo systemctl enable --now wyoming-wakeword-vosk.service
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
journalctl -u wyoming-satelite-vosk.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Services
|
|
||||||
|
|
||||||
| Service | Port | Description |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| `wyoming-satelite-vosk.service` | 10700 | Main Wyoming satellite |
|
|
||||||
| `wyoming-wakeword-vosk.service` | 10400 | Vosk wake word detection |
|
|
||||||
| `wyoming-event.service` | 10500 | LED control + MQTT events |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Linting
|
|
||||||
pylint wyoming_satellite/
|
|
||||||
mypy wyoming_satellite/
|
|
||||||
|
|
||||||
# Formatting
|
|
||||||
black wyoming_satellite/
|
|
||||||
isort wyoming_satellite/
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
pytest tests/
|
|
||||||
tox # Multi-version testing
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config files:**
|
|
||||||
- `pylintrc` — Pylint with many disabled checks (pragmatic approach)
|
|
||||||
- `mypy.ini` — Permissive, ignores missing imports
|
|
||||||
- `.isort.cfg` — Import sorting
|
|
||||||
- `tox.ini` — pytest-based test runner for Python 3.9–3.13
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Satellite/
|
|
||||||
├── wyoming_satellite/ # Main package
|
|
||||||
│ ├── __main__.py # CLI entry point
|
|
||||||
│ ├── satellite.py # Satellite implementations
|
|
||||||
│ ├── settings.py # Dataclass-based settings
|
|
||||||
│ ├── event_handler.py # Wyoming event handling
|
|
||||||
│ ├── vad.py # Voice activity detection
|
|
||||||
│ └── webrtc.py # WebRTC audio processing
|
|
||||||
├── services/ # systemd service files
|
|
||||||
├── examples/ # Example scripts (LED, MQTT)
|
|
||||||
├── scripts/ # Helper scripts
|
|
||||||
├── tests/ # pytest tests
|
|
||||||
├── sounds/ # Audio files (awake.wav, etc.)
|
|
||||||
├── start_satellite.sh # Main startup script
|
|
||||||
├── install_systemd_services.sh # Service installer
|
|
||||||
└── requirements.txt # Dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `wyoming_satellite/__main__.py` | Main entry point, CLI parsing |
|
|
||||||
| `wyoming_satellite/satellite.py` | `SatelliteBase`, `AlwaysStreamingSatellite`, `VadStreamingSatellite`, `WakeStreamingSatellite` |
|
|
||||||
| `wyoming_satellite/settings.py` | Settings dataclasses (`MicSettings`, `SndSettings`, `VadSettings`, `WakeSettings`, etc.) |
|
|
||||||
| `start_satellite.sh` | Production startup script (wake/vad modes) |
|
|
||||||
| `services/*.service` | systemd unit files with placeholders |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Wake Words (Russian)
|
|
||||||
|
|
||||||
Configured in `start_satellite.sh`:
|
|
||||||
```bash
|
|
||||||
--wake-word-name "привет дроид"
|
|
||||||
--wake-word-name "привет придурок"
|
|
||||||
--wake-word-name "привет жопа с ушами"
|
|
||||||
```
|
|
||||||
|
|
||||||
### VAD Settings
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# local/vosk-wake-dev/wyoming_vosk_wake/config.yaml
|
|
||||||
vad:
|
|
||||||
vad_enabled: true
|
|
||||||
energy_threshold: 800
|
|
||||||
speech_threshold: 0.5
|
|
||||||
min_speech_duration_ms: 200
|
|
||||||
```
|
|
||||||
|
|
||||||
### MQTT (LED Control)
|
|
||||||
|
|
||||||
Topics in `examples/wyoming-led.py`:
|
|
||||||
- `wyoming/led/command`
|
|
||||||
- `wyoming/led/brightness/set`
|
|
||||||
- `wyoming/led/brightness/state`
|
|
||||||
- `wyoming/led/state`
|
|
||||||
- `wyoming/led/availability`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Practices
|
|
||||||
|
|
||||||
- **Framework:** pytest + pytest-asyncio
|
|
||||||
- **Test files:** `tests/test_satellite.py`, `tests/test_wake_streaming.py`
|
|
||||||
- **Shared fixtures:** `tests/shared.py`
|
|
||||||
- **Multi-version:** tox runs tests on Python 3.9–3.13
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Notes
|
|
||||||
|
|
||||||
- **Virtual env location:** `~/.venv_satellite` or `~/projects/.venv_satellite`
|
|
||||||
- **Service installer:** `install_systemd_services.sh` auto-detects venv path and substitutes placeholders (`__SAT_BASE_DIR__`, `__SAT_VENV__`, `__CURRENT_USER__`)
|
|
||||||
- **Wrapper script:** `satellite-python` installed to `/usr/local/bin/` for systemd services
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## External Resources
|
|
||||||
|
|
||||||
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
|
|
||||||
- [Home Assistant Wyoming Integration](https://www.home-assistant.io/integrations/wyoming)
|
|
||||||
- [Vosk](https://alphacephei.com/vosk/)
|
|
||||||
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Issues / Workarounds
|
|
||||||
|
|
||||||
- **pylintrc:** Many checks disabled — pragmatic approach, focus on functionality over strict linting
|
|
||||||
- **mypy:** `ignore_missing_imports = true` — third-party stubs not always available
|
|
||||||
- **Audio:** Uses `arecord`/`aplay` with specific hardware (`plughw:CARD=Array,DEV=0`) — adjust for different microphones
|
|
||||||
@@ -1,157 +1,3 @@
|
|||||||
# README — VoskWyoming Satellite
|
# Satellite
|
||||||
|
|
||||||
Удалённый голосовой спутник, использующий [Wyoming protocol](https://github.com/rhasspy/wyoming) для Home Assistant.
|
Спутник на RPI3 wakeWords
|
||||||
|
|
||||||
[](https://www.openhomefoundation.org/)
|
|
||||||
|
|
||||||
## Возможности
|
|
||||||
|
|
||||||
- 🏠 **Интеграция с Home Assistant** — Нативная поддержка Wyoming протокола
|
|
||||||
- 🎤 **Локальная детекция wake word** — Vosk, русские фразы ("привет дроид" и др.)
|
|
||||||
- 🔇 **Аудио улучшения** — Шумоподавление WebRTC и автоматическое усиление
|
|
||||||
- 💡 **MQTT LED управление** — ReSpeaker XVF3800 4-MIC с авто-обнаружением HA
|
|
||||||
- 🎯 **Voice Activity Detection** — Silero или WebRTC VAD для приватной работы
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
### 1. Установка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url> ~/projects/Satellite
|
|
||||||
cd ~/projects/Satellite
|
|
||||||
|
|
||||||
python3 -m venv ~/.venv_satellite
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Установка systemd сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install_systemd_services.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl enable --now wyoming-satelite-vosk.service
|
|
||||||
sudo systemctl status wyoming-satelite-vosk.service
|
|
||||||
```
|
|
||||||
|
|
||||||
В Home Assistant: **Настройки → Устройства и сервисы → Найдено → Wyoming Satellite → Настроить**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
| Руководство | Описание |
|
|
||||||
|-------------|----------|
|
|
||||||
| [**Развёртывание**](DEPLOY.md) | Полное руководство по развёртыванию |
|
|
||||||
| [**Tutorials**](docs/tutorials/) | Пошаговые руководства для разного оборудования |
|
|
||||||
| [**Архитектура**](docs/architecture/overview.md) | Как работает Wyoming Satellite |
|
|
||||||
| [**MQTT LED**](docs/integration/mqtt-led.md) | LED управление с Home Assistant |
|
|
||||||
| [**VAD/Wake интеграция**](docs/integration/vad-wakeword.md) | Техническое руководство |
|
|
||||||
| [**Настройки**](docs/settings-cheatsheet.md) | Шпаргалка по CLI флагам |
|
|
||||||
|
|
||||||
### Tutorials
|
|
||||||
|
|
||||||
- [**2-MIC HAT**](docs/tutorials/2mic-hat.md) — Raspberry Pi + ReSpeaker 2-MIC HAT
|
|
||||||
- [**Установщик**](docs/tutorials/installer.md) — Интерактивный установщик
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- **Python 3.9+**
|
|
||||||
- **Linux** (протестировано на Raspberry Pi OS)
|
|
||||||
- **Микрофон** (USB или HAT)
|
|
||||||
- **Опционально:** ReSpeaker XVF3800 USB 4-MIC Array (для LED управления)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
Satellite/
|
|
||||||
├── wyoming_satellite/ # Основной Python пакет
|
|
||||||
├── examples/ # Примеры скриптов (LED, MQTT, конфиги)
|
|
||||||
├── services/ # systemd сервис файлы
|
|
||||||
├── docs/ # Документация
|
|
||||||
│ ├── tutorials/ # Руководства по оборудованию
|
|
||||||
│ ├── integration/ # Технические руководства
|
|
||||||
│ ├── deployment/ # Руководства по развёртыванию
|
|
||||||
│ └── architecture/ # Документация по архитектуре
|
|
||||||
├── sounds/ # Аудио файлы (awake.wav и др.)
|
|
||||||
├── scripts/ # Вспомогательные скрипты
|
|
||||||
└── tests/ # Юнит тесты
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Wake words (русские)
|
|
||||||
|
|
||||||
Отредактируйте `start_satellite.sh`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--wake-word-name "привет дроид"
|
|
||||||
--wake-word-name "привет придурок"
|
|
||||||
--wake-word-name "привет жопа с ушами"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Аудио настройки
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--mic-noise-suppression 2
|
|
||||||
--mic-auto-gain 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### VAD настройки
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--vad
|
|
||||||
--vad-system webrtc
|
|
||||||
--vad-threshold 0.5
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Линтинг
|
|
||||||
pylint wyoming_satellite/
|
|
||||||
mypy wyoming_satellite/
|
|
||||||
|
|
||||||
# Тесты
|
|
||||||
pytest tests/
|
|
||||||
|
|
||||||
# Форматирование
|
|
||||||
black wyoming_satellite/
|
|
||||||
isort wyoming_satellite/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Видео уроки
|
|
||||||
|
|
||||||
- [Wyoming Voice Satellite с ChatGPT](https://www.youtube.com/watch?v=eTKgc0YDCwE)
|
|
||||||
- [Локальный голосовой ассистент с ChatGPT](https://www.youtube.com/watch?v=pAKqKTkx5X4)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Внешние ресурсы
|
|
||||||
|
|
||||||
- [Wyoming Protocol](https://github.com/rhasspy/wyoming)
|
|
||||||
- [Home Assistant Integration](https://www.home-assistant.io/integrations/wyoming)
|
|
||||||
- [Vosk](https://alphacephei.com/vosk/)
|
|
||||||
- [ReSpeaker 4-MIC Array](https://github.com/respeaker/usb_4mic_array)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Этот проект является частью экосистемы Open Home Foundation.
|
|
||||||
|
|
||||||
[](https://www.openhomefoundation.org/)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Документация Wyoming Satellite
|
|
||||||
|
|
||||||
## 📖 Для пользователей
|
|
||||||
|
|
||||||
| Руководство | Описание |
|
|
||||||
|-------------|----------|
|
|
||||||
| [**Развёртывание**](../../DEPLOY.md) | Быстрый старт и systemd сервисы |
|
|
||||||
| [**2-MIC HAT**](tutorials/2mic-hat.md) | Raspberry Pi + ReSpeaker 2-MIC HAT |
|
|
||||||
| [**Установщик**](tutorials/installer.md) | Интерактивный установщик |
|
|
||||||
|
|
||||||
## 🔧 Для разработчиков
|
|
||||||
|
|
||||||
| Документ | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| [**Шпаргалка по настройкам**](settings-cheatsheet.md) | Все CLI флаги и опции |
|
|
||||||
| [**Архитектура**](architecture/overview.md) | Как работает Wyoming Satellite |
|
|
||||||
| [**Интеграция VAD/Wake**](integration/vad-wakeword.md) | Техническое руководство по интеграции |
|
|
||||||
| [**MQTT LED управление**](integration/mqtt-led.md) | LED управление с Home Assistant |
|
|
||||||
| [**systemd сервисы**](deployment/systemd-services.md) | Детали настройки сервисов |
|
|
||||||
|
|
||||||
## 📁 Структура
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/
|
|
||||||
├── README.md # Этот файл
|
|
||||||
├── settings-cheatsheet.md # Шпаргалка по настройкам
|
|
||||||
├── tutorials/
|
|
||||||
│ ├── 2mic-hat.md # 2-MIC HAT настройка
|
|
||||||
│ └── installer.md # Интерактивный установщик
|
|
||||||
├── integration/
|
|
||||||
│ ├── vad-wakeword.md # Техническая интеграция VAD/Wake
|
|
||||||
│ └── mqtt-led.md # LED/MQTT интеграция
|
|
||||||
├── deployment/
|
|
||||||
│ └── systemd-services.md # systemd конфигурация
|
|
||||||
└── architecture/
|
|
||||||
└── overview.md # Обзор архитектуры
|
|
||||||
```
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
# Архитектура Wyoming Satellite
|
|
||||||
|
|
||||||
Обзор архитектуры Wyoming Satellite.
|
|
||||||
|
|
||||||
## Основные компоненты
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Home Assistant │
|
|
||||||
│ (Wyoming интеграция) │
|
|
||||||
└──────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ Wyoming Protocol (TCP)
|
|
||||||
│ Порт 10700
|
|
||||||
┌──────────────────────▼──────────────────────────────────────┐
|
|
||||||
│ Wyoming Satellite │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ SatelliteBase │ │
|
|
||||||
│ │ ├─ AlwaysStreamingSatellite │ │
|
|
||||||
│ │ ├─ VadStreamingSatellite │ │
|
|
||||||
│ │ └─ WakeStreamingSatellite │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ MicSettings │ │ SndSettings │ │ WakeSettings │ │
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ EventHandler │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
└──────────┬─────────────────────┬───────────────────────────┘
|
|
||||||
│ │
|
|
||||||
│ Audio In │ Audio Out
|
|
||||||
│ (arecord) │ (aplay)
|
|
||||||
┌──────────▼──────┐ ┌────────▼──────────┐
|
|
||||||
│ Микрофон │ │ Динамик │
|
|
||||||
│ USB/HAT │ │ USB/HAT │
|
|
||||||
└─────────────────┘ └───────────────────┘
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Локальные сервисы (опционально) │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
|
||||||
│ │ Wake Word Service │ │ Event Service │ │
|
|
||||||
│ │ (Vosk, порт 10400) │ │ (MQTT, порт 10500) │ │
|
|
||||||
│ └─────────────────────┘ └─────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Режимы работы спутника
|
|
||||||
|
|
||||||
### AlwaysStreamingSatellite
|
|
||||||
|
|
||||||
Постоянно передаёт аудио в Home Assistant.
|
|
||||||
|
|
||||||
- **Преимущества:** Минимальная задержка, простота
|
|
||||||
- **Недостатки:** Высокий трафик, нет локальной обработки
|
|
||||||
- **Применение:** Быстрая LAN, нет требований к приватности
|
|
||||||
|
|
||||||
### VadStreamingSatellite
|
|
||||||
|
|
||||||
Использует Voice Activity Detection для начала стриминга только при обнаружении речи.
|
|
||||||
|
|
||||||
- **VAD системы:**
|
|
||||||
- **Silero:** Нейросетевой, более точный
|
|
||||||
- **WebRTC:** Лёгкий, быстрый
|
|
||||||
- **Возможности:**
|
|
||||||
- Пре-буфер (отправляет аудио до триггера VAD)
|
|
||||||
- Таймаут wake word (ожидание фразы после VAD)
|
|
||||||
- **Применение:** Приватность, ограничение трафика
|
|
||||||
|
|
||||||
### WakeStreamingSatellite
|
|
||||||
|
|
||||||
Использует локальную детекцию wake word перед стримингом.
|
|
||||||
|
|
||||||
- **Системы wake word:**
|
|
||||||
- **Vosk:** Грамматика (русские фразы)
|
|
||||||
- **openWakeWord:** Нейросеть
|
|
||||||
- **Porcupine:** Коммерческий KWS
|
|
||||||
- **Поток:**
|
|
||||||
1. Постоянное прослушивание wake word
|
|
||||||
2. При обнаружении → стриминг в HA
|
|
||||||
3. Обработка команды → остановка стриминга
|
|
||||||
- **Применение:** Полная локальная активация, стиль "Hey Google"
|
|
||||||
|
|
||||||
## Аудио пайплайн
|
|
||||||
|
|
||||||
```
|
|
||||||
Микрофон
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Захват аудио │ arecord -r 16000 -c 1 -f S16_LE
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Предобработка │ Громкость, AGC, шумоподавление
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ VAD / Wake │ Решение: стримить или нет?
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Wyoming Protocol│ Кодирование в Wyoming сообщения
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Home Assistant
|
|
||||||
```
|
|
||||||
|
|
||||||
## Основные файлы
|
|
||||||
|
|
||||||
| Файл | Назначение |
|
|
||||||
|------|------------|
|
|
||||||
| `wyoming_satellite/__main__.py` | CLI entry point, парсинг аргументов |
|
|
||||||
| `wyoming_satellite/satellite.py` | Реализации спутников |
|
|
||||||
| `wyoming_satellite/settings.py` | Dataclass настроек |
|
|
||||||
| `wyoming_satellite/event_handler.py` | Обработка Wyoming событий |
|
|
||||||
| `wyoming_satellite/vad.py` | VAD реализации (Silero, WebRTC) |
|
|
||||||
| `wyoming_satellite/webrtc.py` | WebRTC обработка аудио |
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
Основная конфигурация через CLI аргументы (см. `__main__.py`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Микрофон
|
|
||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw'
|
|
||||||
--mic-noise-suppression 2
|
|
||||||
--mic-auto-gain 5
|
|
||||||
|
|
||||||
# Wake word
|
|
||||||
--wake-uri tcp://127.0.0.1:10400
|
|
||||||
--wake-word-name "привет дроид"
|
|
||||||
|
|
||||||
# VAD
|
|
||||||
--vad
|
|
||||||
--vad-system webrtc
|
|
||||||
--vad-threshold 0.5
|
|
||||||
|
|
||||||
# События (LED, MQTT)
|
|
||||||
--event-uri tcp://127.0.0.1:10500
|
|
||||||
```
|
|
||||||
|
|
||||||
## Машина состояний
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────┐
|
|
||||||
│ Остановка │
|
|
||||||
└──────┬───────┘
|
|
||||||
│ Старт
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
┌────────│ Прослушивание│────────┐
|
|
||||||
│ └──────┬───────┘ │
|
|
||||||
│ │ │
|
|
||||||
VAD триггер Wake детекция Стриминг
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│ VAD │ │ Wake │ │Стриминг │
|
|
||||||
│ Активен │ │ Активен │ │ Аудио │
|
|
||||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
|
||||||
│ │ │
|
|
||||||
└───────────────┴───────────────┘
|
|
||||||
│
|
|
||||||
AudioStop
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ Прослушивание│
|
|
||||||
└──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Настройки (cheat sheet)](settings-cheatsheet.md)
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [Интеграция VAD/Wake](integration/vad-wakeword.md)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# Настройка systemd сервисов
|
|
||||||
|
|
||||||
Подробное руководство по настройке и управлению systemd сервисами Wyoming Satellite.
|
|
||||||
|
|
||||||
## Расположение файлов сервисов
|
|
||||||
|
|
||||||
Файлы сервисов находятся в директории `services/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
services/
|
|
||||||
├── wyoming-event.service # LED управление + MQTT
|
|
||||||
├── wyoming-satelite-vosk.service # Основной спутник
|
|
||||||
└── wyoming-wakeword-vosk.service # Детекция wake word
|
|
||||||
```
|
|
||||||
|
|
||||||
## Скрипт установки
|
|
||||||
|
|
||||||
Скрипт `install_systemd_services.sh` автоматически:
|
|
||||||
|
|
||||||
1. Определяет путь к виртуальному окружению
|
|
||||||
2. Определяет текущего пользователя
|
|
||||||
3. Подставляет плейсхолдеры в файлы сервисов
|
|
||||||
4. Копирует сервисы в `/etc/systemd/system/`
|
|
||||||
5. Устанавливает wrapper `satellite-python` в `/usr/local/bin/`
|
|
||||||
|
|
||||||
### Ручная установка
|
|
||||||
|
|
||||||
Если скрипт не работает, установите вручную:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Отредактируйте файлы сервисов, заменив плейсхолдеры:
|
|
||||||
# __SAT_BASE_DIR__ → /home/sirmrak/projects/Satellite
|
|
||||||
# __SAT_VENV__ → /home/sirmrak/.venv_satellite
|
|
||||||
# __CURRENT_USER__ → sirmrak
|
|
||||||
|
|
||||||
# 2. Скопируйте в директорию systemd
|
|
||||||
sudo cp services/*.service /etc/systemd/system/
|
|
||||||
|
|
||||||
# 3. Перезагрузите systemd
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
# 4. Включите и запустите
|
|
||||||
sudo systemctl enable --now wyoming-satelite-vosk.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## Детали сервисов
|
|
||||||
|
|
||||||
### wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
Основной сервис Wyoming Satellite. Подключается к:
|
|
||||||
- Микрофону (arecord)
|
|
||||||
- Динамику (aplay)
|
|
||||||
- Сервису wake word (опционально, порт 10400)
|
|
||||||
- Сервису событий (опционально, порт 10500)
|
|
||||||
|
|
||||||
**Порты:** 10700 (Wyoming protocol)
|
|
||||||
|
|
||||||
**Зависимости:** network.target
|
|
||||||
|
|
||||||
### wyoming-wakeword-vosk.service
|
|
||||||
|
|
||||||
Сервис детекции wake word на основе Vosk. Слушает русские фразы:
|
|
||||||
- "привет дроид"
|
|
||||||
- "привет придурок"
|
|
||||||
- "привет жопа с ушами"
|
|
||||||
|
|
||||||
**Порты:** 10400 (Wyoming protocol)
|
|
||||||
|
|
||||||
**Конфигурация:** `local/vosk-wake-dev/wyoming_vosk_wake/config.yaml`
|
|
||||||
|
|
||||||
### wyoming-event.service
|
|
||||||
|
|
||||||
LED управление и MQTT интеграция для ReSpeaker XVF3800.
|
|
||||||
|
|
||||||
**Порты:** 10500 (Wyoming event protocol)
|
|
||||||
|
|
||||||
**Требования:**
|
|
||||||
- ReSpeaker XVF3800 USB 4-MIC Array
|
|
||||||
- udev правила: `SUBSYSTEM=="usb", ATTR{idVendor}=="2886", MODE="0666"`
|
|
||||||
|
|
||||||
**USB контроллер:** Использует модуль `wyoming_satellite.xvf_usb` для прямого доступа к LED без subprocess.
|
|
||||||
|
|
||||||
**MQTT топики:**
|
|
||||||
- `wyoming/led/command` — Команды от Home Assistant
|
|
||||||
- `wyoming/led/brightness/set` — Установка яркости (0-255)
|
|
||||||
- `wyoming/led/brightness/state` — Текущая яркость
|
|
||||||
- `wyoming/led/state` — Состояние LED
|
|
||||||
- `wyoming/led/availability` — Статус онлайн/офлайн
|
|
||||||
|
|
||||||
**Конфигурация яркости:** `examples/led_brightness_config.json` (создаётся автоматически)
|
|
||||||
|
|
||||||
**Диагностика:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка статуса
|
|
||||||
sudo systemctl status wyoming-event.service
|
|
||||||
|
|
||||||
# Логи
|
|
||||||
journalctl -u wyoming-event.service -f
|
|
||||||
|
|
||||||
# Тест USB контроллера
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; print('OK' if XVF3800Controller().is_connected() else 'Not found')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Подробности в [MQTT LED документации](../integration/mqtt-led.md).
|
|
||||||
|
|
||||||
## Команды управления
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка статуса
|
|
||||||
sudo systemctl status wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
# Просмотр логов (реальное время)
|
|
||||||
journalctl -u wyoming-satelite-vosk.service -f
|
|
||||||
|
|
||||||
# Перезапуск сервиса
|
|
||||||
sudo systemctl restart wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
# Отключение сервиса
|
|
||||||
sudo systemctl disable --now wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
# Перезагрузка systemd после изменений
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
```
|
|
||||||
|
|
||||||
## Диагностика
|
|
||||||
|
|
||||||
### Сервис не запускается
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка статуса и последних ошибок
|
|
||||||
sudo systemctl status wyoming-satelite-vosk.service
|
|
||||||
|
|
||||||
# Просмотр полных логов
|
|
||||||
journalctl -u wyoming-satelite-vosk.service -n 100
|
|
||||||
|
|
||||||
# Проверка пути к Python
|
|
||||||
which satellite-python
|
|
||||||
satellite-python --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
|
|
||||||
| Проблема | Решение |
|
|
||||||
|----------|---------|
|
|
||||||
| `ModuleNotFoundError` | Проверьте путь к venv в файле сервиса |
|
|
||||||
| `arecord: command not found` | Установите `alsa-utils` |
|
|
||||||
| Permission denied | Проверьте директиву `User=` |
|
|
||||||
| Порт уже используется | Проверьте, не запущен ли другой экземпляр |
|
|
||||||
|
|
||||||
## Wrapper скрипт
|
|
||||||
|
|
||||||
Wrapper `satellite-python` позволяет запускать Python из venv без указания полного пути:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Установлен в /usr/local/bin/satellite-python
|
|
||||||
satellite-python --version # Использует Python из venv
|
|
||||||
```
|
|
||||||
|
|
||||||
Используется в файлах сервисов:
|
|
||||||
```ini
|
|
||||||
ExecStart=satellite-python -m wyoming_satellite ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [MQTT LED интеграция](integration/mqtt-led.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TEST сервисы (для разработки)
|
|
||||||
|
|
||||||
Для тестирования новых функций без влияния на production службы создан **TEST satellite**.
|
|
||||||
|
|
||||||
### Отличия от production
|
|
||||||
|
|
||||||
| Сервис | Production | Test |
|
|
||||||
|--------|------------|------|
|
|
||||||
| Satellite | порт 10700 | порт 10702 + WebSocket :8675 + Audio Level |
|
|
||||||
| Wake Word | порт 10400 | production сервис |
|
|
||||||
| Event | порт 10500 | production сервис |
|
|
||||||
|
|
||||||
**Важно:** TEST satellite использует production сервисы для wake word и event!
|
|
||||||
|
|
||||||
### Вариант 1: Установка TEST сервиса (рекомендуется)
|
|
||||||
|
|
||||||
**Установка:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/satellite-dev
|
|
||||||
sudo ./install_test_services.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Запуск:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Убедись что production сервисы запущены
|
|
||||||
sudo systemctl status wyoming-wakeword-vosk.service
|
|
||||||
sudo systemctl status wyoming-event.service
|
|
||||||
|
|
||||||
# Запуск test satellite
|
|
||||||
sudo systemctl start wyoming-satelite-vosk-test.service
|
|
||||||
|
|
||||||
# Проверка статуса
|
|
||||||
sudo systemctl status wyoming-satelite-vosk-test.service
|
|
||||||
|
|
||||||
# Логи
|
|
||||||
journalctl -u wyoming-satelite-vosk-test.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Автоматический перезапуск при ошибке
|
|
||||||
- ✅ Production службы не затрагиваются
|
|
||||||
- ✅ Легко переключаться между test/prod
|
|
||||||
|
|
||||||
### Вариант 2: Быстрый запуск в tmux
|
|
||||||
|
|
||||||
**Требования:** `tmux` установлен (`sudo apt-get install tmux`)
|
|
||||||
|
|
||||||
**Запуск:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/satellite-dev
|
|
||||||
./services/test/start_test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Управление:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Подключиться к сессии
|
|
||||||
tmux attach -t satellite-test
|
|
||||||
|
|
||||||
# Остановить
|
|
||||||
tmux kill-session -t satellite-test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Важно:** Production сервисы должны быть запущены отдельно!
|
|
||||||
|
|
||||||
### Тестирование WebSocket
|
|
||||||
|
|
||||||
**Проверка подключения:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/satellite-dev
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Python тест клиент
|
|
||||||
python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675
|
|
||||||
|
|
||||||
# Или браузер
|
|
||||||
# Откройте examples/websocket-service/client.html
|
|
||||||
```
|
|
||||||
|
|
||||||
**Проверка audio level:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// В browser console (client.html)
|
|
||||||
// Уровень микрофона должен обновляться 10 раз в секунду
|
|
||||||
// {"type": "audio-level", "data": {"level": 0.42}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Остановка TEST сервисов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# systemd сервис
|
|
||||||
sudo systemctl stop wyoming-satelite-vosk-test.service
|
|
||||||
|
|
||||||
# tmux сессия
|
|
||||||
tmux kill-session -t satellite-test
|
|
||||||
```
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# План: WebSocket сервер для Wyoming Satellite
|
|
||||||
|
|
||||||
**Дата:** 2026-05-20
|
|
||||||
**Статус:** 🔄 Тестирование
|
|
||||||
**Ветка:** `satellite-dev`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
| Этап | Что | Статус |
|
|
||||||
|------|-----|--------|
|
|
||||||
| WS-1 | `settings.py` — поля WebSocket в EventSettings | ✅ |
|
|
||||||
| WS-2 | `satellite.py` — сервер, broadcast, команды | ✅ |
|
|
||||||
| WS-2A | Протокол команд (7 команд) | ✅ |
|
|
||||||
| WS-3 | `__main__.py` — CLI аргументы | ✅ |
|
|
||||||
| WS-4 | Audio level (RMS) мониторинг | ✅ |
|
|
||||||
| WS-4 | Audio recording | ✅ |
|
|
||||||
|
|
||||||
## Что осталось
|
|
||||||
|
|
||||||
| Этап | Что | Статус |
|
|
||||||
|------|-----|--------|
|
|
||||||
| WS-5 | Обновить `docs/integration/mqtt-led.md` | ⏳ |
|
|
||||||
| — | Merge в master | ⏳ |
|
|
||||||
|
|
||||||
## Чеклист тестирования (WS-4)
|
|
||||||
|
|
||||||
- [ ] Satellite запускается с `--websocket-enabled`
|
|
||||||
- [ ] Браузер подключается к порту 8675
|
|
||||||
- [ ] События приходят (`synthesize`, `audio-start/stop`, `played`)
|
|
||||||
- [x] `audio-level` приходит ~10 раз/сек
|
|
||||||
- [x] Команды работают: `set_volume`, `set_mic_volume`, `mute/unmute`
|
|
||||||
- [ ] Команды работают: `play_sound`, `set_led_brightness`
|
|
||||||
- [x] `start_recording` / `stop_recording` — возвращают WAV
|
|
||||||
- [ ] Home Assistant работает (порт 10700) — не сломалось
|
|
||||||
- [ ] wyoming-led.py работает (порт 10500) — не сломалось
|
|
||||||
- [ ] При отключении клиента satellite не падает
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Архитектура (справка)
|
|
||||||
|
|
||||||
```
|
|
||||||
Home Assistant ──10700──► Satellite
|
|
||||||
├── Event CLIENT :10500 → LED, MQTT
|
|
||||||
└── WebSocket SERVER :8675 ← Browser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Протокол команд (справка)
|
|
||||||
|
|
||||||
| Команда | Данные | Описание |
|
|
||||||
|---------|--------|----------|
|
|
||||||
| `set_volume` | `{"level": 0.0-1.0}` | Громкость TTS |
|
|
||||||
| `set_mic_volume` | `{"level": 0.0-2.0}` | Чувствительность микрофона |
|
|
||||||
| `mute` / `unmute` | `{}` | Заглушить/включить микрофон |
|
|
||||||
| `play_sound` | `{"path": "/path.wav"}` | Воспроизвести WAV |
|
|
||||||
| `set_led_brightness` | `{"brightness": 0-255}` | Яркость LED |
|
|
||||||
| `start_recording` | `{}` | Начать запись с микрофона |
|
|
||||||
| `stop_recording` | `{}` | Остановить, вернуть WAV (base64) |
|
|
||||||
| `trigger_event` | `{"event_type": "...", "data": {...}}` | Отправить событие в HA |
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--uri tcp://0.0.0.0:10700 \
|
|
||||||
--event-uri tcp://127.0.0.1:10500 \
|
|
||||||
--websocket-enabled \
|
|
||||||
--websocket-port 8675 \
|
|
||||||
--audio-level-enabled
|
|
||||||
```
|
|
||||||
|
|
||||||
## Известные проблемы
|
|
||||||
|
|
||||||
- **ALSA card hardcoded** — `-c 2` в `WakeStreamingSatellite` (см. `satellite.py` ~1700)
|
|
||||||
- **`_kill_aplay_processes()`** — может убить сторонние aplay процессы
|
|
||||||
- **Recording buffer не ограничен** — длительная запись ест память
|
|
||||||
- **`trigger_event`** — создаёт сырой `Event`, без валидации Wyoming
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# MQTT LED Управление
|
|
||||||
|
|
||||||
Руководство по интеграции LED управления ReSpeaker через MQTT и Home Assistant.
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
LED сервис (`examples/wyoming-led.py`) предоставляет:
|
|
||||||
|
|
||||||
- **Light сущность** — Управление яркостью и цветом LED
|
|
||||||
- **Sensor сущность** — Состояние спутника (ожидание, слушание, говорение)
|
|
||||||
- **Number сущности** — 7 слайдеров для точной настройки
|
|
||||||
- **MQTT Discovery** — Авто-конфигурация в Home Assistant
|
|
||||||
|
|
||||||
## Архитектура
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐ MQTT ┌──────────────────┐
|
|
||||||
│ Home Assistant │◄─────────────►│ wyoming-led.py │
|
|
||||||
│ │ │ (порт 10501) │
|
|
||||||
└──────────────────┘ └────────┬─────────┘
|
|
||||||
│
|
|
||||||
│ Wyoming Events
|
|
||||||
│ (порт 10500)
|
|
||||||
┌────────▼─────────┐
|
|
||||||
│ Satellite │
|
|
||||||
│ (порт 10700) │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### 1. Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/Satellite
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
pip install paho-mqtt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Настройка MQTT
|
|
||||||
|
|
||||||
Отредактируйте `examples/wyoming-led.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
MQTT_BROKER = "192.168.200.100" # Ваш MQTT брокер
|
|
||||||
MQTT_PORT = 1883
|
|
||||||
MQTT_USER = "mqtt"
|
|
||||||
MQTT_PASS = "mqtt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Создание systemd сервиса
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/systemd/system/wyoming-led.service
|
|
||||||
```
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Wyoming LED Service с MQTT
|
|
||||||
After=network.target mosquitto.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=sirmrak
|
|
||||||
WorkingDirectory=/home/sirmrak/projects/Satellite
|
|
||||||
Environment="PATH=/home/sirmrak/projects/.venv_satellite/bin"
|
|
||||||
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 /home/sirmrak/projects/Satellite/examples/wyoming-led.py --host 0.0.0.0 --port 10501
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Включение сервиса
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now wyoming-led.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## MQTT топики
|
|
||||||
|
|
||||||
| Топик | Направление | Описание |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `wyoming/led/command` | HA → Скрипт | Команды света (вкл/выкл, цвет, яркость) |
|
|
||||||
| `wyoming/led/brightness/set` | HA → Скрипт | Установка яркости (0-255) |
|
|
||||||
| `wyoming/led/brightness/state` | Скрипт → HA | Текущая яркость |
|
|
||||||
| `wyoming/led/state` | Скрипт → HA | Состояние LED (вкл/выкл) |
|
|
||||||
| `wyoming/led/availability` | Скрипт → HA | Онлайн/офлайн статус |
|
|
||||||
| `wyoming/satellite/state` | Скрипт → HA | Состояние спутника |
|
|
||||||
|
|
||||||
## Сущности Home Assistant
|
|
||||||
|
|
||||||
После discovery вы увидите:
|
|
||||||
|
|
||||||
- **Light:** `light.wyoming_led` — RGB управление
|
|
||||||
- **Sensor:** `sensor.wyoming_satellite_state` — Текущее состояние
|
|
||||||
- **Number:** `number.wyoming_led_brightness` — Слайдер яркости
|
|
||||||
- **Number:** `number.wyoming_led_red/green/blue` — Слайдеры цвета
|
|
||||||
- **Number:** `number.wyoming_led_idle/listening/speaking` — Цвета состояний
|
|
||||||
|
|
||||||
## Состояния LED
|
|
||||||
|
|
||||||
| Состояние | Цвет | Поведение |
|
|
||||||
|-----------|------|-----------|
|
|
||||||
| Ожидание (Idle) | Синий (тусклый) | Дыхательный эффект |
|
|
||||||
| Слушание (Listening) | Голубой | Постоянный |
|
|
||||||
| Говорение (Speaking) | Зелёный | Постоянный |
|
|
||||||
| Ошибка (Error) | Красный | Мигание |
|
|
||||||
|
|
||||||
## Настройка яркости
|
|
||||||
|
|
||||||
Яркость LED хранится в `examples/led_brightness_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"idle": 50,
|
|
||||||
"listening": 100,
|
|
||||||
"speaking": 150,
|
|
||||||
"error": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот файл создаётся автоматически и должен быть исключён из git tracking:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git update-index --assume-unchanged examples/led_brightness_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Диагностика
|
|
||||||
|
|
||||||
### LED не реагирует
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка статуса сервиса
|
|
||||||
sudo systemctl status wyoming-led.service
|
|
||||||
|
|
||||||
# Просмотр логов
|
|
||||||
journalctl -u wyoming-led.service -f
|
|
||||||
|
|
||||||
# Тест вручную
|
|
||||||
python3 examples/wyoming-led.py --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### MQTT подключение не удалось
|
|
||||||
|
|
||||||
- Проверьте, что брокер запущен: `sudo systemctl status mosquitto`
|
|
||||||
- Проверьте учётные данные в `wyoming-led.py`
|
|
||||||
- Тест подключения: `mosquitto_sub -t 'wyoming/led/#' -v`
|
|
||||||
|
|
||||||
### ReSpeaker не обнаружен
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка USB устройств
|
|
||||||
lsusb | grep -i 2886
|
|
||||||
|
|
||||||
# Тест USB контроллера
|
|
||||||
source ~/.venv_satellite/bin/activate
|
|
||||||
python3 -c "from wyoming_satellite.xvf_usb import XVF3800Controller; c = XVF3800Controller(); print('OK' if c.is_connected() else 'Not found')"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддерживаемое оборудование
|
|
||||||
|
|
||||||
| Устройство | Поддержка | Примечания |
|
|
||||||
|------------|-----------|------------|
|
|
||||||
| ReSpeaker XVF3800 (USB 4-MIC) | ✅ Полная | Рекомендуется |
|
|
||||||
| ReSpeaker 2-MIC HAT | ✅ Полная | Требуется GPIO |
|
|
||||||
| ReSpeaker 4-MIC HAT | ✅ Полная | Требуется GPIO |
|
|
||||||
| Обычный USB микрофон | ⚠️ Частичная | Без LED управления |
|
|
||||||
|
|
||||||
## Связанные файлы
|
|
||||||
|
|
||||||
- `examples/wyoming-led.py` — Основной скрипт LED управления
|
|
||||||
- `wyoming_satellite/xvf_usb.py` — USB контроллер XVF3800 (прямой доступ без subprocess)
|
|
||||||
- `examples/led_brightness_config.json` — Настройки яркости
|
|
||||||
- `services/wyoming-event.service` — Сервис событий (альтернатива)
|
|
||||||
|
|
||||||
## Архитектура USB контроллера
|
|
||||||
|
|
||||||
Начиная с версии 2026.05, `wyoming-led.py` использует прямой USB доступ через модуль `wyoming_satellite.xvf_usb`:
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Быстрее: ~1ms на команду (нет накладных расходов subprocess)
|
|
||||||
- ✅ Надёжнее: контроль USB подключения с авто-переподключением
|
|
||||||
- ✅ Чище: нет процессов subprocess и временных файлов
|
|
||||||
|
|
||||||
**Пример использования:**
|
|
||||||
```python
|
|
||||||
from wyoming_satellite.xvf_usb import XVF3800Controller, EFFECT_BREATH, rgb_to_bgr_int
|
|
||||||
|
|
||||||
controller = XVF3800Controller()
|
|
||||||
controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
controller.set_led_color(rgb_to_bgr_int(0, 188, 212)) # бирюзовый
|
|
||||||
controller.set_led_brightness(150)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [Настройка systemd сервисов](deployment/systemd-services.md)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
## Интеграция VAD и Wakeword: выводы и план
|
|
||||||
|
|
||||||
### 1) Картина текущей архитектуры (Wyoming Satellite)
|
|
||||||
- **Режимы работы спутника**:
|
|
||||||
- AlwaysStreaming — всегда стримит аудио, VAD/Wake не участвуют.
|
|
||||||
- VadStreaming — локальный VAD решает момент старта стриминга; при активации отправляется предречь (pre-roll), опционально действует таймаут на ожидание wakeword.
|
|
||||||
- WakeStreaming — аудио уходит в локальный wakeword-сервис (Wyoming-протокол); при Detection начинается стриминг на сервер (STT/интенты).
|
|
||||||
- **VAD по умолчанию**: `SileroVad` с интерфейсом `__call__(audio_bytes|None) -> bool`, поддержка `reset()` через передачу `None`. Предречь хранится в кольцевом буфере, при активации догружается первой.
|
|
||||||
- **Wakeword**: внешние сервисы по Wyoming (например, openWakeWord, Porcupine, Snowboy), принимают `Audio*` события и отдают `Detection`.
|
|
||||||
|
|
||||||
### 2) Что есть в `myproject`
|
|
||||||
- **VAD** (`myproject/vad_core.py`):
|
|
||||||
- Использует `webrtcvad.Vad` + энерго-критерии, адаптивный порог, простое шумоподавление и автоусиление.
|
|
||||||
- Имеет предбуфер и основной буфер записи сегмента речи; по завершении вызывает callback с целым кусочком аудио.
|
|
||||||
- Ввод аудио сейчас завязан на PyAudio внутри класса, то есть детектор и источник аудио слиты.
|
|
||||||
- **Wakeword на Vosk** (`myproject/vosk_local_STT.py` + `commands.py`):
|
|
||||||
- Собирает грамматику из `commands.yaml` (в т.ч. секцию `Wakewords`) и распознаёт текст.
|
|
||||||
- Факт wakeword определяется как точное совпадение распознанной фразы с элементами из `Wakewords` (то есть post-utterance wakeword через STT).
|
|
||||||
|
|
||||||
### 3) Сопоставление и ключевые различия
|
|
||||||
- В Wyoming VAD — быстрый фрейм-по-фрейм решатель с мгновенным флагом активации, а у вас — сегментатор с внутренним циклом и завершением по тишине.
|
|
||||||
- В Wyoming wakeword — отдельный потоковый сервис (KWS-подобное поведение), а у вас — определение wakeword как часть STT после завершения фразы.
|
|
||||||
|
|
||||||
### 4) Варианты интеграции
|
|
||||||
1. **Заменить локальный VAD на ваш (рекомендуется этап 2):**
|
|
||||||
- Выделить из `VADPolling` чистый детектор без PyAudio-ввода (API вида: `process(frame_bytes) -> { started/stopped/speech_detected, prebuffer, ... }`).
|
|
||||||
- Добавить фабрику VAD в спутник: `settings.vad.system: "silero"|"my_webrtc_vad"`, инициализировать нужную реализацию.
|
|
||||||
- При активации отдавать предбуфер и стартовать стриминг — совместимо с текущей логикой VadStreaming.
|
|
||||||
2. **Оформить wakeword на Vosk как Wyoming-сервис (рекомендуется этап 1):**
|
|
||||||
- Небольшой сервер по Wyoming-протоколу, который:
|
|
||||||
- подгружает грамматику только из `Wakewords`;
|
|
||||||
- принимает `AudioStart/AudioChunk/AudioStop` и кормит чанк в `KaldiRecognizer.AcceptWaveform`;
|
|
||||||
- при совпадении фразы — отправляет `Detection(name=...)` немедленно.
|
|
||||||
- Подключается как `wake.system: "vosk_wake"`, аналогично `openWakeWord`/`porcupine1`.
|
|
||||||
|
|
||||||
### 5) Минимальные изменения кода
|
|
||||||
- **Для wakeword (этап 1):**
|
|
||||||
- Реализовать отдельный модуль `vosk_wake` (сервер Wyoming): запуск по TCP, обработка событий, генерация `Detection` по фразам из `Wakewords`.
|
|
||||||
- Unit-файл/скрипт запуска по аналогии с `local/services/wyoming-wakeword.service` и запись в конфиг `wake.system`.
|
|
||||||
- **Для VAD (этап 2):**
|
|
||||||
- Вынести логику детектора из `VADPolling` в отдельный класс без I/O.
|
|
||||||
- Добавить флаг `vad.system` и фабрику выбора реализации; адаптер, который преобразует `AudioChunk` → фреймы и использует ваш детектор для решения «старт/стоп/предречь».
|
|
||||||
|
|
||||||
### 6) Пошаговый план внедрения
|
|
||||||
1) Wakeword на Vosk (быстрый выигрыш):
|
|
||||||
- Сделать `vosk_wake` сервис: узкая грамматика = только `Wakewords`, немедленный `Detection` при совпадении.
|
|
||||||
- Прописать сервис в systemd/скрипте, указать URI и `wake.system`.
|
|
||||||
- Протестировать в режиме `WakeStreamingSatellite`.
|
|
||||||
2) Замена VAD:
|
|
||||||
- Рефакторинг `VADPolling`: отделить детектор от аудио-источника.
|
|
||||||
- Добавить `vad.system` и внедрить адаптер под текущий интерфейс Wyoming.
|
|
||||||
- Протестировать в `VadStreamingSatellite` с предбуфером и таймаутом wakeword.
|
|
||||||
|
|
||||||
### 7) Риски и примечания
|
|
||||||
- Vosk как wakeword — это распознавание текста; для «на лету» задержка может быть выше, чем у специализированного KWS. С узкой грамматикой (только wakewords) будет быстрее и устойчивее.
|
|
||||||
- Ваши улучшения VAD (AGC/NR/адаптивные пороги) полезны; главное — отделить I/O и обеспечить детерминированный фреймовый API, совместимый с Wyoming.
|
|
||||||
|
|
||||||
### 8) Резюме
|
|
||||||
- Этап 1: добавить `vosk_wake` как Wyoming-сервис wakeword — минимум инвазивности и быстрая интеграция.
|
|
||||||
- Этап 2: внедрить ваш VAD через фабрику и адаптер — получить продвинутую локальную активацию речи, сохранив предречь и таймауты.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Wyoming Satellite — Шпаргалка по настройкам
|
|
||||||
|
|
||||||
Быстрый справочник всех CLI флагов и настроек.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎤 Микрофон (`--mic-*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--mic-uri` | — | Wyoming microphone сервис URI (`tcp://ip:port`) |
|
|
||||||
| `--mic-command` | — | Команда для записи с микрофона (`arecord ...`) |
|
|
||||||
| `--mic-command-rate` | 16000 | Частота дискретизации (Гц) |
|
|
||||||
| `--mic-command-width` | 2 | Разрядность (байт) |
|
|
||||||
| `--mic-command-channels` | 1 | Каналы (1=моно) |
|
|
||||||
| `--mic-command-samples-per-chunk` | 1024 | Сэмплов за чтение |
|
|
||||||
| `--mic-volume-multiplier` | 1.0 | Множитель громкости |
|
|
||||||
| `--mic-noise-suppression` | 0 | Шумоподавление (0-4) |
|
|
||||||
| `--mic-auto-gain` | 0 | Автоусиление (0-31 dbFS) |
|
|
||||||
| `--mic-channel-index` | None | Индекс канала |
|
|
||||||
| `--mic-seconds-to-mute-after-awake-wav` | 0.5 | Глушить после awake WAV |
|
|
||||||
| `--mic-no-mute-during-awake-wav` | false | Не глушить во время awake WAV |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--mic-noise-suppression 2 \
|
|
||||||
--mic-auto-gain 5
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔊 Звук (`--snd-*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--snd-uri` | — | Wyoming sound сервис URI |
|
|
||||||
| `--snd-command` | — | Команда для вывода звука (`aplay ...`) |
|
|
||||||
| `--snd-command-rate` | 22050 | Частота дискретизации (Гц) |
|
|
||||||
| `--snd-command-width` | 2 | Разрядность (байт) |
|
|
||||||
| `--snd-command-channels` | 1 | Каналы |
|
|
||||||
| `--snd-volume-multiplier` | 1.0 | Множитель громкости |
|
|
||||||
| `--awake-wav` | — | WAV после wake word |
|
|
||||||
| `--done-wav` | — | WAV после команды |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Wake Word (`--wake-*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--wake-uri` | — | Wyoming wake word сервис URI |
|
|
||||||
| `--wake-command` | — | Локальная команда wake word |
|
|
||||||
| `--wake-word-name` | — | Фраза wake word (можно повторять) |
|
|
||||||
| `--wake-refractory-seconds` | 5.0 | Задержка между детекциями |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--wake-uri tcp://127.0.0.1:10400 \
|
|
||||||
--wake-word-name "привет дроид" \
|
|
||||||
--wake-word-name "привет придурок"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎤 Voice Activity Detection (`--vad*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--vad` | false | Включить VAD |
|
|
||||||
| `--vad-system` | silero | `silero` или `webrtc` |
|
|
||||||
| `--vad-threshold` | 0.5 | Порог (0-1) |
|
|
||||||
| `--vad-trigger-level` | 1 | Чанков для активации |
|
|
||||||
| `--vad-energy-threshold` | None | Энергия для webrtc (int16) |
|
|
||||||
| `--vad-buffer-seconds` | 2.0 | Пре-буфер (секунды) |
|
|
||||||
| `--vad-wake-word-timeout` | 5.0 | Таймаут wake word |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--vad \
|
|
||||||
--vad-system webrtc \
|
|
||||||
--vad-threshold 0.5 \
|
|
||||||
--vad-energy-threshold 200 \
|
|
||||||
--vad-trigger-level 5
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📡 Event Service (`--event-uri`, `--*-command`)
|
|
||||||
|
|
||||||
| Флаг | Событие |
|
|
||||||
|------|---------|
|
|
||||||
| `--event-uri` | Wyoming event сервис URI |
|
|
||||||
| `--startup-command` | Спутник запущен |
|
|
||||||
| `--streaming-start-command` | Стриминг начался |
|
|
||||||
| `--streaming-stop-command` | Стриминг остановлен |
|
|
||||||
| `--detect-command` | Начало детекции wake |
|
|
||||||
| `--detection-command` | Wake word обнаружен |
|
|
||||||
| `--tts-played-command` | Аудио воспроизведено |
|
|
||||||
| `--transcript-command` | STT текст |
|
|
||||||
| `--stt-start-command` | STT начался |
|
|
||||||
| `--stt-stop-command` | STT завершён |
|
|
||||||
| `--synthesize-command` | TTS текст получен |
|
|
||||||
| `--tts-start-command` | TTS начался |
|
|
||||||
| `--tts-stop-command` | TTS завершён |
|
|
||||||
| `--error-command` | Ошибка |
|
|
||||||
| `--connected-command` | Подключен к серверу |
|
|
||||||
| `--disconnected-command` | Отключен от сервера |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--event-uri tcp://127.0.0.1:10500 \
|
|
||||||
--detection-command 'examples/commands/detection.sh' \
|
|
||||||
--transcript-command 'examples/commands/transcript.sh' \
|
|
||||||
--tts-start-command 'notify-send "TTS started"'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⏱️ Таймеры (`--timer-*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--timer-started-command` | — | Таймер запущен |
|
|
||||||
| `--timer-updated-command` | — | Таймер изменён/пауза/возобновление |
|
|
||||||
| `--timer-cancelled-command` | — | Таймер отменён |
|
|
||||||
| `--timer-finished-command` | — | Таймер завершён |
|
|
||||||
| `--timer-finished-wav` | — | WAV файл для воспроизведения |
|
|
||||||
| `--timer-finished-wav-repeat` | `1 0` | `<повторы> <задержка_сек>` |
|
|
||||||
|
|
||||||
**Пример:**
|
|
||||||
```bash
|
|
||||||
--timer-finished-wav /path/to/timer.wav \
|
|
||||||
--timer-finished-wav-repeat 3 1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛰️ Спутник (`--uri`, `--name`, `--area`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--uri` | **требуется** | URI спутника (`tcp://0.0.0.0:10700`) |
|
|
||||||
| `--name` | Wyoming Satellite | Имя спутника |
|
|
||||||
| `--area` | — | Имя зоны (для Home Assistant) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Отладка и обнаружение (`--debug*`, `--zeroconf*`)
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|--------------|----------|
|
|
||||||
| `--debug` | false | DEBUG логирование |
|
|
||||||
| `--debug-recording-dir` | None | Папка для записи аудио |
|
|
||||||
| `--no-zeroconf` | false | Отключить Zeroconf |
|
|
||||||
| `--zeroconf-name` | MAC address | Имя для Zeroconf |
|
|
||||||
| `--zeroconf-host` | Авто | Хост для Zeroconf |
|
|
||||||
| `--log-format` | BASIC | Формат логов |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Режимы работы
|
|
||||||
|
|
||||||
### Режим 1: Always Streaming (без VAD, без wake)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--name 'my-satellite' \
|
|
||||||
--uri 'tcp://0.0.0.0:10700' \
|
|
||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Применение:** Минимальная задержка, постоянный стриминг в HA.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Режим 2: VAD Streaming (стрим после обнаружения речи)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--name 'my-satellite' \
|
|
||||||
--uri 'tcp://0.0.0.0:10700' \
|
|
||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--vad \
|
|
||||||
--vad-system webrtc \
|
|
||||||
--vad-threshold 0.5 \
|
|
||||||
--vad-energy-threshold 200
|
|
||||||
```
|
|
||||||
|
|
||||||
**Применение:** Приватность, стрим только при разговоре.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Режим 3: Wake Streaming (локальная детекция wake word)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--name 'my-satellite' \
|
|
||||||
--uri 'tcp://0.0.0.0:10700' \
|
|
||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--wake-uri tcp://127.0.0.1:10400 \
|
|
||||||
--wake-word-name "привет дроид" \
|
|
||||||
--wake-word-name "привет придурок"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Применение:** Стиль "Hey Google", полная локальная активация.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Режим 4: С Event Service (LED, MQTT, уведомления)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--name 'my-satellite' \
|
|
||||||
--uri 'tcp://0.0.0.0:10700' \
|
|
||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--wake-uri tcp://127.0.0.1:10400 \
|
|
||||||
--wake-word-name "привет дроид" \
|
|
||||||
--event-uri tcp://127.0.0.1:10500 \
|
|
||||||
--detection-command 'examples/commands/detection.sh' \
|
|
||||||
--transcript-command 'examples/commands/transcript.sh'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Применение:** Полная интеграция с LED, MQTT, внешними скриптами.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Архитектура](architecture/overview.md)
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [MQTT LED интеграция](integration/mqtt-led.md)
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
# 2-MIC HAT Tutorial
|
|
||||||
|
|
||||||
Создание голосового спутника на [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) и [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0).
|
|
||||||
|
|
||||||
**Предварительные требования:** См. [Руководство по развёртыванию](../../DEPLOY.md) для базовой установки.
|
|
||||||
|
|
||||||
## Установка ОС
|
|
||||||
|
|
||||||
Следуйте инструкциям по [установке Raspberry Pi OS](https://www.raspberrypi.com/software/). В разделе "Choose OS" выберите "Raspberry Pi OS (other)" и "Raspberry Pi OS (**64-bit**) Lite".
|
|
||||||
|
|
||||||
Когда спросят о применении настроек кастомизации, выберите "Edit Settings" и:
|
|
||||||
|
|
||||||
* Установите имя пользователя/пароль
|
|
||||||
* Настройте беспроводную LAN
|
|
||||||
* На вкладке Services включите SSH и используйте аутентификацию по паролю
|
|
||||||
|
|
||||||
## Установка ПО
|
|
||||||
|
|
||||||
После прошивки и загрузки спутника, подключитесь к нему по SSH, используя имя пользователя/пароль, настроенные при прошивке.
|
|
||||||
|
|
||||||
**На спутнике**, убедитесь, что системные зависимости установлены:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --no-install-recommends \
|
|
||||||
git \
|
|
||||||
python3-venv
|
|
||||||
```
|
|
||||||
|
|
||||||
Склонируйте репозиторий `wyoming-satellite`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/rhasspy/wyoming-satellite.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у вас ReSpeaker 2Mic или 4Mic HAT, перекомпилируйте и установите драйверы (это займёт много времени):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd wyoming-satellite/
|
|
||||||
sudo bash etc/install-respeaker-drivers.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
После установки драйверов необходимо перезагрузить спутник:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
После перезагрузки подключитесь снова по SSH и продолжите установку:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd wyoming-satellite/
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip3 install --upgrade pip
|
|
||||||
.venv/bin/pip3 install --upgrade wheel setuptools
|
|
||||||
.venv/bin/pip3 install \
|
|
||||||
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
|
|
||||||
-e '.[all]'
|
|
||||||
```
|
|
||||||
|
|
||||||
Если установка прошла успешно, вы сможете запустить:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
script/run --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Определение аудиоустройств
|
|
||||||
|
|
||||||
Правильный выбор устройств микрофона/динамика критически важен для работы спутника. В этом разделе мы сделаем тестовую запись и воспроизведение.
|
|
||||||
|
|
||||||
Список доступных микрофонов:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
arecord -L
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у вас ReSpeaker 2Mic HAT, вы увидите:
|
|
||||||
|
|
||||||
```
|
|
||||||
plughw:CARD=seeed2micvoicec,DEV=0
|
|
||||||
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
|
|
||||||
Hardware device with all software conversions
|
|
||||||
```
|
|
||||||
|
|
||||||
Для других микрофонов предпочитайте устройства, начинающиеся с `plughw:` или используйте `default`, если не знаете, что выбрать.
|
|
||||||
|
|
||||||
Запишите 5-секундный образец с выбранного микрофона:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t wav -d 5 test.wav
|
|
||||||
```
|
|
||||||
|
|
||||||
Скажите что-нибудь во время работы `arecord`. Если получили ошибки, попробуйте другое устройство микрофона, изменив `-D <device>`.
|
|
||||||
|
|
||||||
Список доступных динамиков:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
aplay -L
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у вас ReSpeaker 2Mic HAT, вы увидите:
|
|
||||||
|
|
||||||
```
|
|
||||||
plughw:CARD=seeed2micvoicec,DEV=0
|
|
||||||
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
|
|
||||||
Hardware device with all software conversions
|
|
||||||
```
|
|
||||||
|
|
||||||
Для других динамиков предпочитайте устройства, начинающиеся с `plughw:` или используйте `default`, если не знаете, что выбрать.
|
|
||||||
|
|
||||||
Воспроизведите записанный образец WAV:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
aplay -D plughw:CARD=seeed2micvoicec,DEV=0 test.wav
|
|
||||||
```
|
|
||||||
|
|
||||||
Вы должны услышать записанный образец. Если есть проблемы, попробуйте другое устройство динамика, изменив `-D <device>`.
|
|
||||||
|
|
||||||
Запомните устройства микрофона и динамика для следующего шага.
|
|
||||||
|
|
||||||
## Запуск спутника
|
|
||||||
|
|
||||||
В директории `wyoming-satellite` выполните:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
script/run \
|
|
||||||
--debug \
|
|
||||||
--name 'my satellite' \
|
|
||||||
--uri 'tcp://0.0.0.0:10700' \
|
|
||||||
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
|
|
||||||
```
|
|
||||||
|
|
||||||
Измените `-D <device>` для `arecord` и `aplay` в соответствии с аудиоустройствами из предыдущего раздела.
|
|
||||||
|
|
||||||
Вы можете установить `--name <NAME>` на любое значение, но оно должно оставаться одинаковым при каждом запуске спутника.
|
|
||||||
|
|
||||||
В Home Assistant проверьте раздел "Devices & services" в настройках. Через некоторое время вы увидите ваш спутник как "Discovered" (Wyoming Protocol). Нажмите кнопку "Configure" и "Submit". Выберите зону, где расположен ваш спутник, и нажмите "Finish".
|
|
||||||
|
|
||||||
Ваш спутник должен сказать "Streaming audio", и вы можете использовать wake word предпочитаемого пайплайна.
|
|
||||||
|
|
||||||
## Создание сервисов
|
|
||||||
|
|
||||||
Вы можете запустить wyoming-satellite как systemd сервис, создав файл сервиса:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl edit --force --full wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Unit]
|
|
||||||
Description=Wyoming Satellite
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/home/pi/wyoming-satellite/script/run --name 'my satellite' --uri 'tcp://0.0.0.0:10700' --mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' --snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
|
|
||||||
WorkingDirectory=/home/pi/wyoming-satellite
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Сохраните файл и выйдите из редактора. Далее включите сервис для автозагрузки и запустите:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl enable --now wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
(возможно, вам придётся нажать CTRL+C, чтобы вернуться к командной оболочке)
|
|
||||||
|
|
||||||
После запуска сервиса вы можете просматривать логи в реальном времени:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
journalctl -u wyoming-satellite.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
При необходимости отключите и остановите сервис:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl disable --now wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## Аудио улучшения
|
|
||||||
|
|
||||||
Вы можете запустить спутник с автоматическим усилением и шумоподавлением:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
script/run \
|
|
||||||
... \
|
|
||||||
--mic-auto-gain 5 \
|
|
||||||
--mic-noise-suppression 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Автоматическое усиление от 0 до 31 dbFS, где 31 — самое громкое.
|
|
||||||
Шумоподавление от 0 до 4, где 4 — максимальное подавление (может вызывать искажения аудио).
|
|
||||||
|
|
||||||
Вы также можете использовать `--mic-volume-multiplier X` для умножения всех аудиосэмплов на `X`. Например, использование 2 для `X` удвоит громкость микрофона (но может вызвать искажения). Соответствующий `--snd-volume-multiplier` делает то же самое для воспроизведения аудио.
|
|
||||||
|
|
||||||
## Локальная детекция Wake Word
|
|
||||||
|
|
||||||
Установите необходимые системные зависимости:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --no-install-recommends \
|
|
||||||
libopenblas-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Из домашней директории установите Wyoming сервис openWakeWord:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/rhasspy/wyoming-openwakeword.git
|
|
||||||
cd wyoming-openwakeword
|
|
||||||
script/setup
|
|
||||||
```
|
|
||||||
|
|
||||||
Создайте systemd сервис для него:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl edit --force --full wyoming-openwakeword.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Unit]
|
|
||||||
Description=Wyoming openWakeWord
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/home/pi/wyoming-openwakeword/script/run --uri 'tcp://127.0.0.1:10400'
|
|
||||||
WorkingDirectory=/home/pi/wyoming-openwakeword
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Сохраните файл и выйдите из редактора.
|
|
||||||
|
|
||||||
Теперь вы можете обновить сервис спутника:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl edit --force --full wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Обновите только следующие части:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Unit]
|
|
||||||
...
|
|
||||||
Requires=wyoming-openwakeword.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
...
|
|
||||||
ExecStart=/home/pi/wyoming-satellite/script/run ... --wake-uri 'tcp://127.0.0.1:10400' --wake-word-name 'ok_nabu'
|
|
||||||
...
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Перезагрузите и перезапустите сервис спутника:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl restart wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Вы должны увидеть, что сервис wake загрузился автоматически:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl status wyoming-satellite.service wyoming-openwakeword.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Все они должны быть "active (running)" и зелёные.
|
|
||||||
|
|
||||||
Протестируйте спутник, сказав "ok, nabu" и голосовую команду. Используйте `journalctl` для проверки логов сервисов на наличие ошибок:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
journalctl -u wyoming-openwakeword.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Убедитесь, что запускаете `sudo systemctl daemon-reload` каждый раз при внесении изменений в сервис.
|
|
||||||
|
|
||||||
## LED сервис
|
|
||||||
|
|
||||||
Примеры сервисов событий для ReSpeaker 2Mic и 4Mic HAT включены в `wyoming-satellite/examples`, они изменяют цвет LED в зависимости от состояния спутника. Пример ниже для 2Mic HAT с использованием `2mic_service.py`. Если вы используете 4Mic HAT, используйте `4mic_service.py`, так как светодиоды и GPIO пины немного отличаются.
|
|
||||||
|
|
||||||
Установите его из домашней директории:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd wyoming-satellite/examples
|
|
||||||
python3 -m venv --system-site-packages .venv
|
|
||||||
.venv/bin/pip3 install --upgrade pip
|
|
||||||
.venv/bin/pip3 install --upgrade wheel setuptools
|
|
||||||
.venv/bin/pip3 install 'wyoming==1.5.2'
|
|
||||||
```
|
|
||||||
|
|
||||||
Если вы используете ReSpeaker USB 4mic array v2.0, дополнительно установите pixel-ring:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
.venv/bin/pip3 install 'pixel-ring'
|
|
||||||
```
|
|
||||||
|
|
||||||
Аргумент `--system-site-packages` используется для доступа к предустановленным пакетам `gpiozero` и `spidev`. Если они **ещё не установлены** в вашей системе, выполните:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-get install python3-spidev python3-gpiozero
|
|
||||||
```
|
|
||||||
|
|
||||||
Протестируйте сервис:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
.venv/bin/python3 2mic_service.py --help
|
|
||||||
```
|
|
||||||
|
|
||||||
Создайте systemd сервис:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl edit --force --full 2mic_leds.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Вставьте следующий шаблон и измените `/home/pi` и аргументы `script/run` в соответствии с вашей настройкой:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Unit]
|
|
||||||
Description=2Mic LEDs
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/home/pi/wyoming-satellite/examples/.venv/bin/python3 2mic_service.py --uri 'tcp://127.0.0.1:10500'
|
|
||||||
WorkingDirectory=/home/pi/wyoming-satellite/examples
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Сохраните файл и выйдите из редактора.
|
|
||||||
|
|
||||||
Теперь вы можете обновить сервис спутника:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl edit --force --full wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Обновите только следующие части:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Unit]
|
|
||||||
...
|
|
||||||
Requires=2mic_leds.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
...
|
|
||||||
ExecStart=/home/pi/wyoming-satellite/script/run ... --event-uri 'tcp://127.0.0.1:10500'
|
|
||||||
...
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Перезагрузите и перезапустите сервис спутника:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl restart wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Вы должны увидеть, что сервис загрузился автоматически:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo systemctl status wyoming-satellite.service 2mic_leds.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Все они должны быть "active (running)" и зелёные.
|
|
||||||
|
|
||||||
Попробуйте голосовую команду и посмотрите, реагируют ли светодиоды. Используйте `journalctl` для проверки логов сервисов на наличие ошибок:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
journalctl -u 2mic_leds.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Если возникли проблемы, добавьте аргумент `--debug` в командную строку для увеличения уровня логирования.
|
|
||||||
|
|
||||||
Для управления яркостью светодиодов используйте аргумент `--led-brightness`, который принимает целые числа от 1 до 31.
|
|
||||||
|
|
||||||
Убедитесь, что запускаете `sudo systemctl daemon-reload` каждый раз при внесении изменений в сервис.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [Интерактивный установщик](installer.md)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Интерактивный установщик
|
|
||||||
|
|
||||||
Создание голосового спутника на Raspberry Pi 3+ с USB микрофоном и динамиками.
|
|
||||||
|
|
||||||
**Предварительные требования:** См. [Руководство по развёртыванию](../../DEPLOY.md) для базовой установки.
|
|
||||||
|
|
||||||
## Установка ОС
|
|
||||||
|
|
||||||
Следуйте инструкциям по [установке Raspberry Pi OS](https://www.raspberrypi.com/software/). В разделе "Choose OS" выберите "Raspberry Pi OS (other)" и "Raspberry Pi OS (**64-bit**) Lite".
|
|
||||||
|
|
||||||
Когда спросят о применении настроек кастомизации, выберите "Edit Settings" и:
|
|
||||||
|
|
||||||
* Установите имя пользователя/пароль
|
|
||||||
* Настройте беспроводную LAN
|
|
||||||
* На вкладке Services включите SSH и используйте аутентификацию по паролю
|
|
||||||
|
|
||||||
## Установка зависимостей
|
|
||||||
|
|
||||||
После прошивки и загрузки спутника, подключитесь к нему по SSH, используя имя пользователя/пароль, настроенные при прошивке.
|
|
||||||
|
|
||||||
**На спутнике**, убедитесь, что системные зависимости установлены:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --no-install-recommends \
|
|
||||||
git \
|
|
||||||
python3-venv
|
|
||||||
```
|
|
||||||
|
|
||||||
Склонируйте репозиторий `wyoming-satellite`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/rhasspy/wyoming-satellite.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Теперь вы можете запустить установщик:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd wyoming-satellite/
|
|
||||||
python3 -m installer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Спутник
|
|
||||||
|
|
||||||
В меню "Satellite" выберите "Satellite Type" и выберите "Local wake word detection" (пробелом).
|
|
||||||
|
|
||||||
## Микрофон
|
|
||||||
|
|
||||||
Убедитесь, что USB микрофон подключён.
|
|
||||||
|
|
||||||
В меню "Microphone" выберите "Autodetect" и громко поговорите в микрофон в течение нескольких секунд.
|
|
||||||
|
|
||||||
В "Audio Settings" установите "Noise Suppression" в "Medium" и "Auto Gain" в 15.
|
|
||||||
|
|
||||||
## Динамики
|
|
||||||
|
|
||||||
Убедитесь, что USB динамики подключены.
|
|
||||||
|
|
||||||
В меню "Speakers" выберите "Test All Speakers". Для каждого устройства выберите "Play Sound" и прослушайте. Если слышите звук, выберите "Choose This Device"; иначе выберите "Next Device".
|
|
||||||
|
|
||||||
В "Toggle Feedback Sounds" включите "On wake-up" и "After voice command" (пробелом).
|
|
||||||
|
|
||||||
## Wake Word
|
|
||||||
|
|
||||||
В меню "Wake Word" выберите "Wake Word System" и выберите "openWakeWord" (пробелом). Когда будет предложено, установите openWakeWord.
|
|
||||||
|
|
||||||
В меню "openWakeWord" выберите "Download Community Wake Words". Вернитесь в меню "Wake Word", выберите "Choose Wake Word" и выберите одну фразу (пробелом).
|
|
||||||
|
|
||||||
## Применение настроек
|
|
||||||
|
|
||||||
В главном меню выберите "Apply Settings" и введите пароль.
|
|
||||||
|
|
||||||
Проверьте логи:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo journalctl -f -u wyoming-satellite.service
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [Руководство по развёртыванию](../../DEPLOY.md)
|
|
||||||
- [2-MIC HAT Tutorial](2mic-hat.md)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
|
|
||||||
# Must be run with sudo.
|
|
||||||
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
|
||||||
|
|
||||||
set -eo pipefail
|
|
||||||
|
|
||||||
kernel_formatted="$(uname -r | cut -f1,2 -d.)"
|
|
||||||
driver_url_status="$(curl -ILs https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz | tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
|
|
||||||
|
|
||||||
if [ ! "$driver_url_status" = 200 ]; then
|
|
||||||
echo "Could not find driver for kernel $kernel_formatted"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
apt-get update
|
|
||||||
apt-get install --no-install-recommends --yes \
|
|
||||||
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
|
||||||
|
|
||||||
temp_dir="$(mktemp -d)"
|
|
||||||
|
|
||||||
function finish {
|
|
||||||
rm -rf "${temp_dir}"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap finish EXIT
|
|
||||||
|
|
||||||
pushd "${temp_dir}"
|
|
||||||
|
|
||||||
# Download source code to temporary directory
|
|
||||||
# NOTE: There are different branches in the repo for different kernel versions.
|
|
||||||
echo 'Downloading source code'
|
|
||||||
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
|
|
||||||
tar -xzf -
|
|
||||||
|
|
||||||
cd seeed-voicecard-"$kernel_formatted"/
|
|
||||||
|
|
||||||
# 1. Build kernel module
|
|
||||||
echo 'Building kernel module'
|
|
||||||
ver='0.3'
|
|
||||||
mod='seeed-voicecard'
|
|
||||||
src='./'
|
|
||||||
kernel="$(uname -r)"
|
|
||||||
marker='0.0.0'
|
|
||||||
threads="$(getconf _NPROCESSORS_ONLN)"
|
|
||||||
memory="$(LANG=C free -m|awk '/^Mem:/{print $2}')"
|
|
||||||
|
|
||||||
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
|
|
||||||
threads=2
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "/usr/src/${mod}-${ver}"
|
|
||||||
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
|
|
||||||
|
|
||||||
dkms add -m "${mod}" -v "${ver}"
|
|
||||||
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
|
|
||||||
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir -p "/var/lib/dkms/${mod}/${ver}/${marker}"
|
|
||||||
|
|
||||||
# 2. Install kernel module and configure
|
|
||||||
echo 'Updating boot configuration'
|
|
||||||
config='/boot/config.txt'
|
|
||||||
|
|
||||||
cp seeed-*-voicecard.dtbo /boot/overlays
|
|
||||||
grep -q "^snd-soc-ac108$" /etc/modules || echo "snd-soc-ac108" >> /etc/modules
|
|
||||||
sed -i -e 's:#dtparam=i2c_arm=on:dtparam=i2c_arm=on:g' "${config}"
|
|
||||||
echo "dtoverlay=i2s-mmap" >> "${config}"
|
|
||||||
echo "dtparam=i2s=on" >> "${config}"
|
|
||||||
mkdir -p /etc/voicecard
|
|
||||||
cp *.conf *.state /etc/voicecard
|
|
||||||
cp seeed-voicecard /usr/bin/
|
|
||||||
cp seeed-voicecard.service /lib/systemd/system/
|
|
||||||
systemctl enable --now seeed-voicecard.service
|
|
||||||
|
|
||||||
echo 'Done. Please reboot the system.'
|
|
||||||
popd
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Controls the LEDs on the ReSpeaker 2mic HAT, inspired by smart speakers like Alexa and Alice."""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from functools import partial
|
|
||||||
from math import ceil
|
|
||||||
from typing import Tuple, List, Optional
|
|
||||||
import gpiozero
|
|
||||||
import spidev
|
|
||||||
from wyoming.asr import Transcript
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.satellite import (
|
|
||||||
RunSatellite,
|
|
||||||
SatelliteConnected,
|
|
||||||
SatelliteDisconnected,
|
|
||||||
StreamingStarted,
|
|
||||||
StreamingStopped,
|
|
||||||
)
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
NUM_LEDS = 3
|
|
||||||
LEDS_GPIO = 12
|
|
||||||
RGB_MAP = {
|
|
||||||
"rgb": [3, 2, 1],
|
|
||||||
"rbg": [3, 1, 2],
|
|
||||||
"grb": [2, 3, 1],
|
|
||||||
"gbr": [2, 1, 3],
|
|
||||||
"brg": [1, 3, 2],
|
|
||||||
"bgr": [1, 2, 3],
|
|
||||||
}
|
|
||||||
async def main() -> None:
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
||||||
parser.add_argument(
|
|
||||||
"--led-brightness",
|
|
||||||
type=int,
|
|
||||||
choices=range(1, 32),
|
|
||||||
default=31,
|
|
||||||
help="LED brightness (integer from 1 to 31)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
_LOGGER.info("Ready")
|
|
||||||
# Turn on power to LEDs
|
|
||||||
led_power = gpiozero.LED(LEDS_GPIO, active_high=False)
|
|
||||||
led_power.on()
|
|
||||||
leds = APA102(num_led=NUM_LEDS, global_brightness=args.led_brightness)
|
|
||||||
# Start server
|
|
||||||
server = AsyncServer.from_uri(args.uri)
|
|
||||||
try:
|
|
||||||
await server.run(partial(LEDsEventHandler, args, leds))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
leds.cleanup()
|
|
||||||
led_power.off()
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
_BLACK = (0, 0, 0)
|
|
||||||
_WHITE = (255, 255, 255)
|
|
||||||
_RED = (255, 0, 0)
|
|
||||||
_YELLOW = (255, 255, 0)
|
|
||||||
_BLUE = (0, 0, 255)
|
|
||||||
_GREEN = (0, 255, 0)
|
|
||||||
_PURPLE = (128, 0, 128)
|
|
||||||
_ORANGE = (255, 165, 0)
|
|
||||||
_CYAN = (0, 255, 255)
|
|
||||||
class LEDsEventHandler(AsyncEventHandler):
|
|
||||||
"""Event handler for clients, with animations inspired by Alexa and Alice."""
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
leds: "APA102",
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.client_id = str(time.monotonic_ns())
|
|
||||||
self.leds = leds
|
|
||||||
self.voice_active = False
|
|
||||||
self.voice_animation_task: Optional[asyncio.Task] = None
|
|
||||||
self.streaming_active = False
|
|
||||||
self.streaming_animation_task: Optional[asyncio.Task] = None
|
|
||||||
_LOGGER.debug("Client connected: %s", self.client_id)
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
_LOGGER.debug(event)
|
|
||||||
if StreamingStarted.is_type(event.type):
|
|
||||||
self.streaming_active = True
|
|
||||||
if self.streaming_animation_task is None or self.streaming_animation_task.done():
|
|
||||||
self.streaming_animation_task = asyncio.create_task(self.streaming_animation_loop())
|
|
||||||
elif Detection.is_type(event.type):
|
|
||||||
# Как Alexa: directional blue spotlight for wake/listening
|
|
||||||
await self.rotate_colors([_BLUE, _CYAN, _BLACK], rotations=5, delay=0.2) # Вращение для "просыпания"
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif VoiceStarted.is_type(event.type):
|
|
||||||
self.voice_active = True
|
|
||||||
if self.voice_animation_task is None or self.voice_animation_task.done():
|
|
||||||
self.voice_animation_task = asyncio.create_task(self.voice_animation_loop())
|
|
||||||
elif Transcript.is_type(event.type):
|
|
||||||
# Как Alexa/Alice: Зеленый успех или эмоция (мигание зеленым для подтверждения)
|
|
||||||
await self.pulse_color(_GREEN, pulses=2, duration=0.8)
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif VoiceStopped.is_type(event.type):
|
|
||||||
self.voice_active = False
|
|
||||||
if self.voice_animation_task:
|
|
||||||
self.voice_animation_task.cancel()
|
|
||||||
self.voice_animation_task = None
|
|
||||||
await self.fade_out(_BLUE, steps=10, delay=0.15) # Затухание синего, как конец обработки
|
|
||||||
elif StreamingStopped.is_type(event.type):
|
|
||||||
self.streaming_active = False
|
|
||||||
if self.streaming_animation_task:
|
|
||||||
self.streaming_animation_task.cancel()
|
|
||||||
self.streaming_animation_task = None
|
|
||||||
await self.fade_out(_BLUE, steps=10, delay=0.15)
|
|
||||||
elif RunSatellite.is_type(event.type):
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif SatelliteConnected.is_type(event.type):
|
|
||||||
# Разовая анимация при подключении, без loop, чтобы в idle не светилось
|
|
||||||
await self.pulse_color(_GREEN, pulses=2, duration=1.5)
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif SatelliteDisconnected.is_type(event.type):
|
|
||||||
await self.pulse_color(_RED, pulses=3, duration=0.5) # Красный пульс для ошибки, как Alexa purple/red
|
|
||||||
self.color(_BLACK)
|
|
||||||
return True
|
|
||||||
async def voice_animation_loop(self) -> None:
|
|
||||||
"""Loop анимации для VoiceStarted: как Alexa listening/thinking - alternating blue/cyan."""
|
|
||||||
try:
|
|
||||||
while self.voice_active:
|
|
||||||
await self.gradient([_BLUE, _CYAN, _BLUE], duration=1.5) # Градиент для "thinking"
|
|
||||||
await self.rotate_colors([_CYAN, _BLUE, _BLACK], rotations=1, delay=0.3) # Вращение для активности
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self.color(_BLACK)
|
|
||||||
async def streaming_animation_loop(self) -> None:
|
|
||||||
"""Loop для StreamingStarted: pulsing blue как Alexa processing."""
|
|
||||||
try:
|
|
||||||
while self.streaming_active:
|
|
||||||
await self.pulse_color(_BLUE, pulses=1, duration=0.8)
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self.color(_BLACK)
|
|
||||||
def color(self, rgb: Tuple[int, int, int]) -> None:
|
|
||||||
for i in range(NUM_LEDS):
|
|
||||||
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
|
|
||||||
self.leds.show()
|
|
||||||
def set_colors(self, colors: List[Tuple[int, int, int]]) -> None:
|
|
||||||
for i in range(min(NUM_LEDS, len(colors))):
|
|
||||||
self.leds.set_pixel(i, colors[i][0], colors[i][1], colors[i][2])
|
|
||||||
self.leds.show()
|
|
||||||
async def pulse_color(self, rgb: Tuple[int, int, int], pulses: int = 3, duration: float = 0.5) -> None:
|
|
||||||
steps = 10
|
|
||||||
for _ in range(pulses):
|
|
||||||
for bright in range(steps):
|
|
||||||
self.set_pixel_bright(rgb, (bright + 1) / steps * 100)
|
|
||||||
await asyncio.sleep(duration / (2 * steps))
|
|
||||||
for bright in range(steps, 0, -1):
|
|
||||||
self.set_pixel_bright(rgb, (bright - 1) / steps * 100)
|
|
||||||
await asyncio.sleep(duration / (2 * steps))
|
|
||||||
def set_pixel_bright(self, rgb: Tuple[int, int, int], bright_percent: float) -> None:
|
|
||||||
for i in range(NUM_LEDS):
|
|
||||||
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2], bright_percent)
|
|
||||||
self.leds.show()
|
|
||||||
async def rainbow_cycle(self, steps: int = 20, delay: float = 0.05) -> None:
|
|
||||||
for step in range(steps):
|
|
||||||
colors = []
|
|
||||||
for i in range(NUM_LEDS):
|
|
||||||
r = int((step + i * 85) % 255)
|
|
||||||
g = int((step + i * 85 + 85) % 255)
|
|
||||||
b = int((step + i * 85 + 170) % 255)
|
|
||||||
colors.append((r, g, b))
|
|
||||||
self.set_colors(colors)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
self.color(_BLACK)
|
|
||||||
async def gradient(self, colors: List[Tuple[int, int, int]], duration: float = 1.0) -> None:
|
|
||||||
steps = 10
|
|
||||||
for step in range(steps):
|
|
||||||
blended_colors = []
|
|
||||||
for i in range(NUM_LEDS):
|
|
||||||
idx = i % len(colors)
|
|
||||||
next_idx = (idx + 1) % len(colors)
|
|
||||||
r = int(colors[idx][0] + (colors[next_idx][0] - colors[idx][0]) * step / steps)
|
|
||||||
g = int(colors[idx][1] + (colors[next_idx][1] - colors[idx][1]) * step / steps)
|
|
||||||
b = int(colors[idx][2] + (colors[next_idx][2] - colors[idx][2]) * step / steps)
|
|
||||||
blended_colors.append((r, g, b))
|
|
||||||
self.set_colors(blended_colors)
|
|
||||||
await asyncio.sleep(duration / steps)
|
|
||||||
async def fade_out(self, start_rgb: Tuple[int, int, int], steps: int = 10, delay: float = 0.1) -> None:
|
|
||||||
for step in range(steps, 0, -1):
|
|
||||||
r = int(start_rgb[0] * step / steps)
|
|
||||||
g = int(start_rgb[1] * step / steps)
|
|
||||||
b = int(start_rgb[2] * step / steps)
|
|
||||||
self.color((r, g, b))
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
self.color(_BLACK)
|
|
||||||
async def rotate_colors(self, colors: List[Tuple[int, int, int]], rotations: int = 3, delay: float = 0.2) -> None:
|
|
||||||
current_colors = colors[:NUM_LEDS]
|
|
||||||
for _ in range(rotations):
|
|
||||||
self.set_colors(current_colors)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
current_colors = current_colors[-1:] + current_colors[:-1]
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
class APA102:
|
|
||||||
"""
|
|
||||||
Driver for APA102 LEDS (aka "DotStar").
|
|
||||||
(c) Martin Erzberger 2016-2017
|
|
||||||
"""
|
|
||||||
# Constants
|
|
||||||
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
|
|
||||||
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
num_led,
|
|
||||||
global_brightness,
|
|
||||||
order="rgb",
|
|
||||||
bus=0,
|
|
||||||
device=1,
|
|
||||||
max_speed_hz=8000000,
|
|
||||||
):
|
|
||||||
self.num_led = num_led # The number of LEDs in the Strip
|
|
||||||
order = order.lower()
|
|
||||||
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
|
|
||||||
# Limit the brightness to the maximum if it's set higher
|
|
||||||
if global_brightness > self.MAX_BRIGHTNESS:
|
|
||||||
self.global_brightness = self.MAX_BRIGHTNESS
|
|
||||||
else:
|
|
||||||
self.global_brightness = global_brightness
|
|
||||||
_LOGGER.debug("LED brightness: %d", self.global_brightness)
|
|
||||||
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
|
|
||||||
self.spi = spidev.SpiDev() # Init the SPI device
|
|
||||||
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
|
|
||||||
# Up the speed a bit, so that the LEDs are painted faster
|
|
||||||
if max_speed_hz:
|
|
||||||
self.spi.max_speed_hz = max_speed_hz
|
|
||||||
def clock_start_frame(self):
|
|
||||||
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
|
|
||||||
def clock_end_frame(self):
|
|
||||||
self.spi.xfer2([0xFF] * 4)
|
|
||||||
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
|
|
||||||
if led_num < 0:
|
|
||||||
return # Pixel is invisible, so ignore
|
|
||||||
if led_num >= self.num_led:
|
|
||||||
return # again, invisible
|
|
||||||
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
|
|
||||||
ledstart = (brightness & 0b00011111) | self.LED_START
|
|
||||||
start_index = 4 * led_num
|
|
||||||
self.leds[start_index] = ledstart
|
|
||||||
self.leds[start_index + self.rgb[0]] = red
|
|
||||||
self.leds[start_index + self.rgb[1]] = green
|
|
||||||
self.leds[start_index + self.rgb[2]] = blue
|
|
||||||
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
|
|
||||||
self.set_pixel(
|
|
||||||
led_num,
|
|
||||||
(rgb_color & 0xFF0000) >> 16,
|
|
||||||
(rgb_color & 0x00FF00) >> 8,
|
|
||||||
rgb_color & 0x0000FF,
|
|
||||||
bright_percent,
|
|
||||||
)
|
|
||||||
def rotate(self, positions=1):
|
|
||||||
cutoff = 4 * (positions % self.num_led)
|
|
||||||
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
|
|
||||||
def show(self):
|
|
||||||
self.clock_start_frame()
|
|
||||||
data = list(self.leds)
|
|
||||||
while data:
|
|
||||||
self.spi.xfer2(data[:32])
|
|
||||||
data = data[32:]
|
|
||||||
self.clock_end_frame()
|
|
||||||
def cleanup(self):
|
|
||||||
self.spi.close() # Close SPI port
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Controls the LEDs on the ReSpeaker 4mic HAT."""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from functools import partial
|
|
||||||
from math import ceil
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
import gpiozero
|
|
||||||
import spidev
|
|
||||||
from wyoming.asr import Transcript
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.satellite import (
|
|
||||||
RunSatellite,
|
|
||||||
SatelliteConnected,
|
|
||||||
SatelliteDisconnected,
|
|
||||||
StreamingStarted,
|
|
||||||
StreamingStopped,
|
|
||||||
)
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
|
||||||
from wyoming.vad import VoiceStarted
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
NUM_LEDS = 12
|
|
||||||
LEDS_GPIO = 5
|
|
||||||
RGB_MAP = {
|
|
||||||
"rgb": [3, 2, 1],
|
|
||||||
"rbg": [3, 1, 2],
|
|
||||||
"grb": [2, 3, 1],
|
|
||||||
"gbr": [2, 1, 3],
|
|
||||||
"brg": [1, 3, 2],
|
|
||||||
"bgr": [1, 2, 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
|
||||||
#
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
|
|
||||||
_LOGGER.info("Ready")
|
|
||||||
|
|
||||||
# Turn on power to LEDs
|
|
||||||
led_power = gpiozero.LED(LEDS_GPIO, active_high=True)
|
|
||||||
led_power.on()
|
|
||||||
|
|
||||||
leds = APA102(num_led=NUM_LEDS)
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
server = AsyncServer.from_uri(args.uri)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await server.run(partial(LEDsEventHandler, args, leds))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
leds.cleanup()
|
|
||||||
led_power.off()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_BLACK = (0, 0, 0)
|
|
||||||
_WHITE = (255, 255, 255)
|
|
||||||
_RED = (255, 0, 0)
|
|
||||||
_YELLOW = (255, 255, 0)
|
|
||||||
_BLUE = (0, 0, 255)
|
|
||||||
_GREEN = (0, 255, 0)
|
|
||||||
|
|
||||||
|
|
||||||
class LEDsEventHandler(AsyncEventHandler):
|
|
||||||
"""Event handler for clients."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
leds: "APA102",
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.client_id = str(time.monotonic_ns())
|
|
||||||
self.leds = leds
|
|
||||||
|
|
||||||
_LOGGER.debug("Client connected: %s", self.client_id)
|
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
_LOGGER.debug(event)
|
|
||||||
|
|
||||||
if StreamingStarted.is_type(event.type):
|
|
||||||
self.color(_YELLOW)
|
|
||||||
elif Detection.is_type(event.type):
|
|
||||||
self.color(_BLUE)
|
|
||||||
await asyncio.sleep(1.0) # show for 1 sec
|
|
||||||
elif VoiceStarted.is_type(event.type):
|
|
||||||
self.color(_YELLOW)
|
|
||||||
elif Transcript.is_type(event.type):
|
|
||||||
self.color(_GREEN)
|
|
||||||
await asyncio.sleep(1.0) # show for 1 sec
|
|
||||||
elif StreamingStopped.is_type(event.type):
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif RunSatellite.is_type(event.type):
|
|
||||||
self.color(_BLACK)
|
|
||||||
elif SatelliteConnected.is_type(event.type):
|
|
||||||
# Flash
|
|
||||||
for _ in range(3):
|
|
||||||
self.color(_GREEN)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
self.color(_BLACK)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
elif SatelliteDisconnected.is_type(event.type):
|
|
||||||
self.color(_RED)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def color(self, rgb: Tuple[int, int, int]) -> None:
|
|
||||||
for i in range(NUM_LEDS):
|
|
||||||
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
|
|
||||||
|
|
||||||
self.leds.show()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class APA102:
|
|
||||||
"""
|
|
||||||
Driver for APA102 LEDS (aka "DotStar").
|
|
||||||
(c) Martin Erzberger 2016-2017
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
|
|
||||||
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
num_led,
|
|
||||||
global_brightness=MAX_BRIGHTNESS,
|
|
||||||
order="rgb",
|
|
||||||
bus=0,
|
|
||||||
device=1,
|
|
||||||
max_speed_hz=8000000,
|
|
||||||
):
|
|
||||||
self.num_led = num_led # The number of LEDs in the Strip
|
|
||||||
order = order.lower()
|
|
||||||
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
|
|
||||||
# Limit the brightness to the maximum if it's set higher
|
|
||||||
if global_brightness > self.MAX_BRIGHTNESS:
|
|
||||||
self.global_brightness = self.MAX_BRIGHTNESS
|
|
||||||
else:
|
|
||||||
self.global_brightness = global_brightness
|
|
||||||
|
|
||||||
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
|
|
||||||
self.spi = spidev.SpiDev() # Init the SPI device
|
|
||||||
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
|
|
||||||
# Up the speed a bit, so that the LEDs are painted faster
|
|
||||||
if max_speed_hz:
|
|
||||||
self.spi.max_speed_hz = max_speed_hz
|
|
||||||
|
|
||||||
def clock_start_frame(self):
|
|
||||||
"""Sends a start frame to the LED strip.
|
|
||||||
|
|
||||||
This method clocks out a start frame, telling the receiving LED
|
|
||||||
that it must update its own color now.
|
|
||||||
"""
|
|
||||||
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
|
|
||||||
|
|
||||||
def clock_end_frame(self):
|
|
||||||
"""Sends an end frame to the LED strip.
|
|
||||||
|
|
||||||
As explained above, dummy data must be sent after the last real colour
|
|
||||||
information so that all of the data can reach its destination down the line.
|
|
||||||
The delay is not as bad as with the human example above.
|
|
||||||
It is only 1/2 bit per LED. This is because the SPI clock line
|
|
||||||
needs to be inverted.
|
|
||||||
|
|
||||||
Say a bit is ready on the SPI data line. The sender communicates
|
|
||||||
this by toggling the clock line. The bit is read by the LED
|
|
||||||
and immediately forwarded to the output data line. When the clock goes
|
|
||||||
down again on the input side, the LED will toggle the clock up
|
|
||||||
on the output to tell the next LED that the bit is ready.
|
|
||||||
|
|
||||||
After one LED the clock is inverted, and after two LEDs it is in sync
|
|
||||||
again, but one cycle behind. Therefore, for every two LEDs, one bit
|
|
||||||
of delay gets accumulated. For 300 LEDs, 150 additional bits must be fed to
|
|
||||||
the input of LED one so that the data can reach the last LED.
|
|
||||||
|
|
||||||
Ultimately, we need to send additional numLEDs/2 arbitrary data bits,
|
|
||||||
in order to trigger numLEDs/2 additional clock changes. This driver
|
|
||||||
sends zeroes, which has the benefit of getting LED one partially or
|
|
||||||
fully ready for the next update to the strip. An optimized version
|
|
||||||
of the driver could omit the "clockStartFrame" method if enough zeroes have
|
|
||||||
been sent as part of "clockEndFrame".
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.spi.xfer2([0xFF] * 4)
|
|
||||||
|
|
||||||
# Round up num_led/2 bits (or num_led/16 bytes)
|
|
||||||
# for _ in range((self.num_led + 15) // 16):
|
|
||||||
# self.spi.xfer2([0x00])
|
|
||||||
|
|
||||||
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
|
|
||||||
"""Sets the color of one pixel in the LED stripe.
|
|
||||||
|
|
||||||
The changed pixel is not shown yet on the Stripe, it is only
|
|
||||||
written to the pixel buffer. Colors are passed individually.
|
|
||||||
If brightness is not set the global brightness setting is used.
|
|
||||||
"""
|
|
||||||
if led_num < 0:
|
|
||||||
return # Pixel is invisible, so ignore
|
|
||||||
if led_num >= self.num_led:
|
|
||||||
return # again, invisible
|
|
||||||
|
|
||||||
# Calculate pixel brightness as a percentage of the
|
|
||||||
# defined global_brightness. Round up to nearest integer
|
|
||||||
# as we expect some brightness unless set to 0
|
|
||||||
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
|
|
||||||
|
|
||||||
# LED startframe is three "1" bits, followed by 5 brightness bits
|
|
||||||
ledstart = (brightness & 0b00011111) | self.LED_START
|
|
||||||
|
|
||||||
start_index = 4 * led_num
|
|
||||||
self.leds[start_index] = ledstart
|
|
||||||
self.leds[start_index + self.rgb[0]] = red
|
|
||||||
self.leds[start_index + self.rgb[1]] = green
|
|
||||||
self.leds[start_index + self.rgb[2]] = blue
|
|
||||||
|
|
||||||
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
|
|
||||||
"""Sets the color of one pixel in the LED stripe.
|
|
||||||
|
|
||||||
The changed pixel is not shown yet on the Stripe, it is only
|
|
||||||
written to the pixel buffer.
|
|
||||||
Colors are passed combined (3 bytes concatenated)
|
|
||||||
If brightness is not set the global brightness setting is used.
|
|
||||||
"""
|
|
||||||
self.set_pixel(
|
|
||||||
led_num,
|
|
||||||
(rgb_color & 0xFF0000) >> 16,
|
|
||||||
(rgb_color & 0x00FF00) >> 8,
|
|
||||||
rgb_color & 0x0000FF,
|
|
||||||
bright_percent,
|
|
||||||
)
|
|
||||||
|
|
||||||
def rotate(self, positions=1):
|
|
||||||
"""Rotate the LEDs by the specified number of positions.
|
|
||||||
|
|
||||||
Treating the internal LED array as a circular buffer, rotate it by
|
|
||||||
the specified number of positions. The number could be negative,
|
|
||||||
which means rotating in the opposite direction.
|
|
||||||
"""
|
|
||||||
cutoff = 4 * (positions % self.num_led)
|
|
||||||
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
"""Sends the content of the pixel buffer to the strip.
|
|
||||||
|
|
||||||
Todo: More than 1024 LEDs requires more than one xfer operation.
|
|
||||||
"""
|
|
||||||
self.clock_start_frame()
|
|
||||||
# xfer2 kills the list, unfortunately. So it must be copied first
|
|
||||||
# SPI takes up to 4096 Integers. So we are fine for up to 1024 LEDs.
|
|
||||||
data = list(self.leds)
|
|
||||||
while data:
|
|
||||||
self.spi.xfer2(data[:32])
|
|
||||||
data = data[32:]
|
|
||||||
self.clock_end_frame()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Release the SPI device; Call this method at the end"""
|
|
||||||
|
|
||||||
self.spi.close() # Close SPI port
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
name="$(cat)"
|
|
||||||
echo "Wake word detected: ${name}"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'Satellite has started.'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'Audio streaming to server has started'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'Audio streaming to server has stopped'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'User has started speaking'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'User has stopped speaking'
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
text="$(cat)"
|
|
||||||
echo "Text to speech text: ${text}"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
text="$(cat)"
|
|
||||||
echo "Speech to text transcript: ${text}"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'Text to speech response has started'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
echo 'Text to speech response has stopped'
|
|
||||||
@@ -1,720 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ReSpeaker XVF3800 USB 4-MIC Array – LED Controller with Custom Animations.
|
|
||||||
|
|
||||||
Implements custom LED animations mirroring the ESPHome home-assistant-voice.yaml effects.
|
|
||||||
Uses direct USB control transfers to drive the 12 APA102-style LEDs.
|
|
||||||
|
|
||||||
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
|
|
||||||
|
|
||||||
Muted indicator: LEDs at the 4 mic positions light red.
|
|
||||||
The XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
|
|
||||||
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
|
|
||||||
|
|
||||||
Ввод цифр 0-10 выбирает анимацию, q - выход.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import struct
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
try:
|
|
||||||
import usb.core
|
|
||||||
import usb.util
|
|
||||||
except ImportError:
|
|
||||||
print("❌ pyusb not installed. Install with: pip install pyusb")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Configuration
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
USB_VENDOR_ID = 0x2886
|
|
||||||
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
|
||||||
|
|
||||||
LED_COUNT = 12
|
|
||||||
LED_BRIGHTNESS = 31 # 0–31 (APA102 5-bit global brightness register)
|
|
||||||
|
|
||||||
# GPO_SERVICER_RESID LED commands
|
|
||||||
GPO_RESID = 20
|
|
||||||
LED_EFFECT = 12
|
|
||||||
LED_BRIGHTNESS_CMD = 13
|
|
||||||
LED_SPEED = 15
|
|
||||||
LED_COLOR = 16
|
|
||||||
LED_DOA_COLOR = 17
|
|
||||||
|
|
||||||
# Effects
|
|
||||||
EFFECT_OFF = 0
|
|
||||||
EFFECT_BREATH = 1
|
|
||||||
EFFECT_RAINBOW = 2
|
|
||||||
EFFECT_SINGLE = 3
|
|
||||||
EFFECT_DOA = 4
|
|
||||||
EFFECT_RING = 5
|
|
||||||
|
|
||||||
# Colors (RGB tuples for custom animations)
|
|
||||||
RGB = tuple[int, int, int]
|
|
||||||
|
|
||||||
BLACK: RGB = (0, 0, 0)
|
|
||||||
RED: RGB = (255, 0, 0)
|
|
||||||
BLUE: RGB = (0, 0, 255)
|
|
||||||
CYAN: RGB = (0, 200, 200)
|
|
||||||
GREEN: RGB = (0, 200, 50)
|
|
||||||
YELLOW: RGB = (220, 180, 0)
|
|
||||||
MAGENTA: RGB = (255, 0, 255)
|
|
||||||
WHITE: RGB = (255, 255, 255)
|
|
||||||
|
|
||||||
# Default ring color matching HA Voice PE
|
|
||||||
DEFAULT_RING_COLOR: RGB = (24, 187, 242)
|
|
||||||
|
|
||||||
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
|
|
||||||
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
|
|
||||||
# 180° → LED 6, 270° → LED 9
|
|
||||||
MIC_LEDS = (0, 3, 6, 9)
|
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger("xvf3800-led")
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Helper functions
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
def _scale(color: RGB, factor: float) -> RGB:
|
|
||||||
"""Scale RGB color by factor (0.0-1.0)."""
|
|
||||||
f = max(0.0, min(1.0, factor))
|
|
||||||
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
|
|
||||||
|
|
||||||
|
|
||||||
def _rgb_to_bgr_int(color: RGB) -> int:
|
|
||||||
"""Convert RGB tuple to BGR integer (0xBBGGRR format)."""
|
|
||||||
r, g, b = color
|
|
||||||
return (b << 16) | (g << 8) | r
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# USB LED driver
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
class XVF3800LED:
|
|
||||||
"""
|
|
||||||
Controls the 12 LEDs on ReSpeaker XVF3800 via USB control transfers.
|
|
||||||
|
|
||||||
Supports both built-in effects and custom frame-by-frame control.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TIMEOUT = 1000
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._dev = None
|
|
||||||
self._dev_lock = threading.Lock()
|
|
||||||
self._find_device()
|
|
||||||
|
|
||||||
def _find_device(self) -> None:
|
|
||||||
_LOGGER.info("Searching for XVF3800 USB device (VID=%04x, PIDs=%s)...",
|
|
||||||
USB_VENDOR_ID, [f"{pid:04x}" for pid in USB_PRODUCT_IDS])
|
|
||||||
|
|
||||||
# List all USB devices for debugging
|
|
||||||
try:
|
|
||||||
import usb.core
|
|
||||||
all_devs = usb.core.find(find_all=True)
|
|
||||||
_LOGGER.info("All USB devices found:")
|
|
||||||
for dev in all_devs:
|
|
||||||
_LOGGER.info(" - VID=%04x, PID=%04x, bus=%d, addr=%d",
|
|
||||||
dev.idVendor, dev.idProduct, dev.bus, dev.address)
|
|
||||||
except Exception as exc:
|
|
||||||
_LOGGER.warning("Could not list USB devices: %s", exc)
|
|
||||||
|
|
||||||
for pid in USB_PRODUCT_IDS:
|
|
||||||
_LOGGER.debug("Looking for PID %04x...", pid)
|
|
||||||
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
|
|
||||||
if dev is not None:
|
|
||||||
# НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
|
|
||||||
# Control transfer с CTRL_TYPE_VENDOR работает без claim interface
|
|
||||||
# Так работает xvf_host.py — там тоже нет detach_kernel_driver()
|
|
||||||
|
|
||||||
self._dev = dev
|
|
||||||
_LOGGER.info(
|
|
||||||
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
|
||||||
USB_VENDOR_ID, pid, dev.bus, dev.address,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Device %04x:%04x not found", USB_VENDOR_ID, pid)
|
|
||||||
|
|
||||||
_LOGGER.error(
|
|
||||||
"❌ XVF3800 not found (USB %04x). Check connection and udev rules.",
|
|
||||||
USB_VENDOR_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _ctrl_transfer(self, resid: int, cmdid: int, payload: list) -> bool:
|
|
||||||
with self._dev_lock:
|
|
||||||
if self._dev is None:
|
|
||||||
_LOGGER.warning("USB device not available")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
_LOGGER.debug("CTRL: resid=%d, cmdid=%d, payload=%s", resid, cmdid, payload)
|
|
||||||
self._dev.ctrl_transfer(
|
|
||||||
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
|
||||||
0, cmdid, resid, payload, self.TIMEOUT,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except usb.core.USBError as exc:
|
|
||||||
_LOGGER.warning("USB write error: %s", exc)
|
|
||||||
self._dev = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
def set_effect(self, effect: int) -> bool:
|
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_EFFECT, [effect])
|
|
||||||
|
|
||||||
def set_brightness(self, brightness: int) -> bool:
|
|
||||||
brightness = max(0, min(255, brightness))
|
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS_CMD, [brightness])
|
|
||||||
|
|
||||||
def set_speed(self, speed: int) -> bool:
|
|
||||||
speed = max(0, min(255, speed))
|
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_SPEED, [speed])
|
|
||||||
|
|
||||||
def set_color(self, color: int) -> bool:
|
|
||||||
payload = list(struct.pack(b'<I', color))
|
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_COLOR, payload)
|
|
||||||
|
|
||||||
def set_doa_color(self, base_color: int, doa_color: int) -> bool:
|
|
||||||
payload = list(struct.pack(b'<II', base_color, doa_color))
|
|
||||||
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR, payload)
|
|
||||||
|
|
||||||
def set_led_frame(self, pixels: list[RGB], brightness: float = 1.0) -> bool:
|
|
||||||
"""
|
|
||||||
Set custom LED frame using LED_RING_COLOR command.
|
|
||||||
|
|
||||||
Uses the XVF3800's LED_RING_COLOR command (resid=20, cmdid=19)
|
|
||||||
which expects 12 × uint32 values (one color per LED in 0xBBGGRR format).
|
|
||||||
"""
|
|
||||||
# Build 12 uint32 values in 0xBBGGRR format
|
|
||||||
colors_uint32 = []
|
|
||||||
for r, g, b in pixels:
|
|
||||||
br = max(0, min(255, int(r * brightness)))
|
|
||||||
bg = max(0, min(255, int(g * brightness)))
|
|
||||||
bb = max(0, min(255, int(b * brightness)))
|
|
||||||
# XVF3800 uses 0xBBGGRR format
|
|
||||||
color_int = (bb << 16) | (bg << 8) | br
|
|
||||||
colors_uint32.append(color_int)
|
|
||||||
|
|
||||||
# Pack as 12 × uint32 little-endian
|
|
||||||
payload = b''
|
|
||||||
for color_int in colors_uint32:
|
|
||||||
payload += struct.pack('<I', color_int)
|
|
||||||
|
|
||||||
_LOGGER.debug("LED frame: brightness=%.2f, pixels=%s, payload_len=%d",
|
|
||||||
brightness, pixels, len(payload))
|
|
||||||
|
|
||||||
# Send via vendor control transfer
|
|
||||||
# resid=20 (GPO_SERVICER_RESID), cmdid=19 (LED_RING_COLOR)
|
|
||||||
with self._dev_lock:
|
|
||||||
if self._dev is None:
|
|
||||||
_LOGGER.warning("❌ LEDs [simulated]: device not available")
|
|
||||||
_LOGGER.warning(" Payload would be: %s", payload.hex())
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
result = self._dev.ctrl_transfer(
|
|
||||||
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
|
||||||
0, # request
|
|
||||||
19, # wValue = cmdid
|
|
||||||
20, # wIndex = resid
|
|
||||||
payload,
|
|
||||||
self.TIMEOUT,
|
|
||||||
)
|
|
||||||
_LOGGER.debug("✅ USB transfer successful: %d bytes sent", result)
|
|
||||||
return True
|
|
||||||
except usb.core.USBError as exc:
|
|
||||||
_LOGGER.error("❌ USB write error: %s", exc)
|
|
||||||
_LOGGER.error(" Payload was: %s", payload.hex())
|
|
||||||
self._dev = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
def off(self) -> None:
|
|
||||||
self.set_effect(EFFECT_OFF)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.off()
|
|
||||||
with self._dev_lock:
|
|
||||||
if self._dev is not None:
|
|
||||||
usb.util.dispose_resources(self._dev)
|
|
||||||
self._dev = None
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# LED ring animator - custom animations mirroring ESPHome effects
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
class LEDRing:
|
|
||||||
"""
|
|
||||||
Manages the 12-LED ring animations for ReSpeaker XVF3800.
|
|
||||||
All animations mirror the ESPHome home-assistant-voice.yaml effects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ANIM_OFF = "off"
|
|
||||||
ANIM_TWINKLE = "twinkle"
|
|
||||||
ANIM_TWINKLE_BLUE = "twinkle_blue"
|
|
||||||
ANIM_WAIT_CMD = "waiting"
|
|
||||||
ANIM_LISTENING = "listening"
|
|
||||||
ANIM_THINKING = "thinking"
|
|
||||||
ANIM_REPLYING = "replying"
|
|
||||||
ANIM_MUTED = "muted"
|
|
||||||
ANIM_ERROR = "error"
|
|
||||||
ANIM_TIMER_TICK = "timer_tick"
|
|
||||||
ANIM_TIMER_RING = "timer_ring"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._leds = XVF3800LED()
|
|
||||||
self._animation = self.ANIM_OFF
|
|
||||||
self._anim_lock = threading.Lock()
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._loop, daemon=True, name="led-ring"
|
|
||||||
)
|
|
||||||
self._index: int = 0
|
|
||||||
self._brightness_step: int = 0
|
|
||||||
self._brightness_dec: bool = True
|
|
||||||
self._twinkle_state: list[float] = [0.0] * LED_COUNT
|
|
||||||
# Timer state for timer_tick animation
|
|
||||||
self._timer_total: int = 60
|
|
||||||
self._timer_left: int = 60
|
|
||||||
self._timer_tick_count: int = 0
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop_event.set()
|
|
||||||
self._thread.join(timeout=2)
|
|
||||||
self._leds.off()
|
|
||||||
self._leds.close()
|
|
||||||
|
|
||||||
def set_animation(self, name: str) -> None:
|
|
||||||
with self._anim_lock:
|
|
||||||
if self._animation != name:
|
|
||||||
_LOGGER.info("LED animation → %s", name)
|
|
||||||
self._animation = name
|
|
||||||
self._index = 0
|
|
||||||
self._brightness_step = 0
|
|
||||||
self._brightness_dec = True
|
|
||||||
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
|
|
||||||
# Reset timer state when entering timer_tick
|
|
||||||
if name == self.ANIM_TIMER_TICK:
|
|
||||||
self._timer_total = 60
|
|
||||||
self._timer_left = 60
|
|
||||||
self._timer_tick_count = 0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _color(self) -> RGB:
|
|
||||||
"""Default ring colour matching HA Voice PE."""
|
|
||||||
return DEFAULT_RING_COLOR
|
|
||||||
|
|
||||||
def _pulse_step(self, steps: int = 10) -> float:
|
|
||||||
"""Generate pulsing factor 0.0-1.0."""
|
|
||||||
factor = (steps - self._brightness_step) / steps
|
|
||||||
if self._brightness_dec:
|
|
||||||
self._brightness_step += 1
|
|
||||||
else:
|
|
||||||
self._brightness_step -= 1
|
|
||||||
if self._brightness_step <= 0 or self._brightness_step >= steps:
|
|
||||||
self._brightness_dec = not self._brightness_dec
|
|
||||||
return factor
|
|
||||||
|
|
||||||
def _apply_mic_indicators(self, pixels: list[RGB]) -> None:
|
|
||||||
"""
|
|
||||||
Mark all 4 mic positions red on the rendered frame.
|
|
||||||
|
|
||||||
ReSpeaker XVF3800 has 4 mics at DOA 0°, 90°, 180°, 270°.
|
|
||||||
On the 12-LED ring (30° per step):
|
|
||||||
DOA 0° → LED 0 (right)
|
|
||||||
DOA 90° → LED 3 (top)
|
|
||||||
DOA 180° → LED 6 (left)
|
|
||||||
DOA 270° → LED 9 (bottom)
|
|
||||||
Neighbours are blanked so each indicator reads as a distinct dot.
|
|
||||||
"""
|
|
||||||
pixels[11] = BLACK; pixels[0] = RED; pixels[1] = BLACK
|
|
||||||
pixels[2] = BLACK; pixels[3] = RED; pixels[4] = BLACK
|
|
||||||
pixels[5] = BLACK; pixels[6] = RED; pixels[7] = BLACK
|
|
||||||
pixels[8] = BLACK; pixels[9] = RED; pixels[10] = BLACK
|
|
||||||
|
|
||||||
def _apply_mic_indicators_pulsed(self, pixels: list[RGB], factor: float) -> None:
|
|
||||||
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
|
||||||
pixels[0] = _scale(RED, factor)
|
|
||||||
pixels[3] = _scale(RED, factor)
|
|
||||||
pixels[6] = _scale(RED, factor)
|
|
||||||
pixels[9] = _scale(RED, factor)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Animations — each returns sleep time in seconds
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _anim_off(self) -> float:
|
|
||||||
pixels = [BLACK] * LED_COUNT
|
|
||||||
self._leds.set_led_frame(pixels, brightness=1.0)
|
|
||||||
return 0.1
|
|
||||||
|
|
||||||
def _anim_twinkle(self, color: RGB) -> float:
|
|
||||||
FADE = 0.85
|
|
||||||
SPARK_PROB = 0.15
|
|
||||||
pixels = [BLACK] * LED_COUNT
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
if random.random() < SPARK_PROB:
|
|
||||||
self._twinkle_state[i] = 1.0
|
|
||||||
else:
|
|
||||||
self._twinkle_state[i] *= FADE
|
|
||||||
pixels[i] = _scale(color, self._twinkle_state[i])
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.05
|
|
||||||
|
|
||||||
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
|
|
||||||
"""
|
|
||||||
Shared spin for Waiting / Listening / Replying.
|
|
||||||
Mirrors ESPHome "Waiting for Command", "Listening For Command",
|
|
||||||
and "Replying" effects.
|
|
||||||
"""
|
|
||||||
pixels = [BLACK] * LED_COUNT
|
|
||||||
|
|
||||||
if reverse:
|
|
||||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
|
||||||
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
|
|
||||||
(6, 1.0), (7, 192/255), (8, 128/255)]
|
|
||||||
else:
|
|
||||||
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
|
|
||||||
(6, 1.0), (5, 192/255), (4, 128/255)]
|
|
||||||
|
|
||||||
for offset, brightness in offsets:
|
|
||||||
idx = (self._index + offset) % LED_COUNT
|
|
||||||
pixels[idx] = _scale(color, brightness)
|
|
||||||
|
|
||||||
if not reverse:
|
|
||||||
self._index = (self._index + 1) % LED_COUNT
|
|
||||||
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return interval
|
|
||||||
|
|
||||||
def _anim_thinking(self, color: RGB) -> float:
|
|
||||||
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
pixels = [BLACK] * LED_COUNT
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
|
|
||||||
pixels[i] = _scale(color, factor)
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_muted(self, color: RGB) -> float:
|
|
||||||
"""
|
|
||||||
Solid ring with red at all 4 mic positions (cardinal points).
|
|
||||||
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
|
|
||||||
Neighbours are blanked so each indicator stands out clearly.
|
|
||||||
Mirrors ESPHome "Muted or Silent" effect.
|
|
||||||
"""
|
|
||||||
pixels = [color] * LED_COUNT
|
|
||||||
self._apply_mic_indicators(pixels)
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.016
|
|
||||||
|
|
||||||
def _anim_error(self) -> float:
|
|
||||||
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
pixels = [_scale(RED, factor)] * LED_COUNT
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_timer_ring(self, color: RGB) -> float:
|
|
||||||
"""
|
|
||||||
Full ring pulsing; red at all 4 mic positions if muted.
|
|
||||||
Mirrors ESPHome "Timer Ring" effect.
|
|
||||||
"""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
pixels = [_scale(color, factor)] * LED_COUNT
|
|
||||||
self._apply_mic_indicators_pulsed(pixels, factor)
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_timer_tick(self, color: RGB, seconds_left: int, total_seconds: int) -> float:
|
|
||||||
"""
|
|
||||||
Arc proportional to remaining time with sweep marker dip.
|
|
||||||
Mirrors ESPHome "Timer Tick" effect exactly.
|
|
||||||
|
|
||||||
Visual: A bar graph showing remaining time as an arc of LEDs.
|
|
||||||
- Full timer = all LEDs lit
|
|
||||||
- Half timer = 6 LEDs lit
|
|
||||||
- Each LED represents ~5 seconds (for 60s timer)
|
|
||||||
- A small brightness dip moves around the arc as a "sweep marker"
|
|
||||||
"""
|
|
||||||
total = max(total_seconds, 1)
|
|
||||||
timer_ratio = LED_COUNT * seconds_left / total
|
|
||||||
last_led_on = max(0, math.ceil(timer_ratio) - 1)
|
|
||||||
|
|
||||||
pixels = [BLACK] * LED_COUNT
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
brightness_dip = (
|
|
||||||
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
|
|
||||||
)
|
|
||||||
if i <= timer_ratio:
|
|
||||||
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
|
|
||||||
pixels[i] = _scale(color, brightness)
|
|
||||||
|
|
||||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
|
||||||
self._leds.set_led_frame(pixels)
|
|
||||||
return 0.1
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Animation loop
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _loop(self) -> None:
|
|
||||||
_LOGGER.info("LED animation loop started")
|
|
||||||
frame_count = 0
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
with self._anim_lock:
|
|
||||||
anim = self._animation
|
|
||||||
|
|
||||||
color = self._color()
|
|
||||||
|
|
||||||
if frame_count % 30 == 0:
|
|
||||||
_LOGGER.debug("Animation loop: anim=%s, frame=%d", anim, frame_count)
|
|
||||||
|
|
||||||
if anim == self.ANIM_OFF:
|
|
||||||
sleep = self._anim_off()
|
|
||||||
elif anim == self.ANIM_TWINKLE:
|
|
||||||
sleep = self._anim_twinkle(RED)
|
|
||||||
elif anim == self.ANIM_TWINKLE_BLUE:
|
|
||||||
sleep = self._anim_twinkle(BLUE)
|
|
||||||
elif anim == self.ANIM_WAIT_CMD:
|
|
||||||
sleep = self._anim_spin(color, interval=0.1, reverse=False)
|
|
||||||
elif anim == self.ANIM_LISTENING:
|
|
||||||
sleep = self._anim_spin(color, interval=0.05, reverse=False)
|
|
||||||
elif anim == self.ANIM_THINKING:
|
|
||||||
sleep = self._anim_thinking(color)
|
|
||||||
elif anim == self.ANIM_REPLYING:
|
|
||||||
sleep = self._anim_spin(color, interval=0.05, reverse=True)
|
|
||||||
elif anim == self.ANIM_MUTED:
|
|
||||||
sleep = self._anim_muted(color)
|
|
||||||
elif anim == self.ANIM_ERROR:
|
|
||||||
sleep = self._anim_error()
|
|
||||||
elif anim == self.ANIM_TIMER_RING:
|
|
||||||
sleep = self._anim_timer_ring(color)
|
|
||||||
elif anim == self.ANIM_TIMER_TICK:
|
|
||||||
# Simulate timer counting down for demo
|
|
||||||
with self._anim_lock:
|
|
||||||
self._timer_tick_count += 1
|
|
||||||
# Update timer every second (10 frames at 0.1s each)
|
|
||||||
if self._timer_tick_count % 10 == 0:
|
|
||||||
self._timer_left = max(0, self._timer_left - 1)
|
|
||||||
if self._timer_left == 0:
|
|
||||||
self._timer_left = self._timer_total # Reset for demo
|
|
||||||
sleep = self._anim_timer_tick(color, self._timer_left, self._timer_total)
|
|
||||||
else:
|
|
||||||
sleep = self._anim_off()
|
|
||||||
|
|
||||||
time.sleep(sleep)
|
|
||||||
frame_count += 1
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Main
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
ANIM_MAP = {
|
|
||||||
0: LEDRing.ANIM_OFF,
|
|
||||||
1: LEDRing.ANIM_TWINKLE,
|
|
||||||
2: LEDRing.ANIM_TWINKLE_BLUE,
|
|
||||||
3: LEDRing.ANIM_WAIT_CMD,
|
|
||||||
4: LEDRing.ANIM_LISTENING,
|
|
||||||
5: LEDRing.ANIM_THINKING,
|
|
||||||
6: LEDRing.ANIM_REPLYING,
|
|
||||||
7: LEDRing.ANIM_MUTED,
|
|
||||||
8: LEDRing.ANIM_ERROR,
|
|
||||||
9: LEDRing.ANIM_TIMER_TICK,
|
|
||||||
10: LEDRing.ANIM_TIMER_RING,
|
|
||||||
}
|
|
||||||
|
|
||||||
ANIM_NAMES = {
|
|
||||||
0: "off - выключить",
|
|
||||||
1: "twinkle - красные искры",
|
|
||||||
2: "twinkle_blue - синие искры",
|
|
||||||
3: "waiting - ожидание (медленное вращение)",
|
|
||||||
4: "listening - слушание (быстрое вращение)",
|
|
||||||
5: "thinking - мышление (пульсация)",
|
|
||||||
6: "replying - ответ (обратное вращение)",
|
|
||||||
7: "muted --muted (красные точки на позициях микрофонов)",
|
|
||||||
8: "error - ошибка (красная пульсация)",
|
|
||||||
9: "timer_tick - таймер (дуга времени)",
|
|
||||||
10: "timer_ring - таймер звонит (пульсация)",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def print_menu() -> None:
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(" ReSpeaker XVF3800 LED Controller (Custom Animations)")
|
|
||||||
print(" Анимации как в ESPHome home-assistant-voice.yaml")
|
|
||||||
print("=" * 60)
|
|
||||||
for idx, name in ANIM_NAMES.items():
|
|
||||||
print(f" {idx:2d} - {name}")
|
|
||||||
print(" q - Quit")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def run_terminal_controller() -> None:
|
|
||||||
leds = LEDRing()
|
|
||||||
leds.start()
|
|
||||||
|
|
||||||
def _shutdown(signum, frame) -> None:
|
|
||||||
print("\n\nShutting down...")
|
|
||||||
leds.stop()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _shutdown)
|
|
||||||
signal.signal(signal.SIGTERM, _shutdown)
|
|
||||||
|
|
||||||
# Wait for device to initialize
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# Check if device was found
|
|
||||||
if leds._leds._dev is None:
|
|
||||||
print("\n⚠️ WARNING: XVF3800 device not found!")
|
|
||||||
print(" Check USB connection and udev rules.")
|
|
||||||
print(" Run: lsusb | grep -i 2886")
|
|
||||||
else:
|
|
||||||
print(f"\n✅ Device connected: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
|
|
||||||
|
|
||||||
print_menu()
|
|
||||||
|
|
||||||
_LOGGER.info("Starting with default animation: twinkle (red sparks)")
|
|
||||||
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
user_input = input("\nSelect animation (0-10, q to quit): ").strip().lower()
|
|
||||||
except EOFError:
|
|
||||||
break
|
|
||||||
|
|
||||||
if user_input == "q":
|
|
||||||
print("Exiting...")
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
anim_idx = int(user_input)
|
|
||||||
if anim_idx not in ANIM_MAP:
|
|
||||||
print(f"❌ Invalid selection: {anim_idx}. Choose 0-10 or q.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
anim_name = ANIM_MAP[anim_idx]
|
|
||||||
leds.set_animation(anim_name)
|
|
||||||
print(f"✅ Selected: {anim_idx} - {ANIM_NAMES[anim_idx]}")
|
|
||||||
_LOGGER.info("Animation changed to: %s", anim_name)
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
print(f"❌ Invalid input: '{user_input}'. Choose 0-10 or q.")
|
|
||||||
|
|
||||||
leds.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_pattern() -> None:
|
|
||||||
"""Run test pattern to verify LED hardware."""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(" XVF3800 LED Hardware Test")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
leds = LEDRing()
|
|
||||||
leds.start()
|
|
||||||
|
|
||||||
def _shutdown(signum, frame) -> None:
|
|
||||||
print("\n\nTest interrupted")
|
|
||||||
leds.stop()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _shutdown)
|
|
||||||
signal.signal(signal.SIGTERM, _shutdown)
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if leds._leds._dev is None:
|
|
||||||
print("\n❌ ERROR: XVF3800 device not found!")
|
|
||||||
print(" Check USB connection:")
|
|
||||||
print(" - Run: lsusb | grep -i 2886")
|
|
||||||
print(" - Check udev rules: cat /etc/udev/rules.d/99-respeaker.rules")
|
|
||||||
print(" - Try: sudo chmod 666 /dev/bus/usb/*/`lsusb | grep 2886 | awk '{print $4}' | cut -d/ -f2`")
|
|
||||||
leds.stop()
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"\n✅ Device found: bus={leds._leds._dev.bus}, addr={leds._leds._dev.address}")
|
|
||||||
print("\nRunning test patterns (5 seconds each):")
|
|
||||||
|
|
||||||
# Test 1: All red
|
|
||||||
print(" 1. All RED...")
|
|
||||||
leds.set_animation(LEDRing.ANIM_OFF)
|
|
||||||
time.sleep(0.1)
|
|
||||||
# Direct frame test
|
|
||||||
pixels = [RED] * LED_COUNT
|
|
||||||
leds._leds.set_led_frame(pixels, brightness=1.0)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
# Test 2: All green
|
|
||||||
print(" 2. All GREEN...")
|
|
||||||
pixels = [GREEN] * LED_COUNT
|
|
||||||
leds._leds.set_led_frame(pixels, brightness=1.0)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
# Test 3: All blue
|
|
||||||
print(" 3. All BLUE...")
|
|
||||||
pixels = [BLUE] * LED_COUNT
|
|
||||||
leds._leds.set_led_frame(pixels, brightness=1.0)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
# Test 4: Twinkle animation
|
|
||||||
print(" 4. Twinkle animation (10s)...")
|
|
||||||
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
print("\n✅ Test complete!")
|
|
||||||
leds.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="ReSpeaker XVF3800 USB 4-MIC Array LED Controller"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--debug", action="store_true",
|
|
||||||
help="Enable DEBUG logging",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--test", action="store_true",
|
|
||||||
help="Run LED hardware test pattern",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose", action="store_true",
|
|
||||||
help="Enable INFO logging to console",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
log_level = logging.DEBUG if args.debug else (logging.INFO if args.verbose else logging.WARNING)
|
|
||||||
logging.basicConfig(
|
|
||||||
level=log_level,
|
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.test:
|
|
||||||
run_test_pattern()
|
|
||||||
else:
|
|
||||||
run_terminal_controller()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"listening": 120,
|
|
||||||
"wake": 200,
|
|
||||||
"thinking": 150,
|
|
||||||
"speaking": 140,
|
|
||||||
"idle": 80,
|
|
||||||
"paused": 40,
|
|
||||||
"error": 255,
|
|
||||||
"global_multiplier": 0.3
|
|
||||||
}
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ReSpeaker Mic Array v2.0 (USB) – Linux Voice Assistant peripheral controller.
|
|
||||||
|
|
||||||
Connects to the device via USB HID and drives its 12 APA102 LEDs with
|
|
||||||
animations that mirror the Home Assistant Voice PE LED ring behaviour.
|
|
||||||
|
|
||||||
Hardware
|
|
||||||
--------
|
|
||||||
Connection : USB (Vendor 0x2886, Product 0x0018)
|
|
||||||
LED ring : 12 × APA102 RGB LEDs (controlled via USB HID)
|
|
||||||
Microphones: 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000, USB audio)
|
|
||||||
|
|
||||||
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
|
|
||||||
|
|
||||||
Muted indicator: LEDs at the 4 mic positions light red.
|
|
||||||
The v2.0 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
|
|
||||||
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
|
|
||||||
|
|
||||||
Install dependencies
|
|
||||||
--------------------
|
|
||||||
pip install websockets pyusb pixel-ring
|
|
||||||
|
|
||||||
Host udev rule (run once on the Pi/host, then reboot or run udevadm trigger):
|
|
||||||
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
|
|
||||||
MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
|
|
||||||
sudo udevadm trigger
|
|
||||||
|
|
||||||
Run
|
|
||||||
---
|
|
||||||
python3 respeaker_usb_mic_array.py
|
|
||||||
python3 respeaker_usb_mic_array.py --host 127.0.0.1 --port 6055 --debug
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Hardware dependencies — gracefully stubbed when not on real hardware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
try:
|
|
||||||
import usb.core # type: ignore[import]
|
|
||||||
import usb.util # type: ignore[import]
|
|
||||||
_HAS_USB = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_USB = False
|
|
||||||
logging.warning("pyusb not found – LED output will be simulated")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websockets # type: ignore[import]
|
|
||||||
except ImportError:
|
|
||||||
sys.exit("websockets not installed. Run: pip install websockets")
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Configuration
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
DEFAULT_LVA_HOST = "localhost"
|
|
||||||
DEFAULT_LVA_PORT = 6055
|
|
||||||
|
|
||||||
# USB device identifiers
|
|
||||||
USB_VENDOR_ID = 0x2886
|
|
||||||
USB_PRODUCT_ID = 0x0018
|
|
||||||
|
|
||||||
LED_COUNT = 12
|
|
||||||
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
|
|
||||||
|
|
||||||
RECONNECT_DELAY_S = 3.0
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Logging
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger("respeaker-usb")
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Assistant state
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
class AssistState(str, Enum):
|
|
||||||
NOT_READY = "not_ready"
|
|
||||||
IDLE = "idle"
|
|
||||||
WAKE_WORD = "wake_word_detected"
|
|
||||||
LISTENING = "listening"
|
|
||||||
THINKING = "thinking"
|
|
||||||
SPEAKING = "tts_speaking"
|
|
||||||
TTS_FINISHED = "tts_finished"
|
|
||||||
ERROR = "error"
|
|
||||||
MUTED = "muted"
|
|
||||||
TIMER_TICKING = "timer_ticking"
|
|
||||||
TIMER_RINGING = "timer_ringing"
|
|
||||||
MEDIA_PLAYING = "media_player_playing"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SharedState:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self.assist_state: AssistState = AssistState.NOT_READY
|
|
||||||
self.ha_connected: bool = False
|
|
||||||
self.muted: bool = False
|
|
||||||
self.volume: float = 1.0
|
|
||||||
self.timer_total_seconds: int = 0
|
|
||||||
self.timer_seconds_left: int = 0
|
|
||||||
|
|
||||||
def update(self, **kwargs) -> None:
|
|
||||||
with self._lock:
|
|
||||||
for key, val in kwargs.items():
|
|
||||||
setattr(self, key, val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def snapshot(self) -> dict:
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
"assist_state": self.assist_state,
|
|
||||||
"ha_connected": self.ha_connected,
|
|
||||||
"muted": self.muted,
|
|
||||||
"volume": self.volume,
|
|
||||||
"timer_total_seconds": self.timer_total_seconds,
|
|
||||||
"timer_seconds_left": self.timer_seconds_left,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# USB LED driver
|
|
||||||
#
|
|
||||||
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
|
|
||||||
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
|
|
||||||
# bytes in the order [start_frame | brightness, B, G, R].
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
RGB = Tuple[int, int, int]
|
|
||||||
|
|
||||||
BLACK: RGB = (0, 0, 0)
|
|
||||||
RED: RGB = (255, 0, 0)
|
|
||||||
BLUE: RGB = (0, 0, 255)
|
|
||||||
CYAN: RGB = (0, 200, 200)
|
|
||||||
GREEN: RGB = (0, 200, 50)
|
|
||||||
YELLOW: RGB = (220, 180, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _scale(color: RGB, factor: float) -> RGB:
|
|
||||||
f = max(0.0, min(1.0, factor))
|
|
||||||
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
|
|
||||||
|
|
||||||
|
|
||||||
class USBLEDRing:
|
|
||||||
"""
|
|
||||||
Controls the 12 APA102 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
|
||||||
|
|
||||||
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
|
|
||||||
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
|
|
||||||
This is the same protocol used by Seeed's pixel_ring library.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# USB control transfer parameters (from XMOS firmware)
|
|
||||||
_CTRL_TIMEOUT = 100 # ms
|
|
||||||
_CTRL_REQUEST = 0
|
|
||||||
_CTRL_VALUE = 0x0001
|
|
||||||
_CTRL_INDEX = 0x8001
|
|
||||||
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._pixels: list[RGB] = [BLACK] * LED_COUNT
|
|
||||||
self._dev = None
|
|
||||||
self._dev_lock = threading.Lock()
|
|
||||||
|
|
||||||
if _HAS_USB:
|
|
||||||
self._find_device()
|
|
||||||
|
|
||||||
def _find_device(self) -> None:
|
|
||||||
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=USB_PRODUCT_ID)
|
|
||||||
if dev is None:
|
|
||||||
_LOGGER.error(
|
|
||||||
"ReSpeaker Mic Array v2.0 not found (USB %04x:%04x). "
|
|
||||||
"Check connection and udev rules.",
|
|
||||||
USB_VENDOR_ID, USB_PRODUCT_ID,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if dev.is_kernel_driver_active(0):
|
|
||||||
dev.detach_kernel_driver(0)
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
pass
|
|
||||||
self._dev = dev
|
|
||||||
_LOGGER.info(
|
|
||||||
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
|
|
||||||
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set(self, index: int, color: RGB) -> None:
|
|
||||||
self._pixels[index % LED_COUNT] = color
|
|
||||||
|
|
||||||
def set_all(self, color: RGB) -> None:
|
|
||||||
self._pixels = [color] * LED_COUNT
|
|
||||||
|
|
||||||
def show(self, brightness: float = 1.0) -> None:
|
|
||||||
"""
|
|
||||||
Push the current pixel buffer to the device.
|
|
||||||
|
|
||||||
Frame layout (matches pixel_ring / APA102 USB protocol):
|
|
||||||
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
|
|
||||||
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
|
|
||||||
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
|
|
||||||
"""
|
|
||||||
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
|
|
||||||
|
|
||||||
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
|
|
||||||
for r, g, b in self._pixels:
|
|
||||||
br = max(0, min(255, int(r * brightness)))
|
|
||||||
bg = max(0, min(255, int(g * brightness)))
|
|
||||||
bb = max(0, min(255, int(b * brightness)))
|
|
||||||
frame += [0xE0 | bright5, bb, bg, br]
|
|
||||||
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
|
|
||||||
|
|
||||||
with self._dev_lock:
|
|
||||||
if self._dev is None:
|
|
||||||
pixels = ", ".join(f"rgb{p}" for p in self._pixels)
|
|
||||||
_LOGGER.debug("LEDs [simulated]: %s", pixels)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._dev.ctrl_transfer(
|
|
||||||
self._CTRL_TYPE_OUT,
|
|
||||||
self._CTRL_REQUEST,
|
|
||||||
self._CTRL_VALUE,
|
|
||||||
self._CTRL_INDEX,
|
|
||||||
frame,
|
|
||||||
self._CTRL_TIMEOUT,
|
|
||||||
)
|
|
||||||
except usb.core.USBError as exc:
|
|
||||||
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
|
|
||||||
self._dev = None
|
|
||||||
# Attempt to re-find the device on next show() call
|
|
||||||
threading.Thread(
|
|
||||||
target=self._reconnect, daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _reconnect(self) -> None:
|
|
||||||
time.sleep(2)
|
|
||||||
_LOGGER.info("Attempting to reconnect to ReSpeaker USB device …")
|
|
||||||
self._find_device()
|
|
||||||
|
|
||||||
def off(self) -> None:
|
|
||||||
self._pixels = [BLACK] * LED_COUNT
|
|
||||||
self.show(0)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.off()
|
|
||||||
with self._dev_lock:
|
|
||||||
if self._dev is not None:
|
|
||||||
usb.util.dispose_resources(self._dev)
|
|
||||||
self._dev = None
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# LED ring animator
|
|
||||||
#
|
|
||||||
# All animations mirror the ESPHome home-assistant-voice.yaml effects.
|
|
||||||
# Muted indicator uses LEDs 0, 2, 4, 6, 8, 10 — the 6 outer mic positions
|
|
||||||
# on the v2.0 (0°, 60°, 120°, 180°, 240°, 300° on the ring).
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
|
|
||||||
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
|
|
||||||
# 180° → LED 6, 270° → LED 9
|
|
||||||
MIC_LEDS = (0, 3, 6, 9)
|
|
||||||
|
|
||||||
|
|
||||||
class LEDRing:
|
|
||||||
"""
|
|
||||||
Manages the 12-LED ring animations for the ReSpeaker Mic Array v2.0.
|
|
||||||
All animations run in a dedicated daemon thread.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ANIM_OFF = "off"
|
|
||||||
ANIM_TWINKLE = "twinkle"
|
|
||||||
ANIM_TWINKLE_BLUE = "twinkle_blue"
|
|
||||||
ANIM_WAIT_CMD = "waiting"
|
|
||||||
ANIM_LISTENING = "listening"
|
|
||||||
ANIM_THINKING = "thinking"
|
|
||||||
ANIM_REPLYING = "replying"
|
|
||||||
ANIM_MUTED = "muted"
|
|
||||||
ANIM_ERROR = "error"
|
|
||||||
ANIM_TIMER_TICK = "timer_tick"
|
|
||||||
ANIM_TIMER_RING = "timer_ring"
|
|
||||||
|
|
||||||
def __init__(self, state: SharedState) -> None:
|
|
||||||
self._state = state
|
|
||||||
self._leds = USBLEDRing()
|
|
||||||
self._animation = self.ANIM_OFF
|
|
||||||
self._anim_lock = threading.Lock()
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._loop, daemon=True, name="led-ring"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._index: int = 0
|
|
||||||
self._brightness_step: int = 0
|
|
||||||
self._brightness_dec: bool = True
|
|
||||||
self._twinkle_state: list[float] = [0.0] * LED_COUNT
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop_event.set()
|
|
||||||
self._thread.join(timeout=2)
|
|
||||||
self._leds.off()
|
|
||||||
self._leds.close()
|
|
||||||
|
|
||||||
def set_animation(self, name: str) -> None:
|
|
||||||
with self._anim_lock:
|
|
||||||
if self._animation != name:
|
|
||||||
_LOGGER.debug("LED animation → %s", name)
|
|
||||||
self._animation = name
|
|
||||||
self._index = 0
|
|
||||||
self._brightness_step = 0
|
|
||||||
self._brightness_dec = True
|
|
||||||
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _color(self) -> RGB:
|
|
||||||
"""Default ring colour matching HA Voice PE."""
|
|
||||||
return (24, 187, 242)
|
|
||||||
|
|
||||||
def _pulse_step(self, steps: int = 10) -> float:
|
|
||||||
factor = (steps - self._brightness_step) / steps
|
|
||||||
if self._brightness_dec:
|
|
||||||
self._brightness_step += 1
|
|
||||||
else:
|
|
||||||
self._brightness_step -= 1
|
|
||||||
if self._brightness_step <= 0 or self._brightness_step >= steps:
|
|
||||||
self._brightness_dec = not self._brightness_dec
|
|
||||||
return factor
|
|
||||||
|
|
||||||
def _apply_mic_indicators(self) -> None:
|
|
||||||
"""
|
|
||||||
Mark all 4 mic positions red on the already-rendered frame.
|
|
||||||
|
|
||||||
ReSpeaker Mic Array v2.0 has 4 mics at DOA 0°, 90°, 180°, 270°.
|
|
||||||
On the 12-LED ring (30° per step):
|
|
||||||
DOA 0° → LED 0 (right)
|
|
||||||
DOA 90° → LED 3 (top)
|
|
||||||
DOA 180° → LED 6 (left)
|
|
||||||
DOA 270° → LED 9 (bottom)
|
|
||||||
Neighbours are blanked so each indicator reads as a distinct dot.
|
|
||||||
"""
|
|
||||||
self._leds.set(11, BLACK); self._leds.set(0, RED); self._leds.set(1, BLACK)
|
|
||||||
self._leds.set(2, BLACK); self._leds.set(3, RED); self._leds.set(4, BLACK)
|
|
||||||
self._leds.set(5, BLACK); self._leds.set(6, RED); self._leds.set(7, BLACK)
|
|
||||||
self._leds.set(8, BLACK); self._leds.set(9, RED); self._leds.set(10, BLACK)
|
|
||||||
|
|
||||||
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
|
|
||||||
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
|
||||||
self._leds.set(0, _scale(RED, factor))
|
|
||||||
self._leds.set(3, _scale(RED, factor))
|
|
||||||
self._leds.set(6, _scale(RED, factor))
|
|
||||||
self._leds.set(9, _scale(RED, factor))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Animations — each returns sleep time in seconds
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _anim_off(self) -> float:
|
|
||||||
self._leds.off()
|
|
||||||
return 0.1
|
|
||||||
|
|
||||||
def _anim_twinkle(self, color: RGB) -> float:
|
|
||||||
FADE = 0.85
|
|
||||||
SPARK_PROB = 0.15
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
if random.random() < SPARK_PROB:
|
|
||||||
self._twinkle_state[i] = 1.0
|
|
||||||
else:
|
|
||||||
self._twinkle_state[i] *= FADE
|
|
||||||
self._leds.set(i, _scale(color, self._twinkle_state[i]))
|
|
||||||
self._leds.show()
|
|
||||||
return 0.05
|
|
||||||
|
|
||||||
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
|
|
||||||
"""
|
|
||||||
Shared spin for Waiting / Listening / Replying.
|
|
||||||
Mirrors ESPHome "Waiting for Command", "Listening For Command",
|
|
||||||
and "Replying" effects.
|
|
||||||
"""
|
|
||||||
if reverse:
|
|
||||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
|
||||||
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
|
|
||||||
(6, 1.0), (7, 192/255), (8, 128/255)]
|
|
||||||
else:
|
|
||||||
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
|
|
||||||
(6, 1.0), (5, 192/255), (4, 128/255)]
|
|
||||||
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
self._leds.set(i, BLACK)
|
|
||||||
for offset, brightness in offsets:
|
|
||||||
self._leds.set((self._index + offset) % LED_COUNT, _scale(color, brightness))
|
|
||||||
if not reverse:
|
|
||||||
self._index = (self._index + 1) % LED_COUNT
|
|
||||||
|
|
||||||
self._leds.show()
|
|
||||||
return interval
|
|
||||||
|
|
||||||
def _anim_thinking(self, color: RGB) -> float:
|
|
||||||
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
|
|
||||||
self._leds.set(i, _scale(color, factor))
|
|
||||||
else:
|
|
||||||
self._leds.set(i, BLACK)
|
|
||||||
self._leds.show()
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_muted(self, color: RGB) -> float:
|
|
||||||
"""
|
|
||||||
Solid ring with red at all 4 mic positions (cardinal points).
|
|
||||||
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
|
|
||||||
Neighbours are blanked so each indicator stands out clearly.
|
|
||||||
Mirrors ESPHome "Muted or Silent" effect.
|
|
||||||
"""
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
self._leds.set(i, color)
|
|
||||||
self._apply_mic_indicators()
|
|
||||||
self._leds.show()
|
|
||||||
return 0.016
|
|
||||||
|
|
||||||
def _anim_error(self) -> float:
|
|
||||||
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
self._leds.set(i, _scale(RED, factor))
|
|
||||||
self._leds.show()
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
|
|
||||||
"""
|
|
||||||
Full ring pulsing; red at all 6 mic positions if muted.
|
|
||||||
Mirrors ESPHome "Timer Ring" effect.
|
|
||||||
"""
|
|
||||||
factor = self._pulse_step(10)
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
self._leds.set(i, _scale(color, factor))
|
|
||||||
if muted:
|
|
||||||
self._apply_mic_indicators_pulsed(factor)
|
|
||||||
self._leds.show()
|
|
||||||
return 0.01
|
|
||||||
|
|
||||||
def _anim_timer_tick(
|
|
||||||
self, color: RGB, muted: bool,
|
|
||||||
seconds_left: int, total_seconds: int,
|
|
||||||
) -> float:
|
|
||||||
"""
|
|
||||||
Arc proportional to remaining time with sweep marker dip.
|
|
||||||
Mirrors ESPHome "Timer Tick" effect exactly.
|
|
||||||
"""
|
|
||||||
total = max(total_seconds, 1)
|
|
||||||
timer_ratio = LED_COUNT * seconds_left / total
|
|
||||||
last_led_on = max(0, math.ceil(timer_ratio) - 1)
|
|
||||||
|
|
||||||
for i in range(LED_COUNT):
|
|
||||||
brightness_dip = (
|
|
||||||
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
|
|
||||||
)
|
|
||||||
if i <= timer_ratio:
|
|
||||||
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
|
|
||||||
self._leds.set(i, _scale(color, brightness))
|
|
||||||
else:
|
|
||||||
self._leds.set(i, BLACK)
|
|
||||||
|
|
||||||
if muted:
|
|
||||||
self._apply_mic_indicators()
|
|
||||||
|
|
||||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
|
||||||
self._leds.show()
|
|
||||||
return 0.1
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Animation loop
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _loop(self) -> None:
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
with self._anim_lock:
|
|
||||||
anim = self._animation
|
|
||||||
|
|
||||||
snap = self._state.snapshot
|
|
||||||
color = self._color()
|
|
||||||
muted = snap["muted"]
|
|
||||||
t_total = snap["timer_total_seconds"]
|
|
||||||
t_left = snap["timer_seconds_left"]
|
|
||||||
|
|
||||||
if anim == self.ANIM_OFF:
|
|
||||||
sleep = self._anim_off()
|
|
||||||
elif anim == self.ANIM_TWINKLE:
|
|
||||||
sleep = self._anim_twinkle(RED)
|
|
||||||
elif anim == self.ANIM_TWINKLE_BLUE:
|
|
||||||
sleep = self._anim_twinkle(color)
|
|
||||||
elif anim == self.ANIM_WAIT_CMD:
|
|
||||||
sleep = self._anim_spin(color, interval=0.1, reverse=False)
|
|
||||||
elif anim == self.ANIM_LISTENING:
|
|
||||||
sleep = self._anim_spin(color, interval=0.05, reverse=False)
|
|
||||||
elif anim == self.ANIM_THINKING:
|
|
||||||
sleep = self._anim_thinking(color)
|
|
||||||
elif anim == self.ANIM_REPLYING:
|
|
||||||
sleep = self._anim_spin(color, interval=0.05, reverse=True)
|
|
||||||
elif anim == self.ANIM_MUTED:
|
|
||||||
sleep = self._anim_muted(color)
|
|
||||||
elif anim == self.ANIM_ERROR:
|
|
||||||
sleep = self._anim_error()
|
|
||||||
elif anim == self.ANIM_TIMER_RING:
|
|
||||||
sleep = self._anim_timer_ring(color, muted)
|
|
||||||
elif anim == self.ANIM_TIMER_TICK:
|
|
||||||
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
|
|
||||||
else:
|
|
||||||
sleep = self._anim_off()
|
|
||||||
|
|
||||||
time.sleep(sleep)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# State → animation mapping (identical to 4-Mic HAT)
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
def state_to_animation(
|
|
||||||
state: AssistState, ha_connected: bool, muted: bool
|
|
||||||
) -> str:
|
|
||||||
if not ha_connected:
|
|
||||||
return LEDRing.ANIM_TWINKLE
|
|
||||||
if state == AssistState.NOT_READY:
|
|
||||||
return LEDRing.ANIM_TWINKLE
|
|
||||||
if state == AssistState.TIMER_RINGING:
|
|
||||||
return LEDRing.ANIM_TIMER_RING
|
|
||||||
if state == AssistState.WAKE_WORD:
|
|
||||||
return LEDRing.ANIM_WAIT_CMD
|
|
||||||
if state == AssistState.LISTENING:
|
|
||||||
return LEDRing.ANIM_LISTENING
|
|
||||||
if state == AssistState.THINKING:
|
|
||||||
return LEDRing.ANIM_THINKING
|
|
||||||
if state == AssistState.SPEAKING:
|
|
||||||
return LEDRing.ANIM_REPLYING
|
|
||||||
if state == AssistState.ERROR:
|
|
||||||
return LEDRing.ANIM_ERROR
|
|
||||||
if state == AssistState.MUTED or muted:
|
|
||||||
return LEDRing.ANIM_MUTED
|
|
||||||
if state == AssistState.TIMER_TICKING:
|
|
||||||
return LEDRing.ANIM_TIMER_TICK
|
|
||||||
return LEDRing.ANIM_OFF
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# WebSocket client
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
class LVAClient:
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
state: SharedState,
|
|
||||||
leds: LEDRing,
|
|
||||||
command_queue: asyncio.Queue,
|
|
||||||
) -> None:
|
|
||||||
self._uri = f"ws://{host}:{port}"
|
|
||||||
self._state = state
|
|
||||||
self._leds = leds
|
|
||||||
self._queue = command_queue
|
|
||||||
|
|
||||||
async def run_forever(self) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await self._connect()
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
_LOGGER.warning(
|
|
||||||
"LVA connection lost (%s) – retrying in %.0fs",
|
|
||||||
exc, RECONNECT_DELAY_S,
|
|
||||||
)
|
|
||||||
self._state.update(
|
|
||||||
ha_connected=False,
|
|
||||||
assist_state=AssistState.NOT_READY,
|
|
||||||
)
|
|
||||||
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
|
|
||||||
await asyncio.sleep(RECONNECT_DELAY_S)
|
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
|
||||||
_LOGGER.info("Connecting to LVA at %s …", self._uri)
|
|
||||||
async with websockets.connect(self._uri) as ws:
|
|
||||||
_LOGGER.info("Connected to LVA peripheral API")
|
|
||||||
recv_task = asyncio.create_task(self._recv_loop(ws))
|
|
||||||
send_task = asyncio.create_task(self._send_loop(ws))
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
[recv_task, send_task],
|
|
||||||
return_when=asyncio.FIRST_EXCEPTION,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
for task in done:
|
|
||||||
if task.exception():
|
|
||||||
raise task.exception()
|
|
||||||
|
|
||||||
async def _recv_loop(self, ws) -> None:
|
|
||||||
async for raw in ws:
|
|
||||||
try:
|
|
||||||
msg = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
await self._handle_event(msg)
|
|
||||||
|
|
||||||
async def _send_loop(self, ws) -> None:
|
|
||||||
while True:
|
|
||||||
command = await self._queue.get()
|
|
||||||
try:
|
|
||||||
await ws.send(json.dumps({"command": command}))
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
_LOGGER.warning("Failed to send command %s: %s", command, exc)
|
|
||||||
self._queue.put_nowait(command)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _handle_event(self, msg: dict) -> None:
|
|
||||||
event = msg.get("event", "")
|
|
||||||
data = msg.get("data") or {}
|
|
||||||
|
|
||||||
_LOGGER.debug("Event: %s data=%s", event, data)
|
|
||||||
|
|
||||||
if event == "snapshot":
|
|
||||||
self._state.update(
|
|
||||||
muted=data.get("muted", False),
|
|
||||||
volume=data.get("volume", 1.0),
|
|
||||||
ha_connected=data.get("ha_connected", False),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "wake_word_detected":
|
|
||||||
self._state.update(assist_state=AssistState.WAKE_WORD)
|
|
||||||
|
|
||||||
elif event == "listening":
|
|
||||||
self._state.update(assist_state=AssistState.LISTENING)
|
|
||||||
|
|
||||||
elif event == "thinking":
|
|
||||||
self._state.update(assist_state=AssistState.THINKING)
|
|
||||||
|
|
||||||
elif event == "tts_speaking":
|
|
||||||
self._state.update(assist_state=AssistState.SPEAKING)
|
|
||||||
|
|
||||||
elif event in ("tts_finished", "idle"):
|
|
||||||
self._state.update(assist_state=AssistState.IDLE)
|
|
||||||
|
|
||||||
elif event == "muted":
|
|
||||||
self._state.update(assist_state=AssistState.MUTED, muted=True)
|
|
||||||
|
|
||||||
elif event == "pipeline_error":
|
|
||||||
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
|
|
||||||
self._state.update(assist_state=AssistState.ERROR)
|
|
||||||
|
|
||||||
elif event == "disconnected":
|
|
||||||
_LOGGER.warning("Home Assistant disconnected")
|
|
||||||
self._state.update(
|
|
||||||
ha_connected=False,
|
|
||||||
assist_state=AssistState.NOT_READY,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "timer_ticking":
|
|
||||||
self._state.update(
|
|
||||||
assist_state=AssistState.TIMER_TICKING,
|
|
||||||
timer_total_seconds=data.get("total_seconds", 0),
|
|
||||||
timer_seconds_left=data.get("seconds_left", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "timer_updated":
|
|
||||||
self._state.update(
|
|
||||||
timer_total_seconds=data.get("total_seconds", 0),
|
|
||||||
timer_seconds_left=data.get("seconds_left", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "timer_ringing":
|
|
||||||
self._state.update(
|
|
||||||
assist_state=AssistState.TIMER_RINGING,
|
|
||||||
timer_total_seconds=data.get("total_seconds", 0),
|
|
||||||
timer_seconds_left=data.get("seconds_left", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "media_player_playing":
|
|
||||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
|
||||||
|
|
||||||
elif event == "volume_changed":
|
|
||||||
self._state.update(volume=data.get("volume", 1.0))
|
|
||||||
|
|
||||||
elif event == "volume_muted":
|
|
||||||
self._state.update(muted=data.get("muted", False))
|
|
||||||
|
|
||||||
elif event == "zeroconf":
|
|
||||||
status = data.get("status", "")
|
|
||||||
if status == "connected":
|
|
||||||
self._state.update(ha_connected=True)
|
|
||||||
_LOGGER.info("Home Assistant connected")
|
|
||||||
elif status == "getting_started":
|
|
||||||
_LOGGER.info("LVA starting up, waiting for HA …")
|
|
||||||
|
|
||||||
# Recompute LED animation after every event
|
|
||||||
snap = self._state.snapshot
|
|
||||||
anim = state_to_animation(
|
|
||||||
snap["assist_state"], snap["ha_connected"], snap["muted"]
|
|
||||||
)
|
|
||||||
self._leds.set_animation(anim)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Main
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
async def async_main(host: str, port: int) -> None:
|
|
||||||
state = SharedState()
|
|
||||||
command_queue: asyncio.Queue = asyncio.Queue()
|
|
||||||
|
|
||||||
leds = LEDRing(state)
|
|
||||||
client = LVAClient(host, port, state, leds, command_queue)
|
|
||||||
|
|
||||||
leds.start()
|
|
||||||
leds.set_animation(LEDRing.ANIM_TWINKLE)
|
|
||||||
|
|
||||||
def _shutdown(signum, frame) -> None:
|
|
||||||
_LOGGER.info("Shutdown requested")
|
|
||||||
leds.stop()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _shutdown)
|
|
||||||
signal.signal(signal.SIGTERM, _shutdown)
|
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
"ReSpeaker Mic Array v2.0 controller started – connecting to ws://%s:%d",
|
|
||||||
host, port,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await client.run_forever()
|
|
||||||
finally:
|
|
||||||
leds.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="ReSpeaker Mic Array v2.0 (USB) controller for Linux Voice Assistant"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--host", default=DEFAULT_LVA_HOST,
|
|
||||||
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port", type=int, default=DEFAULT_LVA_PORT,
|
|
||||||
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--debug", action="store_true",
|
|
||||||
help="Enable DEBUG logging",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG if args.debug else logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(async_main(args.host, args.port))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from pixel_ring import pixel_ring
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.satellite import (
|
|
||||||
SatelliteConnected,
|
|
||||||
SatelliteDisconnected,
|
|
||||||
StreamingStarted,
|
|
||||||
StreamingStopped,
|
|
||||||
)
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
|
||||||
from wyoming.snd import Played
|
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
|
|
||||||
_LOGGER.info("Ready")
|
|
||||||
|
|
||||||
# Turn on power to LEDs
|
|
||||||
pixel_ring.set_color_palette(0x0080FF, 0x007A37)
|
|
||||||
pixel_ring.think()
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
pixel_ring.off()
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
server = AsyncServer.from_uri(args.uri)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await server.run(partial(LEDsEventHandler, args))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
pixel_ring.off()
|
|
||||||
|
|
||||||
|
|
||||||
class LEDsEventHandler(AsyncEventHandler):
|
|
||||||
"""Event handler for clients."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.client_id = str(time.monotonic_ns())
|
|
||||||
|
|
||||||
_LOGGER.debug("Client connected: %s", self.client_id)
|
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
_LOGGER.debug(event)
|
|
||||||
|
|
||||||
if Detection.is_type(event.type):
|
|
||||||
_LOGGER.debug("Detection")
|
|
||||||
pixel_ring.wakeup()
|
|
||||||
elif VoiceStarted.is_type(event.type):
|
|
||||||
_LOGGER.debug("VoiceStarted")
|
|
||||||
pixel_ring.speak()
|
|
||||||
elif VoiceStopped.is_type(event.type):
|
|
||||||
_LOGGER.debug("VoiceStopped")
|
|
||||||
pixel_ring.spin()
|
|
||||||
elif StreamingStopped.is_type(event.type):
|
|
||||||
_LOGGER.debug("StreamingStopped")
|
|
||||||
pixel_ring.off()
|
|
||||||
elif SatelliteConnected.is_type(event.type):
|
|
||||||
_LOGGER.debug("SatelliteConnected")
|
|
||||||
pixel_ring.think()
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
pixel_ring.off()
|
|
||||||
elif Played.is_type(event.type):
|
|
||||||
_LOGGER.debug("Played")
|
|
||||||
pixel_ring.off()
|
|
||||||
elif SatelliteDisconnected.is_type(event.type):
|
|
||||||
_LOGGER.debug("SatelliteDisconnected")
|
|
||||||
pixel_ring.mono(0xFF0000)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
@@ -1,650 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Wyoming Satellite Control</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
||||||
color: #eee;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 12px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status::before {
|
|
||||||
content: '';
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected { background: rgba(0, 255, 136, 0.2); color: #00ff88; }
|
|
||||||
.status.connected::before { background: #00ff88; }
|
|
||||||
.status.disconnected { background: rgba(255, 71, 87, 0.2); color: #ff4757; }
|
|
||||||
.status.disconnected::before { background: #ff4757; animation: none; }
|
|
||||||
.status.connecting { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
|
|
||||||
.status.connecting::before { background: #ffc107; }
|
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #00d9ff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Controls */
|
|
||||||
.control-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Volume Slider */
|
|
||||||
.volume-slider {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: linear-gradient(90deg, #00d9ff 0%, #00ff88 100%);
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-value {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #00d9ff;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: linear-gradient(90deg, #ff4757, #ff6b81);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: linear-gradient(90deg, #00ff88, #00d9ff);
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: linear-gradient(90deg, #ffc107, #ff9800);
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RMS Meter */
|
|
||||||
.rms-meter {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rms-bar {
|
|
||||||
height: 30px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rms-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #00ff88 0%, #ffc107 70%, #ff4757 100%);
|
|
||||||
transition: width 0.1s ease;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rms-value {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rms-label {
|
|
||||||
text-align: center;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event Log */
|
|
||||||
.log-container {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry {
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 3px solid #00d9ff;
|
|
||||||
background: rgba(0, 217, 255, 0.05);
|
|
||||||
animation: slideIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.audio-level {
|
|
||||||
border-left-color: #00ff88;
|
|
||||||
background: rgba(0, 255, 136, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.error {
|
|
||||||
border-left-color: #ff4757;
|
|
||||||
background: rgba(255, 71, 87, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.system {
|
|
||||||
border-left-color: #ffc107;
|
|
||||||
background: rgba(255, 193, 7, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-time {
|
|
||||||
color: #666;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-type {
|
|
||||||
color: #00d9ff;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-data {
|
|
||||||
color: #aaa;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings */
|
|
||||||
.settings-panel {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel.show {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #eee;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel input[type="text"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #00d9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: #aaa;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
|
||||||
<div>
|
|
||||||
<h1>🛰️ Wyoming Satellite Control</h1>
|
|
||||||
<div style="color: #aaa; font-size: 14px;">WebSocket Client & Audio Level Monitor</div>
|
|
||||||
</div>
|
|
||||||
<div class="status" id="status">
|
|
||||||
Connecting...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="settings-panel" id="settingsPanel">
|
|
||||||
<label style="color: #00d9ff; font-weight: 600; display: block; margin-bottom: 8px;">
|
|
||||||
WebSocket URL:
|
|
||||||
</label>
|
|
||||||
<input type="text" id="wsUrl" value="ws://localhost:8675">
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center; margin-top: 10px;">
|
|
||||||
<button class="btn btn-primary" onclick="connect()">
|
|
||||||
🔌 Connect
|
|
||||||
</button>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="autoReconnect" checked>
|
|
||||||
Auto-reconnect
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Control Grid -->
|
|
||||||
<div class="grid">
|
|
||||||
<!-- Volume Control -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>🔊 Volume Control</h2>
|
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Master Volume</label>
|
|
||||||
<input type="range" class="volume-slider" id="volumeSlider"
|
|
||||||
min="0" max="100" value="100" oninput="updateVolume(this.value)">
|
|
||||||
<div class="volume-value" id="volumeValue">100%</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-danger" onclick="sendCommand('mute', {})">
|
|
||||||
🔇 Mute
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success" onclick="sendCommand('unmute', {})">
|
|
||||||
🔊 Unmute
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RMS Audio Level -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>📊 RMS Audio Level</h2>
|
|
||||||
|
|
||||||
<div class="rms-meter">
|
|
||||||
<div class="rms-bar">
|
|
||||||
<div class="rms-fill" id="rmsFill" style="width: 0%;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="rms-value" id="rmsValue">0.00</div>
|
|
||||||
<div class="rms-label">Real-time microphone level</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-warning" onclick="sendCommand('play_sound', {path: '/home/sirmrak/projects/satellite-dev/sounds/awake.wav'})">
|
|
||||||
🔔 Test Sound
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LED Control -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>💡 LED Control</h2>
|
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Brightness</label>
|
|
||||||
<input type="range" class="volume-slider" id="ledBrightness"
|
|
||||||
min="0" max="255" value="128" oninput="updateLedBrightness(this.value)">
|
|
||||||
<div class="volume-value" id="ledBrightnessValue">128</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-primary" onclick="sendCommand('set_led_color', {r: 0, g: 188, b: 212})">
|
|
||||||
🔵 Cyan
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger" onclick="sendCommand('set_led_color', {r: 255, g: 0, b: 0})">
|
|
||||||
🔴 Red
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success" onclick="sendCommand('set_led_color', {r: 0, g: 255, b: 0})">
|
|
||||||
🟢 Green
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Event Log -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>📋 Event Log</h2>
|
|
||||||
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
|
|
||||||
<button class="btn btn-primary" onclick="clearLog()">
|
|
||||||
🗑️ Clear Log
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" onclick="toggleSettings()">
|
|
||||||
⚙️ Settings
|
|
||||||
</button>
|
|
||||||
<label class="checkbox-label" style="margin-left: auto;">
|
|
||||||
<input type="checkbox" id="autoScroll" checked>
|
|
||||||
Auto-scroll
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="log-container" id="log"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// State
|
|
||||||
let ws = null;
|
|
||||||
let eventCount = 0;
|
|
||||||
let currentRms = 0;
|
|
||||||
let rmsTimeout = null;
|
|
||||||
|
|
||||||
// Elements
|
|
||||||
const log = document.getElementById('log');
|
|
||||||
const status = document.getElementById('status');
|
|
||||||
const settingsPanel = document.getElementById('settingsPanel');
|
|
||||||
const wsUrlInput = document.getElementById('wsUrl');
|
|
||||||
const autoReconnectCheckbox = document.getElementById('autoReconnect');
|
|
||||||
const rmsFill = document.getElementById('rmsFill');
|
|
||||||
const rmsValue = document.getElementById('rmsValue');
|
|
||||||
|
|
||||||
// Auto-detect WebSocket URL
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const detectedUrl = `ws://${window.location.hostname}:8675`;
|
|
||||||
wsUrlInput.value = detectedUrl;
|
|
||||||
connect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect to WebSocket
|
|
||||||
function connect() {
|
|
||||||
const wsUrl = wsUrlInput.value.trim();
|
|
||||||
|
|
||||||
if (!wsUrl) {
|
|
||||||
addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close existing connection
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('connecting', 'Connecting...');
|
|
||||||
addEvent('SYSTEM', { status: `Connecting to ${wsUrl}...` });
|
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
setStatus('connected', `Connected`);
|
|
||||||
addEvent('SYSTEM', { status: `Connected to ${wsUrl}` });
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
addEvent(data.type, data.data);
|
|
||||||
|
|
||||||
// Handle audio-level events
|
|
||||||
if (data.type === 'audio-level' && data.data && data.data.level !== undefined) {
|
|
||||||
updateRmsMeter(data.data.level);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
addEvent('RAW', { raw: event.data });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
setStatus('disconnected', 'Disconnected');
|
|
||||||
addEvent('SYSTEM', { status: 'Connection closed' });
|
|
||||||
|
|
||||||
// Auto-reconnect
|
|
||||||
if (autoReconnectCheckbox.checked) {
|
|
||||||
setTimeout(() => {
|
|
||||||
addEvent('SYSTEM', { status: 'Attempting to reconnect...' });
|
|
||||||
connect();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
|
||||||
setStatus('disconnected', 'Error');
|
|
||||||
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set status
|
|
||||||
function setStatus(state, text) {
|
|
||||||
status.className = `status ${state}`;
|
|
||||||
status.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
function sendCommand(command, data) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
||||||
addEvent('SYSTEM', { error: 'Not connected to WebSocket server' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = { command, data };
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
addEvent('COMMAND', { command, data });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update volume
|
|
||||||
function updateVolume(value) {
|
|
||||||
document.getElementById('volumeValue').textContent = `${value}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send volume command
|
|
||||||
document.getElementById('volumeSlider').addEventListener('change', function() {
|
|
||||||
const level = parseInt(this.value) / 100;
|
|
||||||
sendCommand('set_volume', { level });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update LED brightness
|
|
||||||
function updateLedBrightness(value) {
|
|
||||||
document.getElementById('ledBrightnessValue').textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send LED brightness command
|
|
||||||
document.getElementById('ledBrightness').addEventListener('change', function() {
|
|
||||||
const brightness = parseInt(this.value);
|
|
||||||
sendCommand('set_led_brightness', { brightness });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update RMS meter
|
|
||||||
function updateRmsMeter(level) {
|
|
||||||
currentRms = level;
|
|
||||||
const percent = Math.min(100, Math.max(0, level * 100));
|
|
||||||
rmsFill.style.width = `${percent}%`;
|
|
||||||
rmsValue.textContent = level.toFixed(2);
|
|
||||||
|
|
||||||
// Reset after 1 second of no audio
|
|
||||||
clearTimeout(rmsTimeout);
|
|
||||||
rmsTimeout = setTimeout(() => {
|
|
||||||
currentRms = 0;
|
|
||||||
rmsFill.style.width = '0%';
|
|
||||||
rmsValue.textContent = '0.00';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle settings
|
|
||||||
function toggleSettings() {
|
|
||||||
settingsPanel.classList.toggle('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event to log
|
|
||||||
function addEvent(type, data) {
|
|
||||||
eventCount++;
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = `log-entry ${type.toLowerCase().replace('_', '-')}`;
|
|
||||||
|
|
||||||
let dataStr = '';
|
|
||||||
if (data.error) {
|
|
||||||
dataStr = `<span style="color: #ff4757;">${escapeHtml(data.error)}</span>`;
|
|
||||||
} else if (data.status) {
|
|
||||||
dataStr = `<span style="color: #00ff88;">${escapeHtml(data.status)}</span>`;
|
|
||||||
} else if (data.command) {
|
|
||||||
dataStr = `<span style="color: #ffc107;">→ ${escapeHtml(data.command)}</span> ${escapeHtml(JSON.stringify(data.data))}`;
|
|
||||||
} else {
|
|
||||||
dataStr = escapeHtml(JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
div.innerHTML = `
|
|
||||||
<span class="log-time">[${time}]</span>
|
|
||||||
<span class="log-type">#${eventCount} ${escapeHtml(type)}</span>
|
|
||||||
<span class="log-data">${dataStr}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
log.appendChild(div);
|
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
if (document.getElementById('autoScroll').checked) {
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear log
|
|
||||||
function clearLog() {
|
|
||||||
log.innerHTML = '';
|
|
||||||
eventCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape HTML
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
websockets==12.0
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from functools import partial
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
|
||||||
parser.add_argument("--websocket-host", default="localhost")
|
|
||||||
parser.add_argument("--websocket-port", type=int, default=8675)
|
|
||||||
#
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
|
|
||||||
_LOGGER.info("Ready")
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
server = AsyncServer.from_uri(args.uri)
|
|
||||||
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with websockets.serve(
|
|
||||||
partial(websocket_connected, queue),
|
|
||||||
args.websocket_host,
|
|
||||||
args.websocket_port,
|
|
||||||
):
|
|
||||||
await server.run(partial(WebsocketEventHandler, args, queue))
|
|
||||||
finally:
|
|
||||||
queue.put_nowait(None)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
event = await queue.get()
|
|
||||||
if event is None:
|
|
||||||
# Stop signal
|
|
||||||
break
|
|
||||||
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except websockets.ConnectionClosed:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Error in websocket handler")
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketEventHandler(AsyncEventHandler):
|
|
||||||
"""Event handler for clients."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
queue: "asyncio.Queue[Optional[Event]]",
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.queue = queue
|
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
_LOGGER.debug(event)
|
|
||||||
self.queue.put_nowait(event)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
async def test():
|
|
||||||
async with websockets.connect("ws://localhost:8675") as ws:
|
|
||||||
print("Connected!")
|
|
||||||
while True:
|
|
||||||
msg = await ws.recv()
|
|
||||||
print(f"Received: {msg}")
|
|
||||||
|
|
||||||
asyncio.run(test())
|
|
||||||
Binary file not shown.
@@ -1,244 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="description" content="Wyoming satellite timer example">
|
|
||||||
<meta name="author" content="Michael Hansen">
|
|
||||||
|
|
||||||
<title>Wyoming Timer Example</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer {
|
|
||||||
display: flex;
|
|
||||||
width: 100px;
|
|
||||||
height: auto;
|
|
||||||
flex-wrap:wrap;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #03a9f4;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
|
||||||
color: white;
|
|
||||||
margin-right: 20px
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
text-align: center;
|
|
||||||
width: 90px;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.finished {
|
|
||||||
background-color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paused {
|
|
||||||
background-color: yellow;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blink {
|
|
||||||
animation: blink-animation 1s linear infinite;
|
|
||||||
-webkit-animation: blink-animation 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes blink-animation {
|
|
||||||
0%, 100% {opacity: 1;}
|
|
||||||
50% {opacity: 0;}
|
|
||||||
}
|
|
||||||
@-webkit-keyframes blink-animation {
|
|
||||||
0%, 100% {opacity: 1;}
|
|
||||||
50% {opacity: 0;}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="timers" class="timer-container">
|
|
||||||
</div>
|
|
||||||
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let socket = new WebSocket("ws://localhost:8675")
|
|
||||||
var timers = []
|
|
||||||
var finished_timers = 0
|
|
||||||
|
|
||||||
function q(selector) {return document.querySelector(selector)}
|
|
||||||
|
|
||||||
socket.onopen = function(e) {
|
|
||||||
console.log("Connected")
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onmessage = function(event) {
|
|
||||||
wyo_event = JSON.parse(event.data)
|
|
||||||
if (wyo_event.type == "timer-started") {
|
|
||||||
let timer_info = wyo_event.data
|
|
||||||
timer_info.is_active = true
|
|
||||||
console.log(timer_info)
|
|
||||||
|
|
||||||
timers.push(timer_info)
|
|
||||||
|
|
||||||
let timers_elem = q("#timers")
|
|
||||||
let timer_div = document.createElement("div")
|
|
||||||
timer_div.id = "timer-" + timer_info.id
|
|
||||||
timer_div.classList.add("timer")
|
|
||||||
|
|
||||||
let message_div = document.createElement("div")
|
|
||||||
message_div.classList.add("message")
|
|
||||||
|
|
||||||
if (timer_info.name) {
|
|
||||||
message_div.innerHTML = timer_info.name
|
|
||||||
} else {
|
|
||||||
if (timer_info.start_hours) {
|
|
||||||
message_div.innerHTML += timer_info.start_hours + " hr "
|
|
||||||
}
|
|
||||||
if (timer_info.start_minutes) {
|
|
||||||
message_div.innerHTML += timer_info.start_minutes + " min "
|
|
||||||
}
|
|
||||||
if (timer_info.start_seconds) {
|
|
||||||
message_div.innerHTML += timer_info.start_seconds + " sec "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer_div.appendChild(message_div)
|
|
||||||
|
|
||||||
let time_div = document.createElement("div")
|
|
||||||
time_div.classList.add("time")
|
|
||||||
|
|
||||||
let hours = timer_info.start_hours ?? 0
|
|
||||||
let minutes = timer_info.start_minutes ?? 0
|
|
||||||
let seconds = timer_info.start_seconds ?? 0
|
|
||||||
|
|
||||||
time_div.innerHTML =
|
|
||||||
hours.toString().padStart(2, 0) + ":" +
|
|
||||||
minutes.toString().padStart(2, 0) + ":" +
|
|
||||||
seconds.toString().padStart(2, 0);
|
|
||||||
|
|
||||||
timer_div.appendChild(time_div)
|
|
||||||
|
|
||||||
timers_elem.appendChild(timer_div)
|
|
||||||
|
|
||||||
setTimeout(update_timer, 1000, timer_info)
|
|
||||||
}
|
|
||||||
else if (wyo_event.type == "timer-cancelled") {
|
|
||||||
let timer_info = wyo_event.data
|
|
||||||
timers = timers.filter(t => t.id != timer_info.id)
|
|
||||||
let timer_div = q("#timer-" + timer_info.id)
|
|
||||||
if (timer_div) {
|
|
||||||
timer_div.remove()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (wyo_event.type == "timer-updated") {
|
|
||||||
let timer_info = wyo_event.data
|
|
||||||
for (i = 0; i < timers.length; i++) {
|
|
||||||
if (timers[i].id == timer_info.id) {
|
|
||||||
timers[i].is_active = timer_info.is_active
|
|
||||||
timers[i].total_seconds = timer_info.total_seconds
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer_div = q("#timer-" + timer_info.id)
|
|
||||||
if (timer_div) {
|
|
||||||
if (timer_info.is_active) {
|
|
||||||
timer_div.classList.remove("paused")
|
|
||||||
} else {
|
|
||||||
timer_div.classList.add("paused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (wyo_event.type == "timer-finished") {
|
|
||||||
let timer_info = wyo_event.data
|
|
||||||
for (i = 0; i < timers.length; i++) {
|
|
||||||
if (timers[i].id == timer_info.id) {
|
|
||||||
timers[i].total_seconds = 0
|
|
||||||
finished_timers += 1
|
|
||||||
if (finished_timers == 1) {
|
|
||||||
q("#audio-finished").play()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timers = timers.filter(t => t.id != timer_info.id)
|
|
||||||
let timer_div = q("#timer-" + timer_info.id)
|
|
||||||
if (timer_div) {
|
|
||||||
timer_div.classList.add("finished")
|
|
||||||
timer_div.classList.add("blink")
|
|
||||||
|
|
||||||
timer_div.onclick = function() {
|
|
||||||
timer_div.remove()
|
|
||||||
finished_timers = Math.max(0, finished_timers - 1)
|
|
||||||
if (finished_timers == 0) {
|
|
||||||
let audio = q("#audio-finished")
|
|
||||||
audio.pause()
|
|
||||||
audio.currentTime = 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let time_div = q("#timer-" + timer_info.id + " .time")
|
|
||||||
if (time_div) {
|
|
||||||
time_div.style.opacity = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = function(event) {
|
|
||||||
console.log("Disconnected")
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = function(error) {
|
|
||||||
};
|
|
||||||
|
|
||||||
function update_timer(timer_info) {
|
|
||||||
if (timer_info.total_seconds < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let time_div = q("#timer-" + timer_info.id + " .time")
|
|
||||||
if (!time_div) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!timer_info.is_active) {
|
|
||||||
// Paused
|
|
||||||
setTimeout(update_timer, 1000, timer_info)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timer_info.total_seconds > 0) {
|
|
||||||
timer_info.total_seconds -= 1
|
|
||||||
}
|
|
||||||
let total_minutes = Math.floor(timer_info.total_seconds / 60)
|
|
||||||
|
|
||||||
let hours = Math.floor(total_minutes / 60)
|
|
||||||
let minutes = total_minutes % 60
|
|
||||||
let seconds = timer_info.total_seconds % 60
|
|
||||||
|
|
||||||
time_div.innerHTML =
|
|
||||||
hours.toString().padStart(2, 0) + ":" +
|
|
||||||
minutes.toString().padStart(2, 0) + ":" +
|
|
||||||
seconds.toString().padStart(2, 0)
|
|
||||||
|
|
||||||
setTimeout(update_timer, 1000, timer_info)
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Wyoming LED Control with MQTT Discovery for Home Assistant
|
|
||||||
Полная версия: Light + Sensor + 7 Number sliders
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import signal
|
|
||||||
import json
|
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.satellite import SatelliteConnected
|
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
|
||||||
from wyoming.snd import Played
|
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
|
|
||||||
# Импорт USB контроллера
|
|
||||||
from wyoming_satellite.xvf_usb import (
|
|
||||||
XVF3800Controller,
|
|
||||||
EFFECT_OFF, EFFECT_BREATH, EFFECT_RAINBOW, EFFECT_DOA,
|
|
||||||
rgb_to_bgr_int, scale_color
|
|
||||||
)
|
|
||||||
|
|
||||||
# ====================== НАСТРОЙКИ ======================
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
logger = logging.getLogger("XVF3800 LED Service")
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
||||||
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
|
|
||||||
|
|
||||||
MQTT_BROKER = "192.168.200.100"
|
|
||||||
MQTT_PORT = 1883
|
|
||||||
MQTT_USER = "mqtt"
|
|
||||||
MQTT_PASS = "mqtt"
|
|
||||||
|
|
||||||
# Топики
|
|
||||||
MQTT_TOPIC_COMMAND = "wyoming/led/command"
|
|
||||||
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
|
|
||||||
MQTT_TOPIC_STATE = "wyoming/led/state"
|
|
||||||
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state"
|
|
||||||
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
|
|
||||||
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
|
|
||||||
|
|
||||||
MQTT_DISCOVERY_PREFIX = "homeassistant"
|
|
||||||
MQTT_CLIENT_ID = "wyoming-led-service"
|
|
||||||
|
|
||||||
MQTT_DISCOVERY_DEVICE = {
|
|
||||||
"identifiers": ["wyoming-led-repeaker"],
|
|
||||||
"name": "Wyoming LED ReSpeaker",
|
|
||||||
"manufacturer": "ReSpeaker",
|
|
||||||
"model": "XVF3800",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Настройки яркости
|
|
||||||
BRIGHTNESS_CONFIG = {
|
|
||||||
"listening": 120, "wake": 200, "thinking": 150,
|
|
||||||
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
|
||||||
"global_multiplier": 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
mqtt_client = None
|
|
||||||
current_state = "idle"
|
|
||||||
last_mqtt_brightness = None
|
|
||||||
ha_disabled = False
|
|
||||||
led_controller: XVF3800Controller | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== CONFIG ======================
|
|
||||||
def load_brightness_config():
|
|
||||||
global BRIGHTNESS_CONFIG
|
|
||||||
if CONFIG_FILE.exists():
|
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE) as f:
|
|
||||||
saved = json.load(f)
|
|
||||||
BRIGHTNESS_CONFIG.update(saved)
|
|
||||||
logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠️ Ошибка загрузки config: {e}")
|
|
||||||
else:
|
|
||||||
save_brightness_config()
|
|
||||||
|
|
||||||
def save_brightness_config():
|
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE, "w") as f:
|
|
||||||
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
|
||||||
logger.info(f"💾 Сохранено в {CONFIG_FILE}: {BRIGHTNESS_CONFIG}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка сохранения config: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== LED ======================
|
|
||||||
def init_leds() -> bool:
|
|
||||||
"""Инициализация USB контроллера."""
|
|
||||||
global led_controller
|
|
||||||
try:
|
|
||||||
led_controller = XVF3800Controller()
|
|
||||||
if led_controller.is_connected():
|
|
||||||
logger.info("✅ XVF3800 USB подключён")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ XVF3800 не найден (USB)")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка инициализации LED: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_effective_brightness(state: str) -> int:
|
|
||||||
base = BRIGHTNESS_CONFIG.get(state, 100)
|
|
||||||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
|
||||||
return int(base * multiplier)
|
|
||||||
|
|
||||||
|
|
||||||
def set_led_state(state: str, skip_publish=False):
|
|
||||||
"""Установка LED состояния через USB контроллер."""
|
|
||||||
global current_state
|
|
||||||
current_state = state
|
|
||||||
brightness = get_effective_brightness(state)
|
|
||||||
|
|
||||||
if led_controller is None:
|
|
||||||
logger.warning("⚠️ LED контроллер не инициализирован")
|
|
||||||
if not skip_publish:
|
|
||||||
publish_state(state)
|
|
||||||
publish_satellite_state(state)
|
|
||||||
return
|
|
||||||
|
|
||||||
if state == "listening":
|
|
||||||
# Бирюзовое дыхание
|
|
||||||
led_controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
led_controller.set_led_color(rgb_to_bgr_int(0, 188, 212)) # 0x00BCD4
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
led_controller.set_led_speed(2)
|
|
||||||
logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})")
|
|
||||||
|
|
||||||
elif state == "wake":
|
|
||||||
# Красное дыхание
|
|
||||||
led_controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 0)) # 0xFF0000
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
logger.info(f"🔴 Wake: красный ({brightness})")
|
|
||||||
|
|
||||||
elif state == "thinking":
|
|
||||||
# Радуга
|
|
||||||
led_controller.set_led_effect(EFFECT_RAINBOW)
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
led_controller.set_led_speed(4)
|
|
||||||
logger.info(f"🌈 Thinking: радуга ({brightness})")
|
|
||||||
|
|
||||||
elif state == "speaking":
|
|
||||||
# Зелёное дыхание
|
|
||||||
led_controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
led_controller.set_led_color(rgb_to_bgr_int(76, 175, 80)) # 0x4CAF50
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
logger.info(f"🟢 Speaking: зелёный ({brightness})")
|
|
||||||
|
|
||||||
elif state == "idle":
|
|
||||||
# DoA эффект: яркость управляется отдельно через set_led_brightness
|
|
||||||
base_color = rgb_to_bgr_int(0x30, 0x30, 0x30) # 0x303030 - серый
|
|
||||||
doa_color = rgb_to_bgr_int(0x00, 0xBC, 0xD4) # 0xD4BC00 - бирюзовый (BGR)
|
|
||||||
led_controller.set_led_effect(EFFECT_DOA)
|
|
||||||
led_controller.set_led_doa_color(base_color, doa_color)
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})")
|
|
||||||
|
|
||||||
elif state == "paused":
|
|
||||||
# Серое дыхание
|
|
||||||
led_controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
led_controller.set_led_color(rgb_to_bgr_int(64, 64, 64)) # 0x404040
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
logger.info(f"⚫ Paused: серый ({brightness})")
|
|
||||||
|
|
||||||
elif state == "error":
|
|
||||||
# Маджента дыхание
|
|
||||||
led_controller.set_led_effect(EFFECT_BREATH)
|
|
||||||
led_controller.set_led_color(rgb_to_bgr_int(255, 0, 255)) # 0xFF00FF
|
|
||||||
led_controller.set_led_brightness(brightness)
|
|
||||||
logger.info(f"🟣 Error: маджента ({brightness})")
|
|
||||||
|
|
||||||
elif state == "off":
|
|
||||||
# Выключить
|
|
||||||
led_controller.set_led_effect(EFFECT_OFF)
|
|
||||||
logger.info("⬛ Off: выключено")
|
|
||||||
|
|
||||||
if not skip_publish:
|
|
||||||
publish_state(state)
|
|
||||||
publish_satellite_state(state)
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== MQTT PUBLISH ======================
|
|
||||||
def publish_discovery_config():
|
|
||||||
if not mqtt_client:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
logger.info("🔄 Публикация Discovery...")
|
|
||||||
|
|
||||||
# Light
|
|
||||||
light_disc = {
|
|
||||||
"name": "Wyoming LED",
|
|
||||||
"unique_id": "wyoming_led_light",
|
|
||||||
"state_topic": MQTT_TOPIC_STATE,
|
|
||||||
"command_topic": MQTT_TOPIC_COMMAND,
|
|
||||||
"brightness_command_topic": MQTT_TOPIC_BRIGHTNESS_SET,
|
|
||||||
"brightness_state_topic": MQTT_TOPIC_BRIGHTNESS_STATE,
|
|
||||||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
|
||||||
"payload_available": "online",
|
|
||||||
"payload_not_available": "offline",
|
|
||||||
"payload_on": "ON",
|
|
||||||
"payload_off": "OFF",
|
|
||||||
"brightness_scale": 100,
|
|
||||||
"optimistic": False,
|
|
||||||
"qos": 1,
|
|
||||||
"device": MQTT_DISCOVERY_DEVICE,
|
|
||||||
}
|
|
||||||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
|
||||||
json.dumps(light_disc), retain=True, qos=1)
|
|
||||||
|
|
||||||
# Sensor
|
|
||||||
sensor_disc = {
|
|
||||||
"name": "Wyoming Satellite State",
|
|
||||||
"unique_id": "wyoming_satellite_state",
|
|
||||||
"state_topic": MQTT_TOPIC_SATELLITE_STATE,
|
|
||||||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
|
||||||
"payload_available": "online",
|
|
||||||
"payload_not_available": "offline",
|
|
||||||
"device": MQTT_DISCOVERY_DEVICE,
|
|
||||||
}
|
|
||||||
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_satellite_state/config",
|
|
||||||
json.dumps(sensor_disc), retain=True, qos=1)
|
|
||||||
|
|
||||||
# Number entities
|
|
||||||
publish_number_discovery()
|
|
||||||
|
|
||||||
logger.info("✅ Discovery опубликован успешно (Light + Sensor + 7 Number)")
|
|
||||||
# Состояния будут опубликованы с задержкой в on_mqtt_connect
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка Discovery: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_number_discovery():
|
|
||||||
if not mqtt_client:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
modes = {
|
|
||||||
"idle": "Idle", "listening": "Listening", "wake": "Wake",
|
|
||||||
"thinking": "Thinking", "speaking": "Speaking",
|
|
||||||
"paused": "Paused", "error": "Error"
|
|
||||||
}
|
|
||||||
for mode, name in modes.items():
|
|
||||||
disc = {
|
|
||||||
"name": f"{name}",
|
|
||||||
"unique_id": f"wyoming_led_{mode}_base",
|
|
||||||
"object_id": f"wyoming_led_{mode}_base",
|
|
||||||
"state_topic": f"wyoming/led/{mode}/state",
|
|
||||||
"command_topic": f"wyoming/led/{mode}/set",
|
|
||||||
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
|
||||||
"payload_available": "online",
|
|
||||||
"payload_not_available": "offline",
|
|
||||||
"min": 0, "max": 100, "step": 5,
|
|
||||||
"unit_of_measurement": "%",
|
|
||||||
|
|
||||||
"icon": "mdi:led-on",
|
|
||||||
"device": MQTT_DISCOVERY_DEVICE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
mqtt_client.publish(
|
|
||||||
f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led_{mode}/config",
|
|
||||||
json.dumps(disc), retain=True, qos=1
|
|
||||||
)
|
|
||||||
logger.info("✅ 7 Number entities опубликованы")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка Number discovery: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def publish_state(state: str):
|
|
||||||
if not mqtt_client: return
|
|
||||||
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
|
|
||||||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
|
||||||
ha_brightness = int(multiplier * 100)
|
|
||||||
|
|
||||||
mqtt_client.publish(MQTT_TOPIC_STATE, ha_state, retain=True, qos=1)
|
|
||||||
mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(ha_brightness), retain=True, qos=1)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_satellite_state(state: str):
|
|
||||||
if mqtt_client:
|
|
||||||
mqtt_client.publish(MQTT_TOPIC_SATELLITE_STATE, state, retain=True, qos=0)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_base_brightness_states():
|
|
||||||
if not mqtt_client: return
|
|
||||||
try:
|
|
||||||
logger.debug(f"📤 Brightness states: {BRIGHTNESS_CONFIG}")
|
|
||||||
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
|
||||||
if mode in BRIGHTNESS_CONFIG:
|
|
||||||
percent = min(100, int(BRIGHTNESS_CONFIG[mode] / 2.55))
|
|
||||||
mqtt_client.publish(f"wyoming/led/{mode}/state", str(percent), retain=True, qos=1)
|
|
||||||
logger.debug(f"📤 {mode}/state = {percent}% (value={BRIGHTNESS_CONFIG[mode]})")
|
|
||||||
logger.info("📊 Brightness states опубликованы")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка publish_base_brightness_states: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== MQTT CALLBACKS ======================
|
|
||||||
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
|
||||||
if rc == 0:
|
|
||||||
logger.info(f"✅ MQTT подключён к {MQTT_BROKER}:{MQTT_PORT}")
|
|
||||||
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
|
|
||||||
client.subscribe(MQTT_TOPIC_COMMAND)
|
|
||||||
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
|
||||||
# Подписка на топики 7 режимов
|
|
||||||
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
|
||||||
client.subscribe(f"wyoming/led/{mode}/set")
|
|
||||||
logger.debug(f"📡 Подписка на wyoming/led/{mode}/set")
|
|
||||||
publish_discovery_config()
|
|
||||||
publish_state(current_state)
|
|
||||||
publish_satellite_state(current_state)
|
|
||||||
publish_base_brightness_states()
|
|
||||||
logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}, 7 режимов")
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ MQTT ошибка подключения: {rc}")
|
|
||||||
|
|
||||||
|
|
||||||
def on_mqtt_message(client, userdata, msg):
|
|
||||||
global ha_disabled, last_mqtt_brightness, current_state
|
|
||||||
try:
|
|
||||||
payload = msg.payload.decode('utf-8').strip()
|
|
||||||
topic = msg.topic
|
|
||||||
logger.info(f"📩 MQTT {topic}: {payload}")
|
|
||||||
|
|
||||||
# Глобальная яркость
|
|
||||||
if topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
|
||||||
ha_value = max(0, min(100, int(payload)))
|
|
||||||
new_multiplier = ha_value / 100.0
|
|
||||||
last_mqtt_brightness = ha_value
|
|
||||||
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
|
|
||||||
save_brightness_config()
|
|
||||||
logger.info(f"🔆 Глобальная яркость: {ha_value}% (×{new_multiplier:.2f})")
|
|
||||||
if not ha_disabled:
|
|
||||||
set_led_state(current_state)
|
|
||||||
publish_base_brightness_states()
|
|
||||||
|
|
||||||
# Базовая яркость режима
|
|
||||||
elif topic.startswith("wyoming/led/") and topic.endswith("/set"):
|
|
||||||
mode = topic.split("/")[2]
|
|
||||||
logger.info(f"🎚️ Получена команда для режима: {mode}")
|
|
||||||
if mode in BRIGHTNESS_CONFIG:
|
|
||||||
percent = max(0, min(100, int(payload)))
|
|
||||||
real_value = int(percent * 2.55)
|
|
||||||
old_value = BRIGHTNESS_CONFIG[mode]
|
|
||||||
BRIGHTNESS_CONFIG[mode] = real_value
|
|
||||||
save_brightness_config()
|
|
||||||
logger.info(f"🎛️ {mode}: {old_value} → {real_value} ({percent}%)")
|
|
||||||
logger.info(f"📤 Публикация {mode}/state = {percent}")
|
|
||||||
publish_base_brightness_states()
|
|
||||||
if current_state == mode and not ha_disabled:
|
|
||||||
logger.info(f"🔄 Применяем новую яркость к текущему состоянию {mode}")
|
|
||||||
set_led_state(mode)
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ Неизвестный режим: {mode}")
|
|
||||||
|
|
||||||
# ON / OFF
|
|
||||||
elif topic == MQTT_TOPIC_COMMAND:
|
|
||||||
ha_state = payload.upper()
|
|
||||||
if ha_state == "OFF":
|
|
||||||
ha_disabled = True
|
|
||||||
if led_controller:
|
|
||||||
led_controller.set_led_effect(EFFECT_OFF)
|
|
||||||
publish_state("off")
|
|
||||||
logger.info("⬛ LED OFF")
|
|
||||||
elif ha_state == "ON":
|
|
||||||
ha_disabled = False
|
|
||||||
set_led_state(current_state)
|
|
||||||
logger.info("✅ LED ON")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка MQTT: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== MQTT CLIENT ======================
|
|
||||||
def start_mqtt_client():
|
|
||||||
global mqtt_client
|
|
||||||
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
|
|
||||||
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
|
|
||||||
mqtt_client.on_connect = on_mqtt_connect
|
|
||||||
mqtt_client.on_message = on_mqtt_message
|
|
||||||
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
|
||||||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
|
||||||
mqtt_client.loop_start()
|
|
||||||
|
|
||||||
|
|
||||||
# ====================== HANDLER & MAIN ======================
|
|
||||||
class LedEventHandler(AsyncEventHandler):
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
if ha_disabled:
|
|
||||||
return True
|
|
||||||
et = event.type
|
|
||||||
logger.debug(f"🔍 Событие: {et}")
|
|
||||||
if Detection.is_type(et):
|
|
||||||
logger.info("🎯 Detection → wake")
|
|
||||||
set_led_state("wake")
|
|
||||||
elif VoiceStarted.is_type(et):
|
|
||||||
logger.info("🎤 VoiceStarted → listening")
|
|
||||||
set_led_state("listening")
|
|
||||||
elif VoiceStopped.is_type(et):
|
|
||||||
logger.info("💭 VoiceStopped → thinking")
|
|
||||||
set_led_state("thinking")
|
|
||||||
elif et == "audio-start":
|
|
||||||
logger.info("🔊 audio-start → speaking")
|
|
||||||
set_led_state("speaking")
|
|
||||||
elif Played.is_type(et):
|
|
||||||
logger.info("✅ Played → idle")
|
|
||||||
set_led_state("idle")
|
|
||||||
elif et == "pause-satellite":
|
|
||||||
logger.info("⏸️ pause → paused")
|
|
||||||
set_led_state("paused")
|
|
||||||
elif SatelliteConnected.is_type(et):
|
|
||||||
logger.info("🔌 connected → idle")
|
|
||||||
set_led_state("idle")
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def on_connected(self) -> None:
|
|
||||||
logger.info("✅ TCP подключён")
|
|
||||||
async def on_disconnected(self) -> None:
|
|
||||||
logger.info("❌ TCP отключён")
|
|
||||||
set_led_state("idle")
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_leds():
|
|
||||||
"""Очистка: выключаем LED и закрываем USB."""
|
|
||||||
logger.info("🧹 Очистка: выключаем LED...")
|
|
||||||
if led_controller:
|
|
||||||
try:
|
|
||||||
led_controller.set_led_effect(EFFECT_OFF)
|
|
||||||
led_controller.close()
|
|
||||||
logger.info("✅ LED выключены, USB закрыт")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка очистки: {e}")
|
|
||||||
else:
|
|
||||||
logger.info("ℹ️ LED контроллер не был инициализирован")
|
|
||||||
|
|
||||||
|
|
||||||
async def main(args):
|
|
||||||
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
|
|
||||||
load_brightness_config()
|
|
||||||
init_leds()
|
|
||||||
start_mqtt_client()
|
|
||||||
set_led_state("idle")
|
|
||||||
|
|
||||||
uri = f"tcp://{args.host}:{args.port}"
|
|
||||||
server = AsyncServer.from_uri(uri)
|
|
||||||
logger.info(f"📡 Слушаем {uri}")
|
|
||||||
await server.run(partial(LedEventHandler))
|
|
||||||
|
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
|
||||||
logger.info(f"\n🛑 Сигнал {sig}")
|
|
||||||
cleanup_leds()
|
|
||||||
if mqtt_client:
|
|
||||||
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
|
|
||||||
mqtt_client.loop_stop()
|
|
||||||
mqtt_client.disconnect()
|
|
||||||
logger.info("📡 MQTT остановлен")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
|
||||||
parser.add_argument("--port", type=int, default=10701)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.run(main(args))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
cleanup_leds()
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
|
|
||||||
import sys
|
|
||||||
import struct
|
|
||||||
import argparse
|
|
||||||
import usb.core
|
|
||||||
import usb.util
|
|
||||||
import libusb_package
|
|
||||||
import time
|
|
||||||
|
|
||||||
CONTROL_SUCCESS = 0
|
|
||||||
SERVICER_COMMAND_RETRY = 64
|
|
||||||
|
|
||||||
# name, resid, cmdid, length, type, description
|
|
||||||
PARAMETERS = {
|
|
||||||
# APPLICATION_SERVICER_RESID commands
|
|
||||||
"VERSION": (48, 0, 3, "ro", "uint8", "The version number of the firmware. The format is VERSION_MAJOR VERSION_MINOR VERSION_PATCH"),
|
|
||||||
"BLD_MSG": (48, 1, 50, "ro", "char", "Retrieve the build message built in to the firmware, normally the build configuration name"),
|
|
||||||
"BLD_HOST": (48, 2, 30, "ro", "char", "Retrieve details of the CI build host used to build the firmware"),
|
|
||||||
"BLD_REPO_HASH": (48, 3, 40, "ro", "char", "Retrieve the GIT hash of the sw_xvf3800 repo used to build the firmware"),
|
|
||||||
"BLD_MODIFIED": (48, 4, 6, "ro", "char", "Show whether or not the current firmware repo has been modified from the official release. Requires use of a GIT repo"),
|
|
||||||
"BOOT_STATUS": (48, 5, 3, "ro", "char", "Shows whether or not the firmware has been booted via SPI or JTAG/FLASH"),
|
|
||||||
"TEST_CORE_BURN": (48, 6, 1, "rw", "uint8", "Set to enable core burn to exercise worst case timing. This will reboot the chip, reset all parameters to default and significantly increase power consumption."),
|
|
||||||
"REBOOT": (48, 7, 1, "wo", "uint8", "Set to any value to reboot the chip and reset all parameters to default."),
|
|
||||||
"USB_BIT_DEPTH": (48, 8, 2, "rw", "uint8", "Only relevant for the UA device variant. For the UA device, set or get the USB bit depth IN, OUT to either 16, 24 or 32. Setting will reboot the chip, resetting all other parameters to default. If issued to the INT device, a set is ignored and the device is not rebooted while a get always returns 0 as both IN and OUT bit depths."),
|
|
||||||
"SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8", "Set to any value to save the current configuration to flash."),
|
|
||||||
"CLEAR_CONFIGURATION": (48, 10, 1, "wo", "uint8", "Set to any value to clear the current configuration and revert to the default configuration."),
|
|
||||||
|
|
||||||
# AEC_RESID commands
|
|
||||||
"SHF_BYPASS": (33, 70, 1, "rw", "uint8", "AEC bypass"),
|
|
||||||
"AEC_NUM_MICS": (33, 71, 1, "ro", "int32", "Number of microphone inputs into the AEC"),
|
|
||||||
"AEC_NUM_FARENDS": (33, 72, 1, "ro", "int32", "Number of farend inputs into the AEC"),
|
|
||||||
"AEC_MIC_ARRAY_TYPE": (33, 73, 1, "ro", "int32", "Microphone array type (1 - linear, 2 - squarecular)"),
|
|
||||||
"AEC_MIC_ARRAY_GEO": (33, 74, 12, "ro", "float", "Microphone array geometry. Each microphone is represented by 3 XYZ coordinates in m."),
|
|
||||||
"AEC_AZIMUTH_VALUES": (33, 75, 4, "ro", "radians", "Azimuth values in radians - beam 1, beam 2, free-running beam, 3 - auto-select beam"),
|
|
||||||
"TEST_AEC_DISABLE_CONTROL": (33, 76, 1, "wo", "uint32", "Disable Control processing in the AEC resource. Only to be used for internal testing! Do not use in normal operation. When disabled, there's no way to bring back control without restarting the device."),
|
|
||||||
"AEC_CURRENT_IDLE_TIME": (33, 77, 1, "ro", "uint32", "AEC processing current idle time in 10ns ticks"),
|
|
||||||
"AEC_MIN_IDLE_TIME": (33, 78, 1, "ro", "uint32", "AEC processing minimum idle time in 10ns ticks"),
|
|
||||||
"AEC_RESET_MIN_IDLE_TIME": (33, 79, 1, "wo", "uint32", "Reset the AEC minimum idle time. The value of the argument passed is ignored."),
|
|
||||||
"AEC_SPENERGY_VALUES": (33, 80, 4, "ro", "float", "Speech energy level values for each beam. Any value above 0 indicates speech. Higher values indicates louder or closer speech, however noise, echo and reverb can cause the energy level to decrease. 0 - beam 1, 1 - beam 2, 2 - free-running beam, 3 - auto-select beam"),
|
|
||||||
"AEC_FIXEDBEAMSAZIMUTH_VALUES": (33, 81, 2, "rw", "radians", "Azimuth values in radians for beams in fixed mode - fixed beam 1, fixed beam 2"),
|
|
||||||
"AEC_FIXEDBEAMSELEVATION_VALUES": (33, 82, 2, "rw", "radians", "Elevation angles in radians for the beams in fixed mode. 0 - fixed beam 1, 1 - fixed beam 2"),
|
|
||||||
"AEC_FIXEDBEAMSGATING": (33, 83, 1, "rw", "uint8", "Enables/disables gating for beams in fixed mode. When enabled, speech energy is used to determine which beam should be active; inactive beams are silenced."),
|
|
||||||
"SPECIAL_CMD_AEC_FAR_MIC_INDEX": (33, 90, 2, "wo", "int32", "Set Far and Mic index for AEC filter read. Also used as a trigger command that starts a new filter read"),
|
|
||||||
"SPECIAL_CMD_AEC_FILTER_COEFF_START_OFFSET": (33, 91, 1, "rw", "int32", "start offset to get or set the filter coefficients from"),
|
|
||||||
"SPECIAL_CMD_AEC_FILTER_COEFFS": (33, 92, 15, "rw", "float", "get or set AEC filter coefficients"),
|
|
||||||
"SPECIAL_CMD_AEC_FILTER_LENGTH": (33, 93, 1, "ro", "int32", "get AEC filter length"),
|
|
||||||
"AEC_FILTER_CMD_ABORT": (33, 94, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from an AEC filter read/write command sequence that has gone wrong."),
|
|
||||||
"AEC_AECPATHCHANGE": (33, 0, 1, "ro", "int32", "AEC Path Change Detection. Valid range: 0,1 (false,true)"),
|
|
||||||
"AEC_HPFONOFF": (33, 1, 1, "rw", "int32", "High-pass Filter on microphone signals. Valid range: 0,1,2,3,4 (0:Off, 1:on70, 2:on125, 3:on150, 4:on180)"),
|
|
||||||
"AEC_AECSILENCELEVEL": (33, 2, 2, "rw", "float", "Power threshold for signal detection in adaptive filter.(set,cur), Valid range (set): [0.0 .. 1.0] Valid range (cur): 0.05*set, 1e-6f or set."),
|
|
||||||
"AEC_AECCONVERGED": (33, 3, 1, "ro", "int32", "Flag indicating whether AEC is converged. Valid range: 0,1 (false,true)"),
|
|
||||||
"AEC_AECEMPHASISONOFF": (33, 4, 1, "rw", "int32", "Pre-emphasis and de-emphasis filtering for AEC. Valid range: 0,1,2 (off,on,on_eq)on: Emphasis filter for speech signals without modification of far-end ref. on_eq: Emphasis filter for far-end reference signals where the low frequencies are boosted by e.g. an equalizer."),
|
|
||||||
"AEC_FAR_EXTGAIN": (33, 5, 1, "rw", "float", "External gain in dB applied to the far-end reference signals. Valid range: [-inf .. inf]"),
|
|
||||||
"AEC_PCD_COUPLINGI": (33, 6, 1, "rw", "float", "Sensitivity parameter for PCD. Valid range: [0.0 .. 1.0]PCD can be disabled by setting a value outside the range"),
|
|
||||||
"AEC_PCD_MINTHR": (33, 7, 1, "rw", "float", "Minimum threshold value used in PCD. Valid range: [0.0 .. 0.02]"),
|
|
||||||
"AEC_PCD_MAXTHR": (33, 8, 1, "rw", "float", "Maximum threshold value used in PCD. Valid range: [0.025 .. 0.2]"),
|
|
||||||
"AEC_RT60": (33, 9, 1, "ro", "float", "Current RT60 estimate. Valid range: [0.250 .. 0.900] (seconds) A negative value indicates that the RT60 estimation is invalid."),
|
|
||||||
"AEC_ASROUTONOFF": (33, 35, 1, "rw", "int32", "Enables the automatic speech recognition (ASR) output: if set to 0, the AEC residuals are output, one channel per microphone, if set to 1, the ASR processed output is used, where each channel is associated with a beam from the beamformer. Valid range: 0,1 (off, on)"),
|
|
||||||
"AEC_ASROUTGAIN": (33, 36, 1, "rw", "float", "Fixed gain applied to qsr output, i.e., only applied if ASROUTONOFF==1.. Valid range: [0.0 .. 1000.0] (linear gain factor)"),
|
|
||||||
"AEC_FIXEDBEAMSONOFF": (33, 37, 1, "rw", "int32", "Enables or disables fixed focused beam mode. Valid range: 0,1 (off, on)"),
|
|
||||||
"AEC_FIXEDBEAMNOISETHR": (33, 38, 2, "rw", "float", "Threshold value for updating the noise canceller when fixed beam mode is enabled. A higher value indicates that the noise canceller may update when the free running beam is close to the fixed beam. A lower value indicates that the noise canceller may update when the free running beam is further away from the focused beam.[BECLEAR_NUMBER_OF_BEAMS], Valid range: [0.0 .. 1.0]"),
|
|
||||||
|
|
||||||
# AUDIO_MGR_RESID commands
|
|
||||||
"AUDIO_MGR_MIC_GAIN": (35, 0, 1, "rw", "float", "Audio Mgr pre SHF microphone gain"),
|
|
||||||
"AUDIO_MGR_REF_GAIN": (35, 1, 1, "rw", "float", "Audio Mgr pre SHF reference gain"),
|
|
||||||
"AUDIO_MGR_CURRENT_IDLE_TIME": (35, 2, 1, "ro", "int32", "Get audio manager current idle time in 10ns ticks"),
|
|
||||||
"AUDIO_MGR_MIN_IDLE_TIME": (35, 3, 1, "ro", "int32", "Get audio manager min idle time in 10ns ticks"),
|
|
||||||
"AUDIO_MGR_RESET_MIN_IDLE_TIME": (35, 4, 1, "wo", "int32", "Reset audio manager min idle time. The value of the argument passed is ignored."),
|
|
||||||
"MAX_CONTROL_TIME": (35, 5, 1, "ro", "int32", "Get audio manager max control time"),
|
|
||||||
"RESET_MAX_CONTROL_TIME": (35, 6, 1, "wo", "int32", "Reset audio manager max control time. The value of the argument passed is ignored."),
|
|
||||||
"I2S_CURRENT_IDLE_TIME": (35, 7, 1, "ro", "int32", "Get I2S current idle time in 10ns ticks"),
|
|
||||||
"I2S_MIN_IDLE_TIME": (35, 8, 1, "ro", "int32", "Get I2S min idle time in 10ns ticks"),
|
|
||||||
"I2S_RESET_MIN_IDLE_TIME": (35, 9, 1, "wo", "int32", "I2S reset idle time. The value of the argument passed is ignored."),
|
|
||||||
"I2S_INPUT_PACKED": (35, 10, 1, "rw", "uint8", "Will expect packed input on both I2S or USB channels if this is not 0. Note, could take up to 3 samples to take effect."),
|
|
||||||
"AUDIO_MGR_SELECTED_AZIMUTHS": (35, 11, 2, "ro", "radians", "This returns the azimuths determined by the beam_selection function in post_shf_dsp.c. The default implementation will provide a processed DoA at index 0 and the DoA of the auto select beam at index 1. The processed DoA uses speech energy to select from the DoA on each fixed beam to provide the direction of a speaker. NAN will be returned if neither of the fixed beams contain speech. Versions 2.0.0 and earlier of XVF3800 had a different default implementation of beam_selection which returned the DoA associated with the AUDIO_MGR_SELECTED_CHANNELS"),
|
|
||||||
"AUDIO_MGR_SELECTED_CHANNELS": (35, 12, 2, "rw", "uint8", "Default implementation of post processing will use this to select which channels should be output to MUX_USER_CHOSEN_CHANNELS. Note that a customer implementation of the beam selection stage could override this command. How this channel selection aligns with actual output depends on the mux configuration"),
|
|
||||||
"AUDIO_MGR_OP_PACKED": (35, 13, 2, "rw", "uint8", "<L>, <R>; Sets/gets packing status for L and R output channels"),
|
|
||||||
"AUDIO_MGR_OP_UPSAMPLE": (35, 14, 2, "rw", "uint8", "<L>, <R>; Sets/gets upsample status for L and R output channels, where appropriate"),
|
|
||||||
"AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8", "<category>, <source>; Sets category and source for L output channel. Equivalent to AUDIO_MGR_OP_L_PK0"),
|
|
||||||
"AUDIO_MGR_OP_L_PK0": (35, 16, 2, "rw", "uint8", "<category>, <source>; Sets category and source for first (of three) sources on the L channel in packed mode. Equivalent to AUDIO_MGR_OP_L"),
|
|
||||||
"AUDIO_MGR_OP_L_PK1": (35, 17, 2, "rw", "uint8", "Sets category and source for second (of three) sources on the L channel in packed mode"),
|
|
||||||
"AUDIO_MGR_OP_L_PK2": (35, 18, 2, "rw", "uint8", "Sets category and source for third (of three) sources on the L channel in packed mode"),
|
|
||||||
"AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8", "<category>, <source>; Sets category and source for R output channel. Equivalent to AUDIO_MGR_OP_R_PK0"),
|
|
||||||
"AUDIO_MGR_OP_R_PK0": (35, 20, 2, "rw", "uint8", "<category>, <source>; Sets category and source for first (of three) sources on the R channel in packed mode. Equivalent to AUDIO_MGR_OP_R"),
|
|
||||||
"AUDIO_MGR_OP_R_PK1": (35, 21, 2, "rw", "uint8", "Sets category and source for second (of three) sources on the R channel in packed mode"),
|
|
||||||
"AUDIO_MGR_OP_R_PK2": (35, 22, 2, "rw", "uint8", "Sets category and source for third (of three) sources on the R channel in packed mode"),
|
|
||||||
"AUDIO_MGR_OP_ALL": (35, 23, 12, "rw", "uint8", "Sets category and source for all 3 sources on L channel and all 3 sources for R channel. Equivalent to AUDIO_MGR_OP_[L,R]_PK[0,1,2] with successive pairs of arguments"),
|
|
||||||
"I2S_INACTIVE": (35, 24, 1, "ro", "uint8", "Returns whether the main audio loop is exchanging samples with I2S (0). If not (1), I2S is inactive"),
|
|
||||||
"AUDIO_MGR_FAR_END_DSP_ENABLE": (35, 25, 1, "rw", "uint8", "Enables/disables XVF3800 far-end DSP (if implemented). Write a 1 to enable, 0 to disable"),
|
|
||||||
"AUDIO_MGR_SYS_DELAY": (35, 26, 1, "rw", "int32", "Delay, measured in samples, that is applied to the reference signal before passing to SHF algorithm"),
|
|
||||||
"I2S_DAC_DSP_ENABLE": (35, 27, 1, "rw", "uint8", "Indicates if the DAC performs DSP on the far-end reference signal. If enabled (1), use the I2S signal as input to the audio pipeline, even in the UA configuration"),
|
|
||||||
|
|
||||||
|
|
||||||
# GPO_SERVICER_RESID commands
|
|
||||||
"GPO_READ_VALUES": (20, 0, 5, "ro", "uint8", "Get current logic level of all GPO pins, in order of Pin X0D11, X0D30, X0D31, X0D33 and X0D39."),
|
|
||||||
"GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8", "Set current logic level of selected GPO pin. Supports Pin X0D11, X0D30, X0D31, X0D33 and X0D39."),
|
|
||||||
"GPO_PORT_PIN_INDEX": (20, 2, 2, "rw", "uint32", "GPO port index and pin index that the following commands would be directed to"),
|
|
||||||
"GPO_PIN_VAL": (20, 3, 3, "wo", "uint8", "value to write to one pin of a GPO port. Payload specifies port_index, pin_index and value to write to the pin"),
|
|
||||||
"GPO_PIN_ACTIVE_LEVEL": (20, 4, 1, "rw", "uint32", "Active level of the port/pin specified by the GPO_PORT_PIN_INDEX command. 1 = ACTIVE_HIGH, 0 = ACTIVE_LOW"),
|
|
||||||
"LED_EFFECT": (20, 12, 1, "rw", "uint8", "Set the LED effect mode, 0 = off, 1 = breath, 2 = rainbow, 3 = single color, 4 = doa, 5 = ring"),
|
|
||||||
"LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8", "Set the brightness value of the LED for the breath and rainbow mode"),
|
|
||||||
"LED_GAMMIFY": (20, 14, 1, "rw", "uint8", "Set to enable gamma correction, 0 = disable, 1 = enable"),
|
|
||||||
"LED_SPEED": (20, 15, 1, "rw", "uint8", "Set the effect speed of breath and rainbow mode"),
|
|
||||||
"LED_COLOR": (20, 16, 1, "rw", "uint32", "Set the LED color of breath mode and single color mode"),
|
|
||||||
"LED_DOA_COLOR": (20, 17, 2, "rw", "uint32", "Set the LED color of doa mode, the first value is the base color and the second value is the doa color"),
|
|
||||||
"DOA_VALUE": (20, 18, 2, "ro", "uint16", "Get the doa value and if speech is detected, payload[0] = doa value, from 0 to 359, payload[1] = 1 if speech is detected, 0 if not"),
|
|
||||||
"LED_RING_COLOR": (20, 19, 12, "rw", "uint32", "Set the LED color of ring mode, each value is the color of one LED"),
|
|
||||||
|
|
||||||
# PP_RESID commands
|
|
||||||
"PP_CURRENT_IDLE_TIME": (17, 70, 1, "ro", "uint32", "PP processing current idle time in 10ns ticks"),
|
|
||||||
"PP_MIN_IDLE_TIME": (17, 71, 1, "ro", "uint32", "PP processing minimum idle time in 10ns ticks"),
|
|
||||||
"PP_RESET_MIN_IDLE_TIME": (17, 72, 1, "wo", "uint32", "Reset the PP minimum idle time. The value of the argument passed is ignored."),
|
|
||||||
"PP_NL_MODEL_CMD_ABORT": (17, 94, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from a NL model read/write sequence that has gone wrong."),
|
|
||||||
"PP_EQUALIZATION_CMD_ABORT": (17, 100, 1, "wo", "int32", "Reset the special command state machine. Used for safely exiting from an equalization read/write sequence that has gone wrong."),
|
|
||||||
"PP_AGCONOFF": (17, 10, 1, "rw", "int32", "Automatic Gain Control. Valid range: 0,1 (off,on)"),
|
|
||||||
"PP_AGCMAXGAIN": (17, 11, 1, "rw", "float", "Maximum AGC gain factor. Valid range: [1.0 .. 1000.0] (linear gain factor)"),
|
|
||||||
"PP_AGCDESIREDLEVEL": (17, 12, 1, "rw", "float", "Target power level of the output signal. Valid range: [1e-8 .. 1.0] (power level)"),
|
|
||||||
"PP_AGCGAIN": (17, 13, 1, "rw", "float", "Current AGC gain factor. Valid range: [1.0 .. 1000.0] (linear gain factor)"),
|
|
||||||
"PP_AGCTIME": (17, 14, 1, "rw", "float", "Ramp-up/down time-constant. Valid range: [0.5 .. 4.0] (seconds)"),
|
|
||||||
"PP_AGCFASTTIME": (17, 15, 1, "rw", "float", "Ramp down time-constant in case of peaks. Valid range: [0.05 .. 4.0] (seconds)"),
|
|
||||||
"PP_AGCALPHAFASTGAIN": (17, 16, 1, "rw", "float", "Gain threshold enabling fast alpha mode. Valid range: [0.0 .. 1000.0] (linear gain factor)"),
|
|
||||||
"PP_AGCALPHASLOW": (17, 17, 1, "rw", "float", "Slow memory parameter for speech power. Valid range [0.0 .. 1.0]"),
|
|
||||||
"PP_AGCALPHAFAST": (17, 18, 1, "rw", "float", "Fast memory parameter for speech power. Valid range [0.0 .. 1.0]"),
|
|
||||||
"PP_LIMITONOFF": (17, 19, 1, "rw", "int32", "Limiter on communication output. Valid range: 0,1 (off,on)"),
|
|
||||||
"PP_LIMITPLIMIT": (17, 20, 1, "rw", "float", "Maximum limiter power. Valid range: [1e-8 .. 1.0] (power level)"),
|
|
||||||
"PP_MIN_NS": (17, 21, 1, "rw", "float", "Gain-floor for stationary noise suppression. Valid range: [0.0 .. 1.0]"),
|
|
||||||
"PP_MIN_NN": (17, 22, 1, "rw", "float", "Gain-floor for non-stationary noise suppression. Valid range: [0.0 .. 1.0]"),
|
|
||||||
"PP_ECHOONOFF": (17, 23, 1, "rw", "int32", "Echo suppression. Valid range: 0,1 (off,on)"),
|
|
||||||
"PP_GAMMA_E": (17, 24, 1, "rw", "float", "Over-subtraction factor of echo (direct and early components). Valid range: [0.0 .. 2.0]"),
|
|
||||||
"PP_GAMMA_ETAIL": (17, 25, 1, "rw", "float", "Over-subtraction factor of echo (tail components). Valid range: [0.0 .. 2.0]"),
|
|
||||||
"PP_GAMMA_ENL": (17, 26, 1, "rw", "float", "Over-subtraction factor of non-linear echo. Valid range: [0.0 .. 5.0]"),
|
|
||||||
"PP_NLATTENONOFF": (17, 27, 1, "rw", "int32", "Non-Linear echo attenuation. Valid range: 0,1 (off,on)"),
|
|
||||||
"PP_NLAEC_MODE": (17, 28, 1, "rw", "int32", "Non-Linear AEC training mode. Valid range: 0,1,2 (normal,train,train2)"),
|
|
||||||
"PP_MGSCALE": (17, 29, 3, "rw", "float", "Minimum gain scale for acoustic echo suppression.(max,min,cur), Valid range (max,min): [(1.0,0.0) .. (1e5,max)] Valid range (cur): min or max."),
|
|
||||||
"PP_FMIN_SPEINDEX": (17, 30, 1, "rw", "float", "In case of double talk, frequencies below SPEINDEX are more suppressed than frequencies above SPEINDEX. The actual suppression is depending on the setting of DTSENSITIVE. The parameter is not a taste parameter but needs to be tuned for a specific device. Valid range: [0.0 .. 7999.0]"),
|
|
||||||
"PP_DTSENSITIVE": (17, 31, 1, "rw", "int32", "Tradeoff between echo suppression and doubletalk performance. A lower value prefers high echo suppression (possibly at the cost of less doubletalk performance), a higher value prefers better doubletalk performance (possibly at the cost of good echo suppression). Good doubletalk performance is only possible for hardware without much non-linearities. When the value has 2 digits, for robustness an extra near-end speech detector is used. Valid range: [0 .. 5, 10 .. 15]"),
|
|
||||||
"PP_ATTNS_MODE": (17, 32, 1, "rw", "int32", "Additional reduction of AGC gain during non-speech. Valid range: 0,1 (off, on)"),
|
|
||||||
"PP_ATTNS_NOMINAL": (17, 33, 1, "rw", "float", "Amount of additional reduction during non-speech at nominal speech level. Valid range: [0.0 .. 1.0]"),
|
|
||||||
"PP_ATTNS_SLOPE": (17, 34, 1, "rw", "float", "Determines the extra amount of suppression during non-speech when the AGC level increases at lower speech level. The extra attenuation is given by (agcgain_nominal/agcgain_current)^attns_slope. With a value of 1.0 the amount of noise in the output remains approximately the same, independent of the agc gain. Valid range: [0.0 .. 5.0]"),
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReSpeaker:
|
|
||||||
TIMEOUT = 100000
|
|
||||||
|
|
||||||
def __init__(self, dev):
|
|
||||||
self.dev = dev
|
|
||||||
|
|
||||||
def write(self, name, data_list):
|
|
||||||
try:
|
|
||||||
data = PARAMETERS[name]
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if data[3] == "ro":
|
|
||||||
raise ValueError('{} is read-only'.format(name))
|
|
||||||
|
|
||||||
if len(data_list) != data[2]:
|
|
||||||
raise ValueError('{} value count is not {}'.format(name, data[2]))
|
|
||||||
|
|
||||||
windex = data[0] # resid
|
|
||||||
wvalue = data[1] # cmdid
|
|
||||||
data_cnt = data[2] # cnt
|
|
||||||
data_type = data[4] # data type
|
|
||||||
payload = []
|
|
||||||
|
|
||||||
if data_type == 'float' or data_type == 'radians':
|
|
||||||
for i in range(data_cnt):
|
|
||||||
payload += struct.pack(b'f', float(data_list[i]))
|
|
||||||
elif data_type == 'char':
|
|
||||||
# For char arrays, convert string to bytes
|
|
||||||
payload = bytearray(data_list, 'utf-8') if isinstance(data_list, str) else bytearray(data_list)
|
|
||||||
elif data_type == 'uint8':
|
|
||||||
for i in range(data_cnt):
|
|
||||||
payload += data_list[i].to_bytes(1, byteorder='little')
|
|
||||||
elif data_type == 'uint32' or data_type == 'int32':
|
|
||||||
for i in range(data_cnt):
|
|
||||||
payload += struct.pack(b'I' if data_type == 'uint32' else b'i', data_list[i])
|
|
||||||
else:
|
|
||||||
# Default to int32 for other types
|
|
||||||
for i in range(data_cnt):
|
|
||||||
payload += struct.pack(b'i', data_list[i])
|
|
||||||
|
|
||||||
print("WriteCMD: cmdid: {}, resid: {}, payload: {}".format(wvalue, windex, payload))
|
|
||||||
|
|
||||||
self.dev.ctrl_transfer(
|
|
||||||
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
|
||||||
0, wvalue, windex, payload, self.TIMEOUT)
|
|
||||||
|
|
||||||
|
|
||||||
def read(self, name):
|
|
||||||
try:
|
|
||||||
data = PARAMETERS[name]
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
|
|
||||||
read_attempts = 1
|
|
||||||
windex = data[0] # resid
|
|
||||||
wvalue = 0x80 | data[1] # cmdid
|
|
||||||
data_cnt = data[2] # cnt
|
|
||||||
data_type = data[4] # data type
|
|
||||||
if data_type == 'uint8' or data_type == 'char':
|
|
||||||
length = data_cnt + 1 # 1 byte for status
|
|
||||||
elif data_type == 'float' or data_type == 'radians' or data_type == 'uint32' or data_type == 'int32':
|
|
||||||
length = data_cnt * 4 + 1 # 1 byte for status
|
|
||||||
elif data_type == 'uint16':
|
|
||||||
length = data_cnt * 2 + 1 # 1 byte for status
|
|
||||||
|
|
||||||
response = self.dev.ctrl_transfer(
|
|
||||||
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
|
||||||
0, wvalue, windex, length, self.TIMEOUT)
|
|
||||||
while True:
|
|
||||||
if read_attempts > 100:
|
|
||||||
raise ValueError('Read attempt exceeds 100 times')
|
|
||||||
if response[0] == CONTROL_SUCCESS:
|
|
||||||
break
|
|
||||||
elif response[0] == SERVICER_COMMAND_RETRY:
|
|
||||||
read_attempts += 1
|
|
||||||
response = self.dev.ctrl_transfer(
|
|
||||||
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
|
||||||
0, wvalue, windex, length, self.TIMEOUT)
|
|
||||||
else:
|
|
||||||
raise ValueError('Unknown status code: {}'.format(response[0]))
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
print("ReadCMD: cmdid: {}, resid: {}, payload: {}".format(wvalue, windex, response.tolist()))
|
|
||||||
|
|
||||||
if data_type == 'uint8':
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
match_str = '<'
|
|
||||||
for i in range(data_cnt):
|
|
||||||
match_str += 'B'
|
|
||||||
result = struct.unpack(match_str, byte_data[1:])
|
|
||||||
elif data_type == 'char':
|
|
||||||
# For char arrays, convert bytes to string
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
# Remove status byte and null terminators
|
|
||||||
result = byte_data[1:].rstrip(b'\x00').decode('utf-8', errors='ignore')
|
|
||||||
elif data_type == 'radians' or data_type == 'float':
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
match_str = '<'
|
|
||||||
for i in range(data_cnt):
|
|
||||||
match_str += 'f'
|
|
||||||
result = struct.unpack(match_str, byte_data[1:])
|
|
||||||
elif data_type == 'uint32':
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
match_str = '<'
|
|
||||||
for i in range(data_cnt):
|
|
||||||
match_str += 'I'
|
|
||||||
result = struct.unpack(match_str, byte_data[1:])
|
|
||||||
elif data_type == 'int32':
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
match_str = '<'
|
|
||||||
for i in range(data_cnt):
|
|
||||||
match_str += 'i'
|
|
||||||
result = struct.unpack(match_str, byte_data[1:])
|
|
||||||
elif data_type == 'uint16':
|
|
||||||
byte_data = response.tobytes()
|
|
||||||
match_str = '<'
|
|
||||||
for i in range(data_cnt):
|
|
||||||
match_str += 'H'
|
|
||||||
result = struct.unpack(match_str, byte_data[1:])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""
|
|
||||||
close the interface
|
|
||||||
"""
|
|
||||||
usb.util.dispose_resources(self.dev)
|
|
||||||
|
|
||||||
|
|
||||||
def find(vid=0x2886, pid=0x001A):
|
|
||||||
if sys.platform.startswith('win'):
|
|
||||||
dev = libusb_package.find(idVendor=vid, idProduct=pid)
|
|
||||||
else:
|
|
||||||
dev = usb.core.find(idVendor=vid, idProduct=pid)
|
|
||||||
if not dev:
|
|
||||||
return
|
|
||||||
|
|
||||||
return ReSpeaker(dev)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_value(value_str):
|
|
||||||
"""Parse a value string that can be decimal, hex (0x prefix), or hex ($ prefix)"""
|
|
||||||
try:
|
|
||||||
# Try hex formats first (0x prefix or $ prefix)
|
|
||||||
if value_str.startswith(('0x', '0X')):
|
|
||||||
return int(value_str, 16)
|
|
||||||
elif value_str.startswith('$'):
|
|
||||||
return int(value_str[1:], 16)
|
|
||||||
else:
|
|
||||||
# Try as decimal float first, then decimal int
|
|
||||||
try:
|
|
||||||
return float(value_str)
|
|
||||||
except ValueError:
|
|
||||||
return int(value_str)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Cannot parse '{value_str}' as a number (supports decimal and hex formats like 0xFF or $FF)")
|
|
||||||
|
|
||||||
def case_insensitive_command(value):
|
|
||||||
"""Convert command to uppercase and validate it exists"""
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise argparse.ArgumentTypeError(f"Command must be a string, got {type(value)}")
|
|
||||||
|
|
||||||
upper_value = value.upper()
|
|
||||||
|
|
||||||
# Check if the uppercase version exists in PARAMETERS
|
|
||||||
if upper_value not in PARAMETERS:
|
|
||||||
# Provide helpful error message with similar commands
|
|
||||||
available_commands = list(PARAMETERS.keys())
|
|
||||||
similar_commands = [cmd for cmd in available_commands if upper_value in cmd.upper()]
|
|
||||||
if similar_commands:
|
|
||||||
raise argparse.ArgumentTypeError(
|
|
||||||
f"Invalid command '{value}'. Did you mean one of: {similar_commands}? "
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise argparse.ArgumentTypeError(
|
|
||||||
f"Invalid command '{value}'. "
|
|
||||||
)
|
|
||||||
|
|
||||||
return upper_value
|
|
||||||
|
|
||||||
def list_commands():
|
|
||||||
"""Display all supported commands with their detailed information."""
|
|
||||||
print("\n=== Supported Commands ===\n")
|
|
||||||
print(f"{'Command':<30} {'RESID':<6} {'CMDID':<6} {'Length':<7} {'Type':<12} {'Access':<8} {'Description'}")
|
|
||||||
print("-" * 120)
|
|
||||||
|
|
||||||
for name, info in PARAMETERS.items():
|
|
||||||
resid, cmdid, length, param_type, access, description = info
|
|
||||||
print(f"{name:<30} {resid:<6} {cmdid:<6} {length:<7} {param_type:<12} {access:<8} {description}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='ReSpeaker Host Control Script')
|
|
||||||
parser.add_argument('-l', '--list', action='store_true',
|
|
||||||
help='list all supported commands with detailed information')
|
|
||||||
parser.add_argument('COMMAND', nargs='?', type=case_insensitive_command,
|
|
||||||
help='command to execute (e.g., VERSION, DOA_VALUE, etc.)')
|
|
||||||
parser.add_argument('--vid', type=lambda x: int(x, 0), default=0x2886,
|
|
||||||
help='usb vendor ID (default: 0x2886)')
|
|
||||||
parser.add_argument('--pid', type=lambda x: int(x, 0), default=0x001A,
|
|
||||||
help='usb product ID (default: 0x001A)')
|
|
||||||
parser.add_argument('--values', nargs='+', type=parse_value,
|
|
||||||
help='values for write commands (only for write operations). Supports decimal (123, 1.5) and hex (0x7B, $7B) formats')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Handle list command
|
|
||||||
if args.list:
|
|
||||||
list_commands()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
dev = find(vid=args.vid, pid=args.pid)
|
|
||||||
if not dev:
|
|
||||||
print('No device found')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if args.values:
|
|
||||||
if PARAMETERS[args.COMMAND][3] == "ro":
|
|
||||||
print(f"Error: {args.COMMAND} is read-only and cannot be written to")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Convert values to appropriate types based on parameter type
|
|
||||||
param_type = PARAMETERS[args.COMMAND][4]
|
|
||||||
if param_type == "float" or param_type == "radians":
|
|
||||||
args.values = [float(v) for v in args.values]
|
|
||||||
else:
|
|
||||||
args.values = [int(v) for v in args.values]
|
|
||||||
|
|
||||||
if PARAMETERS[args.COMMAND][2] != len(args.values):
|
|
||||||
print(f"Error: {args.COMMAND} value count is {PARAMETERS[args.COMMAND][2]}, but {len(args.values)} values provided")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
dev.write(args.COMMAND, args.values)
|
|
||||||
else:
|
|
||||||
if PARAMETERS[args.COMMAND][3] == "wo":
|
|
||||||
print(f"Error: {args.COMMAND} is write-only and cannot be read")
|
|
||||||
resid, cmdid, length, param_type, access, description = PARAMETERS[args.COMMAND]
|
|
||||||
print(f"{'Command':<30} {'RESID':<6} {'CMDID':<6} {'Length':<7} {'Type':<12} {'Access':<8} {'Description'}")
|
|
||||||
print("-" * 120)
|
|
||||||
print(f"{args.COMMAND:<30} {resid:<6} {cmdid:<6} {length:<7} {param_type:<12} {access:<8} {description}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
result = dev.read(args.COMMAND)
|
|
||||||
if args.COMMAND == "LED_COLOR" or args.COMMAND == "LED_DOA_COLOR" or args.COMMAND == "LED_RING_COLOR":
|
|
||||||
formatted_result = []
|
|
||||||
for val in result:
|
|
||||||
formatted_result.append(f"0x{val:06X}")
|
|
||||||
print(f"{args.COMMAND}: [{', '.join(formatted_result)}]")
|
|
||||||
else:
|
|
||||||
formatted_result = []
|
|
||||||
for val in result:
|
|
||||||
if isinstance(val, float):
|
|
||||||
formatted_result.append(f"{val:.3f}")
|
|
||||||
elif isinstance(val, str):
|
|
||||||
formatted_result.append(f"'{val}'")
|
|
||||||
else:
|
|
||||||
formatted_result.append(str(val))
|
|
||||||
print(f"{args.COMMAND}: [{', '.join(formatted_result)}]")
|
|
||||||
|
|
||||||
print(f"Done!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error executing command {args.COMMAND}: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
finally:
|
|
||||||
dev.close()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Установка systemd сервисов для Wyoming Satellite
|
|
||||||
# Автоматически определяет пути и подставляет их в service файлы
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SERVICE_DIR="${SCRIPT_DIR}/services"
|
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
|
||||||
|
|
||||||
echo "🔧 Установка systemd сервисов для Wyoming Satellite"
|
|
||||||
echo "=================================================="
|
|
||||||
|
|
||||||
# Определяем текущего пользователя
|
|
||||||
CURRENT_USER="${SUDO_USER:-$(whoami)}"
|
|
||||||
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
|
|
||||||
|
|
||||||
echo "👤 Пользователь: $CURRENT_USER"
|
|
||||||
echo "🏠 Домашняя директория: $HOME_DIR"
|
|
||||||
|
|
||||||
# Ищем виртуальное окружение
|
|
||||||
VENV_PATH=""
|
|
||||||
for candidate in \
|
|
||||||
"${HOME_DIR}/projects/.venv_satellite" \
|
|
||||||
"${HOME_DIR}/.venv_satellite" \
|
|
||||||
"${SCRIPT_DIR}/.venv" \
|
|
||||||
"${SCRIPT_DIR}/.venv_satellite"
|
|
||||||
do
|
|
||||||
if [ -f "${candidate}/bin/python3" ]; then
|
|
||||||
VENV_PATH="${candidate}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$VENV_PATH" ]; then
|
|
||||||
echo "❌ Виртуальное окружение не найдено!"
|
|
||||||
echo " Создайте его: python3 -m venv /path/to/venv"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ VENV: $VENV_PATH"
|
|
||||||
|
|
||||||
# Устанавливаем глобальный wrapper для Python
|
|
||||||
WRAPPER_SRC="${SCRIPT_DIR}/scripts/satellite-python"
|
|
||||||
WRAPPER_DST="/usr/local/bin/satellite-python"
|
|
||||||
|
|
||||||
if [ -f "$WRAPPER_SRC" ]; then
|
|
||||||
# Обновляем путь к venv в wrapper'е
|
|
||||||
sed -i "s|VENV_PATH=.*|VENV_PATH=\"${VENV_PATH}\"|" "$WRAPPER_SRC" 2>/dev/null || true
|
|
||||||
sudo cp "$WRAPPER_SRC" "$WRAPPER_DST"
|
|
||||||
sudo chmod +x "$WRAPPER_DST"
|
|
||||||
echo "✅ Wrapper установлен: $WRAPPER_DST"
|
|
||||||
else
|
|
||||||
echo "⚠️ Wrapper не найден, используем полные пути"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Создаём временную директорию для обработанных сервисов
|
|
||||||
TEMP_DIR=$(mktemp -d)
|
|
||||||
trap "rm -rf ${TEMP_DIR}" EXIT
|
|
||||||
|
|
||||||
# Обрабатываем каждый service файл
|
|
||||||
for service_file in "${SERVICE_DIR}"/*.service; do
|
|
||||||
[ -f "$service_file" ] || continue
|
|
||||||
|
|
||||||
filename=$(basename "$service_file")
|
|
||||||
echo "📄 Обработка: $filename"
|
|
||||||
|
|
||||||
# Заменяем плейсхолдеры на актуальные пути
|
|
||||||
sed -e "s|__SAT_BASE_DIR__|${SCRIPT_DIR}|g" \
|
|
||||||
-e "s|__SAT_VENV__|${VENV_PATH}|g" \
|
|
||||||
-e "s|__CURRENT_USER__|${CURRENT_USER}|g" \
|
|
||||||
-e "s|__HOME_DIR__|${HOME_DIR}|g" \
|
|
||||||
"$service_file" > "${TEMP_DIR}/${filename}"
|
|
||||||
|
|
||||||
# Копируем в systemd
|
|
||||||
sudo cp "${TEMP_DIR}/${filename}" "${SYSTEMD_DIR}/"
|
|
||||||
echo " ✅ Установлен в ${SYSTEMD_DIR}/${filename}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Перезагружаем systemd
|
|
||||||
echo ""
|
|
||||||
echo "🔄 Перезагрузка systemd..."
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Установка завершена!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Доступные сервисы:"
|
|
||||||
echo " - wyoming-event.service (LED управление)"
|
|
||||||
echo " - wyoming-satelite-vosk.service (спутник)"
|
|
||||||
echo " - wyoming-wakeword-vosk.service (wake word)"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Команды управления:"
|
|
||||||
echo " # LED сервис"
|
|
||||||
echo " sudo systemctl enable wyoming-event.service"
|
|
||||||
echo " sudo systemctl start wyoming-event.service"
|
|
||||||
echo " sudo systemctl status wyoming-event.service"
|
|
||||||
echo ""
|
|
||||||
echo " # Wyoming Satellite"
|
|
||||||
echo " sudo systemctl enable wyoming-satelite-vosk.service"
|
|
||||||
echo " sudo systemctl start wyoming-satelite-vosk.service"
|
|
||||||
echo " sudo systemctl status wyoming-satelite-vosk.service"
|
|
||||||
echo ""
|
|
||||||
echo " # Wake Word сервис"
|
|
||||||
echo " sudo systemctl enable wyoming-wakeword-vosk.service"
|
|
||||||
echo " sudo systemctl start wyoming-wakeword-vosk.service"
|
|
||||||
echo " sudo systemctl status wyoming-wakeword-vosk.service"
|
|
||||||
echo ""
|
|
||||||
echo "📊 Просмотр логов:"
|
|
||||||
echo " journalctl -u wyoming-event.service -f"
|
|
||||||
echo " journalctl -u wyoming-satelite-vosk.service -f"
|
|
||||||
echo " journalctl -u wyoming-wakeword-vosk.service -f"
|
|
||||||
echo ""
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Установка TEST systemd сервисов для Wyoming Satellite (dev branch)
|
|
||||||
# Автоматически определяет пути и подставляет их в service файлы
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SERVICE_DIR="${SCRIPT_DIR}/services/test"
|
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
|
||||||
|
|
||||||
echo "🔧 Установка TEST systemd сервисов для Wyoming Satellite (dev)"
|
|
||||||
echo "=============================================================="
|
|
||||||
|
|
||||||
# Определяем текущего пользователя
|
|
||||||
CURRENT_USER="${SUDO_USER:-$(whoami)}"
|
|
||||||
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
|
|
||||||
|
|
||||||
echo "👤 Пользователь: $CURRENT_USER"
|
|
||||||
echo "🏠 Домашняя директория: $HOME_DIR"
|
|
||||||
|
|
||||||
# Ищем виртуальное окружение
|
|
||||||
# Сначала ищем в проекте satellite-dev (для test сервисов)
|
|
||||||
VENV_PATH=""
|
|
||||||
for candidate in \
|
|
||||||
"${SCRIPT_DIR}/.venv" \
|
|
||||||
"${SCRIPT_DIR}/.venv_satellite" \
|
|
||||||
"${HOME_DIR}/projects/.venv_satellite" \
|
|
||||||
"${HOME_DIR}/.venv_satellite"
|
|
||||||
do
|
|
||||||
if [ -f "${candidate}/bin/python3" ]; then
|
|
||||||
VENV_PATH="${candidate}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$VENV_PATH" ]; then
|
|
||||||
echo "❌ Виртуальное окружение не найдено!"
|
|
||||||
echo " Создайте его: cd ${SCRIPT_DIR} && ./setup_dev.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ VENV: $VENV_PATH"
|
|
||||||
|
|
||||||
# Создаём временную директорию для обработанных сервисов
|
|
||||||
TEMP_DIR=$(mktemp -d)
|
|
||||||
trap "rm -rf ${TEMP_DIR}" EXIT
|
|
||||||
|
|
||||||
# Обрабатываем каждый test service файл
|
|
||||||
for service_file in "${SERVICE_DIR}"/*.service; do
|
|
||||||
[ -f "$service_file" ] || continue
|
|
||||||
|
|
||||||
filename=$(basename "$service_file")
|
|
||||||
echo "📄 Обработка: $filename"
|
|
||||||
|
|
||||||
# Заменяем плейсхолдеры на актуальные пути
|
|
||||||
sed -e "s|__SAT_BASE_DIR__|${SCRIPT_DIR}|g" \
|
|
||||||
-e "s|__SAT_VENV__|${VENV_PATH}|g" \
|
|
||||||
-e "s|__CURRENT_USER__|${CURRENT_USER}|g" \
|
|
||||||
-e "s|__HOME_DIR__|${HOME_DIR}|g" \
|
|
||||||
"$service_file" > "${TEMP_DIR}/${filename}"
|
|
||||||
|
|
||||||
# Копируем в systemd
|
|
||||||
sudo cp "${TEMP_DIR}/${filename}" "${SYSTEMD_DIR}/"
|
|
||||||
echo " ✅ Установлен в ${SYSTEMD_DIR}/${filename}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Перезагружаем systemd
|
|
||||||
echo ""
|
|
||||||
echo "🔄 Перезагрузка systemd..."
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ TEST сервисы установлены!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 TEST сервис (не влияет на production):"
|
|
||||||
echo " - wyoming-satelite-vosk-test.service (спутник, порт 10702)"
|
|
||||||
echo ""
|
|
||||||
echo "💡 Production сервисы должны быть запущены:"
|
|
||||||
echo " - wyoming-wakeword-vosk.service (:10400)"
|
|
||||||
echo " - wyoming-event.service (:10500)"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Команды управления:"
|
|
||||||
echo " # Запуск test satellite"
|
|
||||||
echo " sudo systemctl start wyoming-satelite-vosk-test.service"
|
|
||||||
echo ""
|
|
||||||
echo " # Проверка статуса"
|
|
||||||
echo " sudo systemctl status wyoming-satelite-vosk-test.service"
|
|
||||||
echo ""
|
|
||||||
echo " # Остановка"
|
|
||||||
echo " sudo systemctl stop wyoming-satelite-vosk-test.service"
|
|
||||||
echo ""
|
|
||||||
echo "📊 Просмотр логов:"
|
|
||||||
echo " journalctl -u wyoming-satelite-vosk-test.service -f"
|
|
||||||
echo ""
|
|
||||||
echo "🌐 WebSocket для тестирования:"
|
|
||||||
echo " ws://127.0.0.1:8675"
|
|
||||||
echo ""
|
|
||||||
echo "💡 Пример подключения:"
|
|
||||||
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
|
|
||||||
echo ""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Command-line installer."""
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Command-line installer."""
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from .const import LOCAL_DIR, PROGRAM_DIR, SatelliteType, Settings
|
|
||||||
from .drivers import install_drivers
|
|
||||||
from .microphone import configure_microphone
|
|
||||||
from .packages import (
|
|
||||||
can_import,
|
|
||||||
install_packages,
|
|
||||||
install_packages_nogui,
|
|
||||||
packages_installed,
|
|
||||||
)
|
|
||||||
from .satellite import configure_satellite
|
|
||||||
from .services import generate_services, install_services, stop_services
|
|
||||||
from .speakers import configure_speakers
|
|
||||||
from .wake_word import configure_wake_word
|
|
||||||
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
logging.basicConfig(
|
|
||||||
filename=LOCAL_DIR / "installer.log", filemode="w", level=logging.DEBUG
|
|
||||||
)
|
|
||||||
|
|
||||||
settings = Settings.load()
|
|
||||||
|
|
||||||
if not packages_installed("whiptail"):
|
|
||||||
install_packages_nogui("whiptail")
|
|
||||||
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = main_menu(choice)
|
|
||||||
|
|
||||||
if choice == "satellite":
|
|
||||||
configure_satellite(settings)
|
|
||||||
elif choice == "microphone":
|
|
||||||
configure_microphone(settings)
|
|
||||||
elif choice == "speakers":
|
|
||||||
configure_speakers(settings)
|
|
||||||
elif choice == "wake":
|
|
||||||
configure_wake_word(settings)
|
|
||||||
elif choice == "drivers":
|
|
||||||
install_drivers(settings)
|
|
||||||
elif choice == "apply":
|
|
||||||
apply_settings(settings)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def main_menu(last_choice: Optional[str]) -> Optional[str]:
|
|
||||||
items: List[ItemType] = [
|
|
||||||
("satellite", "Satellite"),
|
|
||||||
("microphone", "Microphone"),
|
|
||||||
("speakers", "Speakers"),
|
|
||||||
("wake", "Wake Word"),
|
|
||||||
("drivers", "Drivers"),
|
|
||||||
("apply", "Apply Settings"),
|
|
||||||
]
|
|
||||||
|
|
||||||
return menu(
|
|
||||||
"Main",
|
|
||||||
items,
|
|
||||||
selected_item=last_choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Exit"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def pip_install(*args) -> List[str]:
|
|
||||||
return [
|
|
||||||
str(PROGRAM_DIR / ".venv" / "bin" / "pip3"),
|
|
||||||
"install",
|
|
||||||
"--extra-index-url",
|
|
||||||
"https://www.piwheels.org/simple",
|
|
||||||
"-f",
|
|
||||||
"https://synesthesiam.github.io/prebuilt-apps/",
|
|
||||||
] + list(args)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_settings(settings: Settings) -> None:
|
|
||||||
if settings.mic.device is None:
|
|
||||||
msgbox("Please configure microphone")
|
|
||||||
return
|
|
||||||
|
|
||||||
if (settings.satellite.type == SatelliteType.WAKE) and (
|
|
||||||
settings.wake.system is None
|
|
||||||
):
|
|
||||||
msgbox("Please set wake word system")
|
|
||||||
return
|
|
||||||
|
|
||||||
password: Optional[str] = None
|
|
||||||
if not packages_installed("python3-pip", "python3-venv"):
|
|
||||||
password = passwordbox("sudo password:")
|
|
||||||
if not password:
|
|
||||||
return
|
|
||||||
|
|
||||||
success = install_packages(
|
|
||||||
"Installing Python packages...", password, "python3-pip", "python3-venv"
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
error("installing pip/venv for Python")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Satellite venv
|
|
||||||
venv_dir = PROGRAM_DIR / ".venv"
|
|
||||||
if not venv_dir.exists():
|
|
||||||
result = run_with_gauge(
|
|
||||||
"Creating virtual environment...", [str(PROGRAM_DIR / "script" / "setup")]
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
error("creating virtual environment")
|
|
||||||
return
|
|
||||||
|
|
||||||
# silero (vad)
|
|
||||||
if (settings.satellite.type == SatelliteType.VAD) and (
|
|
||||||
not can_import("pysilero_vad")
|
|
||||||
):
|
|
||||||
result = run_with_gauge(
|
|
||||||
"Installing vad...",
|
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
error("installing vad")
|
|
||||||
return
|
|
||||||
|
|
||||||
# webrtc (audio enhancements)
|
|
||||||
if ((settings.mic.noise_suppression > 0) or (settings.mic.auto_gain > 0)) and (
|
|
||||||
not can_import("webrtc_noise_gain")
|
|
||||||
):
|
|
||||||
result = run_with_gauge(
|
|
||||||
"Installing audio enhancements...",
|
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
error("installing audio enhancements")
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
settings.satellite.event_service_command
|
|
||||||
and (
|
|
||||||
("2mic" in settings.satellite.event_service_command)
|
|
||||||
or ("4mic" in settings.satellite.event_service_command)
|
|
||||||
)
|
|
||||||
) and (not can_import("gpiozero", "spidev")):
|
|
||||||
result = run_with_gauge(
|
|
||||||
"Installing event requirements...",
|
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
error("installing event requirements")
|
|
||||||
return
|
|
||||||
|
|
||||||
generate_services(settings)
|
|
||||||
|
|
||||||
if password is None:
|
|
||||||
password = passwordbox("sudo password:")
|
|
||||||
if not password:
|
|
||||||
return
|
|
||||||
|
|
||||||
stop_services(password)
|
|
||||||
install_services(settings, password)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Constants and dataclasses."""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from .dataclasses_json import DataClassJsonMixin
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
PROGRAM_DIR = _DIR.parent
|
|
||||||
LOCAL_DIR = PROGRAM_DIR / "local"
|
|
||||||
SETTINGS_PATH = LOCAL_DIR / "settings.json"
|
|
||||||
SERVICES_DIR = LOCAL_DIR / "services"
|
|
||||||
|
|
||||||
RECORD_SECONDS = 5
|
|
||||||
RECORD_RMS_MIN = 30
|
|
||||||
|
|
||||||
TITLE = "Wyoming Satellite"
|
|
||||||
WIDTH = "75"
|
|
||||||
HEIGHT = "20"
|
|
||||||
LIST_HEIGHT = "12"
|
|
||||||
|
|
||||||
|
|
||||||
class SatelliteType(str, Enum):
|
|
||||||
ALWAYS_STREAMING = "always"
|
|
||||||
VAD = "vad"
|
|
||||||
WAKE = "wake"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SatelliteSettings(DataClassJsonMixin):
|
|
||||||
name: str = "Wyoming Satellite"
|
|
||||||
type: SatelliteType = SatelliteType.ALWAYS_STREAMING
|
|
||||||
debug: bool = False
|
|
||||||
event_service_command: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MicrophoneSettings(DataClassJsonMixin):
|
|
||||||
device: Optional[str] = None
|
|
||||||
noise_suppression: int = 0
|
|
||||||
auto_gain: int = 0
|
|
||||||
volume_multiplier: float = 1.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SpeakerSettings(DataClassJsonMixin):
|
|
||||||
device: Optional[str] = None
|
|
||||||
volume_multiplier: float = 1.0
|
|
||||||
feedback_sounds: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OpenWakeWordSettings(DataClassJsonMixin):
|
|
||||||
wake_word: str = "ok_nabu"
|
|
||||||
threshold: float = 0.5
|
|
||||||
trigger_level: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Porcupine1Settings(DataClassJsonMixin):
|
|
||||||
wake_word: str = "porcupine"
|
|
||||||
sensitivity: float = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SnowboySettings(DataClassJsonMixin):
|
|
||||||
wake_word: str = "snowboy"
|
|
||||||
sensitivity: float = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
class WakeWordSystem(str, Enum):
|
|
||||||
OPENWAKEWORD = "openWakeWord"
|
|
||||||
PORCUPINE1 = "porcupine1"
|
|
||||||
SNOWBOY = "snowboy"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WakeWordSettings(DataClassJsonMixin):
|
|
||||||
system: Optional[WakeWordSystem] = None
|
|
||||||
openwakeword: OpenWakeWordSettings = field(default_factory=OpenWakeWordSettings)
|
|
||||||
porcupine1: Porcupine1Settings = field(default_factory=Porcupine1Settings)
|
|
||||||
snowboy: SnowboySettings = field(default_factory=SnowboySettings)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Settings(DataClassJsonMixin):
|
|
||||||
satellite: SatelliteSettings = field(default_factory=SatelliteSettings)
|
|
||||||
mic: MicrophoneSettings = field(default_factory=MicrophoneSettings)
|
|
||||||
snd: SpeakerSettings = field(default_factory=SpeakerSettings)
|
|
||||||
wake: WakeWordSettings = field(default_factory=WakeWordSettings)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load() -> "Settings":
|
|
||||||
if SETTINGS_PATH.exists():
|
|
||||||
_LOGGER.debug("Loading settings from %s", SETTINGS_PATH)
|
|
||||||
with open(SETTINGS_PATH, "r", encoding="utf-8") as settings_file:
|
|
||||||
settings_dict = json.load(settings_file)
|
|
||||||
return Settings.from_dict(settings_dict)
|
|
||||||
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
_LOGGER.debug("Saving settings to %s", SETTINGS_PATH)
|
|
||||||
|
|
||||||
settings_dict = self.to_dict()
|
|
||||||
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(SETTINGS_PATH, "w", encoding="utf-8") as settings_file:
|
|
||||||
json.dump(settings_dict, settings_file, ensure_ascii=False, indent=2)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Implement a tiny subset of dataclasses_json for config."""
|
|
||||||
from collections.abc import Mapping, Sequence
|
|
||||||
from dataclasses import asdict, fields, is_dataclass
|
|
||||||
from typing import Any, Dict, Type
|
|
||||||
|
|
||||||
|
|
||||||
class DataClassJsonMixin:
|
|
||||||
"""Adds from_dict to dataclass."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> Any:
|
|
||||||
"""Parse dataclasses recursively."""
|
|
||||||
kwargs: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
cls_fields = {field.name: field for field in fields(cls)}
|
|
||||||
for key, value in data.items():
|
|
||||||
if key not in cls_fields:
|
|
||||||
# Skip unknown fields
|
|
||||||
continue
|
|
||||||
|
|
||||||
field = cls_fields[key]
|
|
||||||
if is_dataclass(field.type):
|
|
||||||
assert issubclass(field.type, DataClassJsonMixin), field.type
|
|
||||||
kwargs[key] = field.type.from_dict(value)
|
|
||||||
else:
|
|
||||||
kwargs[key] = _decode(value, field.type)
|
|
||||||
|
|
||||||
# Fill in optional fields with None
|
|
||||||
for field in cls_fields.values():
|
|
||||||
if (field.name not in kwargs) and _is_optional(field.type):
|
|
||||||
kwargs[field.name] = None
|
|
||||||
|
|
||||||
return cls(**kwargs)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Alias for asdict."""
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _decode(value: Any, target_type: Type) -> Any:
|
|
||||||
"""Decode value using (possibly generic) type."""
|
|
||||||
if is_dataclass(target_type):
|
|
||||||
assert issubclass(target_type, DataClassJsonMixin), target_type
|
|
||||||
return target_type.from_dict(value) if value is not None else None
|
|
||||||
|
|
||||||
if hasattr(target_type, "__args__"):
|
|
||||||
# Optional[T]
|
|
||||||
if type(None) in target_type.__args__:
|
|
||||||
optional_type = target_type.__args__[0]
|
|
||||||
return _decode(value, optional_type)
|
|
||||||
|
|
||||||
# List[T]
|
|
||||||
if isinstance(value, Sequence):
|
|
||||||
list_type = target_type.__args__[0]
|
|
||||||
return [_decode(item, list_type) for item in value]
|
|
||||||
|
|
||||||
# Dict[str, T]
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
value_type = target_type.__args__[1]
|
|
||||||
return {
|
|
||||||
map_key: _decode(map_value, value_type)
|
|
||||||
for map_key, map_value in value.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _is_optional(target_type: Type):
|
|
||||||
"""True if type is Optional"""
|
|
||||||
return hasattr(target_type, "__args__") and (type(None) in target_type.__args__)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Hardware drivers."""
|
|
||||||
import sys
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .const import PROGRAM_DIR, Settings
|
|
||||||
from .whiptail import error, menu, msgbox, passwordbox, run_with_gauge, yesno
|
|
||||||
|
|
||||||
|
|
||||||
def install_drivers(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Drivers",
|
|
||||||
[("respeaker", "Install ReSpeaker Drivers")],
|
|
||||||
selected_item=choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "respeaker":
|
|
||||||
if yesno(
|
|
||||||
"ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. "
|
|
||||||
"This will take a long time and require a reboot. "
|
|
||||||
"Continue?"
|
|
||||||
):
|
|
||||||
password = passwordbox("sudo password:")
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Installing drivers...",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"sudo",
|
|
||||||
"-S",
|
|
||||||
str(PROGRAM_DIR / "etc" / "install-respeaker-drivers.sh"),
|
|
||||||
]
|
|
||||||
],
|
|
||||||
sudo_password=password,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
error("installing ReSpeaker drivers")
|
|
||||||
return
|
|
||||||
|
|
||||||
msgbox(
|
|
||||||
"Driver installation complete. "
|
|
||||||
"Please reboot your Raspberry Pi and re-run the installer. "
|
|
||||||
'Once rebooted, select the "seeed" microphone.'
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"""Microphone settings."""
|
|
||||||
import array
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import subprocess
|
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
|
|
||||||
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def configure_microphone(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = microphone_menu(choice)
|
|
||||||
|
|
||||||
if choice == "detect":
|
|
||||||
# Automatically detect microphone with hightest RMS
|
|
||||||
best_device: Optional[str] = None
|
|
||||||
best_rms: Optional[float] = None
|
|
||||||
|
|
||||||
devices = get_microphone_devices()
|
|
||||||
with ThreadPoolExecutor() as executor:
|
|
||||||
futures: Dict[str, Future] = {}
|
|
||||||
for device in devices:
|
|
||||||
futures[device] = executor.submit(_record_proc, device)
|
|
||||||
|
|
||||||
gauge("Speak loudly into the microphone.", RECORD_SECONDS)
|
|
||||||
for device, future in futures.items():
|
|
||||||
device_rms = future.result()
|
|
||||||
if device_rms is None:
|
|
||||||
_LOGGER.warning("Failed to record from microphone %s", device)
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Microphone %s got RMS %s (min: %s)",
|
|
||||||
device,
|
|
||||||
device_rms,
|
|
||||||
RECORD_RMS_MIN,
|
|
||||||
)
|
|
||||||
if device_rms < RECORD_RMS_MIN:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if (best_rms is None) or (device_rms > best_rms):
|
|
||||||
best_device = device
|
|
||||||
best_rms = device_rms
|
|
||||||
|
|
||||||
if best_device is not None:
|
|
||||||
msgbox(f"Successfully detected microphone: {best_device}")
|
|
||||||
settings.mic.device = best_device
|
|
||||||
settings.save()
|
|
||||||
else:
|
|
||||||
msgbox(
|
|
||||||
"Audio was not detected from any microphone.\n"
|
|
||||||
"If a satellite is currently running, you may need to stop it."
|
|
||||||
)
|
|
||||||
elif choice == "list":
|
|
||||||
# arecord -L
|
|
||||||
microphone_device = radiolist(
|
|
||||||
"Select ALSA Device:", get_microphone_devices(), settings.mic.device
|
|
||||||
)
|
|
||||||
if microphone_device:
|
|
||||||
settings.mic.device = microphone_device
|
|
||||||
settings.save()
|
|
||||||
elif choice == "manual":
|
|
||||||
microphone_device = inputbox("Enter ALSA Device:", settings.mic.device)
|
|
||||||
if microphone_device:
|
|
||||||
settings.mic.device = microphone_device
|
|
||||||
settings.save()
|
|
||||||
elif choice == "settings":
|
|
||||||
configure_audio_settings(settings)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def microphone_menu(last_choice: Optional[str]) -> Optional[str]:
|
|
||||||
return menu(
|
|
||||||
"Main > Microphone",
|
|
||||||
[
|
|
||||||
("detect", "Autodetect"),
|
|
||||||
("list", "Select From List"),
|
|
||||||
("manual", "Enter Manually"),
|
|
||||||
("settings", "Audio Settings"),
|
|
||||||
],
|
|
||||||
selected_item=last_choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_microphone_devices() -> List[str]:
|
|
||||||
devices = []
|
|
||||||
lines = subprocess.check_output(["arecord", "-L"]).decode("utf-8").splitlines()
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
# default = PulseAudio
|
|
||||||
if (line == "default") or line.startswith("plughw:"):
|
|
||||||
devices.append(line)
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
def _record_proc(device: str) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"arecord",
|
|
||||||
"-q",
|
|
||||||
"-D",
|
|
||||||
device,
|
|
||||||
"-r",
|
|
||||||
"16000",
|
|
||||||
"-c",
|
|
||||||
"1",
|
|
||||||
"-f",
|
|
||||||
"S16_LE",
|
|
||||||
"-t",
|
|
||||||
"raw",
|
|
||||||
"-d",
|
|
||||||
str(RECORD_SECONDS),
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
audio, stderr = proc.communicate()
|
|
||||||
if proc.returncode != 0:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Error recording from device %s: %s", device, stderr.decode("utf-8")
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 16-bit mono
|
|
||||||
audio_array = array.array("h", audio)
|
|
||||||
rms = math.sqrt(
|
|
||||||
(1 / len(audio_array) * sum(x * x for x in audio_array.tolist()))
|
|
||||||
)
|
|
||||||
return rms
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Error recording from device: %s", device)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def configure_audio_settings(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = audio_settings_menu(choice)
|
|
||||||
|
|
||||||
if choice == "noise":
|
|
||||||
noise_suppression = radiolist(
|
|
||||||
"Noise Suppression Level",
|
|
||||||
[(0, "Off"), (1, "Low"), (2, "Medium"), (3, "High"), (4, "Maximum")],
|
|
||||||
settings.mic.noise_suppression,
|
|
||||||
)
|
|
||||||
if noise_suppression is not None:
|
|
||||||
settings.mic.noise_suppression = noise_suppression
|
|
||||||
settings.save()
|
|
||||||
elif choice == "gain":
|
|
||||||
while True:
|
|
||||||
auto_gain = inputbox("Auto Gain (0-31 dbFS)", settings.mic.auto_gain)
|
|
||||||
if auto_gain is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
auto_gain_int = int(auto_gain)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 0 <= auto_gain_int <= 31:
|
|
||||||
settings.mic.auto_gain = auto_gain_int
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Must be 0-31")
|
|
||||||
elif choice == "multiplier":
|
|
||||||
while True:
|
|
||||||
volume_multiplier = inputbox(
|
|
||||||
"Volume Multiplier (1 = default)", settings.mic.volume_multiplier
|
|
||||||
)
|
|
||||||
if volume_multiplier is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
volume_multiplier_float = float(volume_multiplier)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if volume_multiplier_float > 0:
|
|
||||||
settings.mic.volume_multiplier = volume_multiplier_float
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Must be > 0")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def audio_settings_menu(last_choice: Optional[str]) -> Optional[str]:
|
|
||||||
return menu(
|
|
||||||
"Main > Microphone > Audio Settings",
|
|
||||||
[
|
|
||||||
("noise", "Noise Suppression"),
|
|
||||||
("gain", "Auto Gain"),
|
|
||||||
("multiplier", "Volume Multiplier"),
|
|
||||||
],
|
|
||||||
selected_item=last_choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Install system packages."""
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from .const import PROGRAM_DIR
|
|
||||||
from .whiptail import run_with_gauge
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def packages_installed(*packages) -> bool:
|
|
||||||
for package in packages:
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
["dpkg", "--status", str(package)],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_packages_nogui(*packages, update: bool = True) -> bool:
|
|
||||||
assert packages, "No packages"
|
|
||||||
|
|
||||||
commands = []
|
|
||||||
if update:
|
|
||||||
commands.append(["sudo", "apt-get", "update"])
|
|
||||||
|
|
||||||
commands.append(
|
|
||||||
["sudo", "apt-get", "install", "--yes"] + [str(p) for p in packages]
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
for command in commands:
|
|
||||||
subprocess.check_call(
|
|
||||||
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Unexpected error installing packages: %s", packages)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_packages(
|
|
||||||
text: str, sudo_password: str, *packages, update: bool = True
|
|
||||||
) -> bool:
|
|
||||||
assert packages, "No packages"
|
|
||||||
|
|
||||||
commands = []
|
|
||||||
if update:
|
|
||||||
commands.append(["sudo", "-S", "apt-get", "update"])
|
|
||||||
|
|
||||||
commands.append(
|
|
||||||
["sudo", "-S", "apt-get", "install", "--yes"] + [str(p) for p in packages]
|
|
||||||
)
|
|
||||||
|
|
||||||
return run_with_gauge(text, commands, sudo_password=sudo_password)
|
|
||||||
|
|
||||||
|
|
||||||
def can_import(*names) -> bool:
|
|
||||||
assert names, "No names"
|
|
||||||
|
|
||||||
venv_dir = PROGRAM_DIR / ".venv"
|
|
||||||
if not venv_dir.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import venv
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(PROGRAM_DIR / ".venv")
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
[context.env_exe, "-c", "; ".join(f"import {name}" for name in names)],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Satellite settings."""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .const import PROGRAM_DIR, SatelliteType, Settings
|
|
||||||
from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge
|
|
||||||
|
|
||||||
|
|
||||||
def configure_satellite(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = satellite_menu(choice)
|
|
||||||
|
|
||||||
if choice == "name":
|
|
||||||
name = inputbox("Satellite Name:", settings.satellite.name)
|
|
||||||
if name:
|
|
||||||
settings.satellite.name = name
|
|
||||||
settings.save()
|
|
||||||
elif choice == "type":
|
|
||||||
satellite_type = radiolist(
|
|
||||||
"Satellite Type:",
|
|
||||||
[
|
|
||||||
(SatelliteType.ALWAYS_STREAMING, "Always streaming"),
|
|
||||||
(SatelliteType.VAD, "Voice activity detection"),
|
|
||||||
(SatelliteType.WAKE, "Local wake word detection"),
|
|
||||||
],
|
|
||||||
settings.satellite.type,
|
|
||||||
)
|
|
||||||
|
|
||||||
if satellite_type is not None:
|
|
||||||
settings.satellite.type = SatelliteType(satellite_type)
|
|
||||||
settings.save()
|
|
||||||
elif choice == "feedback":
|
|
||||||
configure_feedback(settings)
|
|
||||||
elif choice in ("restart", "stop", "start"):
|
|
||||||
password = passwordbox("sudo password:")
|
|
||||||
if not password:
|
|
||||||
continue
|
|
||||||
|
|
||||||
command = ["sudo", "-S", "systemctl", choice, "wyoming-satellite.service"]
|
|
||||||
text = {"restart": "Restarting", "stop": "Stopping", "start": "Starting"}[
|
|
||||||
choice
|
|
||||||
]
|
|
||||||
success = run_with_gauge(
|
|
||||||
f"{text} satellite...", [command], sudo_password=password
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
error(f"{text.lower()} satellite")
|
|
||||||
elif choice == "debug":
|
|
||||||
debug = radiolist(
|
|
||||||
"Debug Mode:",
|
|
||||||
[
|
|
||||||
("disabled", "Disabled"),
|
|
||||||
("enabled", "Enabled"),
|
|
||||||
],
|
|
||||||
"enabled" if settings.satellite.debug else "disabled",
|
|
||||||
)
|
|
||||||
|
|
||||||
if debug is not None:
|
|
||||||
settings.satellite.debug = debug == "enabled"
|
|
||||||
settings.save()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def satellite_menu(last_choice: Optional[str]) -> Optional[str]:
|
|
||||||
return menu(
|
|
||||||
"Main > Satellite",
|
|
||||||
[
|
|
||||||
("name", "Satellite Name"),
|
|
||||||
("type", "Satellite Type"),
|
|
||||||
("feedback", "Feedback"),
|
|
||||||
("restart", "Restart Services"),
|
|
||||||
("stop", "Stop Services"),
|
|
||||||
("start", "Start Services"),
|
|
||||||
("debug", "Set Debug Mode"),
|
|
||||||
],
|
|
||||||
selected_item=last_choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_feedback(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Satellite > Feedback",
|
|
||||||
[("respeaker", "ReSpeaker")],
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
if choice == "respeaker":
|
|
||||||
selected_service: Optional[str] = None
|
|
||||||
if settings.satellite.event_service_command:
|
|
||||||
if "2mic" in settings.satellite.event_service_command[0]:
|
|
||||||
selected_service = "2mic"
|
|
||||||
elif "4mic" in settings.satellite.event_service_command[0]:
|
|
||||||
selected_service = "4mic"
|
|
||||||
|
|
||||||
event_service = radiolist(
|
|
||||||
"Event Service:",
|
|
||||||
[
|
|
||||||
("none", "None"),
|
|
||||||
("2mic", "2mic LEDs"),
|
|
||||||
("4mic", "4mic LEDs"),
|
|
||||||
],
|
|
||||||
selected_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_service is not None:
|
|
||||||
if event_service == "none":
|
|
||||||
settings.satellite.event_service_command = None
|
|
||||||
else:
|
|
||||||
settings.satellite.event_service_command = [
|
|
||||||
str(PROGRAM_DIR / "script" / f"run_{event_service}"),
|
|
||||||
"--uri",
|
|
||||||
"tcp://127.0.0.1:10500",
|
|
||||||
]
|
|
||||||
|
|
||||||
settings.save()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"""Systemd service management."""
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
LOCAL_DIR,
|
|
||||||
PROGRAM_DIR,
|
|
||||||
SERVICES_DIR,
|
|
||||||
SatelliteType,
|
|
||||||
Settings,
|
|
||||||
WakeWordSystem,
|
|
||||||
)
|
|
||||||
from .whiptail import error, msgbox, run_with_gauge
|
|
||||||
|
|
||||||
|
|
||||||
def stop_services(password: str) -> None:
|
|
||||||
stop_commands = []
|
|
||||||
for service in ("satellite", "wakeword", "event"):
|
|
||||||
service_filename = f"wyoming-{service}.service"
|
|
||||||
service_path = Path("/etc/systemd/system") / service_filename
|
|
||||||
if not service_path.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
stop_commands.append(["sudo", "-S", "systemctl", "stop", service_filename])
|
|
||||||
stop_commands.append(["sudo", "-S", "systemctl", "disable", service_filename])
|
|
||||||
|
|
||||||
run_with_gauge("Stopping Services...", stop_commands, sudo_password=password)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_services(settings: Settings) -> None:
|
|
||||||
SERVICES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
user_id = subprocess.check_output(["id", "-u"], text=True).strip()
|
|
||||||
user_name = subprocess.check_output(["id", "--name", "-u"], text=True).strip()
|
|
||||||
|
|
||||||
satellite_command: List[str] = [
|
|
||||||
str(PROGRAM_DIR / "script" / "run"),
|
|
||||||
"--name",
|
|
||||||
settings.satellite.name,
|
|
||||||
"--uri",
|
|
||||||
"tcp://0.0.0.0:10700",
|
|
||||||
"--mic-command",
|
|
||||||
f"arecord -D {settings.mic.device} -q -r 16000 -c 1 -f S16_LE -t raw",
|
|
||||||
]
|
|
||||||
satellite_requires: List[str] = []
|
|
||||||
|
|
||||||
if settings.snd.device is not None:
|
|
||||||
# Audio output
|
|
||||||
satellite_command.extend(
|
|
||||||
[
|
|
||||||
"--snd-command",
|
|
||||||
f"aplay -D {settings.snd.device} -q -r 22050 -c 1 -f S16_LE -t raw",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.snd.volume_multiplier != 1.0:
|
|
||||||
satellite_command.extend(
|
|
||||||
["--snd-volume-multiplier", str(settings.snd.volume_multiplier)]
|
|
||||||
)
|
|
||||||
|
|
||||||
for sound_name in settings.snd.feedback_sounds:
|
|
||||||
# Try local/sounds first
|
|
||||||
sound_path = LOCAL_DIR / "sounds" / f"{sound_name}.wav"
|
|
||||||
if not sound_path.exists():
|
|
||||||
sound_path = PROGRAM_DIR / "sounds" / f"{sound_name}.wav"
|
|
||||||
|
|
||||||
satellite_command.extend([f"--{sound_name}-wav", str(sound_path)])
|
|
||||||
|
|
||||||
if settings.mic.noise_suppression > 0:
|
|
||||||
satellite_command.extend(
|
|
||||||
["--mic-noise-suppression", str(settings.mic.noise_suppression)]
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.mic.auto_gain > 0:
|
|
||||||
satellite_command.extend(["--mic-auto-gain", str(settings.mic.auto_gain)])
|
|
||||||
|
|
||||||
if settings.mic.volume_multiplier != 1.0:
|
|
||||||
satellite_command.extend(
|
|
||||||
["--mic-volume-multiplier", str(settings.mic.volume_multiplier)]
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.satellite.type == SatelliteType.VAD:
|
|
||||||
# Voice activity detect
|
|
||||||
satellite_command.append("--vad")
|
|
||||||
elif settings.satellite.type == SatelliteType.WAKE:
|
|
||||||
# Local wake word detection
|
|
||||||
assert settings.wake.system is not None, "Wake word system not set"
|
|
||||||
|
|
||||||
wake_word_service = "wyoming-wakeword"
|
|
||||||
wake_word_dir = LOCAL_DIR / (
|
|
||||||
"wyoming-" + WakeWordSystem(settings.wake.system).value.lower()
|
|
||||||
)
|
|
||||||
wake_word_command = [
|
|
||||||
str(wake_word_dir / "script" / "run"),
|
|
||||||
"--uri",
|
|
||||||
"tcp://127.0.0.1:10400",
|
|
||||||
]
|
|
||||||
|
|
||||||
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
|
|
||||||
wake_word = settings.wake.openwakeword.wake_word
|
|
||||||
wake_word_command.extend(
|
|
||||||
[
|
|
||||||
"--threshold",
|
|
||||||
str(settings.wake.openwakeword.threshold),
|
|
||||||
"--trigger-level",
|
|
||||||
str(settings.wake.openwakeword.trigger_level),
|
|
||||||
"--custom-model-dir",
|
|
||||||
str(LOCAL_DIR / "custom-wake-words" / "openWakeWord"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif settings.wake.system == WakeWordSystem.PORCUPINE1:
|
|
||||||
wake_word = settings.wake.porcupine1.wake_word
|
|
||||||
wake_word_command.extend(
|
|
||||||
["--sensitivity", str(settings.wake.porcupine1.sensitivity)]
|
|
||||||
)
|
|
||||||
elif settings.wake.system == WakeWordSystem.SNOWBOY:
|
|
||||||
wake_word = settings.wake.snowboy.wake_word
|
|
||||||
wake_word_command.extend(
|
|
||||||
[
|
|
||||||
"--sensitivity",
|
|
||||||
str(settings.wake.snowboy.sensitivity),
|
|
||||||
"--custom-model-dir",
|
|
||||||
str(LOCAL_DIR / "custom-wake-words" / "snowboy"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(settings.wake.system)
|
|
||||||
|
|
||||||
if settings.satellite.debug:
|
|
||||||
wake_word_command.append("--debug")
|
|
||||||
|
|
||||||
wake_word_command_str = shlex.join(wake_word_command)
|
|
||||||
|
|
||||||
with open(
|
|
||||||
SERVICES_DIR / f"{wake_word_service}.service", "w", encoding="utf-8"
|
|
||||||
) as service_file:
|
|
||||||
print("[Unit]", file=service_file)
|
|
||||||
print(
|
|
||||||
f"Description={WakeWordSystem(settings.wake.system).value}",
|
|
||||||
file=service_file,
|
|
||||||
)
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Service]", file=service_file)
|
|
||||||
print("Type=simple", file=service_file)
|
|
||||||
print(f"User={user_name}", file=service_file)
|
|
||||||
print(f"ExecStart={wake_word_command_str}", file=service_file)
|
|
||||||
print(f"WorkingDirectory={wake_word_dir}", file=service_file)
|
|
||||||
print("Restart=always", file=service_file)
|
|
||||||
print("RestartSec=1", file=service_file)
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Install]", file=service_file)
|
|
||||||
print("WantedBy=default.target", file=service_file)
|
|
||||||
|
|
||||||
satellite_command.extend(
|
|
||||||
[
|
|
||||||
"--wake-uri",
|
|
||||||
"tcp://127.0.0.1:10400",
|
|
||||||
"--wake-word-name",
|
|
||||||
wake_word,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
satellite_requires.append(f"{wake_word_service}.service")
|
|
||||||
|
|
||||||
if settings.satellite.event_service_command:
|
|
||||||
event_service = "wyoming-event"
|
|
||||||
event_command_str = shlex.join(settings.satellite.event_service_command)
|
|
||||||
with open(
|
|
||||||
SERVICES_DIR / f"{event_service}.service", "w", encoding="utf-8"
|
|
||||||
) as service_file:
|
|
||||||
print("[Unit]", file=service_file)
|
|
||||||
print("Description=Event service", file=service_file)
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Service]", file=service_file)
|
|
||||||
print("Type=simple", file=service_file)
|
|
||||||
print(f"User={user_name}", file=service_file)
|
|
||||||
print(f"ExecStart={event_command_str}", file=service_file)
|
|
||||||
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
|
|
||||||
print("Restart=always", file=service_file)
|
|
||||||
print("RestartSec=1", file=service_file)
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Install]", file=service_file)
|
|
||||||
print("WantedBy=default.target", file=service_file)
|
|
||||||
|
|
||||||
satellite_command.extend(["--event-uri", "tcp://127.0.0.1:10500"])
|
|
||||||
satellite_requires.append(f"{event_service}.service")
|
|
||||||
|
|
||||||
if settings.satellite.debug:
|
|
||||||
satellite_command.extend(
|
|
||||||
["--debug", "--debug-recording-dir", str(LOCAL_DIR / "debug-recording")]
|
|
||||||
)
|
|
||||||
|
|
||||||
satellite_command_str = shlex.join(satellite_command)
|
|
||||||
|
|
||||||
with open(
|
|
||||||
SERVICES_DIR / "wyoming-satellite.service", "w", encoding="utf-8"
|
|
||||||
) as service_file:
|
|
||||||
print("[Unit]", file=service_file)
|
|
||||||
print("Description=Wyoming Satellite", file=service_file)
|
|
||||||
print("Wants=network-online.target", file=service_file)
|
|
||||||
print("After=network-online.target", file=service_file)
|
|
||||||
for requires in satellite_requires:
|
|
||||||
print(f"Requires={requires}", file=service_file)
|
|
||||||
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Service]", file=service_file)
|
|
||||||
print("Type=simple", file=service_file)
|
|
||||||
print(f"User={user_name}", file=service_file)
|
|
||||||
|
|
||||||
# For PulseAudio
|
|
||||||
print(f"Environment=XDG_RUNTIME_DIR=/run/user/{user_id}", file=service_file)
|
|
||||||
|
|
||||||
print(f"ExecStart={satellite_command_str}", file=service_file)
|
|
||||||
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
|
|
||||||
print("Restart=always", file=service_file)
|
|
||||||
print("RestartSec=1", file=service_file)
|
|
||||||
print("", file=service_file)
|
|
||||||
print("[Install]", file=service_file)
|
|
||||||
print("WantedBy=default.target", file=service_file)
|
|
||||||
|
|
||||||
|
|
||||||
def install_services(settings: Settings, password: str):
|
|
||||||
# Install and run
|
|
||||||
installed_services = ["satellite"]
|
|
||||||
if settings.satellite.type == SatelliteType.WAKE:
|
|
||||||
assert settings.wake.system is not None, "No wake word system"
|
|
||||||
installed_services.append("wakeword")
|
|
||||||
|
|
||||||
if settings.satellite.event_service_command:
|
|
||||||
installed_services.append("event")
|
|
||||||
|
|
||||||
install_commands = []
|
|
||||||
for service in installed_services:
|
|
||||||
service_filename = f"wyoming-{service}.service"
|
|
||||||
install_commands.append(
|
|
||||||
[
|
|
||||||
"sudo",
|
|
||||||
"-S",
|
|
||||||
"cp",
|
|
||||||
str(SERVICES_DIR / service_filename),
|
|
||||||
"/etc/systemd/system/",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
install_commands.append(["sudo", "-S", "systemctl", "daemon-reload"])
|
|
||||||
|
|
||||||
# Copy first, then enable and start
|
|
||||||
for service in installed_services:
|
|
||||||
install_commands.append(["sudo", "-S", "systemctl", "enable", service_filename])
|
|
||||||
|
|
||||||
install_commands.append(
|
|
||||||
["sudo", "-S", "systemctl", "enable", "--now", "wyoming-satellite.service"]
|
|
||||||
)
|
|
||||||
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Installing Services...", install_commands, sudo_password=password
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
msgbox("Successfully installed services")
|
|
||||||
else:
|
|
||||||
error("installing services")
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Speaker settings."""
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from .const import PROGRAM_DIR, Settings
|
|
||||||
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def configure_speakers(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = speakers_menu(choice)
|
|
||||||
|
|
||||||
if choice == "play":
|
|
||||||
if settings.snd.device is None:
|
|
||||||
msgbox("No speaker device set")
|
|
||||||
continue
|
|
||||||
|
|
||||||
test_sound_device(settings.snd.device)
|
|
||||||
elif choice == "test":
|
|
||||||
sound_device = test_speakers()
|
|
||||||
if sound_device is not None:
|
|
||||||
settings.snd.device = sound_device
|
|
||||||
settings.save()
|
|
||||||
elif choice == "list":
|
|
||||||
sound_device = radiolist(
|
|
||||||
"Select ALSA Device:",
|
|
||||||
get_sound_devices(),
|
|
||||||
settings.snd.device,
|
|
||||||
)
|
|
||||||
if sound_device:
|
|
||||||
settings.snd.device = sound_device
|
|
||||||
settings.save()
|
|
||||||
elif choice == "manual":
|
|
||||||
sound_device = inputbox("Enter ALSA Device:", settings.snd.device)
|
|
||||||
if sound_device:
|
|
||||||
settings.snd.device = sound_device
|
|
||||||
settings.save()
|
|
||||||
elif choice == "disable":
|
|
||||||
settings.snd.device = None
|
|
||||||
settings.save()
|
|
||||||
msgbox("Sound disabled")
|
|
||||||
elif choice == "feedback":
|
|
||||||
feedback_sounds = checklist(
|
|
||||||
"Enabled Sounds:",
|
|
||||||
[("awake", "On wake-up"), ("done", "After voice command")],
|
|
||||||
settings.snd.feedback_sounds,
|
|
||||||
)
|
|
||||||
|
|
||||||
if feedback_sounds is not None:
|
|
||||||
settings.snd.feedback_sounds = feedback_sounds
|
|
||||||
settings.save()
|
|
||||||
elif choice == "multiplier":
|
|
||||||
while True:
|
|
||||||
volume_multiplier = inputbox(
|
|
||||||
"Volume Multiplier (1 = default)", settings.snd.volume_multiplier
|
|
||||||
)
|
|
||||||
if volume_multiplier is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
volume_multiplier_float = float(volume_multiplier)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if volume_multiplier_float > 0:
|
|
||||||
settings.snd.volume_multiplier = volume_multiplier_float
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Must be > 0")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def speakers_menu(last_choice: Optional[str]) -> Optional[str]:
|
|
||||||
return menu(
|
|
||||||
"Main > Speakers",
|
|
||||||
[
|
|
||||||
("play", "Play Sound"),
|
|
||||||
("test", "Test All Speakers"),
|
|
||||||
("list", "Select From List"),
|
|
||||||
("manual", "Enter Manually"),
|
|
||||||
("disable", "Disable Sound"),
|
|
||||||
("multiplier", "Volume Multiplier"),
|
|
||||||
("feedback", "Toggle Feedback Sounds"),
|
|
||||||
],
|
|
||||||
selected_item=last_choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sound_devices() -> List[str]:
|
|
||||||
devices = []
|
|
||||||
lines = subprocess.check_output(["aplay", "-L"]).decode("utf-8").splitlines()
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
# default = PulseAudio
|
|
||||||
if (line == "default") or line.startswith("plughw:"):
|
|
||||||
devices.append(line)
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
def test_speakers() -> Optional[str]:
|
|
||||||
devices = get_sound_devices()
|
|
||||||
if not devices:
|
|
||||||
msgbox("No speakers found")
|
|
||||||
return None
|
|
||||||
|
|
||||||
device = devices.pop()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
f"Device: {device}",
|
|
||||||
[
|
|
||||||
("play", "Play Sound"),
|
|
||||||
("next", "Next Device"),
|
|
||||||
("choose", "Choose This Device"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "play":
|
|
||||||
test_sound_device(device)
|
|
||||||
elif choice == "next":
|
|
||||||
if not devices:
|
|
||||||
msgbox("No more devices")
|
|
||||||
break
|
|
||||||
|
|
||||||
device = devices.pop()
|
|
||||||
elif choice == "choose":
|
|
||||||
return device
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def test_sound_device(device: str) -> None:
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
["aplay", "-q", "-D", device, str(PROGRAM_DIR / "sounds" / "awake.wav")]
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Error testing device: %s", device)
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
"""Wake word settings."""
|
|
||||||
import itertools
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
from .const import LOCAL_DIR, SatelliteType, Settings, WakeWordSystem
|
|
||||||
from .packages import install_packages, packages_installed
|
|
||||||
from .whiptail import (
|
|
||||||
error,
|
|
||||||
inputbox,
|
|
||||||
menu,
|
|
||||||
msgbox,
|
|
||||||
passwordbox,
|
|
||||||
radiolist,
|
|
||||||
run_with_gauge,
|
|
||||||
yesno,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_wake_word(settings: Settings) -> None:
|
|
||||||
if settings.satellite.type != SatelliteType.WAKE:
|
|
||||||
if not yesno("Set satellite type to local wake word?"):
|
|
||||||
return
|
|
||||||
|
|
||||||
settings.satellite.type = SatelliteType.WAKE
|
|
||||||
settings.save()
|
|
||||||
|
|
||||||
def star(ww_system: WakeWordSystem) -> str:
|
|
||||||
return (
|
|
||||||
f"{ww_system.value} [*]"
|
|
||||||
if settings.wake.system == ww_system
|
|
||||||
else ww_system.value
|
|
||||||
)
|
|
||||||
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Wake Word",
|
|
||||||
[
|
|
||||||
("system", "Wake Word System"),
|
|
||||||
("wake_word", "Choose Wake Word"),
|
|
||||||
("openWakeWord", star(WakeWordSystem.OPENWAKEWORD)),
|
|
||||||
("porcupine1", star(WakeWordSystem.PORCUPINE1)),
|
|
||||||
("snowboy", star(WakeWordSystem.SNOWBOY)),
|
|
||||||
],
|
|
||||||
selected_item=choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "system":
|
|
||||||
wake_word_system = radiolist(
|
|
||||||
"Wake Word System:",
|
|
||||||
[v.value for v in WakeWordSystem],
|
|
||||||
settings.wake.system,
|
|
||||||
)
|
|
||||||
if wake_word_system is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
wake_word_system = WakeWordSystem(wake_word_system)
|
|
||||||
install_wake_word(settings, wake_word_system)
|
|
||||||
elif choice == "wake_word":
|
|
||||||
select_wake_word(settings)
|
|
||||||
elif choice == "openWakeWord":
|
|
||||||
configure_openWakeWord(settings)
|
|
||||||
elif choice == "porcupine1":
|
|
||||||
configure_porcupine1(settings)
|
|
||||||
elif choice == "snowboy":
|
|
||||||
configure_snowboy(settings)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def install_wake_word(settings: Settings, wake_word_system: WakeWordSystem) -> None:
|
|
||||||
if wake_word_system == WakeWordSystem.OPENWAKEWORD:
|
|
||||||
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
|
|
||||||
if not oww_dir.exists():
|
|
||||||
if not yesno("Install openWakeWord?"):
|
|
||||||
return
|
|
||||||
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Installing openWakeWord",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"https://github.com/rhasspy/wyoming-openwakeword.git",
|
|
||||||
str(oww_dir),
|
|
||||||
],
|
|
||||||
[str(oww_dir / "script" / "setup")],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Clean up
|
|
||||||
try:
|
|
||||||
shutil.rmtree(oww_dir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
error("installing openWakeWord")
|
|
||||||
return
|
|
||||||
|
|
||||||
msgbox("openWakeWord installed successfully")
|
|
||||||
settings.wake.system = wake_word_system
|
|
||||||
settings.save()
|
|
||||||
return
|
|
||||||
|
|
||||||
if wake_word_system == WakeWordSystem.PORCUPINE1:
|
|
||||||
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
|
|
||||||
if not porcupine1_dir.exists():
|
|
||||||
if not yesno("Install porcupine1?"):
|
|
||||||
return
|
|
||||||
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Installing porcupine1",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"https://github.com/rhasspy/wyoming-porcupine1.git",
|
|
||||||
str(porcupine1_dir),
|
|
||||||
],
|
|
||||||
[str(porcupine1_dir / "script" / "setup")],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Clean up
|
|
||||||
try:
|
|
||||||
shutil.rmtree(porcupine1_dir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
error("installing porcupine1")
|
|
||||||
return
|
|
||||||
|
|
||||||
msgbox("porcupine1 installed successfully")
|
|
||||||
settings.wake.system = wake_word_system
|
|
||||||
settings.save()
|
|
||||||
return
|
|
||||||
|
|
||||||
if wake_word_system == WakeWordSystem.SNOWBOY:
|
|
||||||
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
|
|
||||||
if not snowboy_dir.exists():
|
|
||||||
if not yesno("Install snowboy?"):
|
|
||||||
return
|
|
||||||
|
|
||||||
snowboy_packages = ["python3-dev", "swig", "libatlas-base-dev"]
|
|
||||||
if not packages_installed(*snowboy_packages):
|
|
||||||
password = passwordbox("sudo password:")
|
|
||||||
if not password:
|
|
||||||
return
|
|
||||||
|
|
||||||
success = install_packages(
|
|
||||||
"Installing system packages...", password, *snowboy_packages
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
error("installing " + ", ".join(snowboy_packages))
|
|
||||||
return
|
|
||||||
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Installing snowboy",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"https://github.com/rhasspy/wyoming-snowboy.git",
|
|
||||||
str(snowboy_dir),
|
|
||||||
],
|
|
||||||
[str(snowboy_dir / "script" / "setup")],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Clean up
|
|
||||||
try:
|
|
||||||
shutil.rmtree(snowboy_dir)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
error("installing snowboy")
|
|
||||||
return
|
|
||||||
|
|
||||||
msgbox("snowboy installed successfully")
|
|
||||||
settings.wake.system = wake_word_system
|
|
||||||
settings.save()
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def select_wake_word(settings: Settings) -> None:
|
|
||||||
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
|
|
||||||
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
|
|
||||||
if not oww_dir.exists():
|
|
||||||
msgbox("openWakeWord is not installed")
|
|
||||||
return
|
|
||||||
|
|
||||||
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "openWakeWord"
|
|
||||||
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
|
|
||||||
|
|
||||||
while True:
|
|
||||||
ww_paths: Dict[str, Path] = {
|
|
||||||
p.stem: p for p in custom_wake_word_dir.glob("*.tflite")
|
|
||||||
}
|
|
||||||
|
|
||||||
for ww_path in community_wake_word_dir.rglob("*.tflite"):
|
|
||||||
ww_name = ww_path.stem
|
|
||||||
if ww_name in ww_paths:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ww_paths[ww_name] = ww_path
|
|
||||||
|
|
||||||
for ww_path in (oww_dir / "wyoming_openwakeword" / "models").glob(
|
|
||||||
"*.tflite"
|
|
||||||
):
|
|
||||||
if not re.match("^.+_v[0-9].*$", ww_path.stem):
|
|
||||||
continue
|
|
||||||
|
|
||||||
ww_name = ww_path.stem.rsplit("_", maxsplit=1)[0]
|
|
||||||
if ww_name in ww_paths:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ww_paths[ww_name] = ww_path
|
|
||||||
|
|
||||||
items = sorted(list(ww_paths.keys()))
|
|
||||||
wake_word = radiolist(
|
|
||||||
"Wake Word:", items, settings.wake.openwakeword.wake_word
|
|
||||||
)
|
|
||||||
if wake_word is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
wake_word_path = ww_paths[wake_word]
|
|
||||||
if wake_word_path.is_relative_to(community_wake_word_dir):
|
|
||||||
# Copy to custom directory
|
|
||||||
shutil.copy(wake_word_path, custom_wake_word_dir)
|
|
||||||
|
|
||||||
settings.wake.openwakeword.wake_word = wake_word
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
if settings.wake.system == WakeWordSystem.PORCUPINE1:
|
|
||||||
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
|
|
||||||
if not porcupine1_dir.exists():
|
|
||||||
msgbox("porcupine1 is not installed")
|
|
||||||
return
|
|
||||||
|
|
||||||
ww_names = sorted(
|
|
||||||
list(
|
|
||||||
set(
|
|
||||||
p.stem.rsplit("_", maxsplit=1)[0]
|
|
||||||
for p in (
|
|
||||||
porcupine1_dir / "wyoming_porcupine1" / "data" / "resources"
|
|
||||||
).rglob("*.ppn")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
wake_word = radiolist(
|
|
||||||
"Wake Word:", ww_names, settings.wake.porcupine1.wake_word
|
|
||||||
)
|
|
||||||
if wake_word is not None:
|
|
||||||
settings.wake.porcupine1.wake_word = wake_word
|
|
||||||
settings.save()
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
if settings.wake.system == WakeWordSystem.SNOWBOY:
|
|
||||||
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
|
|
||||||
if not snowboy_dir.exists():
|
|
||||||
msgbox("snowboy is not installed")
|
|
||||||
return
|
|
||||||
|
|
||||||
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "snowboy"
|
|
||||||
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
builtin_wake_words = (snowboy_dir / "wyoming_snowboy" / "data").glob("*.umdl")
|
|
||||||
custom_wake_words = itertools.chain(
|
|
||||||
custom_wake_word_dir.glob("*.pmdl"), custom_wake_word_dir.glob("*.umdl")
|
|
||||||
)
|
|
||||||
ww_names = sorted(
|
|
||||||
list(
|
|
||||||
set(
|
|
||||||
p.stem
|
|
||||||
for p in itertools.chain(builtin_wake_words, custom_wake_words)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
wake_word = radiolist("Wake Word:", ww_names, settings.wake.snowboy.wake_word)
|
|
||||||
if wake_word is not None:
|
|
||||||
settings.wake.snowboy.wake_word = wake_word
|
|
||||||
settings.save()
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def configure_openWakeWord(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Wake Word > openWakeWord",
|
|
||||||
[
|
|
||||||
("community", "Download Community Wake Words"),
|
|
||||||
("threshold", "Set Threshold"),
|
|
||||||
("trigger_level", "Set Trigger Level"),
|
|
||||||
],
|
|
||||||
selected_item=choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "community":
|
|
||||||
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
|
|
||||||
|
|
||||||
if not community_wake_word_dir.exists():
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Downloading community wake words...",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"https://github.com/fwartner/home-assistant-wakewords-collection.git",
|
|
||||||
str(community_wake_word_dir),
|
|
||||||
]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
error("downloading community wake words")
|
|
||||||
else:
|
|
||||||
success = run_with_gauge(
|
|
||||||
"Updating community wake words...",
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"-C",
|
|
||||||
str(community_wake_word_dir),
|
|
||||||
"pull",
|
|
||||||
"origin",
|
|
||||||
"main",
|
|
||||||
]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
error("updating community wake words")
|
|
||||||
elif choice == "threshold":
|
|
||||||
while True:
|
|
||||||
threshold = inputbox(
|
|
||||||
"Threshold (0-1, 0.5 = default):",
|
|
||||||
settings.wake.openwakeword.threshold,
|
|
||||||
)
|
|
||||||
if threshold is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
threshold_float = float(threshold)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 0 < threshold_float < 1:
|
|
||||||
settings.wake.openwakeword.threshold = threshold_float
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Threshold must be in (0, 1)")
|
|
||||||
elif choice == "trigger_level":
|
|
||||||
while True:
|
|
||||||
trigger_level = inputbox(
|
|
||||||
"Trigger Level (> 0, 1 = default):",
|
|
||||||
settings.wake.openwakeword.trigger_level,
|
|
||||||
)
|
|
||||||
if trigger_level is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
trigger_level_int = int(trigger_level)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if trigger_level_int > 0:
|
|
||||||
settings.wake.openwakeword.trigger_level = trigger_level_int
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Trigger level must be > 0")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def configure_porcupine1(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Wake Word > porcupine1",
|
|
||||||
[
|
|
||||||
("sensitivity", "Set Sensitivity"),
|
|
||||||
],
|
|
||||||
selected_item=choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "sensitivity":
|
|
||||||
while True:
|
|
||||||
sensitivity = inputbox(
|
|
||||||
"Sensitivity (0-1, 0.5 = default):",
|
|
||||||
settings.wake.porcupine1.sensitivity,
|
|
||||||
)
|
|
||||||
if sensitivity is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
sensitivity_float = float(sensitivity)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 0 < sensitivity_float < 1:
|
|
||||||
settings.wake.porcupine1.sensitivity = sensitivity_float
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Sensitivity must be in (0, 1)")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def configure_snowboy(settings: Settings) -> None:
|
|
||||||
choice: Optional[str] = None
|
|
||||||
while True:
|
|
||||||
choice = menu(
|
|
||||||
"Main > Wake Word > snowboy",
|
|
||||||
[
|
|
||||||
("sensitivity", "Set Sensitivity"),
|
|
||||||
],
|
|
||||||
selected_item=choice,
|
|
||||||
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if choice == "sensitivity":
|
|
||||||
while True:
|
|
||||||
sensitivity = inputbox(
|
|
||||||
"Sensitivity (0-1, 0.5 = default):",
|
|
||||||
settings.wake.snowboy.sensitivity,
|
|
||||||
)
|
|
||||||
if sensitivity is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
sensitivity_float = float(sensitivity)
|
|
||||||
except ValueError:
|
|
||||||
msgbox("Invalid value")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 0 < sensitivity_float < 1:
|
|
||||||
settings.wake.snowboy.sensitivity = sensitivity_float
|
|
||||||
settings.save()
|
|
||||||
break
|
|
||||||
|
|
||||||
msgbox("Sensitivity must be in (0, 1)")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
"""Python interface to whiptail command."""
|
|
||||||
import logging
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
|
|
||||||
|
|
||||||
ItemType = Union[str, Tuple[Any, str]]
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def whiptail(*args) -> Optional[str]:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["whiptail", "--title", TITLE] + list(args),
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
_stdout, stderr = proc.communicate()
|
|
||||||
if proc.returncode != 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return stderr.decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def menu(
|
|
||||||
text: str,
|
|
||||||
items: Sequence[ItemType],
|
|
||||||
selected_item: Optional[str] = None,
|
|
||||||
menu_args: Optional[Sequence[str]] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
assert items, "No items"
|
|
||||||
|
|
||||||
item_map: Dict[Optional[str], str] = {}
|
|
||||||
item_args: List[str] = []
|
|
||||||
selected_tag: Optional[str] = None
|
|
||||||
for i, item in enumerate(items):
|
|
||||||
item_id = str(i)
|
|
||||||
item_args.append(item_id)
|
|
||||||
|
|
||||||
if isinstance(item, str):
|
|
||||||
item_map[item_id] = item
|
|
||||||
item_args.append(item)
|
|
||||||
|
|
||||||
if selected_item == item:
|
|
||||||
selected_tag = item_id
|
|
||||||
else:
|
|
||||||
item_map[item_id] = item[0]
|
|
||||||
item_args.append(item[1])
|
|
||||||
|
|
||||||
if selected_item == item[0]:
|
|
||||||
selected_tag = item_id
|
|
||||||
|
|
||||||
menu_args = list(menu_args) if menu_args is not None else []
|
|
||||||
if selected_tag is not None:
|
|
||||||
menu_args.extend(["--default-item", selected_tag])
|
|
||||||
|
|
||||||
result = whiptail(
|
|
||||||
"--notags", *menu_args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
|
||||||
)
|
|
||||||
return item_map.get(result)
|
|
||||||
|
|
||||||
|
|
||||||
def inputbox(text: str, init: Optional[Any] = None) -> Optional[str]:
|
|
||||||
return whiptail(
|
|
||||||
"--inputbox", text, HEIGHT, WIDTH, str(init) if init is not None else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def passwordbox(text: str) -> Optional[str]:
|
|
||||||
return whiptail("--passwordbox", text, HEIGHT, WIDTH)
|
|
||||||
|
|
||||||
|
|
||||||
def radiolist(
|
|
||||||
text: str,
|
|
||||||
items: Sequence[ItemType],
|
|
||||||
selected_item: Any,
|
|
||||||
*args,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
assert items, "No items"
|
|
||||||
|
|
||||||
item_map: Dict[str, ItemType] = {}
|
|
||||||
item_args: List[str] = []
|
|
||||||
for i, item in enumerate(items):
|
|
||||||
item_id = str(i)
|
|
||||||
item_args.append(item_id)
|
|
||||||
|
|
||||||
if isinstance(item, str):
|
|
||||||
item_map[item_id] = item
|
|
||||||
item_args.append(item)
|
|
||||||
|
|
||||||
if item == selected_item:
|
|
||||||
item_args.append("1")
|
|
||||||
else:
|
|
||||||
item_args.append("0")
|
|
||||||
else:
|
|
||||||
item_map[item_id] = item[0]
|
|
||||||
item_args.append(item[1])
|
|
||||||
|
|
||||||
if item[0] == selected_item:
|
|
||||||
item_args.append("1")
|
|
||||||
else:
|
|
||||||
item_args.append("0")
|
|
||||||
|
|
||||||
result = whiptail(
|
|
||||||
"--notags", *args, "--radiolist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
|
||||||
)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return item_map.get(result, result)
|
|
||||||
|
|
||||||
|
|
||||||
def checklist(
|
|
||||||
text: str,
|
|
||||||
items: Sequence[ItemType],
|
|
||||||
selected_items: Sequence[Any],
|
|
||||||
*args,
|
|
||||||
) -> Optional[List[Any]]:
|
|
||||||
assert items, "No items"
|
|
||||||
|
|
||||||
item_map: Dict[str, ItemType] = {}
|
|
||||||
item_args: List[str] = []
|
|
||||||
for i, item in enumerate(items):
|
|
||||||
item_id = str(i)
|
|
||||||
item_args.append(item_id)
|
|
||||||
|
|
||||||
if isinstance(item, str):
|
|
||||||
item_map[item_id] = item
|
|
||||||
item_args.append(item)
|
|
||||||
|
|
||||||
if item in selected_items:
|
|
||||||
item_args.append("1")
|
|
||||||
else:
|
|
||||||
item_args.append("0")
|
|
||||||
else:
|
|
||||||
item_map[item_id] = item[0]
|
|
||||||
item_args.append(item[1])
|
|
||||||
|
|
||||||
if item[0] in selected_items:
|
|
||||||
item_args.append("1")
|
|
||||||
else:
|
|
||||||
item_args.append("0")
|
|
||||||
|
|
||||||
result = whiptail(
|
|
||||||
"--notags", *args, "--checklist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
|
||||||
)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return [
|
|
||||||
item_map.get(result_item, result_item) for result_item in shlex.split(result)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def yesno(text: str) -> bool:
|
|
||||||
return whiptail("--yesno", text, HEIGHT, WIDTH) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def msgbox(text: str) -> None:
|
|
||||||
whiptail("--msgbox", text, HEIGHT, WIDTH)
|
|
||||||
|
|
||||||
|
|
||||||
def gauge(text: str, seconds: int, parts: int = 20) -> None:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert proc.stdin is not None
|
|
||||||
|
|
||||||
percent = 0
|
|
||||||
while percent <= 100:
|
|
||||||
time.sleep(seconds / parts)
|
|
||||||
percent += int(100 / parts)
|
|
||||||
print(percent, file=proc.stdin, flush=True)
|
|
||||||
|
|
||||||
proc.communicate()
|
|
||||||
|
|
||||||
|
|
||||||
def error(reason: str) -> None:
|
|
||||||
msgbox(
|
|
||||||
f"An error occurred while {reason}.\n" "See local/installer.log for details."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def run_with_gauge(
|
|
||||||
text: str, commands: Sequence[Sequence[str]], sudo_password: Optional[str] = None
|
|
||||||
) -> bool:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert proc.stdin is not None
|
|
||||||
percent = 0
|
|
||||||
seconds = 5
|
|
||||||
parts = 20
|
|
||||||
|
|
||||||
with ThreadPoolExecutor() as executor:
|
|
||||||
for command in commands:
|
|
||||||
future = executor.submit(_run_command, command, sudo_password)
|
|
||||||
while not future.done():
|
|
||||||
time.sleep(seconds / parts)
|
|
||||||
percent += int(100 / parts)
|
|
||||||
if percent > 100:
|
|
||||||
percent = 0
|
|
||||||
|
|
||||||
print(percent, file=proc.stdin, flush=True)
|
|
||||||
|
|
||||||
if not future.result():
|
|
||||||
# Error occurred
|
|
||||||
return False
|
|
||||||
|
|
||||||
proc.communicate()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _run_command(command: Sequence[str], sudo_password: Optional[str] = None) -> bool:
|
|
||||||
try:
|
|
||||||
assert command
|
|
||||||
proc_input: Optional[str] = None
|
|
||||||
if (command[0] == "sudo") and (sudo_password is not None):
|
|
||||||
proc_input = sudo_password
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
_stdout, stderr = proc.communicate(proc_input)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
_LOGGER.error("Error running command: %s", command)
|
|
||||||
_LOGGER.error(stderr)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Error running command: %s", command)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[mypy]
|
|
||||||
ignore_missing_imports = true
|
|
||||||
|
|
||||||
[mypy-setuptools.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[MESSAGES CONTROL]
|
|
||||||
disable=
|
|
||||||
format,
|
|
||||||
abstract-method,
|
|
||||||
cyclic-import,
|
|
||||||
duplicate-code,
|
|
||||||
global-statement,
|
|
||||||
import-outside-toplevel,
|
|
||||||
inconsistent-return-statements,
|
|
||||||
locally-disabled,
|
|
||||||
not-context-manager,
|
|
||||||
too-few-public-methods,
|
|
||||||
too-many-arguments,
|
|
||||||
too-many-branches,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
too-many-lines,
|
|
||||||
too-many-locals,
|
|
||||||
too-many-public-methods,
|
|
||||||
too-many-return-statements,
|
|
||||||
too-many-statements,
|
|
||||||
too-many-boolean-expressions,
|
|
||||||
unnecessary-pass,
|
|
||||||
unused-argument,
|
|
||||||
broad-except,
|
|
||||||
too-many-nested-blocks,
|
|
||||||
invalid-name,
|
|
||||||
unused-import,
|
|
||||||
fixme,
|
|
||||||
useless-super-delegation,
|
|
||||||
missing-module-docstring,
|
|
||||||
missing-class-docstring,
|
|
||||||
missing-function-docstring,
|
|
||||||
import-error,
|
|
||||||
consider-using-with
|
|
||||||
|
|
||||||
[FORMAT]
|
|
||||||
expected-line-ending-format=LF
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=62.3"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "wyoming_satellite"
|
|
||||||
version = "1.4.1"
|
|
||||||
license = {text = "MIT"}
|
|
||||||
description = "Wyoming server for remote voice satellite"
|
|
||||||
readme = "README.md"
|
|
||||||
authors = [
|
|
||||||
{name = "Michael Hansen", email = "mike@rhasspy.org"}
|
|
||||||
]
|
|
||||||
keywords = ["rhasspy", "wyoming", "satellite"]
|
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 3 - Alpha",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
]
|
|
||||||
requires-python = ">=3.9.0"
|
|
||||||
dependencies = [
|
|
||||||
"wyoming==1.5.4",
|
|
||||||
"zeroconf==0.88.0",
|
|
||||||
"pyring-buffer>=1,<2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
all = ["pysilero-vad==1.0.0", "webrtc-noise-gain==1.2.3"]
|
|
||||||
silerovad = ["pysilero-vad==1.0.0"]
|
|
||||||
webrtc = ["webrtc-noise-gain==1.2.3"]
|
|
||||||
respeaker = ["gpiozero", "spidev"]
|
|
||||||
dev = [
|
|
||||||
"black==22.12.0",
|
|
||||||
"flake8==6.0.0",
|
|
||||||
"isort==5.11.3",
|
|
||||||
"mypy==0.991",
|
|
||||||
"pylint==2.15.9",
|
|
||||||
"pytest==7.4.3",
|
|
||||||
"pytest-asyncio==0.23.3",
|
|
||||||
"build==1.2.2"
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
wyoming-satellite = "wyoming_satellite:__main__.run"
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
"Source Code" = "http://github.com/rhasspy/wyoming-satellite"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
platforms = ["any"]
|
|
||||||
zip-safe = true
|
|
||||||
include-package-data = true
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
include = ["wyoming_satellite*"]
|
|
||||||
exclude = ["tests", "tests.*"]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
certifi==2026.4.22
|
|
||||||
cffi==2.0.0
|
|
||||||
charset-normalizer==3.4.7
|
|
||||||
flatbuffers==20181003210633
|
|
||||||
idna==3.15
|
|
||||||
ifaddr==0.2.0
|
|
||||||
importlib_resources==7.1.0
|
|
||||||
libusb-package==1.0.26.3
|
|
||||||
numpy==1.25.2
|
|
||||||
onnxruntime==1.26.0
|
|
||||||
packaging==26.2
|
|
||||||
paho-mqtt==2.1.0
|
|
||||||
protobuf==7.34.1
|
|
||||||
psutil==7.2.2
|
|
||||||
pycparser==3.0
|
|
||||||
pyring_buffer==1.1.0
|
|
||||||
pysilero-vad==1.0.0
|
|
||||||
pyusb==1.3.1
|
|
||||||
PyYAML==6.0.3
|
|
||||||
requests==2.34.0
|
|
||||||
srt==3.5.3
|
|
||||||
tqdm==4.67.3
|
|
||||||
urllib3==2.7.0
|
|
||||||
vosk==0.3.45
|
|
||||||
webrtc-noise-gain==1.2.3
|
|
||||||
webrtcvad==2.0.10
|
|
||||||
websockets==16.0
|
|
||||||
wyoming==1.5.4
|
|
||||||
-e git+ssh://gitea@192.168.200.208/sirmrak/satellite.git@8f2ff703db66aab33d0f8f43919a1ba5e92d5439#egg=wyoming_satellite
|
|
||||||
zeroconf==0.88.0
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
|
||||||
_TESTS_DIR = _PROGRAM_DIR / "tests"
|
|
||||||
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
|
|
||||||
_EXAMPLES_DIR = _PROGRAM_DIR / "examples"
|
|
||||||
|
|
||||||
_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR]
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
|
||||||
_TESTS_DIR = _PROGRAM_DIR / "tests"
|
|
||||||
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
|
|
||||||
|
|
||||||
_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR]
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "build", "--wheel", "--sdist"])
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "wyoming_satellite"] + sys.argv[1:])
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call(
|
|
||||||
[context.env_exe, str(_PROGRAM_DIR / "examples" / "2mic_service.py")]
|
|
||||||
+ sys.argv[1:]
|
|
||||||
)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call(
|
|
||||||
[context.env_exe, str(_PROGRAM_DIR / "examples" / "4mic_service.py")]
|
|
||||||
+ sys.argv[1:]
|
|
||||||
)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call(
|
|
||||||
[context.env_exe, str(_PROGRAM_DIR / "examples" / "usbmic_service.py")]
|
|
||||||
+ sys.argv[1:]
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
builder = venv.EnvBuilder(with_pip=True)
|
|
||||||
context = builder.ensure_directories(_VENV_DIR)
|
|
||||||
builder.create(_VENV_DIR)
|
|
||||||
|
|
||||||
# Upgrade dependencies
|
|
||||||
pip = [context.env_exe, "-m", "pip"]
|
|
||||||
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
|
||||||
|
|
||||||
# Install requirements
|
|
||||||
subprocess.check_call(
|
|
||||||
pip
|
|
||||||
+ [
|
|
||||||
"install",
|
|
||||||
"--extra-index-url",
|
|
||||||
"https://www.piwheels.org/simple",
|
|
||||||
"-f",
|
|
||||||
"https://synesthesiam.github.io/prebuilt-apps/",
|
|
||||||
"-e",
|
|
||||||
str(_PROGRAM_DIR),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.dev:
|
|
||||||
# Install dev requirements
|
|
||||||
subprocess.check_call(
|
|
||||||
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
|
|
||||||
)
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
_PROGRAM_DIR = _DIR.parent
|
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
_TEST_DIR = _PROGRAM_DIR / "tests"
|
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
|
||||||
subprocess.check_call([context.env_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Глобальный wrapper для Python из venv Satellite
|
|
||||||
# Используется в systemd сервисах
|
|
||||||
#
|
|
||||||
|
|
||||||
VENV_PATH="/home/sirmrak/projects/.venv_satellite"
|
|
||||||
|
|
||||||
# Проверяем существование venv
|
|
||||||
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
|
|
||||||
echo "❌ VENV не найден: ${VENV_PATH}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "${VENV_PATH}/bin/python3" "$@"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Быстрый запуск TEST satellite в tmux
|
|
||||||
# Не требует установки systemd сервисов
|
|
||||||
# Production сервисы (wake word, event) должны быть запущены отдельно
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
VENV_PATH="${SCRIPT_DIR}/.venv"
|
|
||||||
PYTHON="${VENV_PATH}/bin/python3"
|
|
||||||
|
|
||||||
# Проверка venv
|
|
||||||
if [ ! -f "$PYTHON" ]; then
|
|
||||||
echo "❌ Виртуальное окружение не найдено: $VENV_PATH"
|
|
||||||
echo "💡 Запустите: cd ${SCRIPT_DIR} && ./setup_dev.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🚀 Запуск TEST satellite в tmux..."
|
|
||||||
echo "=================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Проверка tmux
|
|
||||||
if ! command -v tmux &> /dev/null; then
|
|
||||||
echo "❌ tmux не найден!"
|
|
||||||
echo "💡 Установите: sudo apt-get install tmux"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Очистка старой сессии если существует
|
|
||||||
tmux kill-session -t satellite-test 2>/dev/null || true
|
|
||||||
|
|
||||||
# Создание новой сессии
|
|
||||||
echo "📡 Создание сессии 'satellite-test'..."
|
|
||||||
|
|
||||||
# Satellite (порт 10702 + WebSocket)
|
|
||||||
tmux new-session -d -s satellite-test -n "satellite" \
|
|
||||||
"cd ${SCRIPT_DIR} && source ${VENV_PATH}/bin/activate && \
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--uri tcp://0.0.0.0:10702 \
|
|
||||||
--name 'Wyoming Satellite Test' \
|
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--event-uri tcp://127.0.0.1:10500 \
|
|
||||||
--wake-uri tcp://127.0.0.1:10400 \
|
|
||||||
--wake-word-name 'привет дройд' \
|
|
||||||
--websocket-enabled \
|
|
||||||
--websocket-port 8675 \
|
|
||||||
--audio-level-enabled \
|
|
||||||
--audio-level-interval-ms 100 \
|
|
||||||
--debug"
|
|
||||||
|
|
||||||
echo " ✅ Satellite запущен (порт 10702 + WebSocket :8675)"
|
|
||||||
echo ""
|
|
||||||
echo "💡 Production сервисы должны быть запущены:"
|
|
||||||
echo " - Wake Word (:10400): sudo systemctl status wyoming-wakeword-vosk.service"
|
|
||||||
echo " - Event (:10500): sudo systemctl status wyoming-event.service"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Возврат к окну
|
|
||||||
tmux select-window -t satellite-test:0
|
|
||||||
|
|
||||||
echo "✅ Запуск завершён!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Сессия tmux: 'satellite-test'"
|
|
||||||
echo ""
|
|
||||||
echo "🎮 Управление tmux:"
|
|
||||||
echo " tmux attach -t satellite-test # Подключиться"
|
|
||||||
echo " tmux kill-session -t satellite-test # Остановить"
|
|
||||||
echo ""
|
|
||||||
echo "🌐 WebSocket:"
|
|
||||||
echo " ws://127.0.0.1:8675"
|
|
||||||
echo ""
|
|
||||||
echo "💡 Пример подключения:"
|
|
||||||
echo " source ${VENV_PATH}/bin/activate"
|
|
||||||
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
|
|
||||||
echo ""
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Wyoming Satellite Test (dev branch)
|
|
||||||
Requires=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__CURRENT_USER__
|
|
||||||
WorkingDirectory=__SAT_BASE_DIR__
|
|
||||||
|
|
||||||
# Запуск satellite с WebSocket и Audio Level
|
|
||||||
# Порт 10702 чтобы не конфликтовать с production (10700) и старым led (10701)
|
|
||||||
# Для wake word и event используются production сервисы (:10400, :10500)
|
|
||||||
ExecStart=__SAT_VENV__/bin/python3 -m wyoming_satellite \
|
|
||||||
--uri tcp://0.0.0.0:10702 \
|
|
||||||
--name 'Wyoming Satellite Test' \
|
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
|
|
||||||
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
|
|
||||||
--event-uri tcp://127.0.0.1:10500 \
|
|
||||||
--wake-uri tcp://127.0.0.1:10400 \
|
|
||||||
--wake-word-name "привет дройд" \
|
|
||||||
--websocket-enabled \
|
|
||||||
--websocket-port 8675 \
|
|
||||||
--audio-level-enabled \
|
|
||||||
--audio-level-interval-ms 100 \
|
|
||||||
--debug
|
|
||||||
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Wyoming Event Service with LED control
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
|
|
||||||
# 🔹 Запуск от имени вашего пользователя
|
|
||||||
User=sirmrak
|
|
||||||
Group=sirmrak
|
|
||||||
|
|
||||||
# 🔹 Рабочая директория — где лежит wyoming-led.py
|
|
||||||
WorkingDirectory=__SAT_BASE_DIR__/examples
|
|
||||||
|
|
||||||
# 🔹 Запуск через wrapper (путь подставляется при установке)
|
|
||||||
ExecStart=satellite-python __SAT_BASE_DIR__/examples/wyoming-led.py --host 127.0.0.1 --port 10500
|
|
||||||
|
|
||||||
# 🔹 Критически важно: передаём окружение
|
|
||||||
Environment="HOME=__HOME_DIR__"
|
|
||||||
Environment="USER=__CURRENT_USER__"
|
|
||||||
Environment="PATH=__SAT_VENV__/bin:/usr/local/bin:/usr/bin:/bin"
|
|
||||||
Environment="VIRTUAL_ENV=__SAT_VENV__"
|
|
||||||
|
|
||||||
# 🔹 Разрешаем доступ к устройству (на всякий случай)
|
|
||||||
PrivateDevices=false
|
|
||||||
|
|
||||||
# 🔹 Перезапуск при ошибке
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
# 🔹 Логирование в journal
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=wyoming-event
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Wyoming Satellite Service (Wake Mode)
|
|
||||||
Requires=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__CURRENT_USER__
|
|
||||||
WorkingDirectory=__SAT_BASE_DIR__
|
|
||||||
ExecStart=__SAT_BASE_DIR__/start_satellite.sh
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=voskWakeWord
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__CURRENT_USER__
|
|
||||||
ExecStart=satellite-python __SAT_BASE_DIR__/local/vosk-wake-dev/script/run \
|
|
||||||
--uri tcp://0.0.0.0:10400 \
|
|
||||||
--model-path /home/__CURRENT_USER__/projects/models/vosk-model-small-ru-0.22 \
|
|
||||||
--rate 16000 \
|
|
||||||
--wake-word "привет дройд"
|
|
||||||
|
|
||||||
WorkingDirectory=__SAT_BASE_DIR__/local/vosk-wake-dev
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[flake8]
|
|
||||||
# To work with Black
|
|
||||||
max-line-length = 88
|
|
||||||
# E501: line too long
|
|
||||||
# W503: Line break occurred before a binary operator
|
|
||||||
# E203: Whitespace before ':'
|
|
||||||
# D202 No blank lines allowed after function docstring
|
|
||||||
# W504 line break after binary operator
|
|
||||||
ignore =
|
|
||||||
E501,
|
|
||||||
W503,
|
|
||||||
E203,
|
|
||||||
D202,
|
|
||||||
W504
|
|
||||||
|
|
||||||
[isort]
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
use_parentheses=True
|
|
||||||
line_length=88
|
|
||||||
indent = " "
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
VENV_PATH="${SCRIPT_DIR}/.venv"
|
|
||||||
|
|
||||||
echo " Настройка Python окружения..."
|
|
||||||
|
|
||||||
# Проверка Python
|
|
||||||
if ! command -v python3 &> /dev/null; then
|
|
||||||
echo "❌ Python3 не найден!" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PYTHON_VERSION=$(python3 --version)
|
|
||||||
echo "✅ ${PYTHON_VERSION}"
|
|
||||||
|
|
||||||
# Создание venv
|
|
||||||
if [ -d "${VENV_PATH}" ]; then
|
|
||||||
echo "⚠️ .venv/ уже существует, пропускаю создание"
|
|
||||||
else
|
|
||||||
echo "📦 Создание виртуального окружения..."
|
|
||||||
python3 -m venv "${VENV_PATH}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Активация и установка зависимостей
|
|
||||||
echo "📥 Установка зависимостей..."
|
|
||||||
"${VENV_PATH}/bin/pip" install --upgrade pip
|
|
||||||
"${VENV_PATH}/bin/pip" install -r "${SCRIPT_DIR}/requirements.txt"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Готово!"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Запуск:"
|
|
||||||
echo " ./start_satellite.sh [wake|vad]"
|
|
||||||
echo ""
|
|
||||||
echo "🔧 Активация вручную:"
|
|
||||||
echo " source .venv/bin/activate"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
|
|
||||||
# Путь к Python-окружению
|
|
||||||
VENV_PATH=""
|
|
||||||
for candidate in \
|
|
||||||
"${HOME}/projects/.venv_satellite" \
|
|
||||||
"${HOME}/.venv_satellite" \
|
|
||||||
"${SCRIPT_DIR}/.venv" \
|
|
||||||
"${SCRIPT_DIR}/.venv_satellite"
|
|
||||||
do
|
|
||||||
if [ -f "${candidate}/bin/python3" ]; then
|
|
||||||
VENV_PATH="${candidate}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$VENV_PATH" ]; then
|
|
||||||
echo "❌ Виртуальное окружение не найдено!" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PY="${VENV_PATH}/bin/python3"
|
|
||||||
LED_SCRIPT="${SCRIPT_DIR}/examples/wyoming-led.py"
|
|
||||||
|
|
||||||
echo "🚀 Запуск LED сервиса..."
|
|
||||||
echo "📡 Wyoming Satellite: tcp://0.0.0.0:10701"
|
|
||||||
echo "📡 MQTT Broker: 192.168.200.100:1883"
|
|
||||||
|
|
||||||
exec "$PY" "$LED_SCRIPT" --host 0.0.0.0 --port 10701
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Делаем текущий проект доступным для Python
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
export PYTHONPATH="${SCRIPT_DIR}"
|
|
||||||
|
|
||||||
# Путь к Python-окружению
|
|
||||||
# Приоритет: .venv/ в корне проекта (локальное окружение)
|
|
||||||
VENV_PATH="${SCRIPT_DIR}/.venv"
|
|
||||||
|
|
||||||
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
|
|
||||||
echo "❌ Виртуальное окружение не найдено: ${VENV_PATH}" >&2
|
|
||||||
echo "💡 Создайте: python3 -m venv ${VENV_PATH} && source ${VENV_PATH}/bin/activate && pip install -r requirements.txt" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PY="${VENV_PATH}/bin/python3"
|
|
||||||
|
|
||||||
# Режим запуска: wake|vad (по умолчанию wake)
|
|
||||||
MODE="${1:-wake}"
|
|
||||||
|
|
||||||
BASE_ARGS=(
|
|
||||||
--debug
|
|
||||||
--name 'Wyoming Pi Vosk Satellite'
|
|
||||||
--uri tcp://0.0.0.0:10700
|
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
|
|
||||||
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
|
|
||||||
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
|
|
||||||
#--mic-noise-suppression 2
|
|
||||||
#--mic-auto-gain 20
|
|
||||||
--event-uri tcp://127.0.0.1:10500
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$MODE" == "wake" ]]; then
|
|
||||||
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
|
|
||||||
EXTRA_ARGS=(
|
|
||||||
--wake-uri tcp://127.0.0.1:10400
|
|
||||||
--wake-word-name "привет дроид" \
|
|
||||||
#--wake-word-name "привет придурок" \
|
|
||||||
#--wake-word-name "привет жопа с ушами"
|
|
||||||
# Добавьте дополнительные фразы при необходимости:
|
|
||||||
# --wake-word-name "привет дурик" \
|
|
||||||
# --wake-word-name "привет говнюк"
|
|
||||||
)
|
|
||||||
elif [[ "$MODE" == "vad" ]]; then
|
|
||||||
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
|
|
||||||
EXTRA_ARGS=(
|
|
||||||
--vad
|
|
||||||
--vad-system webrtc
|
|
||||||
--vad-trigger-level 5 # ≈100мс при 20мс окне
|
|
||||||
--vad-energy-threshold 200
|
|
||||||
--vad-buffer-seconds 1
|
|
||||||
--vad-wake-word-timeout 5
|
|
||||||
)
|
|
||||||
else
|
|
||||||
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Делаем текущий проект доступным для Python
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
export PYTHONPATH="${SCRIPT_DIR}"
|
|
||||||
|
|
||||||
# Путь к Python-окружению
|
|
||||||
PY="/home/sirmrak/projects/.venv_satellite/bin/python"
|
|
||||||
|
|
||||||
# Режим запуска: wake|vad (по умолчанию wake)
|
|
||||||
MODE="${1:-wake}"
|
|
||||||
|
|
||||||
BASE_ARGS=(
|
|
||||||
--debug
|
|
||||||
--name 'Wyoming Pi Satellite Test'
|
|
||||||
--uri tcp://0.0.0.0:10701
|
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
|
|
||||||
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
|
|
||||||
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
|
|
||||||
#--mic-noise-suppression 2
|
|
||||||
#--mic-auto-gain 20
|
|
||||||
--event-uri tcp://127.0.0.1:10500
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$MODE" == "wake" ]]; then
|
|
||||||
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
|
|
||||||
EXTRA_ARGS=(
|
|
||||||
--wake-uri tcp://127.0.0.1:10400
|
|
||||||
#--wake-word-name "привет дроид" \
|
|
||||||
--wake-word-name "привет придурок" \
|
|
||||||
#--wake-word-name "привет жопа с ушами"
|
|
||||||
# Добавьте дополнительные фразы при необходимости:
|
|
||||||
# --wake-word-name "привет дурик" \
|
|
||||||
# --wake-word-name "привет говнюк"
|
|
||||||
)
|
|
||||||
elif [[ "$MODE" == "vad" ]]; then
|
|
||||||
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
|
|
||||||
EXTRA_ARGS=(
|
|
||||||
--vad
|
|
||||||
--vad-system webrtc
|
|
||||||
--vad-trigger-level 5 # ≈100мс при 20мс окне
|
|
||||||
--vad-energy-threshold 200
|
|
||||||
--vad-buffer-seconds 1
|
|
||||||
--vad-wake-word-timeout 5
|
|
||||||
)
|
|
||||||
else
|
|
||||||
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Wyoming satellite tests."""
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Shared code for Wyoming satellite tests."""
|
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
|
||||||
from wyoming.client import AsyncClient
|
|
||||||
from wyoming.event import Event
|
|
||||||
|
|
||||||
AUDIO_START = AudioStart(rate=16000, width=2, channels=1)
|
|
||||||
AUDIO_STOP = AudioStop()
|
|
||||||
|
|
||||||
AUDIO_CHUNK = AudioChunk(
|
|
||||||
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeStreamReaderWriter:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._undrained_data = bytes()
|
|
||||||
self._value = bytes()
|
|
||||||
self._data_ready = asyncio.Event()
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> None:
|
|
||||||
self._undrained_data += data
|
|
||||||
|
|
||||||
def writelines(self, data: Iterable[bytes]) -> None:
|
|
||||||
for line in data:
|
|
||||||
self.write(line)
|
|
||||||
|
|
||||||
async def drain(self) -> None:
|
|
||||||
self._value += self._undrained_data
|
|
||||||
self._undrained_data = bytes()
|
|
||||||
self._data_ready.set()
|
|
||||||
self._data_ready.clear()
|
|
||||||
|
|
||||||
async def readline(self) -> bytes:
|
|
||||||
while b"\n" not in self._value:
|
|
||||||
await self._data_ready.wait()
|
|
||||||
|
|
||||||
with io.BytesIO(self._value) as value_io:
|
|
||||||
data = value_io.readline()
|
|
||||||
self._value = self._value[len(data) :]
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def readexactly(self, n: int) -> bytes:
|
|
||||||
while len(self._value) < n:
|
|
||||||
await self._data_ready.wait()
|
|
||||||
|
|
||||||
data = self._value[:n]
|
|
||||||
self._value = self._value[n:]
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class MicClient(AsyncClient):
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
# Send 30ms of audio every 30ms
|
|
||||||
await asyncio.sleep(AUDIO_CHUNK.seconds)
|
|
||||||
return AUDIO_CHUNK.event()
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
# Output only
|
|
||||||
pass
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Final, Optional
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from wyoming.asr import Transcript
|
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
|
||||||
from wyoming.client import AsyncClient
|
|
||||||
from wyoming.event import Event, async_read_event
|
|
||||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
|
||||||
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
|
|
||||||
from wyoming.tts import Synthesize
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
|
|
||||||
from wyoming_satellite import (
|
|
||||||
EventSettings,
|
|
||||||
MicSettings,
|
|
||||||
SatelliteSettings,
|
|
||||||
SndSettings,
|
|
||||||
WakeSettings,
|
|
||||||
WakeStreamingSatellite,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .shared import (
|
|
||||||
AUDIO_CHUNK,
|
|
||||||
AUDIO_START,
|
|
||||||
AUDIO_STOP,
|
|
||||||
FakeStreamReaderWriter,
|
|
||||||
MicClient,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
TIMEOUT: Final = 1
|
|
||||||
|
|
||||||
|
|
||||||
class WakeClient(AsyncClient):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._event_ready = asyncio.Event()
|
|
||||||
self._event: Optional[Event] = None
|
|
||||||
self._detected: bool = False
|
|
||||||
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
await self._event_ready.wait()
|
|
||||||
self._event_ready.clear()
|
|
||||||
return self._event
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
if AudioChunk.is_type(event.type):
|
|
||||||
if not self._detected:
|
|
||||||
self._detected = True
|
|
||||||
self._event = Detection().event()
|
|
||||||
self._event_ready.set()
|
|
||||||
|
|
||||||
|
|
||||||
class SndClient(AsyncClient):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.synthesize = asyncio.Event()
|
|
||||||
self.audio_start = asyncio.Event()
|
|
||||||
self.audio_chunk = asyncio.Event()
|
|
||||||
self.audio_stop = asyncio.Event()
|
|
||||||
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
# Input only
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
if AudioChunk.is_type(event.type):
|
|
||||||
self.audio_chunk.set()
|
|
||||||
elif Synthesize.is_type(event.type):
|
|
||||||
self.synthesize.set()
|
|
||||||
elif AudioStart.is_type(event.type):
|
|
||||||
self.audio_start.set()
|
|
||||||
elif AudioStop.is_type(event.type):
|
|
||||||
self.audio_stop.set()
|
|
||||||
|
|
||||||
|
|
||||||
class EventClient(AsyncClient):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.detection = asyncio.Event()
|
|
||||||
self.streaming_started = asyncio.Event()
|
|
||||||
self.streaming_stopped = asyncio.Event()
|
|
||||||
self.transcript = asyncio.Event()
|
|
||||||
self.synthesize = asyncio.Event()
|
|
||||||
self.audio_start = asyncio.Event()
|
|
||||||
self.audio_chunk = asyncio.Event()
|
|
||||||
self.audio_stop = asyncio.Event()
|
|
||||||
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
# Input only
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
if Detection.is_type(event.type):
|
|
||||||
self.detection.set()
|
|
||||||
elif StreamingStarted.is_type(event.type):
|
|
||||||
self.streaming_started.set()
|
|
||||||
elif StreamingStopped.is_type(event.type):
|
|
||||||
self.streaming_stopped.set()
|
|
||||||
elif Transcript.is_type(event.type):
|
|
||||||
self.transcript.set()
|
|
||||||
elif Synthesize.is_type(event.type):
|
|
||||||
self.synthesize.set()
|
|
||||||
elif AudioChunk.is_type(event.type):
|
|
||||||
self.audio_chunk.set()
|
|
||||||
elif AudioStart.is_type(event.type):
|
|
||||||
self.audio_start.set()
|
|
||||||
elif AudioStop.is_type(event.type):
|
|
||||||
self.audio_stop.set()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wake_satellite() -> None:
|
|
||||||
mic_client = MicClient()
|
|
||||||
snd_client = SndClient()
|
|
||||||
wake_client = WakeClient()
|
|
||||||
event_client = EventClient()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
|
|
||||||
return_value=mic_client,
|
|
||||||
), patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
|
|
||||||
return_value=snd_client,
|
|
||||||
), patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
|
|
||||||
return_value=wake_client,
|
|
||||||
), patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
|
|
||||||
return_value=event_client,
|
|
||||||
):
|
|
||||||
satellite = WakeStreamingSatellite(
|
|
||||||
SatelliteSettings(
|
|
||||||
mic=MicSettings(uri="test"),
|
|
||||||
snd=SndSettings(uri="test"),
|
|
||||||
wake=WakeSettings(uri="test"),
|
|
||||||
event=EventSettings(uri="test"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def event_from_satellite() -> Optional[Event]:
|
|
||||||
return await async_read_event(server_io)
|
|
||||||
|
|
||||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
|
||||||
|
|
||||||
# Fake server connection
|
|
||||||
server_io = FakeStreamReaderWriter()
|
|
||||||
await satellite.set_server("test", server_io) # type: ignore
|
|
||||||
|
|
||||||
# Start satellite
|
|
||||||
await satellite.event_from_server(RunSatellite().event())
|
|
||||||
|
|
||||||
# Trigger detection
|
|
||||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
|
||||||
assert event is not None
|
|
||||||
assert Detection.is_type(event.type), event
|
|
||||||
|
|
||||||
# Pipeline should start
|
|
||||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
|
||||||
assert event is not None
|
|
||||||
assert RunPipeline.is_type(event.type), event
|
|
||||||
run_pipeline = RunPipeline.from_event(event)
|
|
||||||
assert run_pipeline.start_stage == PipelineStage.ASR
|
|
||||||
assert run_pipeline.end_stage == PipelineStage.TTS
|
|
||||||
|
|
||||||
# Event service should have received detection
|
|
||||||
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Server should be receiving audio now
|
|
||||||
assert satellite.is_streaming, "Not streaming"
|
|
||||||
for _ in range(5):
|
|
||||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
|
||||||
assert event is not None
|
|
||||||
assert AudioChunk.is_type(event.type)
|
|
||||||
|
|
||||||
# Event service should have received streaming start
|
|
||||||
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Send transcript
|
|
||||||
await satellite.event_from_server(Transcript(text="test").event())
|
|
||||||
|
|
||||||
# Event service should have received transcript
|
|
||||||
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Wait for streaming to stop
|
|
||||||
while satellite.is_streaming:
|
|
||||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
|
||||||
assert event is not None
|
|
||||||
assert AudioChunk.is_type(event.type)
|
|
||||||
|
|
||||||
# Event service should have received streaming stop
|
|
||||||
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Fake a TTS response
|
|
||||||
await satellite.event_from_server(Synthesize(text="test").event())
|
|
||||||
|
|
||||||
# Event service should have received synthesize
|
|
||||||
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Audio start, chunk, stop
|
|
||||||
await satellite.event_from_server(AUDIO_START.event())
|
|
||||||
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
|
|
||||||
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Event service does not get audio chunks, just start/stop
|
|
||||||
await satellite.event_from_server(AUDIO_CHUNK.event())
|
|
||||||
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
await satellite.event_from_server(AUDIO_STOP.event())
|
|
||||||
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
|
|
||||||
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
|
|
||||||
|
|
||||||
# Stop satellite
|
|
||||||
await satellite.stop()
|
|
||||||
await satellite_task
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from wyoming.asr import Transcript
|
|
||||||
from wyoming.audio import AudioChunk
|
|
||||||
from wyoming.client import AsyncClient
|
|
||||||
from wyoming.event import Event
|
|
||||||
from wyoming.satellite import RunSatellite
|
|
||||||
from wyoming.wake import Detection
|
|
||||||
|
|
||||||
from wyoming_satellite import (
|
|
||||||
EventSettings,
|
|
||||||
MicSettings,
|
|
||||||
SatelliteSettings,
|
|
||||||
WakeSettings,
|
|
||||||
WakeStreamingSatellite,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .shared import MicClient
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
class WakeClient(AsyncClient):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._detection_event = asyncio.Event()
|
|
||||||
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
await self._detection_event.wait()
|
|
||||||
return Detection().event()
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
if AudioChunk.is_type(event.type):
|
|
||||||
self._detection_event.set()
|
|
||||||
|
|
||||||
|
|
||||||
class EventClient(AsyncClient):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.wake_event = asyncio.Event()
|
|
||||||
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
# Input only
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
if Detection.is_type(event.type):
|
|
||||||
self.wake_event.set()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_multiple_wakeups(tmp_path: Path) -> None:
|
|
||||||
mic_client = MicClient()
|
|
||||||
wake_client = WakeClient()
|
|
||||||
event_client = EventClient()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
|
|
||||||
return_value=mic_client,
|
|
||||||
), patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
|
|
||||||
return_value=wake_client,
|
|
||||||
), patch(
|
|
||||||
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
|
|
||||||
return_value=event_client,
|
|
||||||
):
|
|
||||||
satellite = WakeStreamingSatellite(
|
|
||||||
SatelliteSettings(
|
|
||||||
mic=MicSettings(uri="test"),
|
|
||||||
wake=WakeSettings(uri="test"),
|
|
||||||
event=EventSettings(uri="test"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fake server connection
|
|
||||||
satellite.server_id = "test"
|
|
||||||
|
|
||||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
|
||||||
await satellite.event_from_server(RunSatellite().event())
|
|
||||||
|
|
||||||
await asyncio.wait_for(event_client.wake_event.wait(), timeout=1)
|
|
||||||
event_client.wake_event.clear()
|
|
||||||
|
|
||||||
# Stop streaming
|
|
||||||
await satellite.event_from_server(Transcript("test").event())
|
|
||||||
|
|
||||||
# Should not trigger again within refractory period (default: 5 sec)
|
|
||||||
with pytest.raises(asyncio.TimeoutError):
|
|
||||||
await asyncio.wait_for(event_client.wake_event.wait(), timeout=0.15)
|
|
||||||
|
|
||||||
await satellite.stop()
|
|
||||||
await satellite_task
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[tox]
|
|
||||||
env_list =
|
|
||||||
py{39,310,311,312,313}
|
|
||||||
minversion = 4.12.1
|
|
||||||
|
|
||||||
[testenv]
|
|
||||||
description = run the tests with pytest
|
|
||||||
package = wheel
|
|
||||||
wheel_build_env = .pkg
|
|
||||||
deps =
|
|
||||||
pytest>=7,<8
|
|
||||||
pytest-asyncio<1
|
|
||||||
commands =
|
|
||||||
pytest {tty:--color=yes} {posargs}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# 🛰️ LVA NEXUS — Web Client для Wyoming Satellite
|
|
||||||
|
|
||||||
Современный веб-интерфейс для управления Wyoming Satellite через WebSocket.
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### 1. Запустить satellite с WebSocket
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/satellite-dev
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
python3 -m wyoming_satellite \
|
|
||||||
--uri tcp://0.0.0.0:10700 \
|
|
||||||
--websocket-enabled \
|
|
||||||
--websocket-port 8675 \
|
|
||||||
--audio-level-enabled \
|
|
||||||
--debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Запустить HTTP сервер с паролем
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/satellite-dev/web-client
|
|
||||||
|
|
||||||
# С паролем по умолчанию (admin)
|
|
||||||
python3 start_server.py --port 8080
|
|
||||||
|
|
||||||
# Или со своим паролем
|
|
||||||
python3 start_server.py --port 8080 --password mysecretpassword
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Открыть в браузере
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:8080/client.html
|
|
||||||
или
|
|
||||||
http://192.168.200.111:8080/client.html
|
|
||||||
```
|
|
||||||
|
|
||||||
**🔐 Ввести пароль** (по умолчанию: `admin`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Функции
|
|
||||||
|
|
||||||
| Функция | Статус | Описание |
|
|
||||||
|---------|--------|----------|
|
|
||||||
| 🔊 Громкость | ✅ | Установка громкости satellite (0-100%) |
|
|
||||||
| 🔇 Mute / 🔊 Unmute | ✅ | Заглушить/включить микрофон |
|
|
||||||
| 📊 RMS Audio Level | ✅ | Визуализация громкости в реальном времени |
|
|
||||||
| 💡 LED управление | ⏳ | Цвет, яркость, эффекты |
|
|
||||||
| 🎤 Start Listening | ⏳ | Запуск прослушивания |
|
|
||||||
| ⏹️ Stop Pipeline | ⏳ | Остановка pipeline |
|
|
||||||
| ⏲️ Stop Timer | ⏳ | Остановка таймера |
|
|
||||||
| 🗣️ Transcript | ⏳ | Отображение распознанного текста |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎮 Управление
|
|
||||||
|
|
||||||
### Громкость
|
|
||||||
|
|
||||||
- **Слайдер** — плавная регулировка 0-100%
|
|
||||||
- **Кнопки VOL+ / VOL-** — пошаговое изменение
|
|
||||||
- **Mute/Unmute** — быстрое отключение микрофона
|
|
||||||
|
|
||||||
### LED управление
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Яркость
|
|
||||||
sendCommand('set_led_brightness', { brightness: 200 });
|
|
||||||
|
|
||||||
// Цвет (RGB)
|
|
||||||
sendCommand('set_led_color', { r: 255, g: 0, b: 0 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Аудио визуализация
|
|
||||||
|
|
||||||
- **Canvas** — отрисовка аудио уровня в реальном времени
|
|
||||||
- **RMS метр** — числовое значение громкости
|
|
||||||
- **Орбита** — анимация состояния (idle, listening, speaking)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Настройки
|
|
||||||
|
|
||||||
### Подключение
|
|
||||||
|
|
||||||
При первом запуске введите WebSocket адрес:
|
|
||||||
```
|
|
||||||
ws://192.168.200.111:8675
|
|
||||||
```
|
|
||||||
|
|
||||||
### Авто-подключение
|
|
||||||
|
|
||||||
- **Auto-reconnect** — автоматическое переподключение при разрыве
|
|
||||||
- **Auto-scroll** — автоматическая прокрутка лога событий
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 События
|
|
||||||
|
|
||||||
Web клиент получает от satellite:
|
|
||||||
|
|
||||||
| Событие | Описание |
|
|
||||||
|---------|----------|
|
|
||||||
| `voice-started` | Пользователь начал говорить |
|
|
||||||
| `voice-stopped` | Пользователь перестал говорить |
|
|
||||||
| `transcript` | Распознанный текст |
|
|
||||||
| `synthesize` | Запрос на синтез речи |
|
|
||||||
| `audio-level` | RMS громкость микрофона |
|
|
||||||
| `timer-started` | Таймер запущен |
|
|
||||||
| `timer-finished` | Таймер завершён |
|
|
||||||
| `error` | Ошибка |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Известные проблемы
|
|
||||||
|
|
||||||
| Проблема | Статус | Решение |
|
|
||||||
|----------|--------|---------|
|
|
||||||
| Громкость всегда 100% | ✅ Исправлено | Поддерживаются `level` и `volume` |
|
|
||||||
| WebSocket отключается | ⏳ В работе | Проверить firewall |
|
|
||||||
| RMS не обновляется | ⏳ В работе | Проверить audio-level-enabled |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Разработка
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
|
|
||||||
```
|
|
||||||
web-client/
|
|
||||||
├── client.html # Основной UI
|
|
||||||
├── README.md # Документация
|
|
||||||
└── start_server.py # HTTP сервер (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Добавление команд
|
|
||||||
|
|
||||||
1. **В satellite.py** — обработчик команды:
|
|
||||||
```python
|
|
||||||
async def handle_websocket_command(self, command: str, data: dict):
|
|
||||||
if command == "my_command":
|
|
||||||
# обработка
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **В client.html** — отправка команды:
|
|
||||||
```javascript
|
|
||||||
sendCommand('my_command', { param: value });
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **В client.html** — обработка ответа:
|
|
||||||
```javascript
|
|
||||||
function handleIncomingMessage(ev) {
|
|
||||||
const json = JSON.parse(ev.data);
|
|
||||||
if(json.event === 'my_event') {
|
|
||||||
// обработка
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Changelog
|
|
||||||
|
|
||||||
### 2026-05-21
|
|
||||||
|
|
||||||
- ✅ Исправлен баг volume/level
|
|
||||||
- ✅ Добавлена поддержка обоих названий параметра
|
|
||||||
- ✅ Обновлён план разработки
|
|
||||||
|
|
||||||
### 2026-05-20
|
|
||||||
|
|
||||||
- ✅ Создан современный UI
|
|
||||||
- ✅ WebSocket интеграция
|
|
||||||
- ✅ RMS визуализация
|
|
||||||
- ✅ Лог событий
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Связанное
|
|
||||||
|
|
||||||
- [WebSocket Server Plan](../docs/development/websocket-server-plan.md)
|
|
||||||
- [Wyoming Satellite](../README.md)
|
|
||||||
- [MQTT LED Integration](../docs/integration/mqtt-led.md)
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,396 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== СЛОЙ 1: BODY — общий фон страницы ===== */
|
|
||||||
body {
|
|
||||||
background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c);
|
|
||||||
font-family: 'Segoe UI', 'Inter', system-ui, sans-serif;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Частицы ===== */
|
|
||||||
.particles {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.particle {
|
|
||||||
position: absolute;
|
|
||||||
width: 2px;
|
|
||||||
height: 2px;
|
|
||||||
background: rgba(0, 255, 255, 0.3);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: float 20s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% { transform: translateY(0) translateX(0); opacity: 0; }
|
|
||||||
10% { opacity: 1; }
|
|
||||||
90% { opacity: 1; }
|
|
||||||
100% { transform: translateY(-100vh) translateX(50px); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Контейнер ассистента ===== */
|
|
||||||
.assistant-container {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 40px;
|
|
||||||
padding: 40px;
|
|
||||||
max-width: 600px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Бейдж подключения ===== */
|
|
||||||
.connection-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 30px;
|
|
||||||
right: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 50px;
|
|
||||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
|
||||||
font-size: 14px;
|
|
||||||
color: #b0e0ff;
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ff4d4d;
|
|
||||||
box-shadow: 0 0 10px #ff4d4d;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connected {
|
|
||||||
background: #0ff;
|
|
||||||
box-shadow: 0 0 20px #0ff;
|
|
||||||
animation: pulse-dot 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
|
||||||
50% { transform: scale(1.2); opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== СЛОЙ 3: ANIMATION-WRAPPER — непрозрачный тёмный круг =====
|
|
||||||
isolation: isolate создаёт локальный stacking context,
|
|
||||||
чтобы ::before с z-index: -1 был ПОД wrapper, но НАД body
|
|
||||||
*/
|
|
||||||
.animation-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 420px;
|
|
||||||
height: 420px;
|
|
||||||
border-radius: 50%;
|
|
||||||
isolation: isolate;
|
|
||||||
|
|
||||||
/* CSS-переменные для фона круга и ореола */
|
|
||||||
--bg-light: #1a2540;
|
|
||||||
--bg-dark: #03060c;
|
|
||||||
--glow-color: rgba(59, 130, 246, 0.55);
|
|
||||||
|
|
||||||
/* Непрозрачный тёмный фон — свой для каждого состояния */
|
|
||||||
background: radial-gradient(
|
|
||||||
circle at 30% 30%,
|
|
||||||
var(--bg-light) 0%,
|
|
||||||
#0a0f1e 50%,
|
|
||||||
var(--bg-dark) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Лёгкая внутренняя подсветка (inset) */
|
|
||||||
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
transition: background 0.8s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== СЛОЙ 2: ::BEFORE — свечение-ореол, выходит за границы ===== */
|
|
||||||
.animation-wrapper::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
/* Выходим за границы круга на 70px с каждой стороны */
|
|
||||||
inset: -70px;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
/* Светящийся ореол */
|
|
||||||
background: radial-gradient(
|
|
||||||
circle at center,
|
|
||||||
var(--glow-color) 0%,
|
|
||||||
var(--glow-color) 30%,
|
|
||||||
rgba(0, 0, 0, 0) 70%
|
|
||||||
);
|
|
||||||
|
|
||||||
filter: blur(25px);
|
|
||||||
opacity: 0.85;
|
|
||||||
|
|
||||||
/* ПОД wrapper, но НАД body благодаря isolation: isolate */
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
transition: background 0.8s ease, opacity 0.8s ease;
|
|
||||||
animation: halo-pulse 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes halo-pulse {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.08);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== СЛОЙ 4: LOTTIE-CANVAS — анимация с прозрачным фоном ===== */
|
|
||||||
#lottie-canvas {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
display: block;
|
|
||||||
transform: scale(2);
|
|
||||||
transform-origin: center center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
/* Обрезаем canvas по кругу, чтобы не вылезал за wrapper */
|
|
||||||
clip-path: circle(50% at center);
|
|
||||||
|
|
||||||
/* Фон прозрачный — видно непрозрачный фон wrapper под ним */
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== ЦВЕТА ДЛЯ КАЖДОГО СОСТОЯНИЯ ===== */
|
|
||||||
.animation-wrapper.idle {
|
|
||||||
--bg-light: #1a2540;
|
|
||||||
--bg-dark: #03060c;
|
|
||||||
--glow-color: rgba(59, 130, 246, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-wrapper.yes {
|
|
||||||
--bg-light: #0f2818;
|
|
||||||
--bg-dark: #030a06;
|
|
||||||
--glow-color: rgba(34, 197, 94, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-wrapper.no {
|
|
||||||
--bg-light: #2a1010;
|
|
||||||
--bg-dark: #0a0303;
|
|
||||||
--glow-color: rgba(239, 68, 68, 0.7);
|
|
||||||
}
|
|
||||||
.animation-wrapper.no::before {
|
|
||||||
animation: halo-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-wrapper.alert {
|
|
||||||
--bg-light: #2a1f08;
|
|
||||||
--bg-dark: #0a0803;
|
|
||||||
--glow-color: rgba(245, 158, 11, 0.7);
|
|
||||||
}
|
|
||||||
.animation-wrapper.alert::before {
|
|
||||||
animation: halo-pulse 0.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-wrapper.thinking {
|
|
||||||
--bg-light: #1a0f2a;
|
|
||||||
--bg-dark: #06030a;
|
|
||||||
--glow-color: rgba(168, 85, 247, 0.65);
|
|
||||||
}
|
|
||||||
.animation-wrapper.thinking::before {
|
|
||||||
animation: halo-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-wrapper.jump {
|
|
||||||
--bg-light: #2a0f1f;
|
|
||||||
--bg-dark: #0a0306;
|
|
||||||
--glow-color: rgba(236, 72, 153, 0.75);
|
|
||||||
}
|
|
||||||
.animation-wrapper.jump::before {
|
|
||||||
animation: halo-pulse 0.3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Оверлей загрузки ===== */
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(3, 6, 12, 0.95);
|
|
||||||
z-index: 10;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: opacity 0.5s;
|
|
||||||
}
|
|
||||||
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
|
||||||
|
|
||||||
/* ===== Текст статуса ===== */
|
|
||||||
.status-text {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #00ffff;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 4px;
|
|
||||||
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
|
|
||||||
animation: text-glow 2s ease-in-out infinite;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes text-glow {
|
|
||||||
0%, 100% { text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); }
|
|
||||||
50% { text-shadow: 0 0 40px rgba(0, 255, 255, 0.8); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Контейнер громкости ===== */
|
|
||||||
.volume-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 30px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
color: #b0e0ff;
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #00ffff;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background: rgba(0, 255, 255, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: #00ffff;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Кнопки громкости ===== */
|
|
||||||
.vol-btn {
|
|
||||||
background: rgba(0, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(0, 255, 255, 0.5);
|
|
||||||
color: #00ffff;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 50px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vol-btn:hover {
|
|
||||||
background: rgba(0, 255, 255, 0.3);
|
|
||||||
border-color: #00ffff;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Debug-кнопки ===== */
|
|
||||||
.debug-buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-btn {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(0, 255, 255, 0.4);
|
|
||||||
color: #0ff;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 40px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-btn:hover {
|
|
||||||
background: rgba(0, 255, 255, 0.2);
|
|
||||||
border-color: #0ff;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Transcript box ===== */
|
|
||||||
.transcript-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
border: 1px solid rgba(0, 255, 255, 0.2);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
min-height: 80px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #7f9fcf;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-text {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #eef5ff;
|
|
||||||
font-style: italic;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Адаптив ===== */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.animation-wrapper { width: 280px; height: 280px; }
|
|
||||||
.status-text { font-size: 20px; }
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>Droid Assistant</title>
|
|
||||||
|
|
||||||
<!-- Стили -->
|
|
||||||
<link rel="stylesheet" href="droidAssist.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="particles" id="particles"></div>
|
|
||||||
|
|
||||||
<div class="connection-badge">
|
|
||||||
<div class="status-dot" id="statusDot"></div>
|
|
||||||
<span id="statusLabel">Отключено</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="assistant-container">
|
|
||||||
|
|
||||||
<div class="animation-wrapper idle" id="animationWrapper">
|
|
||||||
<canvas id="lottie-canvas" width="700" height="700"></canvas>
|
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
|
|
||||||
|
|
||||||
<div class="volume-container">
|
|
||||||
<div class="volume-header">
|
|
||||||
<span>🔊 Громкость</span>
|
|
||||||
<span class="volume-value" id="volumeValue">80%</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" step="1">
|
|
||||||
<div class="volume-buttons">
|
|
||||||
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
|
|
||||||
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="debug-buttons">
|
|
||||||
<button class="debug-btn" onclick="goToState('yes')">👍 Yes</button>
|
|
||||||
<button class="debug-btn" onclick="goToState('thinking')">🤔 Think</button>
|
|
||||||
<button class="debug-btn" onclick="goToState('alert')">⚠️ Alert</button>
|
|
||||||
<button class="debug-btn" onclick="goToState('no')">❌ No</button>
|
|
||||||
<button class="debug-btn" onclick="goToState('jump')">🦘 Jump</button>
|
|
||||||
<button class="debug-btn" onclick="goToState('idle')">😐 Idle</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="transcript-box">
|
|
||||||
<div class="transcript-label">💬 Последняя фраза</div>
|
|
||||||
<div class="transcript-text" id="transcriptText">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Скрипт как модуль (нужен для импорта DotLottie) -->
|
|
||||||
<script type="module" src="droidAssist.js"></script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { DotLottie } from 'https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.0/+esm';
|
|
||||||
|
|
||||||
// ==================== КОНФИГУРАЦИЯ ====================
|
|
||||||
const WS_URL = 'ws://192.168.200.111:8675';
|
|
||||||
const NUM_PARTICLES = 30;
|
|
||||||
const LOTTIE_SRC = 'animation.lottie';
|
|
||||||
|
|
||||||
// ==================== СЕГМЕНТЫ АНИМАЦИИ ====================
|
|
||||||
const segments = {
|
|
||||||
idle: { start: 0, end: 29 },
|
|
||||||
yes: { start: 31, end: 105 },
|
|
||||||
no: { start: 106, end: 180 },
|
|
||||||
alert: { start: 181, end: 270 },
|
|
||||||
thinking: { start: 271, end: 390 },
|
|
||||||
jump: { start: 391, end: 479 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== СОСТОЯНИЕ ====================
|
|
||||||
let ws = null;
|
|
||||||
let isConnected = false;
|
|
||||||
let currentVolume = 0.8;
|
|
||||||
let dotLottie = null;
|
|
||||||
let currentState = 'idle';
|
|
||||||
let animationReady = false;
|
|
||||||
|
|
||||||
// ==================== DOM ====================
|
|
||||||
const statusDot = document.getElementById('statusDot');
|
|
||||||
const statusLabel = document.getElementById('statusLabel');
|
|
||||||
const wrapper = document.getElementById('animationWrapper');
|
|
||||||
const statusText = document.getElementById('statusText');
|
|
||||||
const volumeSlider = document.getElementById('volumeSlider');
|
|
||||||
const volumeValue = document.getElementById('volumeValue');
|
|
||||||
const volUpBtn = document.getElementById('volUpBtn');
|
|
||||||
const volDownBtn = document.getElementById('volDownBtn');
|
|
||||||
const transcriptText = document.getElementById('transcriptText');
|
|
||||||
|
|
||||||
// ==================== ЧАСТИЦЫ ====================
|
|
||||||
function initParticles() {
|
|
||||||
const particles = document.getElementById('particles');
|
|
||||||
for (let i = 0; i < NUM_PARTICLES; i++) {
|
|
||||||
const particle = document.createElement('div');
|
|
||||||
particle.className = 'particle';
|
|
||||||
particle.style.left = Math.random() * 100 + '%';
|
|
||||||
particle.style.animationDelay = Math.random() * 20 + 's';
|
|
||||||
particle.style.animationDuration = (15 + Math.random() * 10) + 's';
|
|
||||||
particles.appendChild(particle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== УПРАВЛЕНИЕ АНИМАЦИЕЙ ====================
|
|
||||||
window.goToState = function(stateName, statusLabelText) {
|
|
||||||
if (!animationReady || !dotLottie) return;
|
|
||||||
|
|
||||||
currentState = stateName;
|
|
||||||
const seg = segments[stateName];
|
|
||||||
if (!seg) return;
|
|
||||||
|
|
||||||
dotLottie.stop();
|
|
||||||
dotLottie.setSegment(seg.start, seg.end);
|
|
||||||
|
|
||||||
if (stateName === 'idle') {
|
|
||||||
dotLottie.setSpeed(0.1);
|
|
||||||
} else {
|
|
||||||
dotLottie.setSpeed(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
dotLottie.setLoop(stateName === 'idle');
|
|
||||||
dotLottie.play();
|
|
||||||
|
|
||||||
if (statusLabelText !== undefined) {
|
|
||||||
statusText.textContent = statusLabelText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем класс для свечения фона
|
|
||||||
wrapper.className = `animation-wrapper ${stateName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== МАППИНГ СОБЫТИЙ SATELLITE → АНИМАЦИЯ ====================
|
|
||||||
function handleSatelliteEvent(eventType, eventData) {
|
|
||||||
const data = eventData || {};
|
|
||||||
|
|
||||||
switch (eventType) {
|
|
||||||
// ─── yes: СЛУШАЮ, ГОВОРЮ, АКТИВЕН ───
|
|
||||||
case 'voice-started':
|
|
||||||
case 'stt-start':
|
|
||||||
goToState('yes', 'СЛУШАЮ');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tts-start':
|
|
||||||
case 'audio-start':
|
|
||||||
goToState('yes', 'ГОВОРЮ');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'streaming-started':
|
|
||||||
goToState('yes', 'АКТИВЕН');
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── thinking: ОБРАБОТКА ───
|
|
||||||
case 'voice-stopped':
|
|
||||||
case 'stt-stop':
|
|
||||||
goToState('thinking', 'ОБРАБОТКА');
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── idle: В ОЖИДАНИИ ───
|
|
||||||
case 'played':
|
|
||||||
case 'tts-stop':
|
|
||||||
case 'audio-stop':
|
|
||||||
goToState('idle', 'В ОЖИДАНИИ');
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── no: ОШИБКА, СПЛЮ ───
|
|
||||||
case 'satellite-disconnected':
|
|
||||||
case 'error':
|
|
||||||
goToState('no', 'ОШИБКА');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'pause-satellite':
|
|
||||||
goToState('no', 'СПЛЮ...');
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── jump: ПРИВЕТ!!! ───
|
|
||||||
case 'detection':
|
|
||||||
goToState('jump', 'ПРИВЕТ!!!');
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── transcript / synthesize ───
|
|
||||||
case 'transcript':
|
|
||||||
if (data.text) {
|
|
||||||
transcriptText.textContent = `"${data.text}"`;
|
|
||||||
goToState('yes');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'synthesize':
|
|
||||||
if (data.text) {
|
|
||||||
transcriptText.textContent = `"${data.text}"`;
|
|
||||||
goToState('yes');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ─── audio-level ───
|
|
||||||
case 'audio-level':
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (eventType !== 'audio-level') {
|
|
||||||
console.log('Unhandled event:', eventType, data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== WEBSOCKET ====================
|
|
||||||
function connectWebSocket() {
|
|
||||||
ws = new WebSocket(WS_URL);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
isConnected = true;
|
|
||||||
statusDot.classList.add('connected');
|
|
||||||
statusLabel.textContent = 'Подключено';
|
|
||||||
console.log('✅ WebSocket connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
const eventType = data.type || data.event;
|
|
||||||
const eventData = data.data || {};
|
|
||||||
if (eventType != "audio-level") {
|
|
||||||
console.info('EventType:', eventType);
|
|
||||||
console.info('EventData:', eventData);
|
|
||||||
}
|
|
||||||
handleSatelliteEvent(eventType, eventData);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Parse error:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => console.error('WebSocket error:', error);
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
isConnected = false;
|
|
||||||
statusDot.classList.remove('connected');
|
|
||||||
statusLabel.textContent = 'Отключено';
|
|
||||||
handleSatelliteEvent('satellite-disconnected');
|
|
||||||
console.log('❌ WebSocket disconnected');
|
|
||||||
setTimeout(connectWebSocket, 3000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ГРОМКОСТЬ ====================
|
|
||||||
function updateVolume(newVolume) {
|
|
||||||
currentVolume = Math.max(0, Math.min(1, newVolume));
|
|
||||||
volumeSlider.value = Math.round(currentVolume * 100);
|
|
||||||
volumeValue.textContent = Math.round(currentVolume * 100) + '%';
|
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
command: 'set_volume',
|
|
||||||
data: { level: currentVolume }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
|
||||||
async function init() {
|
|
||||||
const canvas = document.getElementById('lottie-canvas');
|
|
||||||
const overlay = document.getElementById('loadingOverlay');
|
|
||||||
|
|
||||||
initParticles();
|
|
||||||
connectWebSocket();
|
|
||||||
|
|
||||||
dotLottie = new DotLottie({
|
|
||||||
canvas: canvas,
|
|
||||||
src: LOTTIE_SRC,
|
|
||||||
autoplay: false,
|
|
||||||
loop: true,
|
|
||||||
renderConfig: { preserveAspectRatio: 'xMidYMid meet' }
|
|
||||||
});
|
|
||||||
|
|
||||||
dotLottie.addEventListener('load', () => {
|
|
||||||
animationReady = true;
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
goToState('idle', 'В ОЖИДАНИИ');
|
|
||||||
});
|
|
||||||
|
|
||||||
dotLottie.addEventListener('complete', () => {
|
|
||||||
if (currentState !== 'idle') {
|
|
||||||
goToState('idle', 'В ОЖИДАНИИ');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Volume controls
|
|
||||||
volumeSlider.addEventListener('input', (e) => updateVolume(e.target.value / 100));
|
|
||||||
volUpBtn.addEventListener('click', () => updateVolume(currentVolume + 0.1));
|
|
||||||
volDownBtn.addEventListener('click', () => updateVolume(currentVolume - 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запуск после загрузки страницы
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple HTTP server for WebSocket client testing with password protection.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 start_web_server.py [--port PORT] [--password PASSWORD]
|
|
||||||
|
|
||||||
Open in browser:
|
|
||||||
http://localhost:PORT/client.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import http.server
|
|
||||||
import socketserver
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PORT = 8080
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
||||||
|
|
||||||
# Default password
|
|
||||||
DEFAULT_PASSWORD = "admin"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthHandler(http.server.SimpleHTTPRequestHandler):
|
|
||||||
"""HTTP handler with Basic Auth."""
|
|
||||||
|
|
||||||
def __init__(self, *args, password="admin", **kwargs):
|
|
||||||
self.password = password
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def check_auth(self):
|
|
||||||
"""Check Basic Auth credentials."""
|
|
||||||
auth_header = self.headers.get('Authorization')
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Decode "Basic base64(username:password)"
|
|
||||||
auth_type, auth_string = auth_header.split(' ', 1)
|
|
||||||
if auth_type.lower() != 'basic':
|
|
||||||
return False
|
|
||||||
|
|
||||||
decoded = base64.b64decode(auth_string).decode('utf-8')
|
|
||||||
username, password = decoded.split(':', 1)
|
|
||||||
|
|
||||||
# For now, we only check password (username is ignored)
|
|
||||||
return password == self.password
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_auth_request(self):
|
|
||||||
"""Send 401 Unauthorized response."""
|
|
||||||
self.send_response(401)
|
|
||||||
self.send_header('WWW-Authenticate', 'Basic realm="Wyoming Satellite"')
|
|
||||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
html = '''
|
|
||||||
<html>
|
|
||||||
<head><title>401 Unauthorized</title></head>
|
|
||||||
<body>
|
|
||||||
<h1>Wyoming Satellite - Authentication Required</h1>
|
|
||||||
<p>Please enter the password to access the Web UI.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
'''
|
|
||||||
self.wfile.write(html.encode('utf-8'))
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
"""Handle GET requests with authentication."""
|
|
||||||
# Skip auth for static files that are already protected
|
|
||||||
if not self.check_auth():
|
|
||||||
self.send_auth_request()
|
|
||||||
return
|
|
||||||
|
|
||||||
super().do_GET()
|
|
||||||
print(f"[WebServer] ✅ {self.requestline} → authenticated")
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
# Suppress default logging
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_auth_handler(password):
|
|
||||||
"""Create auth handler with password."""
|
|
||||||
class Handler(AuthHandler):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, password=password, **kwargs)
|
|
||||||
return Handler
|
|
||||||
|
|
||||||
|
|
||||||
class ReusableTCPServer(socketserver.TCPServer):
|
|
||||||
"""TCP Server with address reuse enabled."""
|
|
||||||
allow_reuse_address = True
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='WebSocket Client Web Server with Password Protection')
|
|
||||||
parser.add_argument('--port', type=int, default=PORT, help=f'Port (default: {PORT})')
|
|
||||||
parser.add_argument('--host', default='0.0.0.0', help='Host (default: 0.0.0.0)')
|
|
||||||
parser.add_argument('--password', default=DEFAULT_PASSWORD, help=f'Password (default: {DEFAULT_PASSWORD})')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Change to script directory
|
|
||||||
os.chdir(SCRIPT_DIR)
|
|
||||||
|
|
||||||
# Create handler with password
|
|
||||||
Handler = create_auth_handler(args.password)
|
|
||||||
|
|
||||||
print("""
|
|
||||||
+==========================================================+
|
|
||||||
| Wyoming Satellite WebSocket Client Server |
|
|
||||||
+==========================================================+
|
|
||||||
| Serving: {:<53} |
|
|
||||||
| URL: http://localhost:{:<29} |
|
|
||||||
| Remote: http://<your-ip>:{:<25} |
|
|
||||||
| Password: {:<48} |
|
|
||||||
| |
|
|
||||||
| Press Ctrl+C to stop |
|
|
||||||
+==========================================================+
|
|
||||||
""".format(
|
|
||||||
str(SCRIPT_DIR),
|
|
||||||
args.port,
|
|
||||||
args.port,
|
|
||||||
'*' * len(args.password)
|
|
||||||
))
|
|
||||||
|
|
||||||
try:
|
|
||||||
with ReusableTCPServer((args.host, args.port), Handler) as httpd:
|
|
||||||
print(f"Server started on http://{args.host}:{args.port}")
|
|
||||||
print("Serving files from:", SCRIPT_DIR)
|
|
||||||
print(f"Password protected ({len(args.password)} characters)")
|
|
||||||
print("\nPress Ctrl+C to stop\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
httpd.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down gracefully...")
|
|
||||||
# Shutdown in a separate thread to avoid deadlock
|
|
||||||
shutdown_thread = threading.Thread(target=httpd.shutdown)
|
|
||||||
shutdown_thread.start()
|
|
||||||
shutdown_thread.join(timeout=2)
|
|
||||||
httpd.server_close()
|
|
||||||
print("Server stopped successfully")
|
|
||||||
|
|
||||||
except OSError as e:
|
|
||||||
print(f"\nError: Could not bind to port {args.port}")
|
|
||||||
print(f" {e}")
|
|
||||||
print("\nTry using a different port:")
|
|
||||||
print(f" python3 {sys.argv[0]} --port 8081\n")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\nUnexpected error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Voice satellite using the Wyoming protocol."""
|
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .satellite import (
|
|
||||||
AlwaysStreamingSatellite,
|
|
||||||
SatelliteBase,
|
|
||||||
VadStreamingSatellite,
|
|
||||||
WakeStreamingSatellite,
|
|
||||||
)
|
|
||||||
from .settings import (
|
|
||||||
EventSettings,
|
|
||||||
MicSettings,
|
|
||||||
SatelliteSettings,
|
|
||||||
SndSettings,
|
|
||||||
VadSettings,
|
|
||||||
WakeSettings,
|
|
||||||
)
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
__version__ = importlib.metadata.version("wyoming-satellite")
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"__version__",
|
|
||||||
"AlwaysStreamingSatellite",
|
|
||||||
"EventSettings",
|
|
||||||
"MicSettings",
|
|
||||||
"SatelliteBase",
|
|
||||||
"SatelliteSettings",
|
|
||||||
"SndSettings",
|
|
||||||
"VadSettings",
|
|
||||||
"VadStreamingSatellite",
|
|
||||||
"WakeStreamingSatellite",
|
|
||||||
"WakeSettings",
|
|
||||||
]
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
"""Main entry point for Wyoming satellite."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from wyoming.info import Attribution, Info, Satellite
|
|
||||||
from wyoming.server import AsyncServer, AsyncTcpServer
|
|
||||||
|
|
||||||
from . import __version__
|
|
||||||
from .event_handler import SatelliteEventHandler
|
|
||||||
from .satellite import (
|
|
||||||
AlwaysStreamingSatellite,
|
|
||||||
SatelliteBase,
|
|
||||||
VadStreamingSatellite,
|
|
||||||
WakeStreamingSatellite,
|
|
||||||
)
|
|
||||||
from .settings import (
|
|
||||||
EventSettings,
|
|
||||||
MicSettings,
|
|
||||||
SatelliteSettings,
|
|
||||||
SndSettings,
|
|
||||||
TimerSettings,
|
|
||||||
VadSettings,
|
|
||||||
WakeSettings,
|
|
||||||
WakeWordAndPipeline,
|
|
||||||
)
|
|
||||||
from .utils import (
|
|
||||||
get_mac_address,
|
|
||||||
needs_silero,
|
|
||||||
needs_webrtc,
|
|
||||||
run_event_command,
|
|
||||||
split_command,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
|
||||||
_DIR = Path(__file__).parent
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
|
|
||||||
# Microphone input
|
|
||||||
parser.add_argument("--mic-uri", help="URI of Wyoming microphone service")
|
|
||||||
parser.add_argument("--mic-command", help="Program to run for microphone input")
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-command-rate",
|
|
||||||
type=int,
|
|
||||||
default=16000,
|
|
||||||
help="Sample rate of mic-command (hertz, default: 16000)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-command-width",
|
|
||||||
type=int,
|
|
||||||
default=2,
|
|
||||||
help="Sample width of mic-command (bytes, default: 2)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-command-channels",
|
|
||||||
type=int,
|
|
||||||
default=1,
|
|
||||||
help="Sample channels of mic-command (default: 1)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-command-samples-per-chunk",
|
|
||||||
type=int,
|
|
||||||
default=1024,
|
|
||||||
help="Sample per chunk for mic-command (default: 1024)",
|
|
||||||
)
|
|
||||||
parser.add_argument("--mic-volume-multiplier", type=float, default=1.0)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
|
||||||
)
|
|
||||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-seconds-to-mute-after-awake-wav",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-no-mute-during-awake-wav",
|
|
||||||
action="store_true",
|
|
||||||
help="Don't mute the microphone while awake wav is being played",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mic-channel-index",
|
|
||||||
type=int,
|
|
||||||
help="Take microphone input from a specific channel (first channel is 0)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sound output
|
|
||||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
|
||||||
parser.add_argument("--snd-command", help="Program to run for sound output")
|
|
||||||
parser.add_argument(
|
|
||||||
"--snd-command-rate",
|
|
||||||
type=int,
|
|
||||||
default=22050,
|
|
||||||
help="Sample rate of snd-command (hertz, default: 22050)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--snd-command-width",
|
|
||||||
type=int,
|
|
||||||
default=2,
|
|
||||||
help="Sample width of snd-command (bytes, default: 2)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--snd-command-channels",
|
|
||||||
type=int,
|
|
||||||
default=1,
|
|
||||||
help="Sample channels of snd-command (default: 1)",
|
|
||||||
)
|
|
||||||
parser.add_argument("--snd-volume-multiplier", type=float, default=1.0)
|
|
||||||
|
|
||||||
# Local wake word detection
|
|
||||||
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
|
|
||||||
parser.add_argument(
|
|
||||||
"--wake-word-name",
|
|
||||||
action="append",
|
|
||||||
default=[],
|
|
||||||
nargs="+",
|
|
||||||
metavar=("name", "pipeline"),
|
|
||||||
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
|
|
||||||
)
|
|
||||||
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
|
||||||
parser.add_argument(
|
|
||||||
"--wake-command-rate",
|
|
||||||
type=int,
|
|
||||||
default=16000,
|
|
||||||
help="Sample rate of wake-command (hertz, default: 16000)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--wake-command-width",
|
|
||||||
type=int,
|
|
||||||
default=2,
|
|
||||||
help="Sample width of wake-command (bytes, default: 2)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--wake-command-channels",
|
|
||||||
type=int,
|
|
||||||
default=1,
|
|
||||||
help="Sample channels of wake-command (default: 1)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--wake-refractory-seconds",
|
|
||||||
type=float,
|
|
||||||
default=5.0,
|
|
||||||
help="Seconds after a wake word detection before another detection is handled (default: 5)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Voice activity detector
|
|
||||||
parser.add_argument(
|
|
||||||
"--vad", action="store_true", help="Wait for speech before streaming audio"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--vad-system",
|
|
||||||
dest="vad_system",
|
|
||||||
choices=("silero", "webrtc"),
|
|
||||||
default="silero",
|
|
||||||
help="VAD implementation to use (default: silero)",
|
|
||||||
)
|
|
||||||
parser.add_argument("--vad-threshold", type=float, default=0.5)
|
|
||||||
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
|
||||||
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
|
||||||
parser.add_argument(
|
|
||||||
"--vad-wake-word-timeout",
|
|
||||||
type=float,
|
|
||||||
default=5.0,
|
|
||||||
help="Seconds before going back to waiting for speech when wake word isn't detected",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--vad-energy-threshold",
|
|
||||||
type=float,
|
|
||||||
help="Optional RMS energy threshold for 'webrtc' VAD (int16 scale)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# External event handlers
|
|
||||||
parser.add_argument(
|
|
||||||
"--event-uri", help="URI of Wyoming service to forward events to"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--startup-command", help="Command run when the satellite starts"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--detect-command", help="Command to run when wake word detection starts"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--detection-command", help="Command to run when wake word is detected"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--transcript-command",
|
|
||||||
help="Command to run when speech to text transcript is returned",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--stt-start-command",
|
|
||||||
help="Command to run when the user starts speaking",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--stt-stop-command",
|
|
||||||
help="Command to run when the user stops speaking",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--synthesize-command",
|
|
||||||
help="Command to run when text to speech text is returned",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts-start-command",
|
|
||||||
help="Command to run when text to speech response starts",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts-stop-command",
|
|
||||||
help="Command to run when text to speech response stops",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tts-played-command",
|
|
||||||
help="Command to run when text-to-speech audio stopped playing",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--streaming-start-command",
|
|
||||||
help="Command to run when audio streaming starts",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--streaming-stop-command",
|
|
||||||
help="Command to run when audio streaming stops",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--error-command",
|
|
||||||
help="Command to run when an error occurs",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--connected-command",
|
|
||||||
help="Command to run when connected to the server",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--disconnected-command",
|
|
||||||
help="Command to run when disconnected from the server",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-started-command",
|
|
||||||
help="Command to run when a timer starts",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-updated-command",
|
|
||||||
help="Command to run when a timer is paused, resumed, or has time added or removed",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-cancelled-command",
|
|
||||||
"--timer-canceled-command",
|
|
||||||
help="Command to run when a timer is cancelled",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-finished-command",
|
|
||||||
help="Command to run when a timer finishes",
|
|
||||||
)
|
|
||||||
|
|
||||||
# WebSocket server
|
|
||||||
parser.add_argument(
|
|
||||||
"--websocket-enabled",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable WebSocket server for external clients (default: disabled)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--websocket-host",
|
|
||||||
default="0.0.0.0",
|
|
||||||
help="Host for WebSocket server (default: 0.0.0.0)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--websocket-port",
|
|
||||||
type=int,
|
|
||||||
default=8675,
|
|
||||||
help="Port for WebSocket server (default: 8675)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audio level monitoring
|
|
||||||
parser.add_argument(
|
|
||||||
"--audio-level-enabled",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable audio level monitoring and send audio-level events to WebSocket"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--audio-level-interval-ms",
|
|
||||||
type=int,
|
|
||||||
default=100,
|
|
||||||
help="Interval for audio-level events in milliseconds (default: 100)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sounds
|
|
||||||
parser.add_argument(
|
|
||||||
"--awake-wav", help="WAV file to play when wake word is detected"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--done-wav", help="WAV file to play when voice command is done"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-finished-wav", help="WAV file to play when a timer finishes"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timer-finished-wav-repeat",
|
|
||||||
nargs=2,
|
|
||||||
metavar=("repeat", "delay"),
|
|
||||||
type=float,
|
|
||||||
default=(1, 0),
|
|
||||||
help="Number of times to play timer finished WAV and delay between repeats in seconds",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Satellite details
|
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
|
||||||
parser.add_argument(
|
|
||||||
"--name", default="Wyoming Satellite", help="Name of the satellite"
|
|
||||||
)
|
|
||||||
parser.add_argument("--area", help="Area name of the satellite")
|
|
||||||
|
|
||||||
# Zeroconf
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--zeroconf-name",
|
|
||||||
help="Name used for zeroconf discovery (default: MAC from uuid.getnode)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--zeroconf-host",
|
|
||||||
help="Host address for zeroconf discovery (default: detect)",
|
|
||||||
)
|
|
||||||
#
|
|
||||||
parser.add_argument(
|
|
||||||
"--debug-recording-dir", help="Directory to store audio for debugging"
|
|
||||||
)
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
||||||
parser.add_argument(
|
|
||||||
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--version",
|
|
||||||
action="version",
|
|
||||||
version=__version__,
|
|
||||||
help="Print version and exit",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Validate args
|
|
||||||
if (not args.mic_uri) and (not args.mic_command):
|
|
||||||
_LOGGER.fatal("Either --mic-uri or --mic-command is required")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if needs_webrtc(args):
|
|
||||||
try:
|
|
||||||
import webrtc_noise_gain # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
_LOGGER.exception("Extras for webrtc are not installed")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if needs_silero(args):
|
|
||||||
try:
|
|
||||||
import pysilero_vad # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
_LOGGER.exception("Extras for silerovad are not installed")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
|
||||||
_LOGGER.fatal("%s does not exist", args.awake_wav)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.done_wav and (not Path(args.done_wav).is_file()):
|
|
||||||
_LOGGER.fatal("%s does not exist", args.done_wav)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
|
|
||||||
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.vad and (args.wake_uri or args.wake_command):
|
|
||||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
|
||||||
)
|
|
||||||
_LOGGER.debug(args)
|
|
||||||
|
|
||||||
if args.debug_recording_dir:
|
|
||||||
args.debug_recording_dir = Path(args.debug_recording_dir)
|
|
||||||
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
|
|
||||||
|
|
||||||
wyoming_info = Info(
|
|
||||||
satellite=Satellite(
|
|
||||||
name=args.name,
|
|
||||||
area=args.area,
|
|
||||||
description=args.name,
|
|
||||||
attribution=Attribution(name="", url=""),
|
|
||||||
installed=True,
|
|
||||||
version=__version__,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
settings = SatelliteSettings(
|
|
||||||
mic=MicSettings(
|
|
||||||
uri=args.mic_uri,
|
|
||||||
command=split_command(args.mic_command),
|
|
||||||
rate=args.mic_command_rate,
|
|
||||||
width=args.mic_command_width,
|
|
||||||
channels=args.mic_command_channels,
|
|
||||||
samples_per_chunk=args.mic_command_samples_per_chunk,
|
|
||||||
volume_multiplier=args.mic_volume_multiplier,
|
|
||||||
auto_gain=args.mic_auto_gain,
|
|
||||||
noise_suppression=args.mic_noise_suppression,
|
|
||||||
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
|
|
||||||
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
|
|
||||||
channel_index=args.mic_channel_index,
|
|
||||||
),
|
|
||||||
vad=VadSettings(
|
|
||||||
enabled=args.vad,
|
|
||||||
system=args.vad_system,
|
|
||||||
threshold=args.vad_threshold,
|
|
||||||
trigger_level=args.vad_trigger_level,
|
|
||||||
buffer_seconds=args.vad_buffer_seconds,
|
|
||||||
wake_word_timeout=args.vad_wake_word_timeout,
|
|
||||||
energy_threshold=args.vad_energy_threshold,
|
|
||||||
),
|
|
||||||
wake=WakeSettings(
|
|
||||||
uri=args.wake_uri,
|
|
||||||
command=split_command(args.wake_command),
|
|
||||||
names=[
|
|
||||||
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
|
|
||||||
],
|
|
||||||
refractory_seconds=(
|
|
||||||
args.wake_refractory_seconds
|
|
||||||
if args.wake_refractory_seconds > 0
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
),
|
|
||||||
snd=SndSettings(
|
|
||||||
uri=args.snd_uri,
|
|
||||||
command=split_command(args.snd_command),
|
|
||||||
rate=args.snd_command_rate,
|
|
||||||
width=args.snd_command_width,
|
|
||||||
channels=args.snd_command_channels,
|
|
||||||
volume_multiplier=args.snd_volume_multiplier,
|
|
||||||
awake_wav=args.awake_wav,
|
|
||||||
done_wav=args.done_wav,
|
|
||||||
),
|
|
||||||
event=EventSettings(
|
|
||||||
uri=args.event_uri,
|
|
||||||
startup=split_command(args.startup_command),
|
|
||||||
streaming_start=split_command(args.streaming_start_command),
|
|
||||||
streaming_stop=split_command(args.streaming_stop_command),
|
|
||||||
detect=split_command(args.detect_command),
|
|
||||||
detection=split_command(args.detection_command),
|
|
||||||
played=split_command(args.tts_played_command),
|
|
||||||
transcript=split_command(args.transcript_command),
|
|
||||||
stt_start=split_command(args.stt_start_command),
|
|
||||||
stt_stop=split_command(args.stt_stop_command),
|
|
||||||
synthesize=split_command(args.synthesize_command),
|
|
||||||
tts_start=split_command(args.tts_start_command),
|
|
||||||
tts_stop=split_command(args.tts_stop_command),
|
|
||||||
error=split_command(args.error_command),
|
|
||||||
connected=split_command(args.connected_command),
|
|
||||||
disconnected=split_command(args.disconnected_command),
|
|
||||||
|
|
||||||
# WebSocket server
|
|
||||||
websocket_enabled=args.websocket_enabled,
|
|
||||||
websocket_host=args.websocket_host,
|
|
||||||
websocket_port=args.websocket_port,
|
|
||||||
|
|
||||||
# Audio level
|
|
||||||
audio_level_enabled=args.audio_level_enabled,
|
|
||||||
audio_level_interval_ms=args.audio_level_interval_ms,
|
|
||||||
),
|
|
||||||
timer=TimerSettings(
|
|
||||||
started=split_command(args.timer_started_command),
|
|
||||||
updated=split_command(args.timer_updated_command),
|
|
||||||
cancelled=split_command(args.timer_cancelled_command),
|
|
||||||
finished=split_command(args.timer_finished_command),
|
|
||||||
finished_wav=args.timer_finished_wav,
|
|
||||||
finished_wav_plays=int(args.timer_finished_wav_repeat[0]),
|
|
||||||
finished_wav_delay=args.timer_finished_wav_repeat[1],
|
|
||||||
),
|
|
||||||
debug_recording_dir=args.debug_recording_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
satellite: SatelliteBase
|
|
||||||
|
|
||||||
if settings.wake.enabled:
|
|
||||||
# Local wake word detection
|
|
||||||
satellite = WakeStreamingSatellite(settings)
|
|
||||||
elif settings.vad.enabled:
|
|
||||||
# Stream after speech
|
|
||||||
satellite = VadStreamingSatellite(settings)
|
|
||||||
else:
|
|
||||||
# Stream all the time
|
|
||||||
satellite = AlwaysStreamingSatellite(settings)
|
|
||||||
|
|
||||||
if args.startup_command:
|
|
||||||
await run_event_command(split_command(args.startup_command))
|
|
||||||
|
|
||||||
_LOGGER.info("Ready")
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
server = AsyncServer.from_uri(args.uri)
|
|
||||||
|
|
||||||
if (not args.no_zeroconf) and isinstance(server, AsyncTcpServer):
|
|
||||||
from wyoming.zeroconf import register_server
|
|
||||||
|
|
||||||
if not args.zeroconf_name:
|
|
||||||
args.zeroconf_name = get_mac_address()
|
|
||||||
|
|
||||||
tcp_server: AsyncTcpServer = server
|
|
||||||
await register_server(
|
|
||||||
name=args.zeroconf_name,
|
|
||||||
port=tcp_server.port,
|
|
||||||
host=args.zeroconf_host,
|
|
||||||
)
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Zeroconf discovery enabled (name=%s, host=%s)",
|
|
||||||
args.zeroconf_name,
|
|
||||||
args.zeroconf_host,
|
|
||||||
)
|
|
||||||
|
|
||||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
await satellite.stop()
|
|
||||||
await satellite_task
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user