livisi 0.0.1__py3-none-any.whl → 0.0.19__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
@@ -1,93 +1,4 @@
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
- ]
1
+ from .aiolivisi import AioLivisi
2
+ from .websocket import Websocket
3
+ from .errors import IncorrectIpAddressException, WrongCredentialException, ShcUnreachableException, LivisiException
4
+ from .livisi_event import LivisiEvent
livisi/aiolivisi.py ADDED
@@ -0,0 +1,270 @@
1
+ """Code to handle the communication with Livisi Smart home controllers."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+ import uuid
5
+
6
+ from aiohttp.client import ClientSession
7
+
8
+ from .errors import (
9
+ IncorrectIpAddressException,
10
+ ShcUnreachableException,
11
+ WrongCredentialException,
12
+ TokenExpiredException,
13
+ )
14
+
15
+ from .const import (
16
+ AUTH_GRANT_TYPE,
17
+ AUTH_PASSWORD,
18
+ AUTH_USERNAME,
19
+ AUTHENTICATION_HEADERS,
20
+ CLASSIC_PORT,
21
+ LOCATION,
22
+ CAPABILITY_MAP,
23
+ CAPABILITY_CONFIG,
24
+ REQUEST_TIMEOUT,
25
+ USERNAME,
26
+ )
27
+
28
+ ERRORS = {1: Exception}
29
+
30
+
31
+ class AioLivisi:
32
+ """Handles the communication with the Livisi Smart Home controller."""
33
+
34
+ instance = None
35
+
36
+ def __init__(
37
+ self, web_session: ClientSession = None, auth_headers: dict[str, Any] = None
38
+ ) -> None:
39
+ self._web_session: ClientSession = web_session
40
+ self._auth_headers: dict[str, Any] = auth_headers
41
+ self._token: str = ""
42
+ self._livisi_connection_data: dict[str, str] = None
43
+
44
+ async def async_set_token(
45
+ self, livisi_connection_data: dict[str, str] = None
46
+ ) -> None:
47
+ """Set the JWT from the LIVISI Smart Home Controller."""
48
+ access_data: dict = {}
49
+ try:
50
+ if self._livisi_connection_data is not None:
51
+ self._livisi_connection_data = livisi_connection_data
52
+ access_data = await self.async_get_jwt_token(livisi_connection_data)
53
+ self.token = access_data["access_token"]
54
+ self._auth_headers = {
55
+ "authorization": f"Bearer {self.token}",
56
+ "Content-type": "application/json",
57
+ "Accept": "*/*",
58
+ }
59
+ except Exception as error:
60
+ if len(access_data) == 0:
61
+ raise IncorrectIpAddressException from error
62
+ elif access_data["errorcode"] == 2009:
63
+ raise WrongCredentialException from error
64
+ else:
65
+ raise ShcUnreachableException from error
66
+
67
+ async def async_send_authorized_request(
68
+ self,
69
+ method,
70
+ url: str,
71
+ payload=None,
72
+ ) -> dict:
73
+ """Make a request to the Livisi Smart Home controller."""
74
+ ip_address = self._livisi_connection_data["ip_address"]
75
+ path = f"http://{ip_address}:{CLASSIC_PORT}/{url}"
76
+ return await self.async_send_request(method, path, payload, self._auth_headers)
77
+
78
+ async def async_send_unauthorized_request(
79
+ self,
80
+ method,
81
+ url: str,
82
+ headers,
83
+ payload=None,
84
+ ) -> dict:
85
+ """Send a request without JWT token."""
86
+ return await self.async_send_request(method, url, payload, headers)
87
+
88
+ async def async_get_jwt_token(self, livisi_connection_data: dict[str, str]):
89
+ """Send a request for getting the JWT token."""
90
+ login_credentials = {
91
+ AUTH_USERNAME: USERNAME,
92
+ AUTH_PASSWORD: livisi_connection_data["password"],
93
+ AUTH_GRANT_TYPE: "password",
94
+ }
95
+ headers = AUTHENTICATION_HEADERS
96
+ self._livisi_connection_data = livisi_connection_data
97
+ ip_address = self._livisi_connection_data["ip_address"]
98
+ return await self.async_send_request(
99
+ "post",
100
+ url=f"http://{ip_address}:{CLASSIC_PORT}/auth/token",
101
+ payload=login_credentials,
102
+ headers=headers,
103
+ )
104
+
105
+ async def async_send_request(
106
+ self, method, url: str, payload=None, headers=None
107
+ ) -> dict:
108
+ """Send a request to the Livisi Smart Home controller."""
109
+ try:
110
+ response = await self.__async_send_request(method, url, payload, headers)
111
+ except Exception:
112
+ response = await self.__async_send_request(method, url, payload, headers)
113
+ if "errorcode" in response:
114
+ if response["errorcode"] == 2007:
115
+ raise TokenExpiredException
116
+ return response
117
+
118
+ async def __async_send_request(
119
+ self, method, url: str, payload=None, headers=None
120
+ ) -> dict:
121
+ async with self._web_session.request(
122
+ method,
123
+ url,
124
+ json=payload,
125
+ headers=headers,
126
+ ssl=False,
127
+ timeout=REQUEST_TIMEOUT,
128
+ ) as res:
129
+ data = await res.json()
130
+ return data
131
+
132
+ async def async_get_controller(self) -> dict[str, Any]:
133
+ """Get Livisi Smart Home controller data."""
134
+ return await self.async_get_controller_status()
135
+
136
+ async def async_get_controller_status(self) -> dict[str, Any]:
137
+ """Get Livisi Smart Home controller status."""
138
+ shc_info = await self.async_send_authorized_request("get", url="status")
139
+ return shc_info
140
+
141
+ async def async_get_devices(
142
+ self,
143
+ ) -> list[dict[str, Any]]:
144
+ """Send a request for getting the devices."""
145
+ devices = await self.async_send_authorized_request("get", url="device")
146
+ capabilities = await self.async_send_authorized_request("get", url="capability")
147
+
148
+ capability_map = {}
149
+ capability_config = {}
150
+
151
+ for capability in capabilities:
152
+ if "device" in capability:
153
+ device_id = capability["device"].split("/")[-1]
154
+ if device_id not in capability_map:
155
+ capability_map[device_id] = {}
156
+ capability_config[device_id] = {}
157
+ capability_map[device_id][capability["type"]] = (
158
+ "/capability/" + capability["id"]
159
+ )
160
+ if "config" in capability:
161
+ capability_config[device_id][capability["type"]] = capability["config"]
162
+
163
+ for device in devices:
164
+ device_id = device["id"]
165
+ device[CAPABILITY_MAP] = capability_map.get(device_id, {})
166
+ device[CAPABILITY_CONFIG] = capability_config.get(device_id, {})
167
+
168
+ for device in devices.copy():
169
+ if LOCATION in device and device.get(LOCATION) is not None:
170
+ device[LOCATION] = device[LOCATION].removeprefix("/location/")
171
+ return devices
172
+
173
+ async def async_get_device_state(self, capability) -> dict[str, Any] | None:
174
+ """Get the state of the device."""
175
+ url = f"{capability}/state"
176
+ try:
177
+ return await self.async_send_authorized_request("get", url)
178
+ except Exception:
179
+ return None
180
+
181
+ async def async_pss_set_state(self, capability_id, is_on: bool) -> dict[str, Any]:
182
+ """Set the PSS state."""
183
+ set_state_payload: dict[str, Any] = {
184
+ "id": uuid.uuid4().hex,
185
+ "type": "SetState",
186
+ "namespace": "core.RWE",
187
+ "target": capability_id,
188
+ "params": {"onState": {"type": "Constant", "value": is_on}},
189
+ }
190
+ return await self.async_send_authorized_request(
191
+ "post", "action", payload=set_state_payload
192
+ )
193
+
194
+ async def async_set_onstate(self, capability_id, is_on: bool) -> dict[str, Any]:
195
+ """Set the onState for devices that support it."""
196
+ set_state_payload: dict[str, Any] = {
197
+ "id": uuid.uuid4().hex,
198
+ "type": "SetState",
199
+ "namespace": "core.RWE",
200
+ "target": capability_id,
201
+ "params": {"onState": {"type": "Constant", "value": is_on}},
202
+ }
203
+ return await self.async_send_authorized_request(
204
+ "post", "action", payload=set_state_payload
205
+ )
206
+
207
+ async def async_variable_set_value(
208
+ self, capability_id, value: bool
209
+ ) -> dict[str, Any]:
210
+ """Set the boolean variable state."""
211
+ set_value_payload: dict[str, Any] = {
212
+ "id": uuid.uuid4().hex,
213
+ "type": "SetState",
214
+ "namespace": "core.RWE",
215
+ "target": capability_id,
216
+ "params": {"value": {"type": "Constant", "value": value}},
217
+ }
218
+ return await self.async_send_authorized_request(
219
+ "post", "action", payload=set_value_payload
220
+ )
221
+
222
+ async def async_vrcc_set_temperature(
223
+ self, capability_id, target_temperature: float, is_avatar: bool
224
+ ) -> dict[str, Any]:
225
+ """Set the Virtual Climate Control state."""
226
+ if is_avatar:
227
+ params = "setpointTemperature"
228
+ else:
229
+ params = "pointTemperature"
230
+ set_state_payload: dict[str, Any] = {
231
+ "id": uuid.uuid4().hex,
232
+ "type": "SetState",
233
+ "namespace": "core.RWE",
234
+ "target": capability_id,
235
+ "params": {params: {"type": "Constant", "value": target_temperature}},
236
+ }
237
+ return await self.async_send_authorized_request(
238
+ "post", "action", payload=set_state_payload
239
+ )
240
+
241
+ async def async_get_all_rooms(self) -> dict[str, Any]:
242
+ """Get all the rooms from LIVISI configuration."""
243
+ return await self.async_send_authorized_request("get", "location")
244
+
245
+ @property
246
+ def livisi_connection_data(self):
247
+ """Return the connection data."""
248
+ return self._livisi_connection_data
249
+
250
+ @livisi_connection_data.setter
251
+ def livisi_connection_data(self, new_value):
252
+ self._livisi_connection_data = new_value
253
+
254
+ @property
255
+ def token(self):
256
+ """Return the token."""
257
+ return self._token
258
+
259
+ @token.setter
260
+ def token(self, new_value):
261
+ self._token = new_value
262
+
263
+ @property
264
+ def web_session(self):
265
+ """Return the web session."""
266
+ return self._web_session
267
+
268
+ @web_session.setter
269
+ def web_session(self, new_value):
270
+ self._web_session = new_value
livisi/const.py ADDED
@@ -0,0 +1,40 @@
1
+ from typing import Final
2
+
3
+
4
+ CLASSIC_PORT: Final = 8080
5
+ AVATAR_PORT: Final = 9090
6
+ USERNAME: Final = "admin"
7
+ AUTH_USERNAME: Final = "username"
8
+ AUTH_PASSWORD: Final = "password"
9
+ AUTH_GRANT_TYPE: Final = "grant_type"
10
+ REQUEST_TIMEOUT: Final = 2000
11
+
12
+ ON_STATE: Final = "onState"
13
+ VALUE: Final = "value"
14
+ POINT_TEMPERATURE: Final = "pointTemperature"
15
+ SET_POINT_TEMPERATURE: Final = "setpointTemperature"
16
+ TEMPERATURE: Final = "temperature"
17
+ HUMIDITY: Final = "humidity"
18
+ LUMINANCE: Final = "luminance"
19
+ IS_REACHABLE: Final = "isReachable"
20
+ IS_OPEN: Final = "isOpen"
21
+ LOCATION: Final = "location"
22
+
23
+ KEY_INDEX: Final = "index"
24
+ KEY_PRESS_TYPE: Final = "type"
25
+ KEY_PRESS_SHORT: Final = "ShortPress"
26
+ KEY_PRESS_LONG: Final = "LongPress"
27
+
28
+
29
+ CAPABILITY_MAP: Final = "capabilityMap"
30
+ CAPABILITY_CONFIG: Final = "capabilityConfig"
31
+
32
+ EVENT_STATE_CHANGED = "StateChanged"
33
+ EVENT_BUTTON_PRESSED = "ButtonPressed"
34
+ EVENT_MOTION_DETECTED = "MotionDetected"
35
+
36
+ AUTHENTICATION_HEADERS: Final = {
37
+ "Authorization": "Basic Y2xpZW50SWQ6Y2xpZW50UGFzcw==",
38
+ "Content-type": "application/json",
39
+ "Accept": "application/json",
40
+ }
livisi/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Errors for the Livisi Smart Home component."""
2
+
3
+ class LivisiException(Exception):
4
+ """Base class for Livisi exceptions."""
5
+
6
+
7
+ class ShcUnreachableException(LivisiException):
8
+ """Unable to connect to the Smart Home Controller."""
9
+
10
+
11
+ class WrongCredentialException(LivisiException):
12
+ """The user credentials were wrong."""
13
+
14
+
15
+ class IncorrectIpAddressException(LivisiException):
16
+ """The IP address provided by the user is incorrect."""
17
+
18
+
19
+ class TokenExpiredException(LivisiException):
20
+ """The authentication token is expired."""
livisi/livisi_event.py ADDED
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass(init=False)
7
+ class LivisiEvent(BaseModel):
8
+ namespace: str
9
+ properties: Optional[dict]
10
+ source: str
11
+ onState: Optional[bool]
12
+ vrccData: Optional[float]
13
+ luminance: Optional[int]
14
+ isReachable: Optional[bool]
15
+ sequenceNumber: Optional[str]
16
+ type: Optional[str]
17
+ timestamp: Optional[str]
18
+ isOpen: Optional[bool]
19
+ keyIndex: Optional[int]
20
+ isLongKeyPress: Optional[bool]
livisi/websocket.py ADDED
@@ -0,0 +1,106 @@
1
+ """Code for communication with the Livisi application websocket."""
2
+ from typing import Callable
3
+ import urllib.parse
4
+
5
+ import websockets
6
+ from pydantic import ValidationError
7
+
8
+ from aiolivisi.livisi_event import LivisiEvent
9
+
10
+ from .aiolivisi import AioLivisi
11
+ from .const import (
12
+ AVATAR_PORT,
13
+ IS_REACHABLE,
14
+ ON_STATE,
15
+ VALUE,
16
+ IS_OPEN,
17
+ SET_POINT_TEMPERATURE,
18
+ POINT_TEMPERATURE,
19
+ HUMIDITY,
20
+ TEMPERATURE,
21
+ LUMINANCE,
22
+ KEY_INDEX,
23
+ KEY_PRESS_LONG,
24
+ KEY_PRESS_TYPE,
25
+ EVENT_BUTTON_PRESSED,
26
+ EVENT_STATE_CHANGED,
27
+ )
28
+
29
+
30
+ class Websocket:
31
+ """Represents the websocket class."""
32
+
33
+ def __init__(self, aiolivisi: AioLivisi) -> None:
34
+ """Initialize the websocket."""
35
+ self.aiolivisi = aiolivisi
36
+ self.connection_url: str = None
37
+
38
+ async def connect(self, on_data, on_close, port: int) -> None:
39
+ """Connect to the socket."""
40
+ if port == AVATAR_PORT:
41
+ token = urllib.parse.quote(self.aiolivisi.token)
42
+ else:
43
+ token = self.aiolivisi.token
44
+ ip_address = self.aiolivisi.livisi_connection_data["ip_address"]
45
+ self.connection_url = f"ws://{ip_address}:{port}/events?token={token}"
46
+ try:
47
+ async with websockets.connect(
48
+ self.connection_url, ping_interval=10, ping_timeout=10
49
+ ) as websocket:
50
+ try:
51
+ self._websocket = websocket
52
+ await self.consumer_handler(websocket, on_data)
53
+ except Exception:
54
+ await on_close()
55
+ return
56
+ except Exception:
57
+ await on_close()
58
+ return
59
+
60
+ async def disconnect(self) -> None:
61
+ """Close the websocket."""
62
+ await self._websocket.close(code=1000, reason="Handle disconnect request")
63
+
64
+ async def consumer_handler(self, websocket, on_data: Callable):
65
+ """Used when data is transmited using the websocket."""
66
+ async for message in websocket:
67
+ try:
68
+ event_data = LivisiEvent.parse_raw(message)
69
+ except ValidationError:
70
+ continue
71
+
72
+ if "device" in event_data.source:
73
+ event_data.source = event_data.source.replace("/device/", "")
74
+ if event_data.properties is None:
75
+ continue
76
+
77
+ if event_data.type == EVENT_STATE_CHANGED:
78
+ if ON_STATE in event_data.properties.keys():
79
+ event_data.onState = event_data.properties.get(ON_STATE)
80
+ elif VALUE in event_data.properties.keys() and isinstance(
81
+ event_data.properties.get(VALUE), bool
82
+ ):
83
+ event_data.onState = event_data.properties.get(VALUE)
84
+ if SET_POINT_TEMPERATURE in event_data.properties.keys():
85
+ event_data.vrccData = event_data.properties.get(
86
+ SET_POINT_TEMPERATURE
87
+ )
88
+ elif POINT_TEMPERATURE in event_data.properties.keys():
89
+ event_data.vrccData = event_data.properties.get(POINT_TEMPERATURE)
90
+ elif TEMPERATURE in event_data.properties.keys():
91
+ event_data.vrccData = event_data.properties.get(TEMPERATURE)
92
+ elif HUMIDITY in event_data.properties.keys():
93
+ event_data.vrccData = event_data.properties.get(HUMIDITY)
94
+ if LUMINANCE in event_data.properties.keys():
95
+ event_data.luminance = event_data.properties.get(LUMINANCE)
96
+ if IS_REACHABLE in event_data.properties.keys():
97
+ event_data.isReachable = event_data.properties.get(IS_REACHABLE)
98
+ if IS_OPEN in event_data.properties.keys():
99
+ event_data.isOpen = event_data.properties.get(IS_OPEN)
100
+ elif event_data.type == EVENT_BUTTON_PRESSED:
101
+ if KEY_INDEX in event_data.properties.keys():
102
+ event_data.keyIndex = event_data.properties.get(KEY_INDEX)
103
+ event_data.isLongKeyPress = (
104
+ KEY_PRESS_LONG == event_data.properties.get(KEY_PRESS_TYPE)
105
+ )
106
+ on_data(event_data)