python-openevse-http 0.2.5__tar.gz → 0.3.0b0__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.2.5/python_openevse_http.egg-info → python_openevse_http-0.3.0b0}/PKG-INFO +3 -5
- python_openevse_http-0.3.0b0/openevsehttp/__init__.py +58 -0
- python_openevse_http-0.3.0b0/openevsehttp/__main__.py +4 -0
- python_openevse_http-0.3.0b0/openevsehttp/client.py +493 -0
- python_openevse_http-0.3.0b0/openevsehttp/commands.py +493 -0
- python_openevse_http-0.3.0b0/openevsehttp/const.py +62 -0
- python_openevse_http-0.3.0b0/openevsehttp/managers.py +157 -0
- python_openevse_http-0.3.0b0/openevsehttp/properties.py +528 -0
- python_openevse_http-0.3.0b0/openevsehttp/sensors.py +137 -0
- python_openevse_http-0.3.0b0/openevsehttp/websocket.py +275 -0
- python_openevse_http-0.3.0b0/pyproject.toml +32 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0/python_openevse_http.egg-info}/PKG-INFO +3 -5
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/SOURCES.txt +12 -1
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/setup.py +3 -5
- python_openevse_http-0.3.0b0/tests/test_client.py +1653 -0
- python_openevse_http-0.3.0b0/tests/test_commands.py +994 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/tests/test_external_session.py +17 -6
- python_openevse_http-0.3.0b0/tests/test_managers.py +356 -0
- python_openevse_http-0.3.0b0/tests/test_mixins.py +58 -0
- python_openevse_http-0.3.0b0/tests/test_properties.py +1215 -0
- python_openevse_http-0.3.0b0/tests/test_sensors.py +154 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/tests/test_websocket.py +253 -24
- python_openevse_http-0.2.5/openevsehttp/__init__.py +0 -1
- python_openevse_http-0.2.5/openevsehttp/__main__.py +0 -1448
- python_openevse_http-0.2.5/openevsehttp/const.py +0 -18
- python_openevse_http-0.2.5/openevsehttp/websocket.py +0 -196
- python_openevse_http-0.2.5/tests/test_main.py +0 -3250
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/LICENSE +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/README.md +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/openevsehttp/exceptions.py +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/dependency_links.txt +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/not-zip-safe +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/requires.txt +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/python_openevse_http.egg-info/top_level.txt +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/setup.cfg +0 -0
- {python_openevse_http-0.2.5 → python_openevse_http-0.3.0b0}/tests/test_main_edge_cases.py +0 -0
{python_openevse_http-0.2.5/python_openevse_http.egg-info → python_openevse_http-0.3.0b0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python_openevse_http
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0b0
|
|
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
|
|
@@ -11,12 +11,10 @@ Classifier: Development Status :: 4 - Beta
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Natural Language :: English
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
16
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
19
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.13
|
|
20
18
|
Description-Content-Type: text/markdown
|
|
21
19
|
License-File: LICENSE
|
|
22
20
|
Requires-Dist: aiohttp
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Provide a package for python-openevse-http."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F401
|
|
4
|
+
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
|
|
5
|
+
|
|
6
|
+
from .client import (
|
|
7
|
+
OpenEVSE,
|
|
8
|
+
)
|
|
9
|
+
from .const import (
|
|
10
|
+
ERROR_TIMEOUT,
|
|
11
|
+
INFO_LOOP_RUNNING,
|
|
12
|
+
UPDATE_TRIGGERS,
|
|
13
|
+
divert_mode,
|
|
14
|
+
states,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
AlreadyListening,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
InvalidType,
|
|
20
|
+
MissingMethod,
|
|
21
|
+
MissingSerial,
|
|
22
|
+
ParseJSONError,
|
|
23
|
+
UnknownError,
|
|
24
|
+
UnsupportedFeature,
|
|
25
|
+
)
|
|
26
|
+
from .websocket import (
|
|
27
|
+
SIGNAL_CONNECTION_STATE,
|
|
28
|
+
STATE_CONNECTED,
|
|
29
|
+
STATE_DISCONNECTED,
|
|
30
|
+
STATE_STARTING,
|
|
31
|
+
STATE_STOPPED,
|
|
32
|
+
OpenEVSEWebsocket,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ERROR_TIMEOUT",
|
|
37
|
+
"INFO_LOOP_RUNNING",
|
|
38
|
+
"SIGNAL_CONNECTION_STATE",
|
|
39
|
+
"STATE_CONNECTED",
|
|
40
|
+
"STATE_DISCONNECTED",
|
|
41
|
+
"STATE_STARTING",
|
|
42
|
+
"STATE_STOPPED",
|
|
43
|
+
"UPDATE_TRIGGERS",
|
|
44
|
+
"AlreadyListening",
|
|
45
|
+
"AuthenticationError",
|
|
46
|
+
"ContentTypeError",
|
|
47
|
+
"InvalidType",
|
|
48
|
+
"MissingMethod",
|
|
49
|
+
"MissingSerial",
|
|
50
|
+
"OpenEVSE",
|
|
51
|
+
"OpenEVSEWebsocket",
|
|
52
|
+
"ParseJSONError",
|
|
53
|
+
"ServerTimeoutError",
|
|
54
|
+
"UnknownError",
|
|
55
|
+
"UnsupportedFeature",
|
|
56
|
+
"divert_mode",
|
|
57
|
+
"states",
|
|
58
|
+
]
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Core client class for python-openevse-http."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import aiohttp # type: ignore
|
|
15
|
+
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
|
|
16
|
+
from awesomeversion import AwesomeVersion
|
|
17
|
+
from awesomeversion.exceptions import AwesomeVersionCompareException
|
|
18
|
+
|
|
19
|
+
from .commands import CommandsMixin
|
|
20
|
+
from .const import (
|
|
21
|
+
ERROR_TIMEOUT,
|
|
22
|
+
UPDATE_TRIGGERS,
|
|
23
|
+
)
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
AlreadyListening,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
MissingMethod,
|
|
28
|
+
MissingSerial,
|
|
29
|
+
ParseJSONError,
|
|
30
|
+
)
|
|
31
|
+
from .managers import ManagersMixin
|
|
32
|
+
from .properties import PropertiesMixin
|
|
33
|
+
from .sensors import SensorsMixin
|
|
34
|
+
from .websocket import (
|
|
35
|
+
SIGNAL_CONNECTION_STATE,
|
|
36
|
+
STATE_CONNECTED,
|
|
37
|
+
STATE_DISCONNECTED,
|
|
38
|
+
STATE_STOPPED,
|
|
39
|
+
OpenEVSEWebsocket,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_LOGGER = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
|
|
46
|
+
"""Represent an OpenEVSE charger."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
host: str,
|
|
51
|
+
user: str = "",
|
|
52
|
+
pwd: str = "",
|
|
53
|
+
session: aiohttp.ClientSession | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Connect to an OpenEVSE charger equipped with wifi or ethernet."""
|
|
56
|
+
self._user = user
|
|
57
|
+
self._pwd = pwd
|
|
58
|
+
self.url = f"http://{host}/"
|
|
59
|
+
self._status: dict = {}
|
|
60
|
+
self._config: dict = {}
|
|
61
|
+
self._override = None
|
|
62
|
+
self._ws_listening = False
|
|
63
|
+
self.websocket: OpenEVSEWebsocket | None = None
|
|
64
|
+
self.callback: Callable | None = None
|
|
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
|
|
68
|
+
self._owns_loop = False
|
|
69
|
+
self._loop_thread: threading.Thread | None = None
|
|
70
|
+
self._session = session
|
|
71
|
+
self._session_external = session is not None
|
|
72
|
+
|
|
73
|
+
async def process_request(
|
|
74
|
+
self,
|
|
75
|
+
url: str,
|
|
76
|
+
method: str = "",
|
|
77
|
+
data: Any = None,
|
|
78
|
+
rapi: Any = None,
|
|
79
|
+
) -> Mapping[str, Any] | list[Any] | str:
|
|
80
|
+
"""Return result of processed HTTP request."""
|
|
81
|
+
auth = None
|
|
82
|
+
allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
|
|
83
|
+
if method not in allowed_methods:
|
|
84
|
+
raise MissingMethod
|
|
85
|
+
|
|
86
|
+
if self._user and self._pwd:
|
|
87
|
+
auth = aiohttp.BasicAuth(self._user, self._pwd)
|
|
88
|
+
|
|
89
|
+
# Use provided session or create a temporary one
|
|
90
|
+
if (session := self._session) is None:
|
|
91
|
+
async with aiohttp.ClientSession() as session:
|
|
92
|
+
return await self._process_request_with_session(
|
|
93
|
+
session, url, method, data, rapi, auth
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
return await self._process_request_with_session(
|
|
97
|
+
session, url, method, data, rapi, auth
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
|
|
101
|
+
"""Normalize response to a dict or list."""
|
|
102
|
+
if isinstance(response, dict | list):
|
|
103
|
+
return response
|
|
104
|
+
_LOGGER.debug("Normalizing non-json response: %s", response)
|
|
105
|
+
return {"msg": response}
|
|
106
|
+
|
|
107
|
+
async def _process_request_with_session(
|
|
108
|
+
self,
|
|
109
|
+
session: aiohttp.ClientSession,
|
|
110
|
+
url: str,
|
|
111
|
+
method: str,
|
|
112
|
+
data: Any,
|
|
113
|
+
rapi: Any,
|
|
114
|
+
auth: Any,
|
|
115
|
+
) -> Mapping[str, Any] | list[Any] | str:
|
|
116
|
+
"""Process a request with a given session."""
|
|
117
|
+
if not hasattr(session, method):
|
|
118
|
+
raise MissingMethod
|
|
119
|
+
http_method = getattr(session, method)
|
|
120
|
+
_LOGGER.debug(
|
|
121
|
+
"Connecting to %s with data: %s rapi: %s using method %s",
|
|
122
|
+
url,
|
|
123
|
+
data,
|
|
124
|
+
rapi,
|
|
125
|
+
method,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
kwargs = {"data": rapi, "auth": auth}
|
|
129
|
+
if data is not None:
|
|
130
|
+
kwargs["json"] = data
|
|
131
|
+
async with http_method(url, **kwargs) as resp:
|
|
132
|
+
try:
|
|
133
|
+
message = await resp.text()
|
|
134
|
+
except UnicodeDecodeError:
|
|
135
|
+
_LOGGER.debug("Decoding error")
|
|
136
|
+
message = await resp.read()
|
|
137
|
+
message = message.decode(errors="replace")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
message = json.loads(message)
|
|
141
|
+
except ValueError:
|
|
142
|
+
_LOGGER.warning("Non JSON response: %s", message)
|
|
143
|
+
|
|
144
|
+
if resp.status == 400:
|
|
145
|
+
if isinstance(message, dict) and "msg" in message:
|
|
146
|
+
_LOGGER.error("Error 400: %s", message["msg"])
|
|
147
|
+
elif isinstance(message, dict) and "error" in message:
|
|
148
|
+
_LOGGER.error("Error 400: %s", message["error"])
|
|
149
|
+
else:
|
|
150
|
+
_LOGGER.error("Error 400: %s", message)
|
|
151
|
+
raise ParseJSONError
|
|
152
|
+
if resp.status == 401:
|
|
153
|
+
_LOGGER.error("Authentication error: %s", message)
|
|
154
|
+
raise AuthenticationError
|
|
155
|
+
if resp.status in [404, 405, 500]:
|
|
156
|
+
_LOGGER.warning("%s", message)
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
method.lower() != "get"
|
|
160
|
+
and isinstance(message, dict)
|
|
161
|
+
and any(key in message for key in UPDATE_TRIGGERS)
|
|
162
|
+
):
|
|
163
|
+
await self.update()
|
|
164
|
+
return message
|
|
165
|
+
|
|
166
|
+
except (TimeoutError, ServerTimeoutError):
|
|
167
|
+
_LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
|
|
168
|
+
raise
|
|
169
|
+
except ContentTypeError as err:
|
|
170
|
+
_LOGGER.error("Content error: %s", err.message)
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
async def send_command(self, command: str) -> tuple:
|
|
174
|
+
"""Send a RAPI command to the charger and parses the response."""
|
|
175
|
+
url = f"{self.url}r"
|
|
176
|
+
data = {"json": 1, "rapi": command}
|
|
177
|
+
|
|
178
|
+
_LOGGER.debug("Posting data: %s to %s", command, url)
|
|
179
|
+
value = await self.process_request(url=url, method="post", rapi=data)
|
|
180
|
+
if not isinstance(value, Mapping) or "ret" not in value or "cmd" not in value:
|
|
181
|
+
if isinstance(value, Mapping) and "msg" in value:
|
|
182
|
+
return (False, value["msg"])
|
|
183
|
+
return (False, "")
|
|
184
|
+
return (value["cmd"], value["ret"])
|
|
185
|
+
|
|
186
|
+
async def update(self) -> None:
|
|
187
|
+
"""Update the values."""
|
|
188
|
+
# TODO: add addiontal endpoints to update
|
|
189
|
+
urls = [f"{self.url}config"]
|
|
190
|
+
|
|
191
|
+
if not self._ws_listening:
|
|
192
|
+
urls = [f"{self.url}status", f"{self.url}config"]
|
|
193
|
+
|
|
194
|
+
for url in urls:
|
|
195
|
+
_LOGGER.debug("Updating data from %s", url)
|
|
196
|
+
response = await self.process_request(url, method="get")
|
|
197
|
+
if "/status" in url:
|
|
198
|
+
if isinstance(response, Mapping) and "error" not in response:
|
|
199
|
+
self._status = dict(response)
|
|
200
|
+
_LOGGER.debug("Status update: %s", self._status)
|
|
201
|
+
elif isinstance(response, Mapping):
|
|
202
|
+
_LOGGER.warning(
|
|
203
|
+
"Error in /status response: %s", response.get("error")
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
_LOGGER.warning(
|
|
207
|
+
"Received non-JSON response from /status: %s", response
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
if isinstance(response, Mapping) and "error" not in response:
|
|
212
|
+
self._config = dict(response)
|
|
213
|
+
_LOGGER.debug("Config update: %s", self._config)
|
|
214
|
+
elif isinstance(response, Mapping):
|
|
215
|
+
_LOGGER.warning(
|
|
216
|
+
"Error in /config response: %s", response.get("error")
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
_LOGGER.warning(
|
|
220
|
+
"Received non-JSON response from /config: %s", response
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def test_and_get(self) -> dict:
|
|
224
|
+
"""Test connection.
|
|
225
|
+
|
|
226
|
+
Return model serial number as dict
|
|
227
|
+
"""
|
|
228
|
+
url = f"{self.url}config"
|
|
229
|
+
data = {}
|
|
230
|
+
|
|
231
|
+
response = await self.process_request(url, method="get")
|
|
232
|
+
if not isinstance(response, Mapping):
|
|
233
|
+
_LOGGER.debug("Invalid response from config: %s", response)
|
|
234
|
+
raise MissingSerial
|
|
235
|
+
|
|
236
|
+
if "wifi_serial" in response:
|
|
237
|
+
serial = response["wifi_serial"]
|
|
238
|
+
else:
|
|
239
|
+
_LOGGER.debug("Older firmware detected, missing serial.")
|
|
240
|
+
raise MissingSerial
|
|
241
|
+
if "buildenv" in response:
|
|
242
|
+
model = response["buildenv"]
|
|
243
|
+
else:
|
|
244
|
+
model = "unknown"
|
|
245
|
+
|
|
246
|
+
data = {"serial": serial, "model": model}
|
|
247
|
+
return data
|
|
248
|
+
|
|
249
|
+
def ws_start(self) -> None:
|
|
250
|
+
"""Start the websocket listener."""
|
|
251
|
+
if self.websocket and self.websocket.state != STATE_STOPPED:
|
|
252
|
+
raise AlreadyListening
|
|
253
|
+
|
|
254
|
+
# Detect loop mismatch
|
|
255
|
+
use_session = self._session
|
|
256
|
+
try:
|
|
257
|
+
asyncio.get_running_loop()
|
|
258
|
+
except RuntimeError:
|
|
259
|
+
# We are about to create a private loop in _start_listening
|
|
260
|
+
# If we have a session, it's likely bound to another loop
|
|
261
|
+
if self._session:
|
|
262
|
+
_LOGGER.warning(
|
|
263
|
+
"Caller-provided session may not work on private event loop. "
|
|
264
|
+
"Creating a loop-local session."
|
|
265
|
+
)
|
|
266
|
+
use_session = None
|
|
267
|
+
# Clear self._session so subsequent await self.update() uses
|
|
268
|
+
# a loop-local session as well.
|
|
269
|
+
self._session = None
|
|
270
|
+
self._session_external = False
|
|
271
|
+
|
|
272
|
+
if not self.websocket or self.websocket.state == STATE_STOPPED:
|
|
273
|
+
self.websocket = OpenEVSEWebsocket(
|
|
274
|
+
self.url, self._update_status, self._user, self._pwd, use_session
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self._start_listening()
|
|
278
|
+
|
|
279
|
+
def _start_listening(self):
|
|
280
|
+
"""Start the websocket listener."""
|
|
281
|
+
if not self._loop:
|
|
282
|
+
try:
|
|
283
|
+
_LOGGER.debug("Attempting to find running loop...")
|
|
284
|
+
self._loop = asyncio.get_running_loop()
|
|
285
|
+
except RuntimeError:
|
|
286
|
+
self._loop = asyncio.new_event_loop()
|
|
287
|
+
self._owns_loop = True
|
|
288
|
+
_LOGGER.debug("Using new event loop...")
|
|
289
|
+
|
|
290
|
+
if not self._ws_listening and self.websocket is not None:
|
|
291
|
+
_LOGGER.debug("Setting up websocket tasks...")
|
|
292
|
+
self._ws_listen_task = self._loop.create_task(self.websocket.listen())
|
|
293
|
+
self._ws_keepalive_task = self._loop.create_task(
|
|
294
|
+
self.repeat(300, self.websocket.keepalive)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if self._owns_loop:
|
|
298
|
+
self._loop_thread = threading.Thread(
|
|
299
|
+
target=self._loop.run_forever, daemon=True
|
|
300
|
+
)
|
|
301
|
+
self._loop_thread.start()
|
|
302
|
+
|
|
303
|
+
async def _update_status(self, msgtype, data, error):
|
|
304
|
+
"""Update data from websocket listener."""
|
|
305
|
+
if msgtype == SIGNAL_CONNECTION_STATE:
|
|
306
|
+
uri = self.websocket.uri if self.websocket else "Unknown"
|
|
307
|
+
if data == STATE_CONNECTED:
|
|
308
|
+
_LOGGER.debug("Websocket to %s successful", uri)
|
|
309
|
+
self._ws_listening = True
|
|
310
|
+
elif data == STATE_DISCONNECTED:
|
|
311
|
+
_LOGGER.debug(
|
|
312
|
+
"Websocket to %s disconnected, retrying",
|
|
313
|
+
uri,
|
|
314
|
+
)
|
|
315
|
+
_LOGGER.debug("Disconnect message: %s", error)
|
|
316
|
+
self._ws_listening = False
|
|
317
|
+
|
|
318
|
+
# Stopped websockets without errors are expected during shutdown
|
|
319
|
+
# and ignored
|
|
320
|
+
elif data == STATE_STOPPED and error:
|
|
321
|
+
_LOGGER.debug(
|
|
322
|
+
"Websocket to %s failed, aborting [Error: %s]",
|
|
323
|
+
uri,
|
|
324
|
+
error,
|
|
325
|
+
)
|
|
326
|
+
self._ws_listening = False
|
|
327
|
+
|
|
328
|
+
elif msgtype == "data":
|
|
329
|
+
_LOGGER.debug("Websocket data: %s", data)
|
|
330
|
+
if not isinstance(data, Mapping):
|
|
331
|
+
_LOGGER.warning("Received non-Mapping websocket data: %s", data)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
keys = data.keys()
|
|
335
|
+
if "wh" in keys:
|
|
336
|
+
data["watthour"] = data.pop("wh")
|
|
337
|
+
# TODO: update specific endpoints based on _version prefix
|
|
338
|
+
if any(key in keys for key in UPDATE_TRIGGERS):
|
|
339
|
+
await self.update()
|
|
340
|
+
self._status.update(data)
|
|
341
|
+
|
|
342
|
+
if self.callback is not None:
|
|
343
|
+
result = self.callback() # pylint: disable=not-callable
|
|
344
|
+
if inspect.isawaitable(result):
|
|
345
|
+
await result
|
|
346
|
+
|
|
347
|
+
async def _shutdown(self):
|
|
348
|
+
"""Shutdown the websocket and tasks on the listener loop."""
|
|
349
|
+
tasks = []
|
|
350
|
+
if self._ws_keepalive_task:
|
|
351
|
+
self._ws_keepalive_task.cancel()
|
|
352
|
+
tasks.append(self._ws_keepalive_task)
|
|
353
|
+
if self._ws_listen_task:
|
|
354
|
+
self._ws_listen_task.cancel()
|
|
355
|
+
tasks.append(self._ws_listen_task)
|
|
356
|
+
|
|
357
|
+
if self.websocket:
|
|
358
|
+
# Close the websocket (this cancels running() internal tasks)
|
|
359
|
+
await self.websocket.close()
|
|
360
|
+
|
|
361
|
+
# Cancel any remaining callback tasks
|
|
362
|
+
for task in list(self.websocket._tasks):
|
|
363
|
+
if not task.done():
|
|
364
|
+
task.cancel()
|
|
365
|
+
tasks.append(task)
|
|
366
|
+
self.websocket._tasks.clear()
|
|
367
|
+
|
|
368
|
+
if tasks:
|
|
369
|
+
await asyncio.gather(
|
|
370
|
+
*(t for t in tasks if isinstance(t, asyncio.Future | asyncio.Task)),
|
|
371
|
+
return_exceptions=True,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if self.websocket:
|
|
375
|
+
self.websocket = None
|
|
376
|
+
|
|
377
|
+
self._ws_listen_task = None
|
|
378
|
+
self._ws_keepalive_task = None
|
|
379
|
+
if self._owns_loop and self._loop:
|
|
380
|
+
self._loop.stop()
|
|
381
|
+
|
|
382
|
+
async def ws_disconnect(self) -> None:
|
|
383
|
+
"""Disconnect the websocket listener."""
|
|
384
|
+
self._ws_listening = False
|
|
385
|
+
|
|
386
|
+
if self._owns_loop and self._loop:
|
|
387
|
+
# Schedule shutdown coroutine on the loop thread
|
|
388
|
+
future = asyncio.run_coroutine_threadsafe(self._shutdown(), self._loop)
|
|
389
|
+
shutdown_succeeded = False
|
|
390
|
+
try:
|
|
391
|
+
# Wait for the shutdown to complete on the other loop
|
|
392
|
+
await asyncio.wait_for(asyncio.wrap_future(future), timeout=2.0)
|
|
393
|
+
shutdown_succeeded = True
|
|
394
|
+
except (TimeoutError, asyncio.CancelledError) as err:
|
|
395
|
+
_LOGGER.debug("Error during shutdown coroutine: %s", err)
|
|
396
|
+
|
|
397
|
+
if self._loop_thread:
|
|
398
|
+
await asyncio.to_thread(self._loop_thread.join, 2.0)
|
|
399
|
+
if not self._loop_thread.is_alive():
|
|
400
|
+
self._loop_thread = None
|
|
401
|
+
|
|
402
|
+
if shutdown_succeeded and self._loop_thread is None:
|
|
403
|
+
self._loop.close()
|
|
404
|
+
self._loop = None
|
|
405
|
+
self._owns_loop = False
|
|
406
|
+
else:
|
|
407
|
+
# Standard async disconnect for caller loop
|
|
408
|
+
await self._shutdown()
|
|
409
|
+
|
|
410
|
+
def is_coroutine_function(self, callback):
|
|
411
|
+
"""Check if a callback is a coroutine function."""
|
|
412
|
+
return inspect.iscoroutinefunction(callback)
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def ws_state(self) -> Any | None:
|
|
416
|
+
"""Return the status of the websocket listener."""
|
|
417
|
+
if self.websocket is None:
|
|
418
|
+
return STATE_STOPPED
|
|
419
|
+
return self.websocket.state
|
|
420
|
+
|
|
421
|
+
async def repeat(self, interval, func, *args, **kwargs):
|
|
422
|
+
"""Run func every interval seconds.
|
|
423
|
+
|
|
424
|
+
If func has not finished before *interval*, will run again
|
|
425
|
+
immediately when the previous iteration finished.
|
|
426
|
+
|
|
427
|
+
*args and **kwargs are passed as the arguments to func.
|
|
428
|
+
"""
|
|
429
|
+
while self.ws_state != STATE_STOPPED and self._ws_listening:
|
|
430
|
+
await asyncio.sleep(interval)
|
|
431
|
+
if self.ws_state == STATE_STOPPED or not self._ws_listening:
|
|
432
|
+
break
|
|
433
|
+
result = func(*args, **kwargs)
|
|
434
|
+
if inspect.isawaitable(result):
|
|
435
|
+
await result
|
|
436
|
+
|
|
437
|
+
def _version_check(self, min_version: str, max_version: str = "") -> bool:
|
|
438
|
+
"""Return bool if minimum version is met."""
|
|
439
|
+
if "version" not in self._config:
|
|
440
|
+
# Throw warning if we can't find the version
|
|
441
|
+
_LOGGER.warning("Unable to find firmware version.")
|
|
442
|
+
return False
|
|
443
|
+
cutoff = AwesomeVersion(min_version)
|
|
444
|
+
current = ""
|
|
445
|
+
limit = ""
|
|
446
|
+
if max_version != "":
|
|
447
|
+
limit = AwesomeVersion(max_version)
|
|
448
|
+
|
|
449
|
+
firmware_filtered = None
|
|
450
|
+
firmware_search = re.search(r"\d+\.\d+\.\d+", self._config["version"])
|
|
451
|
+
if firmware_search:
|
|
452
|
+
firmware_filtered = firmware_search.group(0)
|
|
453
|
+
|
|
454
|
+
if firmware_filtered is None:
|
|
455
|
+
_LOGGER.warning(
|
|
456
|
+
"Non-standard versioning string: %s", self._config["version"]
|
|
457
|
+
)
|
|
458
|
+
_LOGGER.debug("Non-semver firmware version detected.")
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
_LOGGER.debug("Detected firmware: %s", self._config["version"])
|
|
462
|
+
_LOGGER.debug("Filtered firmware: %s", firmware_filtered)
|
|
463
|
+
|
|
464
|
+
if "dev" in self._config["version"]:
|
|
465
|
+
value = self._config["version"]
|
|
466
|
+
_LOGGER.debug("Stripping 'dev' from version.")
|
|
467
|
+
value = value.split(".")
|
|
468
|
+
value = ".".join(value[0:3])
|
|
469
|
+
elif "master" in self._config["version"]:
|
|
470
|
+
value = "dev"
|
|
471
|
+
else:
|
|
472
|
+
value = firmware_filtered
|
|
473
|
+
|
|
474
|
+
current = AwesomeVersion(value)
|
|
475
|
+
|
|
476
|
+
if limit:
|
|
477
|
+
try:
|
|
478
|
+
if cutoff <= current < limit:
|
|
479
|
+
return True
|
|
480
|
+
except AwesomeVersionCompareException:
|
|
481
|
+
_LOGGER.debug("Non-semver firmware version detected.")
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
if current >= cutoff:
|
|
486
|
+
return True
|
|
487
|
+
except AwesomeVersionCompareException:
|
|
488
|
+
_LOGGER.debug("Non-semver firmware version detected.")
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
def version_check(self, min_version: str, max_version: str = "") -> bool:
|
|
492
|
+
"""Unprotected function call for version checking."""
|
|
493
|
+
return self._version_check(min_version=min_version, max_version=max_version)
|