feat: добавить WebSocket сервер для приёма событий и команд

- 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-05-20 16:51:27 +03:00
co-authored by Qwen-Coder
parent b1c6ef3385
commit 6398dc6a41
3 changed files with 247 additions and 1 deletions
+23
View File
@@ -257,6 +257,24 @@ async def main() -> None:
help="Command to run when a timer finishes", 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 # Sounds
parser.add_argument( parser.add_argument(
"--awake-wav", help="WAV file to play when wake word is detected" "--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), error=split_command(args.error_command),
connected=split_command(args.connected_command), connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_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( timer=TimerSettings(
started=split_command(args.timer_started_command), started=split_command(args.timer_started_command),
+214 -1
View File
@@ -2,6 +2,7 @@
import array import array
import asyncio import asyncio
import json
import logging import logging
import math import math
import time import time
@@ -49,6 +50,13 @@ from .utils import (
from .vad import SileroVad, WebRtcVadEnergy from .vad import SileroVad, WebRtcVadEnergy
from .webrtc import WebRtcAudio from .webrtc import WebRtcAudio
try:
import websockets
WEBSOCKETS_AVAILABLE = True
except ImportError:
websockets = None # type: ignore
WEBSOCKETS_AVAILABLE = False
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
_PONG_TIMEOUT: Final = 5 _PONG_TIMEOUT: Final = 5
@@ -100,6 +108,15 @@ class SatelliteBase:
self.microphone_muted = False self.microphone_muted = False
self._unmute_microphone_task: Optional[asyncio.Task] = None 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 # Debug audio recording
self.wake_audio_writer: Optional[DebugAudioWriter] = None self.wake_audio_writer: Optional[DebugAudioWriter] = None
self.stt_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None
@@ -234,6 +251,11 @@ class SatelliteBase:
"""Connect to services.""" """Connect to services."""
self.state = State.STARTING self.state = State.STARTING
await self._connect_to_services() await self._connect_to_services()
# Запустить WebSocket сервер (новый)
if self.settings.event.websocket_enabled:
await self.start_websocket_server()
self.state = State.STARTED self.state = State.STARTED
await self.started() await self.started()
@@ -245,6 +267,10 @@ class SatelliteBase:
self.server_id = None self.server_id = None
self._writer = None self._writer = None
# Остановить WebSocket сервер (новый)
if self._websocket_server:
await self.stop_websocket_server()
await self._disconnect_from_services() await self._disconnect_from_services()
self._disable_ping() self._disable_ping()
self.state = State.STOPPED self.state = State.STOPPED
@@ -546,6 +572,10 @@ class SatelliteBase:
audio_bytes, self.settings.mic.volume_multiplier 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: if self._mic_webrtc is not None:
# Noise suppression and auto gain # Noise suppression and auto gain
audio_bytes = self._mic_webrtc(audio_bytes) audio_bytes = self._mic_webrtc(audio_bytes)
@@ -916,10 +946,20 @@ class SatelliteBase:
await asyncio.sleep(self.settings.timer.finished_wav_delay) await asyncio.sleep(self.settings.timer.finished_wav_delay)
async def forward_event(self, event: Event) -> None: 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: if self._event_queue is not None:
self._event_queue.put_nowait(event) self._event_queue.put_nowait(event)
# WebSocket клиенты
await self._broadcast_to_websocket(event)
def _make_event_client(self) -> Optional[AsyncClient]: def _make_event_client(self) -> Optional[AsyncClient]:
"""Create client for event service.""" """Create client for event service."""
if self.settings.event.uri: if self.settings.event.uri:
@@ -963,6 +1003,179 @@ class SatelliteBase:
await _disconnect() 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 # Info
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+10
View File
@@ -178,6 +178,16 @@ class EventSettings(ServiceSettings):
connected: Optional[List[str]] = None connected: Optional[List[str]] = None
disconnected: 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) @dataclass(frozen=True)
class TimerSettings: class TimerSettings: