Files
satellite/docs/development/multi-event-server-plan.md
T

919 lines
28 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.
# План: Множественные подключения event сервисов к Wyoming Satellite
**Дата:** 2026-05-20
**Статус:** Готов к реализации
**Ветка:** `feature/multi-event-server`
**Директория для разработки:** `~/projects/satellite-dev`
---
## Стратегия разработки
### Изоляция разработки
**Текущая версия (production):**
- Директория: `~/projects/Satellite`
- Ветка: `master`
- Статус: ✅ Рабочая версия, используется в production
**Версия для разработки:**
- Директория: `~/projects/satellite-dev`
- Ветка: `feature/multi-event-server`
- Статус: 🚧 Экспериментальная версия
### Преимущества такого подхода
- ✅ **Полная изоляция** — эксперименты не влияют на рабочую систему
- ✅ **Быстрый откат** — просто переключаемся на `~/projects/Satellite`
- ✅ **Чистая история** — merge в master только после тестирования
- ✅ **Параллельная работа** — можно продолжать фиксить баги в Satellite пока идёт разработка
---
## Архитектура
### Текущее состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
│ 10500 (один клиент!)
▼
┌─────────────────┐
│ Event Service │
│ (клиент) │
└─────────────────┘
```
**Проблема:** Satellite поддерживает **только одного** event клиента через `event.uri`
### Целевое состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
│ 10600 (event │ │
│ server) │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ LED │ │ MQTT │ │ Logger │
│ Service │ │ Service │ │ Service │
└─────────┘ └──────────┘ └──────────┘
```
**Решение:** Event сервер на satellite для множественных подключений
---
## Этапы реализации
### Этап 1: Подготовка (30 мин)
#### 1.1 Проверка окружения
```bash
# Production версия (не трогать!)
cd ~/projects/Satellite
git status # Должен быть clean
# Development версия
cd ~/projects/satellite-dev
git status # Должен быть на feature/multi-event-server
```
#### 1.2 Настройка venv для satellite-dev
```bash
# Создание отдельного venv
cd ~/projects/satellite-dev
python3 -m venv ~/.venv_satellite_dev
source ~/.venv_satellite_dev/bin/activate
# Установка зависимостей
pip install -r requirements.txt
# Установка пакета в editable режиме
pip install -e .
```
#### 1.3 Проверка что всё работает
```bash
cd ~/projects/satellite-dev
source ~/.venv_satellite_dev/bin/activate
# Проверка импорта
python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"
# Проверка что тестовый клиент работает
python3 examples/test_event_client.py --help # После создания
```
```python
#!/usr/bin/env python3
"""Test event client для подключения к satellite."""
import argparse
import asyncio
import logging
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test_event_client")
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
await client.connect()
logger.info("Connected!")
while True:
event = await client.read_event()
if event is None:
logger.warning("Server disconnected")
break
logger.info(f"Event: {event}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--uri", default="tcp://127.0.0.1:10600")
args = parser.parse_args()
asyncio.run(main(args))
```
---
### Этап 2: Модификация `wyoming_satellite/settings.py` (1 час)
**Добавить настройки event сервера:**
```python
@dataclass(frozen=True)
class EventSettings(ServiceSettings):
"""External event service settings."""
startup: Optional[List[str]] = None
streaming_start: Optional[List[str]] = None
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
played: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
synthesize: Optional[List[str]] = None
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
# === НОВЫЕ ПОЛЯ: Event server ===
server_enabled: bool = False
"""True если event сервер должен быть запущен."""
server_host: str = "0.0.0.0"
"""Host для event сервера."""
server_port: int = 10600
"""Port для event сервера."""
```
**Итого изменений:** ~20 строк
---
### Этап 3: Модификация `wyoming_satellite/satellite.py` (3-4 часа)
#### 3.1 Добавить атрибуты для event сервера
В `SatelliteBase.__init__()` после существующих атрибутов:
```python
# Event server (новый)
self._event_server: Optional[asyncio.Server] = None
self._event_clients: List[asyncio.StreamWriter] = []
self._event_clients_lock = asyncio.Lock()
```
**Импорт:** `List` из `typing` (если ещё не импортирован)
#### 3.2 Метод для рассылки событий всем клиентам
После существующих методов добавить:
```python
async def _broadcast_to_clients(self, event: Event) -> None:
"""
Отправить событие всем подключённым event клиентам.
Мёртвые подключения автоматически удаляются.
"""
async with self._event_clients_lock:
if not self._event_clients:
return
dead_clients = []
for i, writer in enumerate(self._event_clients):
try:
await async_write_event(event, writer)
except Exception:
_LOGGER.debug(
f"Failed to send event to client {i}, removing"
)
dead_clients.append(i)
# Удалить мёртвые подключения
for i in reversed(dead_clients):
self._event_clients.pop(i)
```
#### 3.3 Заменить `forward_event()`
**Было:**
```python
async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service."""
if self._event_queue is not None:
self._event_queue.put_nowait(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 event server clients
"""
# Legacy event service (через TCP клиент)
if self._event_queue is not None:
self._event_queue.put_nowait(event)
# Event server клиенты
await self._broadcast_to_clients(event)
```
#### 3.4 Event сервер для подключений
Добавить методы после `_event_task_proc()`:
```python
async def start_event_server(self) -> None:
"""
Запустить сервер для event клиентов.
Клиенты могут подключаться к порту server_port и получать все события.
"""
async def handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> None:
client_addr = writer.get_extra_info('peername')
_LOGGER.info(f"Event client connected: {client_addr}")
async with self._event_clients_lock:
self._event_clients.append(writer)
try:
# Ждать отключения клиента
await reader.read(1)
except asyncio.IncompleteReadError:
pass # Нормальное отключение
except Exception as exc:
_LOGGER.debug(f"Event client error: {exc}")
finally:
async with self._event_clients_lock:
if writer in self._event_clients:
self._event_clients.remove(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
_LOGGER.info(f"Event client disconnected: {client_addr}")
self._event_server = await asyncio.start_server(
handle_client,
self.settings.event.server_host,
self.settings.event.server_port
)
_LOGGER.info(
f"Event server listening on "
f"{self.settings.event.server_host}:{self.settings.event.server_port}"
)
async def stop_event_server(self) -> None:
"""
Остановить event сервер и закрыть все подключения.
"""
_LOGGER.info("Stopping event server...")
if self._event_server:
self._event_server.close()
await self._event_server.wait_closed()
self._event_server = None
# Закрыть все подключения
async with self._event_clients_lock:
for writer in self._event_clients:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._event_clients.clear()
_LOGGER.info("Event server stopped")
```
#### 3.5 Интеграция в жизненный цикл
**В `_start()`:**
```python
async def _start(self) -> None:
"""Connect to services."""
self.state = State.STARTING
await self._connect_to_services()
# Запустить event сервер (новый)
if self.settings.event.server_enabled:
await self.start_event_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
# Остановить event сервер (новый)
if self._event_server:
await self.stop_event_server()
await self._disconnect_from_services()
self._disable_ping()
self.state = State.STOPPED
```
**Итого изменений:** ~200 строк
---
### Этап 4: Модификация `wyoming_satellite/__main__.py` (30 мин)
#### 4.1 Добавить CLI аргументы
После существующих аргументов (перед `args = parser.parse_args()`):
```python
# Event server
parser.add_argument(
"--event-server-enabled",
action="store_true",
help="Enable event server for multiple event clients (default: disabled)"
)
parser.add_argument(
"--event-server-host",
default="0.0.0.0",
help="Host for event server (default: 0.0.0.0)"
)
parser.add_argument(
"--event-server-port",
type=int,
default=10600,
help="Port for event server (default: 10600)"
)
```
#### 4.2 Передать настройки в SatelliteSettings
В существующем `EventSettings(...)`:
```python
event=EventSettings(
uri=args.event_uri,
command=split_command(args.event_command),
startup=split_command(args.startup_command),
streaming_start=split_command(args.streaming_start_command),
streaming_stop=split_command(args.streaming_stop_command),
detect=split_command(args.detect_command),
detection=split_command(args.detection_command),
played=split_command(args.tts_played_command),
transcript=split_command(args.transcript_command),
stt_start=split_command(args.stt_start_command),
stt_stop=split_command(args.stt_stop_command),
synthesize=split_command(args.synthesize_command),
tts_start=split_command(args.tts_start_command),
tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command),
connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command),
# === НОВЫЕ ПОЛЯ ===
server_enabled=args.event_server_enabled,
server_host=args.event_server_host,
server_port=args.event_server_port,
),
```
**Итого изменений:** ~30 строк
---
### Этап 5: Тестовый event клиент (1 час)
#### 5.1 Создать `examples/test_event_client.py`
```python
#!/usr/bin/env python3
"""
Test event client для подключения к Wyoming Satellite.
Подключается к event server и получает все события.
Используется для тестирования и отладки.
Пример использования:
python3 test_event_client.py --uri tcp://127.0.0.1:10600
"""
import argparse
import asyncio
import logging
import signal
import sys
from datetime import datetime
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("test_event_client")
stop_event = asyncio.Event()
def signal_handler(sig, frame):
logger.info(f"Получен сигнал {sig}, завершаю...")
stop_event.set()
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
try:
await client.connect()
logger.info("✅ Connected!")
event_count = 0
while not stop_event.is_set():
try:
event = await asyncio.wait_for(
client.read_event(),
timeout=5.0
)
if event is None:
logger.warning("⚠️ Server disconnected")
break
event_count += 1
event_type = event.get("type", "unknown")
logger.info(f"📨 [{event_count}] {event_type}")
if args.verbose:
logger.debug(f" Data: {event}")
except asyncio.TimeoutError:
# Проверка stop_event
continue
except Exception as exc:
logger.error(f"❌ Error reading event: {exc}")
break
logger.info(f"Всего получено событий: {event_count}")
except Exception as exc:
logger.error(f"❌ Error: {exc}")
finally:
try:
await client.disconnect()
except Exception:
pass
logger.info("👋 Disconnected")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Test event client для Wyoming Satellite"
)
parser.add_argument(
"--uri",
default="tcp://127.0.0.1:10600",
help="URI для подключения (default: tcp://127.0.0.1:10600)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Выводить полные данные событий"
)
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(main(args))
except KeyboardInterrupt:
pass
```
#### 5.2 Сделать исполняемым
```bash
chmod +x examples/test_event_client.py
```
---
### Этап 6: Тестирование (2-3 часа)
#### 6.1 Запустить satellite с event сервером
```bash
cd ~/projects/Satellite
source ~/.venv_satellite/bin/activate
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600 \
--debug
```
**Ожидаемый вывод:**
```
INFO: Ready
INFO: Event server listening on 0.0.0.0:10600
INFO: Слушаем tcp://0.0.0.0:10700
```
#### 6.2 Проверить подключения
**Терминал 1: Home Assistant (порт 10700)**
```bash
# Эмуляция подключения HA (или реальное HA)
# HA должен подключиться автоматически через Zeroconf
```
**Терминал 2: test_event_client #1 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 3: test_event_client #2 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 4:原有 wyoming-led.py (порт 10500)**
```bash
source ~/.venv_satellite/bin/activate
cd examples
python3 wyoming-led.py --host 127.0.0.1 --port 10500
```
#### 6.3 Проверить логи
**В логе satellite:**
```
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 1
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 2
INFO: Connected to server # Home Assistant
```
**Проверить:**
- ✅ Все 3 клиента получают события
- ✅ Home Assistant продолжает работать (порт 10700)
- ✅ При отключении клиента satellite не падает
- ✅ wyoming-led.py работает через старый порт 10500
#### 6.4 Тест на устойчивость
```bash
# Отключить клиента 1 (Ctrl+C)
# Проверить что клиент 2 продолжает работать
# Отключить wyoming-led.py
# Проверить что event клиенты работают
# Отключить Home Assistant
# Проверить что event клиенты работают
```
---
### Этап 7: Обновление документации (30 мин)
#### 7.1 `docs/integration/mqtt-led.md`
Добавить секцию после "Архитектура USB контроллера":
```markdown
## Event Server (множественные подключения)
Начиная с версии 2026.06, Wyoming Satellite поддерживает event server для
множественных подключений.
### Преимущества
- ✅ **Масштабируемость:** Неограниченное количество event клиентов
- ✅ **Надёжность:** Падение клиента не влияет на satellite
- ✅ **Гибкость:** Каждый клиент выбирает какие события обрабатывать
### Запуск
```bash
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
### Подключение клиента
```python
from wyoming.client import AsyncClient
client = AsyncClient.from_uri("tcp://127.0.0.1:10600")
await client.connect()
while True:
event = await client.read_event()
# Обработка события
```
### Обратная совместимость
Старый `wyoming-led.py` продолжает работать через `--event-uri`:
```bash
python3 -m wyoming_satellite \
--event-uri tcp://127.0.0.1:10500 \
--event-server-enabled \
--event-server-port 10600
```
Оба метода работают параллельно!
```
#### 7.2 `docs/deployment/systemd-services.md`
Обновить секцию `wyoming-event.service`:
```markdown
### Event Server (новый)
Для поддержки множественных event клиентов добавьте в сервис:
```ini
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 \
/home/sirmrak/projects/Satellite/wyoming_satellite/__main__.py \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
Клиенты подключаются к порту 10600:
```bash
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
```
#### 7.3 `DEPLOY.md`
Добавить секцию после "LED управление":
```markdown
## Event Server (множественные подключения)
Для подключения нескольких event сервисов одновременно:
```bash
python3 -m wyoming_satellite \
--event-server-enabled \
--event-server-port 10600
```
**Примеры использования:**
- LED управление + MQTT уведомления + логирование в базу
- Несколько независимых обработчиков событий
- Тестовые клиенты для отладки
**Документация:** [Event Server](docs/integration/mqtt-led.md#event-server)
```
---
### Этап 8: Merge в master (после успешного тестирования)
```bash
# Проверка что всё работает
git status
git diff master
# Merge
git checkout master
git pull origin master
git merge feature/multi-event-server
# Push
git push origin master
# Удалить ветку (опционально)
git branch -d feature/multi-event-server
```
---
## Итоговые изменения
| Файл | Тип | Изменения | Строк |
|------|-----|-----------|-------|
| `wyoming_satellite/settings.py` | Изменён | EventSettings + server настройки | ~20 |
| `wyoming_satellite/satellite.py` | Изменён | Event server + broadcast | ~200 |
| `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~30 |
| `examples/test_event_client.py` | Новый | Тестовый клиент | ~80 |
| `docs/integration/mqtt-led.md` | Изменён | Документация event server | ~40 |
| `docs/deployment/systemd-services.md` | Изменён | Обновление сервисов | ~20 |
| `DEPLOY.md` | Изменён | Quick start | ~15 |
| **ВСЕГО** | | | **~405 строк** |
---
## Риски и план Б
| Риск | Вероятность | Влияние | Решение |
|------|-------------|---------|---------|
| Конфликт портов (10600 занят) | Низкая | Среднее | Проверка `lsof -i :10600`, смена порта |
| Утечка памяти (клиенты) | Средняя | Высокое | Логирование подключений, таймауты |
| Падение satellite | Низкая | Критичное | Тестирование в ветке, backup файлов |
| Home Assistant отключится | Низкая | Критичное | Порт 10700 не меняется, обратная совместимость |
| Event клиенты не получают события | Средняя | Среднее | Проверка `_broadcast_to_clients()`, логирование |
### План Б: Откат
Если что-то идёт не так:
```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
```
---
## Контрольный список
### Перед началом
- [ ] Создана ветка `feature/multi-event-server`
- [ ] Созданы backup файлов
- [ ] Текущая версия работает стабильно
### После этапа 3
- [ ] satellite.py изменён без синтаксических ошибок
- [ ] `python3 -m py_compile wyoming_satellite/satellite.py` проходит
- [ ] Импорт `from wyoming_satellite.satellite import SatelliteBase` работает
### После этапа 4
- [ ] `python3 -m wyoming_satellite --help` показывает новые аргументы
- [ ] `--event-server-enabled` распознаётся
### После этапа 5
- [ ] `examples/test_event_client.py --help` работает
- [ ] Клиент подключается к порту 10600
### После этапа 6
- [ ] Satellite запускается с `--event-server-enabled`
- [ ] Multiple clients can connect
- [ ] Все клиенты получают события
- [ ] Home Assistant работает (порт 10700)
- [ ] wyoming-led.py работает (порт 10500)
- [ ] При отключении клиента satellite не падает
### После этапа 7
- [ ] Документация обновлена
- [ ] Примеры в документации работают
### Перед merge
- [ ] Все тесты пройдены
- [ ] Документация актуальна
- [ ] Backup файлы удалены (опционально)
---
## Приложения
### A. Пример события в event клиенте
```json
{
"type": "voice-started",
"data": {}
}
```
```json
{
"type": "voice-stopped",
"data": {}
}
```
```json
{
"type": "transcript",
"data": {
"text": "привет дроид"
}
}
```
### B. Диагностика
```bash
# Проверка портов
lsof -i :10600
lsof -i :10700
lsof -i :10500
# Проверка подключений
netstat -tlnp | grep python
# Логи satellite
journalctl -u wyoming-satelite-vosk.service -f
# Тест подключения
telnet 127.0.0.1 10600
```
### C. Сравнение подходов
| Аспект | Старый (push) | Новый (pull) |
|--------|---------------|--------------|
| Подключения | 1 satellite → 1 event | N clients → 1 satellite |
| Отказоустойчивость | Падение event → satellite работает | Падение client → satellite работает |
| Масштабируемость | Ограничено 1 сервисом | Неограниченно клиентов |
| Сложность | Проще | Сложнее (управление клиентами) |
| Гибкость | Низкая | Высокая (каждый клиент выбирает события) |
| Обратная совместимость | ✅ Работает | ✅ Работает через event.uri |
---
**Конец документа**