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
112 changes: 30 additions & 82 deletions EXTERNAL_SESSION.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,31 @@
# External Session Management
# HTTP 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.
The `python-openevse-http` library requires you to pass an external `aiohttp.ClientSession` to `OpenEVSE`. Session ownership stays with the caller, so the library no longer constructs temporary HTTP clients internally.

## 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
- **Session Reuse**: Share a single session across multiple OpenEVSE instances or other `aiohttp` clients
- **Custom Configuration**: Configure timeouts, connectors, proxies, and SSL behavior yourself
- **Resource Management**: Keep connection pooling and cleanup in one place
- **Predictable Lifecycle**: Avoid hidden session creation inside request and websocket code paths

## Usage

### With External Session
### Basic Usage

```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
Expand All @@ -59,83 +36,54 @@ 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
### Websocket Startup

### `OpenEVSE.__init__()`
Start websocket listening from the same event loop that owns the
`aiohttp.ClientSession`:

```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__()`
import aiohttp
from openevsehttp import OpenEVSE

```python
def __init__(
self,
server,
callback,
user=None,
password=None,
session: aiohttp.ClientSession | None = None,
):
async def main():
async with aiohttp.ClientSession() as session:
charger = OpenEVSE("openevse.local", session=session)
await charger.ws_start()
await charger.ws_disconnect()
```

**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.
`ws_start()` is async so websocket tasks are created on the event loop that owns
the configured `aiohttp.ClientSession`. This prevents using a session from a
private background loop it was not created on.

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

3. **Websocket Sessions**: The websocket connection will also use the provided session, ensuring consistent session management across all HTTP and WebSocket operations.
- `OpenEVSE(..., session=session)` uses the provided session for HTTP requests.
- `OpenEVSEWebsocket(..., session=session)` uses the provided session for websocket connections.
- If no session is configured, HTTP requests and websocket startup raise `RuntimeError`.
- Call `await charger.ws_start()` from the event loop that owns the session.
- Externally provided sessions are never closed by the library.

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

## Migration Guide
Before:

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

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

**After:**
After:

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

No other changes are required!
28 changes: 12 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,24 @@ pip install python_openevse_http

```python
import asyncio
import aiohttp
from openevsehttp import OpenEVSE

async def main():
# Initialize the charger
charger = OpenEVSE("192.168.1.30")
async with aiohttp.ClientSession() as session:
charger = OpenEVSE("192.168.1.30", session=session)
await charger.update()

# Update state
await charger.update()
print(f"Charger State: {charger.status}")
print(f"Current Charge: {charger.charge_current}A")

print(f"Charger State: {charger.status}")
print(f"Current Charge: {charger.charge_current}A")
if charger.shaper_active:
print("Shaper is active, disabling...")
else:
print("Shaper is inactive, enabling...")

# Toggle the Shaper feature
if charger.shaper_active:
print("Shaper is active, disabling...")
else:
print("Shaper is inactive, enabling...")

await charger.toggle_shaper()

# Clean up
await charger.close()
await charger.toggle_shaper()
await charger.ws_disconnect()

if __name__ == "__main__":
asyncio.run(main())
Expand Down
13 changes: 0 additions & 13 deletions example_external_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,6 @@ async def example_with_external_session():
await charger.ws_disconnect()


async def example_without_external_session():
"""Demonstrate 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():
"""Demonstrate sharing a session between multiple clients."""
async with aiohttp.ClientSession() as session:
Expand Down
72 changes: 41 additions & 31 deletions openevsehttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@

_LOGGER = logging.getLogger(__name__)

ERROR_SESSION_REQUIRED = (
"An aiohttp.ClientSession must be provided via the session argument."
)
ERROR_SESSION_LOOP_MISMATCH = (
"The aiohttp.ClientSession is bound to a different event loop."
)


class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
"""Represent an OpenEVSE charger."""
Expand Down Expand Up @@ -70,6 +77,12 @@ def __init__(
self._session = session
self._session_external = session is not None

def _get_session(self) -> aiohttp.ClientSession:
"""Return the configured HTTP session or fail fast."""
if self._session is None:
raise RuntimeError(ERROR_SESSION_REQUIRED)
return self._session

async def process_request(
self,
url: str,
Expand All @@ -86,16 +99,10 @@ async def process_request(
if self._user and self._pwd:
auth = aiohttp.BasicAuth(self._user, self._pwd)

# Use provided session or create a temporary one
if (session := self._session) is None:
async with aiohttp.ClientSession() as session:
return await self._process_request_with_session(
session, url, method, data, rapi, auth
)
else:
return await self._process_request_with_session(
session, url, method, data, rapi, auth
)
session = self._get_session()
return await self._process_request_with_session(
session, url, method, data, rapi, auth
)

def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
"""Normalize response to a dict or list."""
Expand Down Expand Up @@ -259,36 +266,39 @@ async def test_and_get(self) -> dict[str, Any]:
data = {"serial": serial, "model": model}
return data

def ws_start(self) -> None:
async def ws_start(self) -> None:
"""Start the websocket listener."""
if self.websocket and self.websocket.state != STATE_STOPPED:
raise AlreadyListening

# Detect loop mismatch
use_session = self._session
try:
asyncio.get_running_loop()
except RuntimeError:
# We are about to create a private loop in _start_listening
# If we have a session, it's likely bound to another loop
if self._session:
_LOGGER.warning(
"Caller-provided session may not work on private event loop. "
"Creating a loop-local session."
)
use_session = None
# Clear self._session so subsequent await self.update() uses
# a loop-local session as well.
self._session = None
self._session_external = False
self._get_session()
loop = asyncio.get_running_loop()
self._validate_session_loop(loop)

if not self.websocket or self.websocket.state == STATE_STOPPED:
self.websocket = OpenEVSEWebsocket(
self.url, self._update_status, self._user, self._pwd, use_session
)
self._create_websocket()

self._start_listening()

def _create_websocket(self) -> None:
"""Create a websocket using the configured session."""
self.websocket = OpenEVSEWebsocket(
self.url,
self._update_status,
self._user,
self._pwd,
self._session,
)

def _validate_session_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Ensure the configured session belongs to the active event loop."""
session_loop = getattr(self._session, "_loop", None)
if (
isinstance(session_loop, asyncio.AbstractEventLoop)
and session_loop is not loop
):
raise RuntimeError(ERROR_SESSION_LOOP_MISMATCH)

def _start_listening(self) -> None:
"""Start the websocket listener."""
if not self._loop:
Expand Down
14 changes: 7 additions & 7 deletions openevsehttp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class CommandsMixin:
url: str
_status: dict[str, Any]
_config: dict[str, Any]
_session: Any
_session: aiohttp.ClientSession | None

# These are defined in client.py
def _version_check(self, min_version: str, max_version: str = "") -> bool:
Expand All @@ -46,6 +46,10 @@ def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
"""Normalize response to a dict or list."""
raise NotImplementedError

def _get_session(self) -> aiohttp.ClientSession:
"""Return the configured HTTP session."""
raise NotImplementedError

def _flag_ota_if_started(self, response: Any) -> None:
"""Flag OTA as active if response indicates firmware update has started."""
normalized = self._normalize_response(response)
Expand Down Expand Up @@ -409,12 +413,8 @@ async def firmware_check(self) -> dict[str, Any] | None:
return None

try:
if (session := self._session) is None:
async with aiohttp.ClientSession() as session:
return await self._firmware_check_with_session(session, url, method)
else:
return await self._firmware_check_with_session(session, url, method)

session = self._get_session()
return await self._firmware_check_with_session(session, url, method)
except (TimeoutError, ServerTimeoutError):
_LOGGER.error("%s: %s", "Timeout while updating", url)
except ContentTypeError as err:
Expand Down
8 changes: 5 additions & 3 deletions openevsehttp/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
_LOGGER = logging.getLogger(__name__)

MAX_FAILED_ATTEMPTS = 5
ERROR_SESSION_REQUIRED = (
"An aiohttp.ClientSession must be provided via the session argument."
)

ERROR_AUTH_FAILURE = "Authorization failure"
ERROR_TOO_MANY_RETRIES = "Too many retries"
Expand Down Expand Up @@ -224,10 +227,9 @@ async def listen(self) -> None:
self._listener_loop = None

async def _ensure_session(self) -> None:
"""Ensure aiohttp.ClientSession exists."""
"""Ensure an external aiohttp.ClientSession exists."""
if self.session is None:
self.session = aiohttp.ClientSession()
self._session_external = False
raise RuntimeError(ERROR_SESSION_REQUIRED)

async def close(self) -> None:
"""Close the listening websocket."""
Expand Down
Loading
Loading