livisi 0.9.0__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 +0 -2
- livisi/livisi_connector.py +199 -18
- livisi/livisi_websocket.py +39 -17
- {livisi-0.9.0.dist-info → livisi-1.0.0.dist-info}/METADATA +3 -2
- livisi-1.0.0.dist-info/RECORD +15 -0
- {livisi-0.9.0.dist-info → livisi-1.0.0.dist-info}/WHEEL +1 -1
- livisi-0.9.0.dist-info/RECORD +0 -15
- {livisi-0.9.0.dist-info → livisi-1.0.0.dist-info/licenses}/LICENSE +0 -0
- {livisi-0.9.0.dist-info → livisi-1.0.0.dist-info}/top_level.txt +0 -0
livisi/__init__.py
CHANGED
@@ -45,7 +45,6 @@ from .livisi_errors import (
|
|
45
45
|
ShcUnreachableException,
|
46
46
|
WrongCredentialException,
|
47
47
|
IncorrectIpAddressException,
|
48
|
-
TokenExpiredException,
|
49
48
|
ErrorCodeException,
|
50
49
|
ERROR_CODES,
|
51
50
|
)
|
@@ -87,7 +86,6 @@ __all__ = [
|
|
87
86
|
"ShcUnreachableException",
|
88
87
|
"WrongCredentialException",
|
89
88
|
"IncorrectIpAddressException",
|
90
|
-
"TokenExpiredException",
|
91
89
|
"ErrorCodeException",
|
92
90
|
"ERROR_CODES",
|
93
91
|
]
|
livisi/livisi_connector.py
CHANGED
@@ -3,11 +3,15 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
import asyncio
|
6
|
+
import time
|
7
|
+
import base64
|
8
|
+
|
6
9
|
from contextlib import suppress
|
7
10
|
|
8
11
|
from typing import Any
|
9
12
|
import uuid
|
10
13
|
import json
|
14
|
+
|
11
15
|
from aiohttp import ClientResponseError, ServerDisconnectedError, ClientConnectorError
|
12
16
|
from aiohttp.client import ClientSession, ClientError, TCPConnector
|
13
17
|
from dateutil.parser import parse as parse_timestamp
|
@@ -60,6 +64,90 @@ class LivisiConnection:
|
|
60
64
|
|
61
65
|
self._web_session = None
|
62
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)"
|
63
151
|
|
64
152
|
async def connect(self, host: str, password: str):
|
65
153
|
"""Connect to the livisi SHC and retrieve controller information."""
|
@@ -75,6 +163,8 @@ class LivisiConnection:
|
|
75
163
|
await self.close()
|
76
164
|
raise
|
77
165
|
|
166
|
+
self._connect_time = time.time()
|
167
|
+
|
78
168
|
self.controller = await self._async_get_controller()
|
79
169
|
if self.controller.is_v2:
|
80
170
|
# reconnect with more concurrent connections on v2 SHC
|
@@ -124,9 +214,13 @@ class LivisiConnection:
|
|
124
214
|
return web_session
|
125
215
|
|
126
216
|
async def _async_retrieve_token(self) -> None:
|
127
|
-
"""Set the
|
217
|
+
"""Set the token from the LIVISI Smart Home Controller."""
|
128
218
|
access_data: dict = {}
|
129
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
|
+
|
130
224
|
if self._password is None:
|
131
225
|
raise LivisiException("No password set")
|
132
226
|
|
@@ -150,47 +244,130 @@ class LivisiConnection:
|
|
150
244
|
headers=headers,
|
151
245
|
)
|
152
246
|
LOGGER.debug("Updated access token")
|
153
|
-
|
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
|
154
252
|
if self.token is None:
|
155
253
|
errorcode = access_data.get("errorcode")
|
156
254
|
errordesc = access_data.get("description", "Unknown Error")
|
157
255
|
if errorcode in (2003, 2009):
|
256
|
+
LOGGER.debug("Invalid credentials for SHC")
|
158
257
|
raise WrongCredentialException
|
159
258
|
# log full response for debugging
|
160
259
|
LOGGER.error("SHC response does not contain access token")
|
161
260
|
LOGGER.error(access_data)
|
162
261
|
raise LivisiException(f"No token received from SHC: {errordesc}")
|
262
|
+
self._connect_time = time.time()
|
163
263
|
except ClientError as error:
|
264
|
+
LOGGER.debug("Error connecting to SHC: %s", error)
|
164
265
|
if len(access_data) == 0:
|
165
266
|
raise IncorrectIpAddressException from error
|
166
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
|
+
)
|
167
306
|
|
168
307
|
async def _async_request(
|
169
308
|
self, method, url: str, payload=None, headers=None
|
170
309
|
) -> dict:
|
171
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
|
172
329
|
response = await self._async_send_request(method, url, payload, headers)
|
173
330
|
|
174
331
|
if response is not None and "errorcode" in response:
|
175
332
|
errorcode = response.get("errorcode")
|
176
|
-
#
|
333
|
+
# Handle expired token (2007)
|
177
334
|
if errorcode == 2007:
|
178
|
-
|
179
|
-
|
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
|
180
354
|
if response is not None and "errorcode" in response:
|
355
|
+
retry_errorcode = response.get("errorcode")
|
181
356
|
LOGGER.error(
|
182
|
-
"Livisi sent error code %d after token
|
183
|
-
response.get("errorcode"),
|
357
|
+
"Livisi sent error code %d after token refresh", retry_errorcode
|
184
358
|
)
|
185
|
-
raise ErrorCodeException(
|
359
|
+
raise ErrorCodeException(retry_errorcode)
|
360
|
+
|
361
|
+
return response
|
186
362
|
else:
|
363
|
+
# Handle other error codes
|
187
364
|
LOGGER.error(
|
188
365
|
"Error code %d (%s) on url %s",
|
189
366
|
errorcode,
|
190
367
|
ERROR_CODES.get(errorcode, "unknown"),
|
191
368
|
url,
|
192
369
|
)
|
193
|
-
raise ErrorCodeException(
|
370
|
+
raise ErrorCodeException(errorcode)
|
194
371
|
|
195
372
|
return response
|
196
373
|
|
@@ -267,7 +444,7 @@ class LivisiConnection:
|
|
267
444
|
("device", "capability", "location"),
|
268
445
|
):
|
269
446
|
if isinstance(result, Exception):
|
270
|
-
LOGGER.
|
447
|
+
LOGGER.warning(f"Error loading {path}")
|
271
448
|
raise result # Re-raise the exception immediately
|
272
449
|
|
273
450
|
controller_id = next(
|
@@ -391,18 +568,22 @@ class LivisiConnection:
|
|
391
568
|
return None
|
392
569
|
|
393
570
|
requestUrl = f"capability/{capability}/state"
|
571
|
+
|
394
572
|
try:
|
395
573
|
response = await self.async_send_authorized_request("get", requestUrl)
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
except Exception:
|
402
|
-
LOGGER.warning(
|
403
|
-
f"Error getting device state (url: {requestUrl})", exc_info=True
|
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,
|
404
579
|
)
|
580
|
+
raise
|
581
|
+
|
582
|
+
if response is None:
|
583
|
+
return None
|
584
|
+
if not isinstance(response, dict):
|
405
585
|
return None
|
586
|
+
return response.get(property, None)
|
406
587
|
|
407
588
|
async def async_set_state(
|
408
589
|
self,
|
livisi/livisi_websocket.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
"""Code for communication with the Livisi application websocket."""
|
2
2
|
|
3
|
+
import asyncio
|
3
4
|
from collections.abc import Callable
|
4
5
|
import urllib.parse
|
5
6
|
|
@@ -7,7 +8,14 @@ from json import JSONDecodeError
|
|
7
8
|
import websockets.client
|
8
9
|
|
9
10
|
from .livisi_json_util import parse_dataclass
|
10
|
-
from .livisi_const import
|
11
|
+
from .livisi_const import (
|
12
|
+
CLASSIC_WEBSOCKET_PORT,
|
13
|
+
LIVISI_EVENT_BUTTON_PRESSED,
|
14
|
+
LIVISI_EVENT_MOTION_DETECTED,
|
15
|
+
LIVISI_EVENT_STATE_CHANGED,
|
16
|
+
V2_WEBSOCKET_PORT,
|
17
|
+
LOGGER,
|
18
|
+
)
|
11
19
|
from .livisi_websocket_event import LivisiWebsocketEvent
|
12
20
|
|
13
21
|
|
@@ -36,21 +44,20 @@ class LivisiWebsocket:
|
|
36
44
|
ip_address = self.aiolivisi.host
|
37
45
|
self.connection_url = f"ws://{ip_address}:{port}/events?token={token}"
|
38
46
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
await self.consumer_handler(websocket, on_data)
|
47
|
-
except Exception as e:
|
48
|
-
LOGGER.exception("Error handling websocket connection", exc_info=e)
|
49
|
-
if not self._disconnecting:
|
50
|
-
LOGGER.warning("WebSocket disconnected unexpectedly, retrying...")
|
51
|
-
await on_close()
|
52
|
-
finally:
|
47
|
+
try:
|
48
|
+
async with websockets.client.connect(
|
49
|
+
self.connection_url, ping_interval=10, ping_timeout=10
|
50
|
+
) as websocket:
|
51
|
+
LOGGER.info("WebSocket connection established.")
|
52
|
+
self._websocket = websocket
|
53
|
+
await self.consumer_handler(websocket, on_data)
|
53
54
|
self._websocket = None
|
55
|
+
except Exception as e:
|
56
|
+
self._websocket = None
|
57
|
+
LOGGER.exception("Error handling websocket connection", exc_info=e)
|
58
|
+
if not self._disconnecting:
|
59
|
+
LOGGER.warning("WebSocket disconnected unexpectedly.")
|
60
|
+
await on_close()
|
54
61
|
|
55
62
|
async def disconnect(self) -> None:
|
56
63
|
"""Close the websocket."""
|
@@ -74,13 +81,28 @@ class LivisiWebsocket:
|
|
74
81
|
continue
|
75
82
|
|
76
83
|
if event_data.properties is None or event_data.properties == {}:
|
77
|
-
LOGGER.
|
84
|
+
LOGGER.debug("Received event with no properties, skipping.")
|
85
|
+
LOGGER.debug("Event data: %s", event_data)
|
86
|
+
if event_data.type not in [
|
87
|
+
LIVISI_EVENT_STATE_CHANGED,
|
88
|
+
LIVISI_EVENT_BUTTON_PRESSED,
|
89
|
+
LIVISI_EVENT_MOTION_DETECTED,
|
90
|
+
]:
|
91
|
+
LOGGER.info(
|
92
|
+
"Received %s event from Livisi websocket", event_data.type
|
93
|
+
)
|
78
94
|
continue
|
79
95
|
|
80
96
|
# Remove the URL prefix and use just the ID (which is unique)
|
81
97
|
event_data.source = event_data.source.removeprefix("/device/")
|
82
98
|
event_data.source = event_data.source.removeprefix("/capability/")
|
83
99
|
|
84
|
-
|
100
|
+
try:
|
101
|
+
on_data(event_data)
|
102
|
+
except Exception as e:
|
103
|
+
LOGGER.error("Unhandled error in on_data", exc_info=e)
|
104
|
+
|
105
|
+
except asyncio.exceptions.CancelledError:
|
106
|
+
LOGGER.warning("Livisi WebSocket consumer handler stopped")
|
85
107
|
except Exception as e:
|
86
108
|
LOGGER.error("Unhandled error in WebSocket consumer handler", exc_info=e)
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: livisi
|
3
|
-
Version: 0.
|
3
|
+
Version: 1.0.0
|
4
4
|
Summary: Connection library for the abandoned Livisi Smart Home system
|
5
5
|
Author-email: Felix Rotthowe <felix@planbnet.org>
|
6
6
|
License: Apache-2.0
|
@@ -15,6 +15,7 @@ License-File: LICENSE
|
|
15
15
|
Requires-Dist: colorlog>=6.8.2
|
16
16
|
Requires-Dist: aiohttp>=3.8.5
|
17
17
|
Requires-Dist: websockets>=11.0.3
|
18
|
+
Dynamic: license-file
|
18
19
|
|
19
20
|
# livisi
|
20
21
|
|
@@ -0,0 +1,15 @@
|
|
1
|
+
livisi/__init__.py,sha256=16tPoT93D3md0qHQ40sb5hX-gdzVtjGV0jh0PFzSkuU,2169
|
2
|
+
livisi/livisi_connector.py,sha256=UavIT5Vzu14htoXgI5Ipm4tNyFMroL9ejLigIpMNBtM,24957
|
3
|
+
livisi/livisi_const.py,sha256=6YqoPdlKX7ogfC_E_ea8opA0JeYIweXRRfpfu-QXTqc,779
|
4
|
+
livisi/livisi_controller.py,sha256=XyJ58XMXIxw5anIwHJ5MRVlNUBZyi3RjP8AO8HnYcXo,296
|
5
|
+
livisi/livisi_device.py,sha256=Qeh8kdVWY57S1aS5S4ATfi8t2a2k0Bx6njScRC0XKek,1230
|
6
|
+
livisi/livisi_errors.py,sha256=N-xEF42KfsCVUghdJYuM8yvpUiI_op1i1mpBiKcrM5Y,4511
|
7
|
+
livisi/livisi_event.py,sha256=Z3VN1nW737O1xMt1yj62lC0KTiiXFIlRPEog33IsJpw,456
|
8
|
+
livisi/livisi_json_util.py,sha256=6sQk8ycMUIAKL_8rD3dW_uHWRNa6QMcky-PvcEnM_88,735
|
9
|
+
livisi/livisi_websocket.py,sha256=4AO8oLR845F_Lzcn7vFtDQv_KKmh4hxLEQuEh63ikqQ,4198
|
10
|
+
livisi/livisi_websocket_event.py,sha256=pbjyiKid9gOWMcWiw5jq0dbo2DQ7dAQnxM0D_UJBltw,273
|
11
|
+
livisi-1.0.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
+
livisi-1.0.0.dist-info/METADATA,sha256=J-wtmNvcEIylifeMgitRIndIMNPk3h1vHYgzxJlhjf8,1285
|
13
|
+
livisi-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
14
|
+
livisi-1.0.0.dist-info/top_level.txt,sha256=ctiU5MMpBSwoQR7mJWIuyB1ND1_g004Xa3vNmMsSiCs,7
|
15
|
+
livisi-1.0.0.dist-info/RECORD,,
|
livisi-0.9.0.dist-info/RECORD
DELETED
@@ -1,15 +0,0 @@
|
|
1
|
-
livisi/__init__.py,sha256=Dh8B_PRVSmch_ClhFCVyV7O2doRCfREHvKGob6MYuRk,2225
|
2
|
-
livisi/livisi_connector.py,sha256=VagZFl46y49d3gqrr9iv0YSKLviHRLvAAxcMW4IO734,18095
|
3
|
-
livisi/livisi_const.py,sha256=6YqoPdlKX7ogfC_E_ea8opA0JeYIweXRRfpfu-QXTqc,779
|
4
|
-
livisi/livisi_controller.py,sha256=XyJ58XMXIxw5anIwHJ5MRVlNUBZyi3RjP8AO8HnYcXo,296
|
5
|
-
livisi/livisi_device.py,sha256=Qeh8kdVWY57S1aS5S4ATfi8t2a2k0Bx6njScRC0XKek,1230
|
6
|
-
livisi/livisi_errors.py,sha256=N-xEF42KfsCVUghdJYuM8yvpUiI_op1i1mpBiKcrM5Y,4511
|
7
|
-
livisi/livisi_event.py,sha256=Z3VN1nW737O1xMt1yj62lC0KTiiXFIlRPEog33IsJpw,456
|
8
|
-
livisi/livisi_json_util.py,sha256=6sQk8ycMUIAKL_8rD3dW_uHWRNa6QMcky-PvcEnM_88,735
|
9
|
-
livisi/livisi_websocket.py,sha256=KFY_n7w0grwnczjUDrdijK4RQi-8MOX81uLe6Nw-mSM,3465
|
10
|
-
livisi/livisi_websocket_event.py,sha256=pbjyiKid9gOWMcWiw5jq0dbo2DQ7dAQnxM0D_UJBltw,273
|
11
|
-
livisi-0.9.0.dist-info/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
livisi-0.9.0.dist-info/METADATA,sha256=iIyRLOhx8GWwodC5quOjI1tPK2irJyNjkuYzS0PaWVA,1263
|
13
|
-
livisi-0.9.0.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
|
14
|
-
livisi-0.9.0.dist-info/top_level.txt,sha256=ctiU5MMpBSwoQR7mJWIuyB1ND1_g004Xa3vNmMsSiCs,7
|
15
|
-
livisi-0.9.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|