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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user