From 6398dc6a41c0886c6dcf888d6797bddc471d3bf7 Mon Sep 17 00:00:00 2001 From: sirmrak Date: Wed, 20 May 2026 16:51:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20WebSocket=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.py: websocket_enabled, websocket_host, websocket_port - satellite.py: WebSocket сервер + runtime атрибуты (_runtime_volume, _runtime_led_brightness) - satellite.py: handle_websocket_command() для обработки команд - satellite.py: _broadcast_to_websocket() для рассылки событий - satellite.py: интеграция в _start() / _stop() - __main__.py: CLI аргументы --websocket-enabled/host/port Поддерживаемые команды: - set_volume: изменить громкость - mute/unmute: микрофон - play_sound: воспроизвести WAV - set_led_brightness: яркость LED - trigger_event: отправить событие в HA Co-authored-by: Qwen-Coder --- wyoming_satellite/__main__.py | 23 ++++ wyoming_satellite/satellite.py | 215 ++++++++++++++++++++++++++++++++- wyoming_satellite/settings.py | 10 ++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index edc3e90..44e6aa4 100755 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -257,6 +257,24 @@ async def main() -> None: help="Command to run when a timer finishes", ) + # 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)" + ) + # Sounds parser.add_argument( "--awake-wav", help="WAV file to play when wake word is detected" @@ -428,6 +446,11 @@ async def main() -> None: error=split_command(args.error_command), connected=split_command(args.connected_command), disconnected=split_command(args.disconnected_command), + + # WebSocket server + websocket_enabled=args.websocket_enabled, + websocket_host=args.websocket_host, + websocket_port=args.websocket_port, ), timer=TimerSettings( started=split_command(args.timer_started_command), diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index 2793679..287890f 100755 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -2,6 +2,7 @@ import array import asyncio +import json import logging import math import time @@ -49,6 +50,13 @@ from .utils import ( from .vad import SileroVad, WebRtcVadEnergy from .webrtc import WebRtcAudio +try: + import websockets + WEBSOCKETS_AVAILABLE = True +except ImportError: + websockets = None # type: ignore + WEBSOCKETS_AVAILABLE = False + _LOGGER = logging.getLogger() _PONG_TIMEOUT: Final = 5 @@ -100,6 +108,15 @@ class SatelliteBase: self.microphone_muted = False self._unmute_microphone_task: Optional[asyncio.Task] = None + # Runtime настройки (изменяемые через WebSocket команды) + self._runtime_volume: float = 1.0 + self._runtime_led_brightness: int = 128 + + # WebSocket server + self._websocket_server: Optional["websockets.server.Serve"] = None + self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set() # type: ignore + self._websocket_lock = asyncio.Lock() + # Debug audio recording self.wake_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None @@ -234,6 +251,11 @@ class SatelliteBase: """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() @@ -245,6 +267,10 @@ class SatelliteBase: 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 @@ -546,6 +572,10 @@ class SatelliteBase: audio_bytes, self.settings.mic.volume_multiplier ) + # Применить runtime громкость (из WebSocket команд) + if self._runtime_volume != 1.0: + audio_bytes = multiply_volume(audio_bytes, self._runtime_volume) + if self._mic_webrtc is not None: # Noise suppression and auto gain audio_bytes = self._mic_webrtc(audio_bytes) @@ -916,10 +946,20 @@ class SatelliteBase: await asyncio.sleep(self.settings.timer.finished_wav_delay) async def forward_event(self, event: Event) -> None: - """Forward an event to the event service.""" + """ + 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) + def _make_event_client(self) -> Optional[AsyncClient]: """Create client for event service.""" if self.settings.event.uri: @@ -963,6 +1003,179 @@ class SatelliteBase: await _disconnect() + # ------------------------------------------------------------------------- + # WebSocket Server + # ------------------------------------------------------------------------- + + async def start_websocket_server(self) -> None: + """ + Запустить WebSocket сервер для внешних клиентов. + + Клиенты могут подключаться к порту websocket_port и получать все события, + а также отправлять команды (set_volume, mute, play_sound, etc.). + """ + if not WEBSOCKETS_AVAILABLE: + _LOGGER.warning("websockets package not installed, skipping WebSocket server") + return + + async def handle_websocket(websocket, path): # type: ignore + 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: + try: + data = json.loads(message) + command = data.get('command') + command_data = data.get('data', {}) + + if command: + _LOGGER.debug(f"Received command: {command}") + await self.handle_websocket_command(command, command_data) + else: + _LOGGER.debug(f"Received message (no command): {data}") + except json.JSONDecodeError: + _LOGGER.warning(f"Invalid JSON from WebSocket: {message}") + except Exception as exc: + _LOGGER.error(f"Error processing command: {exc}") + except websockets.exceptions.ConnectionClosed: # type: ignore + 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( # type: ignore + 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") + + 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) + + async def handle_websocket_command(self, command: str, data: dict) -> None: + """ + Обработать команду от WebSocket клиента. + + Поддерживаемые команды: + - set_volume: Изменить громкость + - mute: Заглушить микрофон + - unmute: Включить микрофон + - play_sound: Воспроизвести WAV файл + - set_led_brightness: Яркость LED + - trigger_event: Отправить событие в Home Assistant + """ + try: + if command == "set_volume": + # Изменить громкость (0.0 - 1.0) + level = float(data.get("level", 1.0)) + level = max(0.0, min(1.0, level)) # Clamp 0-1 + self._runtime_volume = level + _LOGGER.info(f"Setting volume to {level}") + + elif command == "mute": + _LOGGER.info("Muting microphone") + self.microphone_muted = True + + elif command == "unmute": + _LOGGER.info("Unmuting microphone") + self.microphone_muted = False + + elif command == "play_sound": + # Воспроизвести WAV файл + wav_path = data.get("path") + if wav_path: + _LOGGER.info(f"Playing sound: {wav_path}") + await self._play_wav(wav_path, mute_microphone=False) + else: + _LOGGER.warning("play_sound command missing 'path'") + + elif command == "set_led_brightness": + # Сохранить яркость LED + brightness = int(data.get("brightness", 128)) + brightness = max(0, min(255, brightness)) # Clamp 0-255 + self._runtime_led_brightness = brightness + _LOGGER.info(f"Setting LED brightness to {brightness}") + + elif command == "trigger_event": + # Отправить событие в Home Assistant + event_type = data.get("event_type") + event_data = data.get("data", {}) + if event_type: + _LOGGER.info(f"Triggering event: {event_type}") + # Отправить событие в server + from wyoming.event import Event as WyomingEvent + event = WyomingEvent(event_type, event_data).event() + await self.event_to_server(event) + else: + _LOGGER.warning("trigger_event missing 'event_type'") + + else: + _LOGGER.warning(f"Unknown command: {command}") + + except Exception as exc: + _LOGGER.error(f"Error handling command {command}: {exc}") + # ------------------------------------------------------------------------- # Info # ------------------------------------------------------------------------- diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index 0e9d42b..606a2be 100755 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -178,6 +178,16 @@ class EventSettings(ServiceSettings): connected: Optional[List[str]] = None disconnected: Optional[List[str]] = None + # WebSocket server settings + websocket_enabled: bool = False + """True если WebSocket сервер должен быть запущен.""" + + websocket_host: str = "0.0.0.0" + """Host для WebSocket сервера.""" + + websocket_port: int = 8675 + """Port для WebSocket сервера.""" + @dataclass(frozen=True) class TimerSettings: