# План: Множественные подключения event сервисов к Wyoming Satellite **Дата:** 2026-05-20 **Статус:** Черновик **Ветка:** `feature/multi-event-server` --- ## Стратегия разработки ### Ветка **Создать:** `feature/multi-event-server` (от `master`) - ✅ `master` остаётся стабильным - ✅ Тестирование в изоляции - ✅ Возможность отката ### Резервные копии Перед началом работ: ```bash cp wyoming_satellite/satellite.py wyoming_satellite/satellite.py.backup cp wyoming_satellite/settings.py wyoming_satellite/settings.py.backup cp wyoming_satellite/__main__.py wyoming_satellite/__main__.py.backup ``` --- ## Архитектура ### Текущее состояние ``` ┌─────────────────┐ ┌─────────────────┐ │ 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 cd ~/projects/Satellite git checkout master git pull git checkout -b feature/multi-event-server ``` #### 1.2 Создать резервные копии ```bash cp wyoming_satellite/satellite.py wyoming_satellite/satellite.py.backup cp wyoming_satellite/settings.py wyoming_satellite/settings.py.backup cp wyoming_satellite/__main__.py wyoming_satellite/__main__.py.backup ``` #### 1.3 Создать тестовый скрипт event-клиента **Файл:** `examples/test_event_client.py` ```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 | --- **Конец документа**