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.
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/autolabeler.yml +2 -0
- python_openevse_http-1.0.0/EXTERNAL_SESSION.md +91 -0
- {python_openevse_http-0.4.3/python_openevse_http.egg-info → python_openevse_http-1.0.0}/PKG-INFO +15 -19
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/README.md +14 -18
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/example_external_session.py +0 -13
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/client.py +103 -75
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/commands.py +15 -15
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/const.py +7 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/managers.py +1 -1
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/properties.py +37 -30
- python_openevse_http-1.0.0/openevsehttp/py.typed +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/sensors.py +1 -1
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/websocket.py +49 -39
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/pyproject.toml +3 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0/python_openevse_http.egg-info}/PKG-INFO +15 -19
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/SOURCES.txt +1 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/conftest.py +140 -143
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_client.py +239 -145
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_commands.py +4 -1
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_external_session.py +12 -26
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_main_edge_cases.py +6 -3
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_mixins.py +2 -0
- python_openevse_http-1.0.0/tests/test_properties.py +492 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_shaper.py +2 -1
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_websocket.py +73 -58
- python_openevse_http-0.4.3/EXTERNAL_SESSION.md +0 -141
- python_openevse_http-0.4.3/tests/test_properties.py +0 -1229
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/dependabot.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/release-drafter.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/links.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/release-drafter.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.github/workflows/test.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.gitignore +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.pre-commit-config.yaml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/.yamllint +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/LICENSE +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/codecov.yml +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/__init__.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/__main__.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/exceptions.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/openevsehttp/utils.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/dependency_links.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/not-zip-safe +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/requires.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/python_openevse_http.egg-info/top_level.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements_lint.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/requirements_test.txt +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/setup.cfg +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/setup.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/__init__.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/common.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/github_v2.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/github_v4.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v2_json/config.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v2_json/status.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-broken.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-dev.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-extra-version.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-new.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/config.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/schedule.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status-broken.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status-new.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/v4_json/status.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/fixtures/websocket.json +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_managers.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tests/test_sensors.py +0 -0
- {python_openevse_http-0.4.3 → python_openevse_http-1.0.0}/tox.ini +0 -0
|
@@ -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
|
+
```
|
{python_openevse_http-0.4.3/python_openevse_http.egg-info → python_openevse_http-1.0.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python_openevse_http
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
69
|
-
|
|
69
|
+
async with aiohttp.ClientSession() as session:
|
|
70
|
+
charger = OpenEVSE("192.168.1.30", session=session)
|
|
71
|
+
await charger.update()
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
print(f"Charger State: {charger.status}")
|
|
74
|
+
print(f"Current Charge: {charger.charge_current}A")
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
if charger.shaper_active:
|
|
77
|
+
print("Shaper is active, disabling...")
|
|
78
|
+
else:
|
|
79
|
+
print("Shaper is inactive, enabling...")
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
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 |
|
|
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 |
|
|
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
|
-
|
|
36
|
-
|
|
36
|
+
async with aiohttp.ClientSession() as session:
|
|
37
|
+
charger = OpenEVSE("192.168.1.30", session=session)
|
|
38
|
+
await charger.update()
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
print(f"Charger State: {charger.status}")
|
|
41
|
+
print(f"Current Charge: {charger.charge_current}A")
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
if charger.shaper_active:
|
|
44
|
+
print("Shaper is active, disabling...")
|
|
45
|
+
else:
|
|
46
|
+
print("Shaper is inactive, enabling...")
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
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 |
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
139
|
+
raw = await resp.text()
|
|
134
140
|
except UnicodeDecodeError:
|
|
135
141
|
_LOGGER.debug("Decoding error")
|
|
136
|
-
|
|
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
|
-
|
|
149
|
+
response_content = json.loads(raw)
|
|
141
150
|
except ValueError:
|
|
142
|
-
_LOGGER.debug("Non JSON response: %s",
|
|
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(
|
|
146
|
-
_LOGGER.error("Error 400: %s",
|
|
147
|
-
elif
|
|
148
|
-
|
|
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",
|
|
169
|
+
_LOGGER.error("Error 400: %s", response_content)
|
|
151
170
|
raise ParseJSONError
|
|
152
171
|
if resp.status == 401:
|
|
153
|
-
_LOGGER.error("Authentication error: %s",
|
|
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",
|
|
175
|
+
_LOGGER.warning("%s", response_content)
|
|
157
176
|
|
|
158
177
|
if (
|
|
159
178
|
method.lower() != "get"
|
|
160
|
-
and isinstance(
|
|
161
|
-
and any(key in
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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,
|
|
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(
|
|
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
|
|
465
|
+
while self.ws_state != STATE_STOPPED:
|
|
440
466
|
await asyncio.sleep(interval)
|
|
441
|
-
if self.ws_state == STATE_STOPPED
|
|
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
|
|
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:
|
|
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
|
-
|
|
413
|
-
|
|
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:
|