diff --git a/README.md b/README.md index e82c9dd..ddb7926 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ The examples below uses `alsa-utils` to record and play audio: sudo apt-get install alsa-utils ``` - ## Remote Wake Word Detection Run the satellite with remote wake word detection: @@ -186,5 +185,22 @@ Satellites can respond to events from the server by running commands: * `--timer-updated-command` - timer has been paused/unpaused or has time added/removed (json on stdin) * `--timer-cancelled-command` - timer has been cancelled (timer id on stdin) * `--timer-finished-command` - timer has finished (timer id on stdin) +* `--websocket-host` - the hostname or IP address where the WebSocket event service will served on, default is `localhost` +* `--websocket-port` - the port number the WebSocket event service will be served on, default is `8675` +* `--enable-event-websockets` - enable the WebSocket event service to subscribe to satellite events, default is False For more advanced scenarios, use an event service (`--event-uri`). See `wyoming_satellite/example_event_client.py` for a basic client that just logs events. + +## Websocket Event Service + +In order to stream satellite events to a WebSocket client, run with the following configuration. + +```shell +script/run \ +--name 'my satellite' \ +--uri 'tcp://0.0.0.0:10700' \ +--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \ +--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \ +--enable-event-websockets True \ +--websocket-host '0.0.0.0' +``` diff --git a/requirements.txt b/requirements.txt index a1bbfef..2c48be7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ wyoming==1.5.4 zeroconf==0.88.0 pyring-buffer==1.0.0 +websockets==12.0 diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index 4cff57b..16d4c29 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -2,11 +2,16 @@ import argparse import asyncio +import base64 +import json import logging import sys from functools import partial +from typing import Optional from pathlib import Path +import websockets +from wyoming.event import Event from wyoming.info import Attribution, Info, Satellite from wyoming.server import AsyncServer, AsyncTcpServer @@ -297,6 +302,12 @@ async def main() -> None: version=__version__, help="Print version and exit", ) + + # Websockets + parser.add_argument("--websocket-host", default="localhost") + parser.add_argument("--websocket-port", type=int, default=8675) + parser.add_argument("--enable-event-websockets", default=False) + args = parser.parse_args() # Validate args @@ -466,17 +477,62 @@ async def main() -> None: ) satellite_task = asyncio.create_task(satellite.run(), name="satellite run") + queue: "asyncio.Queue[Optional[Event]]" = ( + asyncio.Queue() if args.enable_event_websockets else None + ) try: - await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args)) + if args.enable_event_websockets: + async with websockets.serve( + partial(websocket_connected, queue), + args.websocket_host, + args.websocket_port, + ): + await server.run( + partial(SatelliteEventHandler, wyoming_info, satellite, args, queue) + ) + else: + await server.run( + partial(SatelliteEventHandler, wyoming_info, satellite, args, queue) + ) except KeyboardInterrupt: pass finally: + if args.enable_event_websockets: + queue.put_nowait(None) + await satellite.stop() await satellite_task -# ----------------------------------------------------------------------------- +async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket): + try: + while True: + event = await queue.get() + if event is None: + # Stop signal + break + + await websocket.send( + json.dumps( + { + "type": event.type, + "data": event.data or {}, + "payload": ( + base64.b64encode(event.payload) + .decode("utf-8") + .replace("'", '"') + if event.payload + else None + ), + }, + ensure_ascii=False, + ) + ) + except websockets.ConnectionClosed: + pass + except Exception: + _LOGGER.exception("Error in websocket handler") def run(): diff --git a/wyoming_satellite/event_handler.py b/wyoming_satellite/event_handler.py index 58f046b..401943c 100644 --- a/wyoming_satellite/event_handler.py +++ b/wyoming_satellite/event_handler.py @@ -1,7 +1,9 @@ """Wyoming event handler for satellites.""" import argparse +import asyncio import logging import time +from typing import Optional from wyoming.event import Event from wyoming.info import Describe, Info @@ -20,15 +22,18 @@ def __init__( wyoming_info: Info, satellite: SatelliteBase, cli_args: argparse.Namespace, + queue: asyncio.Queue[Optional[Event]] | None, *args, **kwargs, ) -> None: super().__init__(*args, **kwargs) self.cli_args = cli_args - self.wyoming_info = wyoming_info self.client_id = str(time.monotonic_ns()) + self.queue = queue self.satellite = satellite + self.wyoming_info = wyoming_info + # ------------------------------------------------------------------------- @@ -46,6 +51,9 @@ async def handle_event(self, event: Event) -> bool: # New connection _LOGGER.debug("Connection cancelled: %s", self.client_id) return False + + if self.queue: + await self.queue.put(event) await self.satellite.event_from_server(event)