1
1
"""Provide a package for python-openevse-http."""
2
2
from __future__ import annotations
3
3
4
+ import asyncio
4
5
import datetime
5
6
import logging
6
- from typing import Optional
7
+ from typing import Any , Callable , Optional
7
8
9
+ import aiohttp # type: ignore
8
10
import requests # type: ignore
9
11
10
12
from .const import MAX_AMPS , MIN_AMPS
13
+ from .exceptions import AuthenticationError , ParseJSONError , HTTPError
11
14
12
15
_LOGGER = logging .getLogger (__name__ )
13
16
27
30
255 : "disabled" ,
28
31
}
29
32
33
+ ERROR_AUTH_FAILURE = "Authorization failure"
34
+ ERROR_TOO_MANY_RETRIES = "Too many retries"
35
+ ERROR_UNKNOWN = "Unknown"
30
36
31
- class AuthenticationError (Exception ):
32
- """Exception for authentication errors."""
37
+ MAX_FAILED_ATTEMPTS = 5
33
38
39
+ SIGNAL_CONNECTION_STATE = "websocket_state"
40
+ STATE_CONNECTED = "connected"
41
+ STATE_DISCONNECTED = "disconnected"
42
+ STATE_STARTING = "starting"
43
+ STATE_STOPPED = "stopped"
34
44
35
- class ParseJSONError (Exception ):
36
- """Exception for JSON parsing errors."""
37
45
46
+ class OpenEVSEWebsocket :
47
+ """Represent a websocket connection to a OpenEVSE charger."""
38
48
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
41
159
42
160
43
161
class OpenEVSE :
@@ -47,57 +165,133 @@ def __init__(self, host: str, user: str = None, pwd: str = None) -> None:
47
165
"""Connect to an OpenEVSE charger equipped with wifi or ethernet."""
48
166
self ._user = user
49
167
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 = {}
53
171
self ._override = None
172
+ self ._ws_listening = False
173
+ self .websocket : Optional [OpenEVSEWebsocket ] = None
174
+ self .callback : Optional [Callable ] = None
175
+ self ._loop = None
54
176
55
- def send_command (self , command : str ) -> tuple | None :
177
+ async def send_command (self , command : str ) -> tuple | None :
56
178
"""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"
58
181
data = {"json" : 1 , "rapi" : command }
59
182
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 )
77
185
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 :
79
203
"""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
97
291
98
292
def get_override (self ) -> None :
99
293
"""Get the manual override status."""
100
- url = f"{ self ._url } /overrride"
294
+ url = f"{ self .url } /overrride"
101
295
102
296
_LOGGER .debug ("Geting data from %s" , url )
103
297
if self ._user is not None :
@@ -121,7 +315,7 @@ def set_override(
121
315
auto_release : bool = True ,
122
316
) -> str :
123
317
"""Set the manual override status."""
124
- url = f"{ self ._url } /overrride"
318
+ url = f"{ self .url } /overrride"
125
319
126
320
if state not in ["active" , "disabled" ]:
127
321
raise ValueError
@@ -149,7 +343,7 @@ def set_override(
149
343
150
344
def toggle_override (self ) -> None :
151
345
"""Toggle the manual override status."""
152
- url = f"{ self ._url } /overrride"
346
+ url = f"{ self .url } /overrride"
153
347
154
348
_LOGGER .debug ("Toggling manual override %s" , url )
155
349
if self ._user is not None :
@@ -167,7 +361,7 @@ def toggle_override(self) -> None:
167
361
168
362
def clear_override (self ) -> None :
169
363
"""Clear the manual override status."""
170
- url = f"{ self ._url } /overrride"
364
+ url = f"{ self .url } /overrride"
171
365
172
366
_LOGGER .debug ("Clearing manual overrride %s" , url )
173
367
if self ._user is not None :
0 commit comments