pax_global_header00006660000000000000000000000064144332764270014526gustar00rootroot0000000000000052 comment=351aa87a6e5232956ef73727b3ac41e26505293a jjlawren-sonos-websocket-351aa87/000077500000000000000000000000001443327642700170205ustar00rootroot00000000000000jjlawren-sonos-websocket-351aa87/.github/000077500000000000000000000000001443327642700203605ustar00rootroot00000000000000jjlawren-sonos-websocket-351aa87/.github/dependabot.yml000066400000000000000000000004641443327642700232140ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" time: "11:00" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily time: "11:00" open-pull-requests-limit: 10 jjlawren-sonos-websocket-351aa87/.github/workflows/000077500000000000000000000000001443327642700224155ustar00rootroot00000000000000jjlawren-sonos-websocket-351aa87/.github/workflows/pypi.yml000066400000000000000000000012271443327642700241230ustar00rootroot00000000000000name: PyPI on: workflow_dispatch: ~ release: types: [published] env: DEFAULT_PYTHON: "3.10" jobs: pypi: name: Publish to PyPI runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install dependencies and build run: | pip install -U pip pip install -U build python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} jjlawren-sonos-websocket-351aa87/.gitignore000066400000000000000000000000471443327642700210110ustar00rootroot00000000000000*.pyc *.egg *.egg-info .python-version jjlawren-sonos-websocket-351aa87/.pre-commit-config.yaml000066400000000000000000000011431443327642700233000ustar00rootroot00000000000000repos: - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.0.261 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black args: - --quiet - repo: local hooks: - id: pylint name: pylint entry: pylint language: system types: [python] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.2.0 hooks: - id: mypy - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: no-commit-to-branch jjlawren-sonos-websocket-351aa87/README.md000066400000000000000000000014601443327642700203000ustar00rootroot00000000000000# sonos-websocket Async Python library to communicate with Sonos devices over websockets. ## Example use: Audio Clips Sonos audio clip functionality will overlay playback of the provided media on top of currently playing music. The music playback volume will be lowered while the audio clip is played and automatically returned to its original level when finished. This feature is especially useful for text-to-speech and alert sounds. The below shows how to run `sonos-websocket` as a script: ``` python -m sonos_websocket \ --ip_addr 192.168.1.88 \ --uri https://freetestdata.com/wp-content/uploads/2021/09/Free_Test_Data_100KB_MP3.mp3 \ --volume 15 ``` Basic use of how to integrate the package can be found [here](https://github.com/jjlawren/sonos-websocket/blob/main/sonos_websocket/__main__.py). jjlawren-sonos-websocket-351aa87/pyproject.toml000066400000000000000000000034211443327642700217340ustar00rootroot00000000000000[build-system] requires = ["setuptools >= 61.2"] build-backend = "setuptools.build_meta" [project] name = "sonos-websocket" description = "An asynchronous Python library to communicate with Sonos devices over websockets." readme = "README.md" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Home Automation", "Topic :: Multimedia :: Sound/Audio", "Topic :: Multimedia :: Sound/Audio :: Players", "Topic :: Software Development :: Libraries :: Python Modules" ] requires-python = ">=3.10" dynamic = ["version", "dependencies"] [[project.authors]] name = "Jason Lawrence" email = "jjlawren@users.noreply.github.com" [project.license] text = "MIT License" [project.urls] Homepage = "https://github.com/jjlawren/sonos-websocket" [tool.setuptools] packages = ["sonos_websocket"] [tool.setuptools.dynamic.version] attr = "sonos_websocket.__init__.__version__" [tool.setuptools.dynamic.dependencies] file = ["requirements.txt"] [tool.ruff] target-version = "py310" select = [ "C", # complexity "D", # docstrings "E", # pycodestyle "F", # pyflakes/autoflake "I", # isort "RUF006", # Store a reference to the return value of asyncio.create_task "W", # pycodestyle ] ignore = [ "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "E501", # line too long ] [tool.ruff.isort] combine-as-imports = true force-sort-within-sections = true [tool.pylint] disable = [ "invalid-name", ] jjlawren-sonos-websocket-351aa87/requirements-dev.txt000066400000000000000000000001361443327642700230600ustar00rootroot00000000000000aiohttp==3.8.4 async_timeout==4.0.2; python_version < "3.11" pre-commit==3.3.2 pylint==2.17.4 jjlawren-sonos-websocket-351aa87/requirements.txt000066400000000000000000000000571443327642700223060ustar00rootroot00000000000000aiohttp async_timeout; python_version < "3.11" jjlawren-sonos-websocket-351aa87/sonos_websocket/000077500000000000000000000000001443327642700222275ustar00rootroot00000000000000jjlawren-sonos-websocket-351aa87/sonos_websocket/__init__.py000066400000000000000000000001771443327642700243450ustar00rootroot00000000000000"""Library to communicate with Sonos websockets.""" from .websocket import SonosWebsocket # noqa: F401 __version__ = "0.1.2" jjlawren-sonos-websocket-351aa87/sonos_websocket/__main__.py000066400000000000000000000017451443327642700243300ustar00rootroot00000000000000"""Commandline example to play an audio clip.""" import argparse import asyncio import logging from .websocket import SonosWebsocket logging.basicConfig(level=logging.DEBUG) async def main(options): """Entrypoint when running as a script.""" websocket = SonosWebsocket(options.ip_addr) await websocket.connect() await websocket.play_clip( uri=options.uri, volume=options.volume, ) await websocket.close() if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument( "--ip_addr", "-i", required=True, help="IP address of Sonos device" ) parser.add_argument( "--uri", "-u", required=True, help="URI to audio file to play as clip" ) parser.add_argument( "--volume", "-V", type=int, required=False, help="Volume level to play at [0-100]", ) args = parser.parse_args() loop = asyncio.get_event_loop() loop.run_until_complete(main(args)) jjlawren-sonos-websocket-351aa87/sonos_websocket/const.py000066400000000000000000000001541443327642700237270ustar00rootroot00000000000000"""Constants used by sonos_websocket.""" API_KEY = "123e4567-e89b-12d3-a456-426655440000" MAX_ATTEMPTS = 2 jjlawren-sonos-websocket-351aa87/sonos_websocket/exception.py000066400000000000000000000006771443327642700246110ustar00rootroot00000000000000"""Exceptions used by sonos_websocket.""" class SonosWebsocketError(Exception): """Base exception for Sonos websockets.""" class SonosWSConnectionError(SonosWebsocketError): """Connection error encountered on a Sonos websocket.""" class Unauthorized(SonosWebsocketError): """Authorization rejected when connecting to a Sonos websocket.""" class Unsupported(SonosWebsocketError): """Action is unsupported on this device.""" jjlawren-sonos-websocket-351aa87/sonos_websocket/websocket.py000066400000000000000000000146301443327642700245730ustar00rootroot00000000000000"""Handler for Sonos websockets.""" import asyncio import logging import sys from typing import Any, cast import aiohttp from aiohttp import WSMsgType from .const import API_KEY, MAX_ATTEMPTS from .exception import ( SonosWebsocketError, SonosWSConnectionError, Unauthorized, Unsupported, ) if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout else: from asyncio import timeout as asyncio_timeout _LOGGER = logging.getLogger(__name__) class SonosWebsocket: """Sonos websocket handler.""" def __init__( self, ip_addr: str, player_id: str | None = None, household_id: str | None = None, session: aiohttp.ClientSession | None = None, ) -> None: """Initialize the websocket instance.""" self.uri = f"wss://{ip_addr}:1443/websocket/api" self._own_session = not session self.session = session or aiohttp.ClientSession() self.ws: aiohttp.ClientWebSocketResponse | None = None self._household_id = household_id self._player_id = player_id self._connect_lock = asyncio.Lock() async def connect(self) -> None: """Open a persistent websocket connection and act on events.""" async with self._connect_lock: if self.ws and not self.ws.closed: _LOGGER.warning("Websocket is already connected") return _LOGGER.debug("Opening websocket to %s", self.uri) headers = { "X-Sonos-Api-Key": API_KEY, "Sec-WebSocket-Protocol": "v1.api.smartspeaker.audio", } try: async with asyncio_timeout(3): self.ws = await self.session.ws_connect( self.uri, headers=headers, verify_ssl=False ) except aiohttp.ClientResponseError as exc: if exc.code == 401: _LOGGER.error("Credentials rejected: %s", exc) raise Unauthorized("Credentials rejected") from exc raise SonosWSConnectionError( f"Unexpected response received: {exc}" ) from exc except aiohttp.ClientConnectionError as exc: raise SonosWSConnectionError(f"Connection error: {exc}") from exc except asyncio.TimeoutError as exc: raise SonosWSConnectionError("Connection timed out") from exc except Exception as exc: # pylint: disable=broad-except raise SonosWSConnectionError(f"Unknown error: {exc}") from exc _LOGGER.debug("Successfully connected to %s", self.uri) async def close(self): """Close the websocket connection.""" if self.ws and not self.ws.closed: await self.ws.close() if self._own_session and self.session and not self.session.closed: await self.session.close() async def send_command( self, command: dict[str, Any], options: dict[str, Any] | None = None ) -> list[dict[str, Any]]: """Send commands over the websocket and handle their responses.""" attempt = 1 while attempt <= MAX_ATTEMPTS: if not self.ws or self.ws.closed: await self.connect() assert self.ws payload = [command, options or {}] _LOGGER.debug("Sending command: %s", payload) try: async with asyncio_timeout(3): await self.ws.send_json(payload) msg = await self.ws.receive() except asyncio.TimeoutError: _LOGGER.error("Command timed out") except ConnectionResetError: # Websocket closing self.ws = None _LOGGER.debug("Websocket connection reset, will try again") else: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): _LOGGER.debug("Websocket closed, will try again") elif msg.type != WSMsgType.TEXT: _LOGGER.error("Received non-text message: %s", msg.type.name) else: return msg.json() attempt += 1 command_name = command.get("command", "Empty") raise SonosWebsocketError( f"{command_name} command failed after {MAX_ATTEMPTS} attempts" ) async def play_clip( self, uri: str, volume: int | None = None ) -> list[dict[str, Any]]: """Play an audio clip.""" command = { "namespace": "audioClip:1", "command": "loadAudioClip", "playerId": await self.get_player_id(), } options: dict[str, Any] = { "name": "Sonos Websocket", "appId": "com.jjlawren.sonos_websocket", "streamUrl": uri, } if volume: options["volume"] = volume return await self.send_command(command, options) async def get_household_id(self) -> str: """Get the household ID of this device. Note: This is an invalid command but returns the household ID anyway. """ if self._household_id: return self._household_id response, _ = await self.send_command({}) if household_id := response.get("householdId"): self._household_id = household_id return household_id raise SonosWebsocketError("Could not determine household ID") async def get_groups(self) -> list[dict[str, Any]]: """Return the current group and player configuration.""" command = { "namespace": "groups:1", "command": "getGroups", "householdId": await self.get_household_id(), } return await self.send_command(command) async def get_player_id(self) -> str: """Retrieve the player identifier for this speaker.""" if self._player_id: return self._player_id response, data = await self.get_groups() if not response["success"]: raise SonosWebsocketError(f"Retrieving group data failed: {data}") if player := next( (p for p in data["players"] if p["websocketUrl"] == self.uri), None ): if "AUDIO_CLIP" not in player["capabilities"]: raise Unsupported("Device does not support AUDIO_CLIP") self._player_id = cast(str, player["id"]) return self._player_id raise SonosWebsocketError("No matching player found in group data")