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
livisi/livisi_const.py
ADDED
@@ -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
|
livisi/livisi_device.py
ADDED
@@ -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
|
livisi/livisi_errors.py
ADDED
@@ -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,108 @@
|
|
1
|
+
"""Code for communication with the Livisi application websocket."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
from collections.abc import Callable
|
5
|
+
import urllib.parse
|
6
|
+
|
7
|
+
from json import JSONDecodeError
|
8
|
+
import websockets.client
|
9
|
+
|
10
|
+
from .livisi_json_util import parse_dataclass
|
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
|
+
)
|
19
|
+
from .livisi_websocket_event import LivisiWebsocketEvent
|
20
|
+
|
21
|
+
|
22
|
+
class LivisiWebsocket:
|
23
|
+
"""Represents the websocket class."""
|
24
|
+
|
25
|
+
def __init__(self, aiolivisi) -> None:
|
26
|
+
"""Initialize the websocket."""
|
27
|
+
self.aiolivisi = aiolivisi
|
28
|
+
self.connection_url: str = None
|
29
|
+
self._websocket = None
|
30
|
+
self._disconnecting = False
|
31
|
+
|
32
|
+
def is_connected(self):
|
33
|
+
"""Return whether the webservice is currently connected."""
|
34
|
+
return self._websocket is not None
|
35
|
+
|
36
|
+
async def connect(self, on_data, on_close) -> None:
|
37
|
+
"""Connect to the socket."""
|
38
|
+
if self.aiolivisi.controller.is_v2:
|
39
|
+
port = V2_WEBSOCKET_PORT
|
40
|
+
token = urllib.parse.quote(self.aiolivisi.token)
|
41
|
+
else:
|
42
|
+
port = CLASSIC_WEBSOCKET_PORT
|
43
|
+
token = self.aiolivisi.token
|
44
|
+
ip_address = self.aiolivisi.host
|
45
|
+
self.connection_url = f"ws://{ip_address}:{port}/events?token={token}"
|
46
|
+
|
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)
|
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()
|
61
|
+
|
62
|
+
async def disconnect(self) -> None:
|
63
|
+
"""Close the websocket."""
|
64
|
+
self._disconnecting = True
|
65
|
+
if self._websocket is not None:
|
66
|
+
await self._websocket.close(code=1000, reason="Handle disconnect request")
|
67
|
+
LOGGER.info("WebSocket connection closed.")
|
68
|
+
self._websocket = None
|
69
|
+
self._disconnecting = False
|
70
|
+
|
71
|
+
async def consumer_handler(self, websocket, on_data: Callable):
|
72
|
+
"""Parse data transmitted via the websocket."""
|
73
|
+
try:
|
74
|
+
async for message in websocket:
|
75
|
+
LOGGER.debug("Received WebSocket message: %s", message)
|
76
|
+
|
77
|
+
try:
|
78
|
+
event_data = parse_dataclass(message, LivisiWebsocketEvent)
|
79
|
+
except JSONDecodeError:
|
80
|
+
LOGGER.warning("Cannot decode WebSocket message", exc_info=True)
|
81
|
+
continue
|
82
|
+
|
83
|
+
if event_data.properties is None or event_data.properties == {}:
|
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
|
+
)
|
94
|
+
continue
|
95
|
+
|
96
|
+
# Remove the URL prefix and use just the ID (which is unique)
|
97
|
+
event_data.source = event_data.source.removeprefix("/device/")
|
98
|
+
event_data.source = event_data.source.removeprefix("/capability/")
|
99
|
+
|
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")
|
107
|
+
except Exception as e:
|
108
|
+
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,27 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: livisi
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: Connection library for the abandoned Livisi Smart Home system
|
5
|
+
Author-email: Felix Rotthowe <felix@planbnet.org>
|
6
|
+
License: Apache-2.0
|
7
|
+
Project-URL: Source, https://github.com/planbnet/livisi
|
8
|
+
Project-URL: Tracker, https://github.com/planbnet/livisi/issues
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
11
|
+
Classifier: Operating System :: OS Independent
|
12
|
+
Requires-Python: >=3.10
|
13
|
+
Description-Content-Type: text/markdown
|
14
|
+
License-File: LICENSE
|
15
|
+
Requires-Dist: colorlog>=6.8.2
|
16
|
+
Requires-Dist: aiohttp>=3.8.5
|
17
|
+
Requires-Dist: websockets>=11.0.3
|
18
|
+
Dynamic: license-file
|
19
|
+
|
20
|
+
# livisi
|
21
|
+
|
22
|
+
# Asynchronous library to communicate with LIVISI Smart Home Controller
|
23
|
+
Requires Python 3.10+ (might work with versions down to 3.8 but I never tested it) and uses asyncio and aiohttp.
|
24
|
+
|
25
|
+
This library started as a fork of the unmaintained aiolivisi lib and was developed inside the [unofficial livisi integration for Home Assistant](https://github.com/planbnet/livisi_unofficial)
|
26
|
+
|
27
|
+
The versions starting with `0.0.` are still compatible to the old aiolivisi code, while `1.0.0` will introduce lots of breaking changes besides support for more devices and improved connection stability and error handling.
|
@@ -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,,
|
File without changes
|