livisi 0.0.1__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.
livisi-0.0.1/LICENSE ADDED
File without changes
livisi-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: livisi
3
+ Version: 0.0.1
4
+ Summary: Connection library for the abandoned Livisi Smart Home system
5
+ Author-email: Felix Rotthowe <felix@planbnet.org>
6
+ Project-URL: Source, https://github.com/planbnet/livisi
7
+ Project-URL: Tracker, https://github.com/planbnet/livisi/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: colorlog==6.8.2
15
+ Requires-Dist: aiohttp>=3.8.5
16
+ Requires-Dist: websockets>=11.0.3
17
+
18
+ Livisi smart home connection library
19
+
20
+ Readme will be created when everything works as expected
livisi-0.0.1/README.md ADDED
@@ -0,0 +1,3 @@
1
+ Livisi smart home connection library
2
+
3
+ Readme will be created when everything works as expected
@@ -0,0 +1,93 @@
1
+ # __init__.py
2
+
3
+ # Import key classes, constants, and exceptions
4
+
5
+ # livisi_connector.py
6
+ from .livisi_connector import LivisiConnection, connect
7
+
8
+ # livisi_controller.py
9
+ from .livisi_controller import LivisiController
10
+
11
+ # livisi_device.py
12
+ from .livisi_device import LivisiDevice
13
+
14
+ # livisi_websocket.py
15
+ from .livisi_websocket import LivisiWebsocket
16
+
17
+ # livisi_websocket_event.py
18
+ from .livisi_websocket_event import LivisiWebsocketEvent
19
+
20
+ # livisi_const.py
21
+ from .livisi_const import (
22
+ LOGGER,
23
+ V2_NAME,
24
+ V1_NAME,
25
+ V2_WEBSOCKET_PORT,
26
+ CLASSIC_WEBSOCKET_PORT,
27
+ WEBSERVICE_PORT,
28
+ REQUEST_TIMEOUT,
29
+ CONTROLLER_DEVICE_TYPES,
30
+ BATTERY_LOW,
31
+ UPDATE_AVAILABLE,
32
+ LIVISI_EVENT_STATE_CHANGED,
33
+ LIVISI_EVENT_BUTTON_PRESSED,
34
+ LIVISI_EVENT_MOTION_DETECTED,
35
+ IS_REACHABLE,
36
+ EVENT_BUTTON_PRESSED,
37
+ EVENT_BUTTON_LONG_PRESSED,
38
+ EVENT_MOTION_DETECTED,
39
+ COMMAND_RESTART,
40
+ )
41
+
42
+ # livisi_errors.py
43
+ from .livisi_errors import (
44
+ LivisiException,
45
+ ShcUnreachableException,
46
+ WrongCredentialException,
47
+ IncorrectIpAddressException,
48
+ TokenExpiredException,
49
+ ErrorCodeException,
50
+ ERROR_CODES,
51
+ )
52
+
53
+ # Define __all__ to specify what is exported when using 'from livisi import *'
54
+ __all__ = [
55
+ # From livisi_connector.py
56
+ "LivisiConnection",
57
+ "connect",
58
+ # From livisi_controller.py
59
+ "LivisiController",
60
+ # From livisi_device.py
61
+ "LivisiDevice",
62
+ # From livisi_websocket.py
63
+ "LivisiWebsocket",
64
+ # From livisi_websocket_event.py
65
+ "LivisiWebsocketEvent",
66
+ # From livisi_const.py
67
+ "LOGGER",
68
+ "V2_NAME",
69
+ "V1_NAME",
70
+ "V2_WEBSOCKET_PORT",
71
+ "CLASSIC_WEBSOCKET_PORT",
72
+ "WEBSERVICE_PORT",
73
+ "REQUEST_TIMEOUT",
74
+ "CONTROLLER_DEVICE_TYPES",
75
+ "BATTERY_LOW",
76
+ "UPDATE_AVAILABLE",
77
+ "LIVISI_EVENT_STATE_CHANGED",
78
+ "LIVISI_EVENT_BUTTON_PRESSED",
79
+ "LIVISI_EVENT_MOTION_DETECTED",
80
+ "IS_REACHABLE",
81
+ "EVENT_BUTTON_PRESSED",
82
+ "EVENT_BUTTON_LONG_PRESSED",
83
+ "EVENT_MOTION_DETECTED",
84
+ "COMMAND_RESTART",
85
+ # From livisi_errors.py
86
+ "LivisiException",
87
+ "ShcUnreachableException",
88
+ "WrongCredentialException",
89
+ "IncorrectIpAddressException",
90
+ "TokenExpiredException",
91
+ "ErrorCodeException",
92
+ "ERROR_CODES",
93
+ ]
@@ -0,0 +1,508 @@
1
+ """Code to handle the communication with Livisi Smart home controllers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+
8
+ from typing import Any
9
+ import uuid
10
+ import json
11
+ from aiohttp import ClientResponseError, ServerDisconnectedError, ClientConnectorError
12
+ from aiohttp.client import ClientSession, ClientError, TCPConnector
13
+ from dateutil.parser import parse as parse_timestamp
14
+
15
+ from .livisi_device import LivisiDevice
16
+
17
+ from .livisi_json_util import parse_dataclass
18
+ from .livisi_controller import LivisiController
19
+
20
+ from .livisi_errors import (
21
+ ERROR_CODES,
22
+ IncorrectIpAddressException,
23
+ LivisiException,
24
+ ShcUnreachableException,
25
+ WrongCredentialException,
26
+ ErrorCodeException,
27
+ )
28
+
29
+ from .livisi_websocket import LivisiWebsocket
30
+
31
+ from .livisi_const import (
32
+ COMMAND_RESTART,
33
+ CONTROLLER_DEVICE_TYPES,
34
+ V1_NAME,
35
+ V2_NAME,
36
+ LOGGER,
37
+ REQUEST_TIMEOUT,
38
+ WEBSERVICE_PORT,
39
+ )
40
+
41
+
42
+ async def connect(host: str, password: str) -> LivisiConnection:
43
+ """Initialize the lib and connect to the livisi SHC."""
44
+ connection = LivisiConnection()
45
+ await connection.connect(host, password)
46
+ return connection
47
+
48
+
49
+ class LivisiConnection:
50
+ """Handles the communication with the Livisi Smart Home controller."""
51
+
52
+ def __init__(self) -> None:
53
+ """Initialize the livisi connector."""
54
+
55
+ self.host: str = None
56
+ self.controller: LivisiController = None
57
+
58
+ self._password: str = None
59
+ self._token: str = None
60
+
61
+ self._web_session = None
62
+ self._websocket = LivisiWebsocket(self)
63
+
64
+ async def connect(self, host: str, password: str):
65
+ """Connect to the livisi SHC and retrieve controller information."""
66
+ if self._web_session is not None:
67
+ await self.close()
68
+ self._web_session = self._create_web_session(concurrent_connections=1)
69
+ if host is not None and password is not None:
70
+ self.host = host
71
+ self._password = password
72
+ try:
73
+ await self._async_retrieve_token()
74
+ except:
75
+ await self.close()
76
+ raise
77
+
78
+ self.controller = await self._async_get_controller()
79
+ if self.controller.is_v2:
80
+ # reconnect with more concurrent connections on v2 SHC
81
+ await self._web_session.close()
82
+ self._web_session = self._create_web_session(concurrent_connections=10)
83
+
84
+ async def close(self):
85
+ """Disconnect the http client session and websocket."""
86
+ if self._web_session is not None:
87
+ await self._web_session.close()
88
+ self._web_session = None
89
+ self.controller = None
90
+ await self._websocket.disconnect()
91
+
92
+ async def listen_for_events(self, on_data, on_close) -> None:
93
+ """Connect to the websocket."""
94
+ if self._web_session is None:
95
+ raise LivisiException("Not authenticated to SHC")
96
+ if self._websocket.is_connected():
97
+ with suppress(Exception):
98
+ await self._websocket.disconnect()
99
+ await self._websocket.connect(on_data, on_close)
100
+
101
+ async def async_send_authorized_request(
102
+ self,
103
+ method,
104
+ path: str,
105
+ payload=None,
106
+ ) -> dict:
107
+ """Make a request to the Livisi Smart Home controller."""
108
+ url = f"http://{self.host}:{WEBSERVICE_PORT}/{path}"
109
+ auth_headers = {
110
+ "authorization": f"Bearer {self.token}",
111
+ "Content-type": "application/json",
112
+ "Accept": "*/*",
113
+ }
114
+ return await self._async_request(method, url, payload, auth_headers)
115
+
116
+ def _create_web_session(self, concurrent_connections: int = 1):
117
+ """Create a custom web session which limits concurrent connections."""
118
+ connector = TCPConnector(
119
+ limit=concurrent_connections,
120
+ limit_per_host=concurrent_connections,
121
+ force_close=True,
122
+ )
123
+ web_session = ClientSession(connector=connector)
124
+ return web_session
125
+
126
+ async def _async_retrieve_token(self) -> None:
127
+ """Set the JWT from the LIVISI Smart Home Controller."""
128
+ access_data: dict = {}
129
+
130
+ if self._password is None:
131
+ raise LivisiException("No password set")
132
+
133
+ login_credentials = {
134
+ "username": "admin",
135
+ "password": self._password,
136
+ "grant_type": "password",
137
+ }
138
+ headers = {
139
+ "Authorization": "Basic Y2xpZW50SWQ6Y2xpZW50UGFzcw==",
140
+ "Content-type": "application/json",
141
+ "Accept": "application/json",
142
+ }
143
+
144
+ try:
145
+ LOGGER.debug("Updating access token")
146
+ access_data = await self._async_send_request(
147
+ "post",
148
+ url=f"http://{self.host}:{WEBSERVICE_PORT}/auth/token",
149
+ payload=login_credentials,
150
+ headers=headers,
151
+ )
152
+ LOGGER.debug("Updated access token")
153
+ self.token = access_data.get("access_token")
154
+ if self.token is None:
155
+ errorcode = access_data.get("errorcode")
156
+ errordesc = access_data.get("description", "Unknown Error")
157
+ if errorcode in (2003, 2009):
158
+ raise WrongCredentialException
159
+ # log full response for debugging
160
+ LOGGER.error("SHC response does not contain access token")
161
+ LOGGER.error(access_data)
162
+ raise LivisiException(f"No token received from SHC: {errordesc}")
163
+ except ClientError as error:
164
+ if len(access_data) == 0:
165
+ raise IncorrectIpAddressException from error
166
+ raise ShcUnreachableException from error
167
+
168
+ async def _async_request(
169
+ self, method, url: str, payload=None, headers=None
170
+ ) -> dict:
171
+ """Send a request to the Livisi Smart Home controller and handle requesting new token."""
172
+ response = await self._async_send_request(method, url, payload, headers)
173
+
174
+ if response is not None and "errorcode" in response:
175
+ errorcode = response.get("errorcode")
176
+ # reconnect on expired token
177
+ if errorcode == 2007:
178
+ await self._async_retrieve_token()
179
+ response = await self._async_send_request(method, url, payload, headers)
180
+ if response is not None and "errorcode" in response:
181
+ LOGGER.error(
182
+ "Livisi sent error code %d after token request",
183
+ response.get("errorcode"),
184
+ )
185
+ raise ErrorCodeException(response["errorcode"])
186
+ else:
187
+ LOGGER.error(
188
+ "Error code %d (%s) on url %s",
189
+ errorcode,
190
+ ERROR_CODES.get(errorcode, "unknown"),
191
+ url,
192
+ )
193
+ raise ErrorCodeException(response["errorcode"])
194
+
195
+ return response
196
+
197
+ async def _async_send_request(
198
+ self, method, url: str, payload=None, headers=None
199
+ ) -> dict:
200
+ try:
201
+ if payload is not None:
202
+ data = json.dumps(payload).encode("utf-8")
203
+ if headers is None:
204
+ headers = {}
205
+ headers["Content-Type"] = "application/json"
206
+ headers["Content-Encoding"] = "utf-8"
207
+ else:
208
+ data = None
209
+
210
+ async with self._web_session.request(
211
+ method,
212
+ url,
213
+ json=payload,
214
+ headers=headers,
215
+ ssl=False,
216
+ timeout=REQUEST_TIMEOUT,
217
+ ) as res:
218
+ try:
219
+ data = await res.json()
220
+ if data is None and res.status != 200:
221
+ raise LivisiException(
222
+ f"No data received from SHC, response code {res.status} ({res.reason})"
223
+ )
224
+ except ClientResponseError as exc:
225
+ raise LivisiException(
226
+ f"Invalid response from SHC, response code {res.status} ({res.reason})"
227
+ ) from exc
228
+ return data
229
+ except TimeoutError as exc:
230
+ raise ShcUnreachableException("Timeout waiting for shc") from exc
231
+ except ClientConnectorError as exc:
232
+ raise ShcUnreachableException("Failed to connect to shc") from exc
233
+
234
+ async def _async_get_controller(self) -> LivisiController:
235
+ """Get Livisi Smart Home controller data."""
236
+ shc_info = await self.async_send_authorized_request("get", path="status")
237
+ controller = parse_dataclass(shc_info, LivisiController)
238
+ controller.is_v2 = shc_info.get("controllerType") == V2_NAME
239
+ controller.is_v1 = shc_info.get("controllerType") == V1_NAME
240
+ return controller
241
+
242
+ async def async_get_devices(
243
+ self,
244
+ ) -> list[LivisiDevice]:
245
+ """Send requests for getting all required data."""
246
+
247
+ # retrieve messages first, this will also refresh the token if
248
+ # needed so subsequent parallel requests don't fail
249
+ messages = await self.async_send_authorized_request("get", path="message")
250
+
251
+ (
252
+ low_battery_devices,
253
+ update_available_devices,
254
+ unreachable_devices,
255
+ updated_devices,
256
+ ) = self.parse_messages(messages)
257
+
258
+ devices, capabilities, rooms = await asyncio.gather(
259
+ self.async_send_authorized_request("get", path="device"),
260
+ self.async_send_authorized_request("get", path="capability"),
261
+ self.async_send_authorized_request("get", path="location"),
262
+ return_exceptions=True,
263
+ )
264
+
265
+ for result, path in zip(
266
+ (devices, capabilities, rooms),
267
+ ("device", "capability", "location"),
268
+ ):
269
+ if isinstance(result, Exception):
270
+ LOGGER.warn(f"Error loading {path}")
271
+ raise result # Re-raise the exception immediately
272
+
273
+ controller_id = next(
274
+ (x.get("id") for x in devices if x.get("type") in CONTROLLER_DEVICE_TYPES),
275
+ None,
276
+ )
277
+ if controller_id is not None:
278
+ try:
279
+ shc_state = await self.async_send_authorized_request(
280
+ "get", path=f"device/{controller_id}/state"
281
+ )
282
+ if self.controller.is_v1:
283
+ shc_state = shc_state["state"]
284
+ except Exception:
285
+ LOGGER.warning("Error getting shc state", exc_info=True)
286
+
287
+ capability_map = {}
288
+ capability_config = {}
289
+
290
+ room_map = {}
291
+
292
+ for room in rooms:
293
+ if "id" in room:
294
+ roomid = room["id"]
295
+ room_map[roomid] = room.get("config", {}).get("name")
296
+
297
+ for capability in capabilities:
298
+ if "device" in capability:
299
+ device_id = capability["device"].removeprefix("/device/")
300
+
301
+ if device_id not in capability_map:
302
+ capability_map[device_id] = {}
303
+ capability_config[device_id] = {}
304
+
305
+ cap_type = capability.get("type")
306
+ if cap_type is not None:
307
+ capability_map[device_id][cap_type] = capability["id"]
308
+ if "config" in capability:
309
+ capability_config[device_id][cap_type] = capability["config"]
310
+
311
+ devicelist = []
312
+
313
+ for device in devices:
314
+ device_id = device.get("id")
315
+ device["capabilities"] = capability_map.get(device_id, {})
316
+ device["capability_config"] = capability_config.get(device_id, {})
317
+ device["cls"] = device.get("class")
318
+ device["battery_low"] = device_id in low_battery_devices
319
+ device["update_available"] = device_id in update_available_devices
320
+ device["updated"] = device_id in updated_devices
321
+ device["unreachable"] = device_id in unreachable_devices
322
+ if device.get("location") is not None:
323
+ roomid = device["location"].removeprefix("/location/")
324
+ device["room"] = room_map.get(roomid)
325
+
326
+ if device["type"] in CONTROLLER_DEVICE_TYPES:
327
+ device["state"] = shc_state
328
+
329
+ devicelist.append(parse_dataclass(device, LivisiDevice))
330
+
331
+ LOGGER.debug("Loaded %d devices", len(devices))
332
+
333
+ return devicelist
334
+
335
+ def parse_messages(self, messages):
336
+ """Parse message data from shc."""
337
+ low_battery_devices = set()
338
+ update_available_devices = set()
339
+ unreachable_devices = set()
340
+ updated_devices = set()
341
+
342
+ for message in messages:
343
+ if isinstance(message, str):
344
+ LOGGER.warning("Invalid message")
345
+ LOGGER.warning(messages)
346
+ continue
347
+
348
+ msgtype = message.get("type", "")
349
+ msgtimestamp = parse_timestamp(message.get("timestamp", ""))
350
+ if msgtimestamp is None:
351
+ continue
352
+
353
+ device_ids = [
354
+ d.removeprefix("/device/") for d in message.get("devices", [])
355
+ ]
356
+ if len(device_ids) == 0:
357
+ source = message.get("source", "")
358
+ device_ids = [source.replace("/device/", "")]
359
+ if msgtype == "DeviceLowBattery":
360
+ for device_id in device_ids:
361
+ low_battery_devices.add(device_id)
362
+ elif msgtype == "DeviceUpdateAvailable":
363
+ for device_id in device_ids:
364
+ update_available_devices.add(device_id)
365
+ elif msgtype == "ProductUpdated" or msgtype == "ShcUpdateCompleted":
366
+ for device_id in device_ids:
367
+ updated_devices.add(device_id)
368
+ elif msgtype == "DeviceUnreachable":
369
+ for device_id in device_ids:
370
+ unreachable_devices.add(device_id)
371
+ return (
372
+ low_battery_devices,
373
+ update_available_devices,
374
+ unreachable_devices,
375
+ updated_devices,
376
+ )
377
+
378
+ async def async_get_value(
379
+ self, capability: str, property: str, key: str = "value"
380
+ ) -> Any | None:
381
+ """Get current value of the capability."""
382
+ state = await self.async_get_state(capability, property)
383
+ if state is None:
384
+ return None
385
+ return state.get(key, None)
386
+
387
+ async def async_get_state(self, capability: str, property: str) -> dict | None:
388
+ """Get state of a capability."""
389
+
390
+ if capability is None:
391
+ return None
392
+
393
+ requestUrl = f"capability/{capability}/state"
394
+ try:
395
+ 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
404
+ )
405
+ return None
406
+
407
+ async def async_set_state(
408
+ self,
409
+ capability_id: str,
410
+ *,
411
+ key: str = None,
412
+ value: bool | float = None,
413
+ namespace: str = "core.RWE",
414
+ ) -> bool:
415
+ """Set the state of a capability."""
416
+ params = {}
417
+ if key is not None:
418
+ params = {key: {"type": "Constant", "value": value}}
419
+
420
+ return await self.async_send_capability_command(
421
+ capability_id, "SetState", namespace=namespace, params=params
422
+ )
423
+
424
+ async def _async_send_command(
425
+ self,
426
+ target: str,
427
+ command_type: str,
428
+ *,
429
+ namespace: str = "core.RWE",
430
+ params: dict = None,
431
+ ) -> bool:
432
+ """Send a command to a target."""
433
+
434
+ if params is None:
435
+ params = {}
436
+
437
+ set_state_payload: dict[str, Any] = {
438
+ "id": uuid.uuid4().hex,
439
+ "type": command_type,
440
+ "namespace": namespace,
441
+ "target": target,
442
+ "params": params,
443
+ }
444
+ try:
445
+ response = await self.async_send_authorized_request(
446
+ "post", "action", payload=set_state_payload
447
+ )
448
+ if response is None:
449
+ return False
450
+ return response.get("resultCode") == "Success"
451
+ except ServerDisconnectedError:
452
+ # Funny thing: The SHC restarts immediatly upon processing the restart command, it doesn't even answer to the request
453
+ # In order to not throw an error we need to catch and assume the request was successfull.
454
+ if command_type == COMMAND_RESTART:
455
+ return True
456
+ raise
457
+
458
+ async def async_send_device_command(
459
+ self,
460
+ device_id: str,
461
+ command_type: str,
462
+ *,
463
+ namespace: str = "core.RWE",
464
+ params: dict = None,
465
+ ) -> bool:
466
+ """Send a command to a device."""
467
+
468
+ return await self._async_send_command(
469
+ target=f"/device/{device_id}",
470
+ command_type=command_type,
471
+ namespace=namespace,
472
+ params=params,
473
+ )
474
+
475
+ async def async_send_capability_command(
476
+ self,
477
+ capability_id: str,
478
+ command_type: str,
479
+ *,
480
+ namespace: str = "core.RWE",
481
+ params: dict = None,
482
+ ) -> bool:
483
+ """Send a command to a capability."""
484
+
485
+ return await self._async_send_command(
486
+ target=f"/capability/{capability_id}",
487
+ command_type=command_type,
488
+ namespace=namespace,
489
+ params=params,
490
+ )
491
+
492
+ @property
493
+ def livisi_connection_data(self):
494
+ """Return the connection data."""
495
+ return self._livisi_connection_data
496
+
497
+ @livisi_connection_data.setter
498
+ def livisi_connection_data(self, new_value):
499
+ self._livisi_connection_data = new_value
500
+
501
+ @property
502
+ def token(self):
503
+ """Return the token."""
504
+ return self._token
505
+
506
+ @token.setter
507
+ def token(self, new_value):
508
+ self._token = new_value
@@ -0,0 +1,30 @@
1
+ """Constants for the Livisi Smart Home integration."""
2
+
3
+ import logging
4
+ from typing import Final
5
+
6
+ LOGGER = logging.getLogger(__package__)
7
+
8
+ V2_NAME = "Avatar"
9
+ V1_NAME = "Classic"
10
+ V2_WEBSOCKET_PORT: Final = 9090
11
+ CLASSIC_WEBSOCKET_PORT: Final = 8080
12
+ WEBSERVICE_PORT: Final = 8080
13
+ REQUEST_TIMEOUT: Final = 2000
14
+
15
+ CONTROLLER_DEVICE_TYPES: Final = ["SHC", "SHCA"]
16
+
17
+ BATTERY_LOW: Final = "batteryLow"
18
+ UPDATE_AVAILABLE: Final = "DeviceUpdateAvailable"
19
+
20
+ LIVISI_EVENT_STATE_CHANGED = "StateChanged"
21
+ LIVISI_EVENT_BUTTON_PRESSED = "ButtonPressed"
22
+ LIVISI_EVENT_MOTION_DETECTED = "MotionDetected"
23
+
24
+ IS_REACHABLE: Final = "isReachable"
25
+
26
+ EVENT_BUTTON_PRESSED = "button_pressed"
27
+ EVENT_BUTTON_LONG_PRESSED = "button_long_pressed"
28
+ EVENT_MOTION_DETECTED = "motion_detected"
29
+
30
+ COMMAND_RESTART = "Restart"
@@ -0,0 +1,17 @@
1
+ """Code to represent a livisi device."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class LivisiController:
10
+ """Stores the livisi controller data."""
11
+
12
+ controller_type: str
13
+ serial_number: str
14
+ os_version: str
15
+
16
+ is_v2: bool
17
+ is_v1: bool
@@ -0,0 +1,53 @@
1
+ """Code to represent a livisi device."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from .livisi_const import CONTROLLER_DEVICE_TYPES
9
+
10
+
11
+ @dataclass
12
+ class LivisiDevice:
13
+ """Stores the livisi device data."""
14
+
15
+ id: str
16
+ type: str
17
+ tags: dict[str, str]
18
+ config: dict[str, Any]
19
+ state: dict[str, Any]
20
+ manufacturer: str
21
+ version: str
22
+ cls: str
23
+ product: str
24
+ desc: str
25
+ capabilities: dict[str, str]
26
+ capability_config: dict[str, dict[str, Any]]
27
+ room: str
28
+ battery_low: bool
29
+ update_available: bool
30
+ updated: bool
31
+ unreachable: bool
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ """Get name from config."""
36
+ return self.config.get("name")
37
+
38
+ @property
39
+ def tag_category(self) -> str:
40
+ """Get tag type category from config."""
41
+ if self.tags is None:
42
+ return None
43
+ return self.tags.get("typeCategory")
44
+
45
+ @property
46
+ def tag_type(self) -> str:
47
+ """Get tag type from config."""
48
+ return self.tags.get("type")
49
+
50
+ @property
51
+ def is_shc(self) -> bool:
52
+ """Indicate whether this device is the controller."""
53
+ return self.type in CONTROLLER_DEVICE_TYPES
@@ -0,0 +1,124 @@
1
+ """Errors for the Livisi Smart Home component."""
2
+
3
+ # Taken from https://developer.services-smarthome.de/api_reference/errorcodes/
4
+ ERROR_CODES = {
5
+ # General Errors
6
+ 1000: "An unknown error has occurred.",
7
+ 1001: "Service unavailable.",
8
+ 1002: "Service timeout.",
9
+ 1003: "Internal API error.",
10
+ 1004: "SHC invalid operation.",
11
+ 1005: "Missing argument or wrong value.",
12
+ 1006: "Service too busy.",
13
+ 1007: "Unsupported request.",
14
+ 1008: "Precondition failed.",
15
+ # Authentication and Authorization Errors
16
+ 2000: "An unknown error has occurred during Authentication or Authorization process.",
17
+ 2001: "Access not allowed.",
18
+ 2002: "Invalid token request.",
19
+ 2003: "Invalid client credentials.",
20
+ 2004: "The token signature is invalid.",
21
+ 2005: "Failed to initialize user session.",
22
+ 2006: "A connection already exists for the current session.",
23
+ 2007: "The lifetime of the token has expired.",
24
+ 2008: "Login attempted from a different client provider.",
25
+ 2009: "Invalid user credentials.",
26
+ 2010: "Controller access not allowed.",
27
+ 2011: "Insufficient permissions.",
28
+ 2012: "Session not found.",
29
+ 2013: "Account temporary locked.",
30
+ # Entities Errors
31
+ 3000: "The requested entity does not exist.",
32
+ 3001: "The provided request content is invalid and can't be parsed.",
33
+ 3002: "No change performed.",
34
+ 3003: "The provided entity already exists.",
35
+ 3004: "The provided interaction is not valid.",
36
+ 3005: "Too many entities of this type.",
37
+ # Products Errors
38
+ 3500: "Premium Services can't be directly enabled.",
39
+ 3501: "Cannot remove a product that was paid.",
40
+ # Actions Errors
41
+ 4000: "The triggered action is invalid.",
42
+ 4001: "Invalid parameter.",
43
+ 4002: "Permission to trigger action not allowed.",
44
+ 4003: "Unsupported action type.",
45
+ # Configuration Errors
46
+ 5000: "The configuration could not be updated.",
47
+ 5001: "Could not obtain exclusive access on the configuration.",
48
+ 5002: "Communication with the SHC failed.",
49
+ 5003: "The owner did not accept the TaC latest version.",
50
+ 5004: "One SHC already registered.",
51
+ 5005: "The user has no SHC.",
52
+ 5006: "Controller offline.",
53
+ 5009: "Registration failure.",
54
+ # SmartCodes Errors
55
+ 6000: "SmartCode request not allowed.",
56
+ 6001: "The SmartCode cannot be redeemed.",
57
+ 6002: "Restricted access.",
58
+ }
59
+
60
+
61
+ class LivisiException(Exception):
62
+ """Base class for Livisi exceptions."""
63
+
64
+ def __init__(self, message: str = "", *args: object) -> None:
65
+ """Initialize the exception with a message."""
66
+ self.message = message
67
+ super().__init__(message, *args)
68
+
69
+
70
+ class ShcUnreachableException(LivisiException):
71
+ """Unable to connect to the Smart Home Controller."""
72
+
73
+ def __init__(
74
+ self,
75
+ message: str = "Unable to connect to the Smart Home Controller.",
76
+ *args: object,
77
+ ) -> None:
78
+ """Generate error with default message."""
79
+ super().__init__(message, *args)
80
+
81
+
82
+ class WrongCredentialException(LivisiException):
83
+ """The user credentials were wrong."""
84
+
85
+ def __init__(
86
+ self, message: str = "The user credentials are wrong.", *args: object
87
+ ) -> None:
88
+ """Generate error with default message."""
89
+ super().__init__(message, *args)
90
+
91
+
92
+ class IncorrectIpAddressException(LivisiException):
93
+ """The IP address provided by the user is incorrect."""
94
+
95
+ def __init__(
96
+ self,
97
+ message: str = "The IP address provided by the user is incorrect.",
98
+ *args: object,
99
+ ) -> None:
100
+ """Generate error with default message."""
101
+ super().__init__(message, *args)
102
+
103
+
104
+ class TokenExpiredException(LivisiException):
105
+ """The authentication token is expired."""
106
+
107
+ def __init__(
108
+ self, message: str = "The authentication token is expired.", *args: object
109
+ ) -> None:
110
+ """Generate error with default message."""
111
+ super().__init__(message, *args)
112
+
113
+
114
+ class ErrorCodeException(LivisiException):
115
+ """The request sent an errorcode (other than token expired) as response."""
116
+
117
+ def __init__(self, error_code: int, message: str = None, *args: object) -> None:
118
+ """Generate error with code."""
119
+ self.error_code = error_code
120
+ if (message is None) and (error_code in ERROR_CODES):
121
+ message = ERROR_CODES[error_code]
122
+ elif message is None:
123
+ message = f"Unknown error code from shc: {error_code}"
124
+ super().__init__(message, *args)
@@ -0,0 +1,23 @@
1
+ """Helper code to parse json to python dataclass (simple and non recursive)."""
2
+ from dataclasses import fields
3
+ import json
4
+ import re
5
+
6
+
7
+ def parse_dataclass(jsondata, clazz):
8
+ """Convert keys to snake_case and parse to dataclass."""
9
+
10
+ if isinstance(jsondata, str | bytes | bytearray):
11
+ parsed_json = json.loads(jsondata)
12
+ elif isinstance(jsondata, dict):
13
+ parsed_json = jsondata
14
+ else:
15
+ parsed_json = {}
16
+
17
+ # Convert keys to snake_case
18
+ parsed_json = {
19
+ re.sub("([A-Z])", r"_\1", k).lower(): v for k, v in parsed_json.items()
20
+ }
21
+ # Only include keys that are fields in the dataclass
22
+ data_dict = {f.name: parsed_json.get(f.name) for f in fields(clazz)}
23
+ return clazz(**data_dict)
@@ -0,0 +1,86 @@
1
+ """Code for communication with the Livisi application websocket."""
2
+
3
+ from collections.abc import Callable
4
+ import urllib.parse
5
+
6
+ from json import JSONDecodeError
7
+ import websockets.client
8
+
9
+ from .livisi_json_util import parse_dataclass
10
+ from .livisi_const import CLASSIC_WEBSOCKET_PORT, V2_WEBSOCKET_PORT, LOGGER
11
+ from .livisi_websocket_event import LivisiWebsocketEvent
12
+
13
+
14
+ class LivisiWebsocket:
15
+ """Represents the websocket class."""
16
+
17
+ def __init__(self, aiolivisi) -> None:
18
+ """Initialize the websocket."""
19
+ self.aiolivisi = aiolivisi
20
+ self.connection_url: str = None
21
+ self._websocket = None
22
+ self._disconnecting = False
23
+
24
+ def is_connected(self):
25
+ """Return whether the webservice is currently connected."""
26
+ return self._websocket is not None
27
+
28
+ async def connect(self, on_data, on_close) -> None:
29
+ """Connect to the socket."""
30
+ if self.aiolivisi.controller.is_v2:
31
+ port = V2_WEBSOCKET_PORT
32
+ token = urllib.parse.quote(self.aiolivisi.token)
33
+ else:
34
+ port = CLASSIC_WEBSOCKET_PORT
35
+ token = self.aiolivisi.token
36
+ ip_address = self.aiolivisi.host
37
+ self.connection_url = f"ws://{ip_address}:{port}/events?token={token}"
38
+
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:
53
+ self._websocket = None
54
+
55
+ async def disconnect(self) -> None:
56
+ """Close the websocket."""
57
+ self._disconnecting = True
58
+ if self._websocket is not None:
59
+ await self._websocket.close(code=1000, reason="Handle disconnect request")
60
+ LOGGER.info("WebSocket connection closed.")
61
+ self._websocket = None
62
+ self._disconnecting = False
63
+
64
+ async def consumer_handler(self, websocket, on_data: Callable):
65
+ """Parse data transmitted via the websocket."""
66
+ try:
67
+ async for message in websocket:
68
+ LOGGER.debug("Received WebSocket message: %s", message)
69
+
70
+ try:
71
+ event_data = parse_dataclass(message, LivisiWebsocketEvent)
72
+ except JSONDecodeError:
73
+ LOGGER.warning("Cannot decode WebSocket message", exc_info=True)
74
+ continue
75
+
76
+ if event_data.properties is None or event_data.properties == {}:
77
+ LOGGER.warning("Received event with no properties, skipping.")
78
+ continue
79
+
80
+ # Remove the URL prefix and use just the ID (which is unique)
81
+ event_data.source = event_data.source.removeprefix("/device/")
82
+ event_data.source = event_data.source.removeprefix("/capability/")
83
+
84
+ on_data(event_data)
85
+ except Exception as e:
86
+ LOGGER.error("Unhandled error in WebSocket consumer handler", exc_info=e)
@@ -0,0 +1,13 @@
1
+ """LivisiWebsocketEvent."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class LivisiWebsocketEvent:
7
+ """Encapuses a livisi event sent via the websocket."""
8
+
9
+ namespace: str
10
+ type: str | None
11
+ source: str
12
+ timestamp: str | None
13
+ properties: dict | None
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: livisi
3
+ Version: 0.0.1
4
+ Summary: Connection library for the abandoned Livisi Smart Home system
5
+ Author-email: Felix Rotthowe <felix@planbnet.org>
6
+ Project-URL: Source, https://github.com/planbnet/livisi
7
+ Project-URL: Tracker, https://github.com/planbnet/livisi/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: colorlog==6.8.2
15
+ Requires-Dist: aiohttp>=3.8.5
16
+ Requires-Dist: websockets>=11.0.3
17
+
18
+ Livisi smart home connection library
19
+
20
+ Readme will be created when everything works as expected
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ livisi/__init__.py
5
+ livisi/livisi_connector.py
6
+ livisi/livisi_const.py
7
+ livisi/livisi_controller.py
8
+ livisi/livisi_device.py
9
+ livisi/livisi_errors.py
10
+ livisi/livisi_json_util.py
11
+ livisi/livisi_websocket.py
12
+ livisi/livisi_websocket_event.py
13
+ livisi.egg-info/PKG-INFO
14
+ livisi.egg-info/SOURCES.txt
15
+ livisi.egg-info/dependency_links.txt
16
+ livisi.egg-info/requires.txt
17
+ livisi.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ colorlog==6.8.2
2
+ aiohttp>=3.8.5
3
+ websockets>=11.0.3
@@ -0,0 +1 @@
1
+ livisi
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "livisi"
7
+ version = "0.0.1"
8
+ description = "Connection library for the abandoned Livisi Smart Home system"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "Felix Rotthowe", email = "felix@planbnet.org" },
13
+ ]
14
+ license = { file = "LICENSE" }
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "colorlog==6.8.2",
22
+ "aiohttp>=3.8.5",
23
+ "websockets>=11.0.3",
24
+ ]
25
+
26
+ [project.urls]
27
+ "Source" = "https://github.com/planbnet/livisi"
28
+ "Tracker" = "https://github.com/planbnet/livisi/issues"
livisi-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+