python-openevse-http 0.4.3__py3-none-any.whl → 0.4.4__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 +64 -43
- openevsehttp/commands.py +8 -8
- openevsehttp/managers.py +1 -1
- openevsehttp/properties.py +37 -30
- openevsehttp/py.typed +0 -0
- openevsehttp/sensors.py +1 -1
- openevsehttp/websocket.py +34 -31
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-0.4.4.dist-info}/METADATA +3 -3
- python_openevse_http-0.4.4.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-0.4.4.dist-info}/WHEEL +0 -0
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-0.4.4.dist-info}/licenses/LICENSE +0 -0
- {python_openevse_http-0.4.3.dist-info → python_openevse_http-0.4.4.dist-info}/top_level.txt +0 -0
openevsehttp/client.py
CHANGED
|
@@ -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
|
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,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:
|
|
@@ -382,7 +382,7 @@ class CommandsMixin:
|
|
|
382
382
|
_LOGGER.debug("EVSE Restart response: %s", response)
|
|
383
383
|
|
|
384
384
|
# Firmware version
|
|
385
|
-
async def firmware_check(self) -> dict | None:
|
|
385
|
+
async def firmware_check(self) -> dict[str, Any] | None:
|
|
386
386
|
"""Return the latest firmware version."""
|
|
387
387
|
if "version" not in self._config:
|
|
388
388
|
# Throw warning if we can't find the version
|
|
@@ -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/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,9 +1,13 @@
|
|
|
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
|
|
|
@@ -28,12 +32,12 @@ class OpenEVSEWebsocket:
|
|
|
28
32
|
|
|
29
33
|
def __init__(
|
|
30
34
|
self,
|
|
31
|
-
server,
|
|
32
|
-
callback,
|
|
33
|
-
user=None,
|
|
34
|
-
password=None,
|
|
35
|
+
server: str,
|
|
36
|
+
callback: Callable[[str, Any, Any], Any] | None,
|
|
37
|
+
user: str | None = None,
|
|
38
|
+
password: str | None = None,
|
|
35
39
|
session: aiohttp.ClientSession | None = None,
|
|
36
|
-
):
|
|
40
|
+
) -> None:
|
|
37
41
|
"""Initialize a OpenEVSEWebsocket instance."""
|
|
38
42
|
self.session = session
|
|
39
43
|
self._session_external = session is not None
|
|
@@ -43,20 +47,20 @@ class OpenEVSEWebsocket:
|
|
|
43
47
|
self.callback = callback
|
|
44
48
|
self._state = STATE_DISCONNECTED
|
|
45
49
|
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()
|
|
50
|
+
self._error_reason: Any = None
|
|
51
|
+
self._client: aiohttp.ClientWebSocketResponse | None = None
|
|
52
|
+
self._ping: datetime.datetime | None = None
|
|
53
|
+
self._pong: datetime.datetime | None = None
|
|
54
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
51
55
|
self._listener_loop: asyncio.AbstractEventLoop | None = None
|
|
52
56
|
|
|
53
57
|
@property
|
|
54
|
-
def state(self):
|
|
58
|
+
def state(self) -> str:
|
|
55
59
|
"""Return the current state."""
|
|
56
60
|
return self._state
|
|
57
61
|
|
|
58
62
|
@state.setter
|
|
59
|
-
def state(self, value):
|
|
63
|
+
def state(self, value: str) -> None:
|
|
60
64
|
"""Setter that schedules the callback."""
|
|
61
65
|
self._state = value
|
|
62
66
|
_LOGGER.debug("Websocket %s", value)
|
|
@@ -76,21 +80,16 @@ class OpenEVSEWebsocket:
|
|
|
76
80
|
if self._listener_loop:
|
|
77
81
|
self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
|
|
78
82
|
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)
|
|
83
|
+
task = asyncio.ensure_future(coro)
|
|
84
|
+
self._tasks.add(task)
|
|
85
|
+
task.add_done_callback(self._tasks.discard)
|
|
87
86
|
except RuntimeError:
|
|
88
87
|
_LOGGER.error("Failed to schedule callback from sync context: %s", coro)
|
|
89
88
|
if hasattr(coro, "close"):
|
|
90
89
|
coro.close()
|
|
91
90
|
self._error_reason = None
|
|
92
91
|
|
|
93
|
-
def _schedule_task(self, coro):
|
|
92
|
+
def _schedule_task(self, coro: Awaitable[Any]) -> None:
|
|
94
93
|
"""Schedule a task from a thread-safe context."""
|
|
95
94
|
try:
|
|
96
95
|
task = asyncio.ensure_future(coro)
|
|
@@ -103,7 +102,7 @@ class OpenEVSEWebsocket:
|
|
|
103
102
|
if hasattr(coro, "close"):
|
|
104
103
|
coro.close()
|
|
105
104
|
|
|
106
|
-
async def _set_state(self, value):
|
|
105
|
+
async def _set_state(self, value: str) -> None:
|
|
107
106
|
"""Async helper to set the state and await the callback."""
|
|
108
107
|
self._state = value
|
|
109
108
|
_LOGGER.debug("Websocket %s", value)
|
|
@@ -114,11 +113,11 @@ class OpenEVSEWebsocket:
|
|
|
114
113
|
self._error_reason = None
|
|
115
114
|
|
|
116
115
|
@staticmethod
|
|
117
|
-
def _get_uri(server):
|
|
116
|
+
def _get_uri(server: str) -> str:
|
|
118
117
|
"""Generate the websocket URI."""
|
|
119
118
|
return server[: server.rfind("/")].replace("http", "ws") + "/ws"
|
|
120
119
|
|
|
121
|
-
async def running(self):
|
|
120
|
+
async def running(self) -> None:
|
|
122
121
|
"""Open a persistent websocket connection and act on events."""
|
|
123
122
|
await self._ensure_session()
|
|
124
123
|
await self._set_state(STATE_STARTING)
|
|
@@ -128,6 +127,8 @@ class OpenEVSEWebsocket:
|
|
|
128
127
|
auth = aiohttp.BasicAuth(self._user, self._password)
|
|
129
128
|
|
|
130
129
|
try:
|
|
130
|
+
# Narrow type for mypy since _ensure_session sets self.session
|
|
131
|
+
assert self.session is not None
|
|
131
132
|
async with self.session.ws_connect(
|
|
132
133
|
self.uri,
|
|
133
134
|
heartbeat=15,
|
|
@@ -156,7 +157,9 @@ class OpenEVSEWebsocket:
|
|
|
156
157
|
await self._client.close()
|
|
157
158
|
self._client = None
|
|
158
159
|
|
|
159
|
-
async def _handle_messages(
|
|
160
|
+
async def _handle_messages(
|
|
161
|
+
self, ws_client: aiohttp.ClientWebSocketResponse
|
|
162
|
+
) -> None:
|
|
160
163
|
"""Handle incoming websocket messages."""
|
|
161
164
|
async for message in ws_client:
|
|
162
165
|
if self.state == STATE_STOPPED:
|
|
@@ -183,7 +186,7 @@ class OpenEVSEWebsocket:
|
|
|
183
186
|
_LOGGER.error("Websocket error")
|
|
184
187
|
break
|
|
185
188
|
|
|
186
|
-
async def _handle_response_error(self, error):
|
|
189
|
+
async def _handle_response_error(self, error: aiohttp.ClientResponseError) -> None:
|
|
187
190
|
"""Handle ClientResponseError."""
|
|
188
191
|
if error.status == 401:
|
|
189
192
|
_LOGGER.error("Credentials rejected: %s", error)
|
|
@@ -193,7 +196,7 @@ class OpenEVSEWebsocket:
|
|
|
193
196
|
self._error_reason = error
|
|
194
197
|
await self._set_state(STATE_STOPPED)
|
|
195
198
|
|
|
196
|
-
async def _handle_connection_error(self, error):
|
|
199
|
+
async def _handle_connection_error(self, error: BaseException) -> None:
|
|
197
200
|
"""Handle connection errors."""
|
|
198
201
|
self.failed_attempts += 1
|
|
199
202
|
if self.failed_attempts > MAX_FAILED_ATTEMPTS:
|
|
@@ -209,7 +212,7 @@ class OpenEVSEWebsocket:
|
|
|
209
212
|
await self._set_state(STATE_DISCONNECTED)
|
|
210
213
|
await asyncio.sleep(retry_delay)
|
|
211
214
|
|
|
212
|
-
async def listen(self):
|
|
215
|
+
async def listen(self) -> None:
|
|
213
216
|
"""Start the listening websocket."""
|
|
214
217
|
await self._ensure_session()
|
|
215
218
|
self.failed_attempts = 0
|
|
@@ -220,13 +223,13 @@ class OpenEVSEWebsocket:
|
|
|
220
223
|
finally:
|
|
221
224
|
self._listener_loop = None
|
|
222
225
|
|
|
223
|
-
async def _ensure_session(self):
|
|
226
|
+
async def _ensure_session(self) -> None:
|
|
224
227
|
"""Ensure aiohttp.ClientSession exists."""
|
|
225
228
|
if self.session is None:
|
|
226
229
|
self.session = aiohttp.ClientSession()
|
|
227
230
|
self._session_external = False
|
|
228
231
|
|
|
229
|
-
async def close(self):
|
|
232
|
+
async def close(self) -> None:
|
|
230
233
|
"""Close the listening websocket."""
|
|
231
234
|
await self._set_state(STATE_STOPPED)
|
|
232
235
|
|
|
@@ -244,7 +247,7 @@ class OpenEVSEWebsocket:
|
|
|
244
247
|
await self.session.close()
|
|
245
248
|
self.session = None
|
|
246
249
|
|
|
247
|
-
async def keepalive(self):
|
|
250
|
+
async def keepalive(self) -> None:
|
|
248
251
|
"""Send ping requests to websocket."""
|
|
249
252
|
if self._ping and self._pong:
|
|
250
253
|
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.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
|
|
@@ -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=8wd56AXEkEanRFWUPkmSDPUY8Zr7oHuNpg3_89m9iAM,18998
|
|
4
|
+
openevsehttp/commands.py,sha256=X40x1UaXVfPEiquw7mJGSVA3AbO9gQ4Ay0TLm9i1Iqk,27264
|
|
5
|
+
openevsehttp/const.py,sha256=y-2hGv_PCal_-VCSGC7IIyzQYtfeVdq3MjOhBWIdZvc,1440
|
|
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=TIMWzkRDs_9EUm3HAOaLWmZEX72BqHPhfDb9aIkz3uY,10409
|
|
13
|
+
python_openevse_http-0.4.4.dist-info/licenses/LICENSE,sha256=hSB6TOQ7rmwSGb6XzqRjDGMvmUj5_GlacqQin3tegtA,11341
|
|
14
|
+
python_openevse_http-0.4.4.dist-info/METADATA,sha256=RNnswPvgDpWIY5ehHia-XDxl4EGhZe1yIRquF0ZQP8M,4383
|
|
15
|
+
python_openevse_http-0.4.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
python_openevse_http-0.4.4.dist-info/top_level.txt,sha256=u8RUkoEIE33Cjn6gmqiEoVpZ0VZ59WJ3FXBwwOg0CPE,13
|
|
17
|
+
python_openevse_http-0.4.4.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-0.4.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|