656 lines
22 KiB
Markdown
656 lines
22 KiB
Markdown
# План: WebSocket сервер для Wyoming Satellite
|
||
|
||
**Дата:** 2026-05-20
|
||
**Статус:** В разработке
|
||
**Ветка:** `satellite-dev`
|
||
**Директория для разработки:** `~/projects/satellite-dev`
|
||
|
||
---
|
||
|
||
## Чеклист выполнения
|
||
|
||
### ✅ Выполнено
|
||
|
||
- [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 |
|
||
|
||
---
|
||
|
||
**Конец документа**
|