Skip to content

Commit a16fad4

Browse files
authored
refactor: decouple state setter from async callback execution (#506)
* refactor: optimize websocket handling * formatting * linting * add test * remove unused mock_callback from state setter test
1 parent cdbca62 commit a16fad4

4 files changed

Lines changed: 57 additions & 21 deletions

File tree

openevsehttp/websocket.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,26 @@ def state(self):
5151
return self._state
5252

5353
@state.setter
54-
async def state(self, value):
55-
"""Set the state."""
54+
def state(self, value):
55+
"""Setter that schedules the callback."""
56+
self._state = value
57+
_LOGGER.debug("Websocket %s", value)
58+
# Schedule the callback asynchronously without awaiting here.
59+
try:
60+
asyncio.create_task(
61+
self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
62+
)
63+
except RuntimeError:
64+
# If there's no running loop, schedule safely on the event loop.
65+
loop = asyncio.get_event_loop()
66+
loop.call_soon_threadsafe(
67+
asyncio.create_task,
68+
self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason),
69+
)
70+
self._error_reason = None
71+
72+
async def _set_state(self, value):
73+
"""Async helper to set the state and await the callback."""
5674
self._state = value
5775
_LOGGER.debug("Websocket %s", value)
5876
await self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
@@ -65,7 +83,7 @@ def _get_uri(server):
6583

6684
async def running(self):
6785
"""Open a persistent websocket connection and act on events."""
68-
await OpenEVSEWebsocket.state.fset(self, STATE_STARTING)
86+
await self._set_state(STATE_STARTING)
6987
auth = None
7088

7189
if self._user and self._password:
@@ -77,7 +95,7 @@ async def running(self):
7795
heartbeat=15,
7896
auth=auth,
7997
) as ws_client:
80-
await OpenEVSEWebsocket.state.fset(self, STATE_CONNECTED)
98+
await self._set_state(STATE_CONNECTED)
8199
self.failed_attempts = 0
82100
self._client = ws_client
83101

@@ -107,11 +125,11 @@ async def running(self):
107125
else:
108126
_LOGGER.error("Unexpected response received: %s", error)
109127
self._error_reason = error
110-
await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
128+
await self._set_state(STATE_STOPPED)
111129
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error:
112130
if self.failed_attempts > MAX_FAILED_ATTEMPTS:
113131
self._error_reason = ERROR_TOO_MANY_RETRIES
114-
await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
132+
await self._set_state(STATE_STOPPED)
115133
elif self.state != STATE_STOPPED:
116134
retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300)
117135
self.failed_attempts += 1
@@ -120,16 +138,16 @@ async def running(self):
120138
retry_delay,
121139
error,
122140
)
123-
await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
141+
await self._set_state(STATE_DISCONNECTED)
124142
await asyncio.sleep(retry_delay)
125143
except Exception as error: # pylint: disable=broad-except
126144
if self.state != STATE_STOPPED:
127145
_LOGGER.exception("Unexpected exception occurred: %s", error)
128146
self._error_reason = error
129-
await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
147+
await self._set_state(STATE_STOPPED)
130148
else:
131149
if self.state != STATE_STOPPED:
132-
await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
150+
await self._set_state(STATE_DISCONNECTED)
133151
await asyncio.sleep(5)
134152

135153
async def listen(self):
@@ -140,7 +158,7 @@ async def listen(self):
140158

141159
async def close(self):
142160
"""Close the listening websocket."""
143-
await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
161+
await self._set_state(STATE_STOPPED)
144162
await self.session.close()
145163

146164
async def keepalive(self):
@@ -151,7 +169,7 @@ async def keepalive(self):
151169
# Negitive time should indicate no pong reply so consider the
152170
# websocket disconnected.
153171
self._error_reason = ERROR_PING_TIMEOUT
154-
await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
172+
await self._set_state(STATE_DISCONNECTED)
155173

156174
data = {"ping": 1}
157175
_LOGGER.debug("Sending message: %s to websocket.", data)
@@ -168,7 +186,7 @@ async def keepalive(self):
168186
_LOGGER.error("Error parsing data: %s", err)
169187
except RuntimeError as err:
170188
_LOGGER.debug("Websocket connection issue: %s", err)
171-
await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
189+
await self._set_state(STATE_DISCONNECTED)
172190
except Exception as err: # pylint: disable=broad-exception-caught
173191
_LOGGER.debug("Problem sending ping request: %s", err)
174-
await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
192+
await self._set_state(STATE_DISCONNECTED)

tests/conftest.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Provide common pytest fixtures."""
22

3-
import json
4-
53
import pytest
64
from aioresponses import aioresponses
75

tests/test_main.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Library tests."""
22

3-
import asyncio
43
import json
54
import logging
65
from unittest import mock
@@ -12,7 +11,6 @@
1211
from freezegun import freeze_time
1312
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
1413
from aiohttp.client_reqrep import ConnectionKey
15-
from awesomeversion.exceptions import AwesomeVersionCompareException
1614

1715
import openevsehttp.__main__ as main
1816
from openevsehttp.__main__ import OpenEVSE
@@ -23,8 +21,6 @@
2321
UnsupportedFeature,
2422
)
2523
from openevsehttp.websocket import (
26-
SIGNAL_CONNECTION_STATE,
27-
STATE_CONNECTED,
2824
STATE_DISCONNECTED,
2925
)
3026
from tests.common import load_fixture
@@ -1118,7 +1114,7 @@ async def test_firmware_check(
11181114
body="",
11191115
)
11201116
firmware = await test_charger.firmware_check()
1121-
assert firmware == None
1117+
assert firmware is None
11221118

11231119
mock_aioclient.get(
11241120
TEST_URL_GITHUB_v4,
@@ -2177,7 +2173,7 @@ async def test_get_shaper_updated(fixture, expected, request):
21772173
await charger.ws_disconnect()
21782174

21792175

2180-
async def test_get_status(test_charger_timeout, caplog):
2176+
async def test_get_status_error(test_charger_timeout, caplog):
21812177
"""Test v4 Status reply."""
21822178
with caplog.at_level(logging.DEBUG):
21832179
with pytest.raises(TimeoutError):

tests/test_websocket.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,27 @@ async def test_keepalive_send_exceptions(ws_client_auth):
280280
ws_client_auth._client.send_json.side_effect = Exception("Generic err")
281281
await ws_client_auth.keepalive()
282282
assert ws_client_auth.state == STATE_DISCONNECTED
283+
284+
285+
@pytest.mark.asyncio
286+
async def test_state_setter_threadsafe_fallback(ws_client):
287+
"""Test state setter falls back to call_soon_threadsafe on RuntimeError."""
288+
mock_loop = MagicMock()
289+
ws_client._error_reason = "Previous Error"
290+
291+
with (
292+
patch(
293+
"asyncio.create_task", side_effect=RuntimeError("No running loop")
294+
) as mock_create_task,
295+
patch("asyncio.get_event_loop", return_value=mock_loop),
296+
):
297+
298+
ws_client.state = STATE_CONNECTED
299+
assert ws_client.state == STATE_CONNECTED
300+
301+
mock_loop.call_soon_threadsafe.assert_called_once()
302+
303+
args, _ = mock_loop.call_soon_threadsafe.call_args
304+
assert args[0] is mock_create_task
305+
306+
assert ws_client._error_reason is None

0 commit comments

Comments
 (0)