diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 71c3738..8fd0a2e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -6,6 +6,7 @@ on: jobs: claude-review: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: contents: read @@ -24,5 +25,11 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' + prompt: | + Review this pull request for the pyhive-integration Python library. Check for: + - Correctness: logic errors, incorrect async/await usage, blocking calls in async context + - Type annotations: missing or incorrect types on public methods + - Security: no secrets in code, safe HTTP and Cognito API call patterns + - Style: snake_case naming, no unused imports, ruff/pylint compliance + - Tests: are new features or bug fixes covered by tests? + Post inline comments on specific lines where relevant. Be concise. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index da2c7c4..a2f2d2d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write actions: read steps: diff --git a/.secrets.baseline b/.secrets.baseline index 544e5c6..5b53d9c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -264,146 +264,148 @@ ], "src/api/hive_auth_async.py": [ { - "type": "Hex High Entropy String", + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", + "is_verified": false, + "line_number": 48 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", + "is_verified": false, + "line_number": 49 + }, + { + "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", + "hashed_secret": "351b174ccf89601f6f4bd3f3970a4aba7d17c98e", + "is_verified": false, + "line_number": 52 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", + "is_verified": false, + "line_number": 109 + } + ], + "src/api/srp_crypto.py": [ + { + "type": "Hex High Entropy String", + "filename": "src/api/srp_crypto.py", "hashed_secret": "3e619ee0820ecf213c2f38c634e416b53defe3b0", "is_verified": false, - "line_number": 35 + "line_number": 11 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "b8e0d506d969f09a9af89ce89fd9759b72c63262", "is_verified": false, - "line_number": 36 + "line_number": 12 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "e97a751edc71e9afbe0c0f63ec94873392833f9f", "is_verified": false, - "line_number": 37 + "line_number": 13 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "92488c021dd524a2f4e116666b3645308fa0e35c", "is_verified": false, - "line_number": 38 + "line_number": 14 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "d4571e2f026f458aecd2950b0eb6aec190276177", "is_verified": false, - "line_number": 39 + "line_number": 15 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "8109d3c2f659f13cb61fc9e71eed574efe8c8fd8", "is_verified": false, - "line_number": 40 + "line_number": 16 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "08cac7461d7b624b88c53ee47da09cbbb84ea290", "is_verified": false, - "line_number": 41 + "line_number": 17 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "95523fea7e6136c6148299dcc3077debfa2976b3", "is_verified": false, - "line_number": 42 + "line_number": 18 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "c978fb77621e86f5e9077653fe5345ac1616b466", "is_verified": false, - "line_number": 43 + "line_number": 19 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "fc02990268ecf8a35a4912d60dab3754e5f43846", "is_verified": false, - "line_number": 44 + "line_number": 20 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "2c2c0ca491a73e95c8965b6641731057b65f6462", "is_verified": false, - "line_number": 45 + "line_number": 21 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "672b25c6be065170206f3fc6346ebb8e84cbb9d3", "is_verified": false, - "line_number": 46 + "line_number": 22 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "99d02e268ea3ee849fb6e359c6c1b019e4d07efd", "is_verified": false, - "line_number": 47 + "line_number": 23 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "e677fc4cb09d99e1e0d30af31f2e209e541e380e", "is_verified": false, - "line_number": 48 + "line_number": 24 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "05b69b06f40cae0c910a15b1ac75b1f7a847eccb", "is_verified": false, - "line_number": 49 + "line_number": 25 }, { "type": "Hex High Entropy String", - "filename": "src/api/hive_auth_async.py", + "filename": "src/api/srp_crypto.py", "hashed_secret": "c7f914bac2d66eb3f8ae3888fa47bf1ada6caaf5", "is_verified": false, - "line_number": 50 - }, - { - "type": "Secret Keyword", - "filename": "src/api/hive_auth_async.py", - "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", - "is_verified": false, - "line_number": 61 - }, - { - "type": "Secret Keyword", - "filename": "src/api/hive_auth_async.py", - "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Secret Keyword", - "filename": "src/api/hive_auth_async.py", - "hashed_secret": "351b174ccf89601f6f4bd3f3970a4aba7d17c98e", - "is_verified": false, - "line_number": 65 - }, - { - "type": "Secret Keyword", - "filename": "src/api/hive_auth_async.py", - "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", - "is_verified": false, - "line_number": 121 + "line_number": 26 } ] }, - "generated_at": "2026-05-09T10:54:47Z" + "generated_at": "2026-05-09T22:13:38Z" } diff --git a/setup.py b/setup.py index 1e0de19..f6ef23b 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,22 @@ "/pyhiveapi/api/", additional_replacements={"apyhiveapi": "pyhiveapi"}, ), + unasync.Rule( + "/apyhiveapi/devices/", + "/pyhiveapi/devices/", + additional_replacements={ + "apyhiveapi": "pyhiveapi", + "asyncio": "threading", + }, + ), + unasync.Rule( + "/apyhiveapi/session/", + "/pyhiveapi/session/", + additional_replacements={ + "apyhiveapi": "pyhiveapi", + "asyncio": "threading", + }, + ), ] ) }, diff --git a/src/action.py b/src/action.py index 014bca2..5812bc6 100644 --- a/src/action.py +++ b/src/action.py @@ -1,133 +1,15 @@ -"""Hive Action Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.action instead.""" -import json -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HTTP_OK -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.action is deprecated; import from apyhiveapi.devices.action", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.action import HiveAction - -class HiveAction: - """Hive Action Code. - - Returns: - object: Return hive action object. - """ - - action_type = "Actions" - - def __init__(self, session: Any = None): - """Initialise Action. - - Args: - session (object, optional): session to interact with hive account. Defaults to None. - """ - self.session = session - - async def get_action(self, device: Device): - """Action device to update. - - Args: - device (dict): Device to be updated. - - Returns: - dict: Updated device. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "Returning cached state for action %s (slow/busy poll).", - device.ha_name, - ) - return cached - if device.hive_id in self.session.data.actions: - device.status = {"state": await self.get_state(device)} - device.device_data = {} - return self.session.set_cached_device(device) - return "REMOVE" - - async def get_state(self, device: Device): - """Get action state. - - Args: - device (dict): Device to get state of. - - Returns: - str: Return state. - """ - final = None - - try: - data = self.session.data.actions[device.hive_id] - final = data["enabled"] - except KeyError as e: - _LOGGER.error(e) - - return final - - async def _set_action_state(self, device: Device, enabled: bool) -> bool: - """Set action enabled/disabled state. - - Args: - device (dict): Device to set state of. - enabled (bool): True to enable, False to disable. - - Returns: - bool: True if successful. - """ - final = False - - if device.hive_id in self.session.data.actions: - _LOGGER.debug( - "%s action %s.", - "Enabling" if enabled else "Disabling", - device.ha_name, - ) - await self.session.hive_refresh_tokens() - data = self.session.data.actions[device.hive_id].copy() - data.update({"enabled": enabled}) - resp = await self.session.api.set_action(device.hive_id, json.dumps(data)) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_status_on(self, device: Device): - """Set action turn on. - - Args: - device (dict): Device to set state of. - - Returns: - bool: True if successful. - """ - return await self._set_action_state(device, True) - - async def set_status_off(self, device: Device): - """Set action to turn off. - - Args: - device (dict): Device to set state of. - - Returns: - bool: True if successful. - """ - return await self._set_action_state(device, False) - - # Backwards-compatible camelCase aliases - async def getAction(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_action.""" - return await self.get_action(device) - - async def setStatusOn(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for set_status_on.""" - return await self.set_status_on(device) - - async def setStatusOff(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for set_status_off.""" - return await self.set_status_off(device) +__all__ = ["HiveAction"] diff --git a/src/api/device_registration.py b/src/api/device_registration.py new file mode 100644 index 0000000..bc428d2 --- /dev/null +++ b/src/api/device_registration.py @@ -0,0 +1,342 @@ +"""Device registration and management mixin for HiveAuthAsync.""" + +from __future__ import annotations + +import base64 +import datetime +import functools +import hashlib +import hmac +import logging +import os +import re +import socket +from typing import Any + +import botocore + +from ..helper.hive_exceptions import HiveApiError, HiveInvalid2FACode +from .srp_crypto import ( + G_HEX, + N_HEX, + calculate_u, + compute_hkdf, + get_random, + hash_sha256, + hex_hash, + hex_to_long, + long_to_hex, + pad_hex, +) + +_LOGGER = logging.getLogger(__name__) + + +class DeviceRegistrationMixin: + """Device registration, confirmation, and management methods. + + Expects ``self.client``, ``self.loop``, ``self._client_id``, + ``self.access_token``, ``self.device_group_key``, ``self.device_key``, + ``self.device_password``, ``self.k``, ``self.g_value``, ``self.big_n``, + ``self.small_a_value``, ``self.large_a_value``, and ``self.client_secret`` + to be set up by the owning class's ``__init__`` / ``async_init``. + """ + + # Attributes provided by HiveAuthAsync.__init__ / async_init + client: Any + loop: Any + _client_id: str | None + access_token: str | None + device_group_key: str | None + device_key: str | None + device_password: str | None + k: int + g_value: int + big_n: int + small_a_value: int + large_a_value: int + client_secret: str | None + + async def generate_hash_device(self, device_group_key, device_key): + """Generate device hash key.""" + # source: https://github.com/amazon-archives/amazon-cognito-identity-js/blob/6b87f1a30a998072b4d98facb49dcaf8780d15b0/src/AuthenticationHelper.js#L137 # pylint: disable=line-too-long + + device_password = base64.standard_b64encode(os.urandom(40)).decode("utf-8") + combined_string = f"{device_group_key}{device_key}:{device_password}" + combined_string_hash = hash_sha256(combined_string.encode("utf-8")) + salt = pad_hex(get_random(16)) + + x_value = hex_to_long(hex_hash(salt + combined_string_hash)) + g_value = hex_to_long(G_HEX) + big_n = hex_to_long(N_HEX) + verifier_device_not_padded = pow(g_value, x_value, big_n) + verifier = pad_hex(verifier_device_not_padded) + + device_secret_verifier_config = { + "PasswordVerifier": base64.standard_b64encode( + bytearray.fromhex(verifier) + ).decode("utf-8"), + "Salt": base64.standard_b64encode(bytearray.fromhex(salt)).decode("utf-8"), + } + self.device_password = device_password + return device_secret_verifier_config + + async def get_device_authentication_key( # pylint: disable=too-many-positional-arguments + self, device_group_key, device_key, device_password, server_b_value, salt + ): + """Get device authentication key.""" + u_value = calculate_u(self.large_a_value, server_b_value) + if u_value == 0: + raise ValueError("U cannot be zero.") + username_password = f"{device_group_key}{device_key}:{device_password}" + username_password_hash = hash_sha256(username_password.encode("utf-8")) + + x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash)) + g_mod_pow_xn = pow(self.g_value, x_value, self.big_n) + int_value2 = (server_b_value - self.k * g_mod_pow_xn) % self.big_n + exp = self.small_a_value + u_value * x_value + s_value = pow(int_value2, exp, self.big_n) + hkdf = compute_hkdf( + bytearray.fromhex(pad_hex(s_value)), + bytearray.fromhex(pad_hex(long_to_hex(u_value))), + ) + return hkdf + + async def process_device_challenge(self, challenge_parameters): + """Process device challenge.""" + username = challenge_parameters["USERNAME"] + salt_hex = ( + challenge_parameters["SALT"] + if isinstance(challenge_parameters["SALT"], str) + else pad_hex(challenge_parameters["SALT"]) + ) + srp_b_hex = challenge_parameters["SRP_B"] + secret_block_b64 = challenge_parameters["SECRET_BLOCK"] + # re strips leading zero from a day number (required by AWS Cognito) + timestamp = re.sub( + r" 0(\d) ", + r" \1 ", + datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y"), + ) + hkdf = await self.get_device_authentication_key( + self.device_group_key, + self.device_key, + self.device_password, + hex_to_long(srp_b_hex), + salt_hex, + ) + secret_block_bytes = base64.standard_b64decode(secret_block_b64) + msg = ( + bytearray(self.device_group_key, "utf-8") + + bytearray(self.device_key, "utf-8") + + bytearray(secret_block_bytes) + + bytearray(timestamp, "utf-8") + ) + hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) + signature_string = base64.standard_b64encode(hmac_obj.digest()) + response = { + "TIMESTAMP": timestamp, + "USERNAME": username, + "PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64, + "PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"), + "DEVICE_KEY": self.device_key, + } + if self.client_secret is not None: + response.update( + { + "SECRET_HASH": self.get_secret_hash( + username, self._client_id, self.client_secret + ) + } + ) + return response + + async def device_registration(self, device_name: str | None = None): + """Register device with Hive.""" + _LOGGER.debug("device_registration - Registering device with Hive.") + await self.confirm_device(device_name) + await self.update_device_status() + + async def confirm_device(self, device_name: str | None = None): + """Confirm Hive Device.""" + if self.client is None: + await self.async_init() # type: ignore[attr-defined] + + if device_name is None: + device_name = socket.gethostname() + + result = None + try: + device_secret_verifier_config = await self.generate_hash_device( + self.device_group_key, self.device_key + ) + result = await self.loop.run_in_executor( + None, + functools.partial( + self.client.confirm_device, + AccessToken=self.access_token, + DeviceKey=self.device_key, + DeviceName=device_name, + DeviceSecretVerifierConfig=device_secret_verifier_config, + ), + ) + except botocore.exceptions.ClientError as err: + if err.__class__.__name__ in ( + "NotAuthorizedException", + "CodeMismatchException", + ): + raise HiveInvalid2FACode from err + except botocore.exceptions.EndpointConnectionError as err: + if err.__class__.__name__ == "EndpointConnectionError": + raise HiveApiError from err + + return result + + async def update_device_status(self): + """Update Device Hive.""" + if self.client is None: + await self.async_init() # type: ignore[attr-defined] + result = None + try: + result = await self.loop.run_in_executor( + None, + functools.partial( + self.client.update_device_status, + AccessToken=self.access_token, + DeviceKey=self.device_key, + DeviceRememberedStatus="remembered", + ), + ) + except botocore.exceptions.EndpointConnectionError as err: + if err.__class__.__name__ == "EndpointConnectionError": + raise HiveApiError from err + + return result + + async def get_device_data(self): + """Get key device information for device authentication. + + Returns: + tuple: (device_group_key, device_key, device_password, token_created) + token_created is a datetime marking when the current tokens were issued. + Pass all four values as ``device_data`` in ``start_session`` config so the + session can compute token expiry from the real issue time rather than epoch. + """ + return ( + self.device_group_key, + self.device_key, + self.device_password, + self.token_created, + ) + + async def is_device_registered(self, access_token=None, device_key=None): + """Check if the current device is registered with Cognito. + + Args: + access_token (str, optional): Access token. Defaults to self.access_token. + device_key (str, optional): Device key. Defaults to self.device_key. + + Returns: + bool: True if device is registered and remembered, False otherwise. + + Raises: + HiveApiError: If unable to reach Cognito endpoint. + """ + if self.client is None: + await self.async_init() # type: ignore[attr-defined] + + token = access_token or self.access_token + key = device_key or self.device_key + + if not token or not key: + _LOGGER.debug( + "is_device_registered - Missing access token or device key, " + "device not registered" + ) + return False + + _LOGGER.debug( + "is_device_registered - Checking device registration status for device: %s", + key, + ) + + try: + result = await self.loop.run_in_executor( + None, + functools.partial( + self.client.get_device, + AccessToken=token, + DeviceKey=key, + ), + ) + + if result and "Device" in result: + device_status = result["Device"].get("DeviceAttributes", []) + for attr in device_status: + if ( + attr.get("Name") == "dev:device_remembered_status" + and attr.get("Value") == "remembered" + ): + _LOGGER.debug( + "is_device_registered - Device %s is registered and remembered", + key, + ) + return True + + _LOGGER.debug( + "is_device_registered - Device %s is registered but not remembered", + key, + ) + + except botocore.exceptions.ClientError as err: + error = (err.response or {}).get("Error", {}) + error_code = error.get("Code") + error_message = error.get("Message", "") + + if error_code == "ResourceNotFoundException": + _LOGGER.debug( + "is_device_registered - Device %s not found in Cognito", key + ) + elif error_code == "NotAuthorizedException": + _LOGGER.warning( + "is_device_registered - Not authorized to check device status: %s", + error_message, + ) + else: + _LOGGER.error( + "is_device_registered - Error checking device status: %s - %s", + error_code, + error_message, + ) + + except botocore.exceptions.EndpointConnectionError as err: + _LOGGER.error( + "is_device_registered - Cannot reach Cognito endpoint: %s", str(err) + ) + raise HiveApiError from err + + return False + + async def forget_device(self, access_token, device_key): + """Forget device registered with Hive.""" + if self.client is None: + await self.async_init() # type: ignore[attr-defined] + result = None + + try: + result = await self.loop.run_in_executor( + None, + functools.partial( + self.client.forget_device, + AccessToken=access_token, + DeviceKey=device_key, + ), + ) + except botocore.exceptions.ClientError as err: + if err.__class__.__name__ == "NotAuthorizedException": + raise HiveInvalid2FACode from err + except botocore.exceptions.EndpointConnectionError as err: + if err.__class__.__name__ == "ResourceNotFoundException": + raise HiveApiError from err + + return result diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index e6ed79f..177ab7e 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -1,17 +1,15 @@ """Auth file for logging in.""" +from __future__ import annotations + import asyncio import base64 -import binascii -import concurrent.futures import datetime import functools import hashlib import hmac import logging -import os import re -import socket from typing import Any import boto3 @@ -26,36 +24,25 @@ HiveInvalidUsername, HiveRefreshTokenExpired, ) +from .device_registration import DeviceRegistrationMixin from .hive_api import HiveApi +from .srp_crypto import ( + G_HEX, + N_HEX, + calculate_u, + compute_hkdf, + get_random, + hash_sha256, + hex_hash, + hex_to_long, + long_to_hex, + pad_hex, +) _LOGGER = logging.getLogger(__name__) -# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 -N_HEX = ( - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" - + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" - + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" - + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" - + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" - + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" - + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" - + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" - + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" - + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" - + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" - + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" - + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" - + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" - + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" - + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF" -) -# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 -G_HEX = "2" -INFO_BITS = bytearray("Caldera Derived Key", "utf-8") -POOL = concurrent.futures.ThreadPoolExecutor() - -class HiveAuthAsync: +class HiveAuthAsync(DeviceRegistrationMixin): """Async api to interface with hive auth.""" NEW_PASSWORD_REQUIRED_CHALLENGE = "NEW_PASSWORD_REQUIRED" @@ -88,6 +75,7 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self.device_key: str | None = device_key self.device_password: str | None = device_password self.access_token: str | None = None + self.token_created: datetime.datetime | None = None self.api = HiveApi() self.user_id = "user_id" self.client_secret = client_secret @@ -212,102 +200,6 @@ def get_secret_hash(username, client_id, client_secret): hmac_obj = hmac.new(bytearray(client_secret, "utf-8"), message, hashlib.sha256) return base64.standard_b64encode(hmac_obj.digest()).decode("utf-8") - async def generate_hash_device(self, device_group_key, device_key): - """Generate device hash key.""" - # source: https://github.com/amazon-archives/amazon-cognito-identity-js/blob/6b87f1a30a998072b4d98facb49dcaf8780d15b0/src/AuthenticationHelper.js#L137 # pylint: disable=line-too-long - - # random device password, which will be used for DEVICE_SRP_AUTH flow - device_password = base64.standard_b64encode(os.urandom(40)).decode("utf-8") - - combined_string = f"{device_group_key}{device_key}:{device_password}" - combined_string_hash = hash_sha256(combined_string.encode("utf-8")) - salt = pad_hex(get_random(16)) - - x_value = hex_to_long(hex_hash(salt + combined_string_hash)) - g_value = hex_to_long(G_HEX) - big_n = hex_to_long(N_HEX) - verifier_device_not_padded = pow(g_value, x_value, big_n) - verifier = pad_hex(verifier_device_not_padded) - - device_secret_verifier_config = { - "PasswordVerifier": base64.standard_b64encode( - bytearray.fromhex(verifier) - ).decode("utf-8"), - "Salt": base64.standard_b64encode(bytearray.fromhex(salt)).decode("utf-8"), - } - self.device_password = device_password - return device_secret_verifier_config - - async def get_device_authentication_key( # pylint: disable=too-many-positional-arguments - self, device_group_key, device_key, device_password, server_b_value, salt - ): - """Get device authentication key.""" - u_value = calculate_u(self.large_a_value, server_b_value) - if u_value == 0: - raise ValueError("U cannot be zero.") - username_password = f"{device_group_key}{device_key}:{device_password}" - username_password_hash = hash_sha256(username_password.encode("utf-8")) - - x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash)) - g_mod_pow_xn = pow(self.g_value, x_value, self.big_n) - int_value2 = (server_b_value - self.k * g_mod_pow_xn) % self.big_n - exp = self.small_a_value + u_value * x_value - s_value = pow(int_value2, exp, self.big_n) - hkdf = compute_hkdf( - bytearray.fromhex(pad_hex(s_value)), - bytearray.fromhex(pad_hex(long_to_hex(u_value))), - ) - return hkdf - - async def process_device_challenge(self, challenge_parameters): - """Process device challenge.""" - username = challenge_parameters["USERNAME"] - salt_hex = ( - challenge_parameters["SALT"] - if isinstance(challenge_parameters["SALT"], str) - else pad_hex(challenge_parameters["SALT"]) - ) - srp_b_hex = challenge_parameters["SRP_B"] - secret_block_b64 = challenge_parameters["SECRET_BLOCK"] - # re strips leading zero from a day number (required by AWS Cognito) - timestamp = re.sub( - r" 0(\d) ", - r" \1 ", - datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y"), - ) - hkdf = await self.get_device_authentication_key( - self.device_group_key, - self.device_key, - self.device_password, - hex_to_long(srp_b_hex), - salt_hex, - ) - secret_block_bytes = base64.standard_b64decode(secret_block_b64) - msg = ( - bytearray(self.device_group_key, "utf-8") - + bytearray(self.device_key, "utf-8") - + bytearray(secret_block_bytes) - + bytearray(timestamp, "utf-8") - ) - hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) - signature_string = base64.standard_b64encode(hmac_obj.digest()) - response = { - "TIMESTAMP": timestamp, - "USERNAME": username, - "PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64, - "PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"), - "DEVICE_KEY": self.device_key, - } - if self.client_secret is not None: - response.update( - { - "SECRET_HASH": self.get_secret_hash( - username, self._client_id, self.client_secret - ) - } - ) - return response - async def process_challenge(self, challenge_parameters): """Process auth challenge.""" self.user_id = challenge_parameters["USER_ID_FOR_SRP"] @@ -426,18 +318,17 @@ async def login(self): # noqa: PLR0912 _LOGGER.debug("login - SRP auth challenge completed successfully.") - if ( - "AuthenticationResult" in result - and "NewDeviceMetadata" in result["AuthenticationResult"] - ): + if "AuthenticationResult" in result: self.access_token = result["AuthenticationResult"]["AccessToken"] - self.device_group_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceGroupKey"] - self.device_key = result["AuthenticationResult"]["NewDeviceMetadata"][ - "DeviceKey" - ] - _LOGGER.debug("login - Device keys stored successfully.") + self.token_created = datetime.datetime.now() + if "NewDeviceMetadata" in result["AuthenticationResult"]: + self.device_group_key = result["AuthenticationResult"][ + "NewDeviceMetadata" + ]["DeviceGroupKey"] + self.device_key = result["AuthenticationResult"][ + "NewDeviceMetadata" + ]["DeviceKey"] + _LOGGER.debug("login - Device keys stored successfully.") return result @@ -482,6 +373,15 @@ async def device_login(self): ChallengeResponses=device_challenge_response, ), ) + except botocore.exceptions.ClientError as err: + error_code = (err.response or {}).get("Error", {}).get("Code", "") + if error_code in ("ResourceNotFoundException", "NotAuthorizedException"): + _LOGGER.error( + "Device login failed: device not registered or not remembered (%s).", + error_code, + ) + raise HiveInvalidDeviceAuthentication from err + raise except botocore.exceptions.EndpointConnectionError as err: if err.__class__.__name__ == "EndpointConnectionError": _LOGGER.error("Device login failed: cannot reach endpoint.") @@ -491,11 +391,7 @@ async def device_login(self): _LOGGER.debug("device_login - Device authentication completed successfully.") return result - async def sms_2fa( - self, - entered_code, - challenge_parameters, - ): + async def sms_2fa(self, entered_code, challenge_parameters): """Send sms code for auth.""" session = challenge_parameters.get("Session") code = str(entered_code) @@ -515,8 +411,9 @@ async def sms_2fa( }, ), ) + self.access_token = result["AuthenticationResult"]["AccessToken"] + self.token_created = datetime.datetime.now() if "NewDeviceMetadata" in result["AuthenticationResult"]: - self.access_token = result["AuthenticationResult"]["AccessToken"] self.device_group_key = result["AuthenticationResult"][ "NewDeviceMetadata" ]["DeviceGroupKey"] @@ -538,75 +435,6 @@ async def sms_2fa( _LOGGER.debug("sms_2fa - 2FA authentication completed successfully.") return result - async def device_registration(self, device_name: str | None = None): - """Register device with Hive.""" - _LOGGER.debug("device_registration - Registering device with Hive.") - await self.confirm_device(device_name) - await self.update_device_status() - - async def confirm_device( - self, - device_name: str | None = None, - ): - """Confirm Hive Device.""" - if self.client is None: - await self.async_init() - - if device_name is None: - device_name = socket.gethostname() - - result = None - try: - device_secret_verifier_config = await self.generate_hash_device( - self.device_group_key, self.device_key - ) - result = await self.loop.run_in_executor( - None, - functools.partial( - self.client.confirm_device, - AccessToken=self.access_token, - DeviceKey=self.device_key, - DeviceName=device_name, - DeviceSecretVerifierConfig=device_secret_verifier_config, - ), - ) - except botocore.exceptions.ClientError as err: - if err.__class__.__name__ in ( - "NotAuthorizedException", - "CodeMismatchException", - ): - raise HiveInvalid2FACode from err - except botocore.exceptions.EndpointConnectionError as err: - if err.__class__.__name__ == "EndpointConnectionError": - raise HiveApiError from err - - return result - - async def update_device_status(self): - """Update Device Hive.""" - if self.client is None: - await self.async_init() - result = None - try: - result = await self.loop.run_in_executor( - None, - functools.partial( - self.client.update_device_status, - AccessToken=self.access_token, - DeviceKey=self.device_key, - DeviceRememberedStatus="remembered", - ), - ) - except botocore.exceptions.EndpointConnectionError as err: - if err.__class__.__name__ == "EndpointConnectionError": - raise HiveApiError from err - - return result - - async def get_device_data(self): - """Get key device information for device authentication.""" - return self.device_group_key, self.device_key, self.device_password - async def refresh_token(self, token): """Refresh Hive Tokens.""" if self.client is None: @@ -656,177 +484,3 @@ async def refresh_token(self, token): _LOGGER.debug("refresh_token - Cognito token refresh completed successfully.") return result - - async def is_device_registered(self, access_token=None, device_key=None): - """Check if the current device is registered with Cognito. - - Args: - access_token (str, optional): Access token. Defaults to self.access_token. - device_key (str, optional): Device key. Defaults to self.device_key. - - Returns: - bool: True if device is registered and remembered, False otherwise. - - Raises: - HiveApiError: If unable to reach Cognito endpoint. - """ - if self.client is None: - await self.async_init() - - token = access_token or self.access_token - key = device_key or self.device_key - - if not token or not key: - _LOGGER.debug( - "is_device_registered - Missing access token or device key, " - "device not registered" - ) - return False - - _LOGGER.debug( - "is_device_registered - Checking device registration status for device: %s", - key, - ) - - try: - result = await self.loop.run_in_executor( - None, - functools.partial( - self.client.get_device, - AccessToken=token, - DeviceKey=key, - ), - ) - - if result and "Device" in result: - device_status = result["Device"].get("DeviceAttributes", []) - # Check if device is in "remembered" status - for attr in device_status: - if ( - attr.get("Name") == "dev:device_remembered_status" - and attr.get("Value") == "remembered" - ): - _LOGGER.debug( - "is_device_registered - Device %s is registered and remembered", - key, - ) - return True - - _LOGGER.debug( - "is_device_registered - Device %s is registered but not remembered", - key, - ) - - except botocore.exceptions.ClientError as err: - error = (err.response or {}).get("Error", {}) - error_code = error.get("Code") - error_message = error.get("Message", "") - - if error_code == "ResourceNotFoundException": - _LOGGER.debug( - "is_device_registered - Device %s not found in Cognito", key - ) - elif error_code == "NotAuthorizedException": - _LOGGER.warning( - "is_device_registered - Not authorized to check device status: %s", - error_message, - ) - else: - _LOGGER.error( - "is_device_registered - Error checking device status: %s - %s", - error_code, - error_message, - ) - - except botocore.exceptions.EndpointConnectionError as err: - _LOGGER.error( - "is_device_registered - Cannot reach Cognito endpoint: %s", str(err) - ) - raise HiveApiError from err - - # Default: device not registered or status unknown - return False - - async def forget_device(self, access_token, device_key): - """Forget device registered with Hive.""" - if self.client is None: - await self.async_init() - result = None - - try: - result = await self.loop.run_in_executor( - None, - functools.partial( - self.client.forget_device, - AccessToken=access_token, - DeviceKey=device_key, - ), - ) - except botocore.exceptions.ClientError as err: - if err.__class__.__name__ == "NotAuthorizedException": - raise HiveInvalid2FACode from err - except botocore.exceptions.EndpointConnectionError as err: - if err.__class__.__name__ == "ResourceNotFoundException": - raise HiveApiError from err - - return result - - -def hex_to_long(hex_string): - """Convert hex to long number.""" - return int(hex_string, 16) - - -def get_random(nbytes): - """Generate a random hex number.""" - random_hex = binascii.hexlify(os.urandom(nbytes)) - return hex_to_long(random_hex) - - -def hash_sha256(buf): - """Authentication helper.""" - a_value = hashlib.sha256(buf).hexdigest() - return (64 - len(a_value)) * "0" + a_value - - -def hex_hash(hex_string): - """Convert hex value to hash.""" - return hash_sha256(bytearray.fromhex(hex_string)) - - -def calculate_u(big_a, big_b): - """ - Calculate the client's value U which is the hash of A and B. - - :param {Long integer} big_a Large A value. - :param {Long integer} big_b Server B value. - :return {Long integer} Computed U value. - """ - u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b)) - return hex_to_long(u_hex_hash) - - -def long_to_hex(long_num): - """Convert long number to hex.""" - return f"{long_num:x}" - - -def pad_hex(long_int): - """Convert integer to hex format.""" - if not isinstance(long_int, str): - hash_str = long_to_hex(long_int) - else: - hash_str = long_int - if len(hash_str) % 2 == 1: - hash_str = f"0{hash_str}" - elif hash_str[0] in "89ABCDEFabcdef": - hash_str = f"00{hash_str}" - return hash_str - - -def compute_hkdf(ikm, salt): - """Process the hkdf algorithm.""" - prk = hmac.new(salt, ikm, hashlib.sha256).digest() - info_bits_update = INFO_BITS + bytearray(chr(1), "utf-8") - hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() - return hmac_hash[:16] diff --git a/src/api/srp_crypto.py b/src/api/srp_crypto.py new file mode 100644 index 0000000..faf141b --- /dev/null +++ b/src/api/srp_crypto.py @@ -0,0 +1,91 @@ +"""Pure SRP/HKDF crypto helpers for AWS Cognito authentication.""" + +import binascii +import concurrent.futures +import hashlib +import hmac +import os + +# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 +N_HEX = ( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" + + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" + + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" + + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" + + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" + + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" + + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" + + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" + + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" + + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF" +) +# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 +G_HEX = "2" +INFO_BITS = bytearray("Caldera Derived Key", "utf-8") +POOL = concurrent.futures.ThreadPoolExecutor() + + +def hex_to_long(hex_string): + """Convert hex to long number.""" + return int(hex_string, 16) + + +def get_random(nbytes): + """Generate a random hex number.""" + random_hex = binascii.hexlify(os.urandom(nbytes)) + return hex_to_long(random_hex) + + +def hash_sha256(buf): + """Authentication helper.""" + a_value = hashlib.sha256(buf).hexdigest() + return (64 - len(a_value)) * "0" + a_value + + +def hex_hash(hex_string): + """Convert hex value to hash.""" + return hash_sha256(bytearray.fromhex(hex_string)) + + +def calculate_u(big_a, big_b): + """ + Calculate the client's value U which is the hash of A and B. + + :param {Long integer} big_a Large A value. + :param {Long integer} big_b Server B value. + :return {Long integer} Computed U value. + """ + u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b)) + return hex_to_long(u_hex_hash) + + +def long_to_hex(long_num): + """Convert long number to hex.""" + return f"{long_num:x}" + + +def pad_hex(long_int): + """Convert integer to hex format.""" + if not isinstance(long_int, str): + hash_str = long_to_hex(long_int) + else: + hash_str = long_int + if len(hash_str) % 2 == 1: + hash_str = f"0{hash_str}" + elif hash_str[0] in "89ABCDEFabcdef": + hash_str = f"00{hash_str}" + return hash_str + + +def compute_hkdf(ikm, salt): + """Process the hkdf algorithm.""" + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + info_bits_update = INFO_BITS + bytearray(chr(1), "utf-8") + hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() + return hmac_hash[:16] diff --git a/src/boost.py b/src/boost.py new file mode 100644 index 0000000..282537d --- /dev/null +++ b/src/boost.py @@ -0,0 +1,15 @@ +"""Backwards-compatible shim — use apyhiveapi.devices.boost instead.""" + +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings + +warnings.warn( + "apyhiveapi.boost is deprecated; import from apyhiveapi.devices.boost", + DeprecationWarning, + stacklevel=2, +) + +from .devices.boost import BoostMixin + +__all__ = ["BoostMixin"] diff --git a/src/color.py b/src/color.py new file mode 100644 index 0000000..aa0d714 --- /dev/null +++ b/src/color.py @@ -0,0 +1,15 @@ +"""Backwards-compatible shim — use apyhiveapi.devices.color instead.""" + +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings + +warnings.warn( + "apyhiveapi.color is deprecated; import from apyhiveapi.devices.color", + DeprecationWarning, + stacklevel=2, +) + +from .devices.color import LightColorHandler + +__all__ = ["LightColorHandler"] diff --git a/src/device_attributes.py b/src/device_attributes.py index 462d6fa..908e5f0 100644 --- a/src/device_attributes.py +++ b/src/device_attributes.py @@ -1,105 +1,15 @@ -"""Hive Device Attribute Module.""" +"""Backwards-compatible shim — use apyhiveapi.helper.device_attributes instead.""" -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA +warnings.warn( + "apyhiveapi.device_attributes is deprecated; import from apyhiveapi.helper.device_attributes", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .helper.device_attributes import HiveAttributes - -class HiveAttributes: - """Device Attributes Code.""" - - def __init__(self, session: Any = None): - """Initialise attributes. - - Args: - session (object, optional): Session to interact with hive account. Defaults to None. - """ - self.session = session - self.type = "Attribute" - - async def state_attributes(self, n_id: str, _type: str): - """Get HA State Attributes. - - Args: - n_id (str): The id of the device. - _type (str): The device type. - - Returns: - dict: Set of attributes. - """ - attr = {} - - if n_id in self.session.data.products or n_id in self.session.data.devices: - attr.update({"available": (await self.online_offline(n_id))}) - if n_id in self.session.config.battery: - battery = await self.get_battery(n_id) - if battery is not None: - attr.update({"battery": str(battery) + "%"}) - if n_id in self.session.config.mode: - attr.update({"mode": (await self.get_mode(n_id))}) - return attr - - async def online_offline(self, n_id: str): - """Check if device is online. - - Args: - n_id (str): The id of the device. - - Returns: - boolean: True/False if device online. - """ - state = None - - try: - data = self.session.data.devices[n_id] - state = data["props"]["online"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def get_mode(self, n_id: str): - """Get sensor mode. - - Args: - n_id (str): The id of the device - - Returns: - str: The mode of the device. - """ - state = None - final = None - - try: - data = self.session.data.products[n_id] - state = data["state"]["mode"] - final = HIVETOHA[self.type].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_battery(self, n_id: str): - """Get device battery level. - - Args: - n_id (str): The id of the device. - - Returns: - str: Battery level of device. - """ - state = None - final = None - - try: - data = self.session.data.devices[n_id] - state = data["props"]["battery"] - final = state - await self.session.helper.error_check(n_id, self.type, state) - except KeyError as e: - _LOGGER.error(e) - - return final +__all__ = ["HiveAttributes"] diff --git a/src/devices/__init__.py b/src/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/devices/action.py b/src/devices/action.py new file mode 100644 index 0000000..49f2a01 --- /dev/null +++ b/src/devices/action.py @@ -0,0 +1,122 @@ +"""Hive Action Module.""" + +import json +import logging +from typing import Any + +from ..helper.compat_aliases import ActionCompatMixin +from ..helper.const import HTTP_OK +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class HiveAction(ActionCompatMixin, BaseDeviceHandler): + """Hive Action Code. + + Returns: + object: Return hive action object. + """ + + action_type = "Actions" + + def __init__(self, session: Any = None): + """Initialise Action. + + Args: + session (object, optional): session to interact with hive account. Defaults to None. + """ + self.session = session + + async def get_action(self, device: Device): + """Action device to update. + + Args: + device (dict): Device to be updated. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for action %s (slow/busy poll).", + device.ha_name, + ) + return cached + if device.hive_id in self.session.data.actions: + device.status = {"state": await self.get_state(device)} + device.device_data = {} + return self.session.set_cached_device(device) + return "REMOVE" + + async def get_state(self, device: Device): + """Get action state. + + Args: + device (dict): Device to get state of. + + Returns: + str: Return state. + """ + final = None + + try: + data = self.session.data.actions[device.hive_id] + final = data["enabled"] + except KeyError as e: + _LOGGER.error(e) + + return final + + async def _set_action_state(self, device: Device, enabled: bool) -> bool: + """Set action enabled/disabled state. + + Args: + device (dict): Device to set state of. + enabled (bool): True to enable, False to disable. + + Returns: + bool: True if successful. + """ + final = False + + if device.hive_id in self.session.data.actions: + _LOGGER.debug( + "%s action %s.", + "Enabling" if enabled else "Disabling", + device.ha_name, + ) + await self.session.hive_refresh_tokens() + data = self.session.data.actions[device.hive_id].copy() + data.update({"enabled": enabled}) + resp = await self.session.api.set_action(device.hive_id, json.dumps(data)) + if resp["original"] == HTTP_OK: + final = True + await self.session.get_devices(device.hive_id) + + return final + + async def set_status_on(self, device: Device): + """Set action turn on. + + Args: + device (dict): Device to set state of. + + Returns: + bool: True if successful. + """ + return await self._set_action_state(device, True) + + async def set_status_off(self, device: Device): + """Set action to turn off. + + Args: + device (dict): Device to set state of. + + Returns: + bool: True if successful. + """ + return await self._set_action_state(device, False) diff --git a/src/devices/boost.py b/src/devices/boost.py new file mode 100644 index 0000000..2e3f1b7 --- /dev/null +++ b/src/devices/boost.py @@ -0,0 +1,47 @@ +"""Shared boost state helpers for heating and hot water.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..helper.const import HIVETOHA +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class BoostMixin: + """Read-only boost status helpers shared by HiveHeating and HiveHotwater. + + Expects ``self.session`` to be set by the owning class's ``__init__``. + """ + + session: Any + + async def get_boost_status(self, device: Device): + """Get current boost status for the device. + + Returns: + str: ``"ON"`` or ``"OFF"``, or None on error. + """ + try: + data = self.session.data.products[device.hive_id] + return HIVETOHA["Boost"].get(data["state"].get("boost", False), "ON") + except KeyError as e: + _LOGGER.error(e) + return None + + async def get_boost_time(self, device: Device): + """Get boost time remaining (minutes) when boost is active. + + Returns: + int | None: Minutes remaining, or None when boost is not active. + """ + if await self.get_boost_status(device) == "ON": + try: + data = self.session.data.products[device.hive_id] + return data["state"]["boost"] + except KeyError as e: + _LOGGER.error(e) + return None diff --git a/src/devices/color.py b/src/devices/color.py new file mode 100644 index 0000000..ade4cb2 --- /dev/null +++ b/src/devices/color.py @@ -0,0 +1,152 @@ +"""Light colour sub-domain: read and set colour/temperature state.""" + +from __future__ import annotations + +import colorsys +import logging +from typing import Any + +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class LightColorHandler: # pylint: disable=no-member + """Colour and colour-temperature methods for Hive lights. + + Expects ``self.session`` and ``self._execute_state_change`` to be + available via the owning class's inheritance chain. + """ + + session: Any + + async def get_min_color_temp(self, device: Device): + """Get light minimum color temperature (mireds). + + Args: + device (Device): Device to query. + + Returns: + int | None: Minimum colour temperature in mireds. + """ + try: + data = self.session.data.products[device.hive_id] + state = data["props"]["colourTemperature"]["max"] + return round((1 / state) * 1000000) + except KeyError as e: + _LOGGER.error(e) + return None + + async def get_max_color_temp(self, device: Device): + """Get light maximum color temperature (mireds). + + Args: + device (Device): Device to query. + + Returns: + int | None: Maximum colour temperature in mireds. + """ + try: + data = self.session.data.products[device.hive_id] + state = data["props"]["colourTemperature"]["min"] + return round((1 / state) * 1000000) + except KeyError as e: + _LOGGER.error(e) + return None + + async def get_color_temp(self, device: Device): + """Get light current color temperature (mireds). + + Args: + device (Device): Device to query. + + Returns: + int | None: Current colour temperature in mireds. + """ + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["colourTemperature"] + return round((1 / state) * 1000000) + except KeyError as e: + _LOGGER.error(e) + return None + + async def get_color(self, device: Device): + """Get light current colour as an RGB tuple. + + Args: + device (Device): Device to query. + + Returns: + tuple | None: ``(r, g, b)`` each in 0–255. + """ + try: + data = self.session.data.products[device.hive_id] + hsv = [ + data["state"]["hue"] / 360, + data["state"]["saturation"] / 100, + data["state"]["value"] / 100, + ] + return tuple(int(i * 255) for i in colorsys.hsv_to_rgb(*hsv)) + except KeyError as e: + _LOGGER.error(e) + return None + + async def get_color_mode(self, device: Device): + """Get colour mode (``"COLOUR"`` or ``"WHITE"``). + + Args: + device (Device): Device to query. + + Returns: + str | None: Current colour mode. + """ + try: + data = self.session.data.products[device.hive_id] + return data["state"]["colourMode"] + except KeyError as e: + _LOGGER.error(e) + return None + + async def set_color_temp(self, device: Device, color_temp: int): + """Set colour temperature of the light. + + Args: + device (Device): Device to update. + color_temp (int): Colour temperature in mireds. + + Returns: + bool: True on success. + """ + _LOGGER.debug( + "set_color_temp - Setting colour temperature to %s for %s.", + color_temp, + device.ha_name, + ) + # Non-tuneable lights also need colourMode set to WHITE + data = self.session.data.products.get(device.hive_id, {}) + kwargs: dict[str, Any] = {"colourTemperature": color_temp} + if data.get("type") != "tuneablelight": + kwargs["colourMode"] = "WHITE" + return await self._execute_state_change(device, **kwargs) # type: ignore[attr-defined] + + async def set_color(self, device: Device, new_color: list): + """Set colour of the light (HSV). + + Args: + device (Device): Device to update. + new_color (list): ``[hue, saturation, value]`` as integers. + + Returns: + bool: True on success. + """ + _LOGGER.debug( + "set_color - Setting colour to %s for %s.", new_color, device.ha_name + ) + return await self._execute_state_change( # type: ignore[attr-defined] + device, + colourMode="COLOUR", + hue=str(new_color[0]), + saturation=str(new_color[1]), + value=str(new_color[2]), + ) diff --git a/src/devices/heating.py b/src/devices/heating.py new file mode 100644 index 0000000..2b81e1e --- /dev/null +++ b/src/devices/heating.py @@ -0,0 +1,461 @@ +"""Hive Heating Module.""" + +import logging +from datetime import datetime +from typing import Any + +from ..helper.compat_aliases import HeatingCompatMixin +from ..helper.const import HIVETOHA +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device +from .boost import BoostMixin + +_LOGGER = logging.getLogger(__name__) + + +class HiveHeating(BoostMixin, BaseDeviceHandler): + """Hive Heating Code. + + Returns: + object: heating + """ + + heating_type = "Heating" + + async def get_min_temperature(self, device: Device): + """Get heating minimum target temperature. + + Args: + device (dict): Device to get min temp for. + + Returns: + int: Minimum temperature + """ + if device.hive_type == "nathermostat": + return self._get_product_state(device, "props", "minHeat") + return 5 + + async def get_max_temperature(self, device: Device): + """Get heating maximum target temperature. + + Args: + device (dict): Device to get max temp for. + + Returns: + int: Maximum temperature + """ + if device.hive_type == "nathermostat": + return self._get_product_state(device, "props", "maxHeat") + return 32 + + async def get_current_temperature(self, device: Device): + """Get heating current temperature. + + Args: + device (dict): Device to get current temperature for. + + Returns: + float: current temperature + """ + state = None + final = None + device_name = device.ha_name + + try: + data = self.session.data.products[device.hive_id] + state = data["props"]["temperature"] + + try: + state = float(state) + except (ValueError, TypeError): + _LOGGER.warning( + "get_current_temperature - Non-numeric temperature value '%s' for %s.", + state, + device_name, + ) + return None + + if device.hive_id in self.session.data.minMax: + if self.session.data.minMax[device.hive_id]["TodayDate"] == str( + datetime.date(datetime.now()) + ): + self.session.data.minMax[device.hive_id]["TodayMin"] = min( + self.session.data.minMax[device.hive_id]["TodayMin"], state + ) + + self.session.data.minMax[device.hive_id]["TodayMax"] = max( + self.session.data.minMax[device.hive_id]["TodayMax"], state + ) + else: + data = { + "TodayMin": state, + "TodayMax": state, + "TodayDate": str(datetime.date(datetime.now())), + } + self.session.data.minMax[device.hive_id].update(data) + + self.session.data.minMax[device.hive_id]["RestartMin"] = min( + self.session.data.minMax[device.hive_id]["RestartMin"], state + ) + + self.session.data.minMax[device.hive_id]["RestartMax"] = max( + self.session.data.minMax[device.hive_id]["RestartMax"], state + ) + else: + data = { + "TodayMin": state, + "TodayMax": state, + "TodayDate": str(datetime.date(datetime.now())), + "RestartMin": state, + "RestartMax": state, + } + self.session.data.minMax[device.hive_id] = data + + final = round(state, 1) + except KeyError as e: + _LOGGER.error( + "get_current_temperature - KeyError getting temperature for %s: %s", + device_name, + str(e), + ) + + return final + + async def get_target_temperature(self, device: Device): + """Get heating target temperature. + + Args: + device (dict): Device to get target temperature for. + + Returns: + float: Target temperature or None if invalid + """ + state = None + device_name = device.ha_name + + try: + data = self.session.data.products[device.hive_id] + state = data["state"].get("target", None) + if state is None: + state = data["state"].get("heat", None) + + if state is not None: + try: + state = float(state) + except (ValueError, TypeError): + _LOGGER.warning( + "get_target_temperature - Non-numeric target temperature" + " value '%s' for %s.", + state, + device_name, + ) + return None + except (KeyError, TypeError) as e: + _LOGGER.error( + "get_target_temperature - Error getting target temperature for %s: %s", + device_name, + str(e), + ) + + return state + + async def get_mode(self, device: Device): + """Get heating current mode. + + Args: + device (dict): Device to get current mode for. + + Returns: + str: Current Mode + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["mode"] + if state == "BOOST": + state = data["props"]["previous"]["mode"] + final = HIVETOHA[self.heating_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + async def get_state(self, device: Device): + """Get heating current state. + + Args: + device (dict): Device to get state for. + + Returns: + str: Current state. + """ + state = None + final = None + + try: + current_temp = await self.get_current_temperature(device) + target_temp = await self.get_target_temperature(device) + if current_temp is not None and target_temp is not None: + if current_temp < target_temp: + state = "ON" + else: + state = "OFF" + final = HIVETOHA[self.heating_type].get(state, state) + except (KeyError, TypeError) as e: + _LOGGER.error(e) + + return final + + async def get_current_operation(self, device: Device): + """Get heating current operation. + + Args: + device (dict): Device to get current operation for. + + Returns: + str: Current operation. + """ + return self._get_product_state(device, "props", "working") + + async def get_heat_on_demand(self, device: Device): + """Get heat on demand status. + + Args: + device ([dictionary]): [Get Heat on Demand status for Thermostat device.] + + Returns: + str: [Return True or False for the Heat on Demand status.] + """ + return self._get_product_state(device, "props", "autoBoost", "active") + + @staticmethod + async def get_operation_modes(): + """Get heating list of possible modes. + + Returns: + list: Operation modes. + """ + return ["SCHEDULE", "MANUAL", "OFF"] + + async def set_target_temperature(self, device: Device, new_temp: str): + """Set heating target temperature. + + Args: + device (dict): Device to set target temperature for. + new_temp (str): New temperature. + + Returns: + boolean: True/False if successful + """ + _LOGGER.info( + "set_target_temperature - Setting target temperature to %s°C for %s", + new_temp, + device.ha_name, + ) + return await self._execute_state_change(device, target=new_temp) + + async def set_mode(self, device: Device, new_mode: str): + """Set heating mode. + + Args: + device (dict): Device to set mode for. + new_mode (str): New mode to be set. + + Returns: + boolean: True/False if successful + """ + _LOGGER.info( + "set_mode - Setting heating mode to %s for %s", new_mode, device.ha_name + ) + return await self._execute_state_change(device, mode=new_mode) + + async def set_boost_on(self, device: Device, mins: str, temp: float): + """Turn heating boost on. + + Args: + device (dict): Device to boost. + mins (str): Number of minutes to boost for. + temp (float): Temperature to boost to. + + Returns: + boolean: True/False if successful, None if inputs are out of range + """ + min_temp = await self.get_min_temperature(device) + max_temp = await self.get_max_temperature(device) + if not (int(mins) > 0 and min_temp <= int(temp) <= max_temp): + return None + _LOGGER.debug( + "set_boost_on - Setting heating boost ON for %s: %s mins at %s degrees.", + device.ha_name, + mins, + temp, + ) + return await self._execute_state_change( + device, mode="BOOST", boost=mins, target=temp + ) + + async def set_boost_off(self, device: Device): + """Turn heating boost off. + + Args: + device (dict): Device to update boost for. + + Returns: + boolean: True/False if successful + """ + if device.hive_id not in self.session.data.products or not ( + isinstance(device.device_data, dict) and device.device_data.get("online") + ): + return False + + if await self.get_boost_status(device) != "ON": + return False + + _LOGGER.debug( + "set_boost_off - Setting heating boost OFF for %s.", device.ha_name + ) + prev_mode = self._get_product_state(device, "props", "previous", "mode") + kwargs = {"mode": prev_mode} + if prev_mode in ("MANUAL", "OFF"): + kwargs["target"] = ( + self._get_product_state(device, "props", "previous", "target") or 7 + ) + return await self._execute_state_change(device, **kwargs) + + async def set_heat_on_demand(self, device: Device, state: str): + """Enable or disable Heat on Demand for a Thermostat. + + Args: + device ([dictionary]): [This is the Thermostat device you want to update.] + state ([str]): [This is the state you want to set. (Either "ENABLED" or "DISABLED")] + + Returns: + [boolean]: [Return True or False if the Heat on Demand was set successfully.] + """ + _LOGGER.debug( + "set_heat_on_demand - Setting heat on demand to %s for %s.", + state, + device.ha_name, + ) + return await self._execute_state_change(device, autoBoost=state) + + +class Climate(HeatingCompatMixin, HiveHeating): + """Climate class for Home Assistant. + + Args: + Heating (object): Heating class + """ + + def __init__(self, session: Any = None): + """Initialise heating. + + Args: + session (object, optional): Used to interact with hive account. Defaults to None. + """ + self.session = session + + async def get_climate(self, device: Device): + """Get heating data. + + Args: + device (dict): Device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "get_climate - Returning cached state for climate %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_climate - Updating climate data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.min_temp = await self.get_min_temperature(device) + device.max_temp = await self.get_max_temperature(device) + device.status = { + "current_temperature": await self.get_current_temperature(device), + "target_temperature": await self.get_target_temperature(device), + "action": await self.get_current_operation(device), + "mode": await self.get_mode(device), + "boost": await self.get_boost_status(device), + } + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + _LOGGER.debug( + "get_climate - Heating device data for %s: %s", + device.ha_name, + device.status, + ) + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or { + "current_temperature": None, + "target_temperature": None, + "action": None, + "mode": None, + "boost": None, + "state": None, + } + return device + + async def get_schedule_now_next_later(self, device: Device): + """Hive get heating schedule now, next and later. + + Args: + device (dict): Device to get schedule for. + + Returns: + dict: Schedule now, next and later + """ + online = await self.session.attr.online_offline(device.device_id) + current_mode = await self.get_mode(device) + state = None + + try: + if online and current_mode == "SCHEDULE": + data = self.session.data.products[device.hive_id] + state = self.session.helper.get_schedule_nnl(data["state"]["schedule"]) + except KeyError as e: + _LOGGER.error(e) + + return state + + async def minmax_temperature(self, device: Device): + """Min/Max Temp. + + Args: + device (dict): device to get min/max temperature for. + + Returns: + dict: Shows min/max temp for the day. + """ + state = None + final = None + + try: + state = self.session.data.minMax[device.hive_id] + final = state + except KeyError as e: + _LOGGER.error(e) + + return final diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py new file mode 100644 index 0000000..b6985b2 --- /dev/null +++ b/src/devices/hotwater.py @@ -0,0 +1,227 @@ +"""Hive Hotwater Module.""" + +import logging +from typing import Any + +from ..helper.compat_aliases import WaterHeaterCompatMixin +from ..helper.const import HIVETOHA +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device +from .boost import BoostMixin + +_LOGGER = logging.getLogger(__name__) + + +class HiveHotwater(BoostMixin, BaseDeviceHandler): + """Hive Hotwater Code. + + Returns: + object: Hotwater Object. + """ + + session: Any + hotwater_type = "Hotwater" + + async def get_mode(self, device: Device): + """Get hotwater current mode. + + Args: + device (dict): Device to get the mode for. + + Returns: + str: Return mode. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["mode"] + if state == "BOOST": + state = data["props"]["previous"]["mode"] + final = HIVETOHA[self.hotwater_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + @staticmethod + async def get_operation_modes(): + """Get heating list of possible modes. + + Returns: + list: Return list of operation modes. + """ + return ["SCHEDULE", "ON", "OFF"] + + async def get_state(self, device: Device): + """Get hot water current state. + + Args: + device (dict): Device to get the state for. + + Returns: + str: return state of device. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["status"] + mode_current = await self.get_mode(device) + if mode_current == "SCHEDULE": + if await self.get_boost_status(device) == "ON": + state = "ON" + else: + snan = self.session.helper.get_schedule_nnl( + data["state"]["schedule"] + ) + state = snan["now"]["value"]["status"] + + final = HIVETOHA[self.hotwater_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + async def set_mode(self, device: Device, new_mode: str): + """Set hot water mode. + + Args: + device (dict): device to update mode. + new_mode (str): Mode to set the device to. + + Returns: + boolean: return True/False if boost was successful. + """ + _LOGGER.debug( + "set_mode - Setting hot water mode to %s for %s.", new_mode, device.ha_name + ) + return await self._execute_state_change(device, mode=new_mode) + + async def set_boost_on(self, device: Device, mins: int): + """Turn hot water boost on. + + Args: + device (dict): Deice to boost. + mins (int): Number of minutes to boost it for. + + Returns: + boolean: return True/False if boost was successful. + """ + if int(mins) <= 0: + return False + _LOGGER.debug( + "set_boost_on - Setting hot water boost ON for %s: %s mins.", + device.ha_name, + mins, + ) + return await self._execute_state_change(device, mode="BOOST", boost=mins) + + async def set_boost_off(self, device: Device): + """Turn hot water boost off. + + Args: + device (dict): device to set boost off + + Returns: + boolean: return True/False if boost was successful. + """ + if ( + device.hive_id not in self.session.data.products + or not ( + isinstance(device.device_data, dict) + and device.device_data.get("online") + ) + or await self.get_boost_status(device) != "ON" + ): + return False + _LOGGER.debug( + "set_boost_off - Setting hot water boost OFF for %s.", device.ha_name + ) + prev_mode = self._get_product_state(device, "props", "previous", "mode") + return await self._execute_state_change(device, mode=prev_mode) + + +class WaterHeater(WaterHeaterCompatMixin, HiveHotwater): + """Water heater class. + + Args: + Hotwater (object): Hotwater class. + """ + + def __init__(self, session: Any = None): + """Initialise water heater. + + Args: + session (object, optional): Session to interact with account. Defaults to None. + """ + self.session = session + + async def get_water_heater(self, device: Device): + """Update water heater device. + + Args: + device (dict): device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "get_water_heater - Returning cached state for" + " water heater %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug( + "get_water_heater - Updating hot water data for %s.", device.ha_name + ) + data = self.session.data.devices[device.device_id] + device.status = {"current_operation": await self.get_mode(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + _LOGGER.debug( + "get_water_heater - Water heater device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"current_operation": None} + return device + + async def get_schedule_now_next_later(self, device: Device): + """Hive get hotwater schedule now, next and later. + + Args: + device (dict): device to get schedule for. + + Returns: + dict: return now, next and later schedule. + """ + mode_current = await self.get_mode(device) + if mode_current == "SCHEDULE": + schedule = self._get_product_state(device, "state", "schedule") + if schedule is not None: + return self.session.helper.get_schedule_nnl(schedule) + return None diff --git a/src/devices/hub.py b/src/devices/hub.py new file mode 100644 index 0000000..c8cfc58 --- /dev/null +++ b/src/devices/hub.py @@ -0,0 +1,104 @@ +"""Hive Hub Module.""" + +import logging +from typing import Any + +from ..helper.const import HIVETOHA +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class HiveHub(BaseDeviceHandler): + """Hive hub. + + Returns: + object: Returns a hub object. + """ + + hub_type = "Hub" + log_type = "Sensor" + + def __init__(self, session: Any = None): + """Initialise hub. + + Args: + session (object, optional): session to interact with Hive account. Defaults to None. + """ + self.session = session + + async def get_smoke_status(self, device: Device): + """Get the hub smoke status. + + Args: + device (dict): device to get status for + + Returns: + str: Return smoke status. + """ + _LOGGER.debug("get_smoke_status - Getting smoke status for %s", device.hive_id) + state = self._get_product_state( + device, "props", "sensors", "SMOKE_CO", "active" + ) + if state is None: + _LOGGER.debug( + "get_smoke_status - No smoke state found for %s", device.hive_id + ) + return None + result = HIVETOHA[self.hub_type]["Smoke"].get(state, state) + _LOGGER.debug("get_smoke_status - %s smoke status: %s", device.hive_id, result) + return result + + async def get_dog_bark_status(self, device: Device): + """Get dog bark status. + + Args: + device (dict): Device to get status for. + + Returns: + str: Return status. + """ + _LOGGER.debug( + "get_dog_bark_status - Getting dog bark status for %s", device.hive_id + ) + state = self._get_product_state( + device, "props", "sensors", "DOG_BARK", "active" + ) + if state is None: + _LOGGER.debug( + "get_dog_bark_status - No dog bark state found for %s", device.hive_id + ) + return None + result = HIVETOHA[self.hub_type]["Dog"].get(state, state) + _LOGGER.debug( + "get_dog_bark_status - %s dog bark status: %s", device.hive_id, result + ) + return result + + async def get_glass_break_status(self, device: Device): + """Get the glass detected status from the Hive hub. + + Args: + device (dict): Device to get status for. + + Returns: + str: Return status. + """ + _LOGGER.debug( + "get_glass_break_status - Getting glass break status for %s", device.hive_id + ) + state = self._get_product_state( + device, "props", "sensors", "GLASS_BREAK", "active" + ) + if state is None: + _LOGGER.debug( + "get_glass_break_status - No glass break state found for %s", + device.hive_id, + ) + return None + result = HIVETOHA[self.hub_type]["Glass"].get(state, state) + _LOGGER.debug( + "get_glass_break_status - %s glass break status: %s", device.hive_id, result + ) + return result diff --git a/src/devices/light.py b/src/devices/light.py new file mode 100644 index 0000000..b33637e --- /dev/null +++ b/src/devices/light.py @@ -0,0 +1,225 @@ +"""Hive Light Module.""" + +import logging +from typing import Any + +from ..helper.compat_aliases import LightCompatMixin +from ..helper.const import HIVETOHA +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device +from .color import LightColorHandler + +_LOGGER = logging.getLogger(__name__) + + +class HiveLight(LightColorHandler, BaseDeviceHandler): + """Hive Light Code. + + Returns: + object: Hivelight + """ + + session: Any + light_type = "Light" + + async def get_state(self, device: Device): + """Get light current state. + + Args: + device (dict): Device to get the state of. + + Returns: + str: State of the light. + """ + state = None + final = None + device_name = device.ha_name + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["status"] + final = HIVETOHA[self.light_type].get(state, state) + except KeyError as e: + _LOGGER.error( + "KeyError getting light state for %s: %s", device_name, str(e) + ) + + return final + + async def get_brightness(self, device: Device): + """Get light current brightness. + + Args: + device (dict): Device to get the brightness of. + + Returns: + int: Brightness value. + """ + state = None + final = None + device_name = device.ha_name + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["brightness"] + final = (state / 100) * 255 + except KeyError as e: + _LOGGER.error( + "KeyError getting light brightness for %s: %s", device_name, str(e) + ) + + return final + + async def set_status_off(self, device: Device): + """Set light to turn off. + + Args: + device (dict): Device to turn off. + + Returns: + boolean: True/False if successful + """ + _LOGGER.info("Turning off light %s", device.ha_name) + return await self._execute_state_change(device, status="OFF") + + async def set_status_on(self, device: Device): + """Set light to turn on. + + Args: + device (dict): Device to turn on. + + Returns: + boolean: True/False if successful + """ + _LOGGER.info("Turning on light %s", device.ha_name) + return await self._execute_state_change(device, status="ON") + + async def set_brightness(self, device: Device, n_brightness: int): + """Set brightness of the light. + + Args: + device (dict): Device to set brightness of. + n_brightness (int): Brightness value to set the light to. + + Returns: + boolean: True/False if successful + """ + _LOGGER.info( + "Setting brightness to %s for light %s", n_brightness, device.ha_name + ) + return await self._execute_state_change( + device, status="ON", brightness=n_brightness + ) + + +class Light(LightCompatMixin, HiveLight): + """Home Assistant Light Code. + + Args: + HiveLight (object): HiveLight Code. + """ + + def __init__(self, session: Any = None): + """Initialise light. + + Args: + session (object, optional): Used to interact with the hive account. Defaults to None. + """ + self.session = session + + async def get_light(self, device: Device): + """Get light data. + + Args: + device (dict): Device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "get_light - Returning cached state for light %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_light - Updating light data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.status = { + "state": await self.get_state(device), + "brightness": await self.get_brightness(device), + } + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + if device.hive_type in ("tuneablelight", "colourtuneablelight"): + device.status["color_temp"] = await self.get_color_temp(device) + if device.hive_type == "colourtuneablelight": + mode = await self.get_color_mode(device) + device.status["mode"] = mode + if mode == "COLOUR": + device.status["hs_color"] = await self.get_color(device) + + _LOGGER.debug( + "get_light - Light device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device + + async def turn_on( + self, + device: Device, + brightness: int | None, + color_temp: int | None, + color: list | None, + ): + """Set light to turn on. + + Args: + device (dict): Device to turn on + brightness (int): Brightness value to set the light to. + color_temp (int): Color Temp value to set the light to. + color (list): colour values to set the light to. + + Returns: + boolean: True/False if successful. + """ + if brightness is not None: + return await self.set_brightness(device, brightness) + if color_temp is not None: + return await self.set_color_temp(device, color_temp) + if color is not None: + return await self.set_color(device, color) + + return await self.set_status_on(device) + + async def turn_off(self, device: Device): + """Set light to turn off. + + Args: + device (dict): Device to be turned off. + + Returns: + boolean: True/False if successful. + """ + return await self.set_status_off(device) diff --git a/src/devices/plug.py b/src/devices/plug.py new file mode 100644 index 0000000..7bddf87 --- /dev/null +++ b/src/devices/plug.py @@ -0,0 +1,175 @@ +"""Hive Switch Module.""" + +import logging +from typing import Any + +from ..helper.compat_aliases import SwitchCompatMixin +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class HiveSmartPlug(BaseDeviceHandler): + """Plug Device. + + Returns: + object: Returns Plug object + """ + + session: Any + plug_type = "Switch" + + async def get_state(self, device: Device): + """Get smart plug state. + + Args: + device (dict): Device to get the plug state for. + + Returns: + boolean: Returns True or False based on if the plug is on + """ + state = self._get_product_state(device, "state", "status") + return self._map_hive_to_ha("Switch", state) + + async def get_power_usage(self, device: Device): + """Get smart plug current power usage. + + Args: + device (dict): Device to get power usage for. + + Returns: + float: Current power consumption in watts. + """ + return self._get_product_state(device, "props", "powerConsumption") + + async def set_status_on(self, device: Device): + """Set smart plug to turn on. + + Args: + device (dict): Device to switch on. + + Returns: + boolean: True/False if successful + """ + _LOGGER.debug("set_status_on - Turning plug ON for %s.", device.ha_name) + return await self._execute_state_change(device, status="ON") + + async def set_status_off(self, device: Device): + """Set smart plug to turn off. + + Args: + device (dict): Device to switch off. + + Returns: + boolean: True/False if successful + """ + _LOGGER.debug("set_status_off - Turning plug OFF for %s.", device.ha_name) + return await self._execute_state_change(device, status="OFF") + + +class Switch(SwitchCompatMixin, HiveSmartPlug): + """Home Assistant switch class. + + Args: + SmartPlug (Class): Initialises the Smartplug Class. + """ + + def __init__(self, session: Any): + """Initialise switch. + + Args: + session (object): This is the session object to interact with the current session. + """ + self.session = session + + async def get_switch(self, device: Device): + """Home assistant wrapper to get switch device. + + Args: + device (dict): Device to be update. + + Returns: + dict: Return device after update is complete. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "get_switch - Returning cached state for switch %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_switch - Updating switch data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.status = {"state": await self.get_switch_state(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = {} + + if device.hive_type == "activeplug": + device.status["power_usage"] = await self.get_power_usage(device) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + _LOGGER.debug( + "get_switch - Switch device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device + + async def get_switch_state(self, device: Device): + """Home Assistant wrapper to get updated switch state. + + Args: + device (dict): Device to get state for + + Returns: + boolean: Return True or False for the state. + """ + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.get_heat_on_demand(device) + return await self.get_state(device) + + async def turn_on(self, device: Device): + """Home Assisatnt wrapper for turning switch on. + + Args: + device (dict): Device to switch on. + + Returns: + function: Calls relevant function. + """ + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.set_heat_on_demand(device, "ENABLED") + return await self.set_status_on(device) + + async def turn_off(self, device: Device): + """Home Assisatnt wrapper for turning switch off. + + Args: + device (dict): Device to switch off. + + Returns: + function: Calls relevant function. + """ + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.set_heat_on_demand(device, "DISABLED") + return await self.set_status_off(device) diff --git a/src/devices/sensor.py b/src/devices/sensor.py new file mode 100644 index 0000000..9f0a431 --- /dev/null +++ b/src/devices/sensor.py @@ -0,0 +1,157 @@ +"""Hive Sensor Module.""" + +import logging +from typing import Any + +from ..helper.compat_aliases import SensorCompatMixin +from ..helper.const import HIVE_TYPES, HIVETOHA, sensor_commands +from ..helper.device_handler_base import BaseDeviceHandler +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class HiveSensor(BaseDeviceHandler): + """Hive Sensor Code.""" + + session: Any + sensor_type = "Sensor" + + async def get_state(self, device: Device): + """Get sensor state. + + Args: + device (dict): Device to get state off. + + Returns: + str: State of device. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + if data["type"] == "contactsensor": + state = data["props"]["status"] + final = HIVETOHA[self.sensor_type].get(state, state) + elif data["type"] == "motionsensor": + final = data["props"]["motion"]["status"] + except KeyError as e: + _LOGGER.error(e) + + return final + + async def online(self, device: Device): + """Get the online status of the Hive hub. + + Args: + device (dict): Device to get the state of. + + Returns: + boolean: True/False if the device is online. + """ + state = None + final = None + + try: + data = self.session.data.devices[device.device_id] + state = data["props"]["online"] + final = HIVETOHA[self.sensor_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + +class Sensor(SensorCompatMixin, HiveSensor): + """Home Assisatnt sensor code. + + Args: + HiveSensor (object): Hive sensor code. + """ + + def __init__(self, session: Any = None): + """Initialise sensor. + + Args: + session (object, optional): session to interact with Hive account. Defaults to None. + """ + self.session = session + + async def get_sensor(self, device: Device): + """Gets updated sensor data. + + Args: + device (dict): Device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for sensor %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + data = {} + + if device.device_data["online"] or device.hive_type in ( + "Availability", + "Connectivity", + ): + if device.hive_type not in ("Availability", "Connectivity"): + self.session.helper.device_recovered(device.device_id) + + _LOGGER.debug( + "get_sensor - Updating sensor data for %s (%s).", + device.ha_name, + device.hive_type, + ) + + if device.device_id in self.session.data.devices: + data = self.session.data.devices.get(device.device_id, {}) + elif device.hive_id in self.session.data.products: + data = self.session.data.products.get(device.hive_id, {}) + + if ( + device.hive_type in sensor_commands + or getattr(device, "custom", None) in sensor_commands + ): + code = sensor_commands.get( + device.hive_type, + sensor_commands.get(getattr(device, "custom", "")), + ) + device.status = {"state": await code(self, device)} # type: ignore[misc] + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + elif device.hive_type in HIVE_TYPES["Sensor"]: + data = self.session.data.devices.get(device.hive_id, {}) + device.status = {"state": await self.get_state(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + _LOGGER.debug( + "get_sensor - Sensor device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device diff --git a/src/heating.py b/src/heating.py index 2dd123f..5ec060c 100644 --- a/src/heating.py +++ b/src/heating.py @@ -1,656 +1,15 @@ -"""Hive Heating Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.heating instead.""" -import logging -from datetime import datetime -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA, HTTP_OK -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.heating is deprecated; import from apyhiveapi.devices.heating", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.heating import Climate, HiveHeating - -class HiveHeating: - """Hive Heating Code. - - Returns: - object: heating - """ - - session: Any - heating_type = "Heating" - - async def get_min_temperature(self, device: Device): - """Get heating minimum target temperature. - - Args: - device (dict): Device to get min temp for. - - Returns: - int: Minimum temperature - """ - if device.hive_type == "nathermostat": - return self.session.data.products[device.hive_id]["props"]["minHeat"] - return 5 - - async def get_max_temperature(self, device: Device): - """Get heating maximum target temperature. - - Args: - device (dict): Device to get max temp for. - - Returns: - int: Maximum temperature - """ - if device.hive_type == "nathermostat": - return self.session.data.products[device.hive_id]["props"]["maxHeat"] - return 32 - - async def get_current_temperature(self, device: Device): - """Get heating current temperature. - - Args: - device (dict): Device to get current temperature for. - - Returns: - float: current temperature - """ - state = None - final = None - device_name = device.ha_name - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["temperature"] - - try: - state = float(state) - except (ValueError, TypeError): - _LOGGER.warning( - "get_current_temperature - Non-numeric temperature value '%s' for %s.", - state, - device_name, - ) - return None - - if device.hive_id in self.session.data.minMax: - if self.session.data.minMax[device.hive_id]["TodayDate"] == str( - datetime.date(datetime.now()) - ): - self.session.data.minMax[device.hive_id]["TodayMin"] = min( - self.session.data.minMax[device.hive_id]["TodayMin"], state - ) - - self.session.data.minMax[device.hive_id]["TodayMax"] = max( - self.session.data.minMax[device.hive_id]["TodayMax"], state - ) - else: - data = { - "TodayMin": state, - "TodayMax": state, - "TodayDate": str(datetime.date(datetime.now())), - } - self.session.data.minMax[device.hive_id].update(data) - - self.session.data.minMax[device.hive_id]["RestartMin"] = min( - self.session.data.minMax[device.hive_id]["RestartMin"], state - ) - - self.session.data.minMax[device.hive_id]["RestartMax"] = max( - self.session.data.minMax[device.hive_id]["RestartMax"], state - ) - else: - data = { - "TodayMin": state, - "TodayMax": state, - "TodayDate": str(datetime.date(datetime.now())), - "RestartMin": state, - "RestartMax": state, - } - self.session.data.minMax[device.hive_id] = data - - final = round(state, 1) - except KeyError as e: - _LOGGER.error( - "get_current_temperature - KeyError getting temperature for %s: %s", - device_name, - str(e), - ) - - return final - - async def get_target_temperature(self, device: Device): - """Get heating target temperature. - - Args: - device (dict): Device to get target temperature for. - - Returns: - float: Target temperature or None if invalid - """ - state = None - device_name = device.ha_name - - try: - data = self.session.data.products[device.hive_id] - state = data["state"].get("target", None) - if state is None: - state = data["state"].get("heat", None) - - if state is not None: - try: - state = float(state) - except (ValueError, TypeError): - _LOGGER.warning( - "get_target_temperature - Non-numeric target temperature" - " value '%s' for %s.", - state, - device_name, - ) - return None - except (KeyError, TypeError) as e: - _LOGGER.error( - "get_target_temperature - Error getting target temperature for %s: %s", - device_name, - str(e), - ) - - return state - - async def get_mode(self, device: Device): - """Get heating current mode. - - Args: - device (dict): Device to get current mode for. - - Returns: - str: Current Mode - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["mode"] - if state == "BOOST": - state = data["props"]["previous"]["mode"] - final = HIVETOHA[self.heating_type].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_state(self, device: Device): - """Get heating current state. - - Args: - device (dict): Device to get state for. - - Returns: - str: Current state. - """ - state = None - final = None - - try: - current_temp = await self.get_current_temperature(device) - target_temp = await self.get_target_temperature(device) - if current_temp is not None and target_temp is not None: - if current_temp < target_temp: - state = "ON" - else: - state = "OFF" - final = HIVETOHA[self.heating_type].get(state, state) - except (KeyError, TypeError) as e: - _LOGGER.error(e) - - return final - - async def get_current_operation(self, device: Device): - """Get heating current operation. - - Args: - device (dict): Device to get current operation for. - - Returns: - str: Current operation. - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["working"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def get_boost_status(self, device: Device): - """Get heating boost current status. - - Args: - device (dict): Device to get boost status for. - - Returns: - str: Boost status. - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = HIVETOHA["Boost"].get(data["state"].get("boost", False), "ON") - except KeyError as e: - _LOGGER.error(e) - - return state - - async def get_boost_time(self, device: Device): - """Get heating boost time remaining. - - Args: - device (dict): device to get boost time for. - - Returns: - str: Boost time. - """ - if await self.get_boost_status(device) == "ON": - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["boost"] - except KeyError as e: - _LOGGER.error(e) - - return state - return None - - async def get_heat_on_demand(self, device: Device): - """Get heat on demand status. - - Args: - device ([dictionary]): [Get Heat on Demand status for Thermostat device.] - - Returns: - str: [Return True or False for the Heat on Demand status.] - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["autoBoost"]["active"] - except KeyError as e: - _LOGGER.error(e) - - return state - - @staticmethod - async def get_operation_modes(): - """Get heating list of possible modes. - - Returns: - list: Operation modes. - """ - return ["SCHEDULE", "MANUAL", "OFF"] - - async def set_target_temperature(self, device: Device, new_temp: str): - """Set heating target temperature. - - Args: - device (dict): Device to set target temperature for. - new_temp (str): New temperature. - - Returns: - boolean: True/False if successful - """ - device_name = device.ha_name - _LOGGER.info( - "set_target_temperature - Setting target temperature to %s°C for %s", - new_temp, - device_name, - ) - - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_target_temperature - Device %s is online, proceeding with temperature change", - device_name, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, target=new_temp - ) - - if resp["original"] == HTTP_OK: - _LOGGER.debug( - "set_target_temperature - Temperature set successfully" - " for %s, refreshing device data", - device_name, - ) - await self.session.get_devices(device.hive_id) - final = True - else: - _LOGGER.error( - "set_target_temperature - Failed to set temperature for %s, response: %s", - device_name, - resp["original"], - ) - else: - _LOGGER.warning( - "set_target_temperature - Device %s not found or offline, cannot set temperature", - device_name, - ) - - return final - - async def set_mode(self, device: Device, new_mode: str): - """Set heating mode. - - Args: - device (dict): Device to set mode for. - new_mode (str): New mode to be set. - - Returns: - boolean: True/False if successful - """ - device_name = device.ha_name - _LOGGER.info( - "set_mode - Setting heating mode to %s for %s", new_mode, device_name - ) - - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_mode - Device %s is online, proceeding with mode change", - device_name, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, mode=new_mode - ) - - if resp["original"] == HTTP_OK: - _LOGGER.debug( - "set_mode - Mode set successfully for %s, refreshing device data", - device_name, - ) - await self.session.get_devices(device.hive_id) - final = True - else: - _LOGGER.error( - "set_mode - Failed to set mode for %s, response: %s", - device_name, - resp["original"], - ) - else: - _LOGGER.warning( - "set_mode - Device %s not found or offline, cannot set mode", - device_name, - ) - - return final - - async def set_boost_on(self, device: Device, mins: str, temp: float): - """Turn heating boost on. - - Args: - device (dict): Device to boost. - mins (str): Number of minutes to boost for. - temp (float): Temperature to boost to. - - Returns: - boolean: True/False if successful - """ - if int(mins) > 0 and int(temp) >= await self.get_min_temperature(device): - if int(temp) <= await self.get_max_temperature(device): - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_boost_on - Setting heating boost ON for %s: %s mins at %s degrees.", - device.ha_name, - mins, - temp, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], - device.hive_id, - mode="BOOST", - boost=mins, - target=temp, - ) - - if resp["original"] == HTTP_OK: - await self.session.get_devices(device.hive_id) - final = True - - return final - return None - - async def set_boost_off(self, device: Device): - """Turn heating boost off. - - Args: - device (dict): Device to update boost for. - - Returns: - boolean: True/False if successful - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_boost_off - Setting heating boost OFF for %s.", device.ha_name - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - await self.session.get_devices(device.hive_id) - if await self.get_boost_status(device) == "ON": - prev_mode = data["props"]["previous"]["mode"] - if prev_mode in ("MANUAL", "OFF"): - pre_temp = data["props"]["previous"].get("target", 7) - resp = await self.session.api.set_state( - data["type"], - device.hive_id, - mode=prev_mode, - target=pre_temp, - ) - else: - resp = await self.session.api.set_state( - data["type"], device.hive_id, mode=prev_mode - ) - if resp["original"] == HTTP_OK: - await self.session.get_devices(device.hive_id) - final = True - - return final - - async def set_heat_on_demand(self, device: Device, state: str): - """Enable or disable Heat on Demand for a Thermostat. - - Args: - device ([dictionary]): [This is the Thermostat device you want to update.] - state ([str]): [This is the state you want to set. (Either "ENABLED" or "DISABLED")] - - Returns: - [boolean]: [Return True or False if the Heat on Demand was set successfully.] - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_heat_on_demand - Setting heat on demand to %s for %s.", - state, - device.ha_name, - ) - data = self.session.data.products[device.hive_id] - await self.session.hive_refresh_tokens() - resp = await self.session.api.set_state( - data["type"], device.hive_id, autoBoost=state - ) - - if resp["original"] == HTTP_OK: - await self.session.get_devices(device.hive_id) - final = True - - return final - - -class Climate(HiveHeating): - """Climate class for Home Assistant. - - Args: - Heating (object): Heating class - """ - - def __init__(self, session: Any = None): - """Initialise heating. - - Args: - session (object, optional): Used to interact with hive account. Defaults to None. - """ - self.session = session - - async def get_climate(self, device: Device): - """Get heating data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "get_climate - Returning cached state for climate %s (slow/busy poll).", - device.ha_name, - ) - return cached - online = await self.session.attr.online_offline(device.device_id) - if not isinstance(device.device_data, dict): - device.device_data = {} - device.device_data["online"] = online - - if device.device_data["online"]: - self.session.helper.device_recovered(device.device_id) - _LOGGER.debug("get_climate - Updating climate data for %s.", device.ha_name) - data = self.session.data.devices[device.device_id] - device.min_temp = await self.get_min_temperature(device) - device.max_temp = await self.get_max_temperature(device) - device.status = { - "current_temperature": await self.get_current_temperature(device), - "target_temperature": await self.get_target_temperature(device), - "action": await self.get_current_operation(device), - "mode": await self.get_mode(device), - "boost": await self.get_boost_status(device), - } - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - device.attributes = await self.session.attr.state_attributes( - device.device_id, device.hive_type - ) - _LOGGER.debug( - "get_climate - Heating device data for %s: %s", - device.ha_name, - device.status, - ) - return self.session.set_cached_device(device) - await self.session.helper.error_check( - device.device_id, "ERROR", device.device_data["online"] - ) - device.status = device.status or { - "current_temperature": None, - "target_temperature": None, - "action": None, - "mode": None, - "boost": None, - "state": None, - } - return device - - async def get_schedule_now_next_later(self, device: Device): - """Hive get heating schedule now, next and later. - - Args: - device (dict): Device to get schedule for. - - Returns: - dict: Schedule now, next and later - """ - online = await self.session.attr.online_offline(device.device_id) - current_mode = await self.get_mode(device) - state = None - - try: - if online and current_mode == "SCHEDULE": - data = self.session.data.products[device.hive_id] - state = self.session.helper.get_schedule_nnl(data["state"]["schedule"]) - except KeyError as e: - _LOGGER.error(e) - - return state - - async def minmax_temperature(self, device: Device): - """Min/Max Temp. - - Args: - device (dict): device to get min/max temperature for. - - Returns: - dict: Shows min/max temp for the day. - """ - state = None - final = None - - try: - state = self.session.data.minMax[device.hive_id] - final = state - except KeyError as e: - _LOGGER.error(e) - - return final - - async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name - """Backwards-compatible alias for set_mode.""" - return await self.set_mode(device, new_mode) - - async def setTargetTemperature(self, device: Device, new_temp: str): # pylint: disable=invalid-name - """Backwards-compatible alias for set_target_temperature.""" - return await self.set_target_temperature(device, new_temp) - - async def setBoostOn(self, device: Device, mins: str, temp: float): # pylint: disable=invalid-name - """Backwards-compatible alias for set_boost_on.""" - return await self.set_boost_on(device, mins, temp) - - async def setBoostOff(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for set_boost_off.""" - return await self.set_boost_off(device) - - async def getClimate(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_climate.""" - return await self.get_climate(device) +__all__ = ["HiveHeating", "Climate"] diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py new file mode 100644 index 0000000..b1fe79e --- /dev/null +++ b/src/helper/compat_aliases.py @@ -0,0 +1,143 @@ +"""Backwards-compatible camelCase method aliases for Home Assistant integration. + +The HA integration historically called camelCase methods (``turnOn``, ``setMode``, etc.). +These mixins preserve that API so the integration does not need to be updated. +""" +# pylint: disable=no-member + +from __future__ import annotations + +from typing import Any + +from .hivedataclasses import Device + + +class HeatingCompatMixin: + """CamelCase aliases for Climate (heating) public methods.""" + + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_mode.""" + return await self.set_mode(device, new_mode) # type: ignore[attr-defined] + + async def setTargetTemperature(self, device: Device, new_temp: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_target_temperature.""" + return await self.set_target_temperature(device, new_temp) # type: ignore[attr-defined] + + async def setBoostOn(self, device: Device, mins: str, temp: float): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_on.""" + return await self.set_boost_on(device, mins, temp) # type: ignore[attr-defined] + + async def setBoostOff(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_off.""" + return await self.set_boost_off(device) # type: ignore[attr-defined] + + async def getClimate(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_climate.""" + return await self.get_climate(device) # type: ignore[attr-defined] + + +class LightCompatMixin: + """CamelCase aliases for Light public methods.""" + + async def turnOn( # pylint: disable=invalid-name + self, device: Device, brightness: int, color_temp: int, color: list + ): + """Backwards-compatible alias for turn_on.""" + return await self.turn_on( # type: ignore[attr-defined] + device, brightness, color_temp, color + ) + + async def turnOff(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_off.""" + return await self.turn_off(device) # type: ignore[attr-defined] + + async def getLight(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_light.""" + return await self.get_light(device) # type: ignore[attr-defined] + + +class SwitchCompatMixin: + """CamelCase aliases for Switch (plug) public methods.""" + + async def turnOn(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_on.""" + return await self.turn_on(device) # type: ignore[attr-defined] + + async def turnOff(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_off.""" + return await self.turn_off(device) # type: ignore[attr-defined] + + async def getSwitch(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_switch.""" + return await self.get_switch(device) # type: ignore[attr-defined] + + +class WaterHeaterCompatMixin: + """CamelCase aliases for WaterHeater (hotwater) public methods.""" + + async def get_boost(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_status.""" + return await self.get_boost_status(device) # type: ignore[attr-defined] + + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_mode.""" + return await self.set_mode(device, new_mode) # type: ignore[attr-defined] + + async def setBoostOn(self, device: Device, mins: int): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_on.""" + return await self.set_boost_on(device, mins) # type: ignore[attr-defined] + + async def setBoostOff(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_off.""" + return await self.set_boost_off(device) # type: ignore[attr-defined] + + async def getWaterHeater(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_water_heater.""" + return await self.get_water_heater(device) # type: ignore[attr-defined] + + +class SensorCompatMixin: # pylint: disable=too-few-public-methods + """CamelCase aliases for Sensor public methods.""" + + async def getSensor(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_sensor.""" + return await self.get_sensor(device) # type: ignore[attr-defined] + + +class ActionCompatMixin: + """CamelCase aliases for HiveAction public methods.""" + + async def getAction(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_action.""" + return await self.get_action(device) # type: ignore[attr-defined] + + async def setStatusOn(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for set_status_on.""" + return await self.set_status_on(device) # type: ignore[attr-defined] + + async def setStatusOff(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for set_status_off.""" + return await self.set_status_off(device) # type: ignore[attr-defined] + + +class SessionCompatMixin: + """CamelCase and legacy aliases for HiveSession public methods.""" + + device_list: Any # provided by HiveSession.__init__ + + @property + def deviceList(self): # pylint: disable=invalid-name + """Backwards-compatible alias for device_list.""" + return self.device_list + + async def startSession(self, config: dict | None = None): # pylint: disable=invalid-name + """Backwards-compatible alias for start_session.""" + return await self.start_session(config) # type: ignore[attr-defined] + + async def updateData(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for update_data.""" + return await self.update_data(device) # type: ignore[attr-defined] + + async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name,unused-argument + """Backwards-compatible alias for Home Assistant Scan Interval.""" + return True diff --git a/src/helper/const.py b/src/helper/const.py index 77ded7c..7c27601 100644 --- a/src/helper/const.py +++ b/src/helper/const.py @@ -27,6 +27,8 @@ HTTP_BAD_GATEWAY = 502 HTTP_SERVICE_UNAVAILABLE = 503 +EXPECTED_DEVICE_DATA_LENGTH = 3 + HIVETOHA: dict[str, Any] = { "Attribute": {True: "Online", False: "Offline"}, diff --git a/src/helper/device_attributes.py b/src/helper/device_attributes.py new file mode 100644 index 0000000..b703c21 --- /dev/null +++ b/src/helper/device_attributes.py @@ -0,0 +1,105 @@ +"""Hive Device Attribute Module.""" + +import logging +from typing import Any + +from .const import HIVETOHA + +_LOGGER = logging.getLogger(__name__) + + +class HiveAttributes: + """Device Attributes Code.""" + + def __init__(self, session: Any = None): + """Initialise attributes. + + Args: + session (object, optional): Session to interact with hive account. Defaults to None. + """ + self.session = session + self.type = "Attribute" + + async def state_attributes(self, n_id: str, _type: str): + """Get HA State Attributes. + + Args: + n_id (str): The id of the device. + _type (str): The device type. + + Returns: + dict: Set of attributes. + """ + attr = {} + + if n_id in self.session.data.products or n_id in self.session.data.devices: + attr.update({"available": (await self.online_offline(n_id))}) + if n_id in self.session.config.battery: + battery = await self.get_battery(n_id) + if battery is not None: + attr.update({"battery": str(battery) + "%"}) + if n_id in self.session.config.mode: + attr.update({"mode": (await self.get_mode(n_id))}) + return attr + + async def online_offline(self, n_id: str): + """Check if device is online. + + Args: + n_id (str): The id of the device. + + Returns: + boolean: True/False if device online. + """ + state = None + + try: + data = self.session.data.devices[n_id] + state = data["props"]["online"] + except KeyError as e: + _LOGGER.error(e) + + return state + + async def get_mode(self, n_id: str): + """Get sensor mode. + + Args: + n_id (str): The id of the device + + Returns: + str: The mode of the device. + """ + state = None + final = None + + try: + data = self.session.data.products[n_id] + state = data["state"]["mode"] + final = HIVETOHA[self.type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + async def get_battery(self, n_id: str): + """Get device battery level. + + Args: + n_id (str): The id of the device. + + Returns: + str: Battery level of device. + """ + state = None + final = None + + try: + data = self.session.data.devices[n_id] + state = data["props"]["battery"] + final = state + await self.session.helper.error_check(n_id, self.type, state) + except KeyError as e: + _LOGGER.error(e) + + return final diff --git a/src/helper/device_handler_base.py b/src/helper/device_handler_base.py new file mode 100644 index 0000000..97a3018 --- /dev/null +++ b/src/helper/device_handler_base.py @@ -0,0 +1,78 @@ +"""Shared base class for all Hive device handlers.""" + +import logging +from typing import Any + +from .const import HIVETOHA, HTTP_OK +from .hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class BaseDeviceHandler: # pylint: disable=too-few-public-methods + """Common plumbing shared across all Hive device handler classes. + + Subclasses must ensure ``self.session`` is set before any method is called. + """ + + session: Any + + async def _execute_state_change(self, device: Device, **state_kwargs) -> bool: + """Check online → refresh tokens → set_state → get_devices. + + Returns True on HTTP 200, False on failure or when device is + unavailable. Uses the product type stored in session.data so callers + never need to read it themselves. + """ + if device.hive_id not in self.session.data.products: + _LOGGER.debug( + "_execute_state_change - %s not found in products", device.ha_name + ) + return False + if not ( + isinstance(device.device_data, dict) and device.device_data.get("online") + ): + _LOGGER.debug( + "_execute_state_change - %s is offline or device_data not initialised", + device.ha_name, + ) + return False + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, **state_kwargs + ) + if resp["original"] == HTTP_OK: + await self.session.get_devices(device.hive_id) + return True + _LOGGER.error( + "_execute_state_change - set_state failed for %s: HTTP %s", + device.ha_name, + resp["original"], + ) + return False + + def _get_product_state(self, device: Device, *path_keys, default=None): + """Read a nested value from session.data.products[device.hive_id]. + + Returns *default* (None by default) if any key in *path_keys* is + missing rather than raising KeyError. + """ + try: + node = self.session.data.products[device.hive_id] + for key in path_keys: + node = node[key] + return node + except (KeyError, TypeError): + return default + + def _map_hive_to_ha(self, mapping_key: str, value, fallback=None): + """Translate a raw Hive API value through HIVETOHA[mapping_key]. + + Returns *fallback* when the key is absent from the mapping, or the + original *value* when *fallback* is None. + """ + mapping = HIVETOHA.get(mapping_key, {}) + if value in mapping: + return mapping[value] + return fallback if fallback is not None else value diff --git a/src/hive.py b/src/hive.py index 2c7b47d..74ef13c 100644 --- a/src/hive.py +++ b/src/hive.py @@ -7,13 +7,13 @@ from aiohttp import ClientSession -from .action import HiveAction -from .heating import Climate -from .hotwater import WaterHeater -from .hub import HiveHub -from .light import Light -from .plug import Switch -from .sensor import Sensor +from .devices.action import HiveAction +from .devices.heating import Climate +from .devices.hotwater import WaterHeater +from .devices.hub import HiveHub +from .devices.light import Light +from .devices.plug import Switch +from .devices.sensor import Sensor from .session import HiveSession _LOGGER = logging.getLogger(__name__) diff --git a/src/hotwater.py b/src/hotwater.py index 6bb3f0b..ea2bd5f 100644 --- a/src/hotwater.py +++ b/src/hotwater.py @@ -1,320 +1,15 @@ -"""Hive Hotwater Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.hotwater instead.""" -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA, HTTP_OK -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.hotwater is deprecated; import from apyhiveapi.devices.hotwater", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.hotwater import HiveHotwater, WaterHeater - -class HiveHotwater: - """Hive Hotwater Code. - - Returns: - object: Hotwater Object. - """ - - session: Any - hotwater_type = "Hotwater" - - async def get_mode(self, device: Device): - """Get hotwater current mode. - - Args: - device (dict): Device to get the mode for. - - Returns: - str: Return mode. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["mode"] - if state == "BOOST": - state = data["props"]["previous"]["mode"] - final = HIVETOHA[self.hotwater_type].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - @staticmethod - async def get_operation_modes(): - """Get heating list of possible modes. - - Returns: - list: Return list of operation modes. - """ - return ["SCHEDULE", "ON", "OFF"] - - async def get_boost(self, device: Device): - """Get hot water current boost status. - - Args: - device (dict): Device to get boost status for - - Returns: - str: Return boost status. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["boost"] - final = HIVETOHA["Boost"].get(state, "ON") - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_boost_time(self, device: Device): - """Get hotwater boost time remaining. - - Args: - device (dict): Device to get boost time for. - - Returns: - str: Return time remaining on the boost. - """ - state = None - if await self.get_boost(device) == "ON": - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["boost"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def get_state(self, device: Device): - """Get hot water current state. - - Args: - device (dict): Device to get the state for. - - Returns: - str: return state of device. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["status"] - mode_current = await self.get_mode(device) - if mode_current == "SCHEDULE": - if await self.get_boost(device) == "ON": - state = "ON" - else: - snan = self.session.helper.get_schedule_nnl( - data["state"]["schedule"] - ) - state = snan["now"]["value"]["status"] - - final = HIVETOHA[self.hotwater_type].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def set_mode(self, device: Device, new_mode: str): - """Set hot water mode. - - Args: - device (dict): device to update mode. - new_mode (str): Mode to set the device to. - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if device.hive_id in self.session.data.products: - _LOGGER.debug( - "set_mode - Setting hot water mode to %s for %s.", - new_mode, - device.ha_name, - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, mode=new_mode - ) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_boost_on(self, device: Device, mins: int): - """Turn hot water boost on. - - Args: - device (dict): Deice to boost. - mins (int): Number of minutes to boost it for. - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if ( - int(mins) > 0 - and device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_boost_on - Setting hot water boost ON for %s: %s mins.", - device.ha_name, - mins, - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, mode="BOOST", boost=mins - ) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_boost_off(self, device: Device): - """Turn hot water boost off. - - Args: - device (dict): device to set boost off - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if ( - device.hive_id in self.session.data.products - and await self.get_boost(device) == "ON" - and device.device_data["online"] - ): - _LOGGER.debug( - "set_boost_off - Setting hot water boost OFF for %s.", device.ha_name - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - prev_mode = data["props"]["previous"]["mode"] - resp = await self.session.api.set_state( - data["type"], device.hive_id, mode=prev_mode - ) - if resp["original"] == HTTP_OK: - await self.session.get_devices(device.hive_id) - final = True - - return final - - -class WaterHeater(HiveHotwater): - """Water heater class. - - Args: - Hotwater (object): Hotwater class. - """ - - def __init__(self, session: Any = None): - """Initialise water heater. - - Args: - session (object, optional): Session to interact with account. Defaults to None. - """ - self.session = session - - async def get_water_heater(self, device: Device): - """Update water heater device. - - Args: - device (dict): device to update. - - Returns: - dict: Updated device. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "get_water_heater - Returning cached state for" - " water heater %s (slow/busy poll).", - device.ha_name, - ) - return cached - online = await self.session.attr.online_offline(device.device_id) - if not isinstance(device.device_data, dict): - device.device_data = {} - device.device_data["online"] = online - - if device.device_data["online"]: - self.session.helper.device_recovered(device.device_id) - _LOGGER.debug( - "get_water_heater - Updating hot water data for %s.", device.ha_name - ) - data = self.session.data.devices[device.device_id] - device.status = {"current_operation": await self.get_mode(device)} - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - device.attributes = await self.session.attr.state_attributes( - device.device_id, device.hive_type - ) - - _LOGGER.debug( - "get_water_heater - Water heater device data for %s: %s", - device.ha_name, - device.status, - ) - - return self.session.set_cached_device(device) - await self.session.helper.error_check( - device.device_id, "ERROR", device.device_data["online"] - ) - device.status = device.status or {"current_operation": None} - return device - - async def get_schedule_now_next_later(self, device: Device): - """Hive get hotwater schedule now, next and later. - - Args: - device (dict): device to get schedule for. - - Returns: - dict: return now, next and later schedule. - """ - state = None - - try: - mode_current = await self.get_mode(device) - if mode_current == "SCHEDULE": - data = self.session.data.products[device.hive_id] - state = self.session.helper.get_schedule_nnl(data["state"]["schedule"]) - except KeyError as e: - _LOGGER.error(e) - - return state - - async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name - """Backwards-compatible alias for set_mode.""" - return await self.set_mode(device, new_mode) - - async def setBoostOn(self, device: Device, mins: int): # pylint: disable=invalid-name - """Backwards-compatible alias for set_boost_on.""" - return await self.set_boost_on(device, mins) - - async def setBoostOff(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for set_boost_off.""" - return await self.set_boost_off(device) - - async def getWaterHeater(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_water_heater.""" - return await self.get_water_heater(device) +__all__ = ["HiveHotwater", "WaterHeater"] diff --git a/src/hub.py b/src/hub.py index 181d8af..3d5a727 100644 --- a/src/hub.py +++ b/src/hub.py @@ -1,91 +1,15 @@ -"""Hive Hub Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.hub instead.""" -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.hub is deprecated; import from apyhiveapi.devices.hub", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.hub import HiveHub - -class HiveHub: - """Hive hub. - - Returns: - object: Returns a hub object. - """ - - hub_type = "Hub" - log_type = "Sensor" - - def __init__(self, session: Any = None): - """Initialise hub. - - Args: - session (object, optional): session to interact with Hive account. Defaults to None. - """ - self.session = session - - async def get_smoke_status(self, device: Device): - """Get the hub smoke status. - - Args: - device (dict): device to get status for - - Returns: - str: Return smoke status. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["sensors"]["SMOKE_CO"]["active"] - final = HIVETOHA[self.hub_type]["Smoke"].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_dog_bark_status(self, device: Device): - """Get dog bark status. - - Args: - device (dict): Device to get status for. - - Returns: - str: Return status. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["sensors"]["DOG_BARK"]["active"] - final = HIVETOHA[self.hub_type]["Dog"].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_glass_break_status(self, device: Device): - """Get the glass detected status from the Hive hub. - - Args: - device (dict): Device to get status for. - - Returns: - str: Return status. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["sensors"]["GLASS_BREAK"]["active"] - final = HIVETOHA[self.hub_type]["Glass"].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final +__all__ = ["HiveHub"] diff --git a/src/light.py b/src/light.py index 5d3108a..2432b0e 100644 --- a/src/light.py +++ b/src/light.py @@ -1,516 +1,15 @@ -"""Hive Light Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.light instead.""" -import colorsys -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA, HTTP_OK -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.light is deprecated; import from apyhiveapi.devices.light", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.light import HiveLight, Light - -class HiveLight: - """Hive Light Code. - - Returns: - object: Hivelight - """ - - session: Any - light_type = "Light" - - async def get_state(self, device: Device): - """Get light current state. - - Args: - device (dict): Device to get the state of. - - Returns: - str: State of the light. - """ - state = None - final = None - device_name = device.ha_name - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["status"] - final = HIVETOHA[self.light_type].get(state, state) - except KeyError as e: - _LOGGER.error( - "KeyError getting light state for %s: %s", device_name, str(e) - ) - - return final - - async def get_brightness(self, device: Device): - """Get light current brightness. - - Args: - device (dict): Device to get the brightness of. - - Returns: - int: Brightness value. - """ - state = None - final = None - device_name = device.ha_name - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["brightness"] - final = (state / 100) * 255 - except KeyError as e: - _LOGGER.error( - "KeyError getting light brightness for %s: %s", device_name, str(e) - ) - - return final - - async def get_min_color_temp(self, device: Device): - """Get light minimum color temperature. - - Args: - device (dict): Device to get min colour temp for. - - Returns: - int: Min color temperature. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["colourTemperature"]["max"] - final = round((1 / state) * 1000000) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_max_color_temp(self, device: Device): - """Get light maximum color temperature. - - Args: - device (dict): Device to get max colour temp for. - - Returns: - int: Min color temperature. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["colourTemperature"]["min"] - final = round((1 / state) * 1000000) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_color_temp(self, device: Device): - """Get light current color temperature. - - Args: - device (dict): Device to get colour temp for. - - Returns: - int: Current Color Temp. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["colourTemperature"] - final = round((1 / state) * 1000000) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_color(self, device: Device): - """Get light current colour. - - Args: - device (dict): Device to get color for. - - Returns: - tuple: RGB values for the color. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - state = [ - (data["state"]["hue"]) / 360, - (data["state"]["saturation"]) / 100, - (data["state"]["value"]) / 100, - ] - final = tuple( - int(i * 255) for i in colorsys.hsv_to_rgb(state[0], state[1], state[2]) - ) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def get_color_mode(self, device: Device): - """Get Colour Mode. - - Args: - device (dict): Device to get the color mode for. - - Returns: - str: Colour mode. - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["colourMode"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def set_status_off(self, device: Device): - """Set light to turn off. - - Args: - device (dict): Device to turn off. - - Returns: - boolean: True/False if successful - """ - device_name = device.ha_name - _LOGGER.info("Turning off light %s", device_name) - - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_status_off - Device %s is online, proceeding with turn off", - device_name, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, status="OFF" - ) - - if resp["original"] == HTTP_OK: - _LOGGER.debug( - "set_status_off - Light turned off successfully for %s, refreshing device data", - device_name, - ) - await self.session.get_devices(device.hive_id) - final = True - else: - _LOGGER.error( - "Failed to turn off light %s, response: %s", - device_name, - resp["original"], - ) - else: - _LOGGER.warning( - "Device %s not found or offline, cannot turn off", device_name - ) - - return final - - async def set_status_on(self, device: Device): - """Set light to turn on. - - Args: - device (dict): Device to turn on. - - Returns: - boolean: True/False if successful - """ - device_name = device.ha_name - _LOGGER.info("Turning on light %s", device_name) - - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_status_on - Device %s is online, proceeding with turn on", - device_name, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, status="ON" - ) - - if resp["original"] == HTTP_OK: - _LOGGER.debug( - "set_status_on - Light turned on successfully for %s, refreshing device data", - device_name, - ) - await self.session.get_devices(device.hive_id) - final = True - else: - _LOGGER.error( - "Failed to turn on light %s, response: %s", - device_name, - resp["original"], - ) - else: - _LOGGER.warning( - "Device %s not found or offline, cannot turn on", device_name - ) - - return final - - async def set_brightness(self, device: Device, n_brightness: int): - """Set brightness of the light. - - Args: - device (dict): Device to set brightness of. - n_brightness (int): Brightness value to set the light to. - - Returns: - boolean: True/False if successful - """ - device_name = device.ha_name - _LOGGER.info("Setting brightness to %s for light %s", n_brightness, device_name) - - await self.session.hive_refresh_tokens() - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_brightness - Device %s is online, proceeding with brightness change", - device_name, - ) - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], device.hive_id, status="ON", brightness=n_brightness - ) - - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_color_temp(self, device: Device, color_temp: int): - """Set light to turn on. - - Args: - device (dict): Device to set color temp for. - color_temp (int): Color temp value. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_color_temp - Setting colour temperature to %s for %s.", - color_temp, - device.ha_name, - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - - if data["type"] == "tuneablelight": - resp = await self.session.api.set_state( - data["type"], - device.hive_id, - colourTemperature=color_temp, - ) - else: - resp = await self.session.api.set_state( - data["type"], - device.hive_id, - colourMode="WHITE", - colourTemperature=color_temp, - ) - - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_color(self, device: Device, new_color: list): - """Set light to turn on. - - Args: - device (dict): Device to set color for. - new_color (list): HSV value to set the light to. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug( - "set_color - Setting colour to %s for %s.", new_color, device.ha_name - ) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - - resp = await self.session.api.set_state( - data["type"], - device.hive_id, - colourMode="COLOUR", - hue=str(new_color[0]), - saturation=str(new_color[1]), - value=str(new_color[2]), - ) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - -class Light(HiveLight): - """Home Assistant Light Code. - - Args: - HiveLight (object): HiveLight Code. - """ - - def __init__(self, session: Any = None): - """Initialise light. - - Args: - session (object, optional): Used to interact with the hive account. Defaults to None. - """ - self.session = session - - async def get_light(self, device: Device): - """Get light data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "get_light - Returning cached state for light %s (slow/busy poll).", - device.ha_name, - ) - return cached - online = await self.session.attr.online_offline(device.device_id) - if not isinstance(device.device_data, dict): - device.device_data = {} - device.device_data["online"] = online - - if device.device_data["online"]: - self.session.helper.device_recovered(device.device_id) - _LOGGER.debug("get_light - Updating light data for %s.", device.ha_name) - data = self.session.data.devices[device.device_id] - device.status = { - "state": await self.get_state(device), - "brightness": await self.get_brightness(device), - } - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - device.attributes = await self.session.attr.state_attributes( - device.device_id, device.hive_type - ) - - if device.hive_type in ("tuneablelight", "colourtuneablelight"): - device.status["color_temp"] = await self.get_color_temp(device) - if device.hive_type == "colourtuneablelight": - mode = await self.get_color_mode(device) - device.status["mode"] = mode - if mode == "COLOUR": - device.status["hs_color"] = await self.get_color(device) - - _LOGGER.debug( - "get_light - Light device data for %s: %s", - device.ha_name, - device.status, - ) - - return self.session.set_cached_device(device) - await self.session.helper.error_check( - device.device_id, "ERROR", device.device_data["online"] - ) - device.status = device.status or {"state": None} - return device - - async def turn_on( - self, - device: Device, - brightness: int | None, - color_temp: int | None, - color: list | None, - ): - """Set light to turn on. - - Args: - device (dict): Device to turn on - brightness (int): Brightness value to set the light to. - color_temp (int): Color Temp value to set the light to. - color (list): colour values to set the light to. - - Returns: - boolean: True/False if successful. - """ - if brightness is not None: - return await self.set_brightness(device, brightness) - if color_temp is not None: - return await self.set_color_temp(device, color_temp) - if color is not None: - return await self.set_color(device, color) - - return await self.set_status_on(device) - - async def turn_off(self, device: Device): - """Set light to turn off. - - Args: - device (dict): Device to be turned off. - - Returns: - boolean: True/False if successful. - """ - return await self.set_status_off(device) - - async def turnOn( - self, device: Device, brightness: int, color_temp: int, color: list - ): # pylint: disable=invalid-name - """Backwards-compatible alias for turn_on.""" - return await self.turn_on(device, brightness, color_temp, color) - - async def turnOff(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for turn_off.""" - return await self.turn_off(device) - - async def getLight(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_light.""" - return await self.get_light(device) +__all__ = ["HiveLight", "Light"] diff --git a/src/plug.py b/src/plug.py index 7453713..11bdf66 100644 --- a/src/plug.py +++ b/src/plug.py @@ -1,232 +1,15 @@ -"""Hive Switch Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.plug instead.""" -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVETOHA, HTTP_OK -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.plug is deprecated; import from apyhiveapi.devices.plug", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.plug import HiveSmartPlug, Switch - -class HiveSmartPlug: - """Plug Device. - - Returns: - object: Returns Plug object - """ - - session: Any - plug_type = "Switch" - - async def get_state(self, device: Device): - """Get smart plug state. - - Args: - device (dict): Device to get the plug state for. - - Returns: - boolean: Returns True or False based on if the plug is on - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["state"]["status"] - state = HIVETOHA["Switch"].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return state - - async def get_power_usage(self, device: Device): - """Get smart plug current power usage. - - Args: - device (dict): [description] - - Returns: - [type]: [description] - """ - state = None - - try: - data = self.session.data.products[device.hive_id] - state = data["props"]["powerConsumption"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def set_status_on(self, device: Device): - """Set smart plug to turn on. - - Args: - device (dict): Device to switch on. - - Returns: - boolean: True/False if successful - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug("set_status_on - Turning plug ON for %s.", device.ha_name) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], data["id"], status="ON" - ) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - async def set_status_off(self, device: Device): - """Set smart plug to turn off. - - Args: - device (dict): Device to switch off. - - Returns: - boolean: True/False if successful - """ - final = False - - if ( - device.hive_id in self.session.data.products - and device.device_data["online"] - ): - _LOGGER.debug("set_status_off - Turning plug OFF for %s.", device.ha_name) - await self.session.hive_refresh_tokens() - data = self.session.data.products[device.hive_id] - resp = await self.session.api.set_state( - data["type"], data["id"], status="OFF" - ) - if resp["original"] == HTTP_OK: - final = True - await self.session.get_devices(device.hive_id) - - return final - - -class Switch(HiveSmartPlug): - """Home Assistant switch class. - - Args: - SmartPlug (Class): Initialises the Smartplug Class. - """ - - def __init__(self, session: Any): - """Initialise switch. - - Args: - session (object): This is the session object to interact with the current session. - """ - self.session = session - - async def get_switch(self, device: Device): - """Home assistant wrapper to get switch device. - - Args: - device (dict): Device to be update. - - Returns: - dict: Return device after update is complete. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "get_switch - Returning cached state for switch %s (slow/busy poll).", - device.ha_name, - ) - return cached - online = await self.session.attr.online_offline(device.device_id) - if not isinstance(device.device_data, dict): - device.device_data = {} - device.device_data["online"] = online - - if device.device_data["online"]: - self.session.helper.device_recovered(device.device_id) - _LOGGER.debug("get_switch - Updating switch data for %s.", device.ha_name) - data = self.session.data.devices[device.device_id] - device.status = {"state": await self.get_switch_state(device)} - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - device.attributes = {} - - if device.hive_type == "activeplug": - device.status["power_usage"] = await self.get_power_usage(device) - device.attributes = await self.session.attr.state_attributes( - device.device_id, device.hive_type - ) - - _LOGGER.debug( - "get_switch - Switch device data for %s: %s", - device.ha_name, - device.status, - ) - - return self.session.set_cached_device(device) - await self.session.helper.error_check( - device.device_id, "ERROR", device.device_data["online"] - ) - device.status = device.status or {"state": None} - return device - - async def get_switch_state(self, device: Device): - """Home Assistant wrapper to get updated switch state. - - Args: - device (dict): Device to get state for - - Returns: - boolean: Return True or False for the state. - """ - if device.hive_type == "Heating_Heat_On_Demand": - return await self.session.heating.get_heat_on_demand(device) - return await self.get_state(device) - - async def turn_on(self, device: Device): - """Home Assisatnt wrapper for turning switch on. - - Args: - device (dict): Device to switch on. - - Returns: - function: Calls relevant function. - """ - if device.hive_type == "Heating_Heat_On_Demand": - return await self.session.heating.set_heat_on_demand(device, "ENABLED") - return await self.set_status_on(device) - - async def turn_off(self, device: Device): - """Home Assisatnt wrapper for turning switch off. - - Args: - device (dict): Device to switch off. - - Returns: - function: Calls relevant function. - """ - if device.hive_type == "Heating_Heat_On_Demand": - return await self.session.heating.set_heat_on_demand(device, "DISABLED") - return await self.set_status_off(device) - - async def turnOn(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for turn_on.""" - return await self.turn_on(device) - - async def turnOff(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for turn_off.""" - return await self.turn_off(device) - - async def getSwitch(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_switch.""" - return await self.get_switch(device) +__all__ = ["HiveSmartPlug", "Switch"] diff --git a/src/sensor.py b/src/sensor.py index 1f069fd..9555a71 100644 --- a/src/sensor.py +++ b/src/sensor.py @@ -1,159 +1,15 @@ -"""Hive Sensor Module.""" +"""Backwards-compatible shim — use apyhiveapi.devices.sensor instead.""" -import logging -from typing import Any +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings -from .helper.const import HIVE_TYPES, HIVETOHA, sensor_commands -from .helper.hivedataclasses import Device +warnings.warn( + "apyhiveapi.sensor is deprecated; import from apyhiveapi.devices.sensor", + DeprecationWarning, + stacklevel=2, +) -_LOGGER = logging.getLogger(__name__) +from .devices.sensor import HiveSensor, Sensor - -class HiveSensor: - """Hive Sensor Code.""" - - session: Any - sensor_type = "Sensor" - - async def get_state(self, device: Device): - """Get sensor state. - - Args: - device (dict): Device to get state off. - - Returns: - str: State of device. - """ - state = None - final = None - - try: - data = self.session.data.products[device.hive_id] - if data["type"] == "contactsensor": - state = data["props"]["status"] - final = HIVETOHA[self.sensor_type].get(state, state) - elif data["type"] == "motionsensor": - final = data["props"]["motion"]["status"] - except KeyError as e: - _LOGGER.error(e) - - return final - - async def online(self, device: Device): - """Get the online status of the Hive hub. - - Args: - device (dict): Device to get the state of. - - Returns: - boolean: True/False if the device is online. - """ - state = None - final = None - - try: - data = self.session.data.devices[device.device_id] - state = data["props"]["online"] - final = HIVETOHA[self.sensor_type].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - -class Sensor(HiveSensor): - """Home Assisatnt sensor code. - - Args: - HiveSensor (object): Hive sensor code. - """ - - def __init__(self, session: Any = None): - """Initialise sensor. - - Args: - session (object, optional): session to interact with Hive account. Defaults to None. - """ - self.session = session - - async def get_sensor(self, device: Device): - """Gets updated sensor data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.should_use_cached_data(): - cached = self.session.get_cached_device(device) - if cached is not None: - _LOGGER.debug( - "Returning cached state for sensor %s (slow/busy poll).", - device.ha_name, - ) - return cached - online = await self.session.attr.online_offline(device.device_id) - if not isinstance(device.device_data, dict): - device.device_data = {} - device.device_data["online"] = online - data = {} - - if device.device_data["online"] or device.hive_type in ( - "Availability", - "Connectivity", - ): - if device.hive_type not in ("Availability", "Connectivity"): - self.session.helper.device_recovered(device.device_id) - - _LOGGER.debug( - "get_sensor - Updating sensor data for %s (%s).", - device.ha_name, - device.hive_type, - ) - - if device.device_id in self.session.data.devices: - data = self.session.data.devices.get(device.device_id, {}) - elif device.hive_id in self.session.data.products: - data = self.session.data.products.get(device.hive_id, {}) - - if ( - device.hive_type in sensor_commands - or getattr(device, "custom", None) in sensor_commands - ): - code = sensor_commands.get( - device.hive_type, - sensor_commands.get(getattr(device, "custom", "")), - ) - device.status = {"state": await code(self, device)} # type: ignore[misc] - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - elif device.hive_type in HIVE_TYPES["Sensor"]: - data = self.session.data.devices.get(device.hive_id, {}) - device.status = {"state": await self.get_state(device)} - props = data.get("props") or {} - props["online"] = online - device.device_data = props - device.parent_device = data.get("parent", None) - device.attributes = await self.session.attr.state_attributes( - device.device_id, device.hive_type - ) - - _LOGGER.debug( - "get_sensor - Sensor device data for %s: %s", - device.ha_name, - device.status, - ) - - return self.session.set_cached_device(device) - await self.session.helper.error_check( - device.device_id, "ERROR", device.device_data["online"] - ) - device.status = device.status or {"state": None} - return device - - async def getSensor(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for get_sensor.""" - return await self.get_sensor(device) +__all__ = ["HiveSensor", "Sensor"] diff --git a/src/session.py b/src/session.py deleted file mode 100644 index 1b13ca5..0000000 --- a/src/session.py +++ /dev/null @@ -1,969 +0,0 @@ -"""Hive Session Module.""" - -from __future__ import annotations - -import asyncio -import json -import logging -import time -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any - -from aiohttp import ClientSession -from aiohttp.web import HTTPException -from apyhiveapi import API, Auth # type: ignore[import-not-found] - -from .device_attributes import HiveAttributes -from .helper.const import DEVICES, HIVE_TYPES, PRODUCTS -from .helper.hive_exceptions import ( - HiveApiError, - HiveAuthError, - HiveFailedToRefreshTokens, - HiveInvalid2FACode, - HiveInvalidDeviceAuthentication, - HiveInvalidPassword, - HiveInvalidUsername, - HiveReauthRequired, - HiveRefreshTokenExpired, - HiveUnknownConfiguration, -) -from .helper.hive_helper import HiveHelper -from .helper.hivedataclasses import Device, SessionConfig, SessionTokens -from .helper.map import Map - -_DATA_DIR = Path(__file__).parent / "data" - -_LOGGER = logging.getLogger(__name__) - - -class HiveSession: - """Hive Session Code. - - Raises: - HiveUnknownConfiguration: Unknown configuration. - HTTPException: HTTP error has occurred. - HiveApiError: Hive has retuend an error code. - HiveReauthRequired: Tokens have expired and reauthentiction is required. - - Returns: - object: Session object. - """ - - session_type = "Session" - - def __init__( - self, - username: str | None = None, - password: str | None = None, - websession: ClientSession | None = None, - ) -> None: - """Initialise the base variable values. - - Args: - username (str, optional): Hive username. Defaults to None. - password (str, optional): Hive Password. Defaults to None. - websession (object, optional): Websession for api calls. Defaults to None. - """ - self.auth = Auth( - username=username, - password=password, - ) - self.api = API(hive_session=self, websession=websession) - self.helper = HiveHelper(self) - self.attr = HiveAttributes(self) - self.update_lock = asyncio.Lock() - self._refresh_lock = asyncio.Lock() - self.tokens = SessionTokens() - self.config = SessionConfig(username=username) - self.data: Any = Map( - { - "products": {}, - "devices": {}, - "actions": {}, - "user": {}, - "minMax": {}, - } - ) - self.entity_cache: dict[str, Device] = {} - self.device_list: dict[str, list[Device]] = {} - self.hub_id = None - self._last_poll_slow = False - self._slow_poll_threshold = 3 - self._refresh_threshold = 0.90 - self._update_task: asyncio.Task | None = None - - async def close(self) -> None: - """Close the underlying aiohttp ClientSession.""" - if not self.api.websession.closed: - await self.api.websession.close() - - async def __aenter__(self): - return self - - async def __aexit__(self, *_) -> None: - await self.close() - - @staticmethod - def _entity_cache_key(device) -> str: - """Build a stable cache key for an entity instance.""" - return "|".join( - [ - str(getattr(device, "ha_type", "")), - str(getattr(device, "hive_id", "")), - str(getattr(device, "hive_type", "")), - ] - ) - - def get_cached_device(self, device): - """Get cached state for a specific entity.""" - cache_key = self._entity_cache_key(device) - return self.entity_cache.get(cache_key) - - def set_cached_device(self, device): - """Store device state in cache and return it.""" - self.entity_cache[self._entity_cache_key(device)] = device - return device - - def should_use_cached_data(self): - """Determine whether callers should use cached entity state. - - Returns: - bool: True when the last poll was slow or another task is currently polling. - """ - if self._last_poll_slow: - return True - if self.update_lock.locked(): - current_task = asyncio.current_task() - return self._update_task is None or current_task is not self._update_task - return False - - async def _poll_devices(self) -> bool: - """Fetch latest device state from the Hive API.""" - return await self.get_devices("No_ID") - - async def _retry_with_backoff( - self, - coro_factory, - *, - delays: tuple = (0, 5, 10), - reraise_as=None, - pass_through: tuple = (), - ): - """Retry an async operation with sequential delays. - - Args: - coro_factory: Zero-argument callable returning a coroutine to attempt. - delays: Seconds to wait before each attempt; the first (0) is immediate. - reraise_as: Exception *type* to raise once all attempts are exhausted. - Defaults to the type of the last caught exception. - pass_through: Exception types that bypass retrying and propagate - immediately to the caller. - - Returns: - The result of the first successful ``coro_factory()`` call. - - Raises: - reraise_as (or type of last error): When all retry attempts fail. - """ - last_err = None - for delay in delays: - if delay: - await asyncio.sleep(delay) - try: - return await coro_factory() - except pass_through: - raise - except Exception as err: # pylint: disable=broad-except - last_err = err - exc_type = reraise_as or ( - type(last_err) if last_err is not None else RuntimeError - ) - raise exc_type() from last_err # pylint: disable=broad-exception-raised - - def open_file(self, file: str) -> dict: - """Open a JSON fixture file from the package data directory. - - Args: - file (str): Filename relative to the ``data/`` directory (e.g. ``"data.json"``). - - Returns: - dict: Parsed JSON content of the file. - """ - return json.loads((_DATA_DIR / file).read_text(encoding="utf-8")) - - def add_list(self, entity_type: str, data: dict, **kwargs) -> Device | None: - """Add entity to the device list. - - Args: - entity_type (str): HA entity type (e.g. "climate", "sensor"). - data (dict): Raw product or device data from the Hive API. - - Returns: - Device: Created device entity, or None on error. - """ - try: - hive_type = kwargs.get("hive_type", data.get("type", "")) - if hive_type == "action": - device_name = kwargs.get("ha_name", data.get("name", "Action")) - device_obj = Device( - hive_id=data.get("id", ""), - hive_name=device_name, - hive_type="action", - ha_type=entity_type, - device_id=data.get("id", ""), - device_name=device_name, - device_data={}, - parent_device=self.hub_id, - ha_name=device_name, - ) - else: - device_data = self.helper.get_device_data(data) - device_name = ( - device_data["state"]["name"] - if device_data["state"]["name"] != "Receiver" - else "Heating" - ) - - ha_name = kwargs.get("ha_name", "") - if ha_name.startswith(" "): - ha_name = device_name + ha_name - elif not ha_name: - ha_name = device_name - - device_obj = Device( - hive_id=data.get("id", ""), - hive_name=device_name, - hive_type=hive_type, - ha_type=entity_type, - device_id=device_data["id"], - device_name=device_name, - device_data=device_data.get("props", data.get("props", {})), - parent_device=self.hub_id, - is_group=data.get("isGroup", False), - ha_name=ha_name, - category=kwargs.get("category"), - temperature_unit=kwargs.get("temperature_unit"), - ) - - if data.get("type", "") == "hub": - self.device_list["parent"].append(device_obj) - - self.device_list[entity_type].append(device_obj) - return device_obj - except KeyError as error: - _LOGGER.error(error) - return None - - def _configure_file_mode(self, username: str | None = None) -> None: - """Set file mode when the magic testing username is detected. - - Args: - username: If ``"use@file.com"``, switches the session to file-based mode. - """ - if username == "use@file.com": - self.config.file = True - - async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): - """Update session tokens. - - Args: - tokens (dict): Tokens from API response. - refresh_interval (Boolean): Should the refresh internval be updated - - Returns: - dict: Parsed dictionary of tokens - """ - data: dict = {} - _LOGGER.debug( - "update_tokens - Input tokens: %s", self.helper.sanitize_payload(tokens) - ) - if "AuthenticationResult" in tokens: - data = tokens.get("AuthenticationResult") or {} - self.tokens.token_data.update({"token": data["IdToken"]}) - if "RefreshToken" in data: - self.tokens.token_data.update({"refreshToken": data["RefreshToken"]}) - self.tokens.token_data.update({"accessToken": data["AccessToken"]}) - if update_expiry_time: - self.tokens.token_created = datetime.now() - elif "token" in tokens: - data = tokens - self.tokens.token_data.update({"token": data["token"]}) - self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) - self.tokens.token_data.update({"accessToken": data["accessToken"]}) - - if "ExpiresIn" in data: - self.tokens.token_expiry = timedelta(seconds=data["ExpiresIn"]) - - _LOGGER.debug( - "update_tokens — Final session tokens: IdToken: len=%d tail=…%s | " - "AccessToken: len=%d tail=…%s | " - "RefreshToken: %s | " - "ExpiresIn: %s | token_created: %s | token_expiry: %s", - len(self.tokens.token_data.get("token", "")), - self.tokens.token_data.get("token", "")[-4:], - len(self.tokens.token_data.get("accessToken", "")), - self.tokens.token_data.get("accessToken", "")[-4:], - ( - f"present (len={len(self.tokens.token_data.get('refreshToken', ''))}" - f" tail=…{self.tokens.token_data.get('refreshToken', '')[-4:]})" - if self.tokens.token_data.get("refreshToken") - else "not present" - ), - data.get("ExpiresIn", "N/A"), - self.tokens.token_created, - self.tokens.token_expiry, - ) - - return self.tokens - - async def login(self): - """Login to hive account with business logic routing. - - Business Rules: - 1) Login successfully - tokens returned, no device login or SMS2FA needed - 2) Check for device login or SMS challenges - 3) Direct flow to one of the two - 4) If device login, process ends but check if device is registered - 5) If SMS, follow on with device registration - - Raises: - HiveUnknownConfiguration: Login information is unknown. - - Returns: - dict: result of the authentication request. - """ - result = None - if not self.auth: - raise HiveUnknownConfiguration - - _LOGGER.debug("login - Attempting login to Hive account.") - try: - result = await self.auth.login() - except HiveInvalidUsername: - _LOGGER.error("Login failed: invalid username.") - raise - except HiveInvalidPassword: - _LOGGER.error("Login failed: invalid password.") - raise - except HiveApiError: - _LOGGER.error("Login failed: API error or no internet connection.") - raise - - # Rule 1: Login successful - tokens returned, no challenges needed - if result and "AuthenticationResult" in result: - auth_keys = list(result["AuthenticationResult"].keys()) - _LOGGER.debug( - "login - Login successful — AuthenticationResult keys: %s", auth_keys - ) - await self.update_tokens(result) - return result - - # Rule 2 & 3: Check for device login or SMS challenges and route - challenge_name = result.get("ChallengeName") - _LOGGER.debug("login - Challenge detected: %s", challenge_name) - - if challenge_name == self.auth.DEVICE_VERIFIER_CHALLENGE: - # Rule 4: Device login flow - check if device is registered - _LOGGER.debug("login - Routing to device login flow") - return await self._handle_device_login_challenge(result) - if challenge_name == self.auth.SMS_MFA_CHALLENGE: - # Rule 5: SMS flow - will need device registration after 2FA - _LOGGER.debug("login - Routing to SMS 2FA flow (requires user input)") - return result - _LOGGER.error("login - Unsupported challenge: %s", challenge_name) - raise HiveUnknownConfiguration - - async def _handle_device_login_challenge(self, _login_result): - """Handle device login challenge. - - Args: - login_result (dict): Result from initial login with DEVICE_SRP_AUTH challenge. - - Returns: - dict: Authentication result with tokens. - - Raises: - HiveReauthRequired: If device login encounters SMS_MFA (device not remembered). - HiveInvalidDeviceAuthentication: If device is not registered. - """ - _LOGGER.debug("_handle_device_login_challenge - Processing device login") - - # Check if device is registered before attempting device login - is_registered = await self.auth.is_device_registered() - if not is_registered: - _LOGGER.warning( - "_handle_device_login_challenge - Device not registered, " - "cannot complete device login. User must complete SMS 2FA." - ) - raise HiveInvalidDeviceAuthentication - - # Device is registered, proceed with device login - _LOGGER.debug( - "_handle_device_login_challenge - Device is registered, proceeding" - ) - result = await self.auth.device_login() - - # Check if device login returned SMS_MFA challenge (device not remembered by Cognito) - if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: - _LOGGER.error( - "_handle_device_login_challenge - Device login failed: SMS MFA challenge detected. " - "Device is not remembered by Cognito. User must reauthenticate." - ) - raise HiveReauthRequired - - if result and "AuthenticationResult" in result: - auth_keys = list(result["AuthenticationResult"].keys()) - _LOGGER.debug( - "_handle_device_login_challenge - Device login successful" - " — AuthenticationResult keys: %s", - auth_keys, - ) - await self.update_tokens(result) - - return result - - async def sms2fa(self, code, session): - """Login to hive account with 2 factor authentication. - - After successful SMS 2FA, checks if device needs registration and - handles it automatically (Rule 5). - - Raises: - HiveUnknownConfiguration: Login information is unknown. - - Returns: - dict: result of the authentication request with device data if registered. - """ - result = None - if not self.auth: - _LOGGER.error("2FA failed: authentication not initialised.") - raise HiveUnknownConfiguration - - _LOGGER.debug("sms_2fa - Submitting 2FA code.") - try: - result = await self.auth.sms_2fa(code, session) - except HiveInvalid2FACode: - _LOGGER.error("2FA failed: invalid code entered.") - raise - except HiveApiError: - _LOGGER.error("2FA failed: API error or no internet connection.") - raise - - if result and "AuthenticationResult" in result: - auth_keys = list(result["AuthenticationResult"].keys()) - _LOGGER.debug( - "sms_2fa - 2FA login successful — AuthenticationResult keys: %s", - auth_keys, - ) - await self.update_tokens(result) - - return result - - async def _retry_login(self): - """Attempt login with retries and backoff. - - This is called when token refresh fails. It attempts to login again, - which may succeed via device login or may require user interaction (SMS 2FA). - - Raises: - HiveReauthRequired: User interaction required (SMS 2FA challenge), - credentials invalid, or all retries exhausted. - HiveApiError: API error or no internet connection. - """ - - async def _attempt(): - result = await self.login() - if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: - _LOGGER.error( - "_retry_login - Login requires SMS 2FA. User must reauthenticate." - ) - raise HiveReauthRequired - return result - - try: - await self._retry_with_backoff( - _attempt, - reraise_as=HiveReauthRequired, - pass_through=( - HiveReauthRequired, - HiveInvalidUsername, - HiveInvalidPassword, - ), - ) - except (HiveInvalidUsername, HiveInvalidPassword) as exc: - _LOGGER.error( - "_retry_login - Login failed with invalid credentials," - " reauthentication required." - ) - raise HiveReauthRequired from exc - - await self.hive_refresh_tokens(force_refresh=True) - - async def hive_refresh_tokens(self, force_refresh: bool = False): - """Refresh Hive tokens. - - Args: - force_refresh (bool): Whether to force a token refresh regardless of expiry. - - Returns: - boolean: True/False if update was successful - """ - result = None - - if not self.config.file: - expiry_time = self.tokens.token_created + ( - self.tokens.token_expiry * self._refresh_threshold - ) - # Refresh at 90% of token lifetime to prevent expiration during API calls - _LOGGER.debug( - "hive_refresh_tokens - Session token expiry time ( Current: %s | Expiry: %s)", - datetime.now(), - expiry_time, - ) - if datetime.now() >= expiry_time or force_refresh: - async with self._refresh_lock: - # Re-check after acquiring lock — another caller may have already refreshed - expiry_time = self.tokens.token_created + ( - self.tokens.token_expiry * self._refresh_threshold - ) - if datetime.now() < expiry_time and not force_refresh: - return result - actual_expiry = self.tokens.token_created + self.tokens.token_expiry - _LOGGER.debug( - "hive_refresh_tokens - Session Token created: %s | Actual expiry: %s | " - "Early refresh (×%s): %s | Now: %s | Force refresh: %s", - self.tokens.token_created, - actual_expiry, - self._refresh_threshold, - expiry_time, - datetime.now(), - force_refresh, - ) - try: - result = await self.auth.refresh_token( - self.tokens.token_data["refreshToken"] - ) - - if result and "AuthenticationResult" in result: - auth_keys = list(result["AuthenticationResult"].keys()) - _LOGGER.debug( - "hive_refresh_tokens - Token refresh" - " — AuthenticationResult keys: %s", - auth_keys, - ) - await self.update_tokens(result) - new_expiry = ( - self.tokens.token_created + self.tokens.token_expiry - ) - _LOGGER.debug( - "hive_refresh_tokens - Session Token refresh" - " successful. New expiry: %s", - new_expiry, - ) - except (HiveRefreshTokenExpired, HiveFailedToRefreshTokens) as exc: - _LOGGER.warning( - "Session Token refresh failed (%s), falling back to login.", - type(exc).__name__, - ) - if not force_refresh: - await self._retry_login() - else: - _LOGGER.error( - "Token refresh failed during retry attempt, giving up." - ) - raise HiveReauthRequired from exc - except HiveApiError: - _LOGGER.error("API error during token refresh.") - raise - - return result - - async def update_data(self, _device: Device): - """Get latest data for Hive nodes - rate limiting. - - Args: - device (dict): Device requesting the update. - - Returns: - boolean: True/False if update was successful - """ - updated = False - ep = self.config.last_update + self.config.scan_interval - if datetime.now() >= ep: - current_task = asyncio.current_task() - if self.update_lock.locked() and ( - self._update_task is None or current_task is not self._update_task - ): - _LOGGER.debug("update_data - Update poll already in progress") - return updated - async with self.update_lock: - # Re-check after acquiring lock — another caller may have already updated - ep = self.config.last_update + self.config.scan_interval - if datetime.now() < ep: - return updated - self._update_task = current_task - try: - _LOGGER.debug("Polling Hive API for device updates.") - updated = await self._poll_devices() - if updated: - _LOGGER.debug( - "update_data - Device update completed successfully." - ) - else: - _LOGGER.debug( - "update_data - Device update failed, will retry after scan interval." - ) - finally: - if self._update_task is current_task: - self._update_task = None - - return updated - - async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too-many-statements # noqa: PLR0912, PLR0915 - """Get latest data for Hive nodes. - - Args: - n_id (str): ID of the device requesting data. - - Raises: - HTTPException: HTTP error has occurred updating the devices. - HiveApiError: An API error code has been returned. - - Returns: - boolean: True/False if update was successful. - """ - get_nodes_successful = False - api_resp_d = None - - try: - if self.config.file: - _LOGGER.debug("get_devices - Loading device data from file.") - api_resp_d = self.open_file("data.json") - elif self.tokens is not None: - _LOGGER.debug( - "get_devices - Refreshing tokens before fetching devices." - ) - await self.hive_refresh_tokens() - _LOGGER.debug("get_devices - Fetching all devices from Hive API.") - api_call_start = time.monotonic() - try: - api_resp_d = await self.api.get_all() - except HiveAuthError: - _LOGGER.warning( - "Auth error (401/403) after token refresh, " - "falling back to full device re-login." - ) - await self._retry_login() - api_resp_d = await self._retry_with_backoff( - self.api.get_all, - reraise_as=HiveReauthRequired, - ) - api_call_duration = time.monotonic() - api_call_start - if api_call_duration > self._slow_poll_threshold: - _LOGGER.debug( - "get_devices - Hive API response took %.1fs — marking poll as slow.", - api_call_duration, - ) - self._last_poll_slow = True - else: - self._last_poll_slow = False - if not str(api_resp_d["original"]).startswith("2"): - raise HTTPException - if api_resp_d["parsed"] is None: - raise HiveApiError - - api_resp_p = api_resp_d["parsed"] - tmp_products = {} - tmp_devices = {} - tmp_actions = {} - - for hive_type_key in api_resp_p: - if hive_type_key == "user": - self.data.user = api_resp_p[hive_type_key] - self.config.user_id = api_resp_p[hive_type_key]["id"] - if hive_type_key == "products": - for a_product in api_resp_p[hive_type_key]: - tmp_products.update({a_product["id"]: a_product}) - if hive_type_key == "devices": - for a_device in api_resp_p[hive_type_key]: - tmp_devices.update({a_device["id"]: a_device}) - if hive_type_key == "actions": - for a_action in api_resp_p[hive_type_key]: - tmp_actions.update({a_action["id"]: a_action}) - if hive_type_key == "homes": - self.config.home_id = api_resp_p[hive_type_key]["homes"][0]["id"] - - _LOGGER.debug( - "get_devices - API returned %d products, %d devices, %d actions.", - len(tmp_products), - len(tmp_devices), - len(tmp_actions), - ) - if tmp_products: - self.data.products = tmp_products - if tmp_devices: - self.data.devices = tmp_devices - self.data.actions = tmp_actions - self.config.last_update = datetime.now() - get_nodes_successful = True - except HiveReauthRequired: - _LOGGER.error("Reauthentication required, propagating to caller.") - self.config.last_update = datetime.now() - raise - except asyncio.TimeoutError: - _LOGGER.warning("Hive API request timed out — keeping cached device data.") - self._last_poll_slow = True - self.config.last_update = ( - datetime.now() - self.config.scan_interval + timedelta(seconds=30) - ) - get_nodes_successful = False - except ( - OSError, - RuntimeError, - HiveApiError, - ConnectionError, - HTTPException, - ) as err: - _LOGGER.error("Failed to fetch devices: %s", err) - self.config.last_update = ( - datetime.now() - self.config.scan_interval + timedelta(seconds=30) - ) - get_nodes_successful = False - - return get_nodes_successful - - async def start_session(self, config: dict | None = None): - """Setup the Hive platform. - - Args: - config (dict, optional): Configuration for Home Assistant to use. Defaults to {}. - - Raises: - HiveUnknownConfiguration: Unknown configuration identified. - HiveReauthRequired: Tokens have expired and reauthentication is required. - - Returns: - list: List of devices - """ - if config is None: - config = {} - _LOGGER.debug("start_session - Starting Hive session.") - _LOGGER.debug( - "start_session - Config: %s", self.helper.sanitize_payload(config) - ) - self._configure_file_mode(config.get("username", self.config.username)) - - if config != {}: - if "tokens" in config and not self.config.file: - _LOGGER.debug("start_session - Updating tokens from config") - await self.update_tokens(config["tokens"], False) - - if "username" in config and not self.config.file: - self.auth.username = config["username"] - - if "password" in config and not self.config.file: - self.auth.password = config["password"] - - if "device_data" in config and not self.config.file: - self.auth.device_group_key = config["device_data"][0] - self.auth.device_key = config["device_data"][1] - self.auth.device_password = config["device_data"][2] - - if not self.config.file and "tokens" not in config: - raise HiveUnknownConfiguration - - await self.get_devices("No_ID") - - if not self.data.devices or not self.data.products: - _LOGGER.error( - "No devices or products returned from Hive API, reauthentication required." - ) - raise HiveReauthRequired - - return await self.create_devices() - - async def create_devices( # noqa: PLR0912, PLR0915 - self, - ): # pylint: disable=too-many-locals,too-many-statements - """Create list of devices. - - Returns: - list: List of devices - """ - _LOGGER.info("create_devices - Starting device discovery process") - - self.device_list["parent"] = [] - self.device_list["binary_sensor"] = [] - self.device_list["climate"] = [] - self.device_list["light"] = [] - self.device_list["sensor"] = [] - self.device_list["switch"] = [] - self.device_list["water_heater"] = [] - - hive_type = HIVE_TYPES["Thermo"] + HIVE_TYPES["Sensor"] - - # Find hub device first - for a_device in self.data["devices"]: - if self.data["devices"][a_device]["type"] == "hub": - self.hub_id = a_device - hub_name = ( - self.data["devices"][a_device] - .get("state", {}) - .get("name", a_device) - ) - _LOGGER.debug( - "create_devices - Found hub device: %s (ID: %s)", hub_name, a_device - ) - break - else: - _LOGGER.warning("create_devices - No hub device found in device list") - - # Process devices - device_count = 0 - for a_device in self.data["devices"]: - d = self.data.devices[a_device] - device_name = d.get("state", {}).get("name", a_device) - device_type = d.get("type", "Unknown") - _LOGGER.debug( - "create_devices - Processing device: %s (%s - %s)", - device_name, - a_device, - device_type, - ) - - for entity_config in DEVICES.get(device_type, []): - kwargs = {} - if entity_config.ha_name: - kwargs["ha_name"] = entity_config.ha_name - if entity_config.hive_type: - kwargs["hive_type"] = entity_config.hive_type - if entity_config.category: - kwargs["category"] = entity_config.category - try: - self.add_list(entity_config.entity_type, d, **kwargs) - except (KeyError, TypeError, AttributeError) as e: - _LOGGER.error( - "Failed to create device entity for %s: %s", - device_name, - str(e), - ) - - if device_type in hive_type: - self.config.battery.append(d["id"]) - _LOGGER.debug( - "create_devices - Added device %s to battery monitoring list", - device_name, - ) - - device_count += 1 - - # Process actions - _LOGGER.debug( - "create_devices - Processing %d actions", len(self.data["actions"]) - ) - for action_id in self.data["actions"]: - action = self.data["actions"][action_id] - try: - self.add_list( - "switch", action, ha_name=action["name"], hive_type="action" - ) - except (KeyError, TypeError, AttributeError) as e: - _LOGGER.error( - "Failed to create action entity for %s: %s", - action_id, - str(e), - ) - - # Process products - hive_type = HIVE_TYPES["Heating"] + HIVE_TYPES["Switch"] + HIVE_TYPES["Light"] - product_count = 0 - for a_product, p in self.data.products.items(): - if "error" in p: - _LOGGER.warning( - "Skipping product %s due to error: %s", a_product, p["error"] - ) - continue - - product_name = p.get("state", {}).get("name", a_product) - product_type = p.get("type", "Unknown") - _LOGGER.debug( - "create_devices - Processing product: %s (%s - %s)", - product_name, - a_product, - product_type, - ) - - # Only consider single items or heating groups - if p.get("isGroup", False) and p["type"] not in HIVE_TYPES["Heating"]: - _LOGGER.debug( - "create_devices - Skipping group product currently not supported %s (type: %s)", - product_name, - product_type, - ) - continue - - for entity_config in PRODUCTS.get(product_type, []): - kwargs = {} - if entity_config.ha_name: - kwargs["ha_name"] = entity_config.ha_name - if entity_config.hive_type: - kwargs["hive_type"] = entity_config.hive_type - if entity_config.category: - kwargs["category"] = entity_config.category - if entity_config.entity_type == "climate": - kwargs["temperature_unit"] = self.data["user"].get( - "temperatureUnit" - ) - elif entity_config.temperature_unit is not None: - kwargs["temperature_unit"] = entity_config.temperature_unit - try: - self.add_list(entity_config.entity_type, p, **kwargs) - except (NameError, AttributeError) as e: - _LOGGER.warning( - "create_devices - Device %s cannot be setup - %s", - product_name, - e, - ) - - if product_type in hive_type: - self.config.mode.append(p["id"]) - _LOGGER.debug( - "create_devices - Added product %s to mode list", product_name - ) - - product_count += 1 - - _LOGGER.info( - "Device discovery completed: %d devices, %d products processed. " - "Found: %d parent, %d binary_sensor, %d climate," - " %d light, %d sensor, %d switch, %d water_heater", - device_count, - product_count, - len(self.device_list.get("parent", [])), - len(self.device_list.get("binary_sensor", [])), - len(self.device_list.get("climate", [])), - len(self.device_list.get("light", [])), - len(self.device_list.get("sensor", [])), - len(self.device_list.get("switch", [])), - len(self.device_list.get("water_heater", [])), - ) - - return self.device_list - - @property - def deviceList(self): # pylint: disable=invalid-name - """Backwards-compatible alias for device_list.""" - return self.device_list - - async def startSession(self, config: dict | None = None): # pylint: disable=invalid-name - """Backwards-compatible alias for start_session.""" - return await self.start_session(config) - - async def updateData(self, device: Device): # pylint: disable=invalid-name - """Backwards-compatible alias for update_data.""" - return await self.update_data(device) - - async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name,unused-argument - """Backwards-compatible alias for Home Assistant Scan Interval.""" - return True diff --git a/src/session/__init__.py b/src/session/__init__.py new file mode 100644 index 0000000..a240306 --- /dev/null +++ b/src/session/__init__.py @@ -0,0 +1,86 @@ +"""Hive Session Module.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aiohttp import ClientSession +from apyhiveapi import API, Auth # type: ignore[import-not-found] + +from ..helper.compat_aliases import SessionCompatMixin +from ..helper.device_attributes import HiveAttributes +from ..helper.hive_helper import HiveHelper +from ..helper.hivedataclasses import Device, SessionConfig, SessionTokens +from ..helper.map import Map +from .auth import SessionAuthMixin +from .discovery import DiscoveryMixin +from .polling import PollingMixin + + +class HiveSession(SessionCompatMixin, SessionAuthMixin, PollingMixin, DiscoveryMixin): + """Hive Session Code. + + Raises: + HiveUnknownConfiguration: Unknown configuration. + HTTPException: HTTP error has occurred. + HiveApiError: Hive has returned an error code. + HiveReauthRequired: Tokens have expired and reauthentication is required. + + Returns: + object: Session object. + """ + + session_type = "Session" + + def __init__( + self, + username: str | None = None, + password: str | None = None, + websession: ClientSession | None = None, + ) -> None: + """Initialise the base variable values. + + Args: + username (str, optional): Hive username. Defaults to None. + password (str, optional): Hive Password. Defaults to None. + websession (object, optional): Websession for api calls. Defaults to None. + """ + self.auth = Auth( + username=username, + password=password, + ) + self.api = API(hive_session=self, websession=websession) + self.helper = HiveHelper(self) + self.attr = HiveAttributes(self) + self.update_lock = asyncio.Lock() + self._refresh_lock = asyncio.Lock() + self.tokens = SessionTokens() + self.config = SessionConfig(username=username) + self.data: Any = Map( + { + "products": {}, + "devices": {}, + "actions": {}, + "user": {}, + "minMax": {}, + } + ) + self.entity_cache: dict[str, Device] = {} + self.device_list: dict[str, list[Device]] = {} + self.hub_id = None + self._last_poll_slow = False + self._slow_poll_threshold = 3 + self._refresh_threshold = 0.90 + self._update_task: asyncio.Task | None = None + + async def close(self) -> None: + """Close the underlying aiohttp ClientSession.""" + if not self.api.websession.closed: + await self.api.websession.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, *_) -> None: + await self.close() diff --git a/src/session/auth.py b/src/session/auth.py new file mode 100644 index 0000000..1d63f09 --- /dev/null +++ b/src/session/auth.py @@ -0,0 +1,373 @@ +"""Session authentication lifecycle mixin for HiveSession.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Any + +from ..helper.hive_exceptions import ( + HiveApiError, + HiveFailedToRefreshTokens, + HiveInvalid2FACode, + HiveInvalidPassword, + HiveInvalidUsername, + HiveReauthRequired, + HiveRefreshTokenExpired, + HiveUnknownConfiguration, +) + +_LOGGER = logging.getLogger(__name__) + + +class SessionAuthMixin: + """Session authentication lifecycle methods. + + Expects ``self.auth``, ``self.tokens``, ``self.config``, and + ``self.helper`` to be set up by the owning class's ``__init__``. + """ + + # Attributes provided by HiveSession.__init__ + auth: Any + tokens: Any + config: Any + helper: Any + _refresh_threshold: float + _refresh_lock: asyncio.Lock + + async def _retry_with_backoff( + self, + coro_factory, + *, + delays: tuple = (0, 5, 10), + reraise_as=None, + pass_through: tuple = (), + ): + """Retry an async operation with sequential delays. + + Args: + coro_factory: Zero-argument callable returning a coroutine to attempt. + delays: Seconds to wait before each attempt; the first (0) is immediate. + reraise_as: Exception *type* to raise once all attempts are exhausted. + Defaults to the type of the last caught exception. + pass_through: Exception types that bypass retrying and propagate + immediately to the caller. + + Returns: + The result of the first successful ``coro_factory()`` call. + + Raises: + reraise_as (or type of last error): When all retry attempts fail. + """ + last_err = None + for delay in delays: + if delay: + await asyncio.sleep(delay) + try: + return await coro_factory() + except pass_through: + raise + except Exception as err: # pylint: disable=broad-except + last_err = err + exc_type = reraise_as or ( + type(last_err) if last_err is not None else RuntimeError + ) + raise exc_type() from last_err # pylint: disable=broad-exception-raised + + async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): + """Update session tokens. + + Args: + tokens (dict): Tokens from API response. + update_expiry_time (Boolean): Should the refresh interval be updated + + Returns: + dict: Parsed dictionary of tokens + """ + data: dict = {} + _LOGGER.debug( + "update_tokens - Input tokens: %s", self.helper.sanitize_payload(tokens) + ) + if "AuthenticationResult" in tokens: + data = tokens.get("AuthenticationResult") or {} + self.tokens.token_data.update({"token": data["IdToken"]}) + if "RefreshToken" in data: + self.tokens.token_data.update({"refreshToken": data["RefreshToken"]}) + self.tokens.token_data.update({"accessToken": data["AccessToken"]}) + if update_expiry_time: + self.tokens.token_created = datetime.now() + elif "token" in tokens: + data = tokens + self.tokens.token_data.update({"token": data["token"]}) + self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) + self.tokens.token_data.update({"accessToken": data["accessToken"]}) + + if "ExpiresIn" in data: + self.tokens.token_expiry = timedelta(seconds=data["ExpiresIn"]) + + _LOGGER.debug( + "update_tokens — Final session tokens: IdToken: len=%d tail=…%s | " + "AccessToken: len=%d tail=…%s | " + "RefreshToken: %s | " + "ExpiresIn: %s | token_created: %s | token_expiry: %s", + len(self.tokens.token_data.get("token", "")), + self.tokens.token_data.get("token", "")[-4:], + len(self.tokens.token_data.get("accessToken", "")), + self.tokens.token_data.get("accessToken", "")[-4:], + ( + f"present (len={len(self.tokens.token_data.get('refreshToken', ''))}" + f" tail=…{self.tokens.token_data.get('refreshToken', '')[-4:]})" + if self.tokens.token_data.get("refreshToken") + else "not present" + ), + data.get("ExpiresIn", "N/A"), + self.tokens.token_created, + self.tokens.token_expiry, + ) + + return self.tokens + + async def login(self): + """Login to hive account with business logic routing. + + Business Rules: + 1) Login successfully - tokens returned, no device login or SMS2FA needed + 2) Check for device login or SMS challenges + 3) Direct flow to one of the two + 4) If device login, process ends but check if device is registered + 5) If SMS, follow on with device registration + + Raises: + HiveUnknownConfiguration: Login information is unknown. + + Returns: + dict: result of the authentication request. + """ + result = None + if not self.auth: + raise HiveUnknownConfiguration + + _LOGGER.debug("login - Attempting login to Hive account.") + try: + result = await self.auth.login() + except HiveInvalidUsername: + _LOGGER.error("Login failed: invalid username.") + raise + except HiveInvalidPassword: + _LOGGER.error("Login failed: invalid password.") + raise + except HiveApiError: + _LOGGER.error("Login failed: API error or no internet connection.") + raise + + # Rule 1: Login successful - tokens returned, no challenges needed + if result and "AuthenticationResult" in result: + auth_keys = list(result["AuthenticationResult"].keys()) + _LOGGER.debug( + "login - Login successful — AuthenticationResult keys: %s", auth_keys + ) + await self.update_tokens(result) + return result + + # Rule 2 & 3: Check for device login or SMS challenges and route + challenge_name = result.get("ChallengeName") + _LOGGER.debug("login - Challenge detected: %s", challenge_name) + + if challenge_name == self.auth.DEVICE_VERIFIER_CHALLENGE: + # Rule 4: Device login flow - check if device is registered + _LOGGER.debug("login - Routing to device login flow") + return await self._handle_device_login_challenge(result) + if challenge_name == self.auth.SMS_MFA_CHALLENGE: + # Rule 5: SMS flow - will need device registration after 2FA + _LOGGER.debug("login - Routing to SMS 2FA flow (requires user input)") + return result + _LOGGER.error("login - Unsupported challenge: %s", challenge_name) + raise HiveUnknownConfiguration + + async def _handle_device_login_challenge(self, _login_result): + """Handle device login challenge. + + Args: + _login_result (dict): Result from initial login with DEVICE_SRP_AUTH challenge. + + Returns: + dict: Authentication result with tokens. + + Raises: + HiveReauthRequired: If device login encounters SMS_MFA (device not remembered). + HiveInvalidDeviceAuthentication: If device is not registered. + """ + _LOGGER.debug("_handle_device_login_challenge - Processing device login") + result = await self.auth.device_login() + + if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: + _LOGGER.error( + "_handle_device_login_challenge - Device login failed: SMS MFA challenge detected. " + "Device is not remembered by Cognito. User must reauthenticate." + ) + raise HiveReauthRequired + + if result and "AuthenticationResult" in result: + auth_keys = list(result["AuthenticationResult"].keys()) + _LOGGER.debug( + "_handle_device_login_challenge - Device login successful" + " — AuthenticationResult keys: %s", + auth_keys, + ) + await self.update_tokens(result) + + return result + + async def sms2fa(self, code, session): + """Login to hive account with 2 factor authentication. + + After successful SMS 2FA, checks if device needs registration and + handles it automatically (Rule 5). + + Raises: + HiveUnknownConfiguration: Login information is unknown. + + Returns: + dict: result of the authentication request with device data if registered. + """ + result = None + if not self.auth: + _LOGGER.error("2FA failed: authentication not initialised.") + raise HiveUnknownConfiguration + + _LOGGER.debug("sms_2fa - Submitting 2FA code.") + try: + result = await self.auth.sms_2fa(code, session) + except HiveInvalid2FACode: + _LOGGER.error("2FA failed: invalid code entered.") + raise + except HiveApiError: + _LOGGER.error("2FA failed: API error or no internet connection.") + raise + + if result and "AuthenticationResult" in result: + auth_keys = list(result["AuthenticationResult"].keys()) + _LOGGER.debug( + "sms_2fa - 2FA login successful — AuthenticationResult keys: %s", + auth_keys, + ) + await self.update_tokens(result) + + return result + + async def _retry_login(self): + """Attempt login with retries and backoff. + + This is called when token refresh fails. It attempts to login again, + which may succeed via device login or may require user interaction (SMS 2FA). + + Raises: + HiveReauthRequired: User interaction required (SMS 2FA challenge), + credentials invalid, or all retries exhausted. + HiveApiError: API error or no internet connection. + """ + + async def _attempt(): + result = await self.login() + if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: + _LOGGER.error( + "_retry_login - Login requires SMS 2FA. User must reauthenticate." + ) + raise HiveReauthRequired + return result + + try: + await self._retry_with_backoff( + _attempt, + reraise_as=HiveReauthRequired, + pass_through=( + HiveReauthRequired, + HiveInvalidUsername, + HiveInvalidPassword, + ), + ) + except (HiveInvalidUsername, HiveInvalidPassword) as exc: + _LOGGER.error( + "_retry_login - Login failed with invalid credentials," + " reauthentication required." + ) + raise HiveReauthRequired from exc + + async def hive_refresh_tokens(self, force_refresh: bool = False): + """Refresh Hive tokens. + + Args: + force_refresh (bool): Whether to force a token refresh regardless of expiry. + + Returns: + boolean: True/False if update was successful + """ + result = None + + if not self.config.file: + expiry_time = self.tokens.token_created + ( + self.tokens.token_expiry * self._refresh_threshold + ) + _LOGGER.debug( + "hive_refresh_tokens - Session token expiry time ( Current: %s | Expiry: %s)", + datetime.now(), + expiry_time, + ) + if datetime.now() >= expiry_time or force_refresh: + async with self._refresh_lock: + # Re-check after acquiring lock — another caller may have already refreshed + expiry_time = self.tokens.token_created + ( + self.tokens.token_expiry * self._refresh_threshold + ) + if datetime.now() < expiry_time and not force_refresh: + return result + actual_expiry = self.tokens.token_created + self.tokens.token_expiry + _LOGGER.debug( + "hive_refresh_tokens - Session Token created: %s | Actual expiry: %s | " + "Early refresh (×%s): %s | Now: %s | Force refresh: %s", + self.tokens.token_created, + actual_expiry, + self._refresh_threshold, + expiry_time, + datetime.now(), + force_refresh, + ) + try: + result = await self.auth.refresh_token( + self.tokens.token_data["refreshToken"] + ) + + if result and "AuthenticationResult" in result: + auth_keys = list(result["AuthenticationResult"].keys()) + _LOGGER.debug( + "hive_refresh_tokens - Token refresh" + " — AuthenticationResult keys: %s", + auth_keys, + ) + await self.update_tokens(result) + new_expiry = ( + self.tokens.token_created + self.tokens.token_expiry + ) + _LOGGER.debug( + "hive_refresh_tokens - Session Token refresh" + " successful. New expiry: %s", + new_expiry, + ) + except (HiveRefreshTokenExpired, HiveFailedToRefreshTokens) as exc: + _LOGGER.warning( + "Session Token refresh failed (%s), falling back to login.", + type(exc).__name__, + ) + if not force_refresh: + await self._retry_login() + else: + _LOGGER.error( + "Token refresh failed during retry attempt, giving up." + ) + raise HiveReauthRequired from exc + except HiveApiError: + _LOGGER.error("API error during token refresh.") + raise + + return result diff --git a/src/session/discovery.py b/src/session/discovery.py new file mode 100644 index 0000000..6ff691c --- /dev/null +++ b/src/session/discovery.py @@ -0,0 +1,338 @@ +"""Device discovery mixin for HiveSession.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from ..helper.const import DEVICES, EXPECTED_DEVICE_DATA_LENGTH, HIVE_TYPES, PRODUCTS +from ..helper.hive_exceptions import HiveReauthRequired, HiveUnknownConfiguration +from ..helper.hivedataclasses import Device + +_DATA_DIR = Path(__file__).parent.parent / "data" + +_LOGGER = logging.getLogger(__name__) + + +class DiscoveryMixin: + """Device discovery, session start, and entity-list construction. + + Expects ``self.config``, ``self.data``, ``self.helper``, + ``self.hub_id``, and ``self.device_list`` to be set up by the + owning class's ``__init__``. + """ + + # Attributes provided by HiveSession.__init__ + config: Any + data: Any + auth: Any + helper: Any + hub_id: Any + device_list: dict + + def open_file(self, file: str) -> dict: + """Open a JSON fixture file from the package data directory. + + Args: + file (str): Filename relative to the ``data/`` directory (e.g. ``"data.json"``). + + Returns: + dict: Parsed JSON content of the file. + """ + return json.loads((_DATA_DIR / file).read_text(encoding="utf-8")) + + def _configure_file_mode(self, username: str | None = None) -> None: + """Set file mode when the magic testing username is detected. + + Args: + username: If ``"use@file.com"``, switches the session to file-based mode. + """ + if username == "use@file.com": + self.config.file = True + + def add_list(self, entity_type: str, data: dict, **kwargs) -> Device | None: + """Add entity to the device list. + + Args: + entity_type (str): HA entity type (e.g. "climate", "sensor"). + data (dict): Raw product or device data from the Hive API. + + Returns: + Device: Created device entity, or None on error. + """ + try: + hive_type = kwargs.get("hive_type", data.get("type", "")) + if hive_type == "action": + device_name = kwargs.get("ha_name", data.get("name", "Action")) + device_obj = Device( + hive_id=data.get("id", ""), + hive_name=device_name, + hive_type="action", + ha_type=entity_type, + device_id=data.get("id", ""), + device_name=device_name, + device_data={}, + parent_device=self.hub_id, + ha_name=device_name, + ) + else: + device_data = self.helper.get_device_data(data) + device_name = ( + device_data["state"]["name"] + if device_data["state"]["name"] != "Receiver" + else "Heating" + ) + + ha_name = kwargs.get("ha_name", "") + if ha_name.startswith(" "): + ha_name = device_name + ha_name + elif not ha_name: + ha_name = device_name + + device_obj = Device( + hive_id=data.get("id", ""), + hive_name=device_name, + hive_type=hive_type, + ha_type=entity_type, + device_id=device_data["id"], + device_name=device_name, + device_data=device_data.get("props", data.get("props", {})), + parent_device=self.hub_id, + is_group=data.get("isGroup", False), + ha_name=ha_name, + category=kwargs.get("category"), + temperature_unit=kwargs.get("temperature_unit"), + ) + + if data.get("type", "") == "hub": + self.device_list["parent"].append(device_obj) + + self.device_list[entity_type].append(device_obj) + return device_obj + except KeyError as error: + _LOGGER.error(error) + return None + + async def start_session(self, config: dict | None = None): + """Setup the Hive platform. + + Args: + config (dict, optional): Configuration for Home Assistant to use. Defaults to {}. + + Raises: + HiveUnknownConfiguration: Unknown configuration identified. + HiveReauthRequired: Tokens have expired and reauthentication is required. + + Returns: + list: List of devices + """ + if config is None: + config = {} + _LOGGER.debug("start_session - Starting Hive session.") + _LOGGER.debug( + "start_session - Config: %s", self.helper.sanitize_payload(config) + ) + self._configure_file_mode(config.get("username", self.config.username)) + + if config != {}: + if "tokens" in config and not self.config.file: + _LOGGER.debug("start_session - Updating tokens from config") + await self.update_tokens(config["tokens"], False) # type: ignore[attr-defined] + + if "username" in config and not self.config.file: + self.auth.username = config["username"] + + if "password" in config and not self.config.file: + self.auth.password = config["password"] + + if "device_data" in config and not self.config.file: + self.auth.device_group_key = config["device_data"][0] + self.auth.device_key = config["device_data"][1] + self.auth.device_password = config["device_data"][2] + device_data = config["device_data"] + if len(device_data) > EXPECTED_DEVICE_DATA_LENGTH: + token_created = device_data[3] + if token_created: + self.tokens.token_created = token_created # type: ignore[attr-defined] + + if not self.config.file and "tokens" not in config: + raise HiveUnknownConfiguration + + await self.get_devices("No_ID") # type: ignore[attr-defined] + + if not self.data.devices or not self.data.products: + _LOGGER.error( + "No devices or products returned from Hive API, reauthentication required." + ) + raise HiveReauthRequired + + return await self.create_devices() + + async def create_devices( # noqa: PLR0912, PLR0915 + self, + ): # pylint: disable=too-many-locals,too-many-statements + """Create list of devices. + + Returns: + list: List of devices + """ + _LOGGER.info("create_devices - Starting device discovery process") + + self.device_list["parent"] = [] + self.device_list["binary_sensor"] = [] + self.device_list["climate"] = [] + self.device_list["light"] = [] + self.device_list["sensor"] = [] + self.device_list["switch"] = [] + self.device_list["water_heater"] = [] + + hive_type = HIVE_TYPES["Thermo"] + HIVE_TYPES["Sensor"] + + # Find hub device first + for a_device in self.data["devices"]: + if self.data["devices"][a_device]["type"] == "hub": + self.hub_id = a_device + hub_name = ( + self.data["devices"][a_device] + .get("state", {}) + .get("name", a_device) + ) + _LOGGER.debug( + "create_devices - Found hub device: %s (ID: %s)", hub_name, a_device + ) + break + else: + _LOGGER.warning("create_devices - No hub device found in device list") + + # Process devices + device_count = 0 + for a_device in self.data["devices"]: + d = self.data.devices[a_device] + device_name = d.get("state", {}).get("name", a_device) + device_type = d.get("type", "Unknown") + _LOGGER.debug( + "create_devices - Processing device: %s (%s - %s)", + device_name, + a_device, + device_type, + ) + + for entity_config in DEVICES.get(device_type, []): + kwargs = {} + if entity_config.ha_name: + kwargs["ha_name"] = entity_config.ha_name + if entity_config.hive_type: + kwargs["hive_type"] = entity_config.hive_type + if entity_config.category: + kwargs["category"] = entity_config.category + try: + self.add_list(entity_config.entity_type, d, **kwargs) + except (KeyError, TypeError, AttributeError) as e: + _LOGGER.error( + "Failed to create device entity for %s: %s", + device_name, + str(e), + ) + + if device_type in hive_type: + self.config.battery.append(d["id"]) + _LOGGER.debug( + "create_devices - Added device %s to battery monitoring list", + device_name, + ) + + device_count += 1 + + # Process actions + _LOGGER.debug( + "create_devices - Processing %d actions", len(self.data["actions"]) + ) + for action_id in self.data["actions"]: + action = self.data["actions"][action_id] + try: + self.add_list( + "switch", action, ha_name=action["name"], hive_type="action" + ) + except (KeyError, TypeError, AttributeError) as e: + _LOGGER.error( + "Failed to create action entity for %s: %s", + action_id, + str(e), + ) + + # Process products + hive_type = HIVE_TYPES["Heating"] + HIVE_TYPES["Switch"] + HIVE_TYPES["Light"] + product_count = 0 + for a_product, p in self.data.products.items(): + if "error" in p: + _LOGGER.warning( + "Skipping product %s due to error: %s", a_product, p["error"] + ) + continue + + product_name = p.get("state", {}).get("name", a_product) + product_type = p.get("type", "Unknown") + _LOGGER.debug( + "create_devices - Processing product: %s (%s - %s)", + product_name, + a_product, + product_type, + ) + + if p.get("isGroup", False) and p["type"] not in HIVE_TYPES["Heating"]: + _LOGGER.debug( + "create_devices - Skipping group product currently not supported %s (type: %s)", + product_name, + product_type, + ) + continue + + for entity_config in PRODUCTS.get(product_type, []): + kwargs = {} + if entity_config.ha_name: + kwargs["ha_name"] = entity_config.ha_name + if entity_config.hive_type: + kwargs["hive_type"] = entity_config.hive_type + if entity_config.category: + kwargs["category"] = entity_config.category + if entity_config.entity_type == "climate": + kwargs["temperature_unit"] = self.data["user"].get( + "temperatureUnit" + ) + elif entity_config.temperature_unit is not None: + kwargs["temperature_unit"] = entity_config.temperature_unit + try: + self.add_list(entity_config.entity_type, p, **kwargs) + except (NameError, AttributeError) as e: + _LOGGER.warning( + "create_devices - Device %s cannot be setup - %s", + product_name, + e, + ) + + if product_type in hive_type: + self.config.mode.append(p["id"]) + _LOGGER.debug( + "create_devices - Added product %s to mode list", product_name + ) + + product_count += 1 + + _LOGGER.info( + "Device discovery completed: %d devices, %d products processed. " + "Found: %d parent, %d binary_sensor, %d climate," + " %d light, %d sensor, %d switch, %d water_heater", + device_count, + product_count, + len(self.device_list.get("parent", [])), + len(self.device_list.get("binary_sensor", [])), + len(self.device_list.get("climate", [])), + len(self.device_list.get("light", [])), + len(self.device_list.get("sensor", [])), + len(self.device_list.get("switch", [])), + len(self.device_list.get("water_heater", [])), + ) + + return self.device_list diff --git a/src/session/polling.py b/src/session/polling.py new file mode 100644 index 0000000..27341fc --- /dev/null +++ b/src/session/polling.py @@ -0,0 +1,231 @@ +"""Polling and entity-cache mixin for HiveSession.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, timedelta +from typing import Any + +from aiohttp.web import HTTPException + +from ..helper.hive_exceptions import HiveApiError, HiveAuthError, HiveReauthRequired +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + + +class PollingMixin: + """Device polling, rate-limiting, and entity-cache methods. + + Expects ``self.config``, ``self.tokens``, ``self.api``, + ``self.update_lock``, ``self._update_task``, + ``self._last_poll_slow``, and ``self._slow_poll_threshold`` + to be set up by the owning class's ``__init__``. + """ + + # Attributes provided by HiveSession.__init__ + config: Any + tokens: Any + api: Any + data: Any + entity_cache: dict + update_lock: asyncio.Lock + _update_task: asyncio.Task | None + _last_poll_slow: bool + _slow_poll_threshold: int + + @staticmethod + def _entity_cache_key(device) -> str: + """Build a stable cache key for an entity instance.""" + return "|".join( + [ + str(getattr(device, "ha_type", "")), + str(getattr(device, "hive_id", "")), + str(getattr(device, "hive_type", "")), + ] + ) + + def get_cached_device(self, device): + """Get cached state for a specific entity.""" + cache_key = self._entity_cache_key(device) + return self.entity_cache.get(cache_key) + + def set_cached_device(self, device): + """Store device state in cache and return it.""" + self.entity_cache[self._entity_cache_key(device)] = device + return device + + def should_use_cached_data(self): + """Determine whether callers should use cached entity state. + + Returns: + bool: True when the last poll was slow or another task is currently polling. + """ + if self._last_poll_slow: + return True + if self.update_lock.locked(): + current_task = asyncio.current_task() + return self._update_task is None or current_task is not self._update_task + return False + + async def _poll_devices(self) -> bool: + """Fetch latest device state from the Hive API.""" + return await self.get_devices("No_ID") + + async def update_data(self, _device: Device): + """Get latest data for Hive nodes - rate limiting. + + Args: + _device (Device): Device requesting the update. + + Returns: + boolean: True/False if update was successful + """ + updated = False + ep = self.config.last_update + self.config.scan_interval + if datetime.now() >= ep: + current_task = asyncio.current_task() + if self.update_lock.locked() and ( + self._update_task is None or current_task is not self._update_task + ): + _LOGGER.debug("update_data - Update poll already in progress") + return updated + async with self.update_lock: + # Re-check after acquiring lock — another caller may have already updated + ep = self.config.last_update + self.config.scan_interval + if datetime.now() < ep: + return updated + self._update_task = current_task + try: + _LOGGER.debug("Polling Hive API for device updates.") + updated = await self._poll_devices() + if updated: + _LOGGER.debug( + "update_data - Device update completed successfully." + ) + else: + _LOGGER.debug( + "update_data - Device update failed, will retry after scan interval." + ) + finally: + if self._update_task is current_task: + self._update_task = None + + return updated + + async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too-many-statements # noqa: PLR0912, PLR0915 + """Get latest data for Hive nodes. + + Args: + _n_id (str): ID of the device requesting data. + + Raises: + HTTPException: HTTP error has occurred updating the devices. + HiveApiError: An API error code has been returned. + + Returns: + boolean: True/False if update was successful. + """ + get_nodes_successful = False + api_resp_d = None + + try: + if self.config.file: + _LOGGER.debug("get_devices - Loading device data from file.") + api_resp_d = self.open_file("data.json") # type: ignore[attr-defined] + elif self.tokens is not None: + _LOGGER.debug( + "get_devices - Refreshing tokens before fetching devices." + ) + await self.hive_refresh_tokens() # type: ignore[attr-defined] + _LOGGER.debug("get_devices - Fetching all devices from Hive API.") + api_call_start = time.monotonic() + try: + api_resp_d = await self.api.get_all() + except HiveAuthError: + _LOGGER.warning( + "Auth error (401/403) after token refresh, " + "falling back to full device re-login." + ) + await self._retry_login() # type: ignore[attr-defined] + api_resp_d = await self._retry_with_backoff( # type: ignore[attr-defined] + self.api.get_all, + reraise_as=HiveReauthRequired, + ) + api_call_duration = time.monotonic() - api_call_start + if api_call_duration > self._slow_poll_threshold: + _LOGGER.debug( + "get_devices - Hive API response took %.1fs — marking poll as slow.", + api_call_duration, + ) + self._last_poll_slow = True + else: + self._last_poll_slow = False + if not str(api_resp_d["original"]).startswith("2"): + raise HTTPException + if api_resp_d["parsed"] is None: + raise HiveApiError + + if api_resp_d is None: + return get_nodes_successful + api_resp_p = api_resp_d["parsed"] + tmp_products = {} + tmp_devices = {} + tmp_actions = {} + + for hive_type_key in api_resp_p: + if hive_type_key == "user": + self.data.user = api_resp_p[hive_type_key] + self.config.user_id = api_resp_p[hive_type_key]["id"] + if hive_type_key == "products": + for a_product in api_resp_p[hive_type_key]: + tmp_products.update({a_product["id"]: a_product}) + if hive_type_key == "devices": + for a_device in api_resp_p[hive_type_key]: + tmp_devices.update({a_device["id"]: a_device}) + if hive_type_key == "actions": + for a_action in api_resp_p[hive_type_key]: + tmp_actions.update({a_action["id"]: a_action}) + if hive_type_key == "homes": + self.config.home_id = api_resp_p[hive_type_key]["homes"][0]["id"] + + _LOGGER.debug( + "get_devices - API returned %d products, %d devices, %d actions.", + len(tmp_products), + len(tmp_devices), + len(tmp_actions), + ) + if tmp_products: + self.data.products = tmp_products + if tmp_devices: + self.data.devices = tmp_devices + self.data.actions = tmp_actions + self.config.last_update = datetime.now() + get_nodes_successful = True + except HiveReauthRequired: + _LOGGER.error("Reauthentication required, propagating to caller.") + self.config.last_update = datetime.now() + raise + except asyncio.TimeoutError: + _LOGGER.warning("Hive API request timed out — keeping cached device data.") + self._last_poll_slow = True + self.config.last_update = ( + datetime.now() - self.config.scan_interval + timedelta(seconds=30) + ) + get_nodes_successful = False + except ( + OSError, + RuntimeError, + HiveApiError, + ConnectionError, + HTTPException, + ) as err: + _LOGGER.error("Failed to fetch devices: %s", err) + self.config.last_update = ( + datetime.now() - self.config.scan_interval + timedelta(seconds=30) + ) + get_nodes_successful = False + + return get_nodes_successful diff --git a/src/session_discovery.py b/src/session_discovery.py new file mode 100644 index 0000000..8d5f37c --- /dev/null +++ b/src/session_discovery.py @@ -0,0 +1,15 @@ +"""Backwards-compatible shim — use apyhiveapi.session.discovery instead.""" + +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings + +warnings.warn( + "apyhiveapi.session_discovery is deprecated; import from apyhiveapi.session.discovery", + DeprecationWarning, + stacklevel=2, +) + +from .session.discovery import DiscoveryMixin + +__all__ = ["DiscoveryMixin"] diff --git a/src/session_polling.py b/src/session_polling.py new file mode 100644 index 0000000..1de7e8f --- /dev/null +++ b/src/session_polling.py @@ -0,0 +1,15 @@ +"""Backwards-compatible shim — use apyhiveapi.session.polling instead.""" + +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings + +warnings.warn( + "apyhiveapi.session_polling is deprecated; import from apyhiveapi.session.polling", + DeprecationWarning, + stacklevel=2, +) + +from .session.polling import PollingMixin + +__all__ = ["PollingMixin"] diff --git a/src/session_tokens.py b/src/session_tokens.py new file mode 100644 index 0000000..412425b --- /dev/null +++ b/src/session_tokens.py @@ -0,0 +1,15 @@ +"""Backwards-compatible shim — use apyhiveapi.session.auth instead.""" + +# pylint: skip-file +# ruff: noqa: F401, E402 +import warnings + +warnings.warn( + "apyhiveapi.session_tokens is deprecated; import from apyhiveapi.session.auth", + DeprecationWarning, + stacklevel=2, +) + +from .session.auth import SessionAuthMixin as TokenMixin + +__all__ = ["TokenMixin"]