diff --git a/.coverage b/.coverage index 3899299..b6882aa 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 1814367..ed76d97 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d7b6531..34593b2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -18,4 +18,5 @@ sphinx: # Optionally declare the Python requirements required to build your docs python: install: + - requirements: docs/requirements.txt - requirements: requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..e052ba6 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,32 @@ +# readthedocs dependencies +alabaster==0.7.12 +attrs==21.2.0 +Babel==2.9.1 +certifi==2021.10.8 +charset-normalizer==2.0.7 +colorama==0.4.4 +docutils==0.17.1 +idna==3.3 +imagesize==1.3.0 +Jinja2==3.0.3 +markdown-it-py==1.1.0 +MarkupSafe==2.1.4 +matplotlib==3.8.2 +mdit-py-plugins>=0.3.4 +myst-parser==1 +packaging==21.3 +Pygments==2.10.0 +pyparsing==3.0.6 +pytz==2021.3 +PyYAML==6.0 +requests==2.26.0 +snowballstemmer==2.2.0 +Sphinx>=5 +sphinx-rtd-theme==1.0.0 +sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 +urllib3==1.26.7 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 141ef4f..2ac9969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,17 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=69.0"] build-backend = "setuptools.build_meta" [project] name = "bluecurrent-api" -version = "1.0.6" +version = "1.2.0" authors = [ { name="Floris272", email="florispuijk@outlook.com" }, ] description = "A wrapper for the Blue Current websocket api" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.9" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 9e187ab..e0f8ede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ websockets pytz +types-pytz # dev dependencies pylint @@ -8,38 +9,4 @@ pytest-mock pytest-cov pytest-asyncio coverage -build - - -# readthedocs dependencies -alabaster==0.7.12 -attrs==21.2.0 -Babel==2.9.1 -certifi==2021.10.8 -charset-normalizer==2.0.7 -colorama==0.4.4 -docutils==0.17.1 -idna==3.3 -imagesize==1.3.0 -Jinja2==3.0.3 -markdown-it-py==1.1.0 -MarkupSafe==2.0.1 -matplotlib==3.5.1 -mdit-py-plugins==0.2.8 -myst-parser==0.15.2 -packaging==21.3 -Pygments==2.10.0 -pyparsing==3.0.6 -pytz==2021.3 -PyYAML==6.0 -requests==2.26.0 -snowballstemmer==2.2.0 -Sphinx==4.3.0 -sphinx-rtd-theme==1.0.0 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 -urllib3==1.26.7 \ No newline at end of file +build \ No newline at end of file diff --git a/src/bluecurrent_api/client.py b/src/bluecurrent_api/client.py index 9a1d695..585cb3b 100644 --- a/src/bluecurrent_api/client.py +++ b/src/bluecurrent_api/client.py @@ -1,10 +1,15 @@ """Define an object to interact with the BlueCurrent websocket api.""" +import logging from datetime import timedelta -from typing import Any, Callable, Optional +from typing import Any, Optional +from collections.abc import Callable, Coroutine from .utils import get_next_reset_delta from .websocket import Websocket +LOGGER = logging.getLogger(__package__) +DELAY = 10 + class Client: """Api Client for the BlueCurrent Websocket Api.""" @@ -13,29 +18,43 @@ def __init__(self) -> None: """Initialize the Client.""" self.websocket = Websocket() - def get_next_reset_delta(self) -> timedelta: - """Returns the next reset delta""" - return get_next_reset_delta() + def is_connected(self) -> bool: + """Return the connection status""" + return self.websocket.connected.is_set() - async def wait_for_response(self) -> None: + async def wait_for_charge_points(self) -> None: """Wait for next response.""" - await self.websocket.get_receiver_event().wait() + await self.websocket.received_charge_points.wait() async def validate_api_token(self, api_token: str) -> str: - """Validate an api_token.""" + """Validate an api_token and return customer id.""" return await self.websocket.validate_api_token(api_token) async def get_email(self) -> str: """Get user email.""" return await self.websocket.get_email() - async def connect(self, api_token: str) -> None: - """Connect to the websocket.""" - await self.websocket.connect(api_token) + async def _on_open(self) -> None: + """Send requests when connected.""" + await self.websocket.send_request( + { + "command": "HELLO", + "header": "homeassistant", + } + ) + await self.get_charge_cards() + await self.get_charge_points() - async def start_loop(self, receiver: Callable[[dict[str, Any]], None]) -> None: - """Start the receive loop.""" - await self.websocket.loop(receiver) + def get_next_reset_delta(self) -> timedelta: + """Returns the timedelta until the websocket limits are reset.""" + return get_next_reset_delta() + + async def connect( + self, + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + ) -> None: + """Connect to the websocket.""" + await self.websocket.start(receiver, self._on_open) async def disconnect(self) -> None: """Disconnect the websocket.""" @@ -43,7 +62,7 @@ async def disconnect(self) -> None: async def get_charge_cards(self) -> None: """Get the charge cards.""" - await self.websocket.send_request({"command": "GET_CHARGE_CARDS"}) + await self.websocket.send_request({"command": "GET_CHARGE_CARDS", "limit": 100}) async def get_charge_points(self) -> None: """Get the charge points.""" diff --git a/src/bluecurrent_api/py.typed b/src/bluecurrent_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/bluecurrent_api/websocket.py b/src/bluecurrent_api/websocket.py index 806581b..e72be93 100644 --- a/src/bluecurrent_api/websocket.py +++ b/src/bluecurrent_api/websocket.py @@ -1,262 +1,218 @@ -"""Define an object that handles the connection to the Websocket""" -import asyncio -import json -from asyncio import Event -from typing import Any, Callable, Optional, cast - -from websockets.client import WebSocketClientProtocol, connect -from websockets.exceptions import ( - ConnectionClosed, - ConnectionClosedError, - InvalidStatusCode, - WebSocketException, -) - -from .exceptions import ( - AlreadyConnected, - InvalidApiToken, - RequestLimitReached, - WebsocketError, -) -from .utils import ( - get_dummy_message, - get_exception, - handle_charge_points, - handle_grid, - handle_session_messages, - handle_setting_change, - handle_settings, - handle_status, -) - -URL = "wss://motown.bluecurrent.nl/haserver" -BUTTONS = ("START_SESSION", "STOP_SESSION", "SOFT_RESET", "REBOOT") - - -class Websocket: - """Class for handling requests and responses for the BlueCurrent Websocket Api.""" - - def __init__(self) -> None: - self.receiver_is_set = Event() - self._connection: Optional[WebSocketClientProtocol] = None - self._has_connection: bool = False - self.auth_token: Optional[str] = None - self.receiver: Optional[Callable] = None - self.receive_event: Optional[Event] = None - self.receiver_is_coroutine: bool = False - - def get_receiver_event(self) -> Event: - """Return cleared receive_event when connected.""" - - self._check_connection() - if self.receive_event is None: - self.receive_event = Event() - - self.receive_event.clear() - return self.receive_event - - async def validate_api_token(self, api_token: str) -> str: - """Validate an api token.""" - await self._connect() - await self._send({"command": "VALIDATE_API_TOKEN", "token": api_token}) - res = await self._recv() - await self.disconnect() - - if res["object"] == "ERROR": - raise get_exception(res) - - if not res.get("success"): - raise InvalidApiToken - self.auth_token = "Token " + res["token"] - return cast(str, res["customer_id"]) - - async def get_email(self) -> str: - """Return the user email""" - if not self.auth_token: - raise WebsocketError("token not set") - await self._connect() - await self.send_request({"command": "GET_ACCOUNT"}, True) - res = await self._recv() - await self.disconnect() - - if res["object"] == "ERROR": - raise get_exception(res) - - if not res.get("login"): - raise WebsocketError("No email found") - return cast(str, res["login"]) - - async def connect(self, api_token: str) -> None: - """Validate api_token and connect to the websocket.""" - if self._has_connection: - raise WebsocketError("Connection already started.") - await self.validate_api_token(api_token) - await self._connect() - - async def _connect(self) -> None: - """Connect to the websocket.""" - try: - self._connection = await connect(URL) - self._has_connection = True - except Exception as err: - self.check_for_server_reject(err) - raise WebsocketError("Cannot connect to the websocket.") from err - - async def send_request(self, request: dict[str, Any], is_standalone=False) -> None: - """Add authorization and send request.""" - - if not is_standalone: - await self.receiver_is_set.wait() - - if not self.auth_token: - raise WebsocketError("Token not set") - - request["Authorization"] = self.auth_token - await self._send(request) - - async def loop(self, receiver: Callable) -> None: - """Loop the message_handler.""" - - self.receiver = receiver - self.receiver_is_coroutine = asyncio.iscoroutinefunction(receiver) - self.receiver_is_set.set() - # Needed for receiving updates - await self._send( - { - "command": "HELLO", - "Authorization": self.auth_token, - "header": "homeassistant", - } - ) - - while True: - stop = await self._message_handler() - if stop: - break - - async def _message_handler(self) -> bool: - """Wait for a message and give it to the receiver.""" - - message: dict[str, Any] = await self._recv() - - # websocket has disconnected - if not message: - return True - - object_name = message.get("object") - - if not object_name: - raise WebsocketError("Received message has no object.") - - # handle ERROR object - if object_name == "ERROR": - raise get_exception(message) - - # if object other than ERROR has an error key it will be send to the receiver. - error = message.get("error") - - # ignored objects - if ( - ("RECEIVED" in object_name and not error) - or object_name == "HELLO" - or "OPERATIVE" in object_name - ): - return False - if object_name == "CHARGE_POINTS": - handle_charge_points(message) - elif object_name == "CH_STATUS": - handle_status(message) - elif object_name == "CH_SETTINGS": - handle_settings(message) - elif object_name == "CHARGE_CARDS": - pass - elif "GRID" in object_name: - handle_grid(message) - elif object_name in ( - "STATUS_SET_PUBLIC_CHARGING", - "STATUS_SET_PLUG_AND_CHARGE", - ): - handle_setting_change(message) - elif any(button in object_name for button in BUTTONS): - handle_session_messages(message) - else: - return False - - self.handle_receive_event() - - await self.send_to_receiver(message) - - # Fix for api sending old start_datetime - if object_name == "STATUS_START_SESSION" and not error: - await self.send_to_receiver(get_dummy_message(message["evse_id"])) - - return False - - async def send_to_receiver(self, message: dict[str, Any]) -> None: - """Send data to the given receiver.""" - if self.receiver_is_coroutine: - await self.receiver(message) - else: - self.receiver(message) - - async def _send(self, data: dict[str, Any]) -> None: - """Send data to the websocket.""" - self._check_connection() - try: - data_str = json.dumps(data) - assert self._connection is not None - await self._connection.send(data_str) - except (ConnectionClosed, InvalidStatusCode) as err: - self.handle_connection_errors(err) - - async def _recv(self) -> Any: - """Receive data from de websocket.""" - self._check_connection() - assert self._connection is not None - try: - data = await self._connection.recv() - return json.loads(data) - except (ConnectionClosed, InvalidStatusCode) as err: - self.handle_connection_errors(err) - return None - - def handle_connection_errors(self, err: WebSocketException) -> None: - """Handle connection errors.""" - if self._has_connection: - self._has_connection = False - self.handle_receive_event() - self.check_for_server_reject(err) - raise WebsocketError("Connection was closed.") - - async def disconnect(self) -> None: - """Disconnect from de websocket.""" - self._check_connection() - assert self._connection is not None - if not self._has_connection: - raise WebsocketError("Connection is already closed.") - self._has_connection = False - self.handle_receive_event() - await self._connection.close() - - def _check_connection(self) -> None: - """Throw error if there is no connection.""" - if self._connection is None: - raise WebsocketError("No connection with the api.") - - def handle_receive_event(self) -> None: - "Set receive_event if it exists" - if self.receive_event is not None: - self.receive_event.set() - - def check_for_server_reject(self, err: Exception) -> None: - """Check if the client was rejected by the server""" - - if isinstance(err, InvalidStatusCode): - reason = err.headers.get("x-websocket-reject-reason") - if reason is not None: - if "Request limit reached" in reason: - raise RequestLimitReached("Request limit reached") from err - if "Already connected" in reason: - raise AlreadyConnected("Already connected") - if isinstance(err, ConnectionClosedError) and err.code == 4001: - raise RequestLimitReached("Request limit reached") from err +"""Define an object that handles the connection to the Websocket""" +import json +from asyncio import Event, timeout +import logging +from typing import Any, cast +from collections.abc import Callable, Coroutine + +from websockets.client import connect, WebSocketClientProtocol +from websockets.exceptions import ( + ConnectionClosedError, + InvalidStatusCode, + WebSocketException, +) + +from .exceptions import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +from .utils import ( + get_exception, + handle_charge_points, + handle_grid, + handle_session_messages, + handle_setting_change, + handle_settings, + handle_status, +) + +URL = "wss://motown.bluecurrent.nl/haserver" +BUTTONS = ("START_SESSION", "STOP_SESSION", "SOFT_RESET", "REBOOT") +LOGGER = logging.getLogger(__package__) + + +class Websocket: + """Class for handling requests and responses for the BlueCurrent Websocket Api.""" + + def __init__(self) -> None: + self.conn: WebSocketClientProtocol | None = None + self.auth_token: str | None = None + self.connected = Event() + self.received_charge_points = Event() + + async def start( + self, + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + on_open: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + """Opens the connection""" + + if not self.auth_token: + raise WebsocketError("token not validated.") + + try: + await self._loop(receiver, on_open) + except WebSocketException as err: + self.raise_correct_exception(err) + + async def _loop( + self, + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + on_open: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + """listens for incomming messages""" + async with connect(URL) as websocket: + try: + LOGGER.debug("connected") + self.conn = websocket + self.connected.set() + await on_open() + async for message in websocket: + await self._message_handler(json.loads(message), receiver) + except WebSocketException as err: + self.conn = None + self.connected.clear() + self.received_charge_points.set() + self.received_charge_points.clear() + self.raise_correct_exception(err) + + async def _send_recv_single_message(self, message_object: dict) -> dict: + """Send and recv single message.""" + message = json.dumps(message_object) + try: + async with connect(URL) as websocket: + await websocket.send(message) + async with timeout(5): + res = await websocket.recv() + return cast(dict, json.loads(res)) + except WebSocketException as err: + self.raise_correct_exception(err) + # unreachable since raise_correct_exception will always return an error + # added for type hints. + return {} + + async def validate_api_token(self, api_token: str) -> str: + """Validate an api token.""" + res = await self._send_recv_single_message( + {"command": "VALIDATE_API_TOKEN", "token": api_token} + ) + + if res["object"] == "ERROR": + raise get_exception(res) + + if not res.get("success"): + raise InvalidApiToken() + + self.auth_token = "Token " + res["token"] + return cast(str, res["customer_id"]) + + async def get_email(self) -> str: + """Return the user email""" + if not self.auth_token: + raise WebsocketError("token not set") + + res = await self._send_recv_single_message( + {"command": "GET_ACCOUNT", "Authorization": self.auth_token} + ) + + if res["object"] == "ERROR": + raise get_exception(res) + + if not res.get("login"): + raise WebsocketError("No email found") + return cast(str, res["login"]) + + async def send_request(self, request: dict[str, Any]) -> None: + """Add authorization and send request.""" + + if not self.auth_token: + raise WebsocketError("Token not set") + + await self.connected.wait() + + request["Authorization"] = self.auth_token + await self._send(request) + + async def _message_handler( + self, + message: dict[str, Any], + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + ) -> None: + """Handle message and pass to receiver.""" + + object_name = message.get("object") + + if not object_name: + raise WebsocketError("Received message has no object.") + + LOGGER.debug("Received %s", object_name) + + # handle ERROR object + if object_name == "ERROR": + raise get_exception(message) + + # if object other than ERROR has an error key it will be send to the receiver. + error = message.get("error") + + # ignored objects + if ( + ("RECEIVED" in object_name and not error) + or object_name == "HELLO" + or "OPERATIVE" in object_name + ): + return + + if object_name == "CHARGE_POINTS": + self.received_charge_points.set() + handle_charge_points(message) + elif object_name == "CH_STATUS": + handle_status(message) + elif object_name == "CH_SETTINGS": + handle_settings(message) + elif object_name == "CHARGE_CARDS": + pass + elif "GRID" in object_name: + handle_grid(message) + elif object_name in ( + "STATUS_SET_PUBLIC_CHARGING", + "STATUS_SET_PLUG_AND_CHARGE", + ): + handle_setting_change(message) + elif any(button in object_name for button in BUTTONS): + handle_session_messages(message) + else: + return + + await receiver(message) + + async def _send(self, data: dict[str, Any]) -> None: + """Send data to the websocket.""" + if self.conn: + LOGGER.debug("Sending %s.", data["command"]) + data_str = json.dumps(data) + await self.conn.send(data_str) + else: + raise WebsocketError("Connection is closed.") + + async def disconnect(self) -> None: + """Disconnect from de websocket.""" + if not self.conn: + raise WebsocketError("Connection is already closed.") + await self.conn.close() + + def raise_correct_exception(self, err: Exception) -> None: + """Check if the client was rejected by the server""" + + if isinstance(err, InvalidStatusCode): + reason = err.headers.get("x-websocket-reject-reason") + if reason is not None: + if "Request limit reached" in reason: + raise RequestLimitReached("Request limit reached") from err + if "Already connected" in reason: + raise AlreadyConnected("Already connected") + if isinstance(err, ConnectionClosedError) and err.code == 4001: + raise RequestLimitReached("Request limit reached") from err + + raise WebsocketError from err diff --git a/tests/test_client.py b/tests/test_client.py index 6048d9d..b6f7abe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +from datetime import timedelta from src.bluecurrent_api.client import Client import pytest from pytest_mock import MockerFixture @@ -5,7 +6,6 @@ def test_create_request(): client = Client() - client.websocket.auth_token = "123" # command request = client._create_request("GET_CHARGE_POINTS") @@ -34,14 +34,13 @@ def test_create_request(): @pytest.mark.asyncio async def test_requests(mocker: MockerFixture): - test_send_request = mocker.patch( "src.bluecurrent_api.client.Websocket.send_request" ) client = Client() await client.get_charge_cards() - test_send_request.assert_called_with({"command": "GET_CHARGE_CARDS"}) + test_send_request.assert_called_with({"command": "GET_CHARGE_CARDS", "limit": 100}) await client.get_charge_points() test_send_request.assert_called_with({"command": "GET_CHARGE_POINTS"}) @@ -90,3 +89,25 @@ async def test_requests(mocker: MockerFixture): await client.stop_session("101") test_send_request.assert_called_with({"command": "STOP_SESSION", "evse_id": "101"}) + + +@pytest.mark.asyncio +async def test_on_open(mocker: MockerFixture): + test_send_request = mocker.patch( + "src.bluecurrent_api.client.Websocket.send_request" + ) + client = Client() + + await client._on_open() + test_send_request.assert_has_calls( + [ + mocker.call( + { + "command": "HELLO", + "header": "homeassistant", + } + ), + mocker.call({"command": "GET_CHARGE_CARDS", "limit": 100}), + mocker.call({"command": "GET_CHARGE_POINTS"}), + ] + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index e2667e8..5f268da 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -74,7 +74,6 @@ def test_get_vehicle_status(): def test_create_datetime(): - TZ = pytz.timezone("Europe/Amsterdam") test_time = TZ.localize(datetime(2001, 1, 1, 0, 0, 0)) @@ -263,7 +262,6 @@ def test_handle_setting_change(): def test_handle_session_messages(): - message = { "object": "RECEIVED_STOP_SESSION", "success": False, diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 9d0cf6b..0551269 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,47 +1,92 @@ -from unittest.mock import AsyncMock, MagicMock -import websockets - +from unittest.mock import MagicMock from websockets.client import WebSocketClientProtocol -from websockets.exceptions import InvalidStatusCode, ConnectionClosedError +from websockets.legacy.client import Connect +from websockets.exceptions import InvalidStatusCode, ConnectionClosedError, WebSocketException from websockets.frames import Close -from src.bluecurrent_api.websocket import Websocket -from src.bluecurrent_api.exceptions import ( +from src.bluecurrent_api.websocket import ( + Websocket, WebsocketError, - InvalidApiToken, RequestLimitReached, + InvalidApiToken, AlreadyConnected, ) -from asyncio.exceptions import TimeoutError + import pytest from pytest_mock import MockerFixture -import asyncio @pytest.mark.asyncio -async def test_get_receiver_event(mocker: MockerFixture): +async def test_start(mocker: MockerFixture): + mocker.patch("src.bluecurrent_api.websocket.Websocket.validate_api_token") + mock__loop = mocker.patch("src.bluecurrent_api.websocket.Websocket._loop") + websocket = Websocket() + mock_receiver = mocker.AsyncMock() + mock_on_open = mocker.AsyncMock() + with pytest.raises(WebsocketError): - websocket.get_receiver_event() + await websocket.start(mock_receiver, mock_on_open) + + websocket.auth_token = '123' + await websocket.start(mock_receiver, mock_on_open) + mock__loop.assert_called_once_with(mock_receiver, mock_on_open) + + mock_raise_correct_exception = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.raise_correct_exception" + ) + err = WebSocketException() + mock__loop.side_effect = err + + await websocket.start(mock_receiver, mock_on_open) + mock_raise_correct_exception.assert_called_once_with(err) + - websocket._connection = MagicMock(spec=WebSocketClientProtocol) +@pytest.mark.asyncio +async def test__loop(mocker: MockerFixture): + websocket = Websocket() + mock_connect = MagicMock(spec=Connect) + mocker.patch("src.bluecurrent_api.websocket.connect", return_value=mock_connect) + mock_raise_correct_exception = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.raise_correct_exception" + ) - websocket.get_receiver_event() - assert websocket.receive_event is not None - assert websocket.receive_event.is_set() is False + mock_receiver = mocker.AsyncMock() + mock_on_open = mocker.AsyncMock() + + mock_on_open.side_effect = WebSocketException() + await websocket._loop(mock_receiver, mock_on_open) + assert websocket.conn is None + assert websocket.connected.is_set() is False + assert websocket.received_charge_points.is_set() is False + mock_raise_correct_exception.assert_called_once() + + +@pytest.mark.asyncio +async def test__send_recv_single_message(mocker: MockerFixture): + websocket = Websocket() + mock_connect = MagicMock(spec=Connect) + mock_ws = MagicMock(spec=WebSocketClientProtocol) + mocker.patch("src.bluecurrent_api.websocket.connect", return_value=mock_connect) + mock_connect.__aenter__.return_value = mock_ws + mock_ws.recv.return_value = '{"a": 1}' + + assert await websocket._send_recv_single_message({}) == {"a": 1} + + err = WebSocketException() + mock_ws.recv.side_effect = err + with pytest.raises(WebsocketError): + await websocket._send_recv_single_message({}) @pytest.mark.asyncio async def test_validate_token(mocker: MockerFixture): api_token = "123" websocket = Websocket() - mocker.patch("src.bluecurrent_api.websocket.Websocket._connect") - mocker.patch("src.bluecurrent_api.websocket.Websocket._send") - mocker.patch("src.bluecurrent_api.websocket.Websocket.disconnect") mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={ "object": "STATUS_API_TOKEN", "success": True, @@ -54,14 +99,14 @@ async def test_validate_token(mocker: MockerFixture): assert websocket.auth_token == "Token abc" mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={"object": "STATUS_API_TOKEN", "success": False, "error": ""}, ) with pytest.raises(InvalidApiToken): await websocket.validate_api_token(api_token) mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={ "object": "ERROR", "error": 42, @@ -75,11 +120,8 @@ async def test_validate_token(mocker: MockerFixture): @pytest.mark.asyncio async def test_get_email(mocker: MockerFixture): websocket = Websocket() - mocker.patch("src.bluecurrent_api.websocket.Websocket._connect") - mocker.patch("src.bluecurrent_api.websocket.Websocket._send") - mocker.patch("src.bluecurrent_api.websocket.Websocket.disconnect") mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={"object": "ACCOUNT", "login": "test"}, ) @@ -89,14 +131,14 @@ async def test_get_email(mocker: MockerFixture): assert await websocket.get_email() == "test" mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={"object": "ACCOUNT"}, ) with pytest.raises(WebsocketError): await websocket.get_email() mocker.patch( - "src.bluecurrent_api.websocket.Websocket._recv", + "src.bluecurrent_api.websocket.Websocket._send_recv_single_message", return_value={ "object": "ERROR", "error": 42, @@ -107,99 +149,27 @@ async def test_get_email(mocker: MockerFixture): await websocket.get_email() -@pytest.mark.asyncio -async def test_connect(mocker: MockerFixture): - mocker.patch("src.bluecurrent_api.websocket.Websocket._send") - websocket = Websocket() - api_token = "123" - - websocket._has_connection = True - with pytest.raises(WebsocketError): - await websocket.connect(api_token) - - -@pytest.mark.asyncio -async def test__connect(mocker: MockerFixture): - websocket = Websocket() - mocker.patch( - 'src.bluecurrent_api.websocket.connect', - create=True, - side_effect=ConnectionRefusedError - ) - with pytest.raises(WebsocketError): - await websocket._connect() - - mocker.patch( - "src.bluecurrent_api.websocket.connect", create=True, side_effect=TimeoutError - ) - with pytest.raises(WebsocketError): - await websocket._connect() - - mocker.patch( - "src.bluecurrent_api.websocket.connect", - create=True, - side_effect=InvalidStatusCode( - 403, {"x-websocket-reject-reason": "Request limit reached"} - ), - ) - with pytest.raises(RequestLimitReached): - await websocket._connect() - - mocker.patch( - "src.bluecurrent_api.websocket.connect", - create=True, - side_effect=InvalidStatusCode( - 403, {"x-websocket-reject-reason": "Already connected"} - ), - ) - with pytest.raises(AlreadyConnected): - await websocket._connect() - - @pytest.mark.asyncio async def test_send_request(mocker: MockerFixture): mock_send = mocker.patch("src.bluecurrent_api.websocket.Websocket._send") websocket = Websocket() + websocket.connected.set() # without token with pytest.raises(WebsocketError): - await websocket.send_request({"command": "GET_CHARGE_POINTS"}, True) + await websocket.send_request({"command": "GET_CHARGE_POINTS"}) websocket.auth_token = "123" - await websocket.send_request({"command": "GET_CHARGE_POINTS"}, True) + await websocket.send_request({"command": "GET_CHARGE_POINTS"}) mock_send.assert_called_with( {"command": "GET_CHARGE_POINTS", "Authorization": "123"} ) -@pytest.mark.asyncio -async def test_loop(mocker: MockerFixture): - mocker.patch("src.bluecurrent_api.websocket.Websocket._send") - mocker.patch( - "src.bluecurrent_api.websocket.Websocket._message_handler", return_value=True - ) - websocket = Websocket() - - def receiver(): - pass - - async def async_receiver(): - pass - - await websocket.loop(receiver) - assert websocket.receiver == receiver - assert websocket.receiver_is_coroutine is False - - await websocket.loop(async_receiver) - assert websocket.receiver == async_receiver - assert websocket.receiver_is_coroutine is True - - @pytest.mark.asyncio async def test_message_handler(mocker: MockerFixture): - mock_handle_charge_points = mocker.patch( "src.bluecurrent_api.websocket.handle_charge_points" ) @@ -214,212 +184,152 @@ async def test_message_handler(mocker: MockerFixture): "src.bluecurrent_api.websocket.handle_session_messages" ) - mocker.patch("src.bluecurrent_api.websocket.Websocket.handle_receive_event") - - mock_send_to_receiver = mocker.patch( - "src.bluecurrent_api.websocket.Websocket.send_to_receiver", - create=True, - side_effect=AsyncMock(), - ) - websocket = Websocket() + mock_receiver = mocker.AsyncMock() + # CHARGE_POINTS flow message = {"object": "CHARGE_POINTS"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_charge_points.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) + + message = {"object": "CHARGE_CARDS"} + await websocket._message_handler(message, mock_receiver) + mock_receiver.assert_called_with(message) # ch_status flow message = {"object": "CH_STATUS"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_status.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) # grid_status flow message = {"object": "GRID_STATUS"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_grid.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) # grid_current flow message = {"object": "GRID_CURRENT"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_grid.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) # ch_settings flow message = {"object": "CH_SETTINGS"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_settings.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) # setting change flow message = {"object": "STATUS_SET_PLUG_AND_CHARGE"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_setting_change.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) + mock_receiver.assert_called_with(message) # session message flow message = {"object": "STATUS_STOP_SESSION"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_handle_session_messages.assert_called_with(message) - mock_send_to_receiver.assert_called_with(message) - - mock_get_dummy_message = mocker.patch( - "src.bluecurrent_api.websocket.get_dummy_message" - ) + mock_receiver.assert_called_with(message) # STATUS_START_SESSION message = {"object": "STATUS_START_SESSION", "evse_id": "BCU101"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) mock_handle_handle_session_messages.assert_called_with(message) - mock_get_dummy_message.assert_called_with("BCU101") + mock_receiver.assert_called_with(message) # no object message = {"value": True} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # unknown command message = {"error": 0, "object": "ERROR", "message": "Unknown command"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # unknown token message = {"error": 1, "object": "ERROR", "message": "Invalid Auth Token"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # token not autorized message = {"error": 2, "object": "ERROR", "message": "Not authorized"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # unknown error message = {"error": 9, "object": "ERROR", "message": "Unknown error"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # limit reached message = {"error": 42, "object": "ERROR", "message": "Request limit reached"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(RequestLimitReached): - await websocket._message_handler() + await websocket._message_handler(message, mock_receiver) # success false message = {"success": False, "error": "this is an error"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) with pytest.raises(WebsocketError): - await websocket._message_handler() - - # None message - message = None - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - assert await websocket._message_handler() is True + await websocket._message_handler(message, mock_receiver) # Ignore status message = {"object": "STATUS"} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - assert await websocket._message_handler() is False + await websocket._message_handler(message, mock_receiver) + assert mock_receiver.call_count == 9 # RECEIVED without error message = {"object": "RECEIVED_START_SESSION", "error": ""} - mocker.patch("src.bluecurrent_api.websocket.Websocket._recv", return_value=message) - assert await websocket._message_handler() is False + await websocket._message_handler(message, mock_receiver) + assert mock_receiver.call_count == 9 @pytest.mark.asyncio -async def test_send_to_receiver(mocker: MockerFixture): +async def test__send(mocker: MockerFixture): websocket = Websocket() - mock_receiver = mocker.MagicMock() - websocket.receiver = mock_receiver - websocket.receiver_is_coroutine = False - - await websocket.send_to_receiver("test") - - mock_receiver.assert_called_with("test") + with pytest.raises(WebsocketError): + await websocket._send({"command": "test"}) - async_mock_receiver = AsyncMock() - websocket.receiver = async_mock_receiver - websocket.receiver_is_coroutine = True + websocket.conn = mocker.MagicMock(spec=WebSocketClientProtocol) - await websocket.send_to_receiver("test") - async_mock_receiver.assert_called_with("test") + await websocket._send({"command": 1}) + websocket.conn.send.assert_called_with('{"command": 1}') @pytest.mark.asyncio async def test_disconnect(mocker: MockerFixture): websocket = Websocket() - websocket._connection = MagicMock(spec=WebSocketClientProtocol) - mocker.patch('src.bluecurrent_api.websocket.Websocket.handle_receive_event') with pytest.raises(WebsocketError): await websocket.disconnect() - websocket._has_connection = True + websocket.conn = mocker.MagicMock(spec=WebSocketClientProtocol) await websocket.disconnect() - assert websocket._has_connection is False - # test_connection.close.assert_called_once() - + websocket.conn.close.assert_called_once() -@pytest.mark.asyncio -async def test_handle_connection_errors(mocker: MockerFixture): - test_handle_receive_event = mocker.patch('src.bluecurrent_api.websocket.Websocket.handle_receive_event') - - mocker.patch('src.bluecurrent_api.websocket.Websocket.check_for_server_reject') - websocket = Websocket() - - websocket._has_connection = True - websocket.receive_event = asyncio.Event() - - with pytest.raises(WebsocketError): - websocket.handle_connection_errors(None) - - assert websocket._has_connection is False - test_handle_receive_event.assert_called_once() - - -@pytest.mark.asyncio -async def test_handle_receive_event(): - websocket = Websocket() - - websocket.receive_event = asyncio.Event() - websocket.handle_receive_event() - assert websocket.receive_event.is_set() - - -def test_check_for_server_reject(): +def test_raise_correct_exception(): websocket = Websocket() with pytest.raises(RequestLimitReached): - websocket.check_for_server_reject( + websocket.raise_correct_exception( InvalidStatusCode( 403, {"x-websocket-reject-reason": "Request limit reached"} ) ) with pytest.raises(RequestLimitReached): - websocket.check_for_server_reject( + websocket.raise_correct_exception( ConnectionClosedError(Close(4001, "Request limit reached"), None, None) ) with pytest.raises(AlreadyConnected): - websocket.check_for_server_reject( + websocket.raise_correct_exception( InvalidStatusCode(403, {"x-websocket-reject-reason": "Already connected"}) ) + + with pytest.raises(WebsocketError): + websocket.raise_correct_exception(Exception)