pax_global_header00006660000000000000000000000064150277420550014521gustar00rootroot0000000000000052 comment=6a38f25e08b884ec4655899aca90b79eed27765b planbnet-livisi-6a38f25/000077500000000000000000000000001502774205500151615ustar00rootroot00000000000000planbnet-livisi-6a38f25/.github/000077500000000000000000000000001502774205500165215ustar00rootroot00000000000000planbnet-livisi-6a38f25/.github/workflows/000077500000000000000000000000001502774205500205565ustar00rootroot00000000000000planbnet-livisi-6a38f25/.github/workflows/python-package.yml000066400000000000000000000013021502774205500242070ustar00rootroot00000000000000name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI on: push: tags: - 'v*.*.*' jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: Build package run: python -m build - name: Publish package to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | python -m twine upload dist/* planbnet-livisi-6a38f25/.gitignore000066400000000000000000000000251502774205500171460ustar00rootroot00000000000000venv dist *.egg-info planbnet-livisi-6a38f25/LICENSE000066400000000000000000000000001502774205500161540ustar00rootroot00000000000000planbnet-livisi-6a38f25/README.md000066400000000000000000000011441502774205500164400ustar00rootroot00000000000000# livisi # Asynchronous library to communicate with LIVISI Smart Home Controller Requires Python 3.10+ (might work with versions down to 3.8 but I never tested it) and uses asyncio and aiohttp. 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) 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. planbnet-livisi-6a38f25/livisi/000077500000000000000000000000001502774205500164605ustar00rootroot00000000000000planbnet-livisi-6a38f25/livisi/__init__.py000066400000000000000000000041711502774205500205740ustar00rootroot00000000000000# __init__.py # Import key classes, constants, and exceptions # livisi_connector.py from .livisi_connector import LivisiConnection, connect # livisi_controller.py from .livisi_controller import LivisiController # livisi_device.py from .livisi_device import LivisiDevice # livisi_websocket.py from .livisi_websocket import LivisiWebsocket # livisi_websocket_event.py from .livisi_websocket_event import LivisiWebsocketEvent # livisi_const.py from .livisi_const import ( LOGGER, V2_NAME, V1_NAME, V2_WEBSOCKET_PORT, CLASSIC_WEBSOCKET_PORT, WEBSERVICE_PORT, REQUEST_TIMEOUT, CONTROLLER_DEVICE_TYPES, BATTERY_LOW, UPDATE_AVAILABLE, LIVISI_EVENT_STATE_CHANGED, LIVISI_EVENT_BUTTON_PRESSED, LIVISI_EVENT_MOTION_DETECTED, IS_REACHABLE, EVENT_BUTTON_PRESSED, EVENT_BUTTON_LONG_PRESSED, EVENT_MOTION_DETECTED, COMMAND_RESTART, ) # livisi_errors.py from .livisi_errors import ( LivisiException, ShcUnreachableException, WrongCredentialException, IncorrectIpAddressException, ErrorCodeException, ERROR_CODES, ) # Define __all__ to specify what is exported when using 'from livisi import *' __all__ = [ # From livisi_connector.py "LivisiConnection", "connect", # From livisi_controller.py "LivisiController", # From livisi_device.py "LivisiDevice", # From livisi_websocket.py "LivisiWebsocket", # From livisi_websocket_event.py "LivisiWebsocketEvent", # From livisi_const.py "LOGGER", "V2_NAME", "V1_NAME", "V2_WEBSOCKET_PORT", "CLASSIC_WEBSOCKET_PORT", "WEBSERVICE_PORT", "REQUEST_TIMEOUT", "CONTROLLER_DEVICE_TYPES", "BATTERY_LOW", "UPDATE_AVAILABLE", "LIVISI_EVENT_STATE_CHANGED", "LIVISI_EVENT_BUTTON_PRESSED", "LIVISI_EVENT_MOTION_DETECTED", "IS_REACHABLE", "EVENT_BUTTON_PRESSED", "EVENT_BUTTON_LONG_PRESSED", "EVENT_MOTION_DETECTED", "COMMAND_RESTART", # From livisi_errors.py "LivisiException", "ShcUnreachableException", "WrongCredentialException", "IncorrectIpAddressException", "ErrorCodeException", "ERROR_CODES", ] planbnet-livisi-6a38f25/livisi/livisi_connector.py000066400000000000000000000605751502774205500224200ustar00rootroot00000000000000"""Code to handle the communication with Livisi Smart home controllers.""" from __future__ import annotations import asyncio import time import base64 from contextlib import suppress from typing import Any import uuid import json from aiohttp import ClientResponseError, ServerDisconnectedError, ClientConnectorError from aiohttp.client import ClientSession, ClientError, TCPConnector from dateutil.parser import parse as parse_timestamp from .livisi_device import LivisiDevice from .livisi_json_util import parse_dataclass from .livisi_controller import LivisiController from .livisi_errors import ( ERROR_CODES, IncorrectIpAddressException, LivisiException, ShcUnreachableException, WrongCredentialException, ErrorCodeException, ) from .livisi_websocket import LivisiWebsocket from .livisi_const import ( COMMAND_RESTART, CONTROLLER_DEVICE_TYPES, V1_NAME, V2_NAME, LOGGER, REQUEST_TIMEOUT, WEBSERVICE_PORT, ) async def connect(host: str, password: str) -> LivisiConnection: """Initialize the lib and connect to the livisi SHC.""" connection = LivisiConnection() await connection.connect(host, password) return connection class LivisiConnection: """Handles the communication with the Livisi Smart Home controller.""" def __init__(self) -> None: """Initialize the livisi connector.""" self.host: str = None self.controller: LivisiController = None self._password: str = None self._token: str = None self._web_session = None self._websocket = LivisiWebsocket(self) self._token_refresh_lock = asyncio.Lock() def _decode_jwt_payload(self, token: str) -> dict | None: """Decode JWT payload and return payload dict or None on error.""" if not token: return None try: # JWT tokens have 3 parts separated by dots: header.payload.signature parts = token.split(".") if len(parts) != 3: return None # Decode the payload (second part) payload = parts[1] # Add padding if needed (JWT base64 encoding might not have padding) padding = 4 - (len(payload) % 4) if padding != 4: payload += "=" * padding try: decoded_bytes = base64.urlsafe_b64decode(payload) payload_json = json.loads(decoded_bytes.decode("utf-8")) return payload_json except (json.JSONDecodeError, UnicodeDecodeError): return None except Exception: return None def _format_token_info(self, token: str) -> str: """Format token information for logging.""" payload = self._decode_jwt_payload(token) if not payload: return "None" if not token else f"Invalid JWT (length: {len(token)})" info_parts = [] # User/subject if "sub" in payload: info_parts.append(f"user: {payload['sub']}") elif "username" in payload: info_parts.append(f"user: {payload['username']}") # Expiration time if "exp" in payload: exp_time = payload["exp"] current_time = time.time() if exp_time > current_time: time_left = exp_time - current_time if time_left > 3600: info_parts.append(f"expires in: {time_left/3600:.1f}h") elif time_left > 60: info_parts.append(f"expires in: {time_left/60:.1f}m") else: info_parts.append(f"expires in: {time_left:.0f}s") else: info_parts.append("expired") # Issue time (age) if "iat" in payload: iat_time = payload["iat"] age = time.time() - iat_time if age > 3600: info_parts.append(f"age: {age/3600:.1f}h") elif age > 60: info_parts.append(f"age: {age/60:.1f}m") else: info_parts.append(f"age: {age:.0f}s") # Token ID if available if "jti" in payload: jti = payload["jti"] if len(jti) > 8: info_parts.append(f"id: {jti[:8]}...") else: info_parts.append(f"id: {jti}") if info_parts: return f"JWT({', '.join(info_parts)})" else: return f"JWT({len(payload)} claims)" async def connect(self, host: str, password: str): """Connect to the livisi SHC and retrieve controller information.""" if self._web_session is not None: await self.close() self._web_session = self._create_web_session(concurrent_connections=1) if host is not None and password is not None: self.host = host self._password = password try: await self._async_retrieve_token() except: await self.close() raise self._connect_time = time.time() self.controller = await self._async_get_controller() if self.controller.is_v2: # reconnect with more concurrent connections on v2 SHC await self._web_session.close() self._web_session = self._create_web_session(concurrent_connections=10) async def close(self): """Disconnect the http client session and websocket.""" if self._web_session is not None: await self._web_session.close() self._web_session = None self.controller = None await self._websocket.disconnect() async def listen_for_events(self, on_data, on_close) -> None: """Connect to the websocket.""" if self._web_session is None: raise LivisiException("Not authenticated to SHC") if self._websocket.is_connected(): with suppress(Exception): await self._websocket.disconnect() await self._websocket.connect(on_data, on_close) async def async_send_authorized_request( self, method, path: str, payload=None, ) -> dict: """Make a request to the Livisi Smart Home controller.""" url = f"http://{self.host}:{WEBSERVICE_PORT}/{path}" auth_headers = { "authorization": f"Bearer {self.token}", "Content-type": "application/json", "Accept": "*/*", } return await self._async_request(method, url, payload, auth_headers) def _create_web_session(self, concurrent_connections: int = 1): """Create a custom web session which limits concurrent connections.""" connector = TCPConnector( limit=concurrent_connections, limit_per_host=concurrent_connections, force_close=True, ) web_session = ClientSession(connector=connector) return web_session async def _async_retrieve_token(self) -> None: """Set the token from the LIVISI Smart Home Controller.""" access_data: dict = {} # Ensure token is cleared before attempting to fetch a new one # so that future requests will reauthenticate on failure self.token = None if self._password is None: raise LivisiException("No password set") login_credentials = { "username": "admin", "password": self._password, "grant_type": "password", } headers = { "Authorization": "Basic Y2xpZW50SWQ6Y2xpZW50UGFzcw==", "Content-type": "application/json", "Accept": "application/json", } try: LOGGER.debug("Updating access token") access_data = await self._async_send_request( "post", url=f"http://{self.host}:{WEBSERVICE_PORT}/auth/token", payload=login_credentials, headers=headers, ) LOGGER.debug("Updated access token") new_token = access_data.get("access_token") LOGGER.info( "Received token from SHC: %s", self._format_token_info(new_token) ) self.token = new_token if self.token is None: errorcode = access_data.get("errorcode") errordesc = access_data.get("description", "Unknown Error") if errorcode in (2003, 2009): LOGGER.debug("Invalid credentials for SHC") raise WrongCredentialException # log full response for debugging LOGGER.error("SHC response does not contain access token") LOGGER.error(access_data) raise LivisiException(f"No token received from SHC: {errordesc}") self._connect_time = time.time() except ClientError as error: LOGGER.debug("Error connecting to SHC: %s", error) if len(access_data) == 0: raise IncorrectIpAddressException from error raise ShcUnreachableException from error except TimeoutError as error: LOGGER.debug("Timeout waiting for SHC") raise ShcUnreachableException("Timeout waiting for shc") from error except ClientResponseError as error: LOGGER.debug("SHC response: %s", error.message) if error.status == 401: raise WrongCredentialException from error raise LivisiException( f"Invalid response from SHC, response code {error.status} ({error.message})" ) from error except Exception as error: LOGGER.debug("Error retrieving token from SHC: %s", error) raise LivisiException("Error retrieving token from SHC") from error async def _async_refresh_token(self) -> None: """Refresh the token if needed, using a lock to prevent concurrent refreshes.""" # remember the token that was expired, so we can check if it was already refreshed by another request expired_token = self.token async with self._token_refresh_lock: # Check if token needs to be refreshed if self.token is None or self.token == expired_token: LOGGER.info( "Livisi token %s is missing or expired, requesting new token from SHC", self._format_token_info(self.token), ) try: await self._async_retrieve_token() except Exception as e: LOGGER.error("Unhandled error requesting token", exc_info=e) raise else: # Token was already refreshed by another request during the lock LOGGER.debug( "Token already refreshed by another request, using new token %s", self._format_token_info(self.token), ) async def _async_request( self, method, url: str, payload=None, headers=None ) -> dict: """Send a request to the Livisi Smart Home controller and handle requesting new token.""" # Check if the token is expired (not sure if this works on V1 SHC, so keep the old 2007 refresh code below too) token_payload = self._decode_jwt_payload(self.token) if token_payload: expires = token_payload.get("exp", 0) if expires > 0 and time.time() >= expires: LOGGER.debug( "Livisi token %s detected as expired", self._format_token_info(self.token), ) # Token is expired, we need to refresh it try: await self._async_refresh_token() except Exception as e: LOGGER.error("Unhandled error refreshing token", exc_info=e) raise # now send the request response = await self._async_send_request(method, url, payload, headers) if response is not None and "errorcode" in response: errorcode = response.get("errorcode") # Handle expired token (2007) if errorcode == 2007: LOGGER.debug( "Livisi token %s expired (error 2007)", self._format_token_info(self.token), ) await self._async_refresh_token() # Retry the original request with the (possibly new) token try: response = await self._async_send_request( method, url, payload, headers ) except Exception as e: LOGGER.error( "Unhandled error re-sending request after token update", exc_info=e, ) raise # Check if the retry also failed if response is not None and "errorcode" in response: retry_errorcode = response.get("errorcode") LOGGER.error( "Livisi sent error code %d after token refresh", retry_errorcode ) raise ErrorCodeException(retry_errorcode) return response else: # Handle other error codes LOGGER.error( "Error code %d (%s) on url %s", errorcode, ERROR_CODES.get(errorcode, "unknown"), url, ) raise ErrorCodeException(errorcode) return response async def _async_send_request( self, method, url: str, payload=None, headers=None ) -> dict: try: if payload is not None: data = json.dumps(payload).encode("utf-8") if headers is None: headers = {} headers["Content-Type"] = "application/json" headers["Content-Encoding"] = "utf-8" else: data = None async with self._web_session.request( method, url, json=payload, headers=headers, ssl=False, timeout=REQUEST_TIMEOUT, ) as res: try: data = await res.json() if data is None and res.status != 200: raise LivisiException( f"No data received from SHC, response code {res.status} ({res.reason})" ) except ClientResponseError as exc: raise LivisiException( f"Invalid response from SHC, response code {res.status} ({res.reason})" ) from exc return data except TimeoutError as exc: raise ShcUnreachableException("Timeout waiting for shc") from exc except ClientConnectorError as exc: raise ShcUnreachableException("Failed to connect to shc") from exc async def _async_get_controller(self) -> LivisiController: """Get Livisi Smart Home controller data.""" shc_info = await self.async_send_authorized_request("get", path="status") controller = parse_dataclass(shc_info, LivisiController) controller.is_v2 = shc_info.get("controllerType") == V2_NAME controller.is_v1 = shc_info.get("controllerType") == V1_NAME return controller async def async_get_devices( self, ) -> list[LivisiDevice]: """Send requests for getting all required data.""" # retrieve messages first, this will also refresh the token if # needed so subsequent parallel requests don't fail messages = await self.async_send_authorized_request("get", path="message") ( low_battery_devices, update_available_devices, unreachable_devices, updated_devices, ) = self.parse_messages(messages) devices, capabilities, rooms = await asyncio.gather( self.async_send_authorized_request("get", path="device"), self.async_send_authorized_request("get", path="capability"), self.async_send_authorized_request("get", path="location"), return_exceptions=True, ) for result, path in zip( (devices, capabilities, rooms), ("device", "capability", "location"), ): if isinstance(result, Exception): LOGGER.warning(f"Error loading {path}") raise result # Re-raise the exception immediately controller_id = next( (x.get("id") for x in devices if x.get("type") in CONTROLLER_DEVICE_TYPES), None, ) if controller_id is not None: try: shc_state = await self.async_send_authorized_request( "get", path=f"device/{controller_id}/state" ) if self.controller.is_v1: shc_state = shc_state["state"] except Exception: LOGGER.warning("Error getting shc state", exc_info=True) capability_map = {} capability_config = {} room_map = {} for room in rooms: if "id" in room: roomid = room["id"] room_map[roomid] = room.get("config", {}).get("name") for capability in capabilities: if "device" in capability: device_id = capability["device"].removeprefix("/device/") if device_id not in capability_map: capability_map[device_id] = {} capability_config[device_id] = {} cap_type = capability.get("type") if cap_type is not None: capability_map[device_id][cap_type] = capability["id"] if "config" in capability: capability_config[device_id][cap_type] = capability["config"] devicelist = [] for device in devices: device_id = device.get("id") device["capabilities"] = capability_map.get(device_id, {}) device["capability_config"] = capability_config.get(device_id, {}) device["cls"] = device.get("class") device["battery_low"] = device_id in low_battery_devices device["update_available"] = device_id in update_available_devices device["updated"] = device_id in updated_devices device["unreachable"] = device_id in unreachable_devices if device.get("location") is not None: roomid = device["location"].removeprefix("/location/") device["room"] = room_map.get(roomid) if device["type"] in CONTROLLER_DEVICE_TYPES: device["state"] = shc_state devicelist.append(parse_dataclass(device, LivisiDevice)) LOGGER.debug("Loaded %d devices", len(devices)) return devicelist def parse_messages(self, messages): """Parse message data from shc.""" low_battery_devices = set() update_available_devices = set() unreachable_devices = set() updated_devices = set() for message in messages: if isinstance(message, str): LOGGER.warning("Invalid message") LOGGER.warning(messages) continue msgtype = message.get("type", "") msgtimestamp = parse_timestamp(message.get("timestamp", "")) if msgtimestamp is None: continue device_ids = [ d.removeprefix("/device/") for d in message.get("devices", []) ] if len(device_ids) == 0: source = message.get("source", "") device_ids = [source.replace("/device/", "")] if msgtype == "DeviceLowBattery": for device_id in device_ids: low_battery_devices.add(device_id) elif msgtype == "DeviceUpdateAvailable": for device_id in device_ids: update_available_devices.add(device_id) elif msgtype == "ProductUpdated" or msgtype == "ShcUpdateCompleted": for device_id in device_ids: updated_devices.add(device_id) elif msgtype == "DeviceUnreachable": for device_id in device_ids: unreachable_devices.add(device_id) return ( low_battery_devices, update_available_devices, unreachable_devices, updated_devices, ) async def async_get_value( self, capability: str, property: str, key: str = "value" ) -> Any | None: """Get current value of the capability.""" state = await self.async_get_state(capability, property) if state is None: return None return state.get(key, None) async def async_get_state(self, capability: str, property: str) -> dict | None: """Get state of a capability.""" if capability is None: return None requestUrl = f"capability/{capability}/state" try: response = await self.async_send_authorized_request("get", requestUrl) except Exception as e: # just debug log the exception but let the caller handle it LOGGER.debug( "Unhandled error requesting device value", exc_info=e, ) raise if response is None: return None if not isinstance(response, dict): return None return response.get(property, None) async def async_set_state( self, capability_id: str, *, key: str = None, value: bool | float = None, namespace: str = "core.RWE", ) -> bool: """Set the state of a capability.""" params = {} if key is not None: params = {key: {"type": "Constant", "value": value}} return await self.async_send_capability_command( capability_id, "SetState", namespace=namespace, params=params ) async def _async_send_command( self, target: str, command_type: str, *, namespace: str = "core.RWE", params: dict = None, ) -> bool: """Send a command to a target.""" if params is None: params = {} set_state_payload: dict[str, Any] = { "id": uuid.uuid4().hex, "type": command_type, "namespace": namespace, "target": target, "params": params, } try: response = await self.async_send_authorized_request( "post", "action", payload=set_state_payload ) if response is None: return False return response.get("resultCode") == "Success" except ServerDisconnectedError: # Funny thing: The SHC restarts immediatly upon processing the restart command, it doesn't even answer to the request # In order to not throw an error we need to catch and assume the request was successfull. if command_type == COMMAND_RESTART: return True raise async def async_send_device_command( self, device_id: str, command_type: str, *, namespace: str = "core.RWE", params: dict = None, ) -> bool: """Send a command to a device.""" return await self._async_send_command( target=f"/device/{device_id}", command_type=command_type, namespace=namespace, params=params, ) async def async_send_capability_command( self, capability_id: str, command_type: str, *, namespace: str = "core.RWE", params: dict = None, ) -> bool: """Send a command to a capability.""" return await self._async_send_command( target=f"/capability/{capability_id}", command_type=command_type, namespace=namespace, params=params, ) @property def livisi_connection_data(self): """Return the connection data.""" return self._livisi_connection_data @livisi_connection_data.setter def livisi_connection_data(self, new_value): self._livisi_connection_data = new_value @property def token(self): """Return the token.""" return self._token @token.setter def token(self, new_value): self._token = new_value planbnet-livisi-6a38f25/livisi/livisi_const.py000066400000000000000000000014131502774205500215360ustar00rootroot00000000000000"""Constants for the Livisi Smart Home integration.""" import logging from typing import Final LOGGER = logging.getLogger(__package__) V2_NAME = "Avatar" V1_NAME = "Classic" V2_WEBSOCKET_PORT: Final = 9090 CLASSIC_WEBSOCKET_PORT: Final = 8080 WEBSERVICE_PORT: Final = 8080 REQUEST_TIMEOUT: Final = 2000 CONTROLLER_DEVICE_TYPES: Final = ["SHC", "SHCA"] BATTERY_LOW: Final = "batteryLow" UPDATE_AVAILABLE: Final = "DeviceUpdateAvailable" LIVISI_EVENT_STATE_CHANGED = "StateChanged" LIVISI_EVENT_BUTTON_PRESSED = "ButtonPressed" LIVISI_EVENT_MOTION_DETECTED = "MotionDetected" IS_REACHABLE: Final = "isReachable" EVENT_BUTTON_PRESSED = "button_pressed" EVENT_BUTTON_LONG_PRESSED = "button_long_pressed" EVENT_MOTION_DETECTED = "motion_detected" COMMAND_RESTART = "Restart" planbnet-livisi-6a38f25/livisi/livisi_controller.py000066400000000000000000000004501502774205500225730ustar00rootroot00000000000000"""Code to represent a livisi device.""" from __future__ import annotations from dataclasses import dataclass @dataclass class LivisiController: """Stores the livisi controller data.""" controller_type: str serial_number: str os_version: str is_v2: bool is_v1: bool planbnet-livisi-6a38f25/livisi/livisi_device.py000066400000000000000000000023161502774205500216520ustar00rootroot00000000000000"""Code to represent a livisi device.""" from __future__ import annotations from typing import Any from dataclasses import dataclass from .livisi_const import CONTROLLER_DEVICE_TYPES @dataclass class LivisiDevice: """Stores the livisi device data.""" id: str type: str tags: dict[str, str] config: dict[str, Any] state: dict[str, Any] manufacturer: str version: str cls: str product: str desc: str capabilities: dict[str, str] capability_config: dict[str, dict[str, Any]] room: str battery_low: bool update_available: bool updated: bool unreachable: bool @property def name(self) -> str: """Get name from config.""" return self.config.get("name") @property def tag_category(self) -> str: """Get tag type category from config.""" if self.tags is None: return None return self.tags.get("typeCategory") @property def tag_type(self) -> str: """Get tag type from config.""" return self.tags.get("type") @property def is_shc(self) -> bool: """Indicate whether this device is the controller.""" return self.type in CONTROLLER_DEVICE_TYPES planbnet-livisi-6a38f25/livisi/livisi_errors.py000066400000000000000000000106371502774205500217340ustar00rootroot00000000000000"""Errors for the Livisi Smart Home component.""" # Taken from https://developer.services-smarthome.de/api_reference/errorcodes/ ERROR_CODES = { # General Errors 1000: "An unknown error has occurred.", 1001: "Service unavailable.", 1002: "Service timeout.", 1003: "Internal API error.", 1004: "SHC invalid operation.", 1005: "Missing argument or wrong value.", 1006: "Service too busy.", 1007: "Unsupported request.", 1008: "Precondition failed.", # Authentication and Authorization Errors 2000: "An unknown error has occurred during Authentication or Authorization process.", 2001: "Access not allowed.", 2002: "Invalid token request.", 2003: "Invalid client credentials.", 2004: "The token signature is invalid.", 2005: "Failed to initialize user session.", 2006: "A connection already exists for the current session.", 2007: "The lifetime of the token has expired.", 2008: "Login attempted from a different client provider.", 2009: "Invalid user credentials.", 2010: "Controller access not allowed.", 2011: "Insufficient permissions.", 2012: "Session not found.", 2013: "Account temporary locked.", # Entities Errors 3000: "The requested entity does not exist.", 3001: "The provided request content is invalid and can't be parsed.", 3002: "No change performed.", 3003: "The provided entity already exists.", 3004: "The provided interaction is not valid.", 3005: "Too many entities of this type.", # Products Errors 3500: "Premium Services can't be directly enabled.", 3501: "Cannot remove a product that was paid.", # Actions Errors 4000: "The triggered action is invalid.", 4001: "Invalid parameter.", 4002: "Permission to trigger action not allowed.", 4003: "Unsupported action type.", # Configuration Errors 5000: "The configuration could not be updated.", 5001: "Could not obtain exclusive access on the configuration.", 5002: "Communication with the SHC failed.", 5003: "The owner did not accept the TaC latest version.", 5004: "One SHC already registered.", 5005: "The user has no SHC.", 5006: "Controller offline.", 5009: "Registration failure.", # SmartCodes Errors 6000: "SmartCode request not allowed.", 6001: "The SmartCode cannot be redeemed.", 6002: "Restricted access.", } class LivisiException(Exception): """Base class for Livisi exceptions.""" def __init__(self, message: str = "", *args: object) -> None: """Initialize the exception with a message.""" self.message = message super().__init__(message, *args) class ShcUnreachableException(LivisiException): """Unable to connect to the Smart Home Controller.""" def __init__( self, message: str = "Unable to connect to the Smart Home Controller.", *args: object, ) -> None: """Generate error with default message.""" super().__init__(message, *args) class WrongCredentialException(LivisiException): """The user credentials were wrong.""" def __init__( self, message: str = "The user credentials are wrong.", *args: object ) -> None: """Generate error with default message.""" super().__init__(message, *args) class IncorrectIpAddressException(LivisiException): """The IP address provided by the user is incorrect.""" def __init__( self, message: str = "The IP address provided by the user is incorrect.", *args: object, ) -> None: """Generate error with default message.""" super().__init__(message, *args) class TokenExpiredException(LivisiException): """The authentication token is expired.""" def __init__( self, message: str = "The authentication token is expired.", *args: object ) -> None: """Generate error with default message.""" super().__init__(message, *args) class ErrorCodeException(LivisiException): """The request sent an errorcode (other than token expired) as response.""" def __init__(self, error_code: int, message: str = None, *args: object) -> None: """Generate error with code.""" self.error_code = error_code if (message is None) and (error_code in ERROR_CODES): message = ERROR_CODES[error_code] elif message is None: message = f"Unknown error code from shc: {error_code}" super().__init__(message, *args) planbnet-livisi-6a38f25/livisi/livisi_event.py000066400000000000000000000007101502774205500215300ustar00rootroot00000000000000from dataclasses import dataclass from typing import Optional @dataclass class LivisiEvent: namespace: str properties: Optional[dict] source: str onState: Optional[bool] vrccData: Optional[float] luminance: Optional[int] isReachable: Optional[bool] sequenceNumber: Optional[str] type: Optional[str] timestamp: Optional[str] isOpen: Optional[bool] keyIndex: Optional[int] isLongKeyPress: Optional[bool] planbnet-livisi-6a38f25/livisi/livisi_json_util.py000066400000000000000000000013371502774205500224230ustar00rootroot00000000000000"""Helper code to parse json to python dataclass (simple and non recursive).""" from dataclasses import fields import json import re def parse_dataclass(jsondata, clazz): """Convert keys to snake_case and parse to dataclass.""" if isinstance(jsondata, str | bytes | bytearray): parsed_json = json.loads(jsondata) elif isinstance(jsondata, dict): parsed_json = jsondata else: parsed_json = {} # Convert keys to snake_case parsed_json = { re.sub("([A-Z])", r"_\1", k).lower(): v for k, v in parsed_json.items() } # Only include keys that are fields in the dataclass data_dict = {f.name: parsed_json.get(f.name) for f in fields(clazz)} return clazz(**data_dict) planbnet-livisi-6a38f25/livisi/livisi_websocket.py000066400000000000000000000101461502774205500224010ustar00rootroot00000000000000"""Code for communication with the Livisi application websocket.""" import asyncio from collections.abc import Callable import urllib.parse from json import JSONDecodeError import websockets.client from .livisi_json_util import parse_dataclass from .livisi_const import ( CLASSIC_WEBSOCKET_PORT, LIVISI_EVENT_BUTTON_PRESSED, LIVISI_EVENT_MOTION_DETECTED, LIVISI_EVENT_STATE_CHANGED, V2_WEBSOCKET_PORT, LOGGER, ) from .livisi_websocket_event import LivisiWebsocketEvent class LivisiWebsocket: """Represents the websocket class.""" def __init__(self, aiolivisi) -> None: """Initialize the websocket.""" self.aiolivisi = aiolivisi self.connection_url: str = None self._websocket = None self._disconnecting = False def is_connected(self): """Return whether the webservice is currently connected.""" return self._websocket is not None async def connect(self, on_data, on_close) -> None: """Connect to the socket.""" if self.aiolivisi.controller.is_v2: port = V2_WEBSOCKET_PORT token = urllib.parse.quote(self.aiolivisi.token) else: port = CLASSIC_WEBSOCKET_PORT token = self.aiolivisi.token ip_address = self.aiolivisi.host self.connection_url = f"ws://{ip_address}:{port}/events?token={token}" try: async with websockets.client.connect( self.connection_url, ping_interval=10, ping_timeout=10 ) as websocket: LOGGER.info("WebSocket connection established.") self._websocket = websocket await self.consumer_handler(websocket, on_data) self._websocket = None except Exception as e: self._websocket = None LOGGER.exception("Error handling websocket connection", exc_info=e) if not self._disconnecting: LOGGER.warning("WebSocket disconnected unexpectedly.") await on_close() async def disconnect(self) -> None: """Close the websocket.""" self._disconnecting = True if self._websocket is not None: await self._websocket.close(code=1000, reason="Handle disconnect request") LOGGER.info("WebSocket connection closed.") self._websocket = None self._disconnecting = False async def consumer_handler(self, websocket, on_data: Callable): """Parse data transmitted via the websocket.""" try: async for message in websocket: LOGGER.debug("Received WebSocket message: %s", message) try: event_data = parse_dataclass(message, LivisiWebsocketEvent) except JSONDecodeError: LOGGER.warning("Cannot decode WebSocket message", exc_info=True) continue if event_data.properties is None or event_data.properties == {}: LOGGER.debug("Received event with no properties, skipping.") LOGGER.debug("Event data: %s", event_data) if event_data.type not in [ LIVISI_EVENT_STATE_CHANGED, LIVISI_EVENT_BUTTON_PRESSED, LIVISI_EVENT_MOTION_DETECTED, ]: LOGGER.info( "Received %s event from Livisi websocket", event_data.type ) continue # Remove the URL prefix and use just the ID (which is unique) event_data.source = event_data.source.removeprefix("/device/") event_data.source = event_data.source.removeprefix("/capability/") try: on_data(event_data) except Exception as e: LOGGER.error("Unhandled error in on_data", exc_info=e) except asyncio.exceptions.CancelledError: LOGGER.warning("Livisi WebSocket consumer handler stopped") except Exception as e: LOGGER.error("Unhandled error in WebSocket consumer handler", exc_info=e) planbnet-livisi-6a38f25/livisi/livisi_websocket_event.py000066400000000000000000000004211502774205500235750ustar00rootroot00000000000000"""LivisiWebsocketEvent.""" from dataclasses import dataclass @dataclass class LivisiWebsocketEvent: """Encapuses a livisi event sent via the websocket.""" namespace: str type: str | None source: str timestamp: str | None properties: dict | None planbnet-livisi-6a38f25/pyproject.toml000066400000000000000000000014151502774205500200760ustar00rootroot00000000000000[build-system] requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] name = "livisi" version = "1.0.1" description = "Connection library for the abandoned Livisi Smart Home system" readme = "README.md" requires-python = ">=3.10" authors = [ { name = "Felix Rotthowe", email = "felix@planbnet.org" }, ] license = { text = "Apache-2.0" } classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] dependencies = [ "colorlog>=6.8.2", "aiohttp>=3.8.5", "websockets>=11.0.3", "python-dateutil>=2.9.0.post0", ] [project.urls] "Source" = "https://github.com/planbnet/livisi" "Tracker" = "https://github.com/planbnet/livisi/issues" planbnet-livisi-6a38f25/requirements.txt000066400000000000000000000000421502774205500204410ustar00rootroot00000000000000aiohttp>=3.8.5 websockets>=11.0.3