Skip to content

Commit 781ec09

Browse files
authored
Use websockets to update data (firstof9#9)
* fix: url and form data * fix tests * formatting * feature: add ota_update, vehicle, and state properties * start adding HTTP override support * more tests * Delete tests/__pycache__ directory * move exceptions to their own file * Add websocket support (firstof9#7) * Add websocket listener * Add auth to websocket connection * version bump * clean up (firstof9#8) * Add websocket listener * Add auth to websocket connection * clean up * Update setup.py * fix ws url parsing * Update setup.py * update function starts websocket listener * update tests and adjust functions * switch to aiohttp - stage 1 * Update setup.py * remove unneeded .json() * fix typo * adjust tests * Update setup.py * add more debugging * Attempt to place listener in it's own event loop * move websocket listener start out side of session * create event loop for websocket * new function to start the listener * websockets * make sure not to keep opening listeners if one is running * only start a loop if it's not running * better loop handling * move loop detection * more debugging * better loop check * output ws data as json * parse data into status variable * properly update dictionary * don't parse data * add callback * add coordinator * Update setup.py * async * clean up * typing * Revert "Merge branch 'main' into dev" This reverts commit 8a3c855, reversing changes made to fd07d7b.
1 parent 72ffdf2 commit 781ec09

File tree

7 files changed

+477
-211
lines changed

7 files changed

+477
-211
lines changed

openevsehttp/__init__.py

+245-51
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Provide a package for python-openevse-http."""
22
from __future__ import annotations
33

4+
import asyncio
45
import datetime
56
import logging
6-
from typing import Optional
7+
from typing import Any, Callable, Optional
78

9+
import aiohttp # type: ignore
810
import requests # type: ignore
911

1012
from .const import MAX_AMPS, MIN_AMPS
13+
from .exceptions import AuthenticationError, ParseJSONError, HTTPError
1114

1215
_LOGGER = logging.getLogger(__name__)
1316

@@ -27,17 +30,132 @@
2730
255: "disabled",
2831
}
2932

33+
ERROR_AUTH_FAILURE = "Authorization failure"
34+
ERROR_TOO_MANY_RETRIES = "Too many retries"
35+
ERROR_UNKNOWN = "Unknown"
3036

31-
class AuthenticationError(Exception):
32-
"""Exception for authentication errors."""
37+
MAX_FAILED_ATTEMPTS = 5
3338

39+
SIGNAL_CONNECTION_STATE = "websocket_state"
40+
STATE_CONNECTED = "connected"
41+
STATE_DISCONNECTED = "disconnected"
42+
STATE_STARTING = "starting"
43+
STATE_STOPPED = "stopped"
3444

35-
class ParseJSONError(Exception):
36-
"""Exception for JSON parsing errors."""
3745

46+
class OpenEVSEWebsocket:
47+
"""Represent a websocket connection to a OpenEVSE charger."""
3848

39-
class HTTPError(Exception):
40-
"""Exception for HTTP errors."""
49+
def __init__(
50+
self,
51+
server,
52+
callback,
53+
user=None,
54+
password=None,
55+
):
56+
"""Initialize a OpenEVSEWebsocket instance."""
57+
self.session = aiohttp.ClientSession()
58+
self.uri = self._get_uri(server)
59+
self._user = user
60+
self._password = password
61+
self.callback = callback
62+
self._state = None
63+
self.failed_attempts = 0
64+
self._error_reason = None
65+
66+
@property
67+
def state(self):
68+
"""Return the current state."""
69+
return self._state
70+
71+
@state.setter
72+
def state(self, value):
73+
"""Set the state."""
74+
self._state = value
75+
_LOGGER.debug("Websocket %s", value)
76+
self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
77+
self._error_reason = None
78+
79+
@staticmethod
80+
def _get_uri(server):
81+
"""Generate the websocket URI."""
82+
return server[: server.rfind("/")].replace("http", "ws") + "/ws"
83+
84+
async def running(self):
85+
"""Open a persistent websocket connection and act on events."""
86+
self.state = STATE_STARTING
87+
auth = None
88+
89+
if self._user and self._password:
90+
auth = aiohttp.BasicAuth(self._user, self._password)
91+
92+
try:
93+
async with self.session.ws_connect(
94+
self.uri,
95+
heartbeat=15,
96+
auth=auth,
97+
) as ws_client:
98+
self.state = STATE_CONNECTED
99+
self.failed_attempts = 0
100+
101+
async for message in ws_client:
102+
if self.state == STATE_STOPPED:
103+
break
104+
105+
if message.type == aiohttp.WSMsgType.TEXT:
106+
msg = message.json()
107+
msgtype = "data"
108+
self.callback(msgtype, msg, None)
109+
110+
elif message.type == aiohttp.WSMsgType.CLOSED:
111+
_LOGGER.warning("Websocket connection closed")
112+
break
113+
114+
elif message.type == aiohttp.WSMsgType.ERROR:
115+
_LOGGER.error("Websocket error")
116+
break
117+
118+
except aiohttp.ClientResponseError as error:
119+
if error.code == 401:
120+
_LOGGER.error("Credentials rejected: %s", error)
121+
self._error_reason = ERROR_AUTH_FAILURE
122+
else:
123+
_LOGGER.error("Unexpected response received: %s", error)
124+
self._error_reason = ERROR_UNKNOWN
125+
self.state = STATE_STOPPED
126+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error:
127+
if self.failed_attempts >= MAX_FAILED_ATTEMPTS:
128+
self._error_reason = ERROR_TOO_MANY_RETRIES
129+
self.state = STATE_STOPPED
130+
elif self.state != STATE_STOPPED:
131+
retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300)
132+
self.failed_attempts += 1
133+
_LOGGER.error(
134+
"Websocket connection failed, retrying in %ds: %s",
135+
retry_delay,
136+
error,
137+
)
138+
self.state = STATE_DISCONNECTED
139+
await asyncio.sleep(retry_delay)
140+
except Exception as error: # pylint: disable=broad-except
141+
if self.state != STATE_STOPPED:
142+
_LOGGER.exception("Unexpected exception occurred: %s", error)
143+
self._error_reason = ERROR_UNKNOWN
144+
self.state = STATE_STOPPED
145+
else:
146+
if self.state != STATE_STOPPED:
147+
self.state = STATE_DISCONNECTED
148+
await asyncio.sleep(5)
149+
150+
async def listen(self):
151+
"""Start the listening websocket."""
152+
self.failed_attempts = 0
153+
while self.state != STATE_STOPPED:
154+
await self.running()
155+
156+
def close(self):
157+
"""Close the listening websocket."""
158+
self.state = STATE_STOPPED
41159

42160

43161
class OpenEVSE:
@@ -47,57 +165,133 @@ def __init__(self, host: str, user: str = None, pwd: str = None) -> None:
47165
"""Connect to an OpenEVSE charger equipped with wifi or ethernet."""
48166
self._user = user
49167
self._pwd = pwd
50-
self._url = f"http://{host}"
51-
self._status = None
52-
self._config = None
168+
self.url = f"http://{host}/"
169+
self._status: dict = {}
170+
self._config: dict = {}
53171
self._override = None
172+
self._ws_listening = False
173+
self.websocket: Optional[OpenEVSEWebsocket] = None
174+
self.callback: Optional[Callable] = None
175+
self._loop = None
54176

55-
def send_command(self, command: str) -> tuple | None:
177+
async def send_command(self, command: str) -> tuple | None:
56178
"""Send a RAPI command to the charger and parses the response."""
57-
url = f"{self._url}/r"
179+
auth = None
180+
url = f"{self.url}r"
58181
data = {"json": 1, "rapi": command}
59182

60-
_LOGGER.debug("Posting data: %s to %s", command, url)
61-
if self._user is not None:
62-
value = requests.post(url, data=data, auth=(self._user, self._pwd))
63-
else:
64-
value = requests.post(url, data=data)
65-
66-
if value.status_code == 400:
67-
_LOGGER.debug("JSON error: %s", value.text)
68-
raise ParseJSONError
69-
if value.status_code == 401:
70-
_LOGGER.debug("Authentication error: %s", value)
71-
raise AuthenticationError
72-
73-
if "ret" not in value.json():
74-
return False, ""
75-
resp = value.json()
76-
return resp["cmd"], resp["ret"]
183+
if self._user and self._pwd:
184+
auth = aiohttp.BasicAuth(self._user, self._pwd)
77185

78-
def update(self) -> None:
186+
_LOGGER.debug("Posting data: %s to %s", command, url)
187+
async with aiohttp.ClientSession() as session:
188+
async with session.post(url, data=data, auth=auth) as resp:
189+
if resp.status == 400:
190+
_LOGGER.debug("JSON error: %s", await resp.text())
191+
raise ParseJSONError
192+
if resp.status == 401:
193+
_LOGGER.debug("Authentication error: %s", await resp)
194+
raise AuthenticationError
195+
196+
value = await resp.json()
197+
198+
if "ret" not in value:
199+
return False, ""
200+
return value["cmd"], value["ret"]
201+
202+
async def update(self) -> None:
79203
"""Update the values."""
80-
urls = [f"{self._url}/status", f"{self._url}/config"]
81-
82-
for url in urls:
83-
_LOGGER.debug("Updating data from %s", url)
84-
if self._user is not None:
85-
value = requests.get(url, auth=(self._user, self._pwd))
86-
else:
87-
value = requests.get(url)
88-
89-
if value.status_code == 401:
90-
_LOGGER.debug("Authentication error: %s", value)
91-
raise AuthenticationError
92-
93-
if "/status" in url:
94-
self._status = value.json()
95-
else:
96-
self._config = value.json()
204+
auth = None
205+
urls = [f"{self.url}config"]
206+
207+
if self._user and self._pwd:
208+
auth = aiohttp.BasicAuth(self._user, self._pwd)
209+
210+
if not self._ws_listening:
211+
urls = [f"{self.url}status", f"{self.url}config"]
212+
213+
async with aiohttp.ClientSession() as session:
214+
for url in urls:
215+
_LOGGER.debug("Updating data from %s", url)
216+
async with session.get(url, auth=auth) as resp:
217+
if resp.status == 401:
218+
_LOGGER.debug("Authentication error: %s", resp)
219+
raise AuthenticationError
220+
221+
if "/status" in url:
222+
self._status = await resp.json()
223+
_LOGGER.debug("Status update: %s", self._status)
224+
else:
225+
self._config = await resp.json()
226+
_LOGGER.debug("Config update: %s", self._config)
227+
228+
if not self.websocket:
229+
# Start Websocket listening
230+
self.websocket = OpenEVSEWebsocket(
231+
self.url, self._update_status, self._user, self._pwd
232+
)
233+
if not self._ws_listening:
234+
self._start_listening()
235+
236+
def _start_listening(self):
237+
"""Start the websocket listener."""
238+
try:
239+
_LOGGER.debug("Attempting to find running loop...")
240+
self._loop = asyncio.get_running_loop()
241+
except RuntimeError:
242+
self._loop = asyncio.get_event_loop()
243+
_LOGGER.debug("Using new event loop...")
244+
245+
if not self._ws_listening:
246+
self._loop.create_task(self.websocket.listen())
247+
pending = asyncio.all_tasks()
248+
self._ws_listening = True
249+
self._loop.run_until_complete(asyncio.gather(*pending))
250+
251+
def _update_status(self, msgtype, data, error):
252+
"""Update data from websocket listener."""
253+
if msgtype == SIGNAL_CONNECTION_STATE:
254+
if data == STATE_CONNECTED:
255+
_LOGGER.debug("Websocket to %s successful", self.url)
256+
self._ws_listening = True
257+
elif data == STATE_DISCONNECTED:
258+
_LOGGER.debug(
259+
"Websocket to %s disconnected, retrying",
260+
self.url,
261+
)
262+
self._ws_listening = False
263+
# Stopped websockets without errors are expected during shutdown
264+
# and ignored
265+
elif data == STATE_STOPPED and error:
266+
_LOGGER.error(
267+
"Websocket to %s failed, aborting [Error: %s]",
268+
self.url,
269+
error,
270+
)
271+
self._ws_listening = False
272+
273+
elif msgtype == "data":
274+
_LOGGER.debug("ws_data: %s", data)
275+
self._status.update(data)
276+
277+
if self.callback is not None:
278+
self.callback()
279+
280+
def ws_disconnect(self) -> None:
281+
"""Disconnect the websocket listener."""
282+
assert self.websocket
283+
self.websocket.close()
284+
self._ws_listening = False
285+
286+
@property
287+
def ws_state(self) -> Any:
288+
"""Return the status of the websocket listener."""
289+
assert self.websocket
290+
return self.websocket.state
97291

98292
def get_override(self) -> None:
99293
"""Get the manual override status."""
100-
url = f"{self._url}/overrride"
294+
url = f"{self.url}/overrride"
101295

102296
_LOGGER.debug("Geting data from %s", url)
103297
if self._user is not None:
@@ -121,7 +315,7 @@ def set_override(
121315
auto_release: bool = True,
122316
) -> str:
123317
"""Set the manual override status."""
124-
url = f"{self._url}/overrride"
318+
url = f"{self.url}/overrride"
125319

126320
if state not in ["active", "disabled"]:
127321
raise ValueError
@@ -149,7 +343,7 @@ def set_override(
149343

150344
def toggle_override(self) -> None:
151345
"""Toggle the manual override status."""
152-
url = f"{self._url}/overrride"
346+
url = f"{self.url}/overrride"
153347

154348
_LOGGER.debug("Toggling manual override %s", url)
155349
if self._user is not None:
@@ -167,7 +361,7 @@ def toggle_override(self) -> None:
167361

168362
def clear_override(self) -> None:
169363
"""Clear the manual override status."""
170-
url = f"{self._url}/overrride"
364+
url = f"{self.url}/overrride"
171365

172366
_LOGGER.debug("Clearing manual overrride %s", url)
173367
if self._user is not None:

openevsehttp/exceptions.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Exceptions."""
2+
3+
4+
class AuthenticationError(Exception):
5+
"""Exception for authentication errors."""
6+
7+
8+
class ParseJSONError(Exception):
9+
"""Exception for JSON parsing errors."""
10+
11+
12+
class HTTPError(Exception):
13+
"""Exception for HTTP errors."""

pylintrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ disable=
2626
too-many-public-methods,
2727
too-many-instance-attributes,
2828
too-many-branches,
29-
no-self-use
29+
no-self-use,
30+
too-many-statements
3031

3132
[REPORTS]
3233
score=no

requirements_test.txt

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
pytest==6.2.4
22
pytest-cov==2.12.1
33
pytest-timeout==1.4.2
4+
pytest-aiohttp
45
requests_mock
6+
aiohttp
7+
aioresponses

0 commit comments

Comments
 (0)