From 572eefa2e7041e9c778eb14a72ee29a107fea2d0 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Sun, 1 Feb 2026 19:30:59 -0700 Subject: [PATCH 1/5] refactor: optimize websocket handling --- openevsehttp/__main__.py | 32 +++++++--------------------- openevsehttp/websocket.py | 44 +++++++++++++++++++++++++++------------ tests/conftest.py | 1 - tests/test_main.py | 8 ++----- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/openevsehttp/__main__.py b/openevsehttp/__main__.py index 9fee51c..825d4e1 100644 --- a/openevsehttp/__main__.py +++ b/openevsehttp/__main__.py @@ -346,9 +346,7 @@ async def set_charge_mode(self, mode: str = "fast") -> None: data = {"charge_mode": mode} _LOGGER.debug("Setting charge mode to %s", mode) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 result = response["msg"] if result not in ["done", "no change"]: _LOGGER.error("Problem issuing command: %s", response["msg"]) @@ -373,9 +371,7 @@ async def divert_mode(self) -> dict[str, str] | dict[str, Any]: data = {"divert_enabled": mode} _LOGGER.debug("Toggling divert: %s", mode) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 _LOGGER.debug("divert_mode response: %s", response) return response @@ -426,9 +422,7 @@ async def set_override( _LOGGER.debug("Override data: %s", data) _LOGGER.debug("Setting override config on %s", url) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 return response async def toggle_override(self) -> None: @@ -499,9 +493,7 @@ async def set_service_level(self, level: int = 2) -> None: data = {"service": level} _LOGGER.debug("Set service level to: %s", level) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 _LOGGER.debug("service response: %s", response) result = response["msg"] if result not in ["done", "no change"]: @@ -765,9 +757,7 @@ async def set_limit( _LOGGER.debug("Limit data: %s", data) _LOGGER.debug("Setting limit config on %s", url) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 return response async def clear_limit(self) -> Any: @@ -780,9 +770,7 @@ async def clear_limit(self) -> Any: data: Dict[str, Any] = {} _LOGGER.debug("Clearing limit config on %s", url) - response = await self.process_request( - url=url, method="delete", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="delete", data=data) # noqa: E501 return response async def get_limit(self) -> Any: @@ -795,9 +783,7 @@ async def get_limit(self) -> Any: data: Dict[str, Any] = {} _LOGGER.debug("Getting limit config on %s", url) - response = await self.process_request( - url=url, method="get", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="get", data=data) # noqa: E501 return response async def make_claim( @@ -832,9 +818,7 @@ async def make_claim( _LOGGER.debug("Claim data: %s", data) _LOGGER.debug("Setting up claim on %s", url) - response = await self.process_request( - url=url, method="post", data=data - ) # noqa: E501 + response = await self.process_request(url=url, method="post", data=data) # noqa: E501 return response async def release_claim(self, client: int = CLIENT) -> Any: diff --git a/openevsehttp/websocket.py b/openevsehttp/websocket.py index 96a3571..546549f 100644 --- a/openevsehttp/websocket.py +++ b/openevsehttp/websocket.py @@ -51,8 +51,26 @@ def state(self): return self._state @state.setter - async def state(self, value): - """Set the state.""" + def state(self, value): + """Set the state (synchronous setter that schedules the callback).""" + self._state = value + _LOGGER.debug("Websocket %s", value) + # Schedule the callback asynchronously without awaiting here. + try: + asyncio.create_task( + self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason) + ) + except RuntimeError: + # If there's no running loop, schedule safely on the event loop. + loop = asyncio.get_event_loop() + loop.call_soon_threadsafe( + asyncio.create_task, + self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason), + ) + self._error_reason = None + + async def _set_state(self, value): + """Async helper to set the state and await the callback.""" self._state = value _LOGGER.debug("Websocket %s", value) await self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason) @@ -65,7 +83,7 @@ def _get_uri(server): async def running(self): """Open a persistent websocket connection and act on events.""" - await OpenEVSEWebsocket.state.fset(self, STATE_STARTING) + await self._set_state(STATE_STARTING) auth = None if self._user and self._password: @@ -77,7 +95,7 @@ async def running(self): heartbeat=15, auth=auth, ) as ws_client: - await OpenEVSEWebsocket.state.fset(self, STATE_CONNECTED) + await self._set_state(STATE_CONNECTED) self.failed_attempts = 0 self._client = ws_client @@ -107,11 +125,11 @@ async def running(self): else: _LOGGER.error("Unexpected response received: %s", error) self._error_reason = error - await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED) + await self._set_state(STATE_STOPPED) except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error: if self.failed_attempts > MAX_FAILED_ATTEMPTS: self._error_reason = ERROR_TOO_MANY_RETRIES - await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED) + await self._set_state(STATE_STOPPED) elif self.state != STATE_STOPPED: retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300) self.failed_attempts += 1 @@ -120,16 +138,16 @@ async def running(self): retry_delay, error, ) - await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED) + await self._set_state(STATE_DISCONNECTED) await asyncio.sleep(retry_delay) except Exception as error: # pylint: disable=broad-except if self.state != STATE_STOPPED: _LOGGER.exception("Unexpected exception occurred: %s", error) self._error_reason = error - await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED) + await self._set_state(STATE_STOPPED) else: if self.state != STATE_STOPPED: - await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED) + await self._set_state(STATE_DISCONNECTED) await asyncio.sleep(5) async def listen(self): @@ -140,7 +158,7 @@ async def listen(self): async def close(self): """Close the listening websocket.""" - await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED) + await self._set_state(STATE_STOPPED) await self.session.close() async def keepalive(self): @@ -151,7 +169,7 @@ async def keepalive(self): # Negitive time should indicate no pong reply so consider the # websocket disconnected. self._error_reason = ERROR_PING_TIMEOUT - await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED) + await self._set_state(STATE_DISCONNECTED) data = {"ping": 1} _LOGGER.debug("Sending message: %s to websocket.", data) @@ -168,7 +186,7 @@ async def keepalive(self): _LOGGER.error("Error parsing data: %s", err) except RuntimeError as err: _LOGGER.debug("Websocket connection issue: %s", err) - await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED) + await self._set_state(STATE_DISCONNECTED) except Exception as err: # pylint: disable=broad-exception-caught _LOGGER.debug("Problem sending ping request: %s", err) - await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED) + await self._set_state(STATE_DISCONNECTED) diff --git a/tests/conftest.py b/tests/conftest.py index 3ec4795..eb5f0ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Provide common pytest fixtures.""" -import json import pytest from aioresponses import aioresponses diff --git a/tests/test_main.py b/tests/test_main.py index bb801eb..e807beb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,5 @@ """Library tests.""" -import asyncio import json import logging from unittest import mock @@ -12,7 +11,6 @@ from freezegun import freeze_time from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError from aiohttp.client_reqrep import ConnectionKey -from awesomeversion.exceptions import AwesomeVersionCompareException import openevsehttp.__main__ as main from openevsehttp.__main__ import OpenEVSE @@ -23,8 +21,6 @@ UnsupportedFeature, ) from openevsehttp.websocket import ( - SIGNAL_CONNECTION_STATE, - STATE_CONNECTED, STATE_DISCONNECTED, ) from tests.common import load_fixture @@ -1118,7 +1114,7 @@ async def test_firmware_check( body="", ) firmware = await test_charger.firmware_check() - assert firmware == None + assert firmware is None mock_aioclient.get( TEST_URL_GITHUB_v4, @@ -2177,7 +2173,7 @@ async def test_get_shaper_updated(fixture, expected, request): await charger.ws_disconnect() -async def test_get_status(test_charger_timeout, caplog): +async def test_get_status_error(test_charger_timeout, caplog): """Test v4 Status reply.""" with caplog.at_level(logging.DEBUG): with pytest.raises(TimeoutError): From e5e50fd2e5c1d0b96a06f375d2771ad834c41fbf Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Sun, 1 Feb 2026 19:39:04 -0700 Subject: [PATCH 2/5] formatting --- openevsehttp/__main__.py | 32 ++++++++++++++++++++++++-------- tests/conftest.py | 1 - 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/openevsehttp/__main__.py b/openevsehttp/__main__.py index 825d4e1..9fee51c 100644 --- a/openevsehttp/__main__.py +++ b/openevsehttp/__main__.py @@ -346,7 +346,9 @@ async def set_charge_mode(self, mode: str = "fast") -> None: data = {"charge_mode": mode} _LOGGER.debug("Setting charge mode to %s", mode) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 result = response["msg"] if result not in ["done", "no change"]: _LOGGER.error("Problem issuing command: %s", response["msg"]) @@ -371,7 +373,9 @@ async def divert_mode(self) -> dict[str, str] | dict[str, Any]: data = {"divert_enabled": mode} _LOGGER.debug("Toggling divert: %s", mode) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 _LOGGER.debug("divert_mode response: %s", response) return response @@ -422,7 +426,9 @@ async def set_override( _LOGGER.debug("Override data: %s", data) _LOGGER.debug("Setting override config on %s", url) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 return response async def toggle_override(self) -> None: @@ -493,7 +499,9 @@ async def set_service_level(self, level: int = 2) -> None: data = {"service": level} _LOGGER.debug("Set service level to: %s", level) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 _LOGGER.debug("service response: %s", response) result = response["msg"] if result not in ["done", "no change"]: @@ -757,7 +765,9 @@ async def set_limit( _LOGGER.debug("Limit data: %s", data) _LOGGER.debug("Setting limit config on %s", url) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 return response async def clear_limit(self) -> Any: @@ -770,7 +780,9 @@ async def clear_limit(self) -> Any: data: Dict[str, Any] = {} _LOGGER.debug("Clearing limit config on %s", url) - response = await self.process_request(url=url, method="delete", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="delete", data=data + ) # noqa: E501 return response async def get_limit(self) -> Any: @@ -783,7 +795,9 @@ async def get_limit(self) -> Any: data: Dict[str, Any] = {} _LOGGER.debug("Getting limit config on %s", url) - response = await self.process_request(url=url, method="get", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="get", data=data + ) # noqa: E501 return response async def make_claim( @@ -818,7 +832,9 @@ async def make_claim( _LOGGER.debug("Claim data: %s", data) _LOGGER.debug("Setting up claim on %s", url) - response = await self.process_request(url=url, method="post", data=data) # noqa: E501 + response = await self.process_request( + url=url, method="post", data=data + ) # noqa: E501 return response async def release_claim(self, client: int = CLIENT) -> Any: diff --git a/tests/conftest.py b/tests/conftest.py index eb5f0ac..7108967 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Provide common pytest fixtures.""" - import pytest from aioresponses import aioresponses From 2c521cb6f128ba19a3916c543b107d92a71da03b Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Sun, 1 Feb 2026 19:44:10 -0700 Subject: [PATCH 3/5] linting --- openevsehttp/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openevsehttp/websocket.py b/openevsehttp/websocket.py index 546549f..3fc83ae 100644 --- a/openevsehttp/websocket.py +++ b/openevsehttp/websocket.py @@ -52,7 +52,7 @@ def state(self): @state.setter def state(self, value): - """Set the state (synchronous setter that schedules the callback).""" + """Setter that schedules the callback.""" self._state = value _LOGGER.debug("Websocket %s", value) # Schedule the callback asynchronously without awaiting here. From 5eb0db6e74c7327ed329954d3cc14cdc68e26017 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Mon, 2 Feb 2026 06:58:32 -0700 Subject: [PATCH 4/5] add test --- tests/test_websocket.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index e80ef5c..1707a14 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -280,3 +280,27 @@ async def test_keepalive_send_exceptions(ws_client_auth): ws_client_auth._client.send_json.side_effect = Exception("Generic err") await ws_client_auth.keepalive() assert ws_client_auth.state == STATE_DISCONNECTED + + +@pytest.mark.asyncio +async def test_state_setter_threadsafe_fallback(ws_client, mock_callback): + """Test state setter falls back to call_soon_threadsafe on RuntimeError.""" + mock_loop = MagicMock() + ws_client._error_reason = "Previous Error" + + with ( + patch( + "asyncio.create_task", side_effect=RuntimeError("No running loop") + ) as mock_create_task, + patch("asyncio.get_event_loop", return_value=mock_loop), + ): + + ws_client.state = STATE_CONNECTED + assert ws_client.state == STATE_CONNECTED + + mock_loop.call_soon_threadsafe.assert_called_once() + + args, _ = mock_loop.call_soon_threadsafe.call_args + assert args[0] is mock_create_task + + assert ws_client._error_reason is None From b882f338a477d1c213982009c8e885ea0d3a3cbc Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Mon, 2 Feb 2026 07:32:31 -0700 Subject: [PATCH 5/5] remove unused mock_callback from state setter test --- tests/test_websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 1707a14..d97d7aa 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -283,7 +283,7 @@ async def test_keepalive_send_exceptions(ws_client_auth): @pytest.mark.asyncio -async def test_state_setter_threadsafe_fallback(ws_client, mock_callback): +async def test_state_setter_threadsafe_fallback(ws_client): """Test state setter falls back to call_soon_threadsafe on RuntimeError.""" mock_loop = MagicMock() ws_client._error_reason = "Previous Error"