python-openevse-http 0.4.3__tar.gz → 1.0.0__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 (72) hide show
  1. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/autolabeler.yml +2 -0
  2. python_openevse_http-1.0.0/EXTERNAL_SESSION.md +91 -0
  3. {python_openevse_http-0.4.3/python_openevse_http.egg-info → python_openevse_http-1.0.0}/PKG-INFO +15 -19
  4. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/README.md +14 -18
  5. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/example_external_session.py +0 -13
  6. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/client.py +103 -75
  7. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/commands.py +15 -15
  8. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/const.py +7 -0
  9. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/managers.py +1 -1
  10. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/properties.py +37 -30
  11. python_openevse_http-1.0.0/openevsehttp/py.typed +0 -0
  12. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/sensors.py +1 -1
  13. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/websocket.py +49 -39
  14. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/pyproject.toml +3 -0
  15. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0/python_openevse_http.egg-info}/PKG-INFO +15 -19
  16. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/SOURCES.txt +1 -0
  17. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/conftest.py +140 -143
  18. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_client.py +239 -145
  19. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_commands.py +4 -1
  20. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_external_session.py +12 -26
  21. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_main_edge_cases.py +6 -3
  22. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_mixins.py +2 -0
  23. python_openevse_http-1.0.0/tests/test_properties.py +492 -0
  24. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_shaper.py +2 -1
  25. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_websocket.py +73 -58
  26. python_openevse_http-0.4.3/EXTERNAL_SESSION.md +0 -141
  27. python_openevse_http-0.4.3/tests/test_properties.py +0 -1229
  28. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/dependabot.yml +0 -0
  29. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/release-drafter.yml +0 -0
  30. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/links.yml +0 -0
  31. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/publish-to-pypi.yml +0 -0
  32. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/release-drafter.yml +0 -0
  33. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/test.yml +0 -0
  34. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.gitignore +0 -0
  35. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.pre-commit-config.yaml +0 -0
  36. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.yamllint +0 -0
  37. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/LICENSE +0 -0
  38. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/codecov.yml +0 -0
  39. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/__init__.py +0 -0
  40. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/__main__.py +0 -0
  41. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/exceptions.py +0 -0
  42. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/utils.py +0 -0
  43. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  44. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/not-zip-safe +0 -0
  45. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/requires.txt +0 -0
  46. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/top_level.txt +0 -0
  47. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements.txt +0 -0
  48. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements_lint.txt +0 -0
  49. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements_test.txt +0 -0
  50. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/setup.cfg +0 -0
  51. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/setup.py +0 -0
  52. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/__init__.py +0 -0
  53. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/common.py +0 -0
  54. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/github_v2.json +0 -0
  55. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/github_v4.json +0 -0
  56. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v2_json/config.json +0 -0
  57. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v2_json/status.json +0 -0
  58. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
  59. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-broken.json +0 -0
  60. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-dev.json +0 -0
  61. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-extra-version.json +0 -0
  62. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-new.json +0 -0
  63. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
  64. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config.json +0 -0
  65. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/schedule.json +0 -0
  66. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status-broken.json +0 -0
  67. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status-new.json +0 -0
  68. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status.json +0 -0
  69. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/websocket.json +0 -0
  70. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_managers.py +0 -0
  71. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_sensors.py +0 -0
  72. {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tox.ini +0 -0
@@ -6,11 +6,13 @@ on:
6
6
  - opened
7
7
  - synchronize
8
8
  - reopened
9
+ - edited
9
10
  pull_request_target:
10
11
  types:
11
12
  - opened
12
13
  - synchronize
13
14
  - reopened
15
+ - edited
14
16
 
15
17
  permissions: {}
16
18
 
@@ -0,0 +1,91 @@
1
+ # HTTP Session Management
2
+
3
+ ## Overview
4
+
5
+ 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.
6
+
7
+ ## Benefits
8
+
9
+ - **Session Reuse**: Share a single session across multiple OpenEVSE instances or other `aiohttp` clients
10
+ - **Custom Configuration**: Configure timeouts, connectors, proxies, and SSL behavior yourself
11
+ - **Resource Management**: Keep connection pooling and cleanup in one place
12
+ - **Predictable Lifecycle**: Avoid hidden session creation inside request and websocket code paths
13
+
14
+ ## Usage
15
+
16
+ ### Basic Usage
17
+
18
+ ```python
19
+ import aiohttp
20
+ from openevsehttp import OpenEVSE
21
+
22
+ async def main():
23
+ timeout = aiohttp.ClientTimeout(total=30)
24
+ async with aiohttp.ClientSession(timeout=timeout) as session:
25
+ charger = OpenEVSE("openevse.local", session=session)
26
+ await charger.update()
27
+ print(f"Status: {charger.status}")
28
+ await charger.ws_disconnect()
29
+ ```
30
+
31
+ ### Sharing a Session
32
+
33
+ ```python
34
+ import aiohttp
35
+ from openevsehttp import OpenEVSE
36
+
37
+ async def main():
38
+ async with aiohttp.ClientSession() as session:
39
+ charger1 = OpenEVSE("charger1.local", session=session)
40
+ charger2 = OpenEVSE("charger2.local", session=session)
41
+
42
+ await charger1.update()
43
+ await charger2.update()
44
+ ```
45
+
46
+ ### Websocket Startup
47
+
48
+ Start websocket listening from the same event loop that owns the
49
+ `aiohttp.ClientSession`:
50
+
51
+ ```python
52
+ import aiohttp
53
+ from openevsehttp import OpenEVSE
54
+
55
+ async def main():
56
+ async with aiohttp.ClientSession() as session:
57
+ charger = OpenEVSE("openevse.local", session=session)
58
+ await charger.ws_start()
59
+ await charger.ws_disconnect()
60
+ ```
61
+
62
+ `ws_start()` is async so websocket tasks are created on the event loop that owns
63
+ the configured `aiohttp.ClientSession`. This prevents using a session from a
64
+ private background loop it was not created on.
65
+
66
+ ## API Notes
67
+
68
+ - `OpenEVSE(..., session=session)` uses the provided session for HTTP requests.
69
+ - `OpenEVSEWebsocket(..., session=session)` uses the provided session for websocket connections.
70
+ - If no session is configured, HTTP requests and websocket startup raise `RuntimeError`.
71
+ - Call `await charger.ws_start()` from the event loop that owns the session.
72
+ - Externally provided sessions are never closed by the library.
73
+
74
+ ## Migration
75
+
76
+ Before:
77
+
78
+ ```python
79
+ charger = OpenEVSE("openevse.local")
80
+ await charger.update()
81
+ ```
82
+
83
+ After:
84
+
85
+ ```python
86
+ import aiohttp
87
+
88
+ async with aiohttp.ClientSession() as session:
89
+ charger = OpenEVSE("openevse.local", session=session)
90
+ await charger.update()
91
+ ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.3
3
+ Version: 1.0.0
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
@@ -62,28 +62,24 @@ pip install python_openevse_http
62
62
 
63
63
  ```python
64
64
  import asyncio
65
+ import aiohttp
65
66
  from openevsehttp import OpenEVSE
66
67
 
67
68
  async def main():
68
- # Initialize the charger
69
- charger = OpenEVSE("192.168.1.30")
69
+ async with aiohttp.ClientSession() as session:
70
+ charger = OpenEVSE("192.168.1.30", session=session)
71
+ await charger.update()
70
72
 
71
- # Update state
72
- await charger.update()
73
+ print(f"Charger State: {charger.status}")
74
+ print(f"Current Charge: {charger.charge_current}A")
73
75
 
74
- print(f"Charger State: {charger.status}")
75
- print(f"Current Charge: {charger.charge_current}A")
76
+ if charger.shaper_active:
77
+ print("Shaper is active, disabling...")
78
+ else:
79
+ print("Shaper is inactive, enabling...")
76
80
 
77
- # Toggle the Shaper feature
78
- if charger.shaper_active:
79
- print("Shaper is active, disabling...")
80
- else:
81
- print("Shaper is inactive, enabling...")
82
-
83
- await charger.toggle_shaper()
84
-
85
- # Clean up
86
- await charger.close()
81
+ await charger.toggle_shaper()
82
+ await charger.ws_disconnect()
87
83
 
88
84
  if __name__ == "__main__":
89
85
  asyncio.run(main())
@@ -97,7 +93,7 @@ if __name__ == "__main__":
97
93
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
94
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
95
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
- | `/schedule` | GET, POST | | Charging schedule management |
96
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
101
97
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
98
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
99
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -111,7 +107,7 @@ if __name__ == "__main__":
111
107
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
108
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
109
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
- | `/update` | POST | | Firmware update interface |
110
+ | `/update` | POST | | Firmware update interface |
115
111
  | `/rfid/add` | POST | ❌ | RFID tag management |
116
112
 
117
113
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -29,28 +29,24 @@ pip install python_openevse_http
29
29
 
30
30
  ```python
31
31
  import asyncio
32
+ import aiohttp
32
33
  from openevsehttp import OpenEVSE
33
34
 
34
35
  async def main():
35
- # Initialize the charger
36
- charger = OpenEVSE("192.168.1.30")
36
+ async with aiohttp.ClientSession() as session:
37
+ charger = OpenEVSE("192.168.1.30", session=session)
38
+ await charger.update()
37
39
 
38
- # Update state
39
- await charger.update()
40
+ print(f"Charger State: {charger.status}")
41
+ print(f"Current Charge: {charger.charge_current}A")
40
42
 
41
- print(f"Charger State: {charger.status}")
42
- print(f"Current Charge: {charger.charge_current}A")
43
+ if charger.shaper_active:
44
+ print("Shaper is active, disabling...")
45
+ else:
46
+ print("Shaper is inactive, enabling...")
43
47
 
44
- # Toggle the Shaper feature
45
- if charger.shaper_active:
46
- print("Shaper is active, disabling...")
47
- else:
48
- print("Shaper is inactive, enabling...")
49
-
50
- await charger.toggle_shaper()
51
-
52
- # Clean up
53
- await charger.close()
48
+ await charger.toggle_shaper()
49
+ await charger.ws_disconnect()
54
50
 
55
51
  if __name__ == "__main__":
56
52
  asyncio.run(main())
@@ -64,7 +60,7 @@ if __name__ == "__main__":
64
60
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
65
61
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
66
62
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
67
- | `/schedule` | GET, POST | | Charging schedule management |
63
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
68
64
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
69
65
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
70
66
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -78,7 +74,7 @@ if __name__ == "__main__":
78
74
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
79
75
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
80
76
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
81
- | `/update` | POST | | Firmware update interface |
77
+ | `/update` | POST | | Firmware update interface |
82
78
  | `/rfid/add` | POST | ❌ | RFID tag management |
83
79
 
84
80
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -31,19 +31,6 @@ async def example_with_external_session():
31
31
  await charger.ws_disconnect()
32
32
 
33
33
 
34
- async def example_without_external_session():
35
- """Demonstrate 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
34
  async def example_shared_session():
48
35
  """Demonstrate sharing a session between multiple clients."""
49
36
  async with aiohttp.ClientSession() as session:
@@ -7,16 +7,18 @@ import inspect
7
7
  import json
8
8
  import logging
9
9
  import threading
10
- from collections.abc import Callable, Mapping
10
+ from collections.abc import Callable, Mapping, MutableMapping
11
11
  from typing import Any
12
12
 
13
- import aiohttp # type: ignore
13
+ import aiohttp
14
14
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
15
15
  from awesomeversion import AwesomeVersion
16
16
  from awesomeversion.exceptions import AwesomeVersionCompareException
17
17
 
18
18
  from .commands import CommandsMixin
19
19
  from .const import (
20
+ ERROR_SESSION_LOOP_MISMATCH,
21
+ ERROR_SESSION_REQUIRED,
20
22
  ERROR_TIMEOUT,
21
23
  UPDATE_TRIGGERS,
22
24
  )
@@ -56,19 +58,29 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
56
58
  self._user = user
57
59
  self._pwd = pwd
58
60
  self.url = f"http://{host}/"
59
- self._status: dict = {}
60
- self._config: dict = {}
61
- self._override = None
61
+ self._status: dict[str, Any] = {}
62
+ self._config: dict[str, Any] = {}
63
+ self._override: Any = None
62
64
  self._ws_listening = False
63
65
  self.websocket: OpenEVSEWebsocket | None = None
64
- self.callback: Callable | None = None
66
+ self.callback: Callable[[], Any] | None = None
65
67
  self._loop: asyncio.AbstractEventLoop | None = None
66
- self._ws_listen_task: asyncio.Task | None = None
67
- self._ws_keepalive_task: asyncio.Task | None = None
68
+ self._ws_listen_task: asyncio.Task[Any] | None = None
69
+ self._ws_keepalive_task: asyncio.Task[Any] | None = None
68
70
  self._owns_loop = False
69
71
  self._loop_thread: threading.Thread | None = None
70
72
  self._session = session
71
- self._session_external = session is not None
73
+
74
+ def _get_session(self) -> aiohttp.ClientSession:
75
+ """Return the configured HTTP session or fail fast."""
76
+ if self._session is None:
77
+ raise RuntimeError(ERROR_SESSION_REQUIRED)
78
+ try:
79
+ loop = asyncio.get_running_loop()
80
+ except RuntimeError:
81
+ return self._session
82
+ self._validate_session_loop(loop)
83
+ return self._session
72
84
 
73
85
  async def process_request(
74
86
  self,
@@ -76,7 +88,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
76
88
  method: str = "",
77
89
  data: Any = None,
78
90
  rapi: Any = None,
79
- ) -> Mapping[str, Any] | list[Any] | str:
91
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
80
92
  """Return result of processed HTTP request."""
81
93
  auth = None
82
94
  allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
@@ -86,16 +98,10 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
86
98
  if self._user and self._pwd:
87
99
  auth = aiohttp.BasicAuth(self._user, self._pwd)
88
100
 
89
- # Use provided session or create a temporary one
90
- if (session := self._session) is None:
91
- async with aiohttp.ClientSession() as session:
92
- return await self._process_request_with_session(
93
- session, url, method, data, rapi, auth
94
- )
95
- else:
96
- return await self._process_request_with_session(
97
- session, url, method, data, rapi, auth
98
- )
101
+ session = self._get_session()
102
+ return await self._process_request_with_session(
103
+ session, url, method, data, rapi, auth
104
+ )
99
105
 
100
106
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
101
107
  """Normalize response to a dict or list."""
@@ -112,7 +118,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
112
118
  data: Any,
113
119
  rapi: Any,
114
120
  auth: Any,
115
- ) -> Mapping[str, Any] | list[Any] | str:
121
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
116
122
  """Process a request with a given session."""
117
123
  if not hasattr(session, method):
118
124
  raise MissingMethod
@@ -130,38 +136,51 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
130
136
  kwargs["json"] = data
131
137
  async with http_method(url, **kwargs) as resp:
132
138
  try:
133
- message = await resp.text()
139
+ raw = await resp.text()
134
140
  except UnicodeDecodeError:
135
141
  _LOGGER.debug("Decoding error")
136
- message = await resp.read()
137
- message = message.decode(errors="replace")
142
+ raw = (await resp.read()).decode(errors="replace")
138
143
 
144
+ # JSON responses can sometimes be primitive values (like bools).
145
+ # If json.loads fails with ValueError (e.g. non-JSON text/html),
146
+ # we fall back to treating the raw response as a string.
147
+ response_content: Mapping[str, Any] | list[Any] | str | bool = raw
139
148
  try:
140
- message = json.loads(message)
149
+ response_content = json.loads(raw)
141
150
  except ValueError:
142
- _LOGGER.debug("Non JSON response: %s", message)
151
+ _LOGGER.debug("Non JSON response: %s", raw)
152
+ if not isinstance(response_content, dict | list | str | bool):
153
+ _LOGGER.error(
154
+ "Unexpected JSON primitive response from %s: %r",
155
+ url,
156
+ response_content,
157
+ )
158
+ raise ParseJSONError
143
159
 
144
160
  if resp.status == 400:
145
- if isinstance(message, dict) and "msg" in message:
146
- _LOGGER.error("Error 400: %s", message["msg"])
147
- elif isinstance(message, dict) and "error" in message:
148
- _LOGGER.error("Error 400: %s", message["error"])
161
+ if isinstance(response_content, dict) and "msg" in response_content:
162
+ _LOGGER.error("Error 400: %s", response_content["msg"])
163
+ elif (
164
+ isinstance(response_content, dict)
165
+ and "error" in response_content
166
+ ):
167
+ _LOGGER.error("Error 400: %s", response_content["error"])
149
168
  else:
150
- _LOGGER.error("Error 400: %s", message)
169
+ _LOGGER.error("Error 400: %s", response_content)
151
170
  raise ParseJSONError
152
171
  if resp.status == 401:
153
- _LOGGER.error("Authentication error: %s", message)
172
+ _LOGGER.error("Authentication error: %s", response_content)
154
173
  raise AuthenticationError
155
174
  if resp.status in [404, 405, 500]:
156
- _LOGGER.warning("%s", message)
175
+ _LOGGER.warning("%s", response_content)
157
176
 
158
177
  if (
159
178
  method.lower() != "get"
160
- and isinstance(message, dict)
161
- and any(key in message for key in UPDATE_TRIGGERS)
179
+ and isinstance(response_content, dict)
180
+ and any(key in response_content for key in UPDATE_TRIGGERS)
162
181
  ):
163
182
  await self.update()
164
- return message
183
+ return response_content
165
184
 
166
185
  except (TimeoutError, ServerTimeoutError):
167
186
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -170,7 +189,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
170
189
  _LOGGER.error("Content error: %s", err.message)
171
190
  raise
172
191
 
173
- async def send_command(self, command: str) -> tuple:
192
+ async def send_command(self, command: str) -> tuple[Any, Any]:
174
193
  """Send a RAPI command to the charger and parses the response."""
175
194
  url = f"{self.url}r"
176
195
  data = {"json": 1, "rapi": command}
@@ -220,13 +239,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
220
239
  "Received non-JSON response from /config: %s", response
221
240
  )
222
241
 
223
- async def test_and_get(self) -> dict:
242
+ async def test_and_get(self) -> dict[str, Any]:
224
243
  """Test connection.
225
244
 
226
245
  Return model serial number as dict
227
246
  """
228
247
  url = f"{self.url}config"
229
- data = {}
248
+ data: dict[str, Any] = {}
230
249
 
231
250
  response = await self.process_request(url, method="get")
232
251
  if not isinstance(response, Mapping):
@@ -246,37 +265,38 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
246
265
  data = {"serial": serial, "model": model}
247
266
  return data
248
267
 
249
- def ws_start(self) -> None:
268
+ async def ws_start(self) -> None:
250
269
  """Start the websocket listener."""
251
270
  if self.websocket and self.websocket.state != STATE_STOPPED:
252
271
  raise AlreadyListening
253
272
 
254
- # Detect loop mismatch
255
- use_session = self._session
256
- try:
257
- asyncio.get_running_loop()
258
- except RuntimeError:
259
- # We are about to create a private loop in _start_listening
260
- # If we have a session, it's likely bound to another loop
261
- if self._session:
262
- _LOGGER.warning(
263
- "Caller-provided session may not work on private event loop. "
264
- "Creating a loop-local session."
265
- )
266
- use_session = None
267
- # Clear self._session so subsequent await self.update() uses
268
- # a loop-local session as well.
269
- self._session = None
270
- self._session_external = False
273
+ self._get_session()
271
274
 
272
275
  if not self.websocket or self.websocket.state == STATE_STOPPED:
273
- self.websocket = OpenEVSEWebsocket(
274
- self.url, self._update_status, self._user, self._pwd, use_session
275
- )
276
+ self._create_websocket()
276
277
 
277
278
  self._start_listening()
278
279
 
279
- def _start_listening(self):
280
+ def _create_websocket(self) -> None:
281
+ """Create a websocket using the configured session."""
282
+ self.websocket = OpenEVSEWebsocket(
283
+ self.url,
284
+ self._update_status,
285
+ self._user,
286
+ self._pwd,
287
+ self._session,
288
+ )
289
+
290
+ def _validate_session_loop(self, loop: asyncio.AbstractEventLoop) -> None:
291
+ """Ensure the configured session belongs to the active event loop."""
292
+ session_loop = getattr(self._session, "_loop", None)
293
+ if (
294
+ isinstance(session_loop, asyncio.AbstractEventLoop)
295
+ and session_loop is not loop
296
+ ):
297
+ raise RuntimeError(ERROR_SESSION_LOOP_MISMATCH)
298
+
299
+ def _start_listening(self) -> None:
280
300
  """Start the websocket listener."""
281
301
  if not self._loop:
282
302
  try:
@@ -300,7 +320,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
300
320
  )
301
321
  self._loop_thread.start()
302
322
 
303
- async def _update_status(self, msgtype, data, error):
323
+ async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
304
324
  """Update data from websocket listener."""
305
325
  if msgtype == SIGNAL_CONNECTION_STATE:
306
326
  uri = self.websocket.uri if self.websocket else "Unknown"
@@ -316,18 +336,18 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
316
336
  self._ws_listening = False
317
337
 
318
338
  # Stopped websockets without errors are expected during shutdown
319
- # and ignored
320
- elif data == STATE_STOPPED and error:
321
- _LOGGER.debug(
322
- "Websocket to %s failed, aborting [Error: %s]",
323
- uri,
324
- error,
325
- )
339
+ elif data == STATE_STOPPED:
340
+ if error:
341
+ _LOGGER.debug(
342
+ "Websocket to %s failed, aborting [Error: %s]",
343
+ uri,
344
+ error,
345
+ )
326
346
  self._ws_listening = False
327
347
 
328
348
  elif msgtype == "data":
329
349
  _LOGGER.debug("Websocket data: %s", data)
330
- if not isinstance(data, Mapping):
350
+ if not isinstance(data, MutableMapping):
331
351
  _LOGGER.warning("Received non-Mapping websocket data: %s", data)
332
352
  return
333
353
 
@@ -354,7 +374,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
354
374
  if inspect.isawaitable(result):
355
375
  await result
356
376
 
357
- async def _shutdown(self):
377
+ async def _shutdown(self) -> None:
358
378
  """Shutdown the websocket and tasks on the listener loop."""
359
379
  tasks = []
360
380
  if self._ws_keepalive_task:
@@ -417,7 +437,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
417
437
  # Standard async disconnect for caller loop
418
438
  await self._shutdown()
419
439
 
420
- def is_coroutine_function(self, callback):
440
+ def is_coroutine_function(self, callback: Any) -> bool:
421
441
  """Check if a callback is a coroutine function."""
422
442
  return inspect.iscoroutinefunction(callback)
423
443
 
@@ -428,7 +448,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
428
448
  return STATE_STOPPED
429
449
  return self.websocket.state
430
450
 
431
- async def repeat(self, interval, func, *args, **kwargs):
451
+ async def repeat(
452
+ self,
453
+ interval: float,
454
+ func: Callable[..., Any],
455
+ *args: Any,
456
+ **kwargs: Any,
457
+ ) -> None:
432
458
  """Run func every interval seconds.
433
459
 
434
460
  If func has not finished before *interval*, will run again
@@ -436,10 +462,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
436
462
 
437
463
  *args and **kwargs are passed as the arguments to func.
438
464
  """
439
- while self.ws_state != STATE_STOPPED and self._ws_listening:
465
+ while self.ws_state != STATE_STOPPED:
440
466
  await asyncio.sleep(interval)
441
- if self.ws_state == STATE_STOPPED or not self._ws_listening:
467
+ if self.ws_state == STATE_STOPPED:
442
468
  break
469
+ if not self._ws_listening:
470
+ continue
443
471
  result = func(*args, **kwargs)
444
472
  if inspect.isawaitable(result):
445
473
  await result
@@ -7,7 +7,7 @@ import logging
7
7
  from collections.abc import Mapping
8
8
  from typing import Any
9
9
 
10
- import aiohttp # type: ignore
10
+ import aiohttp
11
11
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
12
12
  from awesomeversion import AwesomeVersion
13
13
  from awesomeversion.exceptions import AwesomeVersionCompareException
@@ -23,9 +23,9 @@ class CommandsMixin:
23
23
  """Mixin providing command methods for OpenEVSE."""
24
24
 
25
25
  url: str
26
- _status: dict
27
- _config: dict
28
- _session: Any
26
+ _status: dict[str, Any]
27
+ _config: dict[str, Any]
28
+ _session: aiohttp.ClientSession | None
29
29
 
30
30
  # These are defined in client.py
31
31
  def _version_check(self, min_version: str, max_version: str = "") -> bool:
@@ -33,10 +33,10 @@ class CommandsMixin:
33
33
 
34
34
  async def process_request(
35
35
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
36
- ) -> Mapping[str, Any] | list[Any] | str:
36
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
37
37
  raise NotImplementedError
38
38
 
39
- async def send_command(self, command: str) -> tuple:
39
+ async def send_command(self, command: str) -> tuple[Any, Any]:
40
40
  raise NotImplementedError
41
41
 
42
42
  async def update(self, force_status: bool = False) -> None:
@@ -46,6 +46,10 @@ class CommandsMixin:
46
46
  """Normalize response to a dict or list."""
47
47
  raise NotImplementedError
48
48
 
49
+ def _get_session(self) -> aiohttp.ClientSession:
50
+ """Return the configured HTTP session."""
51
+ raise NotImplementedError
52
+
49
53
  def _flag_ota_if_started(self, response: Any) -> None:
50
54
  """Flag OTA as active if response indicates firmware update has started."""
51
55
  normalized = self._normalize_response(response)
@@ -382,7 +386,7 @@ class CommandsMixin:
382
386
  _LOGGER.debug("EVSE Restart response: %s", response)
383
387
 
384
388
  # Firmware version
385
- async def firmware_check(self) -> dict | None:
389
+ async def firmware_check(self) -> dict[str, Any] | None:
386
390
  """Return the latest firmware version."""
387
391
  if "version" not in self._config:
388
392
  # Throw warning if we can't find the version
@@ -409,12 +413,8 @@ class CommandsMixin:
409
413
  return None
410
414
 
411
415
  try:
412
- if (session := self._session) is None:
413
- async with aiohttp.ClientSession() as session:
414
- return await self._firmware_check_with_session(session, url, method)
415
- else:
416
- return await self._firmware_check_with_session(session, url, method)
417
-
416
+ session = self._get_session()
417
+ return await self._firmware_check_with_session(session, url, method)
418
418
  except (TimeoutError, ServerTimeoutError):
419
419
  _LOGGER.error("%s: %s", "Timeout while updating", url)
420
420
  except ContentTypeError as err:
@@ -426,7 +426,7 @@ class CommandsMixin:
426
426
 
427
427
  async def _firmware_check_with_session(
428
428
  self, session: aiohttp.ClientSession, url: str, method: str
429
- ) -> dict | None:
429
+ ) -> dict[str, Any] | None:
430
430
  """Process a firmware check request with a given session."""
431
431
  http_method = getattr(session, method)
432
432
  _LOGGER.debug(
@@ -504,7 +504,7 @@ class CommandsMixin:
504
504
  firmware_url: str | None = None,
505
505
  firmware_bytes: bytes | None = None,
506
506
  filename: str = "firmware.bin",
507
- ) -> Mapping[str, Any] | list[Any] | str:
507
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
508
508
  """Instruct the device to update its firmware.
509
509
 
510
510
  You can either: