# План: 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 актуализирован ### 🔄 В процессе - [ ] **Этап WS-1: settings.py** — добавить websocket_enabled, websocket_host, websocket_port - [ ] **Этап WS-2: satellite.py** — встроенный WebSocket сервер - [ ] **Этап WS-3: __main__.py** — CLI аргументы для WebSocket - [ ] **Этап 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 Проверка окружения ✅ ```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: # Игнорировать входящие сообщения (только рассылка) _LOGGER.debug(f"Received from WebSocket: {message}") 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 Интеграция в жизненный цикл **В `_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.5 Обновить `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-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 ``` Оба метода работают параллельно! ``` --- ## Итоговые изменения | Файл | Тип | Изменения | Строк | |------|-----|-----------|-------| | `wyoming_satellite/settings.py` | Изменён | EventSettings + WebSocket | ~10 | | `wyoming_satellite/satellite.py` | Изменён | WebSocket server + broadcast | ~150 | | `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~20 | | `examples/websocket-service/*` | Существующий | Готовые клиенты | — | | `docs/integration/mqtt-led.md` | Изменён | Документация WebSocket | ~50 | | **ВСЕГО** | | | **~230 строк** | --- ## Риски и план Б | Риск | Вероятность | Влияние | Решение | |------|-------------|---------|---------| | Конфликт портов (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 (в процессе) - [ ] `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-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 клиенте ```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 | --- **Конец документа**