From 8a38f38cf8af0151e77768bcba01d73540db87e1 Mon Sep 17 00:00:00 2001 From: noahhusby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:55:25 -0400 Subject: [PATCH] feat: change to websocket method --- aiostreammagic/const.py | 3 + aiostreammagic/endpoints.py | 9 + aiostreammagic/models.py | 23 +- aiostreammagic/stream_magic.py | 409 +++++++++++++++++++++++---------- poetry.lock | 97 +++++++- pyproject.toml | 1 + 6 files changed, 408 insertions(+), 134 deletions(-) create mode 100644 aiostreammagic/const.py create mode 100644 aiostreammagic/endpoints.py diff --git a/aiostreammagic/const.py b/aiostreammagic/const.py new file mode 100644 index 0000000..8edd192 --- /dev/null +++ b/aiostreammagic/const.py @@ -0,0 +1,3 @@ +import logging + +_LOGGER = logging.getLogger(__package__) diff --git a/aiostreammagic/endpoints.py b/aiostreammagic/endpoints.py new file mode 100644 index 0000000..6236ad8 --- /dev/null +++ b/aiostreammagic/endpoints.py @@ -0,0 +1,9 @@ +"""List of endpoints for the StreamMagic API.""" + +INFO = "/system/info" +SOURCES = "/system/sources" +ZONE_STATE = "/zone/state" +PLAY_STATE = "/zone/play_state" +UPDATE = "/system/update" +POSITION = "/zone/play_state/position" +NOW_PLAYING = "/zone/now_playing" diff --git a/aiostreammagic/models.py b/aiostreammagic/models.py index 83d8ef2..a80b176 100644 --- a/aiostreammagic/models.py +++ b/aiostreammagic/models.py @@ -46,17 +46,6 @@ class State(DataClassORJSONMixin): volume_percent: int = field(metadata=field_options(alias="volume_percent"), default=None) mute: bool = field(metadata=field_options(alias="mute"), default=False) -@dataclass -class PlayState(DataClassORJSONMixin): - """Data class representing StreamMagic play state.""" - - state: str = field(metadata=field_options(alias="state")) - presettable: bool = field(metadata=field_options(alias="presettable")) - metadata: PlayStateMetadata = field(metadata=field_options(alias="metadata")) - position: int = field(metadata=field_options(alias="position"), default=None) - mode_repeat: str = field(metadata=field_options(alias="mode_repeat"), default="off") - mode_shuffle: str = field(metadata=field_options(alias="mode_shuffle"), default="off") - @dataclass class PlayStateMetadata(DataClassORJSONMixin): @@ -80,3 +69,15 @@ class PlayStateMetadata(DataClassORJSONMixin): artist: str | None = field(metadata=field_options(alias="artist"), default=None) station: str | None = field(metadata=field_options(alias="station"), default=None) album: str | None = field(metadata=field_options(alias="album"), default=None) + + +@dataclass +class PlayState(DataClassORJSONMixin): + """Data class representing StreamMagic play state.""" + + state: str = field(metadata=field_options(alias="state"), default="not_ready") + metadata: PlayStateMetadata = field(metadata=field_options(alias="metadata"), default=PlayStateMetadata()) + presettable: bool = field(metadata=field_options(alias="presettable"), default=False) + position: int = field(metadata=field_options(alias="position"), default=None) + mode_repeat: str = field(metadata=field_options(alias="mode_repeat"), default="off") + mode_shuffle: str = field(metadata=field_options(alias="mode_shuffle"), default="off") diff --git a/aiostreammagic/stream_magic.py b/aiostreammagic/stream_magic.py index 315545a..e1a83fc 100644 --- a/aiostreammagic/stream_magic.py +++ b/aiostreammagic/stream_magic.py @@ -1,161 +1,326 @@ """Asynchronous Python client for StreamMagic API.""" import asyncio +import json import socket +from asyncio import AbstractEventLoop, Future, Task +from collections import defaultdict from dataclasses import dataclass from typing import Any +import websockets from aiohttp import ClientSession, ClientError, ClientResponseError from aiohttp.hdrs import METH_GET +from websockets import WebSocketClientProtocol from yarl import URL from aiostreammagic.exceptions import StreamMagicError, StreamMagicConnectionError from aiostreammagic.models import Info, Source, State, PlayState +from websockets.client import connect as ws_connect + +from . import endpoints as ep +from .const import _LOGGER + VERSION = '1.0.0' -@dataclass class StreamMagicClient: """Client for handling connections with StreamMagic enabled devices.""" - host: str - session: ClientSession | None = None - request_timeout: int = 10 - _close_session: bool = False - - async def _request(self, url: URL, *, method: str = METH_GET, data: dict[str, Any] | None = None) -> dict[str, Any]: - """Send a request to a StreamMagic device.""" - headers = { - "User-Agent": f"aiostreammagic/{VERSION}", - "Accept": "application/json", + def __init__(self, host): + self.host = host + self.connection: WebSocketClientProtocol | None = None + self.futures: dict[str, list[asyncio.Future]] = {} + self._subscriptions: dict[str, Any] = {} + self._loop: AbstractEventLoop = asyncio.get_running_loop() + self.connect_result: Future | None = None + self.connect_task: Task | None = None + self.state_update_callbacks: list[Any] = [] + self.info: Info | None = None + self.sources: list[Source] | None = None + self.state: State | None = None + self.play_state: PlayState | None = None + + async def register_state_update_callbacks(self, callback: Any): + """Register state update callback.""" + self.state_update_callbacks.append(callback) + await callback(self) + + def unregister_state_update_callbacks(self, callback: Any): + """Unregister state update callback.""" + if callback in self.state_update_callbacks: + self.state_update_callbacks.remove(callback) + + def clear_state_update_callbacks(self): + """Clear state update callbacks.""" + self.state_update_callbacks.clear() + + async def do_state_update_callbacks(self): + """Call state update callbacks.""" + callbacks = set() + for callback in self.state_update_callbacks: + callbacks.add(callback(self)) + + if callbacks: + await asyncio.gather(*callbacks) + + async def connect(self): + """Connect to StreamMagic enabled devices.""" + if not self.is_connected(): + self.connect_result = self._loop.create_future() + self.connect_task = asyncio.create_task( + self.connect_handler(self.connect_result) + ) + return await self.connect_result + + def is_connected(self) -> bool: + """Return True if device is connected.""" + return self.connect_task is not None and self.connect_task.done() + + async def _ws_connect(self, uri): + """Establish a connection with a WebSocket.""" + return await ws_connect( + uri, + extra_headers={"Origin": f"ws://{self.host}", "Host": f"{self.host}:80"} + ) + + async def connect_handler(self, res): + """Handle connection for StreamMagic.""" + self.futures = {} + uri = f"ws://{self.host}/smoip" + ws = await self._ws_connect(uri) + self.connection = ws + x = asyncio.create_task( + self.consumer_handler(ws, self._subscriptions, self.futures) + ) + self.info, self.sources = await asyncio.gather(self.get_info(), self.get_sources()) + subscribe_state_updates = { + self.subscribe(self._async_handle_info, ep.INFO), + self.subscribe(self._async_handle_sources, ep.SOURCES), + self.subscribe(self._async_handle_zone_state, ep.ZONE_STATE), + self.subscribe(self._async_handle_play_state, ep.PLAY_STATE), + self.subscribe(self._async_handle_position, ep.POSITION), } + subscribe_tasks = set() + for state_update in subscribe_state_updates: + subscribe_tasks.add(asyncio.create_task(state_update)) + await asyncio.wait(subscribe_tasks) - if self.session is None: - self.session = ClientSession() - self._close_session = True + res.set_result(True) + await asyncio.wait([x], return_when=asyncio.FIRST_COMPLETED) + @staticmethod + async def subscription_handler(queue, callback): + """Handle subscriptions.""" + try: + while True: + msg = await queue.get() + await callback(msg) + except asyncio.CancelledError: + pass + + async def consumer_handler(self, ws: WebSocketClientProtocol, subscriptions: dict[str, list[Any]], + futures: dict[str, list[asyncio.Future]]): + """Callback consumer handler.""" + subscription_queues = {} + subscription_tasks = {} try: - async with asyncio.timeout(self.request_timeout): - response = await self.session.request( - method, - url, - headers=headers, - json=data, - ) - except asyncio.TimeoutError as exception: - msg = "Timeout occurred while connecting to the device" - raise StreamMagicConnectionError(msg) from exception + async for raw_msg in ws: + print(futures or subscriptions) + if futures or subscriptions: + _LOGGER.debug("recv(%s): %s", self.host, raw_msg) + msg = json.loads(raw_msg) + path = msg["path"] + path_futures = self.futures.get(path) + subscription = self._subscriptions.get(path) + if path_futures and msg["type"] == "response": + for future in path_futures: + if not future.done(): + future.set_result(msg) + if subscription: + if path not in subscription_tasks: + queue = asyncio.Queue() + subscription_queues[path] = queue + subscription_tasks[path] = asyncio.create_task( + self.subscription_handler(queue, subscription) + ) + subscription_queues[path].put_nowait(msg) + except ( - ClientError, - ClientResponseError, - socket.gaierror, - ) as exception: - msg = "Error occurred while communicating with the device" - raise StreamMagicConnectionError(msg) from exception - - if response.status != 200: - content_type = response.headers.get("Content-Type", "") - text = await response.text() - msg = "Unexpected response from " - raise StreamMagicConnectionError( - msg, - {"Content-Type": content_type, "response": text}, - ) + asyncio.CancelledError, + websockets.exceptions.ConnectionClosedError, + websockets.exceptions.ConnectionClosedOK, + ): + pass + + async def _send(self, path, params=None): + """Send a command to the device.""" + message = { + "path": path, + "params": params or {}, + } + + if not self.connection: + raise StreamMagicError("Not connected to device.") - return await response.json() + _LOGGER.debug("Sending command: %s", message) + await self.connection.send(json.dumps(message)) - async def _request_device(self, uri: str, *, query: str = ""): - url = URL.build(scheme="http", host=self.host, path=f"/smoip/{uri}", query=query) - print(url) - return await self._request(url, method="GET") + async def request(self, path: str, params=None) -> Any: + res = self._loop.create_future() + path_futures = self.futures.get(path, []) + path_futures.append(res) + self.futures[path] = path_futures + try: + await self._send(path, params) + except (asyncio.CancelledError, StreamMagicError): + path_futures.remove(res) + raise + try: + response = await res + except asyncio.CancelledError: + if res in path_futures: + path_futures.remove(res) + raise + path_futures.remove(res) + print(response) + message = response["message"] + result = response["result"] + if result != 200: + raise StreamMagicError("Error!") + + return response["params"]["data"] + + async def subscribe(self, callback: Any, path: str) -> Any: + self._subscriptions[path] = callback + try: + await self._send(path, {"update": 100, "zone": "ZONE1"}) + except (asyncio.CancelledError, StreamMagicError): + del self._subscriptions[path] + raise async def get_info(self) -> Info: """Get device information from device.""" - data = await self._request_device('system/info') - return Info.from_dict(data["data"]) + data = await self.request(ep.INFO) + return Info.from_dict(data) async def get_sources(self) -> list[Source]: """Get source information from device.""" - data = await self._request_device('system/sources') - sources = [Source.from_dict(x) for x in data["data"]["sources"]] + data = await self.request(ep.SOURCES) + sources = [Source.from_dict(x) for x in data["sources"]] return sources async def get_state(self) -> State: """Get state information from device.""" - data = await self._request_device('zone/state') - return State.from_dict(data["data"]) + data = await self.request(ep.ZONE_STATE) + return State.from_dict(data) async def get_play_state(self) -> PlayState: """Get play state information from device.""" - data = await self._request_device('zone/play_state') - return PlayState.from_dict(data["data"]) - - async def power_on(self) -> None: - """Set the power of the device to on.""" - await self._request_device('system/power', query='power=ON') - - async def power_off(self) -> None: - """Set the power of the device to network.""" - await self._request_device('system/power', query='power=NETWORK') - - async def volume_up(self) -> None: - """Increase the volume of the device by 1.""" - await self._request_device('zone/state', query='zone=ZONE1&volume_step_change=1') - - async def volume_down(self) -> None: - """Increase the volume of the device by -1.""" - await self._request_device('zone/state', query='zone=ZONE1&volume_step_change=-1') - - async def set_volume(self, volume: int) -> None: - """Set the volume of the device.""" - if not 0 <= volume <= 100: - raise StreamMagicError("Volume must be between 0 and 100") - await self._request_device('zone/state', query=f"zone=ZONE1&volume_percent={str(volume)}") - - async def mute(self) -> None: - """Mute the device.""" - await self._request_device('zone/state', query='zone=ZONE1&mute=true') - - async def unmute(self) -> None: - """Unmute the device.""" - await self._request_device('zone/state', query='zone=ZONE1&mute=false') - - async def set_source(self, source: Source) -> None: - """Set the source of the device.""" - await self.set_source_by_id(source.id) - - async def set_source_by_id(self, source_id: str) -> None: - """Set the source of the device.""" - await self._request_device('zone/state', query=f"zone=ZONE1&source={source_id}") - - async def media_seek(self, position: int) -> None: - """Set the media position of the device.""" - await self._request_device('zone/play_control', query=f"position={position}") - - async def next_track(self) -> None: - """Skip the next track.""" - await self._request_device('zone/play_control', query='skip_track=1') - - async def previous_track(self) -> None: - """Skip the next track.""" - await self._request_device('zone/play_control', query='skip_track=-1') - - async def play_pause(self) -> None: - """Toggle play/pause.""" - await self._request_device('zone/play_control', query='action=toggle') - - async def pause(self) -> None: - """Pause the device.""" - await self._request_device('zone/play_control', query='action=pause') - - async def stop(self) -> None: - """Pause the device.""" - await self._request_device('zone/play_control', query='action=stop') - - async def set_shuffle(self, shuffle: str): - """Set the shuffle of the device.""" - await self._request_device('zone/play_control', query=f"mode_shuffle={shuffle}") - - async def set_repeat(self, repeat: str): - """Set the repeat of the device.""" - await self._request_device('zone/play_control', query=f"mode_repeat={repeat}") + data = await self.request(ep.PLAY_STATE) + return PlayState.from_dict(data) + + async def _async_handle_info(self, payload) -> None: + """Handle async info update.""" + params = payload["params"] + if "data" in params: + self.info = Info.from_dict(params["data"]) + await self.do_state_update_callbacks() + + async def _async_handle_sources(self, payload) -> None: + """Handle async sources update.""" + params = payload["params"] + if "data" in params: + self.sources = [Source.from_dict(x) for x in params["data"]["sources"]] + await self.do_state_update_callbacks() + + async def _async_handle_zone_state(self, payload) -> None: + """Handle async zone state update.""" + params = payload["params"] + if "data" in params: + self.state = State.from_dict(params["data"]) + await self.do_state_update_callbacks() + + async def _async_handle_play_state(self, payload) -> None: + """Handle async zone state update.""" + params = payload["params"] + if "data" in params: + self.play_state = PlayState.from_dict(params["data"]) + await self.do_state_update_callbacks() + + async def _async_handle_position(self, payload) -> None: + """Handle async position update.""" + params = payload["params"] + if "data" in params and params["data"]["position"] and self.play_state: + self.play_state.position = params["data"]["position"] + await self.do_state_update_callbacks() + # + # async def power_on(self) -> None: + # """Set the power of the device to on.""" + # await self._request_device('system/power', query='power=ON') + # + # async def power_off(self) -> None: + # """Set the power of the device to network.""" + # await self._request_device('system/power', query='power=NETWORK') + # + # async def volume_up(self) -> None: + # """Increase the volume of the device by 1.""" + # await self._request_device('zone/state', query='zone=ZONE1&volume_step_change=1') + # + # async def volume_down(self) -> None: + # """Increase the volume of the device by -1.""" + # await self._request_device('zone/state', query='zone=ZONE1&volume_step_change=-1') + # + # async def set_volume(self, volume: int) -> None: + # """Set the volume of the device.""" + # if not 0 <= volume <= 100: + # raise StreamMagicError("Volume must be between 0 and 100") + # await self._request_device('zone/state', query=f"zone=ZONE1&volume_percent={str(volume)}") + # + # async def mute(self) -> None: + # """Mute the device.""" + # await self._request_device('zone/state', query='zone=ZONE1&mute=true') + # + # async def unmute(self) -> None: + # """Unmute the device.""" + # await self._request_device('zone/state', query='zone=ZONE1&mute=false') + # + # async def set_source(self, source: Source) -> None: + # """Set the source of the device.""" + # await self.set_source_by_id(source.id) + # + # async def set_source_by_id(self, source_id: str) -> None: + # """Set the source of the device.""" + # await self._request_device('zone/state', query=f"zone=ZONE1&source={source_id}") + # + # async def media_seek(self, position: int) -> None: + # """Set the media position of the device.""" + # await self._request_device('zone/play_control', query=f"position={position}") + # + # async def next_track(self) -> None: + # """Skip the next track.""" + # await self._request_device('zone/play_control', query='skip_track=1') + # + # async def previous_track(self) -> None: + # """Skip the next track.""" + # await self._request_device('zone/play_control', query='skip_track=-1') + # + # async def play_pause(self) -> None: + # """Toggle play/pause.""" + # await self._request_device('zone/play_control', query='action=toggle') + # + # async def pause(self) -> None: + # """Pause the device.""" + # await self._request_device('zone/play_control', query='action=pause') + # + # async def stop(self) -> None: + # """Pause the device.""" + # await self._request_device('zone/play_control', query='action=stop') + # + # async def set_shuffle(self, shuffle: str): + # """Set the shuffle of the device.""" + # await self._request_device('zone/play_control', query=f"mode_shuffle={shuffle}") + # + # async def set_repeat(self, repeat: str): + # """Set the repeat of the device.""" + # await self._request_device('zone/play_control', query=f"mode_repeat={repeat}") diff --git a/poetry.lock b/poetry.lock index 0f64321..8e0aa16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -663,6 +663,101 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "websockets" +version = "13.0.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1841c9082a3ba4a05ea824cf6d99570a6a2d8849ef0db16e9c826acb28089e8f"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c5870b4a11b77e4caa3937142b650fbbc0914a3e07a0cf3131f35c0587489c1c"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d3d1f2eb79fe7b0fb02e599b2bf76a7619c79300fc55f0b5e2d382881d4f7f"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15c7d62ee071fa94a2fc52c2b472fed4af258d43f9030479d9c4a2de885fd543"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6724b554b70d6195ba19650fef5759ef11346f946c07dbbe390e039bcaa7cc3d"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a952fa2ae57a42ba7951e6b2605e08a24801a4931b5644dfc68939e041bc7f"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17118647c0ea14796364299e942c330d72acc4b248e07e639d34b75067b3cdd8"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a11aae1de4c178fa653b07d90f2fb1a2ed31919a5ea2361a38760192e1858b"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0617fd0b1d14309c7eab6ba5deae8a7179959861846cbc5cb528a7531c249448"}, + {file = "websockets-13.0.1-cp310-cp310-win32.whl", hash = "sha256:11f9976ecbc530248cf162e359a92f37b7b282de88d1d194f2167b5e7ad80ce3"}, + {file = "websockets-13.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c3c493d0e5141ec055a7d6809a28ac2b88d5b878bb22df8c621ebe79a61123d0"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df"}, + {file = "websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f"}, + {file = "websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237"}, + {file = "websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185"}, + {file = "websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2"}, + {file = "websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb"}, + {file = "websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b74593e9acf18ea5469c3edaa6b27fa7ecf97b30e9dabd5a94c4c940637ab96e"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:132511bfd42e77d152c919147078460c88a795af16b50e42a0bd14f0ad71ddd2"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:165bedf13556f985a2aa064309baa01462aa79bf6112fbd068ae38993a0e1f1b"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e801ca2f448850685417d723ec70298feff3ce4ff687c6f20922c7474b4746ae"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30d3a1f041360f029765d8704eae606781e673e8918e6b2c792e0775de51352f"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67648f5e50231b5a7f6d83b32f9c525e319f0ddc841be0de64f24928cd75a603"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4f0426d51c8f0926a4879390f53c7f5a855e42d68df95fff6032c82c888b5f36"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ef48e4137e8799998a343706531e656fdec6797b80efd029117edacb74b0a10a"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:249aab278810bee585cd0d4de2f08cfd67eed4fc75bde623be163798ed4db2eb"}, + {file = "websockets-13.0.1-cp38-cp38-win32.whl", hash = "sha256:06c0a667e466fcb56a0886d924b5f29a7f0886199102f0a0e1c60a02a3751cb4"}, + {file = "websockets-13.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1f3cf6d6ec1142412d4535adabc6bd72a63f5f148c43fe559f06298bc21953c9"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1fa082ea38d5de51dd409434edc27c0dcbd5fed2b09b9be982deb6f0508d25bc"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4a365bcb7be554e6e1f9f3ed64016e67e2fa03d7b027a33e436aecf194febb63"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10a0dc7242215d794fb1918f69c6bb235f1f627aaf19e77f05336d147fce7c37"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59197afd478545b1f73367620407b0083303569c5f2d043afe5363676f2697c9"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d20516990d8ad557b5abeb48127b8b779b0b7e6771a265fa3e91767596d7d97"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1a2e272d067030048e1fe41aa1ec8cfbbaabce733b3d634304fa2b19e5c897f"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad327ac80ba7ee61da85383ca8822ff808ab5ada0e4a030d66703cc025b021c4"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:518f90e6dd089d34eaade01101fd8a990921c3ba18ebbe9b0165b46ebff947f0"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68264802399aed6fe9652e89761031acc734fc4c653137a5911c2bfa995d6d6d"}, + {file = "websockets-13.0.1-cp39-cp39-win32.whl", hash = "sha256:a5dc0c42ded1557cc7c3f0240b24129aefbad88af4f09346164349391dea8e58"}, + {file = "websockets-13.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b448a0690ef43db5ef31b3a0d9aea79043882b4632cfc3eaab20105edecf6097"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:faef9ec6354fe4f9a2c0bbb52fb1ff852effc897e2a4501e25eb3a47cb0a4f89"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:03d3f9ba172e0a53e37fa4e636b86cc60c3ab2cfee4935e66ed1d7acaa4625ad"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d450f5a7a35662a9b91a64aefa852f0c0308ee256122f5218a42f1d13577d71e"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f55b36d17ac50aa8a171b771e15fbe1561217510c8768af3d546f56c7576cdc"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b9c006cac63772b31abbcd3e3abb6228233eec966bf062e89e7fa7ae0b7333"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b79915a1179a91f6c5f04ece1e592e2e8a6bd245a0e45d12fd56b2b59e559a32"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f40de079779acbcdbb6ed4c65af9f018f8b77c5ec4e17a4b737c05c2db554491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e4ba642fc87fa532bac07e5ed7e19d56940b6af6a8c61d4429be48718a380f"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a02b0161c43cc9e0232711eff846569fad6ec836a7acab16b3cf97b2344c060"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6aa74a45d4cdc028561a7d6ab3272c8b3018e23723100b12e58be9dfa5a24491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00fd961943b6c10ee6f0b1130753e50ac5dcd906130dcd77b0003c3ab797d026"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d93572720d781331fb10d3da9ca1067817d84ad1e7c31466e9f5e59965618096"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:71e6e5a3a3728886caee9ab8752e8113670936a193284be9d6ad2176a137f376"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c4a6343e3b0714e80da0b0893543bf9a5b5fa71b846ae640e56e9abc6fbc4c83"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a678532018e435396e37422a95e3ab87f75028ac79570ad11f5bf23cd2a7d8c"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6716c087e4aa0b9260c4e579bb82e068f84faddb9bfba9906cb87726fa2e870"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e33505534f3f673270dd67f81e73550b11de5b538c56fe04435d63c02c3f26b5"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acab3539a027a85d568c2573291e864333ec9d912675107d6efceb7e2be5d980"}, + {file = "websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817"}, + {file = "websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e"}, +] + [[package]] name = "yarl" version = "1.9.7" @@ -771,4 +866,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "395aa6fb140ddebbc14d809314096fe14ff8fecdf6d5a0248a78ff5d45ff1426" +content-hash = "2fca3886f410d2d6bbb65c542eff6288c4c7cd39f3ae9721b96b551084842493" diff --git a/pyproject.toml b/pyproject.toml index 6a5e990..25c24f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ aiohttp = ">=3.0.0" yarl = ">=1.6.0" mashumaro = "^3.11" orjson = ">=3.9.0" +websockets = "^13.0.1" [tool.poetry.group.dev.dependencies] pytest = "8.3.2"