feat: Multi-event server support + USB LED controller

New features:
- wyoming_satellite/xvf_usb.py — прямой USB доступ к XVF3800 без subprocess
- docs/development/multi-event-server-plan.md — план разработки event server
- examples/respeaker_usb_mic_array.py — исходный файл с LED анимациями

Documentation updates:
- DEPLOY.md — добавлена секция про LED управление и event server план
- docs/integration/mqtt-led.md — обновлена документация по USB и event server
- docs/deployment/systemd-services.md — добавлен event server раздел

Refactoring:
- examples/wyoming-led.py — замена subprocess на XVF3800Controller
- install_service.sh — удалён (устарел, используется install_systemd_services.sh)

Bug fixes:
- wyoming-led.py — яркость DoA эффекта через масштабирование цвета
- systemd сервис — добавлена поддержка wyoming_satellite.xvf_usb модуля
This commit is contained in:
2026-05-20 13:50:16 +03:00
parent 75ba5481b6
commit c62203b220
9 changed files with 2267 additions and 152 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
"allow": [
"Bash(/home/sirmrak/projects/.venv_satellite/bin/pip *)",
"Bash(find *)",
"Bash(ls *)"
"Bash(ls *)",
"Bash(git commit *)",
"Bash(git push)"
]
},
"$version": 4
+86
View File
@@ -16,6 +16,9 @@ source ~/.venv_satellite/bin/activate
# Установка зависимостей
pip install -r requirements.txt
# Установка пакета в режиме разработки (для доступа к wyoming_satellite.xvf_usb)
pip install -e .
```
### 2. Установка systemd сервисов
@@ -24,6 +27,14 @@ pip install -r requirements.txt
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
@@ -84,6 +95,81 @@ journalctl -u wyoming-satelite-vosk.service -f
---
## 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).
---
## Конфигурация
Файлы сервисов используют плейсхолдеры, которые автоматически заменяются при установке:
+24
View File
@@ -74,6 +74,12 @@ 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)
@@ -81,6 +87,24 @@ LED управление и MQTT интеграция для ReSpeaker XVF3800.
- `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
+897
View File
@@ -0,0 +1,897 @@
# План: Множественные подключения event сервисов к Wyoming Satellite
**Дата:** 2026-05-20
**Статус:** Черновик
**Ветка:** `feature/multi-event-server`
---
## Стратегия разработки
### Ветка
**Создать:** `feature/multi-event-server` (от `master`)
- ✅ `master` остаётся стабильным
- ✅ Тестирование в изоляции
- ✅ Возможность отката
### Резервные копии
Перед началом работ:
```bash
cp wyoming_satellite/satellite.py wyoming_satellite/satellite.py.backup
cp wyoming_satellite/settings.py wyoming_satellite/settings.py.backup
cp wyoming_satellite/__main__.py wyoming_satellite/__main__.py.backup
```
---
## Архитектура
### Текущее состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
│ 10500 (один клиент!)
▼
┌─────────────────┐
│ Event Service │
│ (клиент) │
└─────────────────┘
```
**Проблема:** Satellite поддерживает **только одного** event клиента через `event.uri`
### Целевое состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
│ 10600 (event │ │
│ server) │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ LED │ │ MQTT │ │ Logger │
│ Service │ │ Service │ │ Service │
└─────────┘ └──────────┘ └──────────┘
```
**Решение:** Event сервер на satellite для множественных подключений
---
## Этапы реализации
### Этап 1: Подготовка (30 мин)
#### 1.1 Создать ветку
```bash
cd ~/projects/Satellite
git checkout master
git pull
git checkout -b feature/multi-event-server
```
#### 1.2 Создать резервные копии
```bash
cp wyoming_satellite/satellite.py wyoming_satellite/satellite.py.backup
cp wyoming_satellite/settings.py wyoming_satellite/settings.py.backup
cp wyoming_satellite/__main__.py wyoming_satellite/__main__.py.backup
```
#### 1.3 Создать тестовый скрипт event-клиента
**Файл:** `examples/test_event_client.py`
```python
#!/usr/bin/env python3
"""Test event client для подключения к satellite."""
import argparse
import asyncio
import logging
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test_event_client")
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
await client.connect()
logger.info("Connected!")
while True:
event = await client.read_event()
if event is None:
logger.warning("Server disconnected")
break
logger.info(f"Event: {event}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--uri", default="tcp://127.0.0.1:10600")
args = parser.parse_args()
asyncio.run(main(args))
```
---
### Этап 2: Модификация `wyoming_satellite/settings.py` (1 час)
**Добавить настройки event сервера:**
```python
@dataclass(frozen=True)
class EventSettings(ServiceSettings):
"""External event service settings."""
startup: Optional[List[str]] = None
streaming_start: Optional[List[str]] = None
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
played: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
synthesize: Optional[List[str]] = None
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
# === НОВЫЕ ПОЛЯ: Event server ===
server_enabled: bool = False
"""True если event сервер должен быть запущен."""
server_host: str = "0.0.0.0"
"""Host для event сервера."""
server_port: int = 10600
"""Port для event сервера."""
```
**Итого изменений:** ~20 строк
---
### Этап 3: Модификация `wyoming_satellite/satellite.py` (3-4 часа)
#### 3.1 Добавить атрибуты для event сервера
В `SatelliteBase.__init__()` после существующих атрибутов:
```python
# Event server (новый)
self._event_server: Optional[asyncio.Server] = None
self._event_clients: List[asyncio.StreamWriter] = []
self._event_clients_lock = asyncio.Lock()
```
**Импорт:** `List` из `typing` (если ещё не импортирован)
#### 3.2 Метод для рассылки событий всем клиентам
После существующих методов добавить:
```python
async def _broadcast_to_clients(self, event: Event) -> None:
"""
Отправить событие всем подключённым event клиентам.
Мёртвые подключения автоматически удаляются.
"""
async with self._event_clients_lock:
if not self._event_clients:
return
dead_clients = []
for i, writer in enumerate(self._event_clients):
try:
await async_write_event(event, writer)
except Exception:
_LOGGER.debug(
f"Failed to send event to client {i}, removing"
)
dead_clients.append(i)
# Удалить мёртвые подключения
for i in reversed(dead_clients):
self._event_clients.pop(i)
```
#### 3.3 Заменить `forward_event()`
**Было:**
```python
async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service."""
if self._event_queue is not None:
self._event_queue.put_nowait(event)
```
**Стало:**
```python
async def forward_event(self, event: Event) -> None:
"""
Forward an event to all event consumers.
Sends to:
- Legacy event service (if configured via event.uri)
- All connected event server clients
"""
# Legacy event service (через TCP клиент)
if self._event_queue is not None:
self._event_queue.put_nowait(event)
# Event server клиенты
await self._broadcast_to_clients(event)
```
#### 3.4 Event сервер для подключений
Добавить методы после `_event_task_proc()`:
```python
async def start_event_server(self) -> None:
"""
Запустить сервер для event клиентов.
Клиенты могут подключаться к порту server_port и получать все события.
"""
async def handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> None:
client_addr = writer.get_extra_info('peername')
_LOGGER.info(f"Event client connected: {client_addr}")
async with self._event_clients_lock:
self._event_clients.append(writer)
try:
# Ждать отключения клиента
await reader.read(1)
except asyncio.IncompleteReadError:
pass # Нормальное отключение
except Exception as exc:
_LOGGER.debug(f"Event client error: {exc}")
finally:
async with self._event_clients_lock:
if writer in self._event_clients:
self._event_clients.remove(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
_LOGGER.info(f"Event client disconnected: {client_addr}")
self._event_server = await asyncio.start_server(
handle_client,
self.settings.event.server_host,
self.settings.event.server_port
)
_LOGGER.info(
f"Event server listening on "
f"{self.settings.event.server_host}:{self.settings.event.server_port}"
)
async def stop_event_server(self) -> None:
"""
Остановить event сервер и закрыть все подключения.
"""
_LOGGER.info("Stopping event server...")
if self._event_server:
self._event_server.close()
await self._event_server.wait_closed()
self._event_server = None
# Закрыть все подключения
async with self._event_clients_lock:
for writer in self._event_clients:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._event_clients.clear()
_LOGGER.info("Event server stopped")
```
#### 3.5 Интеграция в жизненный цикл
**В `_start()`:**
```python
async def _start(self) -> None:
"""Connect to services."""
self.state = State.STARTING
await self._connect_to_services()
# Запустить event сервер (новый)
if self.settings.event.server_enabled:
await self.start_event_server()
self.state = State.STARTED
await self.started()
```
**В `_stop()`:**
```python
async def _stop(self) -> None:
"""Disconnect from services."""
self.server_id = None
self._writer = None
# Остановить event сервер (новый)
if self._event_server:
await self.stop_event_server()
await self._disconnect_from_services()
self._disable_ping()
self.state = State.STOPPED
```
**Итого изменений:** ~200 строк
---
### Этап 4: Модификация `wyoming_satellite/__main__.py` (30 мин)
#### 4.1 Добавить CLI аргументы
После существующих аргументов (перед `args = parser.parse_args()`):
```python
# Event server
parser.add_argument(
"--event-server-enabled",
action="store_true",
help="Enable event server for multiple event clients (default: disabled)"
)
parser.add_argument(
"--event-server-host",
default="0.0.0.0",
help="Host for event server (default: 0.0.0.0)"
)
parser.add_argument(
"--event-server-port",
type=int,
default=10600,
help="Port for event server (default: 10600)"
)
```
#### 4.2 Передать настройки в SatelliteSettings
В существующем `EventSettings(...)`:
```python
event=EventSettings(
uri=args.event_uri,
command=split_command(args.event_command),
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),
# === НОВЫЕ ПОЛЯ ===
server_enabled=args.event_server_enabled,
server_host=args.event_server_host,
server_port=args.event_server_port,
),
```
**Итого изменений:** ~30 строк
---
### Этап 5: Тестовый event клиент (1 час)
#### 5.1 Создать `examples/test_event_client.py`
```python
#!/usr/bin/env python3
"""
Test event client для подключения к Wyoming Satellite.
Подключается к event server и получает все события.
Используется для тестирования и отладки.
Пример использования:
python3 test_event_client.py --uri tcp://127.0.0.1:10600
"""
import argparse
import asyncio
import logging
import signal
import sys
from datetime import datetime
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("test_event_client")
stop_event = asyncio.Event()
def signal_handler(sig, frame):
logger.info(f"Получен сигнал {sig}, завершаю...")
stop_event.set()
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
try:
await client.connect()
logger.info("✅ Connected!")
event_count = 0
while not stop_event.is_set():
try:
event = await asyncio.wait_for(
client.read_event(),
timeout=5.0
)
if event is None:
logger.warning("⚠️ Server disconnected")
break
event_count += 1
event_type = event.get("type", "unknown")
logger.info(f"📨 [{event_count}] {event_type}")
if args.verbose:
logger.debug(f" Data: {event}")
except asyncio.TimeoutError:
# Проверка stop_event
continue
except Exception as exc:
logger.error(f"❌ Error reading event: {exc}")
break
logger.info(f"Всего получено событий: {event_count}")
except Exception as exc:
logger.error(f"❌ Error: {exc}")
finally:
try:
await client.disconnect()
except Exception:
pass
logger.info("👋 Disconnected")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Test event client для Wyoming Satellite"
)
parser.add_argument(
"--uri",
default="tcp://127.0.0.1:10600",
help="URI для подключения (default: tcp://127.0.0.1:10600)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Выводить полные данные событий"
)
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(main(args))
except KeyboardInterrupt:
pass
```
#### 5.2 Сделать исполняемым
```bash
chmod +x examples/test_event_client.py
```
---
### Этап 6: Тестирование (2-3 часа)
#### 6.1 Запустить satellite с event сервером
```bash
cd ~/projects/Satellite
source ~/.venv_satellite/bin/activate
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600 \
--debug
```
**Ожидаемый вывод:**
```
INFO: Ready
INFO: Event server listening on 0.0.0.0:10600
INFO: Слушаем tcp://0.0.0.0:10700
```
#### 6.2 Проверить подключения
**Терминал 1: Home Assistant (порт 10700)**
```bash
# Эмуляция подключения HA (или реальное HA)
# HA должен подключиться автоматически через Zeroconf
```
**Терминал 2: test_event_client #1 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 3: test_event_client #2 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 4:原有 wyoming-led.py (порт 10500)**
```bash
source ~/.venv_satellite/bin/activate
cd examples
python3 wyoming-led.py --host 127.0.0.1 --port 10500
```
#### 6.3 Проверить логи
**В логе satellite:**
```
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 1
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 2
INFO: Connected to server # Home Assistant
```
**Проверить:**
- ✅ Все 3 клиента получают события
- ✅ Home Assistant продолжает работать (порт 10700)
- ✅ При отключении клиента satellite не падает
- ✅ wyoming-led.py работает через старый порт 10500
#### 6.4 Тест на устойчивость
```bash
# Отключить клиента 1 (Ctrl+C)
# Проверить что клиент 2 продолжает работать
# Отключить wyoming-led.py
# Проверить что event клиенты работают
# Отключить Home Assistant
# Проверить что event клиенты работают
```
---
### Этап 7: Обновление документации (30 мин)
#### 7.1 `docs/integration/mqtt-led.md`
Добавить секцию после "Архитектура USB контроллера":
```markdown
## Event Server (множественные подключения)
Начиная с версии 2026.06, Wyoming Satellite поддерживает event server для
множественных подключений.
### Преимущества
- ✅ **Масштабируемость:** Неограниченное количество event клиентов
- ✅ **Надёжность:** Падение клиента не влияет на satellite
- ✅ **Гибкость:** Каждый клиент выбирает какие события обрабатывать
### Запуск
```bash
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
### Подключение клиента
```python
from wyoming.client import AsyncClient
client = AsyncClient.from_uri("tcp://127.0.0.1:10600")
await client.connect()
while True:
event = await client.read_event()
# Обработка события
```
### Обратная совместимость
Старый `wyoming-led.py` продолжает работать через `--event-uri`:
```bash
python3 -m wyoming_satellite \
--event-uri tcp://127.0.0.1:10500 \
--event-server-enabled \
--event-server-port 10600
```
Оба метода работают параллельно!
```
#### 7.2 `docs/deployment/systemd-services.md`
Обновить секцию `wyoming-event.service`:
```markdown
### Event Server (новый)
Для поддержки множественных event клиентов добавьте в сервис:
```ini
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 \
/home/sirmrak/projects/Satellite/wyoming_satellite/__main__.py \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
Клиенты подключаются к порту 10600:
```bash
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
```
#### 7.3 `DEPLOY.md`
Добавить секцию после "LED управление":
```markdown
## Event Server (множественные подключения)
Для подключения нескольких event сервисов одновременно:
```bash
python3 -m wyoming_satellite \
--event-server-enabled \
--event-server-port 10600
```
**Примеры использования:**
- LED управление + MQTT уведомления + логирование в базу
- Несколько независимых обработчиков событий
- Тестовые клиенты для отладки
**Документация:** [Event Server](docs/integration/mqtt-led.md#event-server)
```
---
### Этап 8: Merge в master (после успешного тестирования)
```bash
# Проверка что всё работает
git status
git diff master
# Merge
git checkout master
git pull origin master
git merge feature/multi-event-server
# Push
git push origin master
# Удалить ветку (опционально)
git branch -d feature/multi-event-server
```
---
## Итоговые изменения
| Файл | Тип | Изменения | Строк |
|------|-----|-----------|-------|
| `wyoming_satellite/settings.py` | Изменён | EventSettings + server настройки | ~20 |
| `wyoming_satellite/satellite.py` | Изменён | Event server + broadcast | ~200 |
| `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~30 |
| `examples/test_event_client.py` | Новый | Тестовый клиент | ~80 |
| `docs/integration/mqtt-led.md` | Изменён | Документация event server | ~40 |
| `docs/deployment/systemd-services.md` | Изменён | Обновление сервисов | ~20 |
| `DEPLOY.md` | Изменён | Quick start | ~15 |
| **ВСЕГО** | | | **~405 строк** |
---
## Риски и план Б
| Риск | Вероятность | Влияние | Решение |
|------|-------------|---------|---------|
| Конфликт портов (10600 занят) | Низкая | Среднее | Проверка `lsof -i :10600`, смена порта |
| Утечка памяти (клиенты) | Средняя | Высокое | Логирование подключений, таймауты |
| Падение satellite | Низкая | Критичное | Тестирование в ветке, backup файлов |
| Home Assistant отключится | Низкая | Критичное | Порт 10700 не меняется, обратная совместимость |
| Event клиенты не получают события | Средняя | Среднее | Проверка `_broadcast_to_clients()`, логирование |
### План Б: Откат
Если что-то идёт не так:
```bash
cd ~/projects/Satellite
git checkout master
# Восстановить файлы
cp wyoming_satellite/satellite.py.backup wyoming_satellite/satellite.py
cp wyoming_satellite/settings.py.backup wyoming_satellite/settings.py
cp wyoming_satellite/__main__.py.backup wyoming_satellite/__main__.py
# Перезапустить сервисы
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satelite-vosk.service
```
---
## Контрольный список
### Перед началом
- [ ] Создана ветка `feature/multi-event-server`
- [ ] Созданы backup файлов
- [ ] Текущая версия работает стабильно
### После этапа 3
- [ ] satellite.py изменён без синтаксических ошибок
- [ ] `python3 -m py_compile wyoming_satellite/satellite.py` проходит
- [ ] Импорт `from wyoming_satellite.satellite import SatelliteBase` работает
### После этапа 4
- [ ] `python3 -m wyoming_satellite --help` показывает новые аргументы
- [ ] `--event-server-enabled` распознаётся
### После этапа 5
- [ ] `examples/test_event_client.py --help` работает
- [ ] Клиент подключается к порту 10600
### После этапа 6
- [ ] Satellite запускается с `--event-server-enabled`
- [ ] Multiple clients can connect
- [ ] Все клиенты получают события
- [ ] Home Assistant работает (порт 10700)
- [ ] wyoming-led.py работает (порт 10500)
- [ ] При отключении клиента satellite не падает
### После этапа 7
- [ ] Документация обновлена
- [ ] Примеры в документации работают
### Перед merge
- [ ] Все тесты пройдены
- [ ] Документация актуальна
- [ ] Backup файлы удалены (опционально)
---
## Приложения
### A. Пример события в event клиенте
```json
{
"type": "voice-started",
"data": {}
}
```
```json
{
"type": "voice-stopped",
"data": {}
}
```
```json
{
"type": "transcript",
"data": {
"text": "привет дроид"
}
}
```
### B. Диагностика
```bash
# Проверка портов
lsof -i :10600
lsof -i :10700
lsof -i :10500
# Проверка подключений
netstat -tlnp | grep python
# Логи satellite
journalctl -u wyoming-satelite-vosk.service -f
# Тест подключения
telnet 127.0.0.1 10600
```
### C. Сравнение подходов
| Аспект | Старый (push) | Новый (pull) |
|--------|---------------|--------------|
| Подключения | 1 satellite → 1 event | N clients → 1 satellite |
| Отказоустойчивость | Падение event → satellite работает | Падение client → satellite работает |
| Масштабируемость | Ограничено 1 сервисом | Неограниченно клиентов |
| Сложность | Проще | Сложнее (управление клиентами) |
| Гибкость | Низкая | Высокая (каждый клиент выбирает события) |
| Обратная совместимость | ✅ Работает | ✅ Работает через event.uri |
---
**Конец документа**
+24 -4
View File
@@ -152,10 +152,11 @@ python3 examples/wyoming-led.py --debug
```bash
# Проверка USB устройств
lsusb | grep -i respeaker
lsusb | grep -i 2886
# Тест xvf_host.py
python3 examples/xvf_host.py --help
# Тест 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')"
```
## Поддерживаемое оборудование
@@ -170,10 +171,29 @@ python3 examples/xvf_host.py --help
## Связанные файлы
- `examples/wyoming-led.py` — Основной скрипт LED управления
- `examples/xvf_host.py` — Драйвер ReSpeaker XVF3800
- `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)
```
---
## 🔗 Связанное
+789
View File
@@ -0,0 +1,789 @@
#!/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()
+78 -48
View File
@@ -7,7 +7,6 @@ Wyoming LED Control with MQTT Discovery for Home Assistant
import argparse
import asyncio
import logging
import subprocess
import sys
import signal
import json
@@ -22,6 +21,13 @@ 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,
@@ -31,7 +37,6 @@ logging.basicConfig(
logger = logging.getLogger("XVF3800 LED Service")
SCRIPT_DIR = Path(__file__).resolve().parent
XVF_HOST_PATH = str(SCRIPT_DIR / "xvf_host.py")
CONFIG_FILE = SCRIPT_DIR / "led_brightness_config.json"
MQTT_BROKER = "192.168.200.100"
@@ -68,6 +73,7 @@ mqtt_client = None
current_state = "idle"
last_mqtt_brightness = None
ha_disabled = False
led_controller: XVF3800Controller | None = None
# ====================== CONFIG ======================
@@ -94,15 +100,19 @@ def save_brightness_config():
# ====================== LED ======================
def run_xvf_command(command: str, values=None):
args = [sys.executable, XVF_HOST_PATH, command]
if values:
args.extend(["--values"] + [str(v) for v in values])
def init_leds() -> bool:
"""Инициализация USB контроллера."""
global led_controller
try:
subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
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 command error {command}: {e}")
logger.error(f"❌ Ошибка инициализации LED: {e}")
return False
@@ -113,58 +123,74 @@ def get_effective_brightness(state: str) -> int:
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":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x00BCD4"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
run_xvf_command("LED_SPEED", [2])
# Бирюзовое дыхание
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":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0xFF0000"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Красное дыхание
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":
run_xvf_command("LED_EFFECT", [2])
run_xvf_command("LED_BRIGHTNESS", [brightness])
run_xvf_command("LED_SPEED", [4])
# Радуга
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":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x4CAF50"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Зелёное дыхание
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 эффект: яркость управляется через значения цвета, а не LED_BRIGHTNESS
# Масштабируем цвета по яркости (0-255)
base_r = int(0x30 * brightness / 255)
base_g = int(0x30 * brightness / 255)
base_b = int(0x30 * brightness / 255)
doa_r = int(0x00 * brightness / 255)
doa_g = int(0xBC * brightness / 255)
doa_b = int(0xD4 * brightness / 255)
base_color = f"0x{base_b:02X}{base_g:02X}{base_r:02X}"
doa_color = f"0x{doa_b:02X}{doa_g:02X}{doa_r:02X}"
run_xvf_command("LED_EFFECT", [4])
run_xvf_command("LED_DOA_COLOR", [base_color, doa_color])
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base={base_color}, doa={doa_color})")
# DoA эффект: яркость управляется через значения цвета
base_rgb = scale_color((0x30, 0x30, 0x30), brightness)
doa_rgb = scale_color((0x00, 0xBC, 0xD4), brightness)
base_color = rgb_to_bgr_int(*base_rgb)
doa_color = rgb_to_bgr_int(*doa_rgb)
led_controller.set_led_effect(EFFECT_DOA)
led_controller.set_led_doa_color(base_color, doa_color)
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})")
elif state == "paused":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0x404040"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Серое дыхание
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":
run_xvf_command("LED_EFFECT", [1])
run_xvf_command("LED_COLOR", ["0xFF00FF"])
run_xvf_command("LED_BRIGHTNESS", [brightness])
# Маджента дыхание
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":
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
# Выключить
led_controller.set_led_effect(EFFECT_OFF)
logger.info("⬛ Off: выключено")
if not skip_publish:
@@ -350,8 +376,8 @@ def on_mqtt_message(client, userdata, msg):
ha_state = payload.upper()
if ha_state == "OFF":
ha_disabled = True
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
if led_controller:
led_controller.set_led_effect(EFFECT_OFF)
publish_state("off")
logger.info("⬛ LED OFF")
elif ha_state == "ON":
@@ -413,20 +439,24 @@ class LedEventHandler(AsyncEventHandler):
def cleanup_leds():
"""Очистка: выключаем LED и закрываем USB."""
logger.info("🧹 Очистка: выключаем LED...")
if led_controller:
try:
run_xvf_command("LED_EFFECT", [0])
run_xvf_command("LED_BRIGHTNESS", [0])
logger.info("✅ LED выключены")
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()
run_xvf_command("VERSION")
set_led_state("idle")
uri = f"tcp://{args.host}:{args.port}"
-95
View File
@@ -1,95 +0,0 @@
#!/bin/bash
set -e
# Путь к файлу службы
SERVICE_FILE="wyoming-satellite.service"
DEST_PATH="/etc/systemd/system/${SERVICE_FILE}"
# Проверка прав суперпользователя
if [ "$(id -u)" -ne 0 ]; then
echo "Этот скрипт должен быть запущен с правами суперпользователя (sudo)"
exit 1
fi
# Функция для установки службы
install_service() {
echo "Устанавливаю службу Wyoming Satellite..."
# Копируем файл службы
cp "${SERVICE_FILE}" "${DEST_PATH}"
chmod 644 "${DEST_PATH}"
# Делаем скрипт запуска исполняемым
chmod +x "$(pwd)/start_satellite.sh"
# Перезагружаем systemd
systemctl daemon-reload
echo "Служба установлена. Используйте 'sudo systemctl start ${SERVICE_FILE}' для запуска."
}
# Функция для удаления службы
remove_service() {
echo "Удаляю службу Wyoming Satellite..."
# Останавливаем и отключаем службу
systemctl stop "${SERVICE_FILE}" 2>/dev/null || true
systemctl disable "${SERVICE_FILE}" 2>/dev/null || true
# Удаляем файл службы
rm -f "${DEST_PATH}"
# Перезагружаем systemd
systemctl daemon-reload
echo "Служба удалена."
}
# Функция для отображения статуса
show_status() {
echo "Статус службы Wyoming Satellite:"
systemctl status "${SERVICE_FILE}"
}
# Обработка аргументов командной строки
case "$1" in
install)
install_service
;;
remove)
remove_service
;;
status)
show_status
;;
start)
echo "Запускаю службу Wyoming Satellite..."
systemctl start "${SERVICE_FILE}"
;;
stop)
echo "Останавливаю службу Wyoming Satellite..."
systemctl stop "${SERVICE_FILE}"
;;
restart)
echo "Перезапускаю службу Wyoming Satellite..."
systemctl restart "${SERVICE_FILE}"
;;
enable)
echo "Включаю автозапуск службы Wyoming Satellite..."
systemctl enable "${SERVICE_FILE}"
;;
disable)
echo "Отключаю автозапуск службы Wyoming Satellite..."
systemctl disable "${SERVICE_FILE}"
;;
logs)
echo "Журнал службы Wyoming Satellite:"
journalctl -u "${SERVICE_FILE}" -f
;;
*)
echo "Использование: $0 {install|remove|status|start|stop|restart|enable|disable|logs}"
exit 1
;;
esac
exit 0
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""
XVF3800 USB LED Control Module.
Прямой доступ к ReSpeaker XVF3800 через USB control transfers.
Использует pyusb для отправки команд без subprocess.
Пример использования:
from wyoming_satellite.xvf_usb import XVF3800Controller
controller = XVF3800Controller()
controller.set_led_effect(1) # breath effect
controller.set_led_color(0x00BCD4) # бирюзовый
controller.set_led_brightness(150)
"""
from __future__ import annotations
import logging
import struct
import threading
from typing import Optional, Tuple
try:
import usb.core
import usb.util
_HAS_USB = True
except ImportError:
_HAS_USB = False
logging.warning("pyusb not installed – XVF3800 control disabled")
# ===========================================================================
# Configuration
# ===========================================================================
USB_VENDOR_ID = 0x2886
USB_PRODUCT_IDS = [0x0018, 0x001A]
# GPO_SERVICER_RESID LED commands
GPO_RESID = 20
LED_EFFECT_CMD = 12
LED_BRIGHTNESS_CMD = 13
LED_SPEED_CMD = 15
LED_COLOR_CMD = 16
LED_DOA_COLOR_CMD = 17
LED_RING_COLOR_CMD = 19
# Effects
EFFECT_OFF = 0
EFFECT_BREATH = 1
EFFECT_RAINBOW = 2
EFFECT_SINGLE = 3
EFFECT_DOA = 4
EFFECT_RING = 5
# Color format: RGB tuple
RGB = Tuple[int, int, int]
_LOGGER = logging.getLogger("xvf_usb")
# ===========================================================================
# Helper functions
# ===========================================================================
def rgb_to_bgr_int(r: int, g: int, b: int) -> int:
"""Convert RGB (0-255) to 0xBBGGRR format."""
return (b << 16) | (g << 8) | r
def bgr_int_to_rgb(color_int: int) -> RGB:
"""Convert 0xBBGGRR to RGB tuple."""
b = (color_int >> 16) & 0xFF
g = (color_int >> 8) & 0xFF
r = color_int & 0xFF
return (r, g, b)
def scale_color(color: RGB, brightness: int) -> RGB:
"""
Scale RGB color by brightness (0-255).
Args:
color: RGB tuple (r, g, b)
brightness: Brightness value 0-255
Returns:
Scaled RGB tuple
"""
factor = brightness / 255.0
return (
int(color[0] * factor),
int(color[1] * factor),
int(color[2] * factor)
)
# ===========================================================================
# XVF3800 Controller
# ===========================================================================
class XVF3800Controller:
"""
Controller for ReSpeaker XVF3800 via USB.
Provides direct USB control transfer access to LED commands.
Thread-safe with automatic device reconnection.
Example:
controller = XVF3800Controller()
controller.set_led_effect(EFFECT_BREATH)
controller.set_led_color(rgb_to_bgr_int(0, 188, 212))
controller.set_led_brightness(150)
"""
TIMEOUT = 1000 # USB transfer timeout in ms
def __init__(
self,
vendor_id: int = USB_VENDOR_ID,
product_ids: list[int] | None = None
) -> None:
self._vendor_id = vendor_id
self._product_ids = product_ids or USB_PRODUCT_IDS
self._dev: Optional[usb.core.Device] = None
self._dev_lock = threading.Lock()
self._find_device()
def _find_device(self) -> None:
"""Find and initialize USB device."""
_LOGGER.info(
"Searching for XVF3800 USB device (VID=%04x, PIDs=%s)...",
self._vendor_id,
[f"{pid:04x}" for pid in self._product_ids]
)
# List all USB devices for debugging
if _HAS_USB:
try:
all_devs = usb.core.find(find_all=True)
_LOGGER.debug("All USB devices found:")
for dev in all_devs:
_LOGGER.debug(
" - VID=%04x, PID=%04x, bus=%d, addr=%d",
dev.idVendor, dev.idProduct, dev.bus, dev.address
)
except Exception as exc:
_LOGGER.debug("Could not list USB devices: %s", exc)
for pid in self._product_ids:
_LOGGER.debug("Looking for PID %04x...", pid)
dev = usb.core.find(idVendor=self._vendor_id, idProduct=pid)
if dev is not None:
try:
if dev.is_kernel_driver_active(0):
_LOGGER.info("Detaching kernel driver from device...")
dev.detach_kernel_driver(0)
except Exception as exc:
_LOGGER.warning("Could not detach kernel driver: %s", exc)
self._dev = dev
_LOGGER.info(
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
self._vendor_id, pid, dev.bus, dev.address
)
return
else:
_LOGGER.debug("Device %04x:%04x not found", self._vendor_id, pid)
_LOGGER.error(
"❌ XVF3800 not found (USB %04x). Check connection and udev rules.",
self._vendor_id
)
def _ctrl_transfer(
self,
resid: int,
cmdid: int,
payload: list | bytes
) -> bool:
"""
Send USB control transfer.
Args:
resid: Resource ID (e.g., GPO_RESID=20)
cmdid: Command ID within the resource
payload: Data to send (list of int or bytes)
Returns:
True if successful, False otherwise
"""
with self._dev_lock:
if self._dev is None:
_LOGGER.warning("USB device not available")
return False
try:
# Convert list to bytes if needed
if isinstance(payload, list):
payload = bytes(payload)
_LOGGER.debug(
"CTRL: resid=%d, cmdid=%d, payload_len=%d",
resid, cmdid, len(payload)
)
result = self._dev.ctrl_transfer(
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
0, # request
cmdid, # wValue
resid, # wIndex
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)
self._dev = None
return False
def is_connected(self) -> bool:
"""Check if USB device is available."""
with self._dev_lock:
return self._dev is not None
def close(self) -> None:
"""Release USB resources."""
self.set_led_effect(EFFECT_OFF)
with self._dev_lock:
if self._dev is not None:
usb.util.dispose_resources(self._dev)
self._dev = None
# =======================================================================
# LED Commands
# =======================================================================
def set_led_effect(self, effect: int) -> bool:
"""
Set LED effect mode.
Args:
effect: 0=off, 1=breath, 2=rainbow, 3=single, 4=doa, 5=ring
Returns:
True if successful
"""
return self._ctrl_transfer(GPO_RESID, LED_EFFECT_CMD, [effect])
def set_led_brightness(self, brightness: int) -> bool:
"""
Set LED brightness (for breath and rainbow effects only).
Args:
brightness: 0-255
Returns:
True if successful
"""
brightness = max(0, min(255, brightness))
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS_CMD, [brightness])
def set_led_speed(self, speed: int) -> bool:
"""
Set LED effect speed (for breath and rainbow effects).
Args:
speed: 0-255
Returns:
True if successful
"""
speed = max(0, min(255, speed))
return self._ctrl_transfer(GPO_RESID, LED_SPEED_CMD, [speed])
def set_led_color(self, color_bgr: int) -> bool:
"""
Set LED color for breath or single color mode.
Args:
color_bgr: Color in 0xBBGGRR format
Returns:
True if successful
"""
payload = list(struct.pack('<I', color_bgr))
return self._ctrl_transfer(GPO_RESID, LED_COLOR_CMD, payload)
def set_led_doa_color(self, base_color_bgr: int, doa_color_bgr: int) -> bool:
"""
Set LED colors for DoA (Direction of Arrival) mode.
Args:
base_color_bgr: Base color in 0xBBGGRR format
doa_color_bgr: DoA color in 0xBBGGRR format
Returns:
True if successful
"""
payload = list(struct.pack('<II', base_color_bgr, doa_color_bgr))
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR_CMD, payload)
def set_led_ring_color(self, pixels: list[RGB], brightness: float = 1.0) -> bool:
"""
Set custom LED frame for ring mode (12 individual LEDs).
Args:
pixels: List of 12 RGB tuples
brightness: Brightness multiplier 0.0-1.0
Returns:
True if successful
"""
if len(pixels) != 12:
_LOGGER.error("Expected 12 pixels, got %d", len(pixels))
return False
# Build 12 uint32 values in 0xBBGGRR format
payload = b''
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)))
color_int = (bb << 16) | (bg << 8) | br
payload += struct.pack('<I', color_int)
return self._ctrl_transfer(GPO_RESID, LED_RING_COLOR_CMD, payload)
# =======================================================================
# Convenience Methods
# =======================================================================
def set_led_state(
self,
effect: int,
color_bgr: Optional[int] = None,
brightness: Optional[int] = None,
speed: Optional[int] = None
) -> None:
"""
Set complete LED state in one call.
Args:
effect: Effect mode (0-5)
color_bgr: Color in 0xBBGGRR format (for effects 1, 3)
brightness: Brightness 0-255 (for effects 1, 2)
speed: Speed 0-255 (for effects 1, 2)
"""
self.set_led_effect(effect)
if color_bgr is not None:
self.set_led_color(color_bgr)
if brightness is not None:
self.set_led_brightness(brightness)
if speed is not None:
self.set_led_speed(speed)