"""Library for interacting with the homee smart home/home automation platform.""" import asyncio from collections.abc import Callable, Coroutine from datetime import datetime import hashlib import json import logging from urllib.parse import parse_qs from typing import Any, Literal import aiohttp import aiohttp.client_exceptions from aiohttp.helpers import BasicAuth import websockets.asyncio.client import websockets.exceptions from .const import DeviceApp, DeviceOS, DeviceType, WarningCode from .model import ( HomeeDevice, HomeeGroup, HomeeNode, HomeeRelationship, HomeeSettings, HomeeUser, HomeeWarning, ) from .model_homeegram import HomeeGram _LOGGER = logging.getLogger(__name__) class Homee: """Representation of a Homee system.""" def __init__( self, host: str, user: str, password: str, device: str = "pymee", ping_interval: int = 30, reconnect_interval: int = 5, reconnect: bool = True, max_retries: int = 5, ) -> None: """Initialize the virtual Homee.""" self.host: str = host self.user: str = user self.password: str = password self.device: str = device self.ping_interval: int = ping_interval self.should_reconnect: bool = reconnect self.reconnect_interval: int = reconnect_interval self.max_retries: int = max_retries self.device_id: str = str(device).lower().replace(" ", "-") self.devices: list[HomeeDevice] = [] self.groups: list[HomeeGroup] = [] self.nodes: list[HomeeNode] = [] self.relationships: list[HomeeRelationship] = [] self.settings: HomeeSettings self.users: list[HomeeUser] = [] self.homeegrams: list[HomeeGram] = [] self.warning = HomeeWarning( {"code": 0, "description": "None", "message": "None", "data": {}} ) self.token: str = "" self.expires: float = 0 self.connected: bool = False self.retries: int = 0 self.should_close: bool = False self._message_queue: asyncio.Queue[str] = asyncio.Queue() self._connected_event = asyncio.Event() self._disconnected_event = asyncio.Event() self._connection_listeners: list[ Callable[[bool], Coroutine[Any, Any, None]] ] = [] self._nodes_listeners: list[ Callable[[HomeeNode, bool], Coroutine[Any, Any, None]] ] = [] async def get_access_token(self) -> str: """Try asynchronously to get an access token from homee using username and password.""" # Check if current token is still valid if self.token is not None and self.expires > datetime.now().timestamp(): return self.token auth = BasicAuth( self.user, hashlib.sha512(self.password.encode("utf-8")).hexdigest() ) url = f"{self.url}/access_token" headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "device_name": self.device, "device_hardware_id": self.device_id, "device_os": DeviceOS.LINUX, "device_type": DeviceType.NONE, "device_app": DeviceApp.HOMEE, } try: async with aiohttp.ClientSession() as client: req = await client.post( url, auth=auth, data=data, headers=headers, timeout=aiohttp.ClientTimeout(total=5), ) try: req_text = await req.text() except ( aiohttp.client_exceptions.ClientError, asyncio.TimeoutError, UnicodeDecodeError, LookupError, ) as e: raise HomeeAuthFailedException( f"Failed to decode response: {e}" ) from e except asyncio.TimeoutError as e: raise HomeeConnectionFailedException("Connection to Homee timed out") from e except aiohttp.client_exceptions.ClientError as e: raise HomeeConnectionFailedException("Could not connect to Homee") from e if req.status == 200: try: parsed = parse_qs(req_text, strict_parsing=True) self.token = parsed["access_token"][0] expires = int(parsed["expires"][0]) self.expires = datetime.now().timestamp() + expires self.retries = 0 except (KeyError, IndexError, ValueError) as e: raise HomeeAuthFailedException( f"Invalid token format: {req_text}" ) from e else: raise HomeeAuthFailedException( f"Auth request was unsuccessful. Status: {req.status} - {req.reason}" ) return self.token async def run(self) -> None: """Connect to homee after acquiring an access token and runs until the connection is closed. Should be used in combination with asyncio.create_task. """ self.should_close = False initial_connect = True # Reconnect loop to avoid recursive reconnects while initial_connect or ( not self.should_close and self.should_reconnect and self.retries < self.max_retries ): initial_connect = False # Sleep after reconnect if self.retries > 0: await asyncio.sleep(self.reconnect_interval * self.retries) _LOGGER.debug( "Attempting to reconnect in %s seconds", self.reconnect_interval * self.retries, ) try: await self.get_access_token() except HomeeConnectionFailedException as e: _LOGGER.debug("Could not connect to Homee: %s", e) # Reconnect self.retries += 1 continue except HomeeAuthFailedException as e: _LOGGER.debug("Could not authenticate with Homee: %s", e) # Do not reconnect, since the authentication will not magically work. self.should_reconnect = False continue await self.open_ws() # Handle max retries if self.retries >= self.max_retries: await self.on_max_retries() def start(self) -> asyncio.Task: """Wrap run() with asyncio.create_task() and returns the resulting task.""" return asyncio.create_task(self.run()) async def open_ws(self) -> None: """Open the websocket connection assuming an access token was already received. Runs until connection is closed again. """ _LOGGER.debug("Opening websocket") if self.retries > 0: await self.on_reconnect() try: async with websockets.asyncio.client.connect( uri=f"{self.ws_url}/connection?access_token={self.token}", subprotocols=[websockets.Subprotocol("v2")], # homee sends a large "all" dump; avoid default limit max_size=None, ) as ws: await self._ws_on_open() while (not self.should_close) and self.connected: try: receive_task = asyncio.ensure_future( self._ws_receive_handler(ws) ) send_task = asyncio.ensure_future(self._ws_send_handler(ws)) done, pending = await asyncio.wait( [receive_task, send_task], return_when=asyncio.FIRST_COMPLETED, ) exceptions: list[BaseException | None] = [] # Kill pending tasks for task in pending: task.cancel() # Check if we finished with an exception exceptions.extend(task.exception() for task in done) if exceptions and exceptions[0] is not None: raise exceptions[0] except websockets.exceptions.ConnectionClosedError as e: self.connected = False await self.on_disconnected(e) except ( websockets.exceptions.WebSocketException, ConnectionError, OSError, ) as e: await self._ws_on_error(e) self.retries += 1 await self._ws_on_close() async def _ws_receive_handler( self, ws: websockets.asyncio.client.ClientConnection ) -> None: try: msg = await ws.recv(decode=True) await self._ws_on_message(msg) except websockets.exceptions.ConnectionClosedOK: return except websockets.exceptions.ConnectionClosedError as e: if not self.should_close: self.connected = False raise e async def _ws_send_handler( self, ws: websockets.asyncio.client.ClientConnection ) -> None: """Continuously send messages from the message queue until the connection is closed.""" try: msg = await self._message_queue.get() if self.connected and not self.should_close: await ws.send(msg) except websockets.exceptions.ConnectionClosed as e: if not self.should_close: self.connected = False raise e async def _ws_on_open(self) -> None: """Websocket on_open callback.""" _LOGGER.debug("Connection to websocket successful") self.connected = True await self.on_connected() await self.send("GET:all") async def _ws_on_message(self, msg: str) -> None: """Websocket on_message callback.""" await self._handle_message(json.loads(msg)) async def _ws_on_close(self) -> None: """Websocket on_close callback.""" # if not self.should_close and self.retries <= 1: if self.connected: self.connected = False self._disconnected_event.set() await self.on_disconnected() async def _ws_on_error(self, error: Exception) -> None: """Websocket on_error callback.""" await self.on_error(error) async def send(self, msg: str) -> None: """Send a raw string message to homee.""" if not self.connected or self.should_close: return await self._message_queue.put(msg) async def reconnect(self) -> None: """Start a reconnection attempt.""" await self.run() def disconnect(self) -> None: """Disconnect from homee by closing the websocket connection.""" self.should_close = True def add_connection_listener( self, listener: Callable[[bool], Coroutine[Any, Any, None]] ) -> Callable[[], None]: """Add a listener for change in connected state.""" self._connection_listeners.append(listener) def remove_listener() -> None: self._connection_listeners.remove(listener) return remove_listener def add_nodes_listener( self, listener: Callable[[HomeeNode, bool], Coroutine[Any, Any, None]] ) -> Callable[[], None]: """Add a listener for node add/delete events.""" self._nodes_listeners.append(listener) def remove_listener() -> None: self._nodes_listeners.remove(listener) return remove_listener async def _handle_message(self, msg: dict) -> None: """Handle incoming homee messages.""" msg_type = None try: msg_type = list(msg)[0] except TypeError as e: _LOGGER.info("Invalid message: %s", msg) await self.on_error(e) return _LOGGER.debug(msg) if msg_type == "all": self.settings = HomeeSettings(msg["all"]["settings"]) # Create / Update nodes if not self.nodes: # Since there might be lots of nodes, we don't want to check for # all in the next step, so if we start up, just add all nodes. self.nodes = [HomeeNode(node_data) for node_data in msg["all"]["nodes"]] else: for node_data in msg["all"]["nodes"]: await self._update_or_create_node(node_data, self.warning.code) # Create / Update groups for group_data in msg["all"]["groups"]: self._update_or_create_group(group_data) # Create / Update users for user_data in msg["all"]["users"]: self._update_or_create_user(user_data) # Create / Update homeegrams for homeegram_data in msg["all"]["homeegrams"]: self._update_or_create_homeegram(homeegram_data) # Create / Update relationships self._update_or_create_relationships(msg["all"]["relationships"]) self._remap_relationships() self._connected_event.set() elif msg_type == "attribute": await self._handle_attribute_change(msg["attribute"]) # Not sure, if devices can be sent alone or only with user, but just in case... elif msg_type == "device": self._update_or_create_device(msg["device"]) elif msg_type == "devices": for data in msg["devices"]: self._update_or_create_device(data) elif msg_type == "group": self._update_or_create_group(msg["group"]) elif msg_type == "groups": for data in msg["groups"]: self._update_or_create_group(data) elif msg_type == "node": if self.warning.code != WarningCode.CUBE_LEARN_MODE_STARTED: # In learn mode, incomlete nodes are sent. await self._update_or_create_node(msg["node"], self.warning.code) elif msg_type == "nodes": for data in msg["nodes"]: await self._update_or_create_node(data, self.warning.code) elif msg_type == "relationship": self._update_or_create_relationship(msg["relationship"]) elif msg_type == "relationships": self._update_or_create_relationships(msg["relationships"]) self._remap_relationships() elif msg_type == "user": self._update_or_create_user(msg["user"]) elif msg_type == "users": for data in msg["users"]: self._update_or_create_user(data) elif msg_type == "homeegram": self._update_or_create_homeegram(msg["homeegram"]) elif msg_type == "homeegrams": for data in msg["homeegrams"]: self._update_or_create_homeegram(data) elif msg_type == "warning": await self._update_warning(msg["warning"]) else: _LOGGER.debug( "Unknown/Unsupported message type: %s.\nMessage: %s", msg_type, msg ) await self.on_message(msg) async def _handle_attribute_change(self, attribute_data: dict) -> None: """Handle an attribute changed message.""" _LOGGER.debug("Updating attribute %s", attribute_data["id"]) attr_node_id = attribute_data["node_id"] node = self.get_node_by_id(attr_node_id) if node is not None: node.update_attribute(attribute_data) await self.on_attribute_updated(attribute_data, node) async def _update_or_create_node( self, node_data: dict, warning_code: WarningCode ) -> None: existing_node = self.get_node_by_id(node_data["id"]) if ( existing_node is not None and not node_data["attributes"] and warning_code == WarningCode.CUBE_REMOVE_MODE_STARTED ): # A node without attributes is deleted. checking for remove mode for security. _LOGGER.debug( "Node %s has no attributes and cube remove mode is active. Removing", existing_node.id, ) self.nodes.remove(existing_node) self._remap_relationships() for listener in self._nodes_listeners: await listener(existing_node, False) return if existing_node is not None: existing_node.set_data(node_data) else: self.nodes.append(HomeeNode(node_data)) self._remap_relationships() _LOGGER.debug("Notifying listener of new node %s", self.nodes[-1].id) for listener in self._nodes_listeners: await listener(self.nodes[-1], True) def _update_or_create_group(self, data: dict) -> None: group = self.get_group_by_id(data["id"]) if group is not None: group.set_data(data) else: self.groups.append(HomeeGroup(data)) self._remap_relationships() def _update_or_create_relationship(self, data: dict) -> None: relationship: HomeeRelationship | None = next( (r for r in self.relationships if r.id == data["id"]), None ) if relationship is not None: relationship.set_data(data) else: self.relationships.append(HomeeRelationship(data)) self._remap_relationships() def _update_or_create_relationships(self, data: dict) -> None: if len(self.relationships) <= 0: self.relationships = [ HomeeRelationship(relationship_data) for relationship_data in data ] else: for relationship_data in data: self._update_or_create_relationship(relationship_data) def _remap_relationships(self) -> None: """Remap the relationships between nodes and groups defined by the relationships list.""" # Clear existing relationships for n in self.nodes: n.groups.clear() for g in self.groups: g.nodes.clear() for r in self.relationships: node = self.get_node_by_id(r.node_id) group = self.get_group_by_id(r.group_id) if node is not None and group is not None: node.groups.append(group) group.nodes.append(node) def _update_or_create_user(self, data: dict) -> None: """Create a user or update if already exists.""" user = self.get_user_by_id(data["id"]) if user is not None: user.set_data(data) else: self.users.append(HomeeUser(data)) # Create / Update the devices of the user for device in data["devices"]: self._update_or_create_device(device) def _update_or_create_device(self, data: dict) -> None: """Create a device or update if already exists.""" device = self.get_device_by_id(data["id"]) if device is not None: device.set_data(data) else: self.devices.append(HomeeDevice(data)) def _update_or_create_homeegram(self, data: dict[str, Any]) -> None: """Create a homeegram or update if already exists.""" homeegram = self.get_homeegram_by_id(data["id"]) if homeegram is not None: homeegram.set_data(data) else: self.homeegrams.append(HomeeGram(data)) async def _update_warning(self, data: dict) -> None: """Set the warning to the latest one received.""" self.warning.set_data(data) await self.on_warning() def get_node_index(self, node_id: int) -> int: """Return the index of the node with the given id or -1 if none exists.""" return next((i for i, node in enumerate(self.nodes) if node.id == node_id), -1) def get_node_by_id(self, node_id: int) -> HomeeNode | None: """Return the node with the given id or 'None' if none exists.""" index = self.get_node_index(node_id) return self.nodes[index] if index != -1 else None def get_group_index(self, group_id: int) -> int: """Return the index of the group with the given id or -1 if none exists.""" return next( (i for i, group in enumerate(self.groups) if group.id == group_id), -1 ) def get_group_by_id(self, group_id: int) -> HomeeGroup | None: """Return the group with the given id or 'None' if no group with the given id exists.""" index = self.get_group_index(group_id) return self.groups[index] if index != -1 else None def get_user_by_id(self, user_id: int) -> HomeeUser | None: """Return the user with the given id or 'None' if no user with the given id exists.""" index = next((i for i, user in enumerate(self.users) if user.id == user_id), -1) return self.users[index] if index != -1 else None def get_device_by_id(self, device_id: int) -> HomeeDevice | None: """Return the device with the given id or 'None' if no device with the given id exists.""" index = next( (i for i, device in enumerate(self.devices) if device.id == device_id), -1 ) return self.devices[index] if index != -1 else None def get_homeegram_by_id(self, homeegram_id: int) -> HomeeGram | None: """Return the homeegram with the given id or 'None' if none with the given id exists.""" index = next( ( i for i, homeegram in enumerate(self.homeegrams) if homeegram.id == homeegram_id ), -1, ) return self.homeegrams[index] if index != -1 else None async def set_value(self, device_id: int, attribute_id: int, value: float) -> None: """Set the target value of an attribute of a device.""" _LOGGER.debug( "Set value: Device: %s Attribute: %s To: %s", device_id, attribute_id, value ) await self.send( f"PUT:/nodes/{device_id}/attributes/{attribute_id}?target_value={value}" ) async def update_node(self, node_id: int) -> None: """Request current data for a node.""" _LOGGER.debug("Request current data for node %s", node_id) await self.send(f"GET:/nodes/{node_id}/") async def update_attribute(self, node_id: int, attribute_id: int) -> None: """Request current data for an attribute.""" _LOGGER.debug( "Request current data for attribute %s of device %s", attribute_id, node_id ) await self.send(f"GET:/nodes/{node_id}/attributes/{attribute_id}") async def play_homeegram(self, homeegram_id: int) -> None: """Invoke a homeegram.""" await self.send(f"PUT:homeegrams/{homeegram_id}?play=1") @property def url(self) -> str: """Local homee url.""" return f"http://{self.host}:7681" @property def ws_url(self) -> str: """Local homee websocket url.""" return f"ws://{self.host}:7681" async def wait_until_connected(self) -> Literal[True]: """Return a coroutine that runs until a connection has been established.""" return await self._connected_event.wait() async def wait_until_disconnected(self) -> Literal[True]: """Return a coroutine that runs until the connection has been closed.""" return await self._disconnected_event.wait() async def on_reconnect(self) -> None: """Execute right before a reconnection attempt is started.""" _LOGGER.debug("Homee %s Reconnecting", self.device) async def on_max_retries(self) -> None: """Execute if the maximum amount of retries was reached.""" _LOGGER.info( "Could not reconnect Homee %s after %s retries", self.device, self.max_retries, ) async def on_connected(self) -> None: """Execute once the websocket connection has been established.""" for listener in self._connection_listeners: await listener(True) if self.retries > 0: _LOGGER.debug( "Homee %s Reconnected after %s retries", self.device, self.retries ) self.retries = 0 async def on_disconnected(self, error: Exception | None = None) -> None: """Execute after the websocket connection has been closed.""" if not self.should_close: for listener in self._connection_listeners: await listener(False) _LOGGER.info("Homee %s Disconnected. Error: %s", self.device, error) async def on_error(self, error: Exception | None = None) -> None: """Execute after an error has occurred.""" _LOGGER.info("An error occurred: %s", error) async def on_message(self, msg: dict) -> None: """Execute when the websocket receives a message. The message is automatically parsed from json into a dictionary. """ async def on_warning(self) -> None: """Execute when a warning message is received.""" if self.warning.code == WarningCode.CUBE_LEARN_MODE_SUCCESSFUL: # we need to get all nodes again, since there is no way to get the newest only. await self.send("GET:/nodes/") async def on_attribute_updated(self, attribute_data: dict, node: HomeeNode) -> None: """Execute when an 'attribute' message was received and an attribute was updated. Contains the parsed json attribute data and the corresponding node instance. """ class HomeeException(Exception): """Base class for all errors thrown by this library.""" def __init__(self, reason: str | None = None) -> None: self.reason = reason class HomeeConnectionFailedException(HomeeException): """Raised if connection can not be established.""" class HomeeAuthFailedException(HomeeException): """Raised if no valid access token could be acquired."""