livisi 0.0.25__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.
- livisi/__init__.py +91 -4
- livisi/livisi_connector.py +689 -0
- livisi/livisi_const.py +30 -0
- livisi/livisi_controller.py +17 -0
- livisi/livisi_device.py +53 -0
- livisi/livisi_errors.py +124 -0
- livisi/livisi_json_util.py +23 -0
- livisi/livisi_websocket.py +108 -0
- livisi/livisi_websocket_event.py +13 -0
- livisi-1.0.0.dist-info/METADATA +27 -0
- livisi-1.0.0.dist-info/RECORD +15 -0
- {livisi-0.0.25.dist-info → livisi-1.0.0.dist-info}/WHEEL +1 -1
- livisi-1.0.0.dist-info/licenses/LICENSE +0 -0
- livisi/aiolivisi.py +0 -270
- livisi/const.py +0 -40
- livisi/errors.py +0 -20
- livisi/websocket.py +0 -113
- livisi-0.0.25.dist-info/LICENSE +0 -201
- livisi-0.0.25.dist-info/METADATA +0 -24
- livisi-0.0.25.dist-info/RECORD +0 -11
- {livisi-0.0.25.dist-info → livisi-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,689 @@
|
|
1
|
+
"""Code to handle the communication with Livisi Smart home controllers."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import time
|
7
|
+
import base64
|
8
|
+
|
9
|
+
from contextlib import suppress
|
10
|
+
|
11
|
+
from typing import Any
|
12
|
+
import uuid
|
13
|
+
import json
|
14
|
+
|
15
|
+
from aiohttp import ClientResponseError, ServerDisconnectedError, ClientConnectorError
|
16
|
+
from aiohttp.client import ClientSession, ClientError, TCPConnector
|
17
|
+
from dateutil.parser import parse as parse_timestamp
|
18
|
+
|
19
|
+
from .livisi_device import LivisiDevice
|
20
|
+
|
21
|
+
from .livisi_json_util import parse_dataclass
|
22
|
+
from .livisi_controller import LivisiController
|
23
|
+
|
24
|
+
from .livisi_errors import (
|
25
|
+
ERROR_CODES,
|
26
|
+
IncorrectIpAddressException,
|
27
|
+
LivisiException,
|
28
|
+
ShcUnreachableException,
|
29
|
+
WrongCredentialException,
|
30
|
+
ErrorCodeException,
|
31
|
+
)
|
32
|
+
|
33
|
+
from .livisi_websocket import LivisiWebsocket
|
34
|
+
|
35
|
+
from .livisi_const import (
|
36
|
+
COMMAND_RESTART,
|
37
|
+
CONTROLLER_DEVICE_TYPES,
|
38
|
+
V1_NAME,
|
39
|
+
V2_NAME,
|
40
|
+
LOGGER,
|
41
|
+
REQUEST_TIMEOUT,
|
42
|
+
WEBSERVICE_PORT,
|
43
|
+
)
|
44
|
+
|
45
|
+
|
46
|
+
async def connect(host: str, password: str) -> LivisiConnection:
|
47
|
+
"""Initialize the lib and connect to the livisi SHC."""
|
48
|
+
connection = LivisiConnection()
|
49
|
+
await connection.connect(host, password)
|
50
|
+
return connection
|
51
|
+
|
52
|
+
|
53
|
+
class LivisiConnection:
|
54
|
+
"""Handles the communication with the Livisi Smart Home controller."""
|
55
|
+
|
56
|
+
def __init__(self) -> None:
|
57
|
+
"""Initialize the livisi connector."""
|
58
|
+
|
59
|
+
self.host: str = None
|
60
|
+
self.controller: LivisiController = None
|
61
|
+
|
62
|
+
self._password: str = None
|
63
|
+
self._token: str = None
|
64
|
+
|
65
|
+
self._web_session = None
|
66
|
+
self._websocket = LivisiWebsocket(self)
|
67
|
+
self._token_refresh_lock = asyncio.Lock()
|
68
|
+
|
69
|
+
def _decode_jwt_payload(self, token: str) -> dict | None:
|
70
|
+
"""Decode JWT payload and return payload dict or None on error."""
|
71
|
+
if not token:
|
72
|
+
return None
|
73
|
+
|
74
|
+
try:
|
75
|
+
# JWT tokens have 3 parts separated by dots: header.payload.signature
|
76
|
+
parts = token.split(".")
|
77
|
+
if len(parts) != 3:
|
78
|
+
return None
|
79
|
+
|
80
|
+
# Decode the payload (second part)
|
81
|
+
payload = parts[1]
|
82
|
+
|
83
|
+
# Add padding if needed (JWT base64 encoding might not have padding)
|
84
|
+
padding = 4 - (len(payload) % 4)
|
85
|
+
if padding != 4:
|
86
|
+
payload += "=" * padding
|
87
|
+
|
88
|
+
try:
|
89
|
+
decoded_bytes = base64.urlsafe_b64decode(payload)
|
90
|
+
payload_json = json.loads(decoded_bytes.decode("utf-8"))
|
91
|
+
return payload_json
|
92
|
+
|
93
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
94
|
+
return None
|
95
|
+
|
96
|
+
except Exception:
|
97
|
+
return None
|
98
|
+
|
99
|
+
def _format_token_info(self, token: str) -> str:
|
100
|
+
"""Format token information for logging."""
|
101
|
+
payload = self._decode_jwt_payload(token)
|
102
|
+
if not payload:
|
103
|
+
return "None" if not token else f"Invalid JWT (length: {len(token)})"
|
104
|
+
|
105
|
+
info_parts = []
|
106
|
+
|
107
|
+
# User/subject
|
108
|
+
if "sub" in payload:
|
109
|
+
info_parts.append(f"user: {payload['sub']}")
|
110
|
+
elif "username" in payload:
|
111
|
+
info_parts.append(f"user: {payload['username']}")
|
112
|
+
|
113
|
+
# Expiration time
|
114
|
+
if "exp" in payload:
|
115
|
+
exp_time = payload["exp"]
|
116
|
+
current_time = time.time()
|
117
|
+
if exp_time > current_time:
|
118
|
+
time_left = exp_time - current_time
|
119
|
+
if time_left > 3600:
|
120
|
+
info_parts.append(f"expires in: {time_left/3600:.1f}h")
|
121
|
+
elif time_left > 60:
|
122
|
+
info_parts.append(f"expires in: {time_left/60:.1f}m")
|
123
|
+
else:
|
124
|
+
info_parts.append(f"expires in: {time_left:.0f}s")
|
125
|
+
else:
|
126
|
+
info_parts.append("expired")
|
127
|
+
|
128
|
+
# Issue time (age)
|
129
|
+
if "iat" in payload:
|
130
|
+
iat_time = payload["iat"]
|
131
|
+
age = time.time() - iat_time
|
132
|
+
if age > 3600:
|
133
|
+
info_parts.append(f"age: {age/3600:.1f}h")
|
134
|
+
elif age > 60:
|
135
|
+
info_parts.append(f"age: {age/60:.1f}m")
|
136
|
+
else:
|
137
|
+
info_parts.append(f"age: {age:.0f}s")
|
138
|
+
|
139
|
+
# Token ID if available
|
140
|
+
if "jti" in payload:
|
141
|
+
jti = payload["jti"]
|
142
|
+
if len(jti) > 8:
|
143
|
+
info_parts.append(f"id: {jti[:8]}...")
|
144
|
+
else:
|
145
|
+
info_parts.append(f"id: {jti}")
|
146
|
+
|
147
|
+
if info_parts:
|
148
|
+
return f"JWT({', '.join(info_parts)})"
|
149
|
+
else:
|
150
|
+
return f"JWT({len(payload)} claims)"
|
151
|
+
|
152
|
+
async def connect(self, host: str, password: str):
|
153
|
+
"""Connect to the livisi SHC and retrieve controller information."""
|
154
|
+
if self._web_session is not None:
|
155
|
+
await self.close()
|
156
|
+
self._web_session = self._create_web_session(concurrent_connections=1)
|
157
|
+
if host is not None and password is not None:
|
158
|
+
self.host = host
|
159
|
+
self._password = password
|
160
|
+
try:
|
161
|
+
await self._async_retrieve_token()
|
162
|
+
except:
|
163
|
+
await self.close()
|
164
|
+
raise
|
165
|
+
|
166
|
+
self._connect_time = time.time()
|
167
|
+
|
168
|
+
self.controller = await self._async_get_controller()
|
169
|
+
if self.controller.is_v2:
|
170
|
+
# reconnect with more concurrent connections on v2 SHC
|
171
|
+
await self._web_session.close()
|
172
|
+
self._web_session = self._create_web_session(concurrent_connections=10)
|
173
|
+
|
174
|
+
async def close(self):
|
175
|
+
"""Disconnect the http client session and websocket."""
|
176
|
+
if self._web_session is not None:
|
177
|
+
await self._web_session.close()
|
178
|
+
self._web_session = None
|
179
|
+
self.controller = None
|
180
|
+
await self._websocket.disconnect()
|
181
|
+
|
182
|
+
async def listen_for_events(self, on_data, on_close) -> None:
|
183
|
+
"""Connect to the websocket."""
|
184
|
+
if self._web_session is None:
|
185
|
+
raise LivisiException("Not authenticated to SHC")
|
186
|
+
if self._websocket.is_connected():
|
187
|
+
with suppress(Exception):
|
188
|
+
await self._websocket.disconnect()
|
189
|
+
await self._websocket.connect(on_data, on_close)
|
190
|
+
|
191
|
+
async def async_send_authorized_request(
|
192
|
+
self,
|
193
|
+
method,
|
194
|
+
path: str,
|
195
|
+
payload=None,
|
196
|
+
) -> dict:
|
197
|
+
"""Make a request to the Livisi Smart Home controller."""
|
198
|
+
url = f"http://{self.host}:{WEBSERVICE_PORT}/{path}"
|
199
|
+
auth_headers = {
|
200
|
+
"authorization": f"Bearer {self.token}",
|
201
|
+
"Content-type": "application/json",
|
202
|
+
"Accept": "*/*",
|
203
|
+
}
|
204
|
+
return await self._async_request(method, url, payload, auth_headers)
|
205
|
+
|
206
|
+
def _create_web_session(self, concurrent_connections: int = 1):
|
207
|
+
"""Create a custom web session which limits concurrent connections."""
|
208
|
+
connector = TCPConnector(
|
209
|
+
limit=concurrent_connections,
|
210
|
+
limit_per_host=concurrent_connections,
|
211
|
+
force_close=True,
|
212
|
+
)
|
213
|
+
web_session = ClientSession(connector=connector)
|
214
|
+
return web_session
|
215
|
+
|
216
|
+
async def _async_retrieve_token(self) -> None:
|
217
|
+
"""Set the token from the LIVISI Smart Home Controller."""
|
218
|
+
access_data: dict = {}
|
219
|
+
|
220
|
+
# Ensure token is cleared before attempting to fetch a new one
|
221
|
+
# so that future requests will reauthenticate on failure
|
222
|
+
self.token = None
|
223
|
+
|
224
|
+
if self._password is None:
|
225
|
+
raise LivisiException("No password set")
|
226
|
+
|
227
|
+
login_credentials = {
|
228
|
+
"username": "admin",
|
229
|
+
"password": self._password,
|
230
|
+
"grant_type": "password",
|
231
|
+
}
|
232
|
+
headers = {
|
233
|
+
"Authorization": "Basic Y2xpZW50SWQ6Y2xpZW50UGFzcw==",
|
234
|
+
"Content-type": "application/json",
|
235
|
+
"Accept": "application/json",
|
236
|
+
}
|
237
|
+
|
238
|
+
try:
|
239
|
+
LOGGER.debug("Updating access token")
|
240
|
+
access_data = await self._async_send_request(
|
241
|
+
"post",
|
242
|
+
url=f"http://{self.host}:{WEBSERVICE_PORT}/auth/token",
|
243
|
+
payload=login_credentials,
|
244
|
+
headers=headers,
|
245
|
+
)
|
246
|
+
LOGGER.debug("Updated access token")
|
247
|
+
new_token = access_data.get("access_token")
|
248
|
+
LOGGER.info(
|
249
|
+
"Received token from SHC: %s", self._format_token_info(new_token)
|
250
|
+
)
|
251
|
+
self.token = new_token
|
252
|
+
if self.token is None:
|
253
|
+
errorcode = access_data.get("errorcode")
|
254
|
+
errordesc = access_data.get("description", "Unknown Error")
|
255
|
+
if errorcode in (2003, 2009):
|
256
|
+
LOGGER.debug("Invalid credentials for SHC")
|
257
|
+
raise WrongCredentialException
|
258
|
+
# log full response for debugging
|
259
|
+
LOGGER.error("SHC response does not contain access token")
|
260
|
+
LOGGER.error(access_data)
|
261
|
+
raise LivisiException(f"No token received from SHC: {errordesc}")
|
262
|
+
self._connect_time = time.time()
|
263
|
+
except ClientError as error:
|
264
|
+
LOGGER.debug("Error connecting to SHC: %s", error)
|
265
|
+
if len(access_data) == 0:
|
266
|
+
raise IncorrectIpAddressException from error
|
267
|
+
raise ShcUnreachableException from error
|
268
|
+
except TimeoutError as error:
|
269
|
+
LOGGER.debug("Timeout waiting for SHC")
|
270
|
+
raise ShcUnreachableException("Timeout waiting for shc") from error
|
271
|
+
except ClientResponseError as error:
|
272
|
+
LOGGER.debug("SHC response: %s", error.message)
|
273
|
+
if error.status == 401:
|
274
|
+
raise WrongCredentialException from error
|
275
|
+
raise LivisiException(
|
276
|
+
f"Invalid response from SHC, response code {error.status} ({error.message})"
|
277
|
+
) from error
|
278
|
+
except Exception as error:
|
279
|
+
LOGGER.debug("Error retrieving token from SHC: %s", error)
|
280
|
+
raise LivisiException("Error retrieving token from SHC") from error
|
281
|
+
|
282
|
+
async def _async_refresh_token(self) -> None:
|
283
|
+
"""Refresh the token if needed, using a lock to prevent concurrent refreshes."""
|
284
|
+
|
285
|
+
# remember the token that was expired, so we can check if it was already refreshed by another request
|
286
|
+
expired_token = self.token
|
287
|
+
|
288
|
+
async with self._token_refresh_lock:
|
289
|
+
# Check if token needs to be refreshed
|
290
|
+
if self.token is None or self.token == expired_token:
|
291
|
+
LOGGER.info(
|
292
|
+
"Livisi token %s is missing or expired, requesting new token from SHC",
|
293
|
+
self._format_token_info(self.token),
|
294
|
+
)
|
295
|
+
try:
|
296
|
+
await self._async_retrieve_token()
|
297
|
+
except Exception as e:
|
298
|
+
LOGGER.error("Unhandled error requesting token", exc_info=e)
|
299
|
+
raise
|
300
|
+
else:
|
301
|
+
# Token was already refreshed by another request during the lock
|
302
|
+
LOGGER.debug(
|
303
|
+
"Token already refreshed by another request, using new token %s",
|
304
|
+
self._format_token_info(self.token),
|
305
|
+
)
|
306
|
+
|
307
|
+
async def _async_request(
|
308
|
+
self, method, url: str, payload=None, headers=None
|
309
|
+
) -> dict:
|
310
|
+
"""Send a request to the Livisi Smart Home controller and handle requesting new token."""
|
311
|
+
|
312
|
+
# Check if the token is expired (not sure if this works on V1 SHC, so keep the old 2007 refresh code below too)
|
313
|
+
token_payload = self._decode_jwt_payload(self.token)
|
314
|
+
if token_payload:
|
315
|
+
expires = token_payload.get("exp", 0)
|
316
|
+
if expires > 0 and time.time() >= expires:
|
317
|
+
LOGGER.debug(
|
318
|
+
"Livisi token %s detected as expired",
|
319
|
+
self._format_token_info(self.token),
|
320
|
+
)
|
321
|
+
# Token is expired, we need to refresh it
|
322
|
+
try:
|
323
|
+
await self._async_refresh_token()
|
324
|
+
except Exception as e:
|
325
|
+
LOGGER.error("Unhandled error refreshing token", exc_info=e)
|
326
|
+
raise
|
327
|
+
|
328
|
+
# now send the request
|
329
|
+
response = await self._async_send_request(method, url, payload, headers)
|
330
|
+
|
331
|
+
if response is not None and "errorcode" in response:
|
332
|
+
errorcode = response.get("errorcode")
|
333
|
+
# Handle expired token (2007)
|
334
|
+
if errorcode == 2007:
|
335
|
+
LOGGER.debug(
|
336
|
+
"Livisi token %s expired (error 2007)",
|
337
|
+
self._format_token_info(self.token),
|
338
|
+
)
|
339
|
+
await self._async_refresh_token()
|
340
|
+
|
341
|
+
# Retry the original request with the (possibly new) token
|
342
|
+
try:
|
343
|
+
response = await self._async_send_request(
|
344
|
+
method, url, payload, headers
|
345
|
+
)
|
346
|
+
except Exception as e:
|
347
|
+
LOGGER.error(
|
348
|
+
"Unhandled error re-sending request after token update",
|
349
|
+
exc_info=e,
|
350
|
+
)
|
351
|
+
raise
|
352
|
+
|
353
|
+
# Check if the retry also failed
|
354
|
+
if response is not None and "errorcode" in response:
|
355
|
+
retry_errorcode = response.get("errorcode")
|
356
|
+
LOGGER.error(
|
357
|
+
"Livisi sent error code %d after token refresh", retry_errorcode
|
358
|
+
)
|
359
|
+
raise ErrorCodeException(retry_errorcode)
|
360
|
+
|
361
|
+
return response
|
362
|
+
else:
|
363
|
+
# Handle other error codes
|
364
|
+
LOGGER.error(
|
365
|
+
"Error code %d (%s) on url %s",
|
366
|
+
errorcode,
|
367
|
+
ERROR_CODES.get(errorcode, "unknown"),
|
368
|
+
url,
|
369
|
+
)
|
370
|
+
raise ErrorCodeException(errorcode)
|
371
|
+
|
372
|
+
return response
|
373
|
+
|
374
|
+
async def _async_send_request(
|
375
|
+
self, method, url: str, payload=None, headers=None
|
376
|
+
) -> dict:
|
377
|
+
try:
|
378
|
+
if payload is not None:
|
379
|
+
data = json.dumps(payload).encode("utf-8")
|
380
|
+
if headers is None:
|
381
|
+
headers = {}
|
382
|
+
headers["Content-Type"] = "application/json"
|
383
|
+
headers["Content-Encoding"] = "utf-8"
|
384
|
+
else:
|
385
|
+
data = None
|
386
|
+
|
387
|
+
async with self._web_session.request(
|
388
|
+
method,
|
389
|
+
url,
|
390
|
+
json=payload,
|
391
|
+
headers=headers,
|
392
|
+
ssl=False,
|
393
|
+
timeout=REQUEST_TIMEOUT,
|
394
|
+
) as res:
|
395
|
+
try:
|
396
|
+
data = await res.json()
|
397
|
+
if data is None and res.status != 200:
|
398
|
+
raise LivisiException(
|
399
|
+
f"No data received from SHC, response code {res.status} ({res.reason})"
|
400
|
+
)
|
401
|
+
except ClientResponseError as exc:
|
402
|
+
raise LivisiException(
|
403
|
+
f"Invalid response from SHC, response code {res.status} ({res.reason})"
|
404
|
+
) from exc
|
405
|
+
return data
|
406
|
+
except TimeoutError as exc:
|
407
|
+
raise ShcUnreachableException("Timeout waiting for shc") from exc
|
408
|
+
except ClientConnectorError as exc:
|
409
|
+
raise ShcUnreachableException("Failed to connect to shc") from exc
|
410
|
+
|
411
|
+
async def _async_get_controller(self) -> LivisiController:
|
412
|
+
"""Get Livisi Smart Home controller data."""
|
413
|
+
shc_info = await self.async_send_authorized_request("get", path="status")
|
414
|
+
controller = parse_dataclass(shc_info, LivisiController)
|
415
|
+
controller.is_v2 = shc_info.get("controllerType") == V2_NAME
|
416
|
+
controller.is_v1 = shc_info.get("controllerType") == V1_NAME
|
417
|
+
return controller
|
418
|
+
|
419
|
+
async def async_get_devices(
|
420
|
+
self,
|
421
|
+
) -> list[LivisiDevice]:
|
422
|
+
"""Send requests for getting all required data."""
|
423
|
+
|
424
|
+
# retrieve messages first, this will also refresh the token if
|
425
|
+
# needed so subsequent parallel requests don't fail
|
426
|
+
messages = await self.async_send_authorized_request("get", path="message")
|
427
|
+
|
428
|
+
(
|
429
|
+
low_battery_devices,
|
430
|
+
update_available_devices,
|
431
|
+
unreachable_devices,
|
432
|
+
updated_devices,
|
433
|
+
) = self.parse_messages(messages)
|
434
|
+
|
435
|
+
devices, capabilities, rooms = await asyncio.gather(
|
436
|
+
self.async_send_authorized_request("get", path="device"),
|
437
|
+
self.async_send_authorized_request("get", path="capability"),
|
438
|
+
self.async_send_authorized_request("get", path="location"),
|
439
|
+
return_exceptions=True,
|
440
|
+
)
|
441
|
+
|
442
|
+
for result, path in zip(
|
443
|
+
(devices, capabilities, rooms),
|
444
|
+
("device", "capability", "location"),
|
445
|
+
):
|
446
|
+
if isinstance(result, Exception):
|
447
|
+
LOGGER.warning(f"Error loading {path}")
|
448
|
+
raise result # Re-raise the exception immediately
|
449
|
+
|
450
|
+
controller_id = next(
|
451
|
+
(x.get("id") for x in devices if x.get("type") in CONTROLLER_DEVICE_TYPES),
|
452
|
+
None,
|
453
|
+
)
|
454
|
+
if controller_id is not None:
|
455
|
+
try:
|
456
|
+
shc_state = await self.async_send_authorized_request(
|
457
|
+
"get", path=f"device/{controller_id}/state"
|
458
|
+
)
|
459
|
+
if self.controller.is_v1:
|
460
|
+
shc_state = shc_state["state"]
|
461
|
+
except Exception:
|
462
|
+
LOGGER.warning("Error getting shc state", exc_info=True)
|
463
|
+
|
464
|
+
capability_map = {}
|
465
|
+
capability_config = {}
|
466
|
+
|
467
|
+
room_map = {}
|
468
|
+
|
469
|
+
for room in rooms:
|
470
|
+
if "id" in room:
|
471
|
+
roomid = room["id"]
|
472
|
+
room_map[roomid] = room.get("config", {}).get("name")
|
473
|
+
|
474
|
+
for capability in capabilities:
|
475
|
+
if "device" in capability:
|
476
|
+
device_id = capability["device"].removeprefix("/device/")
|
477
|
+
|
478
|
+
if device_id not in capability_map:
|
479
|
+
capability_map[device_id] = {}
|
480
|
+
capability_config[device_id] = {}
|
481
|
+
|
482
|
+
cap_type = capability.get("type")
|
483
|
+
if cap_type is not None:
|
484
|
+
capability_map[device_id][cap_type] = capability["id"]
|
485
|
+
if "config" in capability:
|
486
|
+
capability_config[device_id][cap_type] = capability["config"]
|
487
|
+
|
488
|
+
devicelist = []
|
489
|
+
|
490
|
+
for device in devices:
|
491
|
+
device_id = device.get("id")
|
492
|
+
device["capabilities"] = capability_map.get(device_id, {})
|
493
|
+
device["capability_config"] = capability_config.get(device_id, {})
|
494
|
+
device["cls"] = device.get("class")
|
495
|
+
device["battery_low"] = device_id in low_battery_devices
|
496
|
+
device["update_available"] = device_id in update_available_devices
|
497
|
+
device["updated"] = device_id in updated_devices
|
498
|
+
device["unreachable"] = device_id in unreachable_devices
|
499
|
+
if device.get("location") is not None:
|
500
|
+
roomid = device["location"].removeprefix("/location/")
|
501
|
+
device["room"] = room_map.get(roomid)
|
502
|
+
|
503
|
+
if device["type"] in CONTROLLER_DEVICE_TYPES:
|
504
|
+
device["state"] = shc_state
|
505
|
+
|
506
|
+
devicelist.append(parse_dataclass(device, LivisiDevice))
|
507
|
+
|
508
|
+
LOGGER.debug("Loaded %d devices", len(devices))
|
509
|
+
|
510
|
+
return devicelist
|
511
|
+
|
512
|
+
def parse_messages(self, messages):
|
513
|
+
"""Parse message data from shc."""
|
514
|
+
low_battery_devices = set()
|
515
|
+
update_available_devices = set()
|
516
|
+
unreachable_devices = set()
|
517
|
+
updated_devices = set()
|
518
|
+
|
519
|
+
for message in messages:
|
520
|
+
if isinstance(message, str):
|
521
|
+
LOGGER.warning("Invalid message")
|
522
|
+
LOGGER.warning(messages)
|
523
|
+
continue
|
524
|
+
|
525
|
+
msgtype = message.get("type", "")
|
526
|
+
msgtimestamp = parse_timestamp(message.get("timestamp", ""))
|
527
|
+
if msgtimestamp is None:
|
528
|
+
continue
|
529
|
+
|
530
|
+
device_ids = [
|
531
|
+
d.removeprefix("/device/") for d in message.get("devices", [])
|
532
|
+
]
|
533
|
+
if len(device_ids) == 0:
|
534
|
+
source = message.get("source", "")
|
535
|
+
device_ids = [source.replace("/device/", "")]
|
536
|
+
if msgtype == "DeviceLowBattery":
|
537
|
+
for device_id in device_ids:
|
538
|
+
low_battery_devices.add(device_id)
|
539
|
+
elif msgtype == "DeviceUpdateAvailable":
|
540
|
+
for device_id in device_ids:
|
541
|
+
update_available_devices.add(device_id)
|
542
|
+
elif msgtype == "ProductUpdated" or msgtype == "ShcUpdateCompleted":
|
543
|
+
for device_id in device_ids:
|
544
|
+
updated_devices.add(device_id)
|
545
|
+
elif msgtype == "DeviceUnreachable":
|
546
|
+
for device_id in device_ids:
|
547
|
+
unreachable_devices.add(device_id)
|
548
|
+
return (
|
549
|
+
low_battery_devices,
|
550
|
+
update_available_devices,
|
551
|
+
unreachable_devices,
|
552
|
+
updated_devices,
|
553
|
+
)
|
554
|
+
|
555
|
+
async def async_get_value(
|
556
|
+
self, capability: str, property: str, key: str = "value"
|
557
|
+
) -> Any | None:
|
558
|
+
"""Get current value of the capability."""
|
559
|
+
state = await self.async_get_state(capability, property)
|
560
|
+
if state is None:
|
561
|
+
return None
|
562
|
+
return state.get(key, None)
|
563
|
+
|
564
|
+
async def async_get_state(self, capability: str, property: str) -> dict | None:
|
565
|
+
"""Get state of a capability."""
|
566
|
+
|
567
|
+
if capability is None:
|
568
|
+
return None
|
569
|
+
|
570
|
+
requestUrl = f"capability/{capability}/state"
|
571
|
+
|
572
|
+
try:
|
573
|
+
response = await self.async_send_authorized_request("get", requestUrl)
|
574
|
+
except Exception as e:
|
575
|
+
# just debug log the exception but let the caller handle it
|
576
|
+
LOGGER.debug(
|
577
|
+
"Unhandled error requesting device value",
|
578
|
+
exc_info=e,
|
579
|
+
)
|
580
|
+
raise
|
581
|
+
|
582
|
+
if response is None:
|
583
|
+
return None
|
584
|
+
if not isinstance(response, dict):
|
585
|
+
return None
|
586
|
+
return response.get(property, None)
|
587
|
+
|
588
|
+
async def async_set_state(
|
589
|
+
self,
|
590
|
+
capability_id: str,
|
591
|
+
*,
|
592
|
+
key: str = None,
|
593
|
+
value: bool | float = None,
|
594
|
+
namespace: str = "core.RWE",
|
595
|
+
) -> bool:
|
596
|
+
"""Set the state of a capability."""
|
597
|
+
params = {}
|
598
|
+
if key is not None:
|
599
|
+
params = {key: {"type": "Constant", "value": value}}
|
600
|
+
|
601
|
+
return await self.async_send_capability_command(
|
602
|
+
capability_id, "SetState", namespace=namespace, params=params
|
603
|
+
)
|
604
|
+
|
605
|
+
async def _async_send_command(
|
606
|
+
self,
|
607
|
+
target: str,
|
608
|
+
command_type: str,
|
609
|
+
*,
|
610
|
+
namespace: str = "core.RWE",
|
611
|
+
params: dict = None,
|
612
|
+
) -> bool:
|
613
|
+
"""Send a command to a target."""
|
614
|
+
|
615
|
+
if params is None:
|
616
|
+
params = {}
|
617
|
+
|
618
|
+
set_state_payload: dict[str, Any] = {
|
619
|
+
"id": uuid.uuid4().hex,
|
620
|
+
"type": command_type,
|
621
|
+
"namespace": namespace,
|
622
|
+
"target": target,
|
623
|
+
"params": params,
|
624
|
+
}
|
625
|
+
try:
|
626
|
+
response = await self.async_send_authorized_request(
|
627
|
+
"post", "action", payload=set_state_payload
|
628
|
+
)
|
629
|
+
if response is None:
|
630
|
+
return False
|
631
|
+
return response.get("resultCode") == "Success"
|
632
|
+
except ServerDisconnectedError:
|
633
|
+
# Funny thing: The SHC restarts immediatly upon processing the restart command, it doesn't even answer to the request
|
634
|
+
# In order to not throw an error we need to catch and assume the request was successfull.
|
635
|
+
if command_type == COMMAND_RESTART:
|
636
|
+
return True
|
637
|
+
raise
|
638
|
+
|
639
|
+
async def async_send_device_command(
|
640
|
+
self,
|
641
|
+
device_id: str,
|
642
|
+
command_type: str,
|
643
|
+
*,
|
644
|
+
namespace: str = "core.RWE",
|
645
|
+
params: dict = None,
|
646
|
+
) -> bool:
|
647
|
+
"""Send a command to a device."""
|
648
|
+
|
649
|
+
return await self._async_send_command(
|
650
|
+
target=f"/device/{device_id}",
|
651
|
+
command_type=command_type,
|
652
|
+
namespace=namespace,
|
653
|
+
params=params,
|
654
|
+
)
|
655
|
+
|
656
|
+
async def async_send_capability_command(
|
657
|
+
self,
|
658
|
+
capability_id: str,
|
659
|
+
command_type: str,
|
660
|
+
*,
|
661
|
+
namespace: str = "core.RWE",
|
662
|
+
params: dict = None,
|
663
|
+
) -> bool:
|
664
|
+
"""Send a command to a capability."""
|
665
|
+
|
666
|
+
return await self._async_send_command(
|
667
|
+
target=f"/capability/{capability_id}",
|
668
|
+
command_type=command_type,
|
669
|
+
namespace=namespace,
|
670
|
+
params=params,
|
671
|
+
)
|
672
|
+
|
673
|
+
@property
|
674
|
+
def livisi_connection_data(self):
|
675
|
+
"""Return the connection data."""
|
676
|
+
return self._livisi_connection_data
|
677
|
+
|
678
|
+
@livisi_connection_data.setter
|
679
|
+
def livisi_connection_data(self, new_value):
|
680
|
+
self._livisi_connection_data = new_value
|
681
|
+
|
682
|
+
@property
|
683
|
+
def token(self):
|
684
|
+
"""Return the token."""
|
685
|
+
return self._token
|
686
|
+
|
687
|
+
@token.setter
|
688
|
+
def token(self, new_value):
|
689
|
+
self._token = new_value
|