Files
satellite/docs/development/websocket-server-plan.md
T

1027 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: 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] **Документация обновлена** — plan.md актуализирован
- [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
### 🔄 В процессе
- [ ] **Этап WS-4: Тестирование** — проверка WebSocket клиентов
- [ ] **Этап WS-5: Документация** — обновление docs/
### ✅ Выполнено
- [x] **Этап WS-6: Audio level** — трансляция RMS громкости микрофона
### ⏳ Ожидается
- [ ] 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 |
---
**Конец документа**