python-openevse-http 0.4.3__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
- openevsehttp/client.py +103 -75
- openevsehttp/commands.py +15 -15
- openevsehttp/const.py +7 -0
- openevsehttp/managers.py +1 -1
- openevsehttp/properties.py +37 -30
- openevsehttp/py.typed +0 -0
- openevsehttp/sensors.py +1 -1
- openevsehttp/websocket.py +49 -39
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-1.0.0.dist-info}/METADATA +15 -19
- python_openevse_http-1.0.0.dist-info/RECORD +17 -0
- python_openevse_http-0.4.3.dist-info/RECORD +0 -16
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-1.0.0.dist-info}/WHEEL +0 -0
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-1.0.0.dist-info}/top_level.txt +0 -0
openevsehttp/client.py
CHANGED
|
@@ -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
|
openevsehttp/commands.py
CHANGED
|
@@ -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:
|
openevsehttp/const.py
CHANGED
|
@@ -62,3 +62,10 @@ RAPI_ERRORS = [
|
|
|
62
62
|
]
|
|
63
63
|
|
|
64
64
|
SUCCESS_ANSWERS = ["OK", "done", "no change", "Created", "Updated", "Deleted"]
|
|
65
|
+
|
|
66
|
+
ERROR_SESSION_REQUIRED = (
|
|
67
|
+
"An aiohttp.ClientSession must be provided via the session argument."
|
|
68
|
+
)
|
|
69
|
+
ERROR_SESSION_LOOP_MISMATCH = (
|
|
70
|
+
"The aiohttp.ClientSession is bound to a different event loop."
|
|
71
|
+
)
|
openevsehttp/managers.py
CHANGED
|
@@ -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]:
|
openevsehttp/properties.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
from collections.abc import Mapping
|
|
7
7
|
from datetime import datetime, timedelta, timezone
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, cast
|
|
9
9
|
|
|
10
10
|
from .const import MAX_AMPS, MIN_AMPS, states
|
|
11
11
|
from .exceptions import UnsupportedFeature
|
|
@@ -17,8 +17,8 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
17
17
|
class PropertiesMixin:
|
|
18
18
|
"""Mixin providing all @property accessors for OpenEVSE."""
|
|
19
19
|
|
|
20
|
-
_status: dict
|
|
21
|
-
_config: dict
|
|
20
|
+
_status: dict[str, Any]
|
|
21
|
+
_config: dict[str, Any]
|
|
22
22
|
|
|
23
23
|
# These are used by properties but defined in client.py
|
|
24
24
|
def _version_check(self, min_version: str, max_version: str = "") -> bool:
|
|
@@ -125,15 +125,15 @@ class PropertiesMixin:
|
|
|
125
125
|
@property
|
|
126
126
|
def max_current(self) -> int | None:
|
|
127
127
|
"""Return the max current."""
|
|
128
|
-
return self._status.get("max_current", None)
|
|
128
|
+
return cast(int | None, self._status.get("max_current", None))
|
|
129
129
|
|
|
130
130
|
@property
|
|
131
131
|
def wifi_firmware(self) -> str | None:
|
|
132
132
|
"""Return the ESP firmware version."""
|
|
133
133
|
value = self._config.get("version")
|
|
134
|
-
if value is
|
|
135
|
-
|
|
136
|
-
return value
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
return normalize_version(value)
|
|
137
137
|
|
|
138
138
|
@property
|
|
139
139
|
def ip_address(self) -> str | None:
|
|
@@ -303,27 +303,30 @@ class PropertiesMixin:
|
|
|
303
303
|
@property
|
|
304
304
|
def total_day(self) -> float | None:
|
|
305
305
|
"""Get the total day energy usage."""
|
|
306
|
-
return self._status.get("total_day", None)
|
|
306
|
+
return cast(float | None, self._status.get("total_day", None))
|
|
307
307
|
|
|
308
308
|
@property
|
|
309
309
|
def total_week(self) -> float | None:
|
|
310
310
|
"""Get the total week energy usage."""
|
|
311
|
-
return self._status.get("total_week", None)
|
|
311
|
+
return cast(float | None, self._status.get("total_week", None))
|
|
312
312
|
|
|
313
313
|
@property
|
|
314
314
|
def total_month(self) -> float | None:
|
|
315
315
|
"""Get the total month energy usage."""
|
|
316
|
-
return self._status.get("total_month", None)
|
|
316
|
+
return cast(float | None, self._status.get("total_month", None))
|
|
317
317
|
|
|
318
318
|
@property
|
|
319
319
|
def total_year(self) -> float | None:
|
|
320
320
|
"""Get the total year energy usage."""
|
|
321
|
-
return self._status.get("total_year", None)
|
|
321
|
+
return cast(float | None, self._status.get("total_year", None))
|
|
322
322
|
|
|
323
323
|
@property
|
|
324
324
|
def has_limit(self) -> bool | None:
|
|
325
325
|
"""Return if a limit has been set."""
|
|
326
|
-
return
|
|
326
|
+
return cast(
|
|
327
|
+
"bool | None",
|
|
328
|
+
self._status.get("has_limit", self._status.get("limit", None)),
|
|
329
|
+
)
|
|
327
330
|
|
|
328
331
|
@property
|
|
329
332
|
def protocol_version(self) -> str | None:
|
|
@@ -336,7 +339,7 @@ class PropertiesMixin:
|
|
|
336
339
|
@property
|
|
337
340
|
def vehicle(self) -> bool:
|
|
338
341
|
"""Return if a vehicle is connected to the EVSE."""
|
|
339
|
-
return self._status.get("vehicle", False)
|
|
342
|
+
return bool(self._status.get("vehicle", False))
|
|
340
343
|
|
|
341
344
|
@property
|
|
342
345
|
def ota_update(self) -> bool:
|
|
@@ -356,7 +359,7 @@ class PropertiesMixin:
|
|
|
356
359
|
@property
|
|
357
360
|
def manual_override(self) -> bool:
|
|
358
361
|
"""Return if Manual Override is set."""
|
|
359
|
-
return self._status.get("manual_override", False)
|
|
362
|
+
return bool(self._status.get("manual_override", False))
|
|
360
363
|
|
|
361
364
|
@property
|
|
362
365
|
def divertmode(self) -> str:
|
|
@@ -394,7 +397,7 @@ class PropertiesMixin:
|
|
|
394
397
|
@property
|
|
395
398
|
def wifi_serial(self) -> str | None:
|
|
396
399
|
"""Return wifi serial."""
|
|
397
|
-
return self._config.get("wifi_serial", None)
|
|
400
|
+
return cast(str | None, self._config.get("wifi_serial", None))
|
|
398
401
|
|
|
399
402
|
@property
|
|
400
403
|
def charging_power(self) -> float | None:
|
|
@@ -415,12 +418,12 @@ class PropertiesMixin:
|
|
|
415
418
|
@property
|
|
416
419
|
def shaper_active(self) -> bool | None:
|
|
417
420
|
"""Return if shaper is active."""
|
|
418
|
-
return self._status.get("shaper", None)
|
|
421
|
+
return cast(bool | None, self._status.get("shaper", None))
|
|
419
422
|
|
|
420
423
|
@property
|
|
421
424
|
def shaper_live_power(self) -> int | None:
|
|
422
425
|
"""Return shaper live power reading."""
|
|
423
|
-
return self._status.get("shaper_live_pwr", None)
|
|
426
|
+
return cast(int | None, self._status.get("shaper_live_pwr", None))
|
|
424
427
|
|
|
425
428
|
@property
|
|
426
429
|
def shaper_available_current(self) -> float | None:
|
|
@@ -433,7 +436,7 @@ class PropertiesMixin:
|
|
|
433
436
|
@property
|
|
434
437
|
def shaper_max_power(self) -> int | None:
|
|
435
438
|
"""Return shaper live power reading."""
|
|
436
|
-
return self._status.get("shaper_max_pwr", None)
|
|
439
|
+
return cast(int | None, self._status.get("shaper_max_pwr", None))
|
|
437
440
|
|
|
438
441
|
@property
|
|
439
442
|
def shaper_updated(self) -> bool:
|
|
@@ -444,13 +447,17 @@ class PropertiesMixin:
|
|
|
444
447
|
@property
|
|
445
448
|
def vehicle_soc(self) -> int | None:
|
|
446
449
|
"""Return battery level."""
|
|
447
|
-
return
|
|
450
|
+
return cast(
|
|
451
|
+
"int | None",
|
|
452
|
+
self._status.get("vehicle_soc", self._status.get("battery_level", None)),
|
|
453
|
+
)
|
|
448
454
|
|
|
449
455
|
@property
|
|
450
456
|
def vehicle_range(self) -> int | None:
|
|
451
457
|
"""Return battery range."""
|
|
452
|
-
return
|
|
453
|
-
"
|
|
458
|
+
return cast(
|
|
459
|
+
"int | None",
|
|
460
|
+
self._status.get("vehicle_range", self._status.get("battery_range", None)),
|
|
454
461
|
)
|
|
455
462
|
|
|
456
463
|
@property
|
|
@@ -472,12 +479,12 @@ class PropertiesMixin:
|
|
|
472
479
|
@property
|
|
473
480
|
def min_amps(self) -> int:
|
|
474
481
|
"""Return the minimum amps."""
|
|
475
|
-
return self._config.get("min_current_hard", MIN_AMPS)
|
|
482
|
+
return int(self._config.get("min_current_hard", MIN_AMPS))
|
|
476
483
|
|
|
477
484
|
@property
|
|
478
485
|
def max_amps(self) -> int:
|
|
479
486
|
"""Return the maximum amps."""
|
|
480
|
-
return self._config.get("max_current_hard", MAX_AMPS)
|
|
487
|
+
return int(self._config.get("max_current_hard", MAX_AMPS))
|
|
481
488
|
|
|
482
489
|
@property
|
|
483
490
|
def mqtt_connected(self) -> bool:
|
|
@@ -487,29 +494,29 @@ class PropertiesMixin:
|
|
|
487
494
|
@property
|
|
488
495
|
def emoncms_connected(self) -> bool | None:
|
|
489
496
|
"""Return the status of the emoncms connection."""
|
|
490
|
-
return self._status.get("emoncms_connected", None)
|
|
497
|
+
return cast(bool | None, self._status.get("emoncms_connected", None))
|
|
491
498
|
|
|
492
499
|
@property
|
|
493
500
|
def ocpp_connected(self) -> bool | None:
|
|
494
501
|
"""Return the status of the ocpp connection."""
|
|
495
|
-
return self._status.get("ocpp_connected", None)
|
|
502
|
+
return cast(bool | None, self._status.get("ocpp_connected", None))
|
|
496
503
|
|
|
497
504
|
@property
|
|
498
505
|
def uptime(self) -> int | None:
|
|
499
506
|
"""Return the unit uptime."""
|
|
500
|
-
return self._status.get("uptime", None)
|
|
507
|
+
return cast(int | None, self._status.get("uptime", None))
|
|
501
508
|
|
|
502
509
|
@property
|
|
503
510
|
def freeram(self) -> int | None:
|
|
504
511
|
"""Return the unit freeram."""
|
|
505
|
-
return self._status.get("freeram", None)
|
|
512
|
+
return cast(int | None, self._status.get("freeram", None))
|
|
506
513
|
|
|
507
514
|
# Safety counts
|
|
508
515
|
@property
|
|
509
|
-
def checks_count(self) -> dict:
|
|
516
|
+
def checks_count(self) -> dict[str, Any]:
|
|
510
517
|
"""Return the safety checks counts."""
|
|
511
518
|
attributes = ("gfcicount", "nogndcount", "stuckcount")
|
|
512
|
-
counts = {}
|
|
519
|
+
counts: dict[str, Any] = {}
|
|
513
520
|
if self._status is not None and set(attributes).issubset(self._status.keys()):
|
|
514
521
|
counts["gfcicount"] = self._status["gfcicount"]
|
|
515
522
|
counts["nogndcount"] = self._status["nogndcount"]
|
|
@@ -534,4 +541,4 @@ class PropertiesMixin:
|
|
|
534
541
|
if not self._version_check("4.2.2"):
|
|
535
542
|
_LOGGER.debug("Feature not supported for older firmware.")
|
|
536
543
|
raise UnsupportedFeature
|
|
537
|
-
return self._status.get("power", 0)
|
|
544
|
+
return int(self._status.get("power", 0))
|
openevsehttp/py.typed
ADDED
|
File without changes
|
openevsehttp/sensors.py
CHANGED
|
@@ -23,7 +23,7 @@ class SensorsMixin:
|
|
|
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]:
|
openevsehttp/websocket.py
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
"""Websocket class for OpenEVSE HTTP."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import asyncio
|
|
4
6
|
import datetime
|
|
5
7
|
import inspect
|
|
6
8
|
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any
|
|
7
11
|
|
|
8
12
|
import aiohttp
|
|
9
13
|
|
|
14
|
+
from .const import (
|
|
15
|
+
ERROR_SESSION_LOOP_MISMATCH,
|
|
16
|
+
ERROR_SESSION_REQUIRED,
|
|
17
|
+
)
|
|
18
|
+
|
|
10
19
|
_LOGGER = logging.getLogger(__name__)
|
|
11
20
|
|
|
12
21
|
MAX_FAILED_ATTEMPTS = 5
|
|
@@ -28,35 +37,34 @@ class OpenEVSEWebsocket:
|
|
|
28
37
|
|
|
29
38
|
def __init__(
|
|
30
39
|
self,
|
|
31
|
-
server,
|
|
32
|
-
callback,
|
|
33
|
-
user=None,
|
|
34
|
-
password=None,
|
|
40
|
+
server: str,
|
|
41
|
+
callback: Callable[[str, Any, Any], Any] | None,
|
|
42
|
+
user: str | None = None,
|
|
43
|
+
password: str | None = None,
|
|
35
44
|
session: aiohttp.ClientSession | None = None,
|
|
36
|
-
):
|
|
45
|
+
) -> None:
|
|
37
46
|
"""Initialize a OpenEVSEWebsocket instance."""
|
|
38
47
|
self.session = session
|
|
39
|
-
self._session_external = session is not None
|
|
40
48
|
self.uri = self._get_uri(server)
|
|
41
49
|
self._user = user
|
|
42
50
|
self._password = password
|
|
43
51
|
self.callback = callback
|
|
44
52
|
self._state = STATE_DISCONNECTED
|
|
45
53
|
self.failed_attempts = 0
|
|
46
|
-
self._error_reason = None
|
|
47
|
-
self._client = None
|
|
48
|
-
self._ping = None
|
|
49
|
-
self._pong = None
|
|
50
|
-
self._tasks: set[asyncio.Task] = set()
|
|
54
|
+
self._error_reason: Any = None
|
|
55
|
+
self._client: aiohttp.ClientWebSocketResponse | None = None
|
|
56
|
+
self._ping: datetime.datetime | None = None
|
|
57
|
+
self._pong: datetime.datetime | None = None
|
|
58
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
51
59
|
self._listener_loop: asyncio.AbstractEventLoop | None = None
|
|
52
60
|
|
|
53
61
|
@property
|
|
54
|
-
def state(self):
|
|
62
|
+
def state(self) -> str:
|
|
55
63
|
"""Return the current state."""
|
|
56
64
|
return self._state
|
|
57
65
|
|
|
58
66
|
@state.setter
|
|
59
|
-
def state(self, value):
|
|
67
|
+
def state(self, value: str) -> None:
|
|
60
68
|
"""Setter that schedules the callback."""
|
|
61
69
|
self._state = value
|
|
62
70
|
_LOGGER.debug("Websocket %s", value)
|
|
@@ -76,21 +84,16 @@ class OpenEVSEWebsocket:
|
|
|
76
84
|
if self._listener_loop:
|
|
77
85
|
self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
|
|
78
86
|
else:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
task.add_done_callback(self._tasks.discard)
|
|
83
|
-
except RuntimeError:
|
|
84
|
-
# Fallback to get_event_loop if ensure_future fails and no _listener_loop
|
|
85
|
-
loop = asyncio.get_event_loop()
|
|
86
|
-
loop.call_soon_threadsafe(self._schedule_task, coro)
|
|
87
|
+
task = asyncio.ensure_future(coro)
|
|
88
|
+
self._tasks.add(task)
|
|
89
|
+
task.add_done_callback(self._tasks.discard)
|
|
87
90
|
except RuntimeError:
|
|
88
91
|
_LOGGER.error("Failed to schedule callback from sync context: %s", coro)
|
|
89
92
|
if hasattr(coro, "close"):
|
|
90
93
|
coro.close()
|
|
91
94
|
self._error_reason = None
|
|
92
95
|
|
|
93
|
-
def _schedule_task(self, coro):
|
|
96
|
+
def _schedule_task(self, coro: Awaitable[Any]) -> None:
|
|
94
97
|
"""Schedule a task from a thread-safe context."""
|
|
95
98
|
try:
|
|
96
99
|
task = asyncio.ensure_future(coro)
|
|
@@ -103,7 +106,7 @@ class OpenEVSEWebsocket:
|
|
|
103
106
|
if hasattr(coro, "close"):
|
|
104
107
|
coro.close()
|
|
105
108
|
|
|
106
|
-
async def _set_state(self, value):
|
|
109
|
+
async def _set_state(self, value: str) -> None:
|
|
107
110
|
"""Async helper to set the state and await the callback."""
|
|
108
111
|
self._state = value
|
|
109
112
|
_LOGGER.debug("Websocket %s", value)
|
|
@@ -114,11 +117,11 @@ class OpenEVSEWebsocket:
|
|
|
114
117
|
self._error_reason = None
|
|
115
118
|
|
|
116
119
|
@staticmethod
|
|
117
|
-
def _get_uri(server):
|
|
120
|
+
def _get_uri(server: str) -> str:
|
|
118
121
|
"""Generate the websocket URI."""
|
|
119
122
|
return server[: server.rfind("/")].replace("http", "ws") + "/ws"
|
|
120
123
|
|
|
121
|
-
async def running(self):
|
|
124
|
+
async def running(self) -> None:
|
|
122
125
|
"""Open a persistent websocket connection and act on events."""
|
|
123
126
|
await self._ensure_session()
|
|
124
127
|
await self._set_state(STATE_STARTING)
|
|
@@ -128,6 +131,8 @@ class OpenEVSEWebsocket:
|
|
|
128
131
|
auth = aiohttp.BasicAuth(self._user, self._password)
|
|
129
132
|
|
|
130
133
|
try:
|
|
134
|
+
# Narrow type for mypy since _ensure_session sets self.session
|
|
135
|
+
assert self.session is not None
|
|
131
136
|
async with self.session.ws_connect(
|
|
132
137
|
self.uri,
|
|
133
138
|
heartbeat=15,
|
|
@@ -156,7 +161,9 @@ class OpenEVSEWebsocket:
|
|
|
156
161
|
await self._client.close()
|
|
157
162
|
self._client = None
|
|
158
163
|
|
|
159
|
-
async def _handle_messages(
|
|
164
|
+
async def _handle_messages(
|
|
165
|
+
self, ws_client: aiohttp.ClientWebSocketResponse
|
|
166
|
+
) -> None:
|
|
160
167
|
"""Handle incoming websocket messages."""
|
|
161
168
|
async for message in ws_client:
|
|
162
169
|
if self.state == STATE_STOPPED:
|
|
@@ -183,7 +190,7 @@ class OpenEVSEWebsocket:
|
|
|
183
190
|
_LOGGER.error("Websocket error")
|
|
184
191
|
break
|
|
185
192
|
|
|
186
|
-
async def _handle_response_error(self, error):
|
|
193
|
+
async def _handle_response_error(self, error: aiohttp.ClientResponseError) -> None:
|
|
187
194
|
"""Handle ClientResponseError."""
|
|
188
195
|
if error.status == 401:
|
|
189
196
|
_LOGGER.error("Credentials rejected: %s", error)
|
|
@@ -193,7 +200,7 @@ class OpenEVSEWebsocket:
|
|
|
193
200
|
self._error_reason = error
|
|
194
201
|
await self._set_state(STATE_STOPPED)
|
|
195
202
|
|
|
196
|
-
async def _handle_connection_error(self, error):
|
|
203
|
+
async def _handle_connection_error(self, error: BaseException) -> None:
|
|
197
204
|
"""Handle connection errors."""
|
|
198
205
|
self.failed_attempts += 1
|
|
199
206
|
if self.failed_attempts > MAX_FAILED_ATTEMPTS:
|
|
@@ -209,7 +216,7 @@ class OpenEVSEWebsocket:
|
|
|
209
216
|
await self._set_state(STATE_DISCONNECTED)
|
|
210
217
|
await asyncio.sleep(retry_delay)
|
|
211
218
|
|
|
212
|
-
async def listen(self):
|
|
219
|
+
async def listen(self) -> None:
|
|
213
220
|
"""Start the listening websocket."""
|
|
214
221
|
await self._ensure_session()
|
|
215
222
|
self.failed_attempts = 0
|
|
@@ -220,13 +227,20 @@ class OpenEVSEWebsocket:
|
|
|
220
227
|
finally:
|
|
221
228
|
self._listener_loop = None
|
|
222
229
|
|
|
223
|
-
async def _ensure_session(self):
|
|
224
|
-
"""Ensure aiohttp.ClientSession exists."""
|
|
230
|
+
async def _ensure_session(self) -> None:
|
|
231
|
+
"""Ensure an external aiohttp.ClientSession exists."""
|
|
225
232
|
if self.session is None:
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
raise RuntimeError(ERROR_SESSION_REQUIRED)
|
|
234
|
+
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
session_loop = getattr(self.session, "_loop", None)
|
|
237
|
+
if (
|
|
238
|
+
isinstance(session_loop, asyncio.AbstractEventLoop)
|
|
239
|
+
and session_loop is not loop
|
|
240
|
+
):
|
|
241
|
+
raise RuntimeError(ERROR_SESSION_LOOP_MISMATCH)
|
|
228
242
|
|
|
229
|
-
async def close(self):
|
|
243
|
+
async def close(self) -> None:
|
|
230
244
|
"""Close the listening websocket."""
|
|
231
245
|
await self._set_state(STATE_STOPPED)
|
|
232
246
|
|
|
@@ -239,12 +253,8 @@ class OpenEVSEWebsocket:
|
|
|
239
253
|
if self._client is not None:
|
|
240
254
|
await self._client.close()
|
|
241
255
|
self._client = None
|
|
242
|
-
# Only close the session if we created it
|
|
243
|
-
if not self._session_external and self.session is not None:
|
|
244
|
-
await self.session.close()
|
|
245
|
-
self.session = None
|
|
246
256
|
|
|
247
|
-
async def keepalive(self):
|
|
257
|
+
async def keepalive(self) -> None:
|
|
248
258
|
"""Send ping requests to websocket."""
|
|
249
259
|
if self._ping and self._pong:
|
|
250
260
|
time_delta = self._pong - self._ping
|
|
@@ -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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
|
|
2
|
+
openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
|
|
3
|
+
openevsehttp/client.py,sha256=tw8MGwraUVn5ERbQCR6JRWEvMRc7KnUgJvf_0viOQHo,18983
|
|
4
|
+
openevsehttp/commands.py,sha256=JGxjmGvE2-eUJ1gD76y_701Qk3Kzab-6XtGeaXXDSb0,27243
|
|
5
|
+
openevsehttp/const.py,sha256=9jVzW4CZz7uP7VKbGQT4v0jNeP5PPC55tR-jnG11ODA,1646
|
|
6
|
+
openevsehttp/exceptions.py,sha256=bqz-tHTW1AYJMKcm0s5M6z5tA6XZgjnCiBLW1XrZ_70,672
|
|
7
|
+
openevsehttp/managers.py,sha256=EtQMQziwhoZeqKe2zWY-0yS7zedhuYjYtL5j9xBBCZ0,5380
|
|
8
|
+
openevsehttp/properties.py,sha256=psGGiRacHYs1YYmagiWSlpfua-SnpU2nCviEYDZX0V8,17892
|
|
9
|
+
openevsehttp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
openevsehttp/sensors.py,sha256=yO4Q1sgJkvmfOi5dpoTwEoldsrpqgGz0k9fC7mFWass,4680
|
|
11
|
+
openevsehttp/utils.py,sha256=e3HH_jwZgb1iBWJgIoMOM0JPrQNwXyVdOx5vTWOh4T0,858
|
|
12
|
+
openevsehttp/websocket.py,sha256=dOSemvm7CM5pFb9RaoJSTIM6Tf5xtWMLEBEccKlBUKI,10517
|
|
13
|
+
python_openevse_http-1.0.0.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
|
|
14
|
+
python_openevse_http-1.0.0.dist-info/METADATA,sha256=fYgJfqxD6zmc8b540T6jStiE1AKNUmkqSC2vmHHHRjo,4417
|
|
15
|
+
python_openevse_http-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
python_openevse_http-1.0.0.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
|
|
17
|
+
python_openevse_http-1.0.0.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
openevsehttp/__init__.py,sha256=I6a1mjOZHYiWb_qfCuDuFLOOncrkkB_7uwybtOIujfY,1165
|
|
2
|
-
openevsehttp/__main__.py,sha256=EHmSdT7GjAVvHQxvLBTjZXsj_V5SB6B2_kpgUAT7mPM,146
|
|
3
|
-
openevsehttp/client.py,sha256=2SGL0RKZp08t_hGXHKIIRGk8wyrNJRcSXQE7nc0P9UU,17951
|
|
4
|
-
openevsehttp/commands.py,sha256=vZFzNFg6-DqbpavTulypXOGCDwPChvBzoxIHXBlJrBc,27216
|
|
5
|
-
openevsehttp/const.py,sha256=y-2hGv_PCal_-VCSGC7IIyzQYtfeVdq3MjOhBWIdZvc,1440
|
|
6
|
-
openevsehttp/exceptions.py,sha256=bqz-tHTW1AYJMKcm0s5M6z5tA6XZgjnCiBLW1XrZ_70,672
|
|
7
|
-
openevsehttp/managers.py,sha256=kEX1ZD9u-FY0UEZJsxeFEGBSGzSlkbBc0kmxCiMJtJw,5373
|
|
8
|
-
openevsehttp/properties.py,sha256=9fmJo6xU8im-Dd2QoH8f2E-0veD8uL1QQYiaERvIRr8,17430
|
|
9
|
-
openevsehttp/sensors.py,sha256=sJP2FPnU1Lk5S3VUyFT14JM1nKEBQPsjl-DiZI-pZrs,4673
|
|
10
|
-
openevsehttp/utils.py,sha256=e3HH_jwZgb1iBWJgIoMOM0JPrQNwXyVdOx5vTWOh4T0,858
|
|
11
|
-
openevsehttp/websocket.py,sha256=Mi_WFmlT3-9i6bbHIN6ua09SD8CpIle2vRXB3HyWzmM,10066
|
|
12
|
-
python_openevse_http-0.4.3.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
|
|
13
|
-
python_openevse_http-0.4.3.dist-info/METADATA,sha256=ThGhSK3mu1cGbRBLb4a5El7qhUNERBu6rC9tjdiHd20,4363
|
|
14
|
-
python_openevse_http-0.4.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
-
python_openevse_http-0.4.3.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
|
|
16
|
-
python_openevse_http-0.4.3.dist-info/RECORD,,
|
|
File without changes
|
{python_openevse_http-0.4.3.dist-info → python_openevse_http-1.0.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|