11"""Provide a package for python-openevse-http."""
22from __future__ import annotations
33
4+ import asyncio
45import datetime
56import logging
6- from typing import Optional
7+ from typing import Any , Callable , Optional
78
9+ import aiohttp # type: ignore
810import requests # type: ignore
911
1012from .const import MAX_AMPS , MIN_AMPS
13+ from .exceptions import AuthenticationError , ParseJSONError , HTTPError
1114
1215_LOGGER = logging .getLogger (__name__ )
1316
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
43161class 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 :
0 commit comments