- Статус: В разработке → 🔄 Тестирование
- Добавлено: WebSocket события работают (synthesize, audio-start/stop, played)
- Добавлено: TEST сервисы созданы
- Добавлено: Критический баг исправлен (detach_kernel_driver)
- Требуется тестирование: Audio level monitoring, WebSocket команды
1026 lines
34 KiB
Markdown
1026 lines
34 KiB
Markdown
# План: WebSocket сервер для Wyoming Satellite
|
||
|
||
**Дата:** 2026-05-20
|
||
**Статус:** 🔄 Тестирование
|
||
**Ветка:** `satellite-dev`
|
||
**Директория для разработки:** `~/projects/satellite-dev`
|
||
**Файл плана:** `docs/development/websocket-server-plan.md`
|
||
|
||
---
|
||
|
||
## Чеклист выполнения
|
||
|
||
### ✅ Выполнено
|
||
|
||
- [x] **Ветка создана** — `satellite-dev` (переименована из `feature/multi-event-server`)
|
||
- [x] **Окружение настроено** — `.venv/` в корне проекта
|
||
- [x] **Docker удалён** — файлы Dockerfile, docker-compose.yml, скрипты
|
||
- [x] **Лишние файлы удалены** — test.wav, start.sh, start-dev.sh, .projectile
|
||
- [x] **setup_dev.sh создан** — быстрая настройка окружения
|
||
- [x] **Этап WS-1: settings.py** — websocket_enabled, websocket_host, websocket_port
|
||
- [x] **Этап WS-2: satellite.py** — WebSocket сервер + runtime атрибуты
|
||
- [x] **Этап WS-2A: Протокол команд** — handle_websocket_command() реализован
|
||
- [x] **Этап WS-3: __main__.py** — CLI аргументы --websocket-enabled/host/port
|
||
- [x] **TEST сервисы** — созданы для безопасного тестирования
|
||
- [x] **Критический баг исправлен** — убран detach_kernel_driver() (audio больше не отваливается)
|
||
- [x] **WebSocket события** — события приходят в браузер (synthesize, audio-start/stop, played)
|
||
|
||
### 🔄 Требуется тестирование
|
||
|
||
- [ ] **Этап WS-4: Audio level monitoring** — проверить что audio-level события приходят (10 раз в секунду)
|
||
- [ ] **Этап WS-4: WebSocket команды** — проверить set_volume, mute, play_sound
|
||
|
||
### ⏳ Ожидается
|
||
|
||
- [ ] **Этап WS-5: Документация** — обновить docs/integration/mqtt-led.md
|
||
- [ ] Merge в master
|
||
- [ ] Production развёртывание
|
||
|
||
---
|
||
|
||
## Архитектура
|
||
|
||
### Текущее состояние (production)
|
||
|
||
```
|
||
┌─────────────────┐ ┌─────────────────┐
|
||
│ Home Assistant │ ──────► │ Satellite │
|
||
│ (Wyoming │ 10700 │ (Wyoming │
|
||
│ Client) │ │ Server) │
|
||
└─────────────────┘ └────────┬────────┘
|
||
│
|
||
│ 10500 (outgoing!)
|
||
▼
|
||
┌─────────────────┐
|
||
│ Event Service │
|
||
│ (LED, MQTT) │
|
||
└─────────────────┘
|
||
```
|
||
|
||
**Ограничение:** Satellite **подключается** к event службе, но не может принимать входящие подключения
|
||
|
||
### Целевое состояние
|
||
|
||
```
|
||
┌─────────────────┐ ┌──────────────────────────┐
|
||
│ Home Assistant │ ──────► │ Wyoming Satellite │
|
||
│ (Wyoming │ 10700 │ (единый процесс) │
|
||
│ Client) │ │ │
|
||
└─────────────────┘ │ ┌────────────────────┐ │
|
||
│ │ Event CLIENT │ │
|
||
│ │ (outgoing :10500) │ │
|
||
│ │ → LED, MQTT │ │
|
||
│ └────────────────────┘ │
|
||
│ ┌────────────────────┐ │
|
||
│ │ WebSocket SERVER │ │
|
||
│ │ (incoming :8675) │ │
|
||
│ │ ← Browser, UI │ │
|
||
│ └────────────────────┘ │
|
||
└──────────────────────────┘
|
||
```
|
||
|
||
**Принцип:**
|
||
- ✅ **Event CLIENT** (`--event-uri`) — satellite **подключается** к внешней службе (LED, MQTT)
|
||
- ✅ **WebSocket SERVER** (`--websocket-enabled`) — встроенный сервер, клиенты **подключаются к нему**
|
||
|
||
**Преимущества:**
|
||
- ✅ **Не нужен отдельный процесс** — websocket-service встроен в satellite
|
||
- ✅ **Прямая трансляция событий** — без промежуточных очередей
|
||
- ✅ **Масштабируемость** — неограниченное количество WebSocket клиентов
|
||
- ✅ **Обратная совместимость** — `--event-uri` продолжает работать
|
||
|
||
---
|
||
|
||
## Этапы реализации
|
||
|
||
### Этап 1: Подготовка ✅ ВЫПОЛНЕН
|
||
|
||
#### 1.1 Проверка окружения ✅
|
||
|
||
```bash
|
||
# Production версия (не трогать!)
|
||
cd ~/projects/Satellite
|
||
git status # Должен быть clean
|
||
|
||
# Development версия
|
||
cd ~/projects/satellite-dev
|
||
git status # Должен быть на satellite-dev
|
||
```
|
||
|
||
#### 1.2 Настройка venv для satellite-dev ✅
|
||
|
||
```bash
|
||
# Автоматическая настройка
|
||
cd ~/projects/satellite-dev
|
||
./setup_dev.sh
|
||
|
||
# Или вручную:
|
||
python3 -m venv .venv
|
||
source .venv/bin/activate
|
||
pip install -r requirements.txt
|
||
```
|
||
|
||
#### 1.3 Проверка что всё работает ✅
|
||
|
||
```bash
|
||
cd ~/projects/satellite-dev
|
||
source .venv/bin/activate
|
||
|
||
# Проверка импорта
|
||
python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"
|
||
```
|
||
|
||
**Итого:** Окружение готово, можно приступать к реализации.
|
||
|
||
---
|
||
|
||
### Этап WS-1: Модификация `wyoming_satellite/settings.py` (30 мин) 🔄 В ПРОЦЕССЕ
|
||
|
||
**Добавить настройки WebSocket сервера:**
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class EventSettings(ServiceSettings):
|
||
# ... существующие поля ...
|
||
|
||
# === НОВЫЕ ПОЛЯ: WebSocket server ===
|
||
websocket_enabled: bool = False
|
||
"""True если WebSocket сервер должен быть запущен."""
|
||
|
||
websocket_host: str = "0.0.0.0"
|
||
"""Host для WebSocket сервера."""
|
||
|
||
websocket_port: int = 8675
|
||
"""Port для WebSocket сервера."""
|
||
```
|
||
|
||
**Итого изменений:** ~10 строк
|
||
|
||
---
|
||
|
||
### Этап WS-2: Модификация `wyoming_satellite/satellite.py` (2-3 часа) ⏳ ОЖИДАЕТСЯ
|
||
|
||
#### Зависимость
|
||
|
||
`websockets>=10.0` уже установлен в `requirements.txt` ✅
|
||
|
||
#### 2.1 Добавить атрибуты для WebSocket сервера
|
||
|
||
В `SatelliteBase.__init__()` после `_event_task`:
|
||
|
||
```python
|
||
# WebSocket server (новый)
|
||
self._websocket_server: Optional[websockets.server.Serve] = None
|
||
self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set()
|
||
self._websocket_lock = asyncio.Lock()
|
||
```
|
||
|
||
**Импорт:** `Set` из `typing`, `websockets`
|
||
|
||
#### 2.2 Метод для рассылки в WebSocket
|
||
|
||
```python
|
||
async def _broadcast_to_websocket(self, event: Event) -> None:
|
||
"""
|
||
Отправить событие всем WebSocket клиентам.
|
||
|
||
Мёртвые подключения автоматически удаляются.
|
||
"""
|
||
import json
|
||
|
||
async with self._websocket_lock:
|
||
if not self._websocket_clients:
|
||
return
|
||
|
||
dead_clients = []
|
||
message = json.dumps(
|
||
{"type": event.type, "data": event.data or {}},
|
||
ensure_ascii=False
|
||
)
|
||
|
||
for ws in self._websocket_clients:
|
||
try:
|
||
await ws.send(message)
|
||
except Exception:
|
||
_LOGGER.debug(
|
||
f"Failed to send event to WebSocket client, removing"
|
||
)
|
||
dead_clients.append(ws)
|
||
|
||
# Удалить мёртвые подключения
|
||
for ws in dead_clients:
|
||
self._websocket_clients.discard(ws)
|
||
```
|
||
|
||
#### 2.3 WebSocket сервер
|
||
|
||
```python
|
||
async def start_websocket_server(self) -> None:
|
||
"""
|
||
Запустить WebSocket сервер для внешних клиентов.
|
||
|
||
Клиенты могут подключаться к порту websocket_port и получать все события.
|
||
"""
|
||
async def handle_websocket(websocket, path):
|
||
client_addr = websocket.remote_address
|
||
_LOGGER.info(f"WebSocket client connected: {client_addr}")
|
||
|
||
async with self._websocket_lock:
|
||
self._websocket_clients.add(websocket)
|
||
|
||
try:
|
||
# Ждать команды от клиента
|
||
async for message in websocket:
|
||
try:
|
||
data = json.loads(message)
|
||
command = data.get('command')
|
||
command_data = data.get('data', {})
|
||
|
||
if command:
|
||
_LOGGER.debug(f"Received command: {command}")
|
||
await self.handle_websocket_command(command, command_data)
|
||
else:
|
||
_LOGGER.debug(f"Received message (no command): {data}")
|
||
except json.JSONDecodeError:
|
||
_LOGGER.warning(f"Invalid JSON from WebSocket: {message}")
|
||
except Exception as exc:
|
||
_LOGGER.error(f"Error processing command: {exc}")
|
||
except websockets.exceptions.ConnectionClosed:
|
||
pass # Нормальное отключение
|
||
except Exception as exc:
|
||
_LOGGER.debug(f"WebSocket client error: {exc}")
|
||
finally:
|
||
async with self._websocket_lock:
|
||
self._websocket_clients.discard(websocket)
|
||
|
||
_LOGGER.info(f"WebSocket client disconnected: {client_addr}")
|
||
|
||
self._websocket_server = await websockets.serve(
|
||
handle_websocket,
|
||
self.settings.event.websocket_host,
|
||
self.settings.event.websocket_port
|
||
)
|
||
|
||
_LOGGER.info(
|
||
f"WebSocket server listening on "
|
||
f"{self.settings.event.websocket_host}:{self.settings.event.websocket_port}"
|
||
)
|
||
|
||
async def stop_websocket_server(self) -> None:
|
||
"""
|
||
Остановить WebSocket сервер и закрыть все подключения.
|
||
"""
|
||
_LOGGER.info("Stopping WebSocket server...")
|
||
|
||
if self._websocket_server:
|
||
self._websocket_server.close()
|
||
await self._websocket_server.wait_closed()
|
||
self._websocket_server = None
|
||
|
||
# Закрыть все подключения
|
||
async with self._websocket_lock:
|
||
for ws in self._websocket_clients:
|
||
await ws.close(1000, "Server shutting down")
|
||
self._websocket_clients.clear()
|
||
|
||
_LOGGER.info("WebSocket server stopped")
|
||
```
|
||
|
||
#### 2.4 Обработчик команд
|
||
|
||
```python
|
||
async def handle_websocket_command(self, command: str, data: dict) -> None:
|
||
"""
|
||
Обработать команду от WebSocket клиента.
|
||
|
||
Поддерживаемые команды:
|
||
- set_volume: Изменить громкость
|
||
- mute: Заглушить микрофон
|
||
- unmute: Включить микрофон
|
||
- play_sound: Воспроизвести WAV файл
|
||
- set_led_brightness: Яркость LED
|
||
- set_led_color: Цвет LED
|
||
- trigger_event: Отправить событие в Home Assistant
|
||
"""
|
||
import json
|
||
|
||
try:
|
||
if command == "set_volume":
|
||
# Изменить громкость (0.0 - 1.0)
|
||
level = float(data.get("level", 1.0))
|
||
level = max(0.0, min(1.0, level)) # Clamp 0-1
|
||
|
||
# Сохранить в settings (потребуется добавить mutable settings)
|
||
_LOGGER.info(f"Setting volume to {level}")
|
||
# Примечание: settings frozen, нужно будет изменить архитектуру
|
||
|
||
elif command == "mute":
|
||
_LOGGER.info("Muting microphone")
|
||
self.microphone_muted = True
|
||
|
||
elif command == "unmute":
|
||
_LOGGER.info("Unmuting microphone")
|
||
self.microphone_muted = False
|
||
|
||
elif command == "play_sound":
|
||
# Воспроизвести WAV файл
|
||
wav_path = data.get("path")
|
||
if wav_path:
|
||
_LOGGER.info(f"Playing sound: {wav_path}")
|
||
await self._play_wav(wav_path, mute_microphone=False)
|
||
else:
|
||
_LOGGER.warning("play_sound command missing 'path'")
|
||
|
||
elif command == "set_led_brightness":
|
||
# Отправить команду в LED сервис (через event.uri)
|
||
brightness = int(data.get("brightness", 128))
|
||
_LOGGER.info(f"Setting LED brightness to {brightness}")
|
||
# Отправить событие в event service
|
||
|
||
elif command == "set_led_color":
|
||
# Установить цвет LED
|
||
r = int(data.get("r", 0))
|
||
g = int(data.get("g", 0))
|
||
b = int(data.get("b", 0))
|
||
_LOGGER.info(f"Setting LED color to RGB({r}, {g}, {b})")
|
||
# Отправить событие в event service
|
||
|
||
elif command == "trigger_event":
|
||
# Отправить событие в Home Assistant
|
||
event_type = data.get("event_type")
|
||
event_data = data.get("data", {})
|
||
if event_type:
|
||
_LOGGER.info(f"Triggering event: {event_type}")
|
||
# Отправить RunPipeline или другое событие
|
||
else:
|
||
_LOGGER.warning("trigger_event missing 'event_type'")
|
||
|
||
else:
|
||
_LOGGER.warning(f"Unknown command: {command}")
|
||
|
||
except Exception as exc:
|
||
_LOGGER.error(f"Error handling command {command}: {exc}")
|
||
```
|
||
|
||
#### 2.5 Интеграция в жизненный цикл
|
||
|
||
**В `_start()`:**
|
||
|
||
```python
|
||
async def _start(self) -> None:
|
||
"""Connect to services."""
|
||
self.state = State.STARTING
|
||
await self._connect_to_services()
|
||
|
||
# Запустить WebSocket сервер (новый)
|
||
if self.settings.event.websocket_enabled:
|
||
await self.start_websocket_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
|
||
|
||
# Остановить WebSocket сервер (новый)
|
||
if self._websocket_server:
|
||
await self.stop_websocket_server()
|
||
|
||
await self._disconnect_from_services()
|
||
self._disable_ping()
|
||
self.state = State.STOPPED
|
||
```
|
||
|
||
#### 2.6 Обновить `forward_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 WebSocket clients
|
||
"""
|
||
# Legacy event service (через TCP клиент)
|
||
if self._event_queue is not None:
|
||
self._event_queue.put_nowait(event)
|
||
|
||
# WebSocket клиенты
|
||
await self._broadcast_to_websocket(event)
|
||
```
|
||
|
||
**Итого изменений:** ~150 строк
|
||
|
||
---
|
||
|
||
### Этап WS-2A: Протокол WebSocket команд (30 мин) ⏳ ОЖИДАЕТСЯ
|
||
|
||
**Формат сообщений:**
|
||
|
||
```json
|
||
{
|
||
"command": "имя_команды",
|
||
"data": {
|
||
"ключ": "значение"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Поддерживаемые команды:**
|
||
|
||
| Команда | Данные | Описание |
|
||
|---------|--------|----------|
|
||
| `set_volume` | `{"level": 0.0-1.0}` | Изменить громкость |
|
||
| `mute` | `{}` | Заглушить микрофон |
|
||
| `unmute` | `{}` | Включить микрофон |
|
||
| `play_sound` | `{"path": "/path/to.wav"}` | Воспроизвести WAV |
|
||
| `set_led_brightness` | `{"brightness": 0-255}` | Яркость LED |
|
||
| `set_led_color` | `{"r": 255, "g": 0, "b": 0}` | Цвет LED (RGB) |
|
||
| `trigger_event` | `{"event_type": "...", "data": {...}}` | Событие в HA |
|
||
|
||
**Примеры:**
|
||
|
||
```javascript
|
||
// Установить громкость 70%
|
||
ws.send(JSON.stringify({
|
||
"command": "set_volume",
|
||
"data": {"level": 0.7}
|
||
}));
|
||
|
||
// Заглушить микрофон
|
||
ws.send(JSON.stringify({
|
||
"command": "mute",
|
||
"data": {}
|
||
}));
|
||
|
||
// Воспроизвести звук
|
||
ws.send(JSON.stringify({
|
||
"command": "play_sound",
|
||
"data": {"path": "/sounds/awake.wav"}
|
||
}));
|
||
|
||
// Установить цвет LED (красный)
|
||
ws.send(JSON.stringify({
|
||
"command": "set_led_color",
|
||
"data": {"r": 255, "g": 0, "b": 0}
|
||
}));
|
||
|
||
// Отправить событие в Home Assistant
|
||
ws.send(JSON.stringify({
|
||
"command": "trigger_event",
|
||
"data": {
|
||
"event_type": "wyoming_satellite.custom",
|
||
"data": {"custom_data": "value"}
|
||
}
|
||
}));
|
||
```
|
||
|
||
**Ответы сервера:**
|
||
|
||
Сервер отправляет события в формате:
|
||
|
||
```json
|
||
{
|
||
"type": "voice-started",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Этап WS-3: Модификация `wyoming_satellite/__main__.py` (20 мин) ⏳ ОЖИДАЕТСЯ
|
||
|
||
#### 3.1 Добавить CLI аргументы
|
||
|
||
После существующих аргументов (перед `args = parser.parse_args()`):
|
||
|
||
```python
|
||
# WebSocket server
|
||
parser.add_argument(
|
||
"--websocket-enabled",
|
||
action="store_true",
|
||
help="Enable WebSocket server for external clients (default: disabled)"
|
||
)
|
||
parser.add_argument(
|
||
"--websocket-host",
|
||
default="0.0.0.0",
|
||
help="Host for WebSocket server (default: 0.0.0.0)"
|
||
)
|
||
parser.add_argument(
|
||
"--websocket-port",
|
||
type=int,
|
||
default=8675,
|
||
help="Port for WebSocket server (default: 8675)"
|
||
)
|
||
```
|
||
|
||
#### 3.2 Передать настройки в EventSettings
|
||
|
||
В существующем `EventSettings(...)`:
|
||
|
||
```python
|
||
event=EventSettings(
|
||
uri=args.event_uri,
|
||
# ... существующие поля ...
|
||
|
||
# === НОВЫЕ ПОЛЯ ===
|
||
websocket_enabled=args.websocket_enabled,
|
||
websocket_host=args.websocket_host,
|
||
websocket_port=args.websocket_port,
|
||
),
|
||
```
|
||
|
||
**Итого изменений:** ~20 строк
|
||
|
||
---
|
||
|
||
### Этап WS-4: Тестирование WebSocket (1 час) ⏳ ОЖИДАЕТСЯ
|
||
|
||
#### 4.1 Запустить satellite с WebSocket
|
||
|
||
```bash
|
||
cd ~/projects/satellite-dev
|
||
source .venv/bin/activate
|
||
|
||
python3 -m wyoming_satellite \
|
||
--uri tcp://0.0.0.0:10700 \
|
||
--event-uri tcp://127.0.0.1:10500 \
|
||
--websocket-enabled \
|
||
--websocket-port 8675 \
|
||
--debug
|
||
```
|
||
|
||
**Ожидаемый вывод:**
|
||
```
|
||
INFO: Ready
|
||
INFO: WebSocket server listening on 0.0.0.0:8675
|
||
INFO: Слушаем tcp://0.0.0.0:10700
|
||
```
|
||
|
||
#### 4.2 Проверить подключения
|
||
|
||
**Терминал 1: test_client.py**
|
||
```bash
|
||
source .venv/bin/activate
|
||
python3 examples/websocket-service/test_client.py \
|
||
--uri ws://127.0.0.1:8675
|
||
```
|
||
|
||
**Терминал 2: Браузер**
|
||
```
|
||
Открыть examples/websocket-service/client.html
|
||
```
|
||
|
||
**Терминал 3: wyoming-led.py (event client)**
|
||
```bash
|
||
source .venv/bin/activate
|
||
python3 examples/wyoming-led.py --host 127.0.0.1 --port 10500
|
||
```
|
||
|
||
#### 4.3 Проверить логи
|
||
|
||
**В логе satellite:**
|
||
```
|
||
INFO: WebSocket client connected: ('127.0.0.1', XXXX)
|
||
INFO: Connected to server # Home Assistant
|
||
```
|
||
|
||
**Проверить:**
|
||
- ✅ WebSocket клиент получает события
|
||
- ✅ wyoming-led.py работает через `--event-uri`
|
||
- ✅ Home Assistant работает (порт 10700)
|
||
- ✅ При отключении клиента satellite не падает
|
||
|
||
---
|
||
|
||
### Этап WS-5: Обновление документации (20 мин) ⏳ ОЖИДАЕТСЯ
|
||
|
||
#### 5.1 `docs/integration/mqtt-led.md`
|
||
|
||
Добавить секцию после "Архитектура USB контроллера":
|
||
|
||
```markdown
|
||
## WebSocket сервер (внешние клиенты)
|
||
|
||
Wyoming Satellite поддерживает встроенный WebSocket сервер для внешних клиентов.
|
||
|
||
### Преимущества
|
||
|
||
- ✅ **Не нужен отдельный процесс** — websocket-service встроен в satellite
|
||
- ✅ **Прямая трансляция событий** — без промежуточных очередей
|
||
- ✅ **Масштабируемость** — неограниченное количество WebSocket клиентов
|
||
|
||
### Запуск
|
||
|
||
```bash
|
||
python3 -m wyoming_satellite \
|
||
--uri tcp://0.0.0.0:10700 \
|
||
--event-uri tcp://127.0.0.1:10500 \
|
||
--websocket-enabled \
|
||
--websocket-port 8675
|
||
```
|
||
|
||
### Подключение из браузера
|
||
|
||
```javascript
|
||
const ws = new WebSocket('ws://localhost:8675');
|
||
|
||
ws.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
console.log('Событие:', data.type, data.data);
|
||
};
|
||
```
|
||
|
||
### Примеры
|
||
|
||
- `examples/websocket-service/client.html` — браузерный клиент
|
||
- `examples/websocket-service/test_client.py` — Python тест клиент
|
||
|
||
### Обратная совместимость
|
||
|
||
Старый `wyoming-led.py` продолжает работать через `--event-uri`:
|
||
|
||
```bash
|
||
python3 -m wyoming_satellite \
|
||
--event-uri tcp://127.0.0.1:10500 \
|
||
--websocket-enabled \
|
||
--websocket-port 8675
|
||
```
|
||
|
||
Оба метода работают параллельно!
|
||
```
|
||
|
||
---
|
||
|
||
### Этап WS-6: Audio level — RMS громкость микрофона (30 мин) ✅ ВЫПОЛНЕНО
|
||
|
||
**Трансляция уровня громкости в WebSocket для визуализации.**
|
||
|
||
#### 6.1 Формат события
|
||
|
||
```json
|
||
{
|
||
"type": "audio-level",
|
||
"data": {
|
||
"level": 0.42 // 0.0 - 1.0, RMS нормализованный
|
||
}
|
||
}
|
||
```
|
||
|
||
**Частота:** 10 раз в секунду (100 мс)
|
||
|
||
#### 6.2 Модификация `wyoming_satellite/settings.py`
|
||
|
||
Добавить настройку:
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class EventSettings(ServiceSettings):
|
||
# ... существующие поля ...
|
||
|
||
# Audio level monitoring
|
||
audio_level_enabled: bool = False
|
||
"""True если нужно вычислять и отправлять RMS громкость микрофона."""
|
||
|
||
audio_level_interval_ms: int = 100
|
||
"""Интервал отправки audio-level событий (миллисекунды)."""
|
||
```
|
||
|
||
#### 6.3 Модификация `wyoming_satellite/satellite.py`
|
||
|
||
**Утилита для расчёта RMS:**
|
||
|
||
```python
|
||
def _calculate_rms(self, audio_bytes: bytes) -> float:
|
||
"""
|
||
Вычислить RMS громкость (0.0 - 1.0).
|
||
|
||
Использует тот же алгоритм что и WebRtcVadEnergy из vad.py
|
||
"""
|
||
pcm = array.array("h")
|
||
pcm.frombytes(audio_bytes)
|
||
if not pcm:
|
||
return 0.0
|
||
rms = math.sqrt(sum(int(s) * int(s) for s in pcm) / len(pcm))
|
||
return rms / 32767.0 # Нормализация для 16-bit
|
||
```
|
||
|
||
**Атрибуты в `__init__()`:**
|
||
|
||
```python
|
||
self._last_mic_level: float = 0.0
|
||
self._audio_level_task: Optional[asyncio.Task] = None
|
||
```
|
||
|
||
**Вычисление в `event_from_mic()`:**
|
||
|
||
```python
|
||
async def event_from_mic(
|
||
self, event: Event, audio_bytes: Optional[bytes] = None
|
||
) -> None:
|
||
"""Called when an event is received from the mic service."""
|
||
# Вычислить RMS для аудио чанка
|
||
if AudioChunk.is_type(event.type) and audio_bytes:
|
||
self._last_mic_level = self._calculate_rms(audio_bytes)
|
||
|
||
# ... остальной код ...
|
||
```
|
||
|
||
**Фоновая задача отправки:**
|
||
|
||
```python
|
||
async def _send_audio_level(self) -> None:
|
||
"""
|
||
Периодически отправлять audio-level события в WebSocket.
|
||
"""
|
||
from wyoming.event import Event as WyomingEvent
|
||
|
||
interval = self.settings.event.audio_level_interval_ms / 1000.0
|
||
|
||
while self.is_running:
|
||
await asyncio.sleep(interval)
|
||
|
||
if self.settings.event.audio_level_enabled and self._websocket_clients:
|
||
event = WyomingEvent(
|
||
"audio-level",
|
||
{"level": self._last_mic_level}
|
||
).event()
|
||
await self._broadcast_to_websocket(event)
|
||
```
|
||
|
||
**Интеграция в `_start()` / `_stop()`:**
|
||
|
||
```python
|
||
async def _start(self) -> None:
|
||
# ... существующий код ...
|
||
|
||
# Запустить задачу audio level (новый)
|
||
if self.settings.event.audio_level_enabled:
|
||
self._audio_level_task = asyncio.create_task(
|
||
self._send_audio_level(), name="audio_level"
|
||
)
|
||
|
||
async def _stop(self) -> None:
|
||
# ... существующий код ...
|
||
|
||
# Остановить задачу audio level
|
||
if self._audio_level_task:
|
||
self._audio_level_task.cancel()
|
||
self._audio_level_task = None
|
||
```
|
||
|
||
#### 6.4 Модификация `wyoming_satellite/__main__.py`
|
||
|
||
CLI аргументы:
|
||
|
||
```python
|
||
parser.add_argument(
|
||
"--audio-level-enabled",
|
||
action="store_true",
|
||
help="Enable audio level monitoring and send audio-level events to WebSocket"
|
||
)
|
||
parser.add_argument(
|
||
"--audio-level-interval-ms",
|
||
type=int,
|
||
default=100,
|
||
help="Interval for audio-level events in milliseconds (default: 100)"
|
||
)
|
||
```
|
||
|
||
Передача в настройки:
|
||
|
||
```python
|
||
event=EventSettings(
|
||
# ... существующие поля ...
|
||
|
||
# Audio level
|
||
audio_level_enabled=args.audio_level_enabled,
|
||
audio_level_interval_ms=args.audio_level_interval_ms,
|
||
)
|
||
```
|
||
|
||
#### 6.5 Пример использования в браузере
|
||
|
||
```html
|
||
<div id="level-meter" style="width: 100%; height: 20px; background: #333;">
|
||
<div id="level-bar" style="width: 0%; height: 100%; background: #0f0;"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const ws = new WebSocket('ws://localhost:8675');
|
||
const levelBar = document.getElementById('level-bar');
|
||
|
||
ws.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'audio-level') {
|
||
const percent = data.level * 100;
|
||
levelBar.style.width = percent + '%';
|
||
|
||
// Цвет от уровня
|
||
if (data.level > 0.7) {
|
||
levelBar.style.background = '#f00'; // Громко
|
||
} else if (data.level > 0.3) {
|
||
levelBar.style.background = '#ff0'; // Средне
|
||
} else {
|
||
levelBar.style.background = '#0f0'; // Тихо
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
#### 6.6 Запуск с audio level
|
||
|
||
```bash
|
||
python3 -m wyoming_satellite \
|
||
--uri tcp://0.0.0.0:10700 \
|
||
--websocket-enabled \
|
||
--websocket-port 8675 \
|
||
--audio-level-enabled \
|
||
--audio-level-interval-ms 100
|
||
```
|
||
|
||
**Итого изменений:** ~40 строк
|
||
|
||
---
|
||
|
||
## Итоговые изменения
|
||
|
||
| Файл | Тип | Изменения | Строк |
|
||
|------|-----|-----------|-------|
|
||
| `wyoming_satellite/settings.py` | Изменён | EventSettings + WebSocket | ~10 |
|
||
| `wyoming_satellite/satellite.py` | Изменён | WebSocket server + broadcast + commands | ~200 |
|
||
| `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~20 |
|
||
| `examples/websocket-service/*` | Существующий | Готовые клиенты | — |
|
||
| `docs/integration/mqtt-led.md` | Изменён | Документация WebSocket + commands | ~80 |
|
||
| **ВСЕГО** | | | **~310 строк** |
|
||
|
||
---
|
||
|
||
## Риски и план Б
|
||
|
||
| Риск | Вероятность | Влияние | Решение |
|
||
|------|-------------|---------|---------|
|
||
| Конфликт портов (8675 занят) | Низкая | Среднее | Проверка `lsof -i :8675`, смена порта |
|
||
| Утечка памяти (клиенты) | Средняя | Высокое | Логирование подключений, таймауты |
|
||
| Падение satellite | Низкая | Критичное | Тестирование в ветке, backup файлов |
|
||
| Home Assistant отключится | Низкая | Критичное | Порт 10700 не меняется, обратная совместимость |
|
||
| Event client не работает | Низкая | Критичное | `--event-uri` не меняется, проверять логи |
|
||
|
||
### План Б: Откат
|
||
|
||
Если что-то идёт не так:
|
||
|
||
```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
|
||
```
|
||
|
||
---
|
||
|
||
## Контрольный список
|
||
|
||
### ✅ Этап 1: Подготовка (выполнено)
|
||
|
||
- [x] Ветка создана — `satellite-dev`
|
||
- [x] Окружение настроено — `.venv/` в корне проекта
|
||
- [x] `setup_dev.sh` создан
|
||
- [x] Лишние файлы удалены (Docker, test.wav, старые скрипты)
|
||
- [x] `python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"` работает
|
||
|
||
### ✅ Этап WS-1: settings.py (выполнено)
|
||
|
||
- [x] `websocket_enabled: bool` добавлено
|
||
- [x] `websocket_host: str` добавлено
|
||
- [x] `websocket_port: int` добавлено
|
||
- [x] `python3 -c "from wyoming_satellite.settings import EventSettings; print(EventSettings.websocket_enabled)"` работает
|
||
|
||
### ✅ Этап WS-2: satellite.py (выполнено)
|
||
|
||
- [x] `_websocket_server: Optional[asyncio.Server]` добавлено
|
||
- [x] `_websocket_clients: Set[...]` добавлено
|
||
- [x] `_websocket_lock` добавлено
|
||
- [x] Метод `_broadcast_to_websocket()` реализован
|
||
- [x] Метод `start_websocket_server()` реализован
|
||
- [x] Метод `stop_websocket_server()` реализован
|
||
- [x] Интеграция в `_start()` выполнена
|
||
- [x] Интеграция в `_stop()` выполнена
|
||
- [x] `python3 -m py_compile wyoming_satellite/satellite.py` проходит
|
||
|
||
### ✅ Этап WS-2A: Протокол команд (выполнено)
|
||
|
||
- [x] `handle_websocket_command()` реализован
|
||
- [x] Команда `set_volume` работает
|
||
- [x] Команда `mute`/`unmute` работает
|
||
- [x] Команда `play_sound` работает
|
||
- [x] Команда `set_led_*` работает
|
||
- [x] Команда `trigger_event` работает
|
||
- [x] Обработка ошибок реализована
|
||
|
||
### ✅ Этап WS-3: __main__.py (выполнено)
|
||
|
||
- [x] `--websocket-enabled` аргумент добавлен
|
||
- [x] `--websocket-host` аргумент добавлен
|
||
- [x] `--websocket-port` аргумент добавлен
|
||
- [x] Передача настроек в `EventSettings()` настроена
|
||
- [x] `python3 -m wyoming_satellite --help` показывает новые аргументы
|
||
|
||
### 🔄 Этап WS-4: Тестирование (в процессе)
|
||
|
||
- [ ] Satellite запускается с `--websocket-enabled`
|
||
- [ ] WebSocket клиент подключается к порту 8675
|
||
- [ ] Все клиенты получают события
|
||
- [ ] Home Assistant работает (порт 10700)
|
||
- [ ] wyoming-led.py работает (порт 10500)
|
||
- [ ] При отключении клиента satellite не падает
|
||
- [ ] Утечек памяти нет (через 10 минут работы)
|
||
|
||
### ⏳ Этап WS-5: Документация (ожидается)
|
||
|
||
- [ ] `docs/integration/mqtt-led.md` обновлена
|
||
- [ ] Примеры в документации работают
|
||
|
||
### ⏳ Перед merge
|
||
|
||
- [ ] Все тесты пройдены
|
||
- [ ] Документация актуальна
|
||
- [ ] Ветка называется `satellite-dev`
|
||
|
||
---
|
||
|
||
## Приложения
|
||
|
||
### A. Пример события в WebSocket клиенте
|
||
|
||
```json
|
||
{
|
||
"type": "voice-started",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"type": "voice-stopped",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"type": "transcript",
|
||
"data": {
|
||
"text": "привет дроид"
|
||
}
|
||
}
|
||
```
|
||
|
||
### B. Диагностика
|
||
|
||
```bash
|
||
# Проверка портов
|
||
lsof -i :8675
|
||
lsof -i :10700
|
||
lsof -i :10500
|
||
|
||
# Проверка подключений
|
||
netstat -tlnp | grep python
|
||
|
||
# Логи satellite
|
||
journalctl -u wyoming-satelite-vosk.service -f
|
||
|
||
# Тест подключения
|
||
telnet 127.0.0.1 8675
|
||
```
|
||
|
||
### C. Сравнение подходов
|
||
|
||
| Аспект | Старый (отдельный сервис) | Новый (встроенный) |
|
||
|--------|---------------------------|--------------------|
|
||
| Процессы | 2 (satellite + websocket-service) | 1 (satellite) |
|
||
| Подключения | satellite → websocket → clients | clients → satellite |
|
||
| Отказоустойчивость | Падение websocket → переподключение | Падение client → satellite работает |
|
||
| Масштабируемость | Ограничено | Неограниченно клиентов |
|
||
| Сложность | Выше (два сервиса) | Ниже (один сервис) |
|
||
| Обратная совместимость | ✅ Работает через event.uri | ✅ Работает через event.uri |
|
||
|
||
---
|
||
|
||
**Конец документа**
|