Skip to content

Commit 2ab4f1d

Browse files
authored
feat: Allow passing external aiohttp.ClientSession to OpenEVSE (#508)
* feat: Allow passing external aiohttp.ClientSession to OpenEVSE and OpenEVSEWebsocket for improved session management and lifecycle control. * formatting * formatting again * update test config * update tests * update doc * update tests again * formatting * update tests and suggested changes * formatting * recommended update * formatting and linting
1 parent 56f4967 commit 2ab4f1d

8 files changed

Lines changed: 1392 additions & 16 deletions

File tree

EXTERNAL_SESSION.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# External Session Management
2+
3+
## Overview
4+
5+
The `python-openevse-http` library now supports passing an external `aiohttp.ClientSession` to the `OpenEVSE` class. This allows you to manage the session lifecycle yourself and share sessions across multiple API clients.
6+
7+
## Benefits
8+
9+
- **Session Reuse**: Share a single session across multiple OpenEVSE instances or other aiohttp-based clients
10+
- **Custom Configuration**: Configure session settings like timeouts, connectors, and SSL verification
11+
- **Resource Management**: Better control over connection pooling and resource cleanup
12+
- **Integration**: Easier integration with existing applications that already manage aiohttp sessions
13+
14+
## Usage
15+
16+
### With External Session
17+
18+
```python
19+
import aiohttp
20+
from openevsehttp import OpenEVSE
21+
22+
async def main():
23+
# Create your own session with custom settings
24+
timeout = aiohttp.ClientTimeout(total=30)
25+
async with aiohttp.ClientSession(timeout=timeout) as session:
26+
# Pass the session to OpenEVSE
27+
charger = OpenEVSE("openevse.local", session=session)
28+
29+
# Use the charger normally
30+
await charger.update()
31+
print(f"Status: {charger.status}")
32+
33+
# Clean up
34+
await charger.ws_disconnect()
35+
# Session will be closed by the context manager
36+
```
37+
38+
### Without External Session (Backward Compatible)
39+
40+
```python
41+
from openevsehttp import OpenEVSE
42+
43+
async def main():
44+
# The library creates and manages its own sessions
45+
charger = OpenEVSE("openevse.local")
46+
47+
# Use the charger normally
48+
await charger.update()
49+
print(f"Status: {charger.status}")
50+
51+
await charger.ws_disconnect()
52+
```
53+
54+
### Sharing a Session
55+
56+
```python
57+
import aiohttp
58+
from openevsehttp import OpenEVSE
59+
60+
async def main():
61+
async with aiohttp.ClientSession() as session:
62+
# Use the same session for multiple chargers
63+
charger1 = OpenEVSE("charger1.local", session=session)
64+
charger2 = OpenEVSE("charger2.local", session=session)
65+
66+
# Both chargers use the same session
67+
await charger1.update()
68+
await charger2.update()
69+
70+
await charger1.ws_disconnect()
71+
await charger2.ws_disconnect()
72+
```
73+
74+
## API Changes
75+
76+
### `OpenEVSE.__init__()`
77+
78+
```python
79+
def __init__(
80+
self,
81+
host: str,
82+
user: str = "",
83+
pwd: str = "",
84+
session: aiohttp.ClientSession | None = None,
85+
) -> None:
86+
```
87+
88+
**Parameters:**
89+
- `host` (str): The hostname or IP address of the OpenEVSE charger
90+
- `user` (str, optional): Username for authentication
91+
- `pwd` (str, optional): Password for authentication
92+
- `session` (aiohttp.ClientSession | None, optional): External session to use for HTTP requests. If not provided, the library will create temporary sessions as needed.
93+
94+
### `OpenEVSEWebsocket.__init__()`
95+
96+
```python
97+
def __init__(
98+
self,
99+
server,
100+
callback,
101+
user=None,
102+
password=None,
103+
session: aiohttp.ClientSession | None = None,
104+
):
105+
```
106+
107+
**Parameters:**
108+
- `server`: The server URL
109+
- `callback`: Callback function for websocket events
110+
- `user` (optional): Username for authentication
111+
- `password` (optional): Password for authentication
112+
- `session` (aiohttp.ClientSession | None, optional): External session to use for websocket connections. If not provided, a new session will be created.
113+
114+
## Important Notes
115+
116+
1. **Session Lifecycle**: When you provide an external session, you are responsible for closing it. The library will NOT close externally provided sessions.
117+
118+
2. **Backward Compatibility**: This change is fully backward compatible. Existing code that doesn't provide a session will continue to work exactly as before.
119+
120+
3. **Websocket Sessions**: The websocket connection will also use the provided session, ensuring consistent session management across all HTTP and WebSocket operations.
121+
122+
4. **Thread Safety**: If you're using the same session across multiple OpenEVSE instances, ensure you're following aiohttp's thread safety guidelines.
123+
124+
## Migration Guide
125+
126+
If you want to migrate existing code to use external sessions:
127+
128+
**Before:**
129+
```python
130+
charger = OpenEVSE("openevse.local")
131+
await charger.update()
132+
```
133+
134+
**After:**
135+
```python
136+
async with aiohttp.ClientSession() as session:
137+
charger = OpenEVSE("openevse.local", session=session)
138+
await charger.update()
139+
```
140+
141+
No other changes are required!

example_external_session.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Example of using python-openevse-http with an external aiohttp.ClientSession.
2+
3+
This demonstrates how to pass your own session to the library, which is useful when:
4+
- You want to manage the session lifecycle yourself
5+
- You need to share a session across multiple API clients
6+
- You want to configure custom session settings (timeouts, connectors, etc.)
7+
"""
8+
9+
import asyncio
10+
11+
import aiohttp
12+
13+
from openevsehttp.__main__ import OpenEVSE
14+
15+
16+
async def example_with_external_session():
17+
"""Example using an external session."""
18+
# Create your own session with custom settings
19+
timeout = aiohttp.ClientTimeout(total=30)
20+
async with aiohttp.ClientSession(timeout=timeout) as session:
21+
# Pass the session to OpenEVSE
22+
charger = OpenEVSE("openevse.local", session=session)
23+
24+
# Use the charger normally
25+
await charger.update()
26+
print(f"Status: {charger.status}")
27+
print(f"Current: {charger.charging_current}A")
28+
29+
# The session will be closed when the context manager exits
30+
# but OpenEVSE won't close it (since it's externally managed)
31+
await charger.ws_disconnect()
32+
33+
34+
async def example_without_external_session():
35+
"""Example without external session (backward compatible)."""
36+
# The library will create and manage its own sessions
37+
charger = OpenEVSE("openevse.local")
38+
39+
# Use the charger normally
40+
await charger.update()
41+
print(f"Status: {charger.status}")
42+
print(f"Current: {charger.charging_current}A")
43+
44+
await charger.ws_disconnect()
45+
46+
47+
async def example_shared_session():
48+
"""Example sharing a session between multiple clients."""
49+
async with aiohttp.ClientSession() as session:
50+
# Use the same session for multiple chargers
51+
charger1 = OpenEVSE("charger1.local", session=session)
52+
charger2 = OpenEVSE("charger2.local", session=session)
53+
54+
# Both chargers use the same session
55+
await charger1.update()
56+
await charger2.update()
57+
58+
print(f"Charger 1 Status: {charger1.status}")
59+
print(f"Charger 2 Status: {charger2.status}")
60+
61+
await charger1.ws_disconnect()
62+
await charger2.ws_disconnect()
63+
64+
65+
if __name__ == "__main__":
66+
# Run one of the examples
67+
asyncio.run(example_with_external_session())

openevsehttp/__main__.py

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from __future__ import annotations
44

55
import asyncio
6-
from datetime import datetime, timedelta, timezone
76
import json
87
import logging
98
import re
9+
from datetime import datetime, timedelta, timezone
1010
from typing import Any, Callable, Dict, Union
1111

1212
import aiohttp # type: ignore
@@ -84,7 +84,13 @@
8484
class OpenEVSE:
8585
"""Represent an OpenEVSE charger."""
8686

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:
8894
"""Connect to an OpenEVSE charger equipped with wifi or ethernet."""
8995
self._user = user
9096
self._pwd = pwd
@@ -97,6 +103,8 @@ def __init__(self, host: str, user: str = "", pwd: str = "") -> None:
97103
self.callback: Callable | None = None
98104
self._loop = None
99105
self.tasks = None
106+
self._session = session
107+
self._session_external = session is not None
100108

101109
async def process_request(
102110
self,
@@ -113,7 +121,9 @@ async def process_request(
113121
if self._user and self._pwd:
114122
auth = aiohttp.BasicAuth(self._user, self._pwd)
115123

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
117127
http_method = getattr(session, method)
118128
_LOGGER.debug(
119129
"Connecting to %s with data: %s rapi: %s using method %s",
@@ -165,9 +175,59 @@ async def process_request(
165175
except ContentTypeError as err:
166176
_LOGGER.error("Content error: %s", err.message)
167177
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
171231

172232
async def send_command(self, command: str) -> tuple:
173233
"""Send a RAPI command to the charger and parses the response."""
@@ -204,7 +264,7 @@ async def update(self) -> None:
204264
if not self.websocket:
205265
# Start Websocket listening
206266
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
208268
)
209269

210270
async def test_and_get(self) -> dict:
@@ -573,7 +633,8 @@ async def firmware_check(self) -> dict | None:
573633
return None
574634

575635
try:
576-
async with aiohttp.ClientSession() as session:
636+
if self._session:
637+
session = self._session
577638
http_method = getattr(session, method)
578639
_LOGGER.debug(
579640
"Connecting to %s using method %s",
@@ -590,6 +651,24 @@ async def firmware_check(self) -> dict | None:
590651
response["release_notes"] = message["body"]
591652
response["release_url"] = message["html_url"]
592653
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
593672

594673
except (TimeoutError, ServerTimeoutError):
595674
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)

openevsehttp/websocket.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ def __init__(
3131
callback,
3232
user=None,
3333
password=None,
34+
session: aiohttp.ClientSession | None = None,
3435
):
3536
"""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
3739
self.uri = self._get_uri(server)
3840
self._user = user
3941
self._password = password
@@ -159,7 +161,9 @@ async def listen(self):
159161
async def close(self):
160162
"""Close the listening websocket."""
161163
await self._set_state(STATE_STOPPED)
162-
await self.session.close()
164+
# Only close the session if we created it
165+
if not self._session_external:
166+
await self.session.close()
163167

164168
async def keepalive(self):
165169
"""Send ping requests to websocket."""

pylintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ disable=
2727
too-many-branches,
2828
too-many-statements,
2929
too-many-lines,
30-
too-many-positional-arguments
30+
too-many-positional-arguments,
31+
too-many-return-statements
3132

3233
[REPORTS]
3334
score=no

0 commit comments

Comments
 (0)