livisi 0.9.0__py3-none-any.whl → 1.0.1__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 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
  ]
@@ -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 JWT from the LIVISI Smart Home Controller."""
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
- self.token = access_data.get("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
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
- # reconnect on expired token
333
+ # Handle expired token (2007)
177
334
  if errorcode == 2007:
178
- await self._async_retrieve_token()
179
- response = await self._async_send_request(method, url, payload, headers)
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 request",
183
- response.get("errorcode"),
357
+ "Livisi sent error code %d after token refresh", retry_errorcode
184
358
  )
185
- raise ErrorCodeException(response["errorcode"])
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(response["errorcode"])
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.warn(f"Error loading {path}")
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
- if response is None:
397
- return None
398
- if not isinstance(response, dict):
399
- return None
400
- return response.get(property, None)
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,
@@ -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 CLASSIC_WEBSOCKET_PORT, V2_WEBSOCKET_PORT, LOGGER
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
- while not self._disconnecting:
40
- try:
41
- async with websockets.client.connect(
42
- self.connection_url, ping_interval=10, ping_timeout=10
43
- ) as websocket:
44
- LOGGER.info("WebSocket connection established.")
45
- self._websocket = websocket
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.warning("Received event with no properties, skipping.")
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
- on_data(event_data)
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.2
1
+ Metadata-Version: 2.4
2
2
  Name: livisi
3
- Version: 0.9.0
3
+ Version: 1.0.1
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,8 @@ 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
+ Requires-Dist: python-dateutil>=2.9.0.post0
19
+ Dynamic: license-file
18
20
 
19
21
  # livisi
20
22
 
@@ -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.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ livisi-1.0.1.dist-info/METADATA,sha256=Y73AZBQmL5k8n4ynax5O64ajY5djw_QrZV0CQXEfNPY,1329
13
+ livisi-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ livisi-1.0.1.dist-info/top_level.txt,sha256=ctiU5MMpBSwoQR7mJWIuyB1ND1_g004Xa3vNmMsSiCs,7
15
+ livisi-1.0.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,