From 682d80204936400c6c80a62b8b688dc8e1224762 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 31 Jan 2024 00:18:33 +0100 Subject: [PATCH 1/9] Refactored websocket and moved reconnect to pkg --- .coverage | Bin 53248 -> 53248 bytes pyproject.toml | 2 +- requirements.txt | 11 +- src/bluecurrent_api/client.py | 61 +++- src/bluecurrent_api/websocket.py | 475 ++++++++++++++----------------- tests/test_client.py | 55 +++- tests/test_utils.py | 2 - tests/test_websocket.py | 324 ++++++++------------- 8 files changed, 436 insertions(+), 494 deletions(-) diff --git a/.coverage b/.coverage index 3899299ac7b8a2086f9d2531e27ceaa85adda720..b6882aacef73097d46606fce5237649c425a98d0 100644 GIT binary patch delta 424 zcmZozz}&Eac|wv>ybLdY7y}PmF9UxZuRp&o&m3Mg?u$IRoL$`W*b_OE*?Kn%3UIP* zp2l{VjWe2wU0hU@v0ZX<67M9gfc)a(%%q%Dh3w5=dHIDnnD`$u@W1DO2-I+uUz>-C zS(ZN|u{b`lD7n~xpOINwyeze-I5R)bT+c+$kc)wVfrWntuWVy$adXxO~|`s=#7zuDWi-b`J4*{JZZ{kp2spZfAFKot%w z>wbCqpDg<|xh!hdwvydfxB15XdbiU1*Gp$+PG+D25oRM}V`fPvAk&0tpV{h;j(K1A s{a+N7y>{!qYf-PWqgfeQI63*)KtaOFf1iQ>H~$y@xBQ#=Cw#OA04r*WG5`Po delta 597 zcmZozz}&Eac|wwse=RS67y}R6GzR`S-UxnOo~69{+_!kjI45&2XV2oyVVkyDP(X-n zb1&OrHqIa>c5zWr#&-6}k-U=_y*Gd5d-wc??)Mm_->uUT5ZJmS%+dlZl&ImJ{YdUZ5UU zK5GX4rTjj8UpDhObn-DpvrPWz7tI#N%)-!UIN8z9i>0(AGiS1g|54@{d|x*U1a$CC z{ty?#$`Z~3ln;#e2Fq{uh~KBcGl_x!C;wyq!~8q=Cjld)fj>=!m4%Vhi}fFi!=LE9 zy?g6+|10bLcFSmI%GoFX*RK&(aERZ3?H4mA3s98^i;=N0vm7&!X~!IAzjS$u^=0.3.4 +myst-parser==1 packaging==21.3 Pygments==2.10.0 pyparsing==3.0.6 @@ -34,7 +35,7 @@ pytz==2021.3 PyYAML==6.0 requests==2.26.0 snowballstemmer==2.2.0 -Sphinx==4.3.0 +Sphinx>=5 sphinx-rtd-theme==1.0.0 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-devhelp==1.0.2 diff --git a/src/bluecurrent_api/client.py b/src/bluecurrent_api/client.py index 9a1d695..f2b966c 100644 --- a/src/bluecurrent_api/client.py +++ b/src/bluecurrent_api/client.py @@ -1,10 +1,17 @@ """Define an object to interact with the BlueCurrent websocket api.""" -from datetime import timedelta -from typing import Any, Callable, Optional +import asyncio +import logging +from typing import Any, Optional +from collections.abc import Callable, Coroutine + +from .exceptions import RequestLimitReached, WebsocketError 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 +20,55 @@ 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: + 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 connect( + self, + token: str, + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + on_disconnect: Callable[[], Coroutine[Any, Any, None]], + ) -> None: """Connect to the websocket.""" - await self.websocket.connect(api_token) - - async def start_loop(self, receiver: Callable[[dict[str, Any]], None]) -> None: - """Start the receive loop.""" - await self.websocket.loop(receiver) + try: + await self.websocket.start(token, receiver, self._on_open) + except RequestLimitReached: + LOGGER.warning( + "Request limit reached. reconnecting at 00:00 (Europe/Amsterdam)." + ) + await on_disconnect() + delay = get_next_reset_delta() + await asyncio.sleep(delay.seconds) + await self.connect(token, receiver, on_disconnect) + except WebsocketError: + LOGGER.debug("Disconnected, retrying in background.") + await on_disconnect() + await asyncio.sleep(DELAY) + await self.connect(token, receiver, on_disconnect) async def disconnect(self) -> None: """Disconnect the websocket.""" diff --git a/src/bluecurrent_api/websocket.py b/src/bluecurrent_api/websocket.py index 806581b..7af5726 100644 --- a/src/bluecurrent_api/websocket.py +++ b/src/bluecurrent_api/websocket.py @@ -1,262 +1,213 @@ -"""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 +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, + token: str, + receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + on_open: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + """Opens the connection""" + await self.validate_api_token(token) + + try: + await self._loop(receiver, on_open) + except WebSocketException as err: + self.check_for_server_reject(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.check_for_server_reject(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) + res = await websocket.recv() + return cast(dict, json.loads(res)) + except WebSocketException as err: + self.check_for_server_reject(err) + + 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 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 + + raise WebsocketError from err diff --git a/tests/test_client.py b/tests/test_client.py index 6048d9d..0bfbbd3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,11 @@ -from src.bluecurrent_api.client import Client +from datetime import timedelta +from src.bluecurrent_api.client import Client, RequestLimitReached, WebsocketError import pytest from pytest_mock import MockerFixture def test_create_request(): client = Client() - client.websocket.auth_token = "123" # command request = client._create_request("GET_CHARGE_POINTS") @@ -34,7 +34,6 @@ 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" ) @@ -90,3 +89,53 @@ 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"}), + mocker.call({"command": "GET_CHARGE_POINTS"}), + ] + ) + + +@pytest.mark.asyncio +async def test_connect(mocker: MockerFixture): + test_get_next_reset_delta = mocker.patch( + "src.bluecurrent_api.client.get_next_reset_delta", + return_value=timedelta(seconds=0), + ) + mocker.patch("src.bluecurrent_api.client.DELAY", 0) + test_start = mocker.patch( + "src.bluecurrent_api.client.Websocket.start", + side_effect=[WebsocketError, None, RequestLimitReached, None], + ) + + client = Client() + + test_on_disconnect = mocker.AsyncMock() + + await client.connect("123", mocker.Mock, test_on_disconnect) + + assert test_on_disconnect.call_count == 1 + assert test_start.call_count == 2 + + await client.connect("123", mocker.Mock, test_on_disconnect) + + test_get_next_reset_delta.assert_called_once() + assert test_on_disconnect.call_count == 2 + assert test_start.call_count == 4 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..11d69fe 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() - with pytest.raises(WebsocketError): - websocket.get_receiver_event() + mock_receiver = mocker.AsyncMock() + mock_on_open = mocker.AsyncMock() + + await websocket.start("123", mock_receiver, mock_on_open) + + mock__loop.assert_called_once_with(mock_receiver, mock_on_open) + + mock_check_for_server_reject = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" + ) + err = WebSocketException() + mock__loop.side_effect = err + + await websocket.start("123", mock_receiver, mock_on_open) + mock_check_for_server_reject.assert_called_once_with(err) + + +@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_check_for_server_reject = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" + ) + + 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_check_for_server_reject.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}' - websocket._connection = MagicMock(spec=WebSocketClientProtocol) + assert await websocket._send_recv_single_message({}) == {"a": 1} - websocket.get_receiver_event() - assert websocket.receive_event is not None - assert websocket.receive_event.is_set() is False + mock_check_for_server_reject = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" + ) + err = WebSocketException() + mock_ws.recv.side_effect = err + await websocket._send_recv_single_message({}) + mock_check_for_server_reject.assert_called_once_with(err) @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,194 +184,131 @@ 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() - - -@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() + websocket.conn.close.assert_called_once() def test_check_for_server_reject(): @@ -423,3 +330,6 @@ def test_check_for_server_reject(): websocket.check_for_server_reject( InvalidStatusCode(403, {"x-websocket-reject-reason": "Already connected"}) ) + + with pytest.raises(WebsocketError): + websocket.check_for_server_reject(Exception) From 8ff242ba244c7292ac44e0ddb0f1294bb4555db2 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 31 Jan 2024 11:06:49 +0100 Subject: [PATCH 2/9] Increment python version --- .github/workflows/lint_and_test.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 1814367..36f2e34 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.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 53fbd22..562ab45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "A wrapper for the Blue Current websocket api" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 75b0c3d2bf06f5b15c2798567248d14986812634 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 31 Jan 2024 12:24:53 +0100 Subject: [PATCH 3/9] fix requirements --- .readthedocs.yaml | 1 + docs/requirements.txt | 32 ++++++++++++++++++++++++++++ requirements.txt | 36 +------------------------------- src/bluecurrent_api/websocket.py | 2 +- tests/test_websocket.py | 7 ++----- 5 files changed, 37 insertions(+), 41 deletions(-) create mode 100644 docs/requirements.txt 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/requirements.txt b/requirements.txt index ba88f2c..e0f8ede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,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.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 +build \ No newline at end of file diff --git a/src/bluecurrent_api/websocket.py b/src/bluecurrent_api/websocket.py index 7af5726..5660ce4 100644 --- a/src/bluecurrent_api/websocket.py +++ b/src/bluecurrent_api/websocket.py @@ -84,9 +84,9 @@ async def _send_recv_single_message(self, message_object: dict) -> dict: async with connect(URL) as websocket: await websocket.send(message) res = await websocket.recv() - return cast(dict, json.loads(res)) except WebSocketException as err: self.check_for_server_reject(err) + return cast(dict, json.loads(res)) async def validate_api_token(self, api_token: str) -> str: """Validate an api token.""" diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 11d69fe..129c687 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -71,13 +71,10 @@ async def test__send_recv_single_message(mocker: MockerFixture): assert await websocket._send_recv_single_message({}) == {"a": 1} - mock_check_for_server_reject = mocker.patch( - "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" - ) err = WebSocketException() mock_ws.recv.side_effect = err - await websocket._send_recv_single_message({}) - mock_check_for_server_reject.assert_called_once_with(err) + with pytest.raises(WebsocketError): + await websocket._send_recv_single_message({}) @pytest.mark.asyncio From 8c80711ed84dd701344c648a8b071d42eede14bc Mon Sep 17 00:00:00 2001 From: Floris272 Date: Sat, 3 Feb 2024 17:58:31 +0100 Subject: [PATCH 4/9] add py.typed --- pyproject.toml | 2 +- src/bluecurrent_api/py.typed | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/bluecurrent_api/py.typed diff --git a/pyproject.toml b/pyproject.toml index 562ab45..a1c4966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=69.0"] build-backend = "setuptools.build_meta" [project] diff --git a/src/bluecurrent_api/py.typed b/src/bluecurrent_api/py.typed new file mode 100644 index 0000000..e69de29 From 07fe5c5d9f995c182324b931c4891ae7ed1567e9 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 13 Feb 2024 11:10:43 +0100 Subject: [PATCH 5/9] Add timeout to _send_recv_single_message --- src/bluecurrent_api/websocket.py | 18 +++++++++++------- tests/test_websocket.py | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/bluecurrent_api/websocket.py b/src/bluecurrent_api/websocket.py index 5660ce4..8e04324 100644 --- a/src/bluecurrent_api/websocket.py +++ b/src/bluecurrent_api/websocket.py @@ -1,6 +1,6 @@ """Define an object that handles the connection to the Websocket""" import json -from asyncio import Event +from asyncio import Event, timeout import logging from typing import Any, cast from collections.abc import Callable, Coroutine @@ -54,7 +54,7 @@ async def start( try: await self._loop(receiver, on_open) except WebSocketException as err: - self.check_for_server_reject(err) + self.raise_correct_exception(err) async def _loop( self, @@ -75,7 +75,7 @@ async def _loop( self.connected.clear() self.received_charge_points.set() self.received_charge_points.clear() - self.check_for_server_reject(err) + self.raise_correct_exception(err) async def _send_recv_single_message(self, message_object: dict) -> dict: """Send and recv single message.""" @@ -83,10 +83,14 @@ async def _send_recv_single_message(self, message_object: dict) -> dict: try: async with connect(URL) as websocket: await websocket.send(message) - res = await websocket.recv() + async with timeout(5): + res = await websocket.recv() + return cast(dict, json.loads(res)) except WebSocketException as err: - self.check_for_server_reject(err) - return cast(dict, json.loads(res)) + 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.""" @@ -197,7 +201,7 @@ async def disconnect(self) -> None: raise WebsocketError("Connection is already closed.") await self.conn.close() - def check_for_server_reject(self, err: Exception) -> None: + def raise_correct_exception(self, err: Exception) -> None: """Check if the client was rejected by the server""" if isinstance(err, InvalidStatusCode): diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 129c687..24e5311 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -30,14 +30,14 @@ async def test_start(mocker: MockerFixture): mock__loop.assert_called_once_with(mock_receiver, mock_on_open) - mock_check_for_server_reject = mocker.patch( - "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" + mock_raise_correct_exception = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.raise_correct_exception" ) err = WebSocketException() mock__loop.side_effect = err await websocket.start("123", mock_receiver, mock_on_open) - mock_check_for_server_reject.assert_called_once_with(err) + mock_raise_correct_exception.assert_called_once_with(err) @pytest.mark.asyncio @@ -45,8 +45,8 @@ 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_check_for_server_reject = mocker.patch( - "src.bluecurrent_api.websocket.Websocket.check_for_server_reject" + mock_raise_correct_exception = mocker.patch( + "src.bluecurrent_api.websocket.Websocket.raise_correct_exception" ) mock_receiver = mocker.AsyncMock() @@ -57,7 +57,7 @@ async def test__loop(mocker: MockerFixture): assert websocket.conn is None assert websocket.connected.is_set() is False assert websocket.received_charge_points.is_set() is False - mock_check_for_server_reject.assert_called_once() + mock_raise_correct_exception.assert_called_once() @pytest.mark.asyncio @@ -308,25 +308,25 @@ async def test_disconnect(mocker: MockerFixture): websocket.conn.close.assert_called_once() -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.check_for_server_reject(Exception) + websocket.raise_correct_exception(Exception) From ba4cfc69150c4805c82c14a74048a4640ffd4496 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 13 Feb 2024 13:50:06 +0100 Subject: [PATCH 6/9] Remove reconnect logic from pkg --- src/bluecurrent_api/client.py | 26 ++++++-------------------- src/bluecurrent_api/websocket.py | 5 +++-- tests/test_client.py | 30 +----------------------------- tests/test_websocket.py | 7 +++++-- 4 files changed, 15 insertions(+), 53 deletions(-) diff --git a/src/bluecurrent_api/client.py b/src/bluecurrent_api/client.py index f2b966c..88c2e98 100644 --- a/src/bluecurrent_api/client.py +++ b/src/bluecurrent_api/client.py @@ -1,11 +1,9 @@ """Define an object to interact with the BlueCurrent websocket api.""" -import asyncio import logging +from datetime import timedelta from typing import Any, Optional from collections.abc import Callable, Coroutine -from .exceptions import RequestLimitReached, WebsocketError - from .utils import get_next_reset_delta from .websocket import Websocket @@ -47,28 +45,16 @@ async def _on_open(self) -> None: await self.get_charge_cards() await self.get_charge_points() + 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, - token: str, receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - on_disconnect: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Connect to the websocket.""" - try: - await self.websocket.start(token, receiver, self._on_open) - except RequestLimitReached: - LOGGER.warning( - "Request limit reached. reconnecting at 00:00 (Europe/Amsterdam)." - ) - await on_disconnect() - delay = get_next_reset_delta() - await asyncio.sleep(delay.seconds) - await self.connect(token, receiver, on_disconnect) - except WebsocketError: - LOGGER.debug("Disconnected, retrying in background.") - await on_disconnect() - await asyncio.sleep(DELAY) - await self.connect(token, receiver, on_disconnect) + await self.websocket.start(receiver, self._on_open) async def disconnect(self) -> None: """Disconnect the websocket.""" diff --git a/src/bluecurrent_api/websocket.py b/src/bluecurrent_api/websocket.py index 8e04324..e72be93 100644 --- a/src/bluecurrent_api/websocket.py +++ b/src/bluecurrent_api/websocket.py @@ -44,12 +44,13 @@ def __init__(self) -> None: async def start( self, - token: str, receiver: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], on_open: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Opens the connection""" - await self.validate_api_token(token) + + if not self.auth_token: + raise WebsocketError("token not validated.") try: await self._loop(receiver, on_open) diff --git a/tests/test_client.py b/tests/test_client.py index 0bfbbd3..a5ff5e3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,5 @@ from datetime import timedelta -from src.bluecurrent_api.client import Client, RequestLimitReached, WebsocketError +from src.bluecurrent_api.client import Client import pytest from pytest_mock import MockerFixture @@ -111,31 +111,3 @@ async def test_on_open(mocker: MockerFixture): mocker.call({"command": "GET_CHARGE_POINTS"}), ] ) - - -@pytest.mark.asyncio -async def test_connect(mocker: MockerFixture): - test_get_next_reset_delta = mocker.patch( - "src.bluecurrent_api.client.get_next_reset_delta", - return_value=timedelta(seconds=0), - ) - mocker.patch("src.bluecurrent_api.client.DELAY", 0) - test_start = mocker.patch( - "src.bluecurrent_api.client.Websocket.start", - side_effect=[WebsocketError, None, RequestLimitReached, None], - ) - - client = Client() - - test_on_disconnect = mocker.AsyncMock() - - await client.connect("123", mocker.Mock, test_on_disconnect) - - assert test_on_disconnect.call_count == 1 - assert test_start.call_count == 2 - - await client.connect("123", mocker.Mock, test_on_disconnect) - - test_get_next_reset_delta.assert_called_once() - assert test_on_disconnect.call_count == 2 - assert test_start.call_count == 4 diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 24e5311..0551269 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -26,8 +26,11 @@ async def test_start(mocker: MockerFixture): mock_receiver = mocker.AsyncMock() mock_on_open = mocker.AsyncMock() - await websocket.start("123", mock_receiver, mock_on_open) + with pytest.raises(WebsocketError): + 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( @@ -36,7 +39,7 @@ async def test_start(mocker: MockerFixture): err = WebSocketException() mock__loop.side_effect = err - await websocket.start("123", mock_receiver, mock_on_open) + await websocket.start(mock_receiver, mock_on_open) mock_raise_correct_exception.assert_called_once_with(err) From e37922e290630e6e118cbc683a34fe82ebe600ac Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 13 Feb 2024 16:18:17 +0100 Subject: [PATCH 7/9] Add limit to GET_CHARGE_CARDS request --- src/bluecurrent_api/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bluecurrent_api/client.py b/src/bluecurrent_api/client.py index 88c2e98..585cb3b 100644 --- a/src/bluecurrent_api/client.py +++ b/src/bluecurrent_api/client.py @@ -62,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.""" From 158e2b40317cc142a7642e5d3850e29ccd51562c Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 13 Feb 2024 16:28:18 +0100 Subject: [PATCH 8/9] fix tests --- tests/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index a5ff5e3..b6f7abe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -40,7 +40,7 @@ async def test_requests(mocker: MockerFixture): 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"}) @@ -107,7 +107,7 @@ async def test_on_open(mocker: MockerFixture): "header": "homeassistant", } ), - mocker.call({"command": "GET_CHARGE_CARDS"}), + mocker.call({"command": "GET_CHARGE_CARDS", "limit": 100}), mocker.call({"command": "GET_CHARGE_POINTS"}), ] ) From 9754a928e9a4139d636e14e3c319cdeef3561dc1 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 13 Feb 2024 16:33:47 +0100 Subject: [PATCH 9/9] Remove 3.10 support because of asyncio.timeout --- .github/workflows/lint_and_test.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 36f2e34..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.10", "3.11", "3.12"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index a1c4966..2ac9969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "A wrapper for the Blue Current websocket api" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License",