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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Файлы сервисов используют плейсхолдеры, которые автоматически заменяются при установке:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
**Конец документа**
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Связанное
|
||||
|
||||
@@ -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()
|
||||
+82
-52
@@ -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)
|
||||
return True
|
||||
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...")
|
||||
try:
|
||||
run_xvf_command("LED_EFFECT", [0])
|
||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||
logger.info("✅ LED выключены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки: {e}")
|
||||
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()
|
||||
run_xvf_command("VERSION")
|
||||
set_led_state("idle")
|
||||
|
||||
uri = f"tcp://{args.host}:{args.port}"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user