Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions EXTERNAL_SESSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# External Session Management

## Overview

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.

## Benefits

- **Session Reuse**: Share a single session across multiple OpenEVSE instances or other aiohttp-based clients
- **Custom Configuration**: Configure session settings like timeouts, connectors, and SSL verification
- **Resource Management**: Better control over connection pooling and resource cleanup
- **Integration**: Easier integration with existing applications that already manage aiohttp sessions

## Usage

### With External Session

```python
import aiohttp
from openevsehttp import OpenEVSE

async def main():
# Create your own session with custom settings
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
# Pass the session to OpenEVSE
charger = OpenEVSE("openevse.local", session=session)

# Use the charger normally
await charger.update()
print(f"Status: {charger.status}")

# Clean up
await charger.ws_disconnect()
# Session will be closed by the context manager
```

### Without External Session (Backward Compatible)

```python
from openevsehttp import OpenEVSE

async def main():
# The library creates and manages its own sessions
charger = OpenEVSE("openevse.local")

# Use the charger normally
await charger.update()
print(f"Status: {charger.status}")

await charger.ws_disconnect()
```

### Sharing a Session

```python
import aiohttp
from openevsehttp import OpenEVSE

async def main():
async with aiohttp.ClientSession() as session:
# Use the same session for multiple chargers
charger1 = OpenEVSE("charger1.local", session=session)
charger2 = OpenEVSE("charger2.local", session=session)

# Both chargers use the same session
await charger1.update()
await charger2.update()

await charger1.ws_disconnect()
await charger2.ws_disconnect()
```

## API Changes

### `OpenEVSE.__init__()`

```python
def __init__(
self,
host: str,
user: str = "",
pwd: str = "",
session: aiohttp.ClientSession | None = None,
) -> None:
```

**Parameters:**
- `host` (str): The hostname or IP address of the OpenEVSE charger
- `user` (str, optional): Username for authentication
- `pwd` (str, optional): Password for authentication
- `session` (aiohttp.ClientSession | None, optional): External session to use for HTTP requests. If not provided, the library will create temporary sessions as needed.

### `OpenEVSEWebsocket.__init__()`

```python
def __init__(
self,
server,
callback,
user=None,
password=None,
session: aiohttp.ClientSession | None = None,
):
```

**Parameters:**
- `server`: The server URL
- `callback`: Callback function for websocket events
- `user` (optional): Username for authentication
- `password` (optional): Password for authentication
- `session` (aiohttp.ClientSession | None, optional): External session to use for websocket connections. If not provided, a new session will be created.

## Important Notes

1. **Session Lifecycle**: When you provide an external session, you are responsible for closing it. The library will NOT close externally provided sessions.

2. **Backward Compatibility**: This change is fully backward compatible. Existing code that doesn't provide a session will continue to work exactly as before.

3. **Websocket Sessions**: The websocket connection will also use the provided session, ensuring consistent session management across all HTTP and WebSocket operations.

4. **Thread Safety**: If you're using the same session across multiple OpenEVSE instances, ensure you're following aiohttp's thread safety guidelines.

## Migration Guide

If you want to migrate existing code to use external sessions:

**Before:**
```python
charger = OpenEVSE("openevse.local")
await charger.update()
```

**After:**
```python
async with aiohttp.ClientSession() as session:
charger = OpenEVSE("openevse.local", session=session)
await charger.update()
```

No other changes are required!
67 changes: 67 additions & 0 deletions example_external_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Example of using python-openevse-http with an external aiohttp.ClientSession.

This demonstrates how to pass your own session to the library, which is useful when:
- You want to manage the session lifecycle yourself
- You need to share a session across multiple API clients
- You want to configure custom session settings (timeouts, connectors, etc.)
"""

import asyncio

import aiohttp

from openevsehttp.__main__ import OpenEVSE


async def example_with_external_session():
"""Example using an external session."""
# Create your own session with custom settings
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
# Pass the session to OpenEVSE
charger = OpenEVSE("openevse.local", session=session)

# Use the charger normally
await charger.update()
print(f"Status: {charger.status}")
print(f"Current: {charger.charging_current}A")

# The session will be closed when the context manager exits
# but OpenEVSE won't close it (since it's externally managed)
await charger.ws_disconnect()


async def example_without_external_session():
"""Example without external session (backward compatible)."""
# The library will create and manage its own sessions
charger = OpenEVSE("openevse.local")

# Use the charger normally
await charger.update()
print(f"Status: {charger.status}")
print(f"Current: {charger.charging_current}A")

await charger.ws_disconnect()


async def example_shared_session():
"""Example sharing a session between multiple clients."""
async with aiohttp.ClientSession() as session:
# Use the same session for multiple chargers
charger1 = OpenEVSE("charger1.local", session=session)
charger2 = OpenEVSE("charger2.local", session=session)

# Both chargers use the same session
await charger1.update()
await charger2.update()

print(f"Charger 1 Status: {charger1.status}")
print(f"Charger 2 Status: {charger2.status}")

await charger1.ws_disconnect()
await charger2.ws_disconnect()


if __name__ == "__main__":
# Run one of the examples
asyncio.run(example_with_external_session())
95 changes: 87 additions & 8 deletions openevsehttp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
from __future__ import annotations

import asyncio
from datetime import datetime, timedelta, timezone
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, Union

import aiohttp # type: ignore
Expand Down Expand Up @@ -84,7 +84,13 @@
class OpenEVSE:
"""Represent an OpenEVSE charger."""

def __init__(self, host: str, user: str = "", pwd: str = "") -> None:
def __init__(
self,
host: str,
user: str = "",
pwd: str = "",
session: aiohttp.ClientSession | None = None,
) -> None:
"""Connect to an OpenEVSE charger equipped with wifi or ethernet."""
self._user = user
self._pwd = pwd
Expand All @@ -97,6 +103,8 @@ def __init__(self, host: str, user: str = "", pwd: str = "") -> None:
self.callback: Callable | None = None
self._loop = None
self.tasks = None
self._session = session
self._session_external = session is not None

async def process_request(
self,
Expand All @@ -113,7 +121,9 @@ async def process_request(
if self._user and self._pwd:
auth = aiohttp.BasicAuth(self._user, self._pwd)

async with aiohttp.ClientSession() as session:
# Use provided session or create a temporary one
if self._session is not None:
session = self._session
http_method = getattr(session, method)
_LOGGER.debug(
"Connecting to %s with data: %s rapi: %s using method %s",
Expand Down Expand Up @@ -165,9 +175,59 @@ async def process_request(
except ContentTypeError as err:
_LOGGER.error("Content error: %s", err.message)
raise err

await session.close()
return message
else:
async with aiohttp.ClientSession() as session:
http_method = getattr(session, method)
_LOGGER.debug(
"Connecting to %s with data: %s rapi: %s using method %s",
url,
data,
rapi,
method,
)
try:
async with http_method(
url,
data=rapi,
json=data,
auth=auth,
) as resp:
try:
message = await resp.text()
except UnicodeDecodeError:
_LOGGER.debug("Decoding error")
message = await resp.read()
message = message.decode(errors="replace")

try:
message = json.loads(message)
except ValueError:
_LOGGER.warning("Non JSON response: %s", message)

if resp.status == 400:
index = ""
if "msg" in message.keys():
index = "msg"
elif "error" in message.keys():
index = "error"
_LOGGER.error("Error 400: %s", message[index])
raise ParseJSONError
if resp.status == 401:
_LOGGER.error("Authentication error: %s", message)
raise AuthenticationError
if resp.status in [404, 405, 500]:
_LOGGER.warning("%s", message)

if method == "post" and "config_version" in message:
await self.update()
return message

except (TimeoutError, ServerTimeoutError) as err:
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
raise err
except ContentTypeError as err:
_LOGGER.error("Content error: %s", err.message)
raise err

async def send_command(self, command: str) -> tuple:
"""Send a RAPI command to the charger and parses the response."""
Expand Down Expand Up @@ -204,7 +264,7 @@ async def update(self) -> None:
if not self.websocket:
# Start Websocket listening
self.websocket = OpenEVSEWebsocket(
self.url, self._update_status, self._user, self._pwd
self.url, self._update_status, self._user, self._pwd, self._session
)

async def test_and_get(self) -> dict:
Expand Down Expand Up @@ -573,7 +633,8 @@ async def firmware_check(self) -> dict | None:
return None

try:
async with aiohttp.ClientSession() as session:
if self._session:
session = self._session
http_method = getattr(session, method)
_LOGGER.debug(
"Connecting to %s using method %s",
Expand All @@ -590,6 +651,24 @@ async def firmware_check(self) -> dict | None:
response["release_notes"] = message["body"]
response["release_url"] = message["html_url"]
return response
else:
async with aiohttp.ClientSession() as session:
http_method = getattr(session, method)
_LOGGER.debug(
"Connecting to %s using method %s",
url,
method,
)
async with http_method(url) as resp:
if resp.status != 200:
return None
message = await resp.text()
message = json.loads(message)
response = {}
response["latest_version"] = message["tag_name"]
response["release_notes"] = message["body"]
response["release_url"] = message["html_url"]
return response

except (TimeoutError, ServerTimeoutError):
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
Expand Down
8 changes: 6 additions & 2 deletions openevsehttp/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ def __init__(
callback,
user=None,
password=None,
session: aiohttp.ClientSession | None = None,
):
"""Initialize a OpenEVSEWebsocket instance."""
self.session = aiohttp.ClientSession()
self.session = session if session is not None else aiohttp.ClientSession()
self._session_external = session is not None
self.uri = self._get_uri(server)
self._user = user
self._password = password
Expand Down Expand Up @@ -159,7 +161,9 @@ async def listen(self):
async def close(self):
"""Close the listening websocket."""
await self._set_state(STATE_STOPPED)
await self.session.close()
# Only close the session if we created it
if not self._session_external:
await self.session.close()

async def keepalive(self):
"""Send ping requests to websocket."""
Expand Down
3 changes: 2 additions & 1 deletion pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ disable=
too-many-branches,
too-many-statements,
too-many-lines,
too-many-positional-arguments
too-many-positional-arguments,
too-many-return-statements

[REPORTS]
score=no
Expand Down
Loading