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

28 KiB
Raw Blame History

План: Множественные подключения 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 Проверка окружения

# Production версия (не трогать!)
cd ~/projects/Satellite
git status  # Должен быть clean

# Development версия
cd ~/projects/satellite-dev
git status  # Должен быть на feature/multi-event-server

1.2 Настройка venv для satellite-dev

# Создание отдельного 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 Проверка что всё работает

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  # После создания
#!/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 сервера:

@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__() после существующих атрибутов:

# 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 Метод для рассылки событий всем клиентам

После существующих методов добавить:

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()

Было:

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)

Стало:

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():

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():

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():

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()):

# 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(...):

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

#!/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 Сделать исполняемым

chmod +x examples/test_event_client.py

Этап 6: Тестирование (2-3 часа)

6.1 Запустить satellite с event сервером

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)

# Эмуляция подключения HA (или реальное HA)
# HA должен подключиться автоматически через Zeroconf

Терминал 2: test_event_client #1 (порт 10600)

source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600

Терминал 3: test_event_client #2 (порт 10600)

source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600

Терминал 4:原有 wyoming-led.py (порт 10500)

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 Тест на устойчивость

# Отключить клиента 1 (Ctrl+C)
# Проверить что клиент 2 продолжает работать

# Отключить wyoming-led.py
# Проверить что event клиенты работают

# Отключить Home Assistant
# Проверить что event клиенты работают

Этап 7: Обновление документации (30 мин)

7.1 docs/integration/mqtt-led.md

Добавить секцию после "Архитектура USB контроллера":

## 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

Подключение клиента

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:

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:

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


---

### Этап 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(), логирование

План Б: Откат

Если что-то идёт не так:

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 клиенте

{
  "type": "voice-started",
  "data": {}
}
{
  "type": "voice-stopped",
  "data": {}
}
{
  "type": "transcript",
  "data": {
    "text": "привет дроид"
  }
}

B. Диагностика

# Проверка портов
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

Конец документа