28 KiB
План: WebSocket сервер для Wyoming Satellite
Дата: 2026-05-20
Статус: В разработке
Ветка: satellite-dev
Директория для разработки: ~/projects/satellite-dev
Файл плана: docs/development/websocket-server-plan.md
Чеклист выполнения
✅ Выполнено
- Ветка создана —
satellite-dev(переименована изfeature/multi-event-server) - Окружение настроено —
.venv/в корне проекта - Docker удалён — файлы Dockerfile, docker-compose.yml, скрипты
- Лишние файлы удалены — test.wav, start.sh, start-dev.sh, .projectile
- setup_dev.sh создан — быстрая настройка окружения
- Документация обновлена — plan.md актуализирован
- Этап WS-1: settings.py — websocket_enabled, websocket_host, websocket_port
- Этап WS-2: satellite.py — WebSocket сервер + runtime атрибуты
- Этап WS-2A: Протокол команд — handle_websocket_command()
- Этап WS-3: __main__.py — CLI аргументы --websocket-enabled/host/port
🔄 В процессе
- Этап WS-4: Тестирование — проверка WebSocket клиентов
- Этап WS-5: Документация — обновление docs/
⏳ Ожидается
- 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 Проверка окружения ✅
# Production версия (не трогать!)
cd ~/projects/Satellite
git status # Должен быть clean
# Development версия
cd ~/projects/satellite-dev
git status # Должен быть на satellite-dev
1.2 Настройка venv для satellite-dev ✅
# Автоматическая настройка
cd ~/projects/satellite-dev
./setup_dev.sh
# Или вручную:
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
1.3 Проверка что всё работает ✅
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 сервера:
@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:
# 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
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 сервер
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 Обработчик команд
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():
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():
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()
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 мин) ⏳ ОЖИДАЕТСЯ
Формат сообщений:
{
"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 |
Примеры:
// Установить громкость 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"}
}
}));
Ответы сервера:
Сервер отправляет события в формате:
{
"type": "voice-started",
"data": {}
}
Этап WS-3: Модификация wyoming_satellite/__main__.py (20 мин) ⏳ ОЖИДАЕТСЯ
3.1 Добавить CLI аргументы
После существующих аргументов (перед args = parser.parse_args()):
# 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(...):
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
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
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)
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 контроллера":
## 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
Подключение из браузера
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:
python3 -m wyoming_satellite \
--event-uri tcp://127.0.0.1:10500 \
--websocket-enabled \
--websocket-port 8675
Оба метода работают параллельно!
---
## Итоговые изменения
| Файл | Тип | Изменения | Строк |
|------|-----|-----------|-------|
| `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: Подготовка (выполнено)
- Ветка создана —
satellite-dev - Окружение настроено —
.venv/в корне проекта setup_dev.shсоздан- Лишние файлы удалены (Docker, test.wav, старые скрипты)
python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"работает
✅ Этап WS-1: settings.py (выполнено)
websocket_enabled: boolдобавленоwebsocket_host: strдобавленоwebsocket_port: intдобавленоpython3 -c "from wyoming_satellite.settings import EventSettings; print(EventSettings.websocket_enabled)"работает
✅ Этап WS-2: satellite.py (выполнено)
_websocket_server: Optional[asyncio.Server]добавлено_websocket_clients: Set[...]добавлено_websocket_lockдобавлено- Метод
_broadcast_to_websocket()реализован - Метод
start_websocket_server()реализован - Метод
stop_websocket_server()реализован - Интеграция в
_start()выполнена - Интеграция в
_stop()выполнена python3 -m py_compile wyoming_satellite/satellite.pyпроходит
✅ Этап WS-2A: Протокол команд (выполнено)
handle_websocket_command()реализован- Команда
set_volumeработает - Команда
mute/unmuteработает - Команда
play_soundработает - Команда
set_led_*работает - Команда
trigger_eventработает - Обработка ошибок реализована
✅ Этап WS-3: __main__.py (выполнено)
--websocket-enabledаргумент добавлен--websocket-hostаргумент добавлен--websocket-portаргумент добавлен- Передача настроек в
EventSettings()настроена 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 клиенте
{
"type": "voice-started",
"data": {}
}
{
"type": "voice-stopped",
"data": {}
}
{
"type": "transcript",
"data": {
"text": "привет дроид"
}
}
B. Диагностика
# Проверка портов
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 |
Конец документа