Skip to content

feat: Allow passing external aiohttp.ClientSession to OpenEVSE#508

Open
firstof9 wants to merge 12 commits intomainfrom
external-session
Open

feat: Allow passing external aiohttp.ClientSession to OpenEVSE#508
firstof9 wants to merge 12 commits intomainfrom
external-session

Conversation

@firstof9
Copy link
Owner

@firstof9 firstof9 commented Feb 13, 2026

potentially replaces #505

Summary by CodeRabbit

  • New Features

    • Support passing an external aiohttp.ClientSession to OpenEVSE and OpenEVSEWebsocket constructors for shared/custom session management; backward compatible when omitted.
  • Documentation

    • Added guide and examples demonstrating external session usage, sharing patterns, lifecycle, and migration.
  • Tests

    • New tests covering external-session behavior, websocket interactions, firmware/version checks, and many expanded error-case scenarios.
  • Style

    • Lint configuration minor updates.

…enEVSEWebsocket for improved session management and lifecycle control.
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

This PR adds optional external aiohttp.ClientSession support to OpenEVSE and OpenEVSEWebsocket, allowing callers to supply and reuse sessions. Library behavior remains unchanged when no session is provided; externally supplied sessions are not closed by the library.

Changes

Cohort / File(s) Summary
Documentation & Examples
EXTERNAL_SESSION.md, example_external_session.py
New documentation and an example demonstrating providing an external aiohttp.ClientSession, using library-managed sessions, and sharing a single session across multiple OpenEVSE instances; clarifies lifecycle and migration.
Core Library
openevsehttp/__main__.py, openevsehttp/websocket.py
Constructors for OpenEVSE and OpenEVSEWebsocket accept optional session parameter; added _session_external ownership flag; HTTP/websocket flows now reuse provided sessions and avoid closing external sessions.
Tests
tests/test_external_session.py, tests/test_main.py, tests/test_websocket.py
New and expanded tests covering external session usage, request/update flows, websocket initialization/cleanup, firmware checks, extensive error paths, and various edge cases; minor generator adjustment in websocket test.
Configuration
pylintrc
Added too-many-return-statements to disabled messages and adjusted trailing comma for too-many-positional-arguments.

Sequence Diagram

sequenceDiagram
    actor User
    participant OpenEVSE
    participant aiohttp.ClientSession
    participant WebSocket
    participant TargetServer

    rect rgba(100, 150, 200, 0.5)
        Note over User,TargetServer: With external session
        User->>aiohttp.ClientSession: create session
        User->>OpenEVSE: __init__(host, session=my_session)
        OpenEVSE->>OpenEVSE: store session, set _session_external = True
        User->>OpenEVSE: update()
        OpenEVSE->>aiohttp.ClientSession: send HTTP request
        aiohttp.ClientSession->>TargetServer: HTTP GET/POST
        TargetServer-->>aiohttp.ClientSession: response
        aiohttp.ClientSession-->>OpenEVSE: data
        OpenEVSE-->>User: status updated
        User->>OpenEVSE: ws_disconnect()
        OpenEVSE->>WebSocket: close (do not close external session)
        User->>aiohttp.ClientSession: close (user responsibility)
    end

    rect rgba(150, 100, 200, 0.5)
        Note over User,TargetServer: Without external session
        User->>OpenEVSE: __init__(host)
        OpenEVSE->>OpenEVSE: session=None, _session_external=False
        User->>OpenEVSE: update()
        OpenEVSE->>aiohttp.ClientSession: create temporary session
        aiohttp.ClientSession->>TargetServer: HTTP GET/POST
        TargetServer-->>aiohttp.ClientSession: response
        aiohttp.ClientSession-->>OpenEVSE: data
        OpenEVSE->>aiohttp.ClientSession: close temporary session
        OpenEVSE-->>User: status updated
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hop with joy, a session in paw,
Shared connections hum without a flaw,
One session, many chargers in tune,
I nibble bugs beneath the moon,
Close not my cup — that’s your job, friend!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature addition: allowing external aiohttp.ClientSession to be passed to the OpenEVSE constructor, which is the primary change across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 94.59% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch external-session

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Pylint (4.0.4)
openevsehttp/websocket.py
openevsehttp/__main__.py
tests/test_main.py
  • 1 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@firstof9 firstof9 added the code quality Improves code quality label Feb 13, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@example_external_session.py`:
- Line 13: The example is importing OpenEVSE from the non-public module
openevsehttp.__main__; update the import to use the public package root by
replacing the import line to import OpenEVSE from openevsehttp (e.g. change
"from openevsehttp.__main__ import OpenEVSE" to "from openevsehttp import
OpenEVSE") so the example uses the documented public entrypoint.

In `@EXTERNAL_SESSION.md`:
- Around line 74-105: The API headings use underscores which the markdown linter
treats as strong-style; update the two headings by wrapping the function names
in inline code spans instead of bolding—replace the bolded "__init__"
occurrences with `OpenEVSE.__init__` and `OpenEVSEWebsocket.__init__` (or just
`__init__` inside the respective section) so the headings use backticks for the
function names and the MD050 lint error is resolved.

In `@tests/test_external_session.py`:
- Around line 130-132: The test function name
test_firmware_check_with_external_session is misleading because the test does
not pass a session; rename the test function to reflect that it does not use an
external session (e.g., test_firmware_check_without_session or
test_firmware_check_uses_internal_session) and update any references/imports or
test markers accordingly so the name matches the test behavior; locate the
function definition test_firmware_check_with_external_session in
tests/test_external_session.py and perform the rename there and in any calls or
fixtures that reference it.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openevsehttp/__main__.py (1)

124-233: 🛠️ Refactor suggestion | 🟠 Major

Significant code duplication between external and internal session paths.

The if self._session: and else: branches contain nearly identical logic (~100 lines duplicated). This creates a maintenance burden where any future changes must be applied in two places.

Consider extracting the common request logic into a helper method:

♻️ Proposed refactor to reduce duplication
+    async def _execute_request(
+        self,
+        session: aiohttp.ClientSession,
+        url: str,
+        method: str,
+        data: Any,
+        rapi: Any,
+        auth: aiohttp.BasicAuth | None,
+    ) -> dict[str, str] | dict[str, Any]:
+        """Execute HTTP request with the given 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
+        except ContentTypeError as err:
+            _LOGGER.error("Content error: %s", err.message)
+            raise

     async def process_request(
         self, url: str, method: str = "", data: Any = None, rapi: Any = None,
     ) -> dict[str, str] | dict[str, Any]:
         """Return result of processed HTTP request."""
         auth = None
         if method is None:
             raise MissingMethod

         if self._user and self._pwd:
             auth = aiohttp.BasicAuth(self._user, self._pwd)

-        # Use provided session or create a temporary one
-        if self._session:
-            session = self._session
-            # ... 50+ lines of duplicated code ...
-        else:
-            async with aiohttp.ClientSession() as session:
-                # ... 50+ lines of duplicated code ...
+        if self._session:
+            return await self._execute_request(
+                self._session, url, method, data, rapi, auth
+            )
+        else:
+            async with aiohttp.ClientSession() as session:
+                return await self._execute_request(
+                    session, url, method, data, rapi, auth
+                )
🤖 Fix all issues with AI agents
In `@openevsehttp/__main__.py`:
- Around line 232-233: The final await session.close() and return message are
unreachable because the try block always returns (and exceptions are re-raised);
fix by ensuring the session is closed and the message returned via a finally or
context manager: move session cleanup into a finally block (or use "async with"
for the session) and perform the return from after the try/finally so that
"session.close()" (reference the session variable) always runs and "message" is
returned reliably instead of being skipped by the early return in the try block.

In `@tests/test_external_session.py`:
- Around line 20-48: The test function test_external_session_provided has an
unused fixture parameter mock_aioclient; remove mock_aioclient from the function
signature so the test only accepts parameters it uses (i.e., change async def
test_external_session_provided(mock_aioclient): to async def
test_external_session_provided():) and run the tests to confirm no other
fixtures depend on it; ensure references to mock_aioclient are not used
elsewhere in that test.

In `@tests/test_main.py`:
- Around line 2576-2582: The test function test_firmware_check_no_config
currently declares an unused fixture parameter mock_aioclient; remove the unused
parameter from the test signature so it becomes async def
test_firmware_check_no_config(): to avoid lingering unused fixtures, keeping the
test focused on calling OpenEVSE(SERVER_URL).firmware_check() without HTTP
interactions.
🧹 Nitpick comments (1)
openevsehttp/__main__.py (1)

639-674: Similar code duplication in firmware_check.

The same duplication pattern exists here between the external session path (lines 639-656) and internal session path (lines 657-674). Consider applying the same refactoring pattern.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@tests/test_main.py`:
- Around line 3223-3226: The side_effect coroutine defines unused parameters
`data` and `error` which triggers the linter; rename them to `_data` and
`_error` (or prefix with underscores) in the async def signature for side_effect
to silence Ruff ARG001 while leaving the body unchanged where it sets ws._state
= "stopped" and references ws._state.
🧹 Nitpick comments (2)
openevsehttp/__main__.py (1)

124-230: Consider factoring duplicated request handling.
Line 124 onward repeats nearly identical request/response logic for external vs internal sessions; extracting a small helper would reduce drift and keep error handling consistent (and can be reused by the firmware_check flow).

tests/test_main.py (1)

2890-2894: Drop duplicate pytestmark/constants.
Line 2890 redefines values already declared at the top of the file; this is redundant and risks drift.

♻️ Proposed cleanup
-pytestmark = pytest.mark.asyncio
-SERVER_URL = "openevse.test.tld"
-TEST_URL_STATUS = "http://openevse.test.tld/status"

@firstof9 firstof9 marked this pull request as ready for review February 13, 2026 23:51
@firstof9
Copy link
Owner Author

@c00w what do you think about this one to replace #505 ?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openevsehttp/__main__.py (2)

124-230: 🛠️ Refactor suggestion | 🟠 Major

Significant code duplication between external and internal session branches.

The entire request handling logic (~100 lines) is copy-pasted between the if self._session and else branches. The only difference is whether the session is self._session or created via async with aiohttp.ClientSession(). This violates DRY and makes maintenance error-prone—any bug fix or behavior change must be applied in both places.

Consider extracting the common logic into a helper, or restructuring to conditionally create/use the session before a single request-handling block:

♻️ Suggested approach
     async def process_request(self, url, method="", data=None, rapi=None):
         """Return result of processed HTTP request."""
         auth = None
         if method is None:
             raise MissingMethod
         if self._user and self._pwd:
             auth = aiohttp.BasicAuth(self._user, self._pwd)

-        if self._session:
-            session = self._session
-            # ... ~50 lines of request logic ...
-        else:
-            async with aiohttp.ClientSession() as session:
-                # ... same ~50 lines duplicated ...
+        if self._session is not None:
+            return await self._do_request(self._session, url, method, data, rapi, auth)
+        else:
+            async with aiohttp.ClientSession() as session:
+                return await self._do_request(session, url, method, data, rapi, auth)

Then move the shared logic into _do_request(self, session, url, method, data, rapi, auth).


636-671: 🛠️ Refactor suggestion | 🟠 Major

Same duplication pattern in firmware_check.

The GitHub API call logic is duplicated between the external and internal session branches. Apply the same extract-helper approach suggested for process_request.

🤖 Fix all issues with AI agents
In `@openevsehttp/__main__.py`:
- Around line 106-107: The code sets self._session_external = session is not
None but later checks session truthiness with "if self._session:", causing
inconsistent semantics; find the conditional that currently reads "if
self._session:" and change it to "if self._session is not None" so the presence
check matches how self._session_external is initialized (refer to the attributes
self._session and self._session_external in __main__.py and update the
conditional that inspects self._session accordingly).

In `@openevsehttp/websocket.py`:
- Around line 37-38: The None-checks are inconsistent: change the session
assignment in the constructor to use an identity check like the external flag so
a falsy-but-non-None session is preserved; update the line that sets
self.session to use "session if session is not None else
aiohttp.ClientSession()" (matching the existing _session_external = session is
not None) so the behavior of self.session and _session_external stays consistent
(look for the constructor/initializer where self.session and
self._session_external are set).

In `@tests/test_main.py`:
- Around line 2890-2893: Remove the duplicate module-level redefinitions of
pytestmark, SERVER_URL, and TEST_URL_STATUS: locate the redundant block that
reassigns pytestmark = pytest.mark.asyncio and SERVER_URL/TEST_URL_STATUS to the
same values, and delete that duplicate block so only the original definitions
(the ones earlier in the module) remain; ensure no other references rely on the
second definitions and run tests to confirm.
🧹 Nitpick comments (2)
tests/test_main.py (2)

3170-3170: Unused error parameter triggers Ruff ARG001.

Prefix with underscore for consistency with the fix at line 3223.

Proposed fix
-            async def side_effect(msgtype, data, error):
+            async def side_effect(msgtype, data, _error):

2672-2888: Consider consolidating external session error-handling tests.

Tests test_external_session_* (lines 2672–2888) duplicate the same error scenarios already covered by test_process_request_* (lines 2349–2526) but with an external session. While this provides coverage for both code paths, it reinforces the point that extracting the shared request logic in process_request would eliminate the need for duplicate test suites.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openevsehttp/__main__.py (2)

124-230: 🛠️ Refactor suggestion | 🟠 Major

Significant code duplication in process_request — extract session acquisition instead.

The entire request handling logic (lines 135–177 vs 188–230) is duplicated verbatim across the two branches. The only difference is how the session is obtained. This violates DRY and doubles the maintenance surface for any future bug fix or feature change.

♻️ Proposed refactor to eliminate duplication
     async def process_request(
         self,
         url: str,
         method: str = "",
         data: Any = None,
         rapi: Any = None,
     ) -> dict[str, str] | dict[str, Any]:
         """Return result of processed HTTP request."""
         auth = None
         if method is None:
             raise MissingMethod
 
         if self._user and self._pwd:
             auth = aiohttp.BasicAuth(self._user, self._pwd)
 
-        # Use provided session or create a temporary one
-        if self._session is not None:
-            session = self._session
-            http_method = getattr(session, method)
-            ... # ~50 duplicated lines
-        else:
-            async with aiohttp.ClientSession() as session:
-                http_method = getattr(session, method)
-                ... # ~50 duplicated lines
+        # Use provided session or create a temporary one
+        owned_session = None
+        session = self._session
+        if session is None:
+            owned_session = aiohttp.ClientSession()
+            session = owned_session
+
+        try:
+            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
+        finally:
+            if owned_session is not None:
+                await owned_session.close()

636-671: 🛠️ Refactor suggestion | 🟠 Major

Same duplication problem in firmware_check.

The two branches (lines 636–653 vs 654–671) are identical aside from session acquisition. The same refactoring pattern (acquire-or-create + try/finally) would eliminate this duplication.

🧹 Nitpick comments (1)
tests/test_main.py (1)

2671-2886: External session test variants mirror production code duplication.

These tests (e.g., test_external_session_400_error_with_msg vs test_process_request_400_error_with_msg) duplicate assertions for every error path across both session modes. If the production process_request is refactored to eliminate branching (as suggested), these paired tests can be consolidated with a @pytest.mark.parametrize over session=None vs an external session, cutting ~200 lines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code quality Improves code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant