python-openevse-http 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {python_openevse_http-0.2.3/python_openevse_http.egg-info → python_openevse_http-0.2.5}/PKG-INFO +1 -1
  2. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/openevsehttp/__main__.py +101 -8
  3. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/openevsehttp/websocket.py +37 -15
  4. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5/python_openevse_http.egg-info}/PKG-INFO +1 -1
  5. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/python_openevse_http.egg-info/SOURCES.txt +1 -0
  6. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/setup.py +1 -1
  7. python_openevse_http-0.2.5/tests/test_external_session.py +199 -0
  8. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/tests/test_main.py +916 -5
  9. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/tests/test_websocket.py +26 -2
  10. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/LICENSE +0 -0
  11. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/README.md +0 -0
  12. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/openevsehttp/__init__.py +0 -0
  13. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/openevsehttp/const.py +0 -0
  14. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/openevsehttp/exceptions.py +0 -0
  15. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  16. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/python_openevse_http.egg-info/not-zip-safe +0 -0
  17. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/python_openevse_http.egg-info/requires.txt +0 -0
  18. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/python_openevse_http.egg-info/top_level.txt +0 -0
  19. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/setup.cfg +0 -0
  20. {python_openevse_http-0.2.3 → python_openevse_http-0.2.5}/tests/test_main_edge_cases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Python wrapper for OpenEVSE HTTP API
5
5
  Home-page: https://github.com/firstof9/python-openevse-http
6
6
  Download-URL: https://github.com/firstof9/python-openevse-http
@@ -3,10 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from datetime import datetime, timedelta, timezone
7
6
  import json
8
7
  import logging
9
8
  import re
9
+ from datetime import datetime, timedelta, timezone
10
10
  from typing import Any, Callable, Dict, Union
11
11
 
12
12
  import aiohttp # type: ignore
@@ -84,7 +84,13 @@ UPDATE_TRIGGERS = [
84
84
  class OpenEVSE:
85
85
  """Represent an OpenEVSE charger."""
86
86
 
87
- def __init__(self, host: str, user: str = "", pwd: str = "") -> None:
87
+ def __init__(
88
+ self,
89
+ host: str,
90
+ user: str = "",
91
+ pwd: str = "",
92
+ session: aiohttp.ClientSession | None = None,
93
+ ) -> None:
88
94
  """Connect to an OpenEVSE charger equipped with wifi or ethernet."""
89
95
  self._user = user
90
96
  self._pwd = pwd
@@ -97,6 +103,8 @@ class OpenEVSE:
97
103
  self.callback: Callable | None = None
98
104
  self._loop = None
99
105
  self.tasks = None
106
+ self._session = session
107
+ self._session_external = session is not None
100
108
 
101
109
  async def process_request(
102
110
  self,
@@ -113,7 +121,9 @@ class OpenEVSE:
113
121
  if self._user and self._pwd:
114
122
  auth = aiohttp.BasicAuth(self._user, self._pwd)
115
123
 
116
- async with aiohttp.ClientSession() as session:
124
+ # Use provided session or create a temporary one
125
+ if self._session is not None:
126
+ session = self._session
117
127
  http_method = getattr(session, method)
118
128
  _LOGGER.debug(
119
129
  "Connecting to %s with data: %s rapi: %s using method %s",
@@ -165,9 +175,59 @@ class OpenEVSE:
165
175
  except ContentTypeError as err:
166
176
  _LOGGER.error("Content error: %s", err.message)
167
177
  raise err
168
-
169
- await session.close()
170
- return message
178
+ else:
179
+ async with aiohttp.ClientSession() as session:
180
+ http_method = getattr(session, method)
181
+ _LOGGER.debug(
182
+ "Connecting to %s with data: %s rapi: %s using method %s",
183
+ url,
184
+ data,
185
+ rapi,
186
+ method,
187
+ )
188
+ try:
189
+ async with http_method(
190
+ url,
191
+ data=rapi,
192
+ json=data,
193
+ auth=auth,
194
+ ) as resp:
195
+ try:
196
+ message = await resp.text()
197
+ except UnicodeDecodeError:
198
+ _LOGGER.debug("Decoding error")
199
+ message = await resp.read()
200
+ message = message.decode(errors="replace")
201
+
202
+ try:
203
+ message = json.loads(message)
204
+ except ValueError:
205
+ _LOGGER.warning("Non JSON response: %s", message)
206
+
207
+ if resp.status == 400:
208
+ index = ""
209
+ if "msg" in message.keys():
210
+ index = "msg"
211
+ elif "error" in message.keys():
212
+ index = "error"
213
+ _LOGGER.error("Error 400: %s", message[index])
214
+ raise ParseJSONError
215
+ if resp.status == 401:
216
+ _LOGGER.error("Authentication error: %s", message)
217
+ raise AuthenticationError
218
+ if resp.status in [404, 405, 500]:
219
+ _LOGGER.warning("%s", message)
220
+
221
+ if method == "post" and "config_version" in message:
222
+ await self.update()
223
+ return message
224
+
225
+ except (TimeoutError, ServerTimeoutError) as err:
226
+ _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
227
+ raise err
228
+ except ContentTypeError as err:
229
+ _LOGGER.error("Content error: %s", err.message)
230
+ raise err
171
231
 
172
232
  async def send_command(self, command: str) -> tuple:
173
233
  """Send a RAPI command to the charger and parses the response."""
@@ -204,7 +264,7 @@ class OpenEVSE:
204
264
  if not self.websocket:
205
265
  # Start Websocket listening
206
266
  self.websocket = OpenEVSEWebsocket(
207
- self.url, self._update_status, self._user, self._pwd
267
+ self.url, self._update_status, self._user, self._pwd, self._session
208
268
  )
209
269
 
210
270
  async def test_and_get(self) -> dict:
@@ -573,7 +633,8 @@ class OpenEVSE:
573
633
  return None
574
634
 
575
635
  try:
576
- async with aiohttp.ClientSession() as session:
636
+ if self._session:
637
+ session = self._session
577
638
  http_method = getattr(session, method)
578
639
  _LOGGER.debug(
579
640
  "Connecting to %s using method %s",
@@ -590,6 +651,24 @@ class OpenEVSE:
590
651
  response["release_notes"] = message["body"]
591
652
  response["release_url"] = message["html_url"]
592
653
  return response
654
+ else:
655
+ async with aiohttp.ClientSession() as session:
656
+ http_method = getattr(session, method)
657
+ _LOGGER.debug(
658
+ "Connecting to %s using method %s",
659
+ url,
660
+ method,
661
+ )
662
+ async with http_method(url) as resp:
663
+ if resp.status != 200:
664
+ return None
665
+ message = await resp.text()
666
+ message = json.loads(message)
667
+ response = {}
668
+ response["latest_version"] = message["tag_name"]
669
+ response["release_notes"] = message["body"]
670
+ response["release_url"] = message["html_url"]
671
+ return response
593
672
 
594
673
  except (TimeoutError, ServerTimeoutError):
595
674
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -1233,6 +1312,20 @@ class OpenEVSE:
1233
1312
  return round(self._status["voltage"] * self._status["amp"], 2)
1234
1313
  return None
1235
1314
 
1315
+ # Shaper HTTP Posting
1316
+ async def set_shaper_live_pwr(self, power: int) -> None:
1317
+ """Send pushed sensor data to shaper."""
1318
+ if not self._version_check("4.0.0"):
1319
+ _LOGGER.debug("Feature not supported for older firmware.")
1320
+ raise UnsupportedFeature
1321
+
1322
+ url = f"{self.url}status"
1323
+ data = {"shaper_live_pwr": power}
1324
+
1325
+ _LOGGER.debug("Posting shaper data: %s", data)
1326
+ response = await self.process_request(url=url, method="post", data=data)
1327
+ _LOGGER.debug("Shaper response: %s", response)
1328
+
1236
1329
  # Shaper values
1237
1330
  @property
1238
1331
  def shaper_active(self) -> bool | None:
@@ -31,9 +31,11 @@ class OpenEVSEWebsocket:
31
31
  callback,
32
32
  user=None,
33
33
  password=None,
34
+ session: aiohttp.ClientSession | None = None,
34
35
  ):
35
36
  """Initialize a OpenEVSEWebsocket instance."""
36
- self.session = aiohttp.ClientSession()
37
+ self.session = session if session is not None else aiohttp.ClientSession()
38
+ self._session_external = session is not None
37
39
  self.uri = self._get_uri(server)
38
40
  self._user = user
39
41
  self._password = password
@@ -51,8 +53,26 @@ class OpenEVSEWebsocket:
51
53
  return self._state
52
54
 
53
55
  @state.setter
54
- async def state(self, value):
55
- """Set the state."""
56
+ def state(self, value):
57
+ """Setter that schedules the callback."""
58
+ self._state = value
59
+ _LOGGER.debug("Websocket %s", value)
60
+ # Schedule the callback asynchronously without awaiting here.
61
+ try:
62
+ asyncio.create_task(
63
+ self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
64
+ )
65
+ except RuntimeError:
66
+ # If there's no running loop, schedule safely on the event loop.
67
+ loop = asyncio.get_event_loop()
68
+ loop.call_soon_threadsafe(
69
+ asyncio.create_task,
70
+ self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason),
71
+ )
72
+ self._error_reason = None
73
+
74
+ async def _set_state(self, value):
75
+ """Async helper to set the state and await the callback."""
56
76
  self._state = value
57
77
  _LOGGER.debug("Websocket %s", value)
58
78
  await self.callback(SIGNAL_CONNECTION_STATE, value, self._error_reason)
@@ -65,7 +85,7 @@ class OpenEVSEWebsocket:
65
85
 
66
86
  async def running(self):
67
87
  """Open a persistent websocket connection and act on events."""
68
- await OpenEVSEWebsocket.state.fset(self, STATE_STARTING)
88
+ await self._set_state(STATE_STARTING)
69
89
  auth = None
70
90
 
71
91
  if self._user and self._password:
@@ -77,7 +97,7 @@ class OpenEVSEWebsocket:
77
97
  heartbeat=15,
78
98
  auth=auth,
79
99
  ) as ws_client:
80
- await OpenEVSEWebsocket.state.fset(self, STATE_CONNECTED)
100
+ await self._set_state(STATE_CONNECTED)
81
101
  self.failed_attempts = 0
82
102
  self._client = ws_client
83
103
 
@@ -107,11 +127,11 @@ class OpenEVSEWebsocket:
107
127
  else:
108
128
  _LOGGER.error("Unexpected response received: %s", error)
109
129
  self._error_reason = error
110
- await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
130
+ await self._set_state(STATE_STOPPED)
111
131
  except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as error:
112
132
  if self.failed_attempts > MAX_FAILED_ATTEMPTS:
113
133
  self._error_reason = ERROR_TOO_MANY_RETRIES
114
- await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
134
+ await self._set_state(STATE_STOPPED)
115
135
  elif self.state != STATE_STOPPED:
116
136
  retry_delay = min(2 ** (self.failed_attempts - 1) * 30, 300)
117
137
  self.failed_attempts += 1
@@ -120,16 +140,16 @@ class OpenEVSEWebsocket:
120
140
  retry_delay,
121
141
  error,
122
142
  )
123
- await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
143
+ await self._set_state(STATE_DISCONNECTED)
124
144
  await asyncio.sleep(retry_delay)
125
145
  except Exception as error: # pylint: disable=broad-except
126
146
  if self.state != STATE_STOPPED:
127
147
  _LOGGER.exception("Unexpected exception occurred: %s", error)
128
148
  self._error_reason = error
129
- await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
149
+ await self._set_state(STATE_STOPPED)
130
150
  else:
131
151
  if self.state != STATE_STOPPED:
132
- await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
152
+ await self._set_state(STATE_DISCONNECTED)
133
153
  await asyncio.sleep(5)
134
154
 
135
155
  async def listen(self):
@@ -140,8 +160,10 @@ class OpenEVSEWebsocket:
140
160
 
141
161
  async def close(self):
142
162
  """Close the listening websocket."""
143
- await OpenEVSEWebsocket.state.fset(self, STATE_STOPPED)
144
- await self.session.close()
163
+ await self._set_state(STATE_STOPPED)
164
+ # Only close the session if we created it
165
+ if not self._session_external:
166
+ await self.session.close()
145
167
 
146
168
  async def keepalive(self):
147
169
  """Send ping requests to websocket."""
@@ -151,7 +173,7 @@ class OpenEVSEWebsocket:
151
173
  # Negitive time should indicate no pong reply so consider the
152
174
  # websocket disconnected.
153
175
  self._error_reason = ERROR_PING_TIMEOUT
154
- await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
176
+ await self._set_state(STATE_DISCONNECTED)
155
177
 
156
178
  data = {"ping": 1}
157
179
  _LOGGER.debug("Sending message: %s to websocket.", data)
@@ -168,7 +190,7 @@ class OpenEVSEWebsocket:
168
190
  _LOGGER.error("Error parsing data: %s", err)
169
191
  except RuntimeError as err:
170
192
  _LOGGER.debug("Websocket connection issue: %s", err)
171
- await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
193
+ await self._set_state(STATE_DISCONNECTED)
172
194
  except Exception as err: # pylint: disable=broad-exception-caught
173
195
  _LOGGER.debug("Problem sending ping request: %s", err)
174
- await OpenEVSEWebsocket.state.fset(self, STATE_DISCONNECTED)
196
+ await self._set_state(STATE_DISCONNECTED)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Python wrapper for OpenEVSE HTTP API
5
5
  Home-page: https://github.com/firstof9/python-openevse-http
6
6
  Download-URL: https://github.com/firstof9/python-openevse-http
@@ -12,6 +12,7 @@ python_openevse_http.egg-info/dependency_links.txt
12
12
  python_openevse_http.egg-info/not-zip-safe
13
13
  python_openevse_http.egg-info/requires.txt
14
14
  python_openevse_http.egg-info/top_level.txt
15
+ tests/test_external_session.py
15
16
  tests/test_main.py
16
17
  tests/test_main_edge_cases.py
17
18
  tests/test_websocket.py
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
6
6
 
7
7
  PROJECT_DIR = Path(__file__).parent.resolve()
8
8
  README_FILE = PROJECT_DIR / "README.md"
9
- VERSION = "0.2.3"
9
+ VERSION = "0.2.5"
10
10
 
11
11
  setup(
12
12
  name="python_openevse_http",
@@ -0,0 +1,199 @@
1
+ """Test external session management."""
2
+
3
+ import json
4
+ from unittest.mock import AsyncMock, MagicMock
5
+
6
+ import aiohttp
7
+ import pytest
8
+
9
+ from openevsehttp.__main__ import OpenEVSE
10
+ from tests.common import load_fixture
11
+
12
+ pytestmark = pytest.mark.asyncio
13
+
14
+ TEST_URL_STATUS = "http://openevse.test.tld/status"
15
+ TEST_URL_CONFIG = "http://openevse.test.tld/config"
16
+ TEST_TLD = "openevse.test.tld"
17
+
18
+
19
+ async def test_external_session_provided():
20
+ """Test that an external session is used when provided."""
21
+ # Create a mock session
22
+ mock_session = MagicMock(spec=aiohttp.ClientSession)
23
+ mock_session.closed = False
24
+
25
+ # Mock the response
26
+ mock_response = AsyncMock()
27
+ mock_response.status = 200
28
+ mock_response.text = AsyncMock(return_value=load_fixture("v4_json/status.json"))
29
+
30
+ # Mock the get method to return the response
31
+ mock_get = AsyncMock(return_value=mock_response)
32
+ mock_get.__aenter__ = AsyncMock(return_value=mock_response)
33
+ mock_get.__aexit__ = AsyncMock(return_value=None)
34
+ mock_session.get = MagicMock(return_value=mock_get)
35
+
36
+ # Create OpenEVSE instance with external session
37
+ charger = OpenEVSE(TEST_TLD, session=mock_session)
38
+
39
+ # Verify the session is stored
40
+ assert charger._session is mock_session
41
+ assert charger._session_external is True
42
+
43
+ # Make a request
44
+ await charger.process_request(TEST_URL_STATUS, method="get")
45
+
46
+ # Verify the external session was used
47
+ mock_session.get.assert_called_once()
48
+
49
+
50
+ async def test_no_external_session(mock_aioclient):
51
+ """Test that a temporary session is created when none is provided."""
52
+ mock_aioclient.get(
53
+ TEST_URL_STATUS,
54
+ status=200,
55
+ body=load_fixture("v4_json/status.json"),
56
+ )
57
+
58
+ # Create OpenEVSE instance without external session
59
+ charger = OpenEVSE(TEST_TLD)
60
+
61
+ # Verify no session is stored
62
+ assert charger._session is None
63
+ assert charger._session_external is False
64
+
65
+ # Make a request - should create a temporary session
66
+ await charger.process_request(TEST_URL_STATUS, method="get")
67
+
68
+
69
+ async def test_external_session_with_update(mock_aioclient):
70
+ """Test that external session is used during update."""
71
+ mock_aioclient.get(
72
+ TEST_URL_STATUS,
73
+ status=200,
74
+ body=load_fixture("v4_json/status.json"),
75
+ )
76
+ mock_aioclient.get(
77
+ TEST_URL_CONFIG,
78
+ status=200,
79
+ body=load_fixture("v4_json/config.json"),
80
+ )
81
+
82
+ # Create a real session for testing
83
+ async with aiohttp.ClientSession() as session:
84
+ # Create OpenEVSE instance with external session
85
+ charger = OpenEVSE(TEST_TLD, session=session)
86
+
87
+ # Verify the session is stored
88
+ assert charger._session is session
89
+ assert charger._session_external is True
90
+
91
+ # Update should use the external session
92
+ await charger.update()
93
+
94
+ # Verify status was updated
95
+ assert charger._status is not None
96
+ assert charger._config is not None
97
+
98
+
99
+ async def test_websocket_uses_external_session(mock_aioclient):
100
+ """Test that websocket uses the external session."""
101
+ mock_aioclient.get(
102
+ TEST_URL_STATUS,
103
+ status=200,
104
+ body=load_fixture("v4_json/status.json"),
105
+ )
106
+ mock_aioclient.get(
107
+ TEST_URL_CONFIG,
108
+ status=200,
109
+ body=load_fixture("v4_json/config.json"),
110
+ )
111
+
112
+ # Create a real session for testing
113
+ async with aiohttp.ClientSession() as session:
114
+ # Create OpenEVSE instance with external session
115
+ charger = OpenEVSE(TEST_TLD, session=session)
116
+
117
+ # Update to initialize websocket
118
+ await charger.update()
119
+
120
+ # Verify websocket was created with the session
121
+ assert charger.websocket is not None
122
+ assert charger.websocket.session is session
123
+ assert charger.websocket._session_external is True
124
+
125
+ # Cleanup
126
+ await charger.ws_disconnect()
127
+
128
+
129
+ async def test_firmware_check_with_external_session(mock_aioclient):
130
+ """Test that firmware_check uses external session."""
131
+ mock_aioclient.get(
132
+ TEST_URL_STATUS,
133
+ status=200,
134
+ body=load_fixture("v4_json/status.json"),
135
+ )
136
+ mock_aioclient.get(
137
+ TEST_URL_CONFIG,
138
+ status=200,
139
+ body=load_fixture("v4_json/config.json"),
140
+ )
141
+
142
+ github_response = {
143
+ "tag_name": "v4.2.0",
144
+ "body": "Release notes",
145
+ "html_url": "https://github.com/OpenEVSE/ESP32_WiFi_V4.x/releases/tag/v4.2.0",
146
+ }
147
+
148
+ mock_aioclient.get(
149
+ "https://api.github.com/repos/OpenEVSE/ESP32_WiFi_V4.x/releases/latest",
150
+ status=200,
151
+ body=json.dumps(github_response),
152
+ )
153
+
154
+ # Create OpenEVSE instance without external session (use mocked responses)
155
+ charger = OpenEVSE(TEST_TLD)
156
+
157
+ # Load config first
158
+ await charger.update()
159
+
160
+ # Check firmware - should use mocked session
161
+ result = await charger.firmware_check()
162
+
163
+ # Verify result
164
+ assert result is not None
165
+ assert result["latest_version"] == "v4.2.0"
166
+
167
+
168
+ async def test_session_not_closed_when_external(mock_aioclient):
169
+ """Test that external session is not closed by the library."""
170
+ mock_aioclient.get(
171
+ TEST_URL_STATUS,
172
+ status=200,
173
+ body=load_fixture("v4_json/status.json"),
174
+ )
175
+ mock_aioclient.get(
176
+ TEST_URL_CONFIG,
177
+ status=200,
178
+ body=load_fixture("v4_json/config.json"),
179
+ )
180
+
181
+ # Create a real session
182
+ session = aiohttp.ClientSession()
183
+
184
+ try:
185
+ # Create OpenEVSE instance with external session
186
+ charger = OpenEVSE(TEST_TLD, session=session)
187
+
188
+ # Update to initialize websocket
189
+ await charger.update()
190
+
191
+ # Disconnect websocket
192
+ await charger.ws_disconnect()
193
+
194
+ # Session should still be open (not closed by library)
195
+ assert not session.closed
196
+
197
+ finally:
198
+ # Clean up the session ourselves
199
+ await session.close()