python-openevse-http 0.4.2__tar.gz → 0.4.4__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.2/python_openevse_http.egg-info → python_openevse_http-0.4.4}/PKG-INFO +3 -3
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/README.md +2 -2
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/client.py +64 -43
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/commands.py +80 -10
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/managers.py +1 -1
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/properties.py +37 -30
- python_openevse_http-0.4.4/openevsehttp/py.typed +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/sensors.py +1 -1
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/websocket.py +34 -31
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/pyproject.toml +3 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4/python_openevse_http.egg-info}/PKG-INFO +3 -3
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/SOURCES.txt +1 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements_test.txt +1 -2
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/conftest.py +176 -3
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_client.py +44 -4
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_commands.py +154 -25
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_managers.py +58 -15
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_properties.py +7 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_websocket.py +37 -34
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/dependabot.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/release-drafter.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/autolabeler.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/links.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/publish-to-pypi.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/release-drafter.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/test.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.gitignore +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.pre-commit-config.yaml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.yamllint +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/EXTERNAL_SESSION.md +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/LICENSE +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/codecov.yml +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/example_external_session.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/__init__.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/__main__.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/const.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/exceptions.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/utils.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/dependency_links.txt +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/not-zip-safe +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/requires.txt +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/top_level.txt +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements.txt +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements_lint.txt +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/setup.cfg +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/setup.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/__init__.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/common.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/github_v2.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/github_v4.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/config.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/status.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-dev.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-extra-version.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-new.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/schedule.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-broken.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-new.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/websocket.json +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_external_session.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_main_edge_cases.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_mixins.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_sensors.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_shaper.py +0 -0
- {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tox.ini +0 -0
{python_openevse_http-0.4.2/python_openevse_http.egg-info → python_openevse_http-0.4.4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python_openevse_http
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
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
|
|
@@ -97,7 +97,7 @@ if __name__ == "__main__":
|
|
|
97
97
|
| `/config` | GET, POST | ✅ | System and WiFi configuration |
|
|
98
98
|
| `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
|
|
99
99
|
| `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
|
|
100
|
-
| `/schedule` | GET, POST |
|
|
100
|
+
| `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
|
|
101
101
|
| `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
|
|
102
102
|
| `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
|
|
103
103
|
| `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
|
|
@@ -111,7 +111,7 @@ if __name__ == "__main__":
|
|
|
111
111
|
| `/tesla` | GET | ❌ | Tesla vehicle integration |
|
|
112
112
|
| `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
|
|
113
113
|
| `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
|
|
114
|
-
| `/update` | POST |
|
|
114
|
+
| `/update` | POST | ✅ | Firmware update interface |
|
|
115
115
|
| `/rfid/add` | POST | ❌ | RFID tag management |
|
|
116
116
|
|
|
117
117
|
✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
|
|
@@ -64,7 +64,7 @@ if __name__ == "__main__":
|
|
|
64
64
|
| `/config` | GET, POST | ✅ | System and WiFi configuration |
|
|
65
65
|
| `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
|
|
66
66
|
| `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
|
|
67
|
-
| `/schedule` | GET, POST |
|
|
67
|
+
| `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
|
|
68
68
|
| `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
|
|
69
69
|
| `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
|
|
70
70
|
| `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
|
|
@@ -78,7 +78,7 @@ if __name__ == "__main__":
|
|
|
78
78
|
| `/tesla` | GET | ❌ | Tesla vehicle integration |
|
|
79
79
|
| `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
|
|
80
80
|
| `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
|
|
81
|
-
| `/update` | POST |
|
|
81
|
+
| `/update` | POST | ✅ | Firmware update interface |
|
|
82
82
|
| `/rfid/add` | POST | ❌ | RFID tag management |
|
|
83
83
|
|
|
84
84
|
✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
|
|
@@ -7,10 +7,10 @@ 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
|
|
@@ -56,15 +56,15 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
56
56
|
self._user = user
|
|
57
57
|
self._pwd = pwd
|
|
58
58
|
self.url = f"http://{host}/"
|
|
59
|
-
self._status: dict = {}
|
|
60
|
-
self._config: dict = {}
|
|
61
|
-
self._override = None
|
|
59
|
+
self._status: dict[str, Any] = {}
|
|
60
|
+
self._config: dict[str, Any] = {}
|
|
61
|
+
self._override: Any = None
|
|
62
62
|
self._ws_listening = False
|
|
63
63
|
self.websocket: OpenEVSEWebsocket | None = None
|
|
64
|
-
self.callback: Callable | None = None
|
|
64
|
+
self.callback: Callable[[], Any] | None = None
|
|
65
65
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
66
|
-
self._ws_listen_task: asyncio.Task | None = None
|
|
67
|
-
self._ws_keepalive_task: asyncio.Task | None = None
|
|
66
|
+
self._ws_listen_task: asyncio.Task[Any] | None = None
|
|
67
|
+
self._ws_keepalive_task: asyncio.Task[Any] | None = None
|
|
68
68
|
self._owns_loop = False
|
|
69
69
|
self._loop_thread: threading.Thread | None = None
|
|
70
70
|
self._session = session
|
|
@@ -76,7 +76,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
76
76
|
method: str = "",
|
|
77
77
|
data: Any = None,
|
|
78
78
|
rapi: Any = None,
|
|
79
|
-
) -> Mapping[str, Any] | list[Any] | str:
|
|
79
|
+
) -> Mapping[str, Any] | list[Any] | str | bool:
|
|
80
80
|
"""Return result of processed HTTP request."""
|
|
81
81
|
auth = None
|
|
82
82
|
allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
|
|
@@ -112,7 +112,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
112
112
|
data: Any,
|
|
113
113
|
rapi: Any,
|
|
114
114
|
auth: Any,
|
|
115
|
-
) -> Mapping[str, Any] | list[Any] | str:
|
|
115
|
+
) -> Mapping[str, Any] | list[Any] | str | bool:
|
|
116
116
|
"""Process a request with a given session."""
|
|
117
117
|
if not hasattr(session, method):
|
|
118
118
|
raise MissingMethod
|
|
@@ -130,38 +130,51 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
130
130
|
kwargs["json"] = data
|
|
131
131
|
async with http_method(url, **kwargs) as resp:
|
|
132
132
|
try:
|
|
133
|
-
|
|
133
|
+
raw = await resp.text()
|
|
134
134
|
except UnicodeDecodeError:
|
|
135
135
|
_LOGGER.debug("Decoding error")
|
|
136
|
-
|
|
137
|
-
message = message.decode(errors="replace")
|
|
136
|
+
raw = (await resp.read()).decode(errors="replace")
|
|
138
137
|
|
|
138
|
+
# JSON responses can sometimes be primitive values (like bools).
|
|
139
|
+
# If json.loads fails with ValueError (e.g. non-JSON text/html),
|
|
140
|
+
# we fall back to treating the raw response as a string.
|
|
141
|
+
response_content: Mapping[str, Any] | list[Any] | str | bool = raw
|
|
139
142
|
try:
|
|
140
|
-
|
|
143
|
+
response_content = json.loads(raw)
|
|
141
144
|
except ValueError:
|
|
142
|
-
_LOGGER.debug("Non JSON response: %s",
|
|
145
|
+
_LOGGER.debug("Non JSON response: %s", raw)
|
|
146
|
+
if not isinstance(response_content, dict | list | str | bool):
|
|
147
|
+
_LOGGER.error(
|
|
148
|
+
"Unexpected JSON primitive response from %s: %r",
|
|
149
|
+
url,
|
|
150
|
+
response_content,
|
|
151
|
+
)
|
|
152
|
+
raise ParseJSONError
|
|
143
153
|
|
|
144
154
|
if resp.status == 400:
|
|
145
|
-
if isinstance(
|
|
146
|
-
_LOGGER.error("Error 400: %s",
|
|
147
|
-
elif
|
|
148
|
-
|
|
155
|
+
if isinstance(response_content, dict) and "msg" in response_content:
|
|
156
|
+
_LOGGER.error("Error 400: %s", response_content["msg"])
|
|
157
|
+
elif (
|
|
158
|
+
isinstance(response_content, dict)
|
|
159
|
+
and "error" in response_content
|
|
160
|
+
):
|
|
161
|
+
_LOGGER.error("Error 400: %s", response_content["error"])
|
|
149
162
|
else:
|
|
150
|
-
_LOGGER.error("Error 400: %s",
|
|
163
|
+
_LOGGER.error("Error 400: %s", response_content)
|
|
151
164
|
raise ParseJSONError
|
|
152
165
|
if resp.status == 401:
|
|
153
|
-
_LOGGER.error("Authentication error: %s",
|
|
166
|
+
_LOGGER.error("Authentication error: %s", response_content)
|
|
154
167
|
raise AuthenticationError
|
|
155
168
|
if resp.status in [404, 405, 500]:
|
|
156
|
-
_LOGGER.warning("%s",
|
|
169
|
+
_LOGGER.warning("%s", response_content)
|
|
157
170
|
|
|
158
171
|
if (
|
|
159
172
|
method.lower() != "get"
|
|
160
|
-
and isinstance(
|
|
161
|
-
and any(key in
|
|
173
|
+
and isinstance(response_content, dict)
|
|
174
|
+
and any(key in response_content for key in UPDATE_TRIGGERS)
|
|
162
175
|
):
|
|
163
176
|
await self.update()
|
|
164
|
-
return
|
|
177
|
+
return response_content
|
|
165
178
|
|
|
166
179
|
except (TimeoutError, ServerTimeoutError):
|
|
167
180
|
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
|
|
@@ -170,7 +183,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
170
183
|
_LOGGER.error("Content error: %s", err.message)
|
|
171
184
|
raise
|
|
172
185
|
|
|
173
|
-
async def send_command(self, command: str) -> tuple:
|
|
186
|
+
async def send_command(self, command: str) -> tuple[Any, Any]:
|
|
174
187
|
"""Send a RAPI command to the charger and parses the response."""
|
|
175
188
|
url = f"{self.url}r"
|
|
176
189
|
data = {"json": 1, "rapi": command}
|
|
@@ -220,13 +233,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
220
233
|
"Received non-JSON response from /config: %s", response
|
|
221
234
|
)
|
|
222
235
|
|
|
223
|
-
async def test_and_get(self) -> dict:
|
|
236
|
+
async def test_and_get(self) -> dict[str, Any]:
|
|
224
237
|
"""Test connection.
|
|
225
238
|
|
|
226
239
|
Return model serial number as dict
|
|
227
240
|
"""
|
|
228
241
|
url = f"{self.url}config"
|
|
229
|
-
data = {}
|
|
242
|
+
data: dict[str, Any] = {}
|
|
230
243
|
|
|
231
244
|
response = await self.process_request(url, method="get")
|
|
232
245
|
if not isinstance(response, Mapping):
|
|
@@ -276,7 +289,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
276
289
|
|
|
277
290
|
self._start_listening()
|
|
278
291
|
|
|
279
|
-
def _start_listening(self):
|
|
292
|
+
def _start_listening(self) -> None:
|
|
280
293
|
"""Start the websocket listener."""
|
|
281
294
|
if not self._loop:
|
|
282
295
|
try:
|
|
@@ -300,7 +313,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
300
313
|
)
|
|
301
314
|
self._loop_thread.start()
|
|
302
315
|
|
|
303
|
-
async def _update_status(self, msgtype, data, error):
|
|
316
|
+
async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
|
|
304
317
|
"""Update data from websocket listener."""
|
|
305
318
|
if msgtype == SIGNAL_CONNECTION_STATE:
|
|
306
319
|
uri = self.websocket.uri if self.websocket else "Unknown"
|
|
@@ -316,18 +329,18 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
316
329
|
self._ws_listening = False
|
|
317
330
|
|
|
318
331
|
# Stopped websockets without errors are expected during shutdown
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
elif data == STATE_STOPPED:
|
|
333
|
+
if error:
|
|
334
|
+
_LOGGER.debug(
|
|
335
|
+
"Websocket to %s failed, aborting [Error: %s]",
|
|
336
|
+
uri,
|
|
337
|
+
error,
|
|
338
|
+
)
|
|
326
339
|
self._ws_listening = False
|
|
327
340
|
|
|
328
341
|
elif msgtype == "data":
|
|
329
342
|
_LOGGER.debug("Websocket data: %s", data)
|
|
330
|
-
if not isinstance(data,
|
|
343
|
+
if not isinstance(data, MutableMapping):
|
|
331
344
|
_LOGGER.warning("Received non-Mapping websocket data: %s", data)
|
|
332
345
|
return
|
|
333
346
|
|
|
@@ -354,7 +367,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
354
367
|
if inspect.isawaitable(result):
|
|
355
368
|
await result
|
|
356
369
|
|
|
357
|
-
async def _shutdown(self):
|
|
370
|
+
async def _shutdown(self) -> None:
|
|
358
371
|
"""Shutdown the websocket and tasks on the listener loop."""
|
|
359
372
|
tasks = []
|
|
360
373
|
if self._ws_keepalive_task:
|
|
@@ -417,7 +430,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
417
430
|
# Standard async disconnect for caller loop
|
|
418
431
|
await self._shutdown()
|
|
419
432
|
|
|
420
|
-
def is_coroutine_function(self, callback):
|
|
433
|
+
def is_coroutine_function(self, callback: Any) -> bool:
|
|
421
434
|
"""Check if a callback is a coroutine function."""
|
|
422
435
|
return inspect.iscoroutinefunction(callback)
|
|
423
436
|
|
|
@@ -428,7 +441,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
428
441
|
return STATE_STOPPED
|
|
429
442
|
return self.websocket.state
|
|
430
443
|
|
|
431
|
-
async def repeat(
|
|
444
|
+
async def repeat(
|
|
445
|
+
self,
|
|
446
|
+
interval: float,
|
|
447
|
+
func: Callable[..., Any],
|
|
448
|
+
*args: Any,
|
|
449
|
+
**kwargs: Any,
|
|
450
|
+
) -> None:
|
|
432
451
|
"""Run func every interval seconds.
|
|
433
452
|
|
|
434
453
|
If func has not finished before *interval*, will run again
|
|
@@ -436,10 +455,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
|
436
455
|
|
|
437
456
|
*args and **kwargs are passed as the arguments to func.
|
|
438
457
|
"""
|
|
439
|
-
while self.ws_state != STATE_STOPPED
|
|
458
|
+
while self.ws_state != STATE_STOPPED:
|
|
440
459
|
await asyncio.sleep(interval)
|
|
441
|
-
if self.ws_state == STATE_STOPPED
|
|
460
|
+
if self.ws_state == STATE_STOPPED:
|
|
442
461
|
break
|
|
462
|
+
if not self._ws_listening:
|
|
463
|
+
continue
|
|
443
464
|
result = func(*args, **kwargs)
|
|
444
465
|
if inspect.isawaitable(result):
|
|
445
466
|
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,8 +23,8 @@ class CommandsMixin:
|
|
|
23
23
|
"""Mixin providing command methods for OpenEVSE."""
|
|
24
24
|
|
|
25
25
|
url: str
|
|
26
|
-
_status: dict
|
|
27
|
-
_config: dict
|
|
26
|
+
_status: dict[str, Any]
|
|
27
|
+
_config: dict[str, Any]
|
|
28
28
|
_session: Any
|
|
29
29
|
|
|
30
30
|
# These are defined in client.py
|
|
@@ -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:
|
|
@@ -53,7 +53,12 @@ class CommandsMixin:
|
|
|
53
53
|
normalized.get("msg") == "started"
|
|
54
54
|
or normalized.get("msg") in SUCCESS_ANSWERS
|
|
55
55
|
):
|
|
56
|
+
_LOGGER.debug("Firmware update started, setting ota_update flag.")
|
|
56
57
|
self._status["ota_update"] = 1
|
|
58
|
+
else:
|
|
59
|
+
_LOGGER.debug(
|
|
60
|
+
"Firmware update response did not indicate start: %s", normalized
|
|
61
|
+
)
|
|
57
62
|
|
|
58
63
|
async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
|
|
59
64
|
"""Return the current schedule."""
|
|
@@ -131,7 +136,12 @@ class CommandsMixin:
|
|
|
131
136
|
time_limit: int | None = None,
|
|
132
137
|
auto_release: bool | None = None,
|
|
133
138
|
) -> Any:
|
|
134
|
-
"""Set the manual override status.
|
|
139
|
+
"""Set the manual override status.
|
|
140
|
+
|
|
141
|
+
Fetches the current override payload first and merges existing values
|
|
142
|
+
into the request payload. This prevents the firmware from clearing/resetting
|
|
143
|
+
previously configured properties that are not passed in the function call.
|
|
144
|
+
"""
|
|
135
145
|
if not self._version_check("4.0.1"):
|
|
136
146
|
_LOGGER.debug("Feature not supported for older firmware.")
|
|
137
147
|
raise UnsupportedFeature
|
|
@@ -149,6 +159,18 @@ class CommandsMixin:
|
|
|
149
159
|
raise ValueError
|
|
150
160
|
|
|
151
161
|
data: dict[str, Any] = {}
|
|
162
|
+
if isinstance(response, Mapping):
|
|
163
|
+
for key in (
|
|
164
|
+
"state",
|
|
165
|
+
"charge_current",
|
|
166
|
+
"max_current",
|
|
167
|
+
"energy_limit",
|
|
168
|
+
"time_limit",
|
|
169
|
+
"auto_release",
|
|
170
|
+
):
|
|
171
|
+
if key in response:
|
|
172
|
+
data[key] = response[key]
|
|
173
|
+
|
|
152
174
|
if auto_release is not None:
|
|
153
175
|
data["auto_release"] = auto_release
|
|
154
176
|
|
|
@@ -360,7 +382,7 @@ class CommandsMixin:
|
|
|
360
382
|
_LOGGER.debug("EVSE Restart response: %s", response)
|
|
361
383
|
|
|
362
384
|
# Firmware version
|
|
363
|
-
async def firmware_check(self) -> dict | None:
|
|
385
|
+
async def firmware_check(self) -> dict[str, Any] | None:
|
|
364
386
|
"""Return the latest firmware version."""
|
|
365
387
|
if "version" not in self._config:
|
|
366
388
|
# Throw warning if we can't find the version
|
|
@@ -381,6 +403,7 @@ class CommandsMixin:
|
|
|
381
403
|
url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
|
|
382
404
|
else:
|
|
383
405
|
url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
|
|
406
|
+
_LOGGER.debug("Firmware check URL: %s", url)
|
|
384
407
|
except AwesomeVersionCompareException:
|
|
385
408
|
_LOGGER.debug("Non-semver firmware version detected.")
|
|
386
409
|
return None
|
|
@@ -403,7 +426,7 @@ class CommandsMixin:
|
|
|
403
426
|
|
|
404
427
|
async def _firmware_check_with_session(
|
|
405
428
|
self, session: aiohttp.ClientSession, url: str, method: str
|
|
406
|
-
) -> dict | None:
|
|
429
|
+
) -> dict[str, Any] | None:
|
|
407
430
|
"""Process a firmware check request with a given session."""
|
|
408
431
|
http_method = getattr(session, method)
|
|
409
432
|
_LOGGER.debug(
|
|
@@ -412,6 +435,7 @@ class CommandsMixin:
|
|
|
412
435
|
method,
|
|
413
436
|
)
|
|
414
437
|
async with http_method(url) as resp:
|
|
438
|
+
_LOGGER.debug("Firmware check response status: %d", resp.status)
|
|
415
439
|
if resp.status != 200:
|
|
416
440
|
return None
|
|
417
441
|
message = await resp.text()
|
|
@@ -422,19 +446,51 @@ class CommandsMixin:
|
|
|
422
446
|
return None
|
|
423
447
|
|
|
424
448
|
if not isinstance(message, dict):
|
|
449
|
+
_LOGGER.debug(
|
|
450
|
+
"Invalid JSON response type from GitHub: %s", type(message)
|
|
451
|
+
)
|
|
425
452
|
return None
|
|
426
453
|
|
|
454
|
+
_LOGGER.debug(
|
|
455
|
+
"GitHub release metadata successfully fetched for version: %s",
|
|
456
|
+
message.get("tag_name"),
|
|
457
|
+
)
|
|
458
|
+
|
|
427
459
|
# Match browser_download_url based on buildenv
|
|
428
460
|
download_url = None
|
|
429
461
|
buildenv = self._config.get("buildenv")
|
|
430
462
|
assets = message.get("assets", [])
|
|
431
463
|
|
|
432
|
-
if buildenv
|
|
464
|
+
if not buildenv:
|
|
465
|
+
_LOGGER.debug(
|
|
466
|
+
"Cannot resolve firmware asset: missing buildenv in config."
|
|
467
|
+
)
|
|
468
|
+
assets = []
|
|
469
|
+
elif not isinstance(assets, list):
|
|
470
|
+
_LOGGER.debug("Invalid GitHub assets payload: %r", assets)
|
|
471
|
+
assets = []
|
|
472
|
+
else:
|
|
473
|
+
_LOGGER.debug("Matching buildenv '%s' against assets", buildenv)
|
|
433
474
|
target_filename = f"{buildenv}.bin"
|
|
434
475
|
for asset in assets:
|
|
476
|
+
if not isinstance(asset, Mapping):
|
|
477
|
+
continue
|
|
435
478
|
if asset.get("name") == target_filename:
|
|
436
479
|
download_url = asset.get("browser_download_url")
|
|
480
|
+
_LOGGER.debug("Found matching firmware asset: %s", download_url)
|
|
437
481
|
break
|
|
482
|
+
if buildenv and not download_url:
|
|
483
|
+
_LOGGER.debug(
|
|
484
|
+
"Could not find asset matching target filename '%s.bin' in assets: %s",
|
|
485
|
+
buildenv,
|
|
486
|
+
[
|
|
487
|
+
asset.get("name")
|
|
488
|
+
for asset in assets
|
|
489
|
+
if isinstance(asset, Mapping)
|
|
490
|
+
]
|
|
491
|
+
if assets
|
|
492
|
+
else "None",
|
|
493
|
+
)
|
|
438
494
|
|
|
439
495
|
return {
|
|
440
496
|
"latest_version": message.get("tag_name"),
|
|
@@ -448,7 +504,7 @@ class CommandsMixin:
|
|
|
448
504
|
firmware_url: str | None = None,
|
|
449
505
|
firmware_bytes: bytes | None = None,
|
|
450
506
|
filename: str = "firmware.bin",
|
|
451
|
-
) -> Mapping[str, Any] | list[Any] | str:
|
|
507
|
+
) -> Mapping[str, Any] | list[Any] | str | bool:
|
|
452
508
|
"""Instruct the device to update its firmware.
|
|
453
509
|
|
|
454
510
|
You can either:
|
|
@@ -461,10 +517,16 @@ class CommandsMixin:
|
|
|
461
517
|
raise UnsupportedFeature
|
|
462
518
|
|
|
463
519
|
if firmware_bytes is not None and firmware_url is not None:
|
|
520
|
+
_LOGGER.error("Cannot specify both firmware_bytes and firmware_url")
|
|
464
521
|
raise ValueError("Cannot specify both firmware_bytes and firmware_url")
|
|
465
522
|
|
|
523
|
+
if firmware_bytes is not None and len(firmware_bytes) == 0:
|
|
524
|
+
_LOGGER.error("Empty firmware bytes provided")
|
|
525
|
+
raise ValueError("Empty firmware bytes provided")
|
|
526
|
+
|
|
466
527
|
if firmware_url is not None:
|
|
467
528
|
if not isinstance(firmware_url, str) or not firmware_url.strip():
|
|
529
|
+
_LOGGER.error("Invalid firmware_url: %s", firmware_url)
|
|
468
530
|
raise ValueError("Invalid firmware_url")
|
|
469
531
|
|
|
470
532
|
url = f"{self.url}update"
|
|
@@ -485,13 +547,20 @@ class CommandsMixin:
|
|
|
485
547
|
response = await self.process_request(
|
|
486
548
|
url=url, method="post", rapi=form_data
|
|
487
549
|
)
|
|
550
|
+
_LOGGER.debug("Firmware upload request completed. Response: %s", response)
|
|
488
551
|
self._flag_ota_if_started(response)
|
|
489
552
|
return response
|
|
490
553
|
|
|
491
554
|
# 2. Resolve URL from GitHub if not specified
|
|
492
555
|
if firmware_url is None:
|
|
556
|
+
_LOGGER.debug(
|
|
557
|
+
"No firmware URL provided. Resolving latest matching firmware from GitHub."
|
|
558
|
+
)
|
|
493
559
|
check_result = await self.firmware_check()
|
|
494
560
|
if not check_result or not check_result.get("browser_download_url"):
|
|
561
|
+
_LOGGER.error(
|
|
562
|
+
"Could not resolve latest firmware download URL from GitHub."
|
|
563
|
+
)
|
|
495
564
|
raise RuntimeError(
|
|
496
565
|
"Could not resolve latest firmware download URL from GitHub."
|
|
497
566
|
)
|
|
@@ -503,6 +572,7 @@ class CommandsMixin:
|
|
|
503
572
|
"Requesting OpenEVSE to download and update from: %s", firmware_url
|
|
504
573
|
)
|
|
505
574
|
response = await self.process_request(url=url, method="post", data=data)
|
|
575
|
+
_LOGGER.debug("Firmware update request completed. Response: %s", response)
|
|
506
576
|
self._flag_ota_if_started(response)
|
|
507
577
|
return response
|
|
508
578
|
|
|
@@ -23,7 +23,7 @@ class ManagersMixin:
|
|
|
23
23
|
|
|
24
24
|
async def process_request(
|
|
25
25
|
self, url: str, method: str = "", data: Any = None, rapi: Any = None
|
|
26
|
-
) -> Mapping[str, Any] | list[Any] | str:
|
|
26
|
+
) -> Mapping[str, Any] | list[Any] | str | bool:
|
|
27
27
|
raise NotImplementedError
|
|
28
28
|
|
|
29
29
|
def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
|