From 36fa65c1be725dbd7a969d3712128e2d1f7cc33a Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 2 Jul 2024 10:44:30 +0200 Subject: [PATCH 01/52] chore: custom endpoints --- custom_components/optispark/api.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 06020bf..d93a865 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -102,7 +102,8 @@ def datetime_set_utc(self, d: dict[str, datetime]): async def upload_history(self, dynamo_data): """Upload historical data to dynamoDB without calculating heat pump profile.""" - lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + lambda_url = 'http://localhost:5000/home-assistant/history' payload = {'dynamo_data': dynamo_data} payload['upload_only'] = True extra = await self._api_wrapper( @@ -119,7 +120,8 @@ async def get_data_dates(self, dynamo_data: dict): dynamo_data will only contain the user_hash. """ - lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + lambda_url = 'http://localhost:5000/home-assistant/data-dates' payload = {'dynamo_data': dynamo_data} payload['get_newest_oldest_data_date_only'] = True extra = await self._api_wrapper( @@ -134,7 +136,8 @@ async def get_data_dates(self, dynamo_data: dict): async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" - lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + lambda_url = 'http://localhost:5000/home-assistant/profile' payload = lambda_args payload['get_profile_only'] = True From 8ebf10b3ad608729314644c54fc6d3485b373772 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 4 Jul 2024 11:29:32 +0200 Subject: [PATCH 02/52] wip: login wip: login wip: connect login chore: login into OptiSpark backend --- .gitignore | 4 + custom_components/optispark/api.py | 182 ++++++++++++------ .../optispark/rest/models/__init__.py | 0 .../optispark/rest/models/login_response.py | 11 ++ 4 files changed, 141 insertions(+), 56 deletions(-) create mode 100644 custom_components/optispark/rest/models/__init__.py create mode 100644 custom_components/optispark/rest/models/login_response.py diff --git a/.gitignore b/.gitignore index 712db33..0aee108 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ config/* homeassistant Session.vim + +# IntelliJ + +.idea diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index d93a865..1051e58 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -1,4 +1,5 @@ """Optispark API Client.""" + from __future__ import annotations import asyncio @@ -13,45 +14,36 @@ import base64 from .const import LOGGER import traceback +from http import HTTPStatus + +from .rest.models.login_response import LoginResponse class OptisparkApiClientError(Exception): """Exception to indicate a general API error.""" -class OptisparkApiClientTimeoutError( - OptisparkApiClientError -): +class OptisparkApiClientTimeoutError(OptisparkApiClientError): """Lamba probably took too long starting up.""" -class OptisparkApiClientCommunicationError( - OptisparkApiClientError -): +class OptisparkApiClientCommunicationError(OptisparkApiClientError): """Exception to indicate a communication error.""" -class OptisparkApiClientAuthenticationError( - OptisparkApiClientError -): +class OptisparkApiClientAuthenticationError(OptisparkApiClientError): """Exception to indicate an authentication error.""" -class OptisparkApiClientLambdaError( - OptisparkApiClientError -): +class OptisparkApiClientLambdaError(OptisparkApiClientError): """Exception to indicate lambda return an error.""" -class OptisparkApiClientPostcodeError( - OptisparkApiClientError -): +class OptisparkApiClientPostcodeError(OptisparkApiClientError): """Exception to indicate invalid postcode.""" -class OptisparkApiClientUnitError( - OptisparkApiClientError -): +class OptisparkApiClientUnitError(OptisparkApiClientError): """Exception to indicate unit error.""" @@ -68,7 +60,10 @@ def floats_to_decimal(obj): return None # Go deeper elif isinstance(obj, dict): - return {floats_to_decimal(key): floats_to_decimal(value) for key, value in obj.items()} + return { + floats_to_decimal(key): floats_to_decimal(value) + for key, value in obj.items() + } elif isinstance(obj, set): return {floats_to_decimal(element) for element in obj} elif isinstance(obj, list): @@ -78,19 +73,22 @@ def floats_to_decimal(obj): elif isinstance(obj, datetime): return floats_to_decimal(obj.timestamp()) else: - LOGGER.error(f'Object of type {type(obj)} not supported by DynamoDB') - raise TypeError(f'Object of type {type(obj)} not supported by DynamoDB') + LOGGER.error(f"Object of type {type(obj)} not supported by DynamoDB") + raise TypeError(f"Object of type {type(obj)} not supported by DynamoDB") class OptisparkApiClient: """Optispark API Client.""" + _token: str | None + def __init__( self, session: aiohttp.ClientSession, ) -> None: """Sample API Client.""" self._session = session + self._token = None def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -103,16 +101,16 @@ def datetime_set_utc(self, d: dict[str, datetime]): async def upload_history(self, dynamo_data): """Upload historical data to dynamoDB without calculating heat pump profile.""" # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = 'http://localhost:5000/home-assistant/history' - payload = {'dynamo_data': dynamo_data} - payload['upload_only'] = True + lambda_url = "http://localhost:5000/home-assistant/history" + payload = {"dynamo_data": dynamo_data} + payload["upload_only"] = True extra = await self._api_wrapper( method="post", url=lambda_url, data=payload, ) - oldest_dates = self.datetime_set_utc(extra['oldest_dates']) - newest_dates = self.datetime_set_utc(extra['newest_dates']) + oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) + newest_dates = self.datetime_set_utc(extra["newest_dates"]) return oldest_dates, newest_dates async def get_data_dates(self, dynamo_data: dict): @@ -120,106 +118,178 @@ async def get_data_dates(self, dynamo_data: dict): dynamo_data will only contain the user_hash. """ + # auth_url = "http://localhost:5000/auth/ha_login" + # LOGGER.debug(auth_url) + # LOGGER.debug("***********************************************") + # LOGGER.debug(dynamo_data["user_hash"]) + + # # user_hash = dynamo_data["user_hash"] + # payload = {"user_hash": dynamo_data["user_hash"]} + + # response = await self._session.request( + # method="post", + # url=auth_url, + # json=payload, + # ) + + # print(response.status) + # res = await response.json() + # print(res["access_token"]) + # token = res["access_token"] + # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = 'http://localhost:5000/home-assistant/data-dates' - payload = {'dynamo_data': dynamo_data} - payload['get_newest_oldest_data_date_only'] = True + lambda_url = "http://localhost:5000/home-assistant/data-dates" + payload = {"dynamo_data": dynamo_data} + payload["get_newest_oldest_data_date_only"] = True + payload["user_hash"] = dynamo_data["user_hash"] + # print(payload) extra = await self._api_wrapper( method="post", url=lambda_url, data=payload, ) - oldest_dates = self.datetime_set_utc(extra['oldest_dates']) - newest_dates = self.datetime_set_utc(extra['newest_dates']) + print(extra) + oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) + newest_dates = self.datetime_set_utc(extra["newest_dates"]) return oldest_dates, newest_dates async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = 'http://localhost:5000/home-assistant/profile' + lambda_url = "http://localhost:5000/home-assistant/profile" payload = lambda_args - payload['get_profile_only'] = True - LOGGER.debug('----------Lambda get profile----------') + payload["get_profile_only"] = True + LOGGER.debug("----------Lambda get profile----------") results, errors = await self._api_wrapper( method="post", url=lambda_url, data=payload, ) - if errors['success'] is False: + if errors["success"] is False: LOGGER.debug(f'OptisparkApiClientLambdaError: {errors["error_message"]}') - raise OptisparkApiClientLambdaError(errors['error_message']) - if results['optimised_cost'] == 0: + raise OptisparkApiClientLambdaError(errors["error_message"]) + if results["optimised_cost"] == 0: # Heating isn't active. Should the savings be 0? - results['projected_percent_savings'] = 100 + results["projected_percent_savings"] = 100 else: - results['projected_percent_savings'] = results['base_cost']/results['optimised_cost']*100 - 100 + results["projected_percent_savings"] = ( + results["base_cost"] / results["optimised_cost"] * 100 - 100 + ) return results def json_serialisable(self, data): """Convert to compressed bytes so that data can be converted to json.""" uncompressed_data = pickle.dumps(data) + # print(data) + # print(uncompressed_data) compressed_data = gzip.compress(uncompressed_data) - LOGGER.debug(f'len(uncompressed_data): {len(uncompressed_data)}') - LOGGER.debug(f'len(compressed_data): {len(compressed_data)}') - base64_string = base64.b64encode(compressed_data).decode('utf-8') + LOGGER.debug(f"len(uncompressed_data): {len(uncompressed_data)}") + LOGGER.debug(f"len(compressed_data): {len(compressed_data)}") + base64_string = base64.b64encode(compressed_data).decode("utf-8") return base64_string def json_deserialise(self, payload): """Convert from the compressed bytes to original objects.""" - payload = payload['serialised_payload'] + # payload = payload["serialised_payload"] + # payload = payload["serialisePayload"] payload = base64.b64decode(payload) payload = gzip.decompress(payload) payload = pickle.loads(payload) return payload - async def _api_wrapper( - self, - method: str, - url: str, - data: dict, - ): + async def _login(self, user_hash: str) -> LoginResponse: + # TODO: move to config + auth_url = "http://localhost:5000/auth/ha_login" + try: + payload = {"user_hash": user_hash} + response = await self._session.request( + method="post", + url=auth_url, + json=payload, + ) + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + json_response = await response.json() + + return LoginResponse( + token=json_response["accessToken"], + token_type=json_response["tokenType"], + has_locations=json_response["hasLocations"], + has_devices=json_response["hasDevices"], + ) + + except: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + async def _api_wrapper(self, method: str, url: str, data: dict): """Call the Lambda function.""" try: - if 'dynamo_data' in data: - data['dynamo_data'] = floats_to_decimal(data['dynamo_data']) + if "dynamo_data" in data: + data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) data_serialised = self.json_serialisable(data) async with async_timeout.timeout(120): + LOGGER.debug(f" Initiating login into OptiSpark backend") + if not self._token: + user_hash = data["user_hash"] + if user_hash: + loginResponse: LoginResponse = await self._login( + user_hash="user_hash" + ) + self._token = loginResponse.token + LOGGER.debug(f" User token: {loginResponse.token}") + response = await self._session.request( method=method, url=url, json=data_serialised, ) if response.status in (401, 403): + # Clean token forcing login in next api_wrapper call + self._token = None raise OptisparkApiClientAuthenticationError( "Invalid credentials", ) + if response.status == 502: # HomeAssistant will not print errors if there was never a successful update - LOGGER.debug('OptisparkApiClientCommunicationError:\n 502 Bad Gateway - check payload') + LOGGER.debug( + "OptisparkApiClientCommunicationError:\n 502 Bad Gateway - check payload" + ) raise OptisparkApiClientCommunicationError( - '502 Bad Gateway - check payload') + "502 Bad Gateway - check payload" + ) response.raise_for_status() payload = await response.json() return self.json_deserialise(payload) except asyncio.TimeoutError as exception: LOGGER.error(traceback.format_exc()) - LOGGER.error('OptisparkApiClientTimeoutError:\n Timeout error fetching information') + LOGGER.error( + "OptisparkApiClientTimeoutError:\n Timeout error fetching information" + ) raise OptisparkApiClientTimeoutError( "Timeout error fetching information", ) from exception except (aiohttp.ClientError, socket.gaierror) as exception: LOGGER.error(traceback.format_exc()) - LOGGER.error('OptisparkApiClientCommunicationError:\n Error fetching information') + LOGGER.error( + "OptisparkApiClientCommunicationError:\n Error fetching information" + ) raise OptisparkApiClientCommunicationError( "Error fetching information", ) from exception except Exception as exception: # pylint: disable=broad-except LOGGER.error(traceback.format_exc()) - LOGGER.error('OptisparkApiClientError:\n Something really wrong happened!') + LOGGER.error("OptisparkApiClientError:\n Something really wrong happened!") raise OptisparkApiClientError( "Something really wrong happened!" ) from exception diff --git a/custom_components/optispark/rest/models/__init__.py b/custom_components/optispark/rest/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/rest/models/login_response.py b/custom_components/optispark/rest/models/login_response.py new file mode 100644 index 0000000..78c660a --- /dev/null +++ b/custom_components/optispark/rest/models/login_response.py @@ -0,0 +1,11 @@ +class LoginResponse: + token: str + token_type: str + has_locations: bool + has_devices: bool + + def __init__(self, token: str, token_type: str, has_locations: bool, has_devices: bool): + self.token = token + self.token_type = token_type + self.has_locations = has_locations + self.has_devices = has_devices From 0b3c3d5ca96bb96ee7f88eacaf4054ef213c5c99 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 4 Jul 2024 15:51:29 +0200 Subject: [PATCH 03/52] chore: deps update and refactor --- .../{optispark/rest/models => }/__init__.py | 0 custom_components/optispark/api.py | 121 ++++++++++-------- custom_components/optispark/rest/__init__.py | 0 .../optispark/rest/auth/__init__.py | 0 .../optispark/rest/auth/auth_service.py | 46 +++++++ .../optispark/rest/auth/model/__init__.py | 0 .../{models => auth/model}/login_response.py | 0 .../optispark/rest/exception/__init__.py | 0 .../optispark/rest/exception/exceptions.py | 36 ++++++ requirements.txt | 2 +- 10 files changed, 151 insertions(+), 54 deletions(-) rename custom_components/{optispark/rest/models => }/__init__.py (100%) create mode 100644 custom_components/optispark/rest/__init__.py create mode 100644 custom_components/optispark/rest/auth/__init__.py create mode 100644 custom_components/optispark/rest/auth/auth_service.py create mode 100644 custom_components/optispark/rest/auth/model/__init__.py rename custom_components/optispark/rest/{models => auth/model}/login_response.py (100%) create mode 100644 custom_components/optispark/rest/exception/__init__.py create mode 100644 custom_components/optispark/rest/exception/exceptions.py diff --git a/custom_components/optispark/rest/models/__init__.py b/custom_components/__init__.py similarity index 100% rename from custom_components/optispark/rest/models/__init__.py rename to custom_components/__init__.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 1051e58..629c3b9 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -16,35 +16,37 @@ import traceback from http import HTTPStatus -from .rest.models.login_response import LoginResponse +from .rest.auth.auth_service import AuthService +from .rest.auth.model.login_response import LoginResponse +from .rest.exception.exceptions import * -class OptisparkApiClientError(Exception): - """Exception to indicate a general API error.""" +# class OptisparkApiClientError(Exception): +# """Exception to indicate a general API error.""" -class OptisparkApiClientTimeoutError(OptisparkApiClientError): - """Lamba probably took too long starting up.""" +# class OptisparkApiClientTimeoutError(OptisparkApiClientError): +# """Lamba probably took too long starting up.""" -class OptisparkApiClientCommunicationError(OptisparkApiClientError): - """Exception to indicate a communication error.""" - - -class OptisparkApiClientAuthenticationError(OptisparkApiClientError): - """Exception to indicate an authentication error.""" - - -class OptisparkApiClientLambdaError(OptisparkApiClientError): - """Exception to indicate lambda return an error.""" - - -class OptisparkApiClientPostcodeError(OptisparkApiClientError): - """Exception to indicate invalid postcode.""" - - -class OptisparkApiClientUnitError(OptisparkApiClientError): - """Exception to indicate unit error.""" +# class OptisparkApiClientCommunicationError(OptisparkApiClientError): +# """Exception to indicate a communication error.""" +# +# +# class OptisparkApiClientAuthenticationError(OptisparkApiClientError): +# """Exception to indicate an authentication error.""" +# +# +# class OptisparkApiClientLambdaError(OptisparkApiClientError): +# """Exception to indicate lambda return an error.""" +# +# +# class OptisparkApiClientPostcodeError(OptisparkApiClientError): +# """Exception to indicate invalid postcode.""" +# +# +# class OptisparkApiClientUnitError(OptisparkApiClientError): +# """Exception to indicate unit error.""" def floats_to_decimal(obj): @@ -81,6 +83,7 @@ class OptisparkApiClient: """Optispark API Client.""" _token: str | None + _auth_service: AuthService def __init__( self, @@ -89,6 +92,7 @@ def __init__( """Sample API Client.""" self._session = session self._token = None + self._auth_service = AuthService(session=session) def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -199,38 +203,49 @@ def json_deserialise(self, payload): payload = pickle.loads(payload) return payload - async def _login(self, user_hash: str) -> LoginResponse: - # TODO: move to config - auth_url = "http://localhost:5000/auth/ha_login" - try: - payload = {"user_hash": user_hash} - response = await self._session.request( - method="post", - url=auth_url, - json=payload, - ) - - if response.status != HTTPStatus.OK: - raise OptisparkApiClientAuthenticationError( - "Invalid credentials", - ) from Exception - - json_response = await response.json() - - return LoginResponse( - token=json_response["accessToken"], - token_type=json_response["tokenType"], - has_locations=json_response["hasLocations"], - has_devices=json_response["hasDevices"], - ) - - except: - raise OptisparkApiClientAuthenticationError( - "Invalid credentials", - ) from Exception + # async def _login(self, user_hash: str) -> LoginResponse: + # # TODO: move to config + # auth_url = "http://localhost:5000/auth/ha_login" + # try: + # payload = {"user_hash": user_hash} + # response = await self._session.request( + # method="post", + # url=auth_url, + # json=payload, + # ) + # + # if response.status != HTTPStatus.OK: + # raise OptisparkApiClientAuthenticationError( + # "Invalid credentials", + # ) from Exception + # + # json_response = await response.json() + # + # return LoginResponse( + # token=json_response["accessToken"], + # token_type=json_response["tokenType"], + # has_locations=json_response["hasLocations"], + # has_devices=json_response["hasDevices"], + # ) + # + # except: + # raise OptisparkApiClientAuthenticationError( + # "Invalid credentials", + # ) from Exception + + # async def _add_location(self): async def _api_wrapper(self, method: str, url: str, data: dict): """Call the Lambda function.""" + # ha: core.HomeAssistant = core.async_get_hass() + # print("************************************") + # print(ha.data.keys()) + # + # config = ha.config + # if not config: + # print("NOOOOOOOOOO") + # print(config.latitude) + # print(config.longitude) try: if "dynamo_data" in data: data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) @@ -241,7 +256,7 @@ async def _api_wrapper(self, method: str, url: str, data: dict): if not self._token: user_hash = data["user_hash"] if user_hash: - loginResponse: LoginResponse = await self._login( + loginResponse: LoginResponse = await self._auth_service.login( user_hash="user_hash" ) self._token = loginResponse.token diff --git a/custom_components/optispark/rest/__init__.py b/custom_components/optispark/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/rest/auth/__init__.py b/custom_components/optispark/rest/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/rest/auth/auth_service.py b/custom_components/optispark/rest/auth/auth_service.py new file mode 100644 index 0000000..6fb3838 --- /dev/null +++ b/custom_components/optispark/rest/auth/auth_service.py @@ -0,0 +1,46 @@ +from http import HTTPStatus + +import aiohttp + +from custom_components.optispark.rest.auth.model.login_response import LoginResponse +from custom_components.optispark.rest.exception.exceptions import OptisparkApiClientAuthenticationError + + +class AuthService: + + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + + async def login(self, user_hash: str) -> LoginResponse: + # TODO: move to config + auth_url = "http://localhost:5000/auth/ha_login" + try: + payload = {"user_hash": user_hash} + response = await self._session.request( + method="post", + url=auth_url, + json=payload, + ) + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + json_response = await response.json() + + return LoginResponse( + token=json_response["accessToken"], + token_type=json_response["tokenType"], + has_locations=json_response["hasLocations"], + has_devices=json_response["hasDevices"], + ) + + except: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception \ No newline at end of file diff --git a/custom_components/optispark/rest/auth/model/__init__.py b/custom_components/optispark/rest/auth/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/rest/models/login_response.py b/custom_components/optispark/rest/auth/model/login_response.py similarity index 100% rename from custom_components/optispark/rest/models/login_response.py rename to custom_components/optispark/rest/auth/model/login_response.py diff --git a/custom_components/optispark/rest/exception/__init__.py b/custom_components/optispark/rest/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/rest/exception/exceptions.py b/custom_components/optispark/rest/exception/exceptions.py new file mode 100644 index 0000000..1d800c1 --- /dev/null +++ b/custom_components/optispark/rest/exception/exceptions.py @@ -0,0 +1,36 @@ + +__all__ = [ + "OptisparkApiClientError", + "OptisparkApiClientTimeoutError", + "OptisparkApiClientCommunicationError", + "OptisparkApiClientAuthenticationError", + "OptisparkApiClientLambdaError", + "OptisparkApiClientPostcodeError", + "OptisparkApiClientUnitError" +] +class OptisparkApiClientError(Exception): + """Exception to indicate a general API error.""" + + +class OptisparkApiClientTimeoutError(OptisparkApiClientError): + """Lamba probably took too long starting up.""" + + +class OptisparkApiClientCommunicationError(OptisparkApiClientError): + """Exception to indicate a communication error.""" + + +class OptisparkApiClientAuthenticationError(OptisparkApiClientError): + """Exception to indicate an authentication error.""" + + +class OptisparkApiClientLambdaError(OptisparkApiClientError): + """Exception to indicate lambda return an error.""" + + +class OptisparkApiClientPostcodeError(OptisparkApiClientError): + """Exception to indicate invalid postcode.""" + + +class OptisparkApiClientUnitError(OptisparkApiClientError): + """Exception to indicate unit error.""" diff --git a/requirements.txt b/requirements.txt index 124a01f..7e1a1ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.7.0 -homeassistant==2023.11.2 +homeassistant==2024.6.4 pip>=21.0,<23.4 ruff==0.1.5 geopy==2.4.1 From b27bf7e2a7193d5724c0ef8d7f0c831d353f7ef3 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 4 Jul 2024 17:01:41 +0200 Subject: [PATCH 04/52] wip: address wip: address from geopy chore: get full address during config --- custom_components/optispark/__init__.py | 4 +++- custom_components/optispark/config_flow.py | 5 +++++ custom_components/optispark/coordinator.py | 6 +++++- .../optispark/rest/electricity/__init__.py | 12 ++++++++++++ .../rest/electricity/electricity_service.py | 0 custom_components/optispark/translations/en.json | 3 ++- 6 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 custom_components/optispark/rest/electricity/__init__.py create mode 100644 custom_components/optispark/rest/electricity/electricity_service.py diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index a083621..45225c6 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -34,7 +34,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: external_temp_entity_id=entry.data['external_temp_entity_id'], user_hash=entry.data['user_hash'], postcode=entry.data['postcode'], - tariff=entry.data['tariff'] + tariff=entry.data['tariff'], + address=entry.data['address'], + country=entry.data['country'], ) # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/optispark/config_flow.py b/custom_components/optispark/config_flow.py index 01b226f..83b2580 100644 --- a/custom_components/optispark/config_flow.py +++ b/custom_components/optispark/config_flow.py @@ -77,8 +77,10 @@ async def async_step_heat_pump_details(self, user_input: dict | None = None) -> postcode = await self.test_postcode(user_input['postcode']) await self.test_units(user_input['heat_pump_power_entity_id']) user_input['postcode'] = postcode # Fix postcode formating + user_input['address'] = user_input['address'] else: user_input['postcode'] = None + user_input['address'] = None if 'external_temp_entity_id' not in user_input: user_input['external_temp_entity_id'] = None @@ -106,13 +108,16 @@ async def async_step_heat_pump_details(self, user_input: dict | None = None) -> self.hass.config.latitude, self.hass.config.longitude)) postcode = location.raw['address']['postcode'] + address = location.raw['address']['road'] + ', ' + location.raw['address']['city'] if postcode == '' or postcode is None: raise OptisparkApiClientPostcodeError() except Exception as err: LOGGER.warning(err) postcode = '' + address = '' errors["base"] = "postcode_homeassistant" data_schema[vol.Required('postcode', default=postcode)] = str + data_schema[vol.Required('address', default=address)] = str data_schema[vol.Required("climate_entity_id")] = selector({ "entity": { diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 8048bb9..5b09f1d 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -44,7 +44,9 @@ def __init__( external_temp_entity_id: str, user_hash: str, postcode: str, - tariff: str + tariff: str, + address: str, + country: str, ) -> None: """Initialize.""" self.client = client @@ -56,6 +58,8 @@ def __init__( ) self._postcode = postcode if postcode is not None else 'AB11 6LU' self._tariff = tariff + self._address = address + self._country = country #user_hash = 'debug_hash' self._user_hash = user_hash self._climate_entity_id = climate_entity_id diff --git a/custom_components/optispark/rest/electricity/__init__.py b/custom_components/optispark/rest/electricity/__init__.py new file mode 100644 index 0000000..5373d5d --- /dev/null +++ b/custom_components/optispark/rest/electricity/__init__.py @@ -0,0 +1,12 @@ +import aiohttp + + +class ElectrityService: + + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + diff --git a/custom_components/optispark/rest/electricity/electricity_service.py b/custom_components/optispark/rest/electricity/electricity_service.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/translations/en.json b/custom_components/optispark/translations/en.json index db1681a..6baf83f 100644 --- a/custom_components/optispark/translations/en.json +++ b/custom_components/optispark/translations/en.json @@ -14,6 +14,7 @@ "country": "Country", "tariff": "Electricity tariff", "username": "Username", + "address": "Address", "postcode": "Postcode", "climate_entity_id": "Heat pump", "heat_pump_power_entity_id": "Power usage of heat pump", @@ -21,7 +22,7 @@ } }, "accept":{ - "description": "The following information is needed to calculate the optimised heat pump profile:\n - Heat pump power usage\n - Temperature data\n - Postcode (for weather and electricity price data)", + "description": "The following information is needed to calculate the optimised heat pump profile:\n - Heat pump power usage\n - Temperature data\n - Address & Postcode (for weather and electricity price data)", "data": { "accept_agreement": "" } From 9873e312a91ae3fb7e88319476e5b5e8f02d8a1e Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 4 Jul 2024 17:56:58 +0200 Subject: [PATCH 05/52] chore: add address to lambda_args --- custom_components/optispark/coordinator.py | 332 ++++++++++++++------- 1 file changed, 217 insertions(+), 115 deletions(-) diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 5b09f1d..a813cfe 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -1,4 +1,5 @@ """DataUpdateCoordinator for optispark.""" + from __future__ import annotations from datetime import timedelta, datetime, timezone @@ -56,11 +57,11 @@ def __init__( name=const.DOMAIN, update_interval=timedelta(seconds=10), ) - self._postcode = postcode if postcode is not None else 'AB11 6LU' + self._postcode = postcode if postcode is not None else "AB11 6LU" self._tariff = tariff self._address = address self._country = country - #user_hash = 'debug_hash' + # user_hash = 'debug_hash' self._user_hash = user_hash self._climate_entity_id = climate_entity_id self._heat_pump_power_entity_id = heat_pump_power_entity_id @@ -74,8 +75,9 @@ def __init__( const.LAMBDA_USER_HASH: user_hash, const.LAMBDA_INITIAL_INTERNAL_TEMP: None, const.LAMBDA_OUTSIDE_RANGE: False, - const.LAMBDA_HEAT_PUMP_MODE_RAW: 'HEATING', - const.LAMBDA_HOME_ASSISTANT_VERSION: const.VERSION + const.LAMBDA_HEAT_PUMP_MODE_RAW: "HEATING", + const.LAMBDA_HOME_ASSISTANT_VERSION: const.VERSION, + const.LAMBDA_ADDRESS: self._address, } self._lambda_update_handler = LambdaUpdateHandler( hass=self.hass, @@ -85,7 +87,9 @@ def __init__( external_temp_entity_id=self._external_temp_entity_id, user_hash=self._user_hash, postcode=self._postcode, - tariff=self._tariff) + address=self._address, + tariff=self._tariff, + ) def convert_sensor_from_farenheit(self, entity, temp): """Ensure that the sensor returns values in Celcius. @@ -98,9 +102,9 @@ def convert_sensor_from_farenheit(self, entity, temp): return temp elif sensor_unit == homeassistant.const.TEMP_FAHRENHEIT: # Convert temperature from Celcius to Farenheit - return (temp-32) * 5/9 + return (temp - 32) * 5 / 9 else: - raise ValueError(f'Heat pump uses unkown units ({sensor_unit})') + raise ValueError(f"Heat pump uses unkown units ({sensor_unit})") def convert_climate_from_farenheit(self, entity, temp): """Ensure that the heat pump returns values in Celcius. @@ -113,9 +117,9 @@ def convert_climate_from_farenheit(self, entity, temp): return temp elif heat_pump_unit == homeassistant.const.TEMP_FAHRENHEIT: # Convert temperature from Celcius to Farenheit - return (temp-32) * 5/9 + return (temp - 32) * 5 / 9 else: - raise ValueError(f'Heat pump uses unkown units ({heat_pump_unit})') + raise ValueError(f"Heat pump uses unkown units ({heat_pump_unit})") def convert_climate_from_celcius(self, entity, temp): """Ensure that the heat pump is given a temperature in the correct units. @@ -128,9 +132,9 @@ def convert_climate_from_celcius(self, entity, temp): return temp elif heat_pump_unit == homeassistant.const.TEMP_FAHRENHEIT: # Convert temperature from Celcius to Farenheit - return temp*9/5 + 32 + return temp * 9 / 5 + 32 else: - raise ValueError(f'Heat pump uses unkown units ({heat_pump_unit})') + raise ValueError(f"Heat pump uses unkown units ({heat_pump_unit})") async def update_heat_pump_temperature(self, data): """Set the temperature of the heat pump using the value from lambda.""" @@ -140,15 +144,23 @@ async def update_heat_pump_temperature(self, data): try: if self.heat_pump_target_temperature == temp: return - LOGGER.debug('Change in target temperature!') - supports_target_temperature_range = climate_entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + LOGGER.debug("Change in target temperature!") + supports_target_temperature_range = ( + climate_entity.supported_features + & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) if supports_target_temperature_range: await climate_entity.async_set_temperature( - target_temp_low=self.convert_climate_from_celcius(climate_entity, temp), - target_temp_high=climate_entity.target_temperature_high) + target_temp_low=self.convert_climate_from_celcius( + climate_entity, temp + ), + target_temp_high=climate_entity.target_temperature_high, + ) else: await climate_entity.async_set_temperature( - temperature=self.convert_climate_from_celcius(climate_entity, temp)) + temperature=self.convert_climate_from_celcius(climate_entity, temp) + ) except Exception as err: LOGGER.error(traceback.format_exc()) raise OptisparkSetTemperatureError(err) @@ -164,14 +176,13 @@ def get_optispark_entities(self, include_switch=True) -> list[RegistryEntry]: # Id not found - this is the first time the integration has been initialised return [] entities: list[RegistryEntry] = entity_registry.async_entries_for_device( - entity_register, - device_id, - include_disabled_entities=True) + entity_register, device_id, include_disabled_entities=True + ) if include_switch is False: # Remove the switch from the list so it doesn't get disabled idx_store = None for idx, entity in enumerate(entities): - if entity.entity_id == 'switch.' + const.SWITCH_KEY: + if entity.entity_id == "switch." + const.SWITCH_KEY: idx_store = idx if idx_store is not None: del entities[idx_store] @@ -180,9 +191,14 @@ def get_optispark_entities(self, include_switch=True) -> list[RegistryEntry]: def enable_disable_entities(self, entities: list[RegistryEntry], enable: bool): """Enable/Disable all entities given in the list.""" entity_register: EntityRegistry = entity_registry.async_get(self.hass) - enable_lookup = {True: None, False: entity_registry.RegistryEntryDisabler.INTEGRATION} + enable_lookup = { + True: None, + False: entity_registry.RegistryEntryDisabler.INTEGRATION, + } for entity in entities: - entity_register.async_update_entity(entity.entity_id, disabled_by=enable_lookup[enable]) + entity_register.async_update_entity( + entity.entity_id, disabled_by=enable_lookup[enable] + ) def enable_disable_integration(self, enable: bool): """Enable/Disable all entities other than the switch.""" @@ -192,7 +208,7 @@ def enable_disable_integration(self, enable: bool): if enable is False: # The coordinator is available once data is fetched self._available = False - #self.always_update = enable + # self.always_update = enable async def async_set_lambda_args(self, lambda_args): """Update the lambda arguments. @@ -215,7 +231,11 @@ def heat_pump_target_temperature(self): Assumes that the heat pump is being used for heating. """ climate_entity = get_entity(self.hass, self._climate_entity_id) - supports_target_temperature_range = climate_entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + supports_target_temperature_range = ( + climate_entity.supported_features + & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) if supports_target_temperature_range: temperature = climate_entity.target_temperature_low else: @@ -239,13 +259,17 @@ def heat_pump_power_usage(self): entity = get_entity(self.hass, self._heat_pump_power_entity_id) native_value = entity.native_value match entity.unit_of_measurement: - case 'W': - return native_value/1000 - case 'kW': + case "W": + return native_value / 1000 + case "kW": return native_value case _: - LOGGER.error(f'Heat pump does not use supported unit({entity.unit_of_measurement})') - raise TypeError(f'Heat pump does not use supported unit({entity.unit_of_measurement})') + LOGGER.error( + f"Heat pump does not use supported unit({entity.unit_of_measurement})" + ) + raise TypeError( + f"Heat pump does not use supported unit({entity.unit_of_measurement})" + ) @property def external_temp(self): @@ -263,10 +287,16 @@ def lambda_args(self): Updates the initial_internal_temp and checks outside_range. """ self._lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP] = self.internal_temp - if abs(self.internal_temp - self._lambda_args[const.LAMBDA_SET_POINT]) > self._lambda_args[const.LAMBDA_TEMP_RANGE]: + if ( + abs(self.internal_temp - self._lambda_args[const.LAMBDA_SET_POINT]) + > self._lambda_args[const.LAMBDA_TEMP_RANGE] + ): self._lambda_args[const.LAMBDA_OUTSIDE_RANGE] = True else: self._lambda_args[const.LAMBDA_OUTSIDE_RANGE] = False + + # self._lambda_args[const.LAMBDA_ADDRESS] = self._address + # self._lambda_args[const.LAMBDA_POSTCODE] = self._postcode return self._lambda_args @property @@ -282,7 +312,6 @@ async def async_request_update(self): """ await self.async_request_refresh() - async def _async_update_data(self): """Update data for entities. @@ -309,8 +338,17 @@ class LambdaUpdateHandler: Gets the heating profile and ensure dynamo is up to date. """ - def __init__(self, hass, client: OptisparkApiClient, climate_entity_id, - heat_pump_power_entity_id, external_temp_entity_id, user_hash, postcode, tariff): + def __init__( + self, + hass, + client: OptisparkApiClient, + climate_entity_id, + heat_pump_power_entity_id, + external_temp_entity_id, + user_hash, + postcode, + tariff, + ): """Init.""" self.hass = hass self.client: OptisparkApiClient = client @@ -320,18 +358,25 @@ def __init__(self, hass, client: OptisparkApiClient, climate_entity_id, self.user_hash = user_hash self.postcode = postcode self.tariff = tariff - self.expire_time = datetime(1, 1, 1, 0, 0, 0, tzinfo=timezone.utc) # Already expired + self.expire_time = datetime( + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc + ) # Already expired self.manual_update = False self.history_upload_complete = False self.outside_range_flag = False self.id_to_column_name_lookup = { climate_entity_id: const.DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY, heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, - external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE} - LOGGER.debug(f'{self.user_hash = }') + external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, + } + LOGGER.debug(f"{self.user_hash = }") # Entity ids will be None if they are optional and not enabled self.active_entity_ids = [] - for entity_id in [climate_entity_id, heat_pump_power_entity_id, external_temp_entity_id]: + for entity_id in [ + climate_entity_id, + heat_pump_power_entity_id, + external_temp_entity_id, + ]: if entity_id is not None: self.active_entity_ids.append(entity_id) @@ -346,9 +391,7 @@ def get_missing_histories_boundary(self, history_states, dynamo_date): def get_missing_old_histories_states(self, history_states, column): """Get states that are older than anything in dynamo.""" dynamo_date = self.dynamo_oldest_dates[column] - idx_bound = self.get_missing_histories_boundary( - history_states, - dynamo_date) + idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) return history_states[:idx_bound] def get_missing_new_histories_states(self, history_states, column): @@ -356,16 +399,15 @@ def get_missing_new_histories_states(self, history_states, column): dynamo_date = self.dynamo_newest_dates[column] if dynamo_date is None: # No data in dynamo - upload first x days - dynamo_date = datetime.now(tz=timezone.utc) - timedelta(days=const.HISTORY_DAYS) - idx_bound = self.get_missing_histories_boundary( - history_states, - dynamo_date) + dynamo_date = datetime.now(tz=timezone.utc) - timedelta( + days=const.HISTORY_DAYS + ) + idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) if idx_bound == len(history_states) - 1: error = True else: error = False - return history_states[idx_bound+1:], error - + return history_states[idx_bound + 1 :], error async def upload_new_history(self, missing_entities): """Upload section of new history states that are newer than anything in dynamo. @@ -376,48 +418,71 @@ async def upload_new_history(self, missing_entities): """ histories = {} constant_attributes = {} + async def debug_check_history_length(days): history_states = await history.get_state_changes( - self.hass, - active_entity_id, - days) - LOGGER.debug(f'---------- days: {days} ----------') - LOGGER.debug(f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') - LOGGER.debug(f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') + self.hass, active_entity_id, days + ) + LOGGER.debug(f"---------- days: {days} ----------") + LOGGER.debug( + f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) history_states = await history.get_state_changes_period( - self.hass, - active_entity_id, - days) - LOGGER.debug(f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') - LOGGER.debug(f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') + self.hass, active_entity_id, days + ) + LOGGER.debug( + f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) for active_entity_id in missing_entities: column = self.id_to_column_name_lookup[active_entity_id] history_states = await history.get_state_changes( - self.hass, - active_entity_id, - const.DYNAMO_HISTORY_DAYS) - missing_new_histories_states, error = self.get_missing_new_histories_states(history_states, column) + self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS + ) + missing_new_histories_states, error = self.get_missing_new_histories_states( + history_states, column + ) if error: - raise RuntimeError('No missing history data to upload, should not have gotten here') + raise RuntimeError( + "No missing history data to upload, should not have gotten here" + ) - LOGGER.debug(f' column: {column}') + LOGGER.debug(f" column: {column}") if len(missing_new_histories_states) == 0: - LOGGER.debug(f' ({column}) - Upload complete') + LOGGER.debug(f" ({column}) - Upload complete") continue - LOGGER.debug(f' len(missing_new_histories_states): {len(missing_new_histories_states)}') - missing_new_histories_states = missing_new_histories_states[:const.MAX_UPLOAD_HISTORY_READINGS] - LOGGER.debug(f' len(missing_new_histories_states): {len(missing_new_histories_states)}') - LOGGER.debug(f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') - LOGGER.debug(f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}') - - histories[column], constant_attributes[column] = history.states_to_histories( - self.hass, - column, - missing_new_histories_states) + LOGGER.debug( + f" len(missing_new_histories_states): {len(missing_new_histories_states)}" + ) + missing_new_histories_states = missing_new_histories_states[ + : const.MAX_UPLOAD_HISTORY_READINGS + ] + LOGGER.debug( + f" len(missing_new_histories_states): {len(missing_new_histories_states)}" + ) + LOGGER.debug( + f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + + histories[column], constant_attributes[column] = ( + history.states_to_histories( + self.hass, column, missing_new_histories_states + ) + ) if histories == {}: - raise RuntimeError('Should not have gotten here! No missing history data to upload') + raise RuntimeError( + "Should not have gotten here! No missing history data to upload" + ) dynamo_data = history.histories_to_dynamo_data( self.hass, histories, @@ -425,8 +490,12 @@ async def debug_check_history_length(days): self.user_hash, self.climate_entity_id, self.postcode, - self.tariff) - self.dynamo_oldest_dates, self.dynamo_newest_dates = await self.client.upload_history(dynamo_data) + self.tariff, + ) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.upload_history(dynamo_data) async def upload_old_history(self): """Upload section of old history states that are older than anything in dynamo. @@ -435,31 +504,37 @@ async def upload_old_history(self): uploaded. const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. """ - LOGGER.debug('Uploading portion of old history...') + LOGGER.debug("Uploading portion of old history...") histories = {} constant_attributes = {} for active_entity_id in self.active_entity_ids: column = self.id_to_column_name_lookup[active_entity_id] history_states = await history.get_state_changes( - self.hass, - active_entity_id, - const.DYNAMO_HISTORY_DAYS) - missing_old_histories_states = self.get_missing_old_histories_states(history_states, column) + self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS + ) + missing_old_histories_states = self.get_missing_old_histories_states( + history_states, column + ) - LOGGER.debug(f' column: {column}') + LOGGER.debug(f" column: {column}") if len(missing_old_histories_states) == 0: - LOGGER.debug(f' ({column}) - Upload complete') + LOGGER.debug(f" ({column}) - Upload complete") continue - LOGGER.debug(f' len(missing_old_histories_states): {len(missing_old_histories_states)}') - missing_old_histories_states = missing_old_histories_states[-const.MAX_UPLOAD_HISTORY_READINGS:] - - histories[column], constant_attributes[column] = history.states_to_histories( - self.hass, - column, - missing_old_histories_states) + LOGGER.debug( + f" len(missing_old_histories_states): {len(missing_old_histories_states)}" + ) + missing_old_histories_states = missing_old_histories_states[ + -const.MAX_UPLOAD_HISTORY_READINGS : + ] + + histories[column], constant_attributes[column] = ( + history.states_to_histories( + self.hass, column, missing_old_histories_states + ) + ) if histories == {}: self.history_upload_complete = True - LOGGER.debug('History upload complete, recalculate heating profile...\n') + LOGGER.debug("History upload complete, recalculate heating profile...\n") # Now that we have all the history, recalculate heating profile self.manual_update = True return @@ -470,8 +545,12 @@ async def upload_old_history(self): self.user_hash, self.climate_entity_id, self.postcode, - self.tariff) - self.dynamo_oldest_dates, self.dynamo_newest_dates = await self.client.upload_history(dynamo_data) + self.tariff, + ) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.upload_history(dynamo_data) async def __call__(self, lambda_args): """Return lambda data for the current time. @@ -479,6 +558,8 @@ async def __call__(self, lambda_args): Calls lambda if new heating profile is needed Otherwise, slowly uploads historical data """ + print("--------------------------------------") + print(lambda_args.keys()) now = datetime.now(tz=timezone.utc) # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: @@ -490,16 +571,22 @@ async def __call__(self, lambda_args): async def update_dynamo_dates(self): """Call the lambda function and get the oldest and newest dates in dynamodb.""" - self.dynamo_oldest_dates, self.dynamo_newest_dates = await self.client.get_data_dates( - dynamo_data={'user_hash': self.user_hash}) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.get_data_dates(dynamo_data={"user_hash": self.user_hash}) async def update_ha_dates(self): """Get the oldest and newest dates in HA histories for active_entity_ids.""" - self.ha_oldest_dates, self.ha_newest_dates = await history.get_earliest_and_latest_data_dates( + ( + self.ha_oldest_dates, + self.ha_newest_dates, + ) = await history.get_earliest_and_latest_data_dates( hass=self.hass, climate_entity_id=self.climate_entity_id, heat_pump_power_entity_id=self.heat_pump_power_entity_id, - external_temp_entity_id=self.external_temp_entity_id) + external_temp_entity_id=self.external_temp_entity_id, + ) def entities_with_data_missing_from_dynamo(self): """Return entities with new data that needs to be uploaded. @@ -508,22 +595,30 @@ def entities_with_data_missing_from_dynamo(self): return those entities. """ entities_missing = [] - LOGGER.debug('---entities_with_data_missing_from_dynamo---') + LOGGER.debug("---entities_with_data_missing_from_dynamo---") for active_entity_id in self.active_entity_ids: column = self.id_to_column_name_lookup[active_entity_id] if self.dynamo_newest_dates[column] is None: # First run, therefore data is missing - LOGGER.debug(f'First run, upload ({const.HISTORY_DAYS}) days of history...\n') + LOGGER.debug( + f"First run, upload ({const.HISTORY_DAYS}) days of history...\n" + ) entities_missing.append(active_entity_id) continue if self.dynamo_newest_dates[column] < self.ha_newest_dates[column]: - LOGGER.debug(f'self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}') - LOGGER.debug(f'self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}') - LOGGER.debug(f' column: {column}') - LOGGER.debug(f' dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}') + LOGGER.debug( + f"self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}" + ) + LOGGER.debug( + f"self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}" + ) + LOGGER.debug(f" column: {column}") + LOGGER.debug( + f" dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}" + ) entities_missing.append(active_entity_id) return entities_missing - #return False + # return False async def call_lambda(self, lambda_args): """Fetch heating profile from AWS Lambda. @@ -532,22 +627,22 @@ async def call_lambda(self, lambda_args): If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. Records the when the heating profile expires and should be refreshed. """ - LOGGER.debug(f'********** self.expire_time: {self.expire_time}') + LOGGER.debug(f"********** self.expire_time: {self.expire_time}") count = 0 await self.update_dynamo_dates() await self.update_ha_dates() while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 - LOGGER.debug(f'Updating dynamo with NEW data: round ({count})') + LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") await self.upload_new_history(missing_entities) - LOGGER.debug('Upload of new history complete\n') + LOGGER.debug("Upload of new history complete\n") self.lambda_results = await self.client.async_get_profile(lambda_args) self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] # The backend will currently only update upon a new day. FIX! self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) - LOGGER.debug(f'---------- self.expire_time: {self.expire_time}') + LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") self.manual_update = False def get_closest_time(self, lambda_args): @@ -556,18 +651,23 @@ def get_closest_time(self, lambda_args): const.LAMBDA_BASE_DEMAND, const.LAMBDA_PRICE, const.LAMBDA_TEMP_CONTROLS, - const.LAMBDA_OPTIMISED_DEMAND] + const.LAMBDA_OPTIMISED_DEMAND, + ] non_time_based_keys = [ const.LAMBDA_BASE_COST, const.LAMBDA_OPTIMISED_COST, - const.LAMBDA_PROJECTED_PERCENT_SAVINGS] + const.LAMBDA_PROJECTED_PERCENT_SAVINGS, + ] # Convert lists to {datetime: list_element} my_data = {} for key in time_based_keys: my_data[key] = { - self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[key][i] - for i in range(len(self.lambda_results[key]))} + self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[ + key + ][i] + for i in range(len(self.lambda_results[key])) + } for key in non_time_based_keys: my_data[key] = self.lambda_results[key] @@ -588,10 +688,12 @@ def get_closest_time(self, lambda_args): # requested out[const.LAMBDA_TEMP_CONTROLS] = lambda_args[const.LAMBDA_SET_POINT] self.outside_range_flag = True - LOGGER.debug(f'initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})') + LOGGER.debug( + f"initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})" + ) elif self.outside_range_flag: # We have just entered the temp_range! The optimisation can now be run - LOGGER.debug('Temperature range reached') + LOGGER.debug("Temperature range reached") self.manual_update = True self.outside_range_flag = False return out From 869496b5e561ba8b1e80739f88b6e7f405fee03b Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 08:53:39 +0200 Subject: [PATCH 06/52] chore: add location into optispark backend --- custom_components/optispark/__init__.py | 40 ++++++----- custom_components/optispark/api.py | 72 +++++++++---------- .../optispark/config/config.json | 8 +++ custom_components/optispark/config_flow.py | 7 +- .../optispark/configuration_service.py | 49 +++++++++++++ custom_components/optispark/const.py | 5 ++ custom_components/optispark/coordinator.py | 25 +++++-- .../optispark/{rest => domain}/__init__.py | 0 .../{rest => domain}/auth/__init__.py | 0 .../{rest => domain}/auth/auth_service.py | 4 +- .../{rest => domain}/auth/model/__init__.py | 0 .../auth/model/login_response.py | 0 .../{rest => domain}/exception/__init__.py | 0 .../{rest => domain}/exception/exceptions.py | 10 ++- .../domain/location/location_service.py | 58 +++++++++++++++ .../location/model/__init__.py} | 0 .../domain/location/model/location_request.py | 39 ++++++++++ .../optispark/rest/electricity/__init__.py | 12 ---- .../optispark/translations/en.json | 1 + 19 files changed, 254 insertions(+), 76 deletions(-) create mode 100644 custom_components/optispark/config/config.json create mode 100644 custom_components/optispark/configuration_service.py rename custom_components/optispark/{rest => domain}/__init__.py (100%) rename custom_components/optispark/{rest => domain}/auth/__init__.py (100%) rename custom_components/optispark/{rest => domain}/auth/auth_service.py (86%) rename custom_components/optispark/{rest => domain}/auth/model/__init__.py (100%) rename custom_components/optispark/{rest => domain}/auth/model/login_response.py (100%) rename custom_components/optispark/{rest => domain}/exception/__init__.py (100%) rename custom_components/optispark/{rest => domain}/exception/exceptions.py (84%) create mode 100644 custom_components/optispark/domain/location/location_service.py rename custom_components/optispark/{rest/electricity/electricity_service.py => domain/location/model/__init__.py} (100%) create mode 100644 custom_components/optispark/domain/location/model/location_request.py delete mode 100644 custom_components/optispark/rest/electricity/__init__.py diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index 45225c6..5815868 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -3,6 +3,7 @@ For more details about this integration, please refer to https://github.com/Big-Tree/HomeAssistant-OptiSpark """ + from __future__ import annotations from homeassistant.config_entries import ConfigEntry @@ -16,7 +17,7 @@ Platform.SENSOR, Platform.SWITCH, Platform.NUMBER, - Platform.CLIMATE + Platform.CLIMATE, ] @@ -24,19 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" from .coordinator import OptisparkDataUpdateCoordinator # Prevent circular import + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator = OptisparkDataUpdateCoordinator( hass=hass, - client=OptisparkApiClient( - session=async_get_clientsession(hass)), - climate_entity_id=entry.data['climate_entity_id'], - heat_pump_power_entity_id=entry.data['heat_pump_power_entity_id'], - external_temp_entity_id=entry.data['external_temp_entity_id'], - user_hash=entry.data['user_hash'], - postcode=entry.data['postcode'], - tariff=entry.data['tariff'], - address=entry.data['address'], - country=entry.data['country'], + client=OptisparkApiClient(session=async_get_clientsession(hass)), + climate_entity_id=entry.data["climate_entity_id"], + heat_pump_power_entity_id=entry.data["heat_pump_power_entity_id"], + external_temp_entity_id=entry.data["external_temp_entity_id"], + user_hash=entry.data["user_hash"], + postcode=entry.data["postcode"], + tariff=entry.data["tariff"], + address=entry.data["address"], + city=entry.data["city"], + country=entry.data["country"], ) # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities await coordinator.async_config_entry_first_refresh() @@ -79,17 +81,19 @@ def get_entity(hass, entity_id): entities_found = [] successful_domains = [] for domain in hass.data: - if hasattr(hass.data[domain], 'get_entity'): + if hasattr(hass.data[domain], "get_entity"): entity = hass.data[domain].get_entity(entity_id) if entity is not None: entities_found.append(entity) successful_domains.append(domain) if len(entities_found) != 1: - LOGGER.error(f'({len(entities_found)}) entities found instead of 1') - LOGGER.error(f'successful_domains:\n {successful_domains}') - LOGGER.error(f'entities_found:\n {entities_found}') - LOGGER.error(f'hass.data.keys():\n {hass.data.keys()}') - raise OptisparkGetEntityError(f'({len(entities_found)}) entities found instead of 1') + LOGGER.error(f"({len(entities_found)}) entities found instead of 1") + LOGGER.error(f"successful_domains:\n {successful_domains}") + LOGGER.error(f"entities_found:\n {entities_found}") + LOGGER.error(f"hass.data.keys():\n {hass.data.keys()}") + raise OptisparkGetEntityError( + f"({len(entities_found)}) entities found instead of 1" + ) return entities_found[0] @@ -99,6 +103,6 @@ def get_username(hass): Surely there is a better way than this. """ try: - return list(hass.data['person'][1].data.keys())[0] + return list(hass.data["person"][1].data.keys())[0] except Exception: return None diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 629c3b9..9194791 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -12,13 +12,17 @@ import pickle import gzip import base64 -from .const import LOGGER +from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE import traceback from http import HTTPStatus -from .rest.auth.auth_service import AuthService -from .rest.auth.model.login_response import LoginResponse -from .rest.exception.exceptions import * +from .domain.auth.auth_service import AuthService +from .domain.auth.model.login_response import LoginResponse +from .domain.exception.exceptions import * +from .domain.location.location_service import LocationService +from .domain.location.model.location_request import ( + LocationRequest, +) # class OptisparkApiClientError(Exception): @@ -83,7 +87,10 @@ class OptisparkApiClient: """Optispark API Client.""" _token: str | None + _has_locations: bool + _has_devices: bool _auth_service: AuthService + _location_service: LocationService def __init__( self, @@ -92,7 +99,11 @@ def __init__( """Sample API Client.""" self._session = session self._token = None + self._has_locations = False + self._has_devices = False self._auth_service = AuthService(session=session) + self._location_service = LocationService(session=session) + # self._config_service = ConfigurationService(config_file='./config/config.json') def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -203,38 +214,6 @@ def json_deserialise(self, payload): payload = pickle.loads(payload) return payload - # async def _login(self, user_hash: str) -> LoginResponse: - # # TODO: move to config - # auth_url = "http://localhost:5000/auth/ha_login" - # try: - # payload = {"user_hash": user_hash} - # response = await self._session.request( - # method="post", - # url=auth_url, - # json=payload, - # ) - # - # if response.status != HTTPStatus.OK: - # raise OptisparkApiClientAuthenticationError( - # "Invalid credentials", - # ) from Exception - # - # json_response = await response.json() - # - # return LoginResponse( - # token=json_response["accessToken"], - # token_type=json_response["tokenType"], - # has_locations=json_response["hasLocations"], - # has_devices=json_response["hasDevices"], - # ) - # - # except: - # raise OptisparkApiClientAuthenticationError( - # "Invalid credentials", - # ) from Exception - - # async def _add_location(self): - async def _api_wrapper(self, method: str, url: str, data: dict): """Call the Lambda function.""" # ha: core.HomeAssistant = core.async_get_hass() @@ -260,8 +239,29 @@ async def _api_wrapper(self, method: str, url: str, data: dict): user_hash="user_hash" ) self._token = loginResponse.token + self._has_locations = loginResponse.has_locations + self._has_devices = loginResponse.has_devices LOGGER.debug(f" User token: {loginResponse.token}") + if not self._has_locations: + info = data["dynamo_data"] + location_request = LocationRequest( + name="home", + address=info["address"], + zipcode=info["postcode"], + city="", + country="GB", + tariff_id=1, + tariff_params={ + "product_code": TARIFF_PRODUCT_CODE, + "tariff_code": TARIFF_CODE, + } + ) + self._has_locations = await self._location_service.add_location( + request=location_request, + access_token=self._token + ) + response = await self._session.request( method=method, url=url, diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json new file mode 100644 index 0000000..b85c07a --- /dev/null +++ b/custom_components/optispark/config/config.json @@ -0,0 +1,8 @@ +{ + "backend": { + "baseUrl": "http://localhost:5000", + "location": { + "base": "/location/" + } + } +} \ No newline at end of file diff --git a/custom_components/optispark/config_flow.py b/custom_components/optispark/config_flow.py index 83b2580..6ee13cd 100644 --- a/custom_components/optispark/config_flow.py +++ b/custom_components/optispark/config_flow.py @@ -78,9 +78,11 @@ async def async_step_heat_pump_details(self, user_input: dict | None = None) -> await self.test_units(user_input['heat_pump_power_entity_id']) user_input['postcode'] = postcode # Fix postcode formating user_input['address'] = user_input['address'] + user_input['city'] = user_input['city'] else: user_input['postcode'] = None user_input['address'] = None + user_input['city'] = None if 'external_temp_entity_id' not in user_input: user_input['external_temp_entity_id'] = None @@ -108,16 +110,19 @@ async def async_step_heat_pump_details(self, user_input: dict | None = None) -> self.hass.config.latitude, self.hass.config.longitude)) postcode = location.raw['address']['postcode'] - address = location.raw['address']['road'] + ', ' + location.raw['address']['city'] + address = location.raw['address']['road'] + city = location.raw['address']['city'] if postcode == '' or postcode is None: raise OptisparkApiClientPostcodeError() except Exception as err: LOGGER.warning(err) postcode = '' address = '' + city = '' errors["base"] = "postcode_homeassistant" data_schema[vol.Required('postcode', default=postcode)] = str data_schema[vol.Required('address', default=address)] = str + data_schema[vol.Required('city', default=city)] = str data_schema[vol.Required("climate_entity_id")] = selector({ "entity": { diff --git a/custom_components/optispark/configuration_service.py b/custom_components/optispark/configuration_service.py new file mode 100644 index 0000000..e2f004a --- /dev/null +++ b/custom_components/optispark/configuration_service.py @@ -0,0 +1,49 @@ +import json +import os + + +class ConfigurationService: + _instance = None + _initialized = False + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(ConfigurationService, cls).__new__(cls) + return cls._instance + + def __init__(self, config_file=None): + if config_file and not ConfigurationService._initialized: + self.config_file = config_file + self.config_data = self._load_config() + ConfigurationService._initialized = True + + def _load_config(self): + script_path = os.path.abspath(__file__) + parent_dir = os.path.dirname(script_path) + file_path = os.path.join(parent_dir, self.config_file) + try: + with open(file_path, "r") as file: + return json.load(file) + except FileNotFoundError: + print(f"Error: The configuration file {self.config_file} was not found.") + return {} + except json.JSONDecodeError: + print( + f"Error: The configuration file {self.config_file} is not a valid JSON." + ) + return {} + + def get(self, path): + if not ConfigurationService._initialized: + raise Exception("ConfigurationService must be initialized with a config file before use.") + + keys = path.split('.') + data = self.config_data + for key in keys: + data = data.get(key, {}) + if data == {}: + break + return data + + +config_service = ConfigurationService(config_file='./config/config.json') \ No newline at end of file diff --git a/custom_components/optispark/const.py b/custom_components/optispark/const.py index 12f2d91..0eb4847 100644 --- a/custom_components/optispark/const.py +++ b/custom_components/optispark/const.py @@ -21,6 +21,8 @@ LAMBDA_SET_POINT = 'temp_set_point' LAMBDA_TEMP_RANGE = 'temp_range' LAMBDA_POSTCODE = 'postcode' +LAMBDA_ADDRESS = 'address' +LAMBDA_CITY = 'city' LAMBDA_USER_HASH = 'user_hash' LAMBDA_INITIAL_INTERNAL_TEMP = 'initial_internal_temp' LAMBDA_OUTSIDE_RANGE = 'outside_range' @@ -34,3 +36,6 @@ DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY = 'climate_entity' SWITCH_KEY = 'enable_optispark' + +TARIFF_PRODUCT_CODE = "AGILE-FLEX-22-11-25" +TARIFF_CODE = "E-1R-AGILE-FLEX-22-11-25-A" diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index a813cfe..7d3660c 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -47,6 +47,7 @@ def __init__( postcode: str, tariff: str, address: str, + city: str, country: str, ) -> None: """Initialize.""" @@ -60,6 +61,7 @@ def __init__( self._postcode = postcode if postcode is not None else "AB11 6LU" self._tariff = tariff self._address = address + self._city = city self._country = country # user_hash = 'debug_hash' self._user_hash = user_hash @@ -78,6 +80,7 @@ def __init__( const.LAMBDA_HEAT_PUMP_MODE_RAW: "HEATING", const.LAMBDA_HOME_ASSISTANT_VERSION: const.VERSION, const.LAMBDA_ADDRESS: self._address, + const.LAMBDA_CITY: self._city, } self._lambda_update_handler = LambdaUpdateHandler( hass=self.hass, @@ -88,6 +91,7 @@ def __init__( user_hash=self._user_hash, postcode=self._postcode, address=self._address, + city=self._city, tariff=self._tariff, ) @@ -347,6 +351,8 @@ def __init__( external_temp_entity_id, user_hash, postcode, + address, + city, tariff, ): """Init.""" @@ -357,6 +363,8 @@ def __init__( self.external_temp_entity_id = external_temp_entity_id self.user_hash = user_hash self.postcode = postcode + self.address = address + self.city = city self.tariff = tariff self.expire_time = datetime( 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc @@ -558,8 +566,6 @@ async def __call__(self, lambda_args): Calls lambda if new heating profile is needed Otherwise, slowly uploads historical data """ - print("--------------------------------------") - print(lambda_args.keys()) now = datetime.now(tz=timezone.utc) # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: @@ -569,12 +575,21 @@ async def __call__(self, lambda_args): await self.upload_old_history() return self.get_closest_time(lambda_args) - async def update_dynamo_dates(self): + async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" + # print(lambda_args.keys()) + dynamo_data = { + "user_hash": self.user_hash, + "postcode": lambda_args["postcode"], + "address": lambda_args["address"], + "city": lambda_args["city"], + } + print("-----------------------------------------") + print(dynamo_data) ( self.dynamo_oldest_dates, self.dynamo_newest_dates, - ) = await self.client.get_data_dates(dynamo_data={"user_hash": self.user_hash}) + ) = await self.client.get_data_dates(dynamo_data=dynamo_data) async def update_ha_dates(self): """Get the oldest and newest dates in HA histories for active_entity_ids.""" @@ -629,7 +644,7 @@ async def call_lambda(self, lambda_args): """ LOGGER.debug(f"********** self.expire_time: {self.expire_time}") count = 0 - await self.update_dynamo_dates() + await self.update_dynamo_dates(lambda_args) await self.update_ha_dates() while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 diff --git a/custom_components/optispark/rest/__init__.py b/custom_components/optispark/domain/__init__.py similarity index 100% rename from custom_components/optispark/rest/__init__.py rename to custom_components/optispark/domain/__init__.py diff --git a/custom_components/optispark/rest/auth/__init__.py b/custom_components/optispark/domain/auth/__init__.py similarity index 100% rename from custom_components/optispark/rest/auth/__init__.py rename to custom_components/optispark/domain/auth/__init__.py diff --git a/custom_components/optispark/rest/auth/auth_service.py b/custom_components/optispark/domain/auth/auth_service.py similarity index 86% rename from custom_components/optispark/rest/auth/auth_service.py rename to custom_components/optispark/domain/auth/auth_service.py index 6fb3838..29e9e41 100644 --- a/custom_components/optispark/rest/auth/auth_service.py +++ b/custom_components/optispark/domain/auth/auth_service.py @@ -2,8 +2,8 @@ import aiohttp -from custom_components.optispark.rest.auth.model.login_response import LoginResponse -from custom_components.optispark.rest.exception.exceptions import OptisparkApiClientAuthenticationError +from custom_components.optispark.domain.auth.model.login_response import LoginResponse +from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError class AuthService: diff --git a/custom_components/optispark/rest/auth/model/__init__.py b/custom_components/optispark/domain/auth/model/__init__.py similarity index 100% rename from custom_components/optispark/rest/auth/model/__init__.py rename to custom_components/optispark/domain/auth/model/__init__.py diff --git a/custom_components/optispark/rest/auth/model/login_response.py b/custom_components/optispark/domain/auth/model/login_response.py similarity index 100% rename from custom_components/optispark/rest/auth/model/login_response.py rename to custom_components/optispark/domain/auth/model/login_response.py diff --git a/custom_components/optispark/rest/exception/__init__.py b/custom_components/optispark/domain/exception/__init__.py similarity index 100% rename from custom_components/optispark/rest/exception/__init__.py rename to custom_components/optispark/domain/exception/__init__.py diff --git a/custom_components/optispark/rest/exception/exceptions.py b/custom_components/optispark/domain/exception/exceptions.py similarity index 84% rename from custom_components/optispark/rest/exception/exceptions.py rename to custom_components/optispark/domain/exception/exceptions.py index 1d800c1..aff1927 100644 --- a/custom_components/optispark/rest/exception/exceptions.py +++ b/custom_components/optispark/domain/exception/exceptions.py @@ -1,4 +1,3 @@ - __all__ = [ "OptisparkApiClientError", "OptisparkApiClientTimeoutError", @@ -6,8 +5,11 @@ "OptisparkApiClientAuthenticationError", "OptisparkApiClientLambdaError", "OptisparkApiClientPostcodeError", - "OptisparkApiClientUnitError" + "OptisparkApiClientUnitError", + "OptisparkApiClientLocationError" ] + + class OptisparkApiClientError(Exception): """Exception to indicate a general API error.""" @@ -34,3 +36,7 @@ class OptisparkApiClientPostcodeError(OptisparkApiClientError): class OptisparkApiClientUnitError(OptisparkApiClientError): """Exception to indicate unit error.""" + + +class OptisparkApiClientLocationError(OptisparkApiClientError): + """Exception to indicate an location error.""" diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/domain/location/location_service.py new file mode 100644 index 0000000..b632df8 --- /dev/null +++ b/custom_components/optispark/domain/location/location_service.py @@ -0,0 +1,58 @@ +from http import HTTPStatus + +import aiohttp + +from custom_components.optispark.configuration_service import config_service +from custom_components.optispark.domain.exception.exceptions import ( + OptisparkApiClientAuthenticationError, + OptisparkApiClientLocationError, +) +from custom_components.optispark.domain.location.model.location_request import ( + LocationRequest, +) + + +class LocationService: + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + + async def add_location(self, request: LocationRequest, access_token: str) -> bool: + """Add new location""" + + base_url = config_service.get("backend.baseUrl") + location_url = f'{base_url}{config_service.get("backend.location.base")}' + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + try: + response = await self._session.request( + method="post", + url=location_url, + headers=headers, + json=request.payload(), + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientLocationError( + "Add location error", + ) from Exception + + return True + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientLocationError("Add location error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise diff --git a/custom_components/optispark/rest/electricity/electricity_service.py b/custom_components/optispark/domain/location/model/__init__.py similarity index 100% rename from custom_components/optispark/rest/electricity/electricity_service.py rename to custom_components/optispark/domain/location/model/__init__.py diff --git a/custom_components/optispark/domain/location/model/location_request.py b/custom_components/optispark/domain/location/model/location_request.py new file mode 100644 index 0000000..7cb45a2 --- /dev/null +++ b/custom_components/optispark/domain/location/model/location_request.py @@ -0,0 +1,39 @@ +class LocationRequest: + name: str + address: str + zipcode: str + city: str + country: str + tariff_id: int + tariff_params: dict + + def __init__( + self, + name: str, + address: str, + zipcode: str, + city: str, + country: str, + tariff_id: int, + tariff_params: dict, + ): + self.name = name + self.address = address + self.zipcode = zipcode + self.city = city + self.country = country + self.tariff_id = tariff_id + self.tariff_params = tariff_params + + def payload(self) -> dict: + return { + "name": self.name, + "address": { + "address": self.address, + "zipcode": self.zipcode, + "city": self.city, + "country": self.country, + }, + "tariffId": self.tariff_id, + "tariffParams": self.tariff_params, + } diff --git a/custom_components/optispark/rest/electricity/__init__.py b/custom_components/optispark/rest/electricity/__init__.py deleted file mode 100644 index 5373d5d..0000000 --- a/custom_components/optispark/rest/electricity/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import aiohttp - - -class ElectrityService: - - def __init__( - self, - session: aiohttp.ClientSession, - ) -> None: - """Sample API Client.""" - self._session = session - diff --git a/custom_components/optispark/translations/en.json b/custom_components/optispark/translations/en.json index 6baf83f..a066d4d 100644 --- a/custom_components/optispark/translations/en.json +++ b/custom_components/optispark/translations/en.json @@ -16,6 +16,7 @@ "username": "Username", "address": "Address", "postcode": "Postcode", + "city": "City", "climate_entity_id": "Heat pump", "heat_pump_power_entity_id": "Power usage of heat pump", "external_temp_entity_id": "(Optional) External house temperature" From 5bd0b2e0e8ba7e9dbba1a2d95b2369e89788786b Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 15:47:07 +0200 Subject: [PATCH 07/52] wip: add device --- custom_components/optispark/api.py | 3 +- .../optispark/config/config.json | 3 + .../optispark/domain/device/__init__.py | 0 .../optispark/domain/device/device_service.py | 55 +++++++++++++++++++ .../optispark/domain/device/model/__init__.py | 0 .../domain/device/model/device_request.py | 37 +++++++++++++ .../domain/device/model/device_response.py | 45 +++++++++++++++ .../optispark/domain/exception/exceptions.py | 4 ++ .../domain/location/location_service.py | 6 +- .../location/model/location_response.py | 50 +++++++++++++++++ 10 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 custom_components/optispark/domain/device/__init__.py create mode 100644 custom_components/optispark/domain/device/device_service.py create mode 100644 custom_components/optispark/domain/device/model/__init__.py create mode 100644 custom_components/optispark/domain/device/model/device_request.py create mode 100644 custom_components/optispark/domain/device/model/device_response.py create mode 100644 custom_components/optispark/domain/location/model/location_response.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 9194791..d57a37e 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -257,10 +257,11 @@ async def _api_wrapper(self, method: str, url: str, data: dict): "tariff_code": TARIFF_CODE, } ) - self._has_locations = await self._location_service.add_location( + location_response = await self._location_service.add_location( request=location_request, access_token=self._token ) + self.has_location = True if location_response else False response = await self._session.request( method=method, diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index b85c07a..3fe0dbf 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -3,6 +3,9 @@ "baseUrl": "http://localhost:5000", "location": { "base": "/location/" + }, + "device": { + "base": "/device/" } } } \ No newline at end of file diff --git a/custom_components/optispark/domain/device/__init__.py b/custom_components/optispark/domain/device/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/device/device_service.py b/custom_components/optispark/domain/device/device_service.py new file mode 100644 index 0000000..906b3fb --- /dev/null +++ b/custom_components/optispark/domain/device/device_service.py @@ -0,0 +1,55 @@ +from http import HTTPStatus + +import aiohttp +from aiohttp import ClientResponse + +from custom_components.optispark.configuration_service import config_service +from custom_components.optispark.domain.device.model.device_request import DeviceRequest +from custom_components.optispark.domain.device.model.device_response import DeviceResponse +from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ + OptisparkApiClientDeviceError + + +class DeviceService: + + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + + async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceResponse | None: + base_url = config_service.get("backend.baseUrl") + device_url = f'{base_url}{config_service.get("backend.device.base")}' + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + try: + response: ClientResponse = await self._session.request( + method="post", + url=device_url, + headers=headers, + json=request.payload(), + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientDeviceError( + "Add device error", + ) from Exception + + jsonResponse = await response.json() + return DeviceResponse.from_json(jsonResponse) + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientDeviceError("Add device error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise diff --git a/custom_components/optispark/domain/device/model/__init__.py b/custom_components/optispark/domain/device/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/device/model/device_request.py b/custom_components/optispark/domain/device/model/device_request.py new file mode 100644 index 0000000..5cc2173 --- /dev/null +++ b/custom_components/optispark/domain/device/model/device_request.py @@ -0,0 +1,37 @@ +class DeviceRequest: + name: str + location_id: int + manufacturer: str + model_name: str + version: str + integration_type: str + integration_params: dict + + def __init__( + self, name: str, + location_id: int, + manufacturer: str, + model_name: str, + version: str, + integration_type: str, + integration_params: dict + ): + self.name = name + self.location_id = location_id + self.manufacturer = manufacturer + self.model_name = model_name + self.version = version + self.integration_type = integration_type + self.integration_params = integration_params + + def payload(self) -> dict: + return { + "name": self.name, + "locationId": self.location_id, + "manufacturer": self.manufacturer, + "modelname": self.model_name, + "version": self.version, + "integrationType": self.integration_type, + "integrationParams": self.integration_params + } + diff --git a/custom_components/optispark/domain/device/model/device_response.py b/custom_components/optispark/domain/device/model/device_response.py new file mode 100644 index 0000000..bcfcb28 --- /dev/null +++ b/custom_components/optispark/domain/device/model/device_response.py @@ -0,0 +1,45 @@ +class DeviceResponse: + id: int + name: str + location_id: int + manufacturer: str + model_name: str + version: str + integration_type: str + integration_params: dict + + def __init__( + self, + id: int, + name: str, + location_id: int, + manufacturer: str, + model_name: str, + version: str, + integration_type: str, + integration_params: dict + ): + self.id = id + self.name = name + self.location_id = location_id + self.manufacturer = manufacturer + self.model_name = model_name + self.version = version + self.integration_type = integration_type + self.integration_params = integration_params + + @classmethod + def from_json(cls, json: dict): + try: + return cls( + id=json["id"], + name=json["name"], + location_id=json["location"], + manufacturer=json["manufacturer"], + model_name=json["modelname"], + version=json["version"], + integration_type=json["integrationType"], + integration_params=json["integrationParams"] + ) + except: + return None diff --git a/custom_components/optispark/domain/exception/exceptions.py b/custom_components/optispark/domain/exception/exceptions.py index aff1927..863ad8d 100644 --- a/custom_components/optispark/domain/exception/exceptions.py +++ b/custom_components/optispark/domain/exception/exceptions.py @@ -40,3 +40,7 @@ class OptisparkApiClientUnitError(OptisparkApiClientError): class OptisparkApiClientLocationError(OptisparkApiClientError): """Exception to indicate an location error.""" + + +class OptisparkApiClientDeviceError(OptisparkApiClientError): + """Exception to indicate an location error.""" diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/domain/location/location_service.py index b632df8..b9b44f3 100644 --- a/custom_components/optispark/domain/location/location_service.py +++ b/custom_components/optispark/domain/location/location_service.py @@ -10,6 +10,7 @@ from custom_components.optispark.domain.location.model.location_request import ( LocationRequest, ) +from custom_components.optispark.domain.location.model.location_response import LocationResponse class LocationService: @@ -20,7 +21,7 @@ def __init__( """Sample API Client.""" self._session = session - async def add_location(self, request: LocationRequest, access_token: str) -> bool: + async def add_location(self, request: LocationRequest, access_token: str) -> LocationResponse | None: """Add new location""" base_url = config_service.get("backend.baseUrl") @@ -48,7 +49,8 @@ async def add_location(self, request: LocationRequest, access_token: str) -> boo "Add location error", ) from Exception - return True + jsonResponse = await response.json() + return LocationResponse.from_json(jsonResponse) except aiohttp.ClientError as e: print(f"HTTP error occurred: {e}") diff --git a/custom_components/optispark/domain/location/model/location_response.py b/custom_components/optispark/domain/location/model/location_response.py new file mode 100644 index 0000000..1787b8c --- /dev/null +++ b/custom_components/optispark/domain/location/model/location_response.py @@ -0,0 +1,50 @@ +class LocationResponse: + id: int + name: str + address: str + zipcode: str + city: str + country: str + tariff_id: int + tariff_params: dict + thermostat_id: int + + def __init__( + self, + id: int, + name: str, + address: str, + zipcode: str, + city: str, + country: str, + tariff_id: int, + tariff_params: dict, + thermostat_id: int, + ): + self.id = id + self.name = name + self.address = address + self.zipcode = zipcode + self.city = city + self.country = country + self.tariff_id = tariff_id + self.tariff_params = tariff_params + self.thermostat_id = thermostat_id + + @classmethod + def from_json(cls, json: dict): + try: + address = json["address"] + cls( + id=json["id"], + name=json["name"], + address=address["address"], + zipcode=address["zipcode"], + city=address["city"], + country=address["country"], + tariff_id=json["tariffId"], + tariff_params=json["tariffParams"], + thermostat_id=json["thermostatId"], + ) + except: + return None From f1ad5bc32f7db72a58379a6e56548b286e923ed9 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 15:51:03 +0200 Subject: [PATCH 08/52] chore: delete prints --- custom_components/optispark/coordinator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 7d3660c..8389f56 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -584,8 +584,6 @@ async def update_dynamo_dates(self, lambda_args: dict): "address": lambda_args["address"], "city": lambda_args["city"], } - print("-----------------------------------------") - print(dynamo_data) ( self.dynamo_oldest_dates, self.dynamo_newest_dates, From 7933499885a9bc4c0ecd5114ad09340f12836672 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 16:50:30 +0200 Subject: [PATCH 09/52] wip: add device --- custom_components/optispark/api.py | 29 +++++++++++++++++-- .../domain/device/model/device_request.py | 15 +++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index d57a37e..97a0627 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -18,11 +18,14 @@ from .domain.auth.auth_service import AuthService from .domain.auth.model.login_response import LoginResponse +from .domain.device.device_service import DeviceService +from .domain.device.model.device_request import DeviceRequest from .domain.exception.exceptions import * from .domain.location.location_service import LocationService from .domain.location.model.location_request import ( LocationRequest, ) +from .domain.location.model.location_response import LocationResponse # class OptisparkApiClientError(Exception): @@ -103,6 +106,7 @@ def __init__( self._has_devices = False self._auth_service = AuthService(session=session) self._location_service = LocationService(session=session) + self._device_service = DeviceService(session=session) # self._config_service = ConfigurationService(config_file='./config/config.json') def datetime_set_utc(self, d: dict[str, datetime]): @@ -232,6 +236,7 @@ async def _api_wrapper(self, method: str, url: str, data: dict): async with async_timeout.timeout(120): LOGGER.debug(f" Initiating login into OptiSpark backend") + location: LocationResponse | None = None if not self._token: user_hash = data["user_hash"] if user_hash: @@ -257,11 +262,31 @@ async def _api_wrapper(self, method: str, url: str, data: dict): "tariff_code": TARIFF_CODE, } ) - location_response = await self._location_service.add_location( + location = await self._location_service.add_location( request=location_request, access_token=self._token ) - self.has_location = True if location_response else False + self.has_location = True if location else False + + if not self._has_devices: + if not location: + print('NO Location!!!!') + else: + device_request = DeviceRequest( + name='Heat Pump', + location_id=location.id, + manufacturer='ha', + model_name='ha_model', + version='version', + integration_params={} + ) + device_response = await self._device_service.add_device( + request=device_request, + access_token=self._token + ) + + self._has_devices = True if device_response else False + response = await self._session.request( method=method, diff --git a/custom_components/optispark/domain/device/model/device_request.py b/custom_components/optispark/domain/device/model/device_request.py index 5cc2173..679e6e1 100644 --- a/custom_components/optispark/domain/device/model/device_request.py +++ b/custom_components/optispark/domain/device/model/device_request.py @@ -8,20 +8,19 @@ class DeviceRequest: integration_params: dict def __init__( - self, name: str, - location_id: int, - manufacturer: str, - model_name: str, - version: str, - integration_type: str, - integration_params: dict + self, name: str, + location_id: int, + manufacturer: str, + model_name: str, + version: str, + integration_params: dict ): self.name = name self.location_id = location_id self.manufacturer = manufacturer self.model_name = model_name self.version = version - self.integration_type = integration_type + self.integration_type = 'HomeAssistant' self.integration_params = integration_params def payload(self) -> dict: From a7d3c2ee4507d1e1f8f9c10d4813da30b0654f8a Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 16:58:48 +0200 Subject: [PATCH 10/52] chore: cleaning --- custom_components/optispark/api.py | 113 +++++++++++++---------------- 1 file changed, 52 insertions(+), 61 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 97a0627..f0f5cff 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -220,73 +220,13 @@ def json_deserialise(self, payload): async def _api_wrapper(self, method: str, url: str, data: dict): """Call the Lambda function.""" - # ha: core.HomeAssistant = core.async_get_hass() - # print("************************************") - # print(ha.data.keys()) - # - # config = ha.config - # if not config: - # print("NOOOOOOOOOO") - # print(config.latitude) - # print(config.longitude) try: if "dynamo_data" in data: data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) data_serialised = self.json_serialisable(data) async with async_timeout.timeout(120): - LOGGER.debug(f" Initiating login into OptiSpark backend") - location: LocationResponse | None = None - if not self._token: - user_hash = data["user_hash"] - if user_hash: - loginResponse: LoginResponse = await self._auth_service.login( - user_hash="user_hash" - ) - self._token = loginResponse.token - self._has_locations = loginResponse.has_locations - self._has_devices = loginResponse.has_devices - LOGGER.debug(f" User token: {loginResponse.token}") - - if not self._has_locations: - info = data["dynamo_data"] - location_request = LocationRequest( - name="home", - address=info["address"], - zipcode=info["postcode"], - city="", - country="GB", - tariff_id=1, - tariff_params={ - "product_code": TARIFF_PRODUCT_CODE, - "tariff_code": TARIFF_CODE, - } - ) - location = await self._location_service.add_location( - request=location_request, - access_token=self._token - ) - self.has_location = True if location else False - - if not self._has_devices: - if not location: - print('NO Location!!!!') - else: - device_request = DeviceRequest( - name='Heat Pump', - location_id=location.id, - manufacturer='ha', - model_name='ha_model', - version='version', - integration_params={} - ) - device_response = await self._device_service.add_device( - request=device_request, - access_token=self._token - ) - - self._has_devices = True if device_response else False - + await self._login(data) response = await self._session.request( method=method, @@ -334,3 +274,54 @@ async def _api_wrapper(self, method: str, url: str, data: dict): raise OptisparkApiClientError( "Something really wrong happened!" ) from exception + + async def _login(self, data): + LOGGER.debug(f" Initiating login into OptiSpark backend") + location: LocationResponse | None = None + if not self._token: + user_hash = data["user_hash"] + if user_hash: + loginResponse: LoginResponse = await self._auth_service.login( + user_hash="user_hash" + ) + self._token = loginResponse.token + self._has_locations = loginResponse.has_locations + self._has_devices = loginResponse.has_devices + LOGGER.debug(f" User token: {loginResponse.token}") + if not self._has_locations: + info = data["dynamo_data"] + location_request = LocationRequest( + name="home", + address=info["address"], + zipcode=info["postcode"], + city="", + country="GB", + tariff_id=1, + tariff_params={ + "product_code": TARIFF_PRODUCT_CODE, + "tariff_code": TARIFF_CODE, + } + ) + location = await self._location_service.add_location( + request=location_request, + access_token=self._token + ) + self.has_location = True if location else False + if not self._has_devices: + if not location: + print('NO Location!!!!') + else: + device_request = DeviceRequest( + name='Heat Pump', + location_id=location.id, + manufacturer='ha', + model_name='ha_model', + version='version', + integration_params={} + ) + device_response = await self._device_service.add_device( + request=device_request, + access_token=self._token + ) + + self._has_devices = True if device_response else False From e4254639fe77750268beb70ba297bfcd5beadb78 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 17:22:23 +0200 Subject: [PATCH 11/52] chore: get location id from backend if necessary --- custom_components/optispark/api.py | 30 +++++++------- .../domain/location/location_service.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index f0f5cff..ae88ce6 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -309,19 +309,19 @@ async def _login(self, data): self.has_location = True if location else False if not self._has_devices: if not location: - print('NO Location!!!!') - else: - device_request = DeviceRequest( - name='Heat Pump', - location_id=location.id, - manufacturer='ha', - model_name='ha_model', - version='version', - integration_params={} - ) - device_response = await self._device_service.add_device( - request=device_request, - access_token=self._token - ) + location = await self._location_service.get_locations(access_token=self._token) + + device_request = DeviceRequest( + name='Heat Pump', + location_id=location.id, + manufacturer='ha', + model_name='ha_model', + version='version', + integration_params={} + ) + device_response = await self._device_service.add_device( + request=device_request, + access_token=self._token + ) - self._has_devices = True if device_response else False + self._has_devices = True if device_response else False diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/domain/location/location_service.py index b9b44f3..6e52a88 100644 --- a/custom_components/optispark/domain/location/location_service.py +++ b/custom_components/optispark/domain/location/location_service.py @@ -58,3 +58,42 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc except Exception as e: print(f"Unexpected error occurred: {e}") raise + + async def get_locations(self, access_token: str) -> [LocationResponse]: + """Get locations from OptiSpark backend""" + base_url = config_service.get("backend.baseUrl") + location_url = f'{base_url}{config_service.get("backend.location.base")}' + headers = { + "Authorization": f"Bearer {access_token}" + } + + try: + response = await self._session.request( + method="get", + url=location_url, + headers=headers, + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientLocationError( + "Get locations error", + ) from Exception + + jsonResponse = await response.json() + # return LocationResponse.from_json(jsonResponse) + locations = list(map(LocationResponse.from_json, jsonResponse)) + # Filter out any None values in case of invalid JSON elements + return [location for location in locations if location is not None] + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientLocationError("Get locations error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise + From 9b56b5635d693a64317eb481e0a780efa890685e Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 5 Jul 2024 17:48:24 +0200 Subject: [PATCH 12/52] fix: LocationResponse from_json --- custom_components/optispark/api.py | 3 ++- .../optispark/domain/location/location_service.py | 2 +- .../optispark/domain/location/model/location_response.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index ae88ce6..4db9eb6 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -309,7 +309,8 @@ async def _login(self, data): self.has_location = True if location else False if not self._has_devices: if not location: - location = await self._location_service.get_locations(access_token=self._token) + locations: [LocationResponse] = await self._location_service.get_locations(access_token=self._token) + location = locations[0] device_request = DeviceRequest( name='Heat Pump', diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/domain/location/location_service.py index 6e52a88..9f63688 100644 --- a/custom_components/optispark/domain/location/location_service.py +++ b/custom_components/optispark/domain/location/location_service.py @@ -79,7 +79,7 @@ async def get_locations(self, access_token: str) -> [LocationResponse]: "Invalid credentials", ) from Exception - if response.status != HTTPStatus.CREATED: + if response.status != HTTPStatus.OK: raise OptisparkApiClientLocationError( "Get locations error", ) from Exception diff --git a/custom_components/optispark/domain/location/model/location_response.py b/custom_components/optispark/domain/location/model/location_response.py index 1787b8c..fa15136 100644 --- a/custom_components/optispark/domain/location/model/location_response.py +++ b/custom_components/optispark/domain/location/model/location_response.py @@ -35,7 +35,7 @@ def __init__( def from_json(cls, json: dict): try: address = json["address"] - cls( + return cls( id=json["id"], name=json["name"], address=address["address"], From a43c364896ff35c682a6870f0b138f9017a132f6 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Mon, 8 Jul 2024 11:04:58 +0200 Subject: [PATCH 13/52] fix: user_hash and json key in DeviceResponse --- custom_components/optispark/api.py | 9 +++++---- .../optispark/domain/device/model/device_response.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 4db9eb6..dfc92ef 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -20,6 +20,7 @@ from .domain.auth.model.login_response import LoginResponse from .domain.device.device_service import DeviceService from .domain.device.model.device_request import DeviceRequest +from .domain.device.model.device_response import DeviceResponse from .domain.exception.exceptions import * from .domain.location.location_service import LocationService from .domain.location.model.location_request import ( @@ -282,7 +283,7 @@ async def _login(self, data): user_hash = data["user_hash"] if user_hash: loginResponse: LoginResponse = await self._auth_service.login( - user_hash="user_hash" + user_hash=user_hash ) self._token = loginResponse.token self._has_locations = loginResponse.has_locations @@ -302,11 +303,11 @@ async def _login(self, data): "tariff_code": TARIFF_CODE, } ) - location = await self._location_service.add_location( + location: LocationResponse | None = await self._location_service.add_location( request=location_request, access_token=self._token ) - self.has_location = True if location else False + self._has_locations = True if location else False if not self._has_devices: if not location: locations: [LocationResponse] = await self._location_service.get_locations(access_token=self._token) @@ -320,7 +321,7 @@ async def _login(self, data): version='version', integration_params={} ) - device_response = await self._device_service.add_device( + device_response: DeviceResponse | None = await self._device_service.add_device( request=device_request, access_token=self._token ) diff --git a/custom_components/optispark/domain/device/model/device_response.py b/custom_components/optispark/domain/device/model/device_response.py index bcfcb28..4cf670c 100644 --- a/custom_components/optispark/domain/device/model/device_response.py +++ b/custom_components/optispark/domain/device/model/device_response.py @@ -34,7 +34,7 @@ def from_json(cls, json: dict): return cls( id=json["id"], name=json["name"], - location_id=json["location"], + location_id=json["locationId"], manufacturer=json["manufacturer"], model_name=json["modelname"], version=json["version"], From a2862eb11a66dd4a31ad46ed9d6fb0ccd467e9ba Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Mon, 8 Jul 2024 16:31:11 +0200 Subject: [PATCH 14/52] wip: mock lambda response --- custom_components/optispark/api.py | 117 +++++++++++++++++++---------- 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index dfc92ef..0504e2c 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -28,6 +28,8 @@ ) from .domain.location.model.location_response import LocationResponse +import time +import pytz # class OptisparkApiClientError(Exception): # """Exception to indicate a general API error.""" @@ -138,64 +140,103 @@ async def get_data_dates(self, dynamo_data: dict): dynamo_data will only contain the user_hash. """ - # auth_url = "http://localhost:5000/auth/ha_login" - # LOGGER.debug(auth_url) - # LOGGER.debug("***********************************************") - # LOGGER.debug(dynamo_data["user_hash"]) - - # # user_hash = dynamo_data["user_hash"] - # payload = {"user_hash": dynamo_data["user_hash"]} - - # response = await self._session.request( + # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' + # lambda_url = "http://localhost:5000/home-assistant/data-dates" + # payload = {"dynamo_data": dynamo_data} + # payload["get_newest_oldest_data_date_only"] = True + # payload["user_hash"] = dynamo_data["user_hash"] + # # print(payload) + # extra = await self._api_wrapper( # method="post", - # url=auth_url, - # json=payload, + # url=lambda_url, + # data=payload, # ) + # oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) + # newest_dates = self.datetime_set_utc(extra["newest_dates"]) + # + # return oldest_dates, newest_dates + + print("--------------------------") + print(dynamo_data) + + tz = pytz.timezone("Europe/London") + todays_time = time.time() + current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) + # yesterday = current - datetime.date.timedelta(days=1) + yesterday = datetime(year=2024, month=6, day=8) + + extra = { + "oldest_dates": { + "heat_pump_power": yesterday, + "external_temperature": yesterday, + "climate_entity": yesterday, + }, + "newest_dates": { + "heat_pump_power": current, + "external_temperature": current, + "climate_entity": current, + }, + } - # print(response.status) - # res = await response.json() - # print(res["access_token"]) - # token = res["access_token"] - - # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = "http://localhost:5000/home-assistant/data-dates" - payload = {"dynamo_data": dynamo_data} - payload["get_newest_oldest_data_date_only"] = True - payload["user_hash"] = dynamo_data["user_hash"] - # print(payload) - extra = await self._api_wrapper( - method="post", - url=lambda_url, - data=payload, - ) - print(extra) oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) newest_dates = self.datetime_set_utc(extra["newest_dates"]) return oldest_dates, newest_dates + async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = "http://localhost:5000/home-assistant/profile" + # lambda_url = "http://localhost:5000/home-assistant/profile" + # + # payload = lambda_args + # payload["get_profile_only"] = True + # LOGGER.debug("----------Lambda get profile----------") + # results, errors = await self._api_wrapper( + # method="post", + # url=lambda_url, + # data=payload, + # ) + # if errors["success"] is False: + # LOGGER.debug(f'OptisparkApiClientLambdaError: {errors["error_message"]}') + # raise OptisparkApiClientLambdaError(errors["error_message"]) + # if results["optimised_cost"] == 0: + # # Heating isn't active. Should the savings be 0? + # results["projected_percent_savings"] = 100 + # else: + # results["projected_percent_savings"] = ( + # results["base_cost"] / results["optimised_cost"] * 100 - 100 + # ) + # return results payload = lambda_args payload["get_profile_only"] = True + print(lambda_args) LOGGER.debug("----------Lambda get profile----------") - results, errors = await self._api_wrapper( - method="post", - url=lambda_url, - data=payload, - ) - if errors["success"] is False: - LOGGER.debug(f'OptisparkApiClientLambdaError: {errors["error_message"]}') - raise OptisparkApiClientLambdaError(errors["error_message"]) + tz = pytz.timezone("Europe/London") + todays_time = time.time() + current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) + + results = { + "timestamp": [current], + "electricity_price": [10], + "base_power": [15], + "optimised_power": [10], + "optimised_internal_temp": [17], + "external_temp": [15], + "temp_controls": [2], + "dni": [10], + "total_cost_optimised": 1.3, + "base_cost": 1.0, + "optimised_cost": 2.0, + } + if results["optimised_cost"] == 0: # Heating isn't active. Should the savings be 0? results["projected_percent_savings"] = 100 else: results["projected_percent_savings"] = ( - results["base_cost"] / results["optimised_cost"] * 100 - 100 + results["base_cost"] / results["optimised_cost"] * 100 - 100 ) return results From a414cf2d6167d1c885073cc7ecdbc8278f382980 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 13:48:55 +0200 Subject: [PATCH 15/52] chore: get backend url from config --- custom_components/optispark/api.py | 17 +++++++++++------ .../optispark/domain/auth/auth_service.py | 7 +++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 0504e2c..fe63931 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -12,6 +12,8 @@ import pickle import gzip import base64 + +from .configuration_service import ConfigurationService from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE import traceback from http import HTTPStatus @@ -58,6 +60,7 @@ # class OptisparkApiClientUnitError(OptisparkApiClientError): # """Exception to indicate unit error.""" +BACKEND_URL = 'backend.url' def floats_to_decimal(obj): """Convert data types to those supported by DynamoDB.""" @@ -97,6 +100,7 @@ class OptisparkApiClient: _has_devices: bool _auth_service: AuthService _location_service: LocationService + _config_service: ConfigurationService def __init__( self, @@ -110,7 +114,7 @@ def __init__( self._auth_service = AuthService(session=session) self._location_service = LocationService(session=session) self._device_service = DeviceService(session=session) - # self._config_service = ConfigurationService(config_file='./config/config.json') + self._config_service = ConfigurationService(config_file='./config/config.json') def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -122,13 +126,12 @@ def datetime_set_utc(self, d: dict[str, datetime]): async def upload_history(self, dynamo_data): """Upload historical data to dynamoDB without calculating heat pump profile.""" - # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - lambda_url = "http://localhost:5000/home-assistant/history" + url = self._config_service.get(BACKEND_URL) payload = {"dynamo_data": dynamo_data} payload["upload_only"] = True extra = await self._api_wrapper( method="post", - url=lambda_url, + url=url, data=payload, ) oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) @@ -142,13 +145,14 @@ async def get_data_dates(self, dynamo_data: dict): """ # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' # lambda_url = "http://localhost:5000/home-assistant/data-dates" + # url = self._config_service.get(BACKEND_URL) # payload = {"dynamo_data": dynamo_data} # payload["get_newest_oldest_data_date_only"] = True # payload["user_hash"] = dynamo_data["user_hash"] # # print(payload) # extra = await self._api_wrapper( # method="post", - # url=lambda_url, + # url=url, # data=payload, # ) # oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) @@ -188,13 +192,14 @@ async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' # lambda_url = "http://localhost:5000/home-assistant/profile" + # url = self._config_service.get(BACKEND_URL) # # payload = lambda_args # payload["get_profile_only"] = True # LOGGER.debug("----------Lambda get profile----------") # results, errors = await self._api_wrapper( # method="post", - # url=lambda_url, + # url=url, # data=payload, # ) # if errors["success"] is False: diff --git a/custom_components/optispark/domain/auth/auth_service.py b/custom_components/optispark/domain/auth/auth_service.py index 29e9e41..b6d2d96 100644 --- a/custom_components/optispark/domain/auth/auth_service.py +++ b/custom_components/optispark/domain/auth/auth_service.py @@ -2,22 +2,25 @@ import aiohttp +from custom_components.optispark.configuration_service import ConfigurationService from custom_components.optispark.domain.auth.model.login_response import LoginResponse from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError class AuthService: + _config_service: ConfigurationService + def __init__( self, session: aiohttp.ClientSession, ) -> None: """Sample API Client.""" self._session = session + self._config_service = ConfigurationService(config_file="./config/config.json") async def login(self, user_hash: str) -> LoginResponse: - # TODO: move to config - auth_url = "http://localhost:5000/auth/ha_login" + auth_url = self._config_service.get("backend.baseUrl") try: payload = {"user_hash": user_hash} response = await self._session.request( From 11b377dcfb8c597baa6537931b8718b683cb6fcc Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 16:15:17 +0200 Subject: [PATCH 16/52] wip --- custom_components/optispark/__init__.py | 2 +- custom_components/optispark/api.py | 26 ++++++++++++++++--- .../optispark/config/config.json | 3 +++ custom_components/optispark/coordinator.py | 11 ++++++++ .../optispark/domain/auth/auth_service.py | 3 ++- .../optispark/domain/shared/model/__init__.py | 0 .../domain/shared/model/base_enum.py | 11 ++++++++ .../domain/shared/model/working_mode.py | 8 ++++++ .../optispark/domain/thermostat/__init__.py | 0 .../optispark/domain/thermostat/thermostat.py | 16 ++++++++++++ 10 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 custom_components/optispark/domain/shared/model/__init__.py create mode 100644 custom_components/optispark/domain/shared/model/base_enum.py create mode 100644 custom_components/optispark/domain/shared/model/working_mode.py create mode 100644 custom_components/optispark/domain/thermostat/__init__.py create mode 100644 custom_components/optispark/domain/thermostat/thermostat.py diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index 5815868..8e8bf1e 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator = OptisparkDataUpdateCoordinator( hass=hass, - client=OptisparkApiClient(session=async_get_clientsession(hass)), + client=OptisparkApiClient(session=async_get_clientsession(hass), user_hash=entry.data["user_hash"]), climate_entity_id=entry.data["climate_entity_id"], heat_pump_power_entity_id=entry.data["heat_pump_power_entity_id"], external_temp_entity_id=entry.data["external_temp_entity_id"], diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index fe63931..a8fdee1 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -13,7 +13,9 @@ import gzip import base64 -from .configuration_service import ConfigurationService +from homeassistant.core import async_get_hass, HomeAssistant + +from .configuration_service import ConfigurationService, config_service from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE import traceback from http import HTTPStatus @@ -96,6 +98,7 @@ class OptisparkApiClient: """Optispark API Client.""" _token: str | None + _user_hash: str _has_locations: bool _has_devices: bool _auth_service: AuthService @@ -105,16 +108,19 @@ class OptisparkApiClient: def __init__( self, session: aiohttp.ClientSession, + user_hash: str, ) -> None: """Sample API Client.""" self._session = session + self._user_hash = user_hash self._token = None self._has_locations = False self._has_devices = False self._auth_service = AuthService(session=session) self._location_service = LocationService(session=session) self._device_service = DeviceService(session=session) - self._config_service = ConfigurationService(config_file='./config/config.json') + # self._config_service = ConfigurationService(config_file='./config/config.json') + self._config_service: ConfigurationService = config_service def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -145,7 +151,8 @@ async def get_data_dates(self, dynamo_data: dict): """ # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' # lambda_url = "http://localhost:5000/home-assistant/data-dates" - # url = self._config_service.get(BACKEND_URL) + # base_url = self._config_service.get(BACKEND_URL) + # url = f'{baser_url}/' # payload = {"dynamo_data": dynamo_data} # payload["get_newest_oldest_data_date_only"] = True # payload["user_hash"] = dynamo_data["user_hash"] @@ -245,6 +252,19 @@ async def async_get_profile(self, lambda_args: dict): ) return results + async def get_working_mode(self): + if not self._token: + result = await self._auth_service.login(self._user_hash) + if result: + self._token = result.token + + locations = await self._location_service.get_locations(self._token) + if locations[0]: + thermostat_id = locations[0].thermostat_id + + + + def json_serialisable(self, data): """Convert to compressed bytes so that data can be converted to json.""" uncompressed_data = pickle.dumps(data) diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 3fe0dbf..da42f97 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -6,6 +6,9 @@ }, "device": { "base": "/device/" + }, + "thermostat": { + "control": "thermostat/{thermostat_id}/control" } } } \ No newline at end of file diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 8389f56..bed833c 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -566,6 +566,11 @@ async def __call__(self, lambda_args): Calls lambda if new heating profile is needed Otherwise, slowly uploads historical data """ + + if not self._check_running_manual_mode(): + LOGGER.debug('Request manual mode') + print('Request manual mode') + now = datetime.now(tz=timezone.utc) # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: @@ -575,6 +580,12 @@ async def __call__(self, lambda_args): await self.upload_old_history() return self.get_closest_time(lambda_args) + async def _check_running_manual_mode(self) -> bool: + now = datetime.now(tz=timezone.utc) + print(f'{now} - checking mode') + return True + + async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" # print(lambda_args.keys()) diff --git a/custom_components/optispark/domain/auth/auth_service.py b/custom_components/optispark/domain/auth/auth_service.py index b6d2d96..9abd6de 100644 --- a/custom_components/optispark/domain/auth/auth_service.py +++ b/custom_components/optispark/domain/auth/auth_service.py @@ -20,7 +20,8 @@ def __init__( self._config_service = ConfigurationService(config_file="./config/config.json") async def login(self, user_hash: str) -> LoginResponse: - auth_url = self._config_service.get("backend.baseUrl") + base_url = self._config_service.get("backend.baseUrl") + auth_url = f'{base_url}/auth/ha_login' try: payload = {"user_hash": user_hash} response = await self._session.request( diff --git a/custom_components/optispark/domain/shared/model/__init__.py b/custom_components/optispark/domain/shared/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/shared/model/base_enum.py b/custom_components/optispark/domain/shared/model/base_enum.py new file mode 100644 index 0000000..5f01a50 --- /dev/null +++ b/custom_components/optispark/domain/shared/model/base_enum.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class BaseEnum(Enum): + def __str__(self) -> str: + return "%s" % self.value + + +class FilterOperator(BaseEnum): + OR = "or" + AND = "and" \ No newline at end of file diff --git a/custom_components/optispark/domain/shared/model/working_mode.py b/custom_components/optispark/domain/shared/model/working_mode.py new file mode 100644 index 0000000..28ca485 --- /dev/null +++ b/custom_components/optispark/domain/shared/model/working_mode.py @@ -0,0 +1,8 @@ +from custom_components.optispark.domain.shared.model.base_enum import BaseEnum + + +class WorkingMode(str, BaseEnum): + HEATING = "Heating" + COOLING = "Cooling" + STOPPED = "Stopped" + HEAT_AND_COOL = "HeatAndCool" \ No newline at end of file diff --git a/custom_components/optispark/domain/thermostat/__init__.py b/custom_components/optispark/domain/thermostat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/thermostat/thermostat.py b/custom_components/optispark/domain/thermostat/thermostat.py new file mode 100644 index 0000000..bf257c8 --- /dev/null +++ b/custom_components/optispark/domain/thermostat/thermostat.py @@ -0,0 +1,16 @@ +import aiohttp + +from custom_components.optispark.configuration_service import ConfigurationService, config_service + +class ThermostatService: + + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + self._config_service: ConfigurationService = config_service + + async def get_control(self, thermostat_id: int): + return None \ No newline at end of file From 3113f38a98560e0fd5adb751115390e7b62c659f Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 16:39:24 +0200 Subject: [PATCH 17/52] wip --- custom_components/optispark/api.py | 17 +++++++++++++---- .../{thermostat.py => thermostat_service.py} | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) rename custom_components/optispark/domain/thermostat/{thermostat.py => thermostat_service.py} (51%) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index a8fdee1..92ce5e0 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -35,6 +35,8 @@ import time import pytz +from .domain.thermostat.thermostat_service import ThermostatService + # class OptisparkApiClientError(Exception): # """Exception to indicate a general API error.""" @@ -119,6 +121,7 @@ def __init__( self._auth_service = AuthService(session=session) self._location_service = LocationService(session=session) self._device_service = DeviceService(session=session) + self._thermostat_service = ThermostatService(session=session) # self._config_service = ConfigurationService(config_file='./config/config.json') self._config_service: ConfigurationService = config_service @@ -153,9 +156,9 @@ async def get_data_dates(self, dynamo_data: dict): # lambda_url = "http://localhost:5000/home-assistant/data-dates" # base_url = self._config_service.get(BACKEND_URL) # url = f'{baser_url}/' - # payload = {"dynamo_data": dynamo_data} - # payload["get_newest_oldest_data_date_only"] = True - # payload["user_hash"] = dynamo_data["user_hash"] + payload = {"dynamo_data": dynamo_data} + payload["get_newest_oldest_data_date_only"] = True + payload["user_hash"] = dynamo_data["user_hash"] # # print(payload) # extra = await self._api_wrapper( # method="post", @@ -168,7 +171,10 @@ async def get_data_dates(self, dynamo_data: dict): # return oldest_dates, newest_dates print("--------------------------") - print(dynamo_data) + # print(dynamo_data) + await self._login(payload) + await self.get_working_mode() + tz = pytz.timezone("Europe/London") todays_time = time.time() @@ -253,6 +259,7 @@ async def async_get_profile(self, lambda_args: dict): return results async def get_working_mode(self): + print('get working mode') if not self._token: result = await self._auth_service.login(self._user_hash) if result: @@ -261,6 +268,8 @@ async def get_working_mode(self): locations = await self._location_service.get_locations(self._token) if locations[0]: thermostat_id = locations[0].thermostat_id + print('calling thermostat service') + await self._thermostat_service.get_control(thermostat_id) diff --git a/custom_components/optispark/domain/thermostat/thermostat.py b/custom_components/optispark/domain/thermostat/thermostat_service.py similarity index 51% rename from custom_components/optispark/domain/thermostat/thermostat.py rename to custom_components/optispark/domain/thermostat/thermostat_service.py index bf257c8..0747708 100644 --- a/custom_components/optispark/domain/thermostat/thermostat.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -2,6 +2,7 @@ from custom_components.optispark.configuration_service import ConfigurationService, config_service + class ThermostatService: def __init__( @@ -11,6 +12,10 @@ def __init__( """Sample API Client.""" self._session = session self._config_service: ConfigurationService = config_service + self._base_url = config_service.get("backend.baseUrl") - async def get_control(self, thermostat_id: int): - return None \ No newline at end of file + async def get_control(self, thermostat_id: int): + endpoint = config_service.get("backend.thermostat.control") + thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) + print(thermostat_url) + return None \ No newline at end of file From 33ff39f2833c04b10875c59fd252d4f2f5ac2035 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 17:17:44 +0200 Subject: [PATCH 18/52] wip: thermostat service --- custom_components/optispark/api.py | 5 +- .../optispark/domain/exception/exceptions.py | 4 ++ .../domain/location/location_service.py | 4 +- .../location/model/location_response.py | 6 ++- .../domain/thermostat/model/__init__.py | 0 .../thermostat/model/thermostat_response.py | 49 +++++++++++++++++++ .../domain/thermostat/thermostat_service.py | 38 ++++++++++++-- 7 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 custom_components/optispark/domain/thermostat/model/__init__.py create mode 100644 custom_components/optispark/domain/thermostat/model/thermostat_response.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 92ce5e0..2b401c1 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -269,9 +269,8 @@ async def get_working_mode(self): if locations[0]: thermostat_id = locations[0].thermostat_id print('calling thermostat service') - await self._thermostat_service.get_control(thermostat_id) - - + result = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) + print(f'thermostat id: {result.thermostat_id}') def json_serialisable(self, data): diff --git a/custom_components/optispark/domain/exception/exceptions.py b/custom_components/optispark/domain/exception/exceptions.py index 863ad8d..8833741 100644 --- a/custom_components/optispark/domain/exception/exceptions.py +++ b/custom_components/optispark/domain/exception/exceptions.py @@ -44,3 +44,7 @@ class OptisparkApiClientLocationError(OptisparkApiClientError): class OptisparkApiClientDeviceError(OptisparkApiClientError): """Exception to indicate an location error.""" + + +class OptisparkApiClientThermostatError(OptisparkApiClientError): + """Exception to indicate an thermostat error.""" diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/domain/location/location_service.py index 9f63688..c1be1af 100644 --- a/custom_components/optispark/domain/location/location_service.py +++ b/custom_components/optispark/domain/location/location_service.py @@ -49,8 +49,8 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc "Add location error", ) from Exception - jsonResponse = await response.json() - return LocationResponse.from_json(jsonResponse) + json_response = await response.json() + return LocationResponse.from_json(json_response) except aiohttp.ClientError as e: print(f"HTTP error occurred: {e}") diff --git a/custom_components/optispark/domain/location/model/location_response.py b/custom_components/optispark/domain/location/model/location_response.py index fa15136..3a92174 100644 --- a/custom_components/optispark/domain/location/model/location_response.py +++ b/custom_components/optispark/domain/location/model/location_response.py @@ -46,5 +46,9 @@ def from_json(cls, json: dict): tariff_params=json["tariffParams"], thermostat_id=json["thermostatId"], ) - except: + except KeyError as e: + print(f"Error: No key in JSON - {e}") + return None + except Exception as e: + print(f"Error: {e}") return None diff --git a/custom_components/optispark/domain/thermostat/model/__init__.py b/custom_components/optispark/domain/thermostat/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_response.py b/custom_components/optispark/domain/thermostat/model/thermostat_response.py new file mode 100644 index 0000000..6067354 --- /dev/null +++ b/custom_components/optispark/domain/thermostat/model/thermostat_response.py @@ -0,0 +1,49 @@ +from typing import Optional + +from custom_components.optispark.domain.shared.model.working_mode import WorkingMode + + +class ThermostatResponse: + thermostat_id: int + status: str + mode: WorkingMode + heat_set_point: float | None + cool_set_point: float | None + + def __init__( + self, + thermostat_id: int, + status: str, + mode: WorkingMode, + heat_set_point: Optional[float] = None, + cool_set_point: Optional[float] = None + ): + self.thermostat_id = thermostat_id + self.status = status + self.mode = mode + self.heat_set_point = heat_set_point + self.cool_set_point = cool_set_point + + @classmethod + def from_json(cls, json: dict): + heat_set_point = None + cool_set_point = None + try: + if json['heatSetPoint']: + heat_set_point = json['heatSetPoint'] + if json['heatSetPoint']: + heat_set_point = json['heatSetPoint'] + return cls( + thermostat_id=json['thermostatId'], + status=json['status'], + mode=WorkingMode(json['mode']), + heat_set_point=heat_set_point, + cool_set_point=cool_set_point, + ) + + except KeyError as e: + print(f"Error: No key in JSON - {e}") + return None + except Exception as e: + print(f"Error: {e}") + return None diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/domain/thermostat/thermostat_service.py index 0747708..c2ce435 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -1,6 +1,11 @@ +from http import HTTPStatus + import aiohttp from custom_components.optispark.configuration_service import ConfigurationService, config_service +from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ + OptisparkApiClientThermostatError +from custom_components.optispark.domain.thermostat.model.thermostat_response import ThermostatResponse class ThermostatService: @@ -14,8 +19,35 @@ def __init__( self._config_service: ConfigurationService = config_service self._base_url = config_service.get("backend.baseUrl") - async def get_control(self, thermostat_id: int): + async def get_control(self, thermostat_id: int, access_token: str): endpoint = config_service.get("backend.thermostat.control") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) - print(thermostat_url) - return None \ No newline at end of file + headers = { + "Authorization": f"Bearer {access_token}", + } + try: + response = await self._session.get( + # method="get", + url=thermostat_url, + headers=headers, + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientThermostatError( + "Add location error", + ) from Exception + + json_response = await response.json() + return ThermostatResponse.from_json(json_response) + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientThermostatError("get thermostat control error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise From 47fb18e05546686976e7084c1d403415d27f198e Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 17:22:52 +0200 Subject: [PATCH 19/52] wip --- custom_components/optispark/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 2b401c1..7315204 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -271,6 +271,7 @@ async def get_working_mode(self): print('calling thermostat service') result = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) print(f'thermostat id: {result.thermostat_id}') + print(f'mode: {result.mode}') def json_serialisable(self, data): From c5188a24872656a9a60ea0dc1f421a8872831421 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 17:38:07 +0200 Subject: [PATCH 20/52] wip --- .../optispark/domain/thermostat/thermostat_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/domain/thermostat/thermostat_service.py index c2ce435..2fe951b 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -37,9 +37,9 @@ async def get_control(self, thermostat_id: int, access_token: str): "Invalid credentials", ) from Exception - if response.status != HTTPStatus.CREATED: + if response.status != HTTPStatus.OK: raise OptisparkApiClientThermostatError( - "Add location error", + "Get thermostat control error", ) from Exception json_response = await response.json() From ba48619ee06ad36009305e68caea3344cf3f344c Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 17:54:47 +0200 Subject: [PATCH 21/52] wip --- custom_components/optispark/api.py | 18 ++++++++++++------ ...ponse.py => thermostat_control_response.py} | 9 +++++---- .../model/thermostat_control_status.py | 7 +++++++ 3 files changed, 24 insertions(+), 10 deletions(-) rename custom_components/optispark/domain/thermostat/model/{thermostat_response.py => thermostat_control_response.py} (82%) create mode 100644 custom_components/optispark/domain/thermostat/model/thermostat_control_status.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 7315204..58519d1 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -35,6 +35,9 @@ import time import pytz +from .domain.shared.model.working_mode import WorkingMode +from .domain.thermostat.model.thermostat_control_response import ThermostatControlResponse +from .domain.thermostat.model.thermostat_control_status import ThermostatControlStatus from .domain.thermostat.thermostat_service import ThermostatService # class OptisparkApiClientError(Exception): @@ -173,8 +176,9 @@ async def get_data_dates(self, dynamo_data: dict): print("--------------------------") # print(dynamo_data) await self._login(payload) - await self.get_working_mode() - + control = await self.get_thermostat_control() + if not control.status == ThermostatControlStatus.MANUAL: + print(f'{control.status}') tz = pytz.timezone("Europe/London") todays_time = time.time() @@ -258,7 +262,7 @@ async def async_get_profile(self, lambda_args: dict): ) return results - async def get_working_mode(self): + async def get_thermostat_control(self) -> ThermostatControlResponse: print('get working mode') if not self._token: result = await self._auth_service.login(self._user_hash) @@ -269,9 +273,11 @@ async def get_working_mode(self): if locations[0]: thermostat_id = locations[0].thermostat_id print('calling thermostat service') - result = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) - print(f'thermostat id: {result.thermostat_id}') - print(f'mode: {result.mode}') + control = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) + print(f'thermostat id: {control.thermostat_id}') + print(f'mode: {control.mode}') + print(f'mode: {control.status}') + return control def json_serialisable(self, data): diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_response.py b/custom_components/optispark/domain/thermostat/model/thermostat_control_response.py similarity index 82% rename from custom_components/optispark/domain/thermostat/model/thermostat_response.py rename to custom_components/optispark/domain/thermostat/model/thermostat_control_response.py index 6067354..f9b3ce5 100644 --- a/custom_components/optispark/domain/thermostat/model/thermostat_response.py +++ b/custom_components/optispark/domain/thermostat/model/thermostat_control_response.py @@ -1,11 +1,12 @@ from typing import Optional from custom_components.optispark.domain.shared.model.working_mode import WorkingMode +from custom_components.optispark.domain.thermostat.model.thermostat_control_status import ThermostatControlStatus -class ThermostatResponse: +class ThermostatControlResponse: thermostat_id: int - status: str + status: ThermostatControlStatus mode: WorkingMode heat_set_point: float | None cool_set_point: float | None @@ -13,7 +14,7 @@ class ThermostatResponse: def __init__( self, thermostat_id: int, - status: str, + status: ThermostatControlStatus, mode: WorkingMode, heat_set_point: Optional[float] = None, cool_set_point: Optional[float] = None @@ -35,7 +36,7 @@ def from_json(cls, json: dict): heat_set_point = json['heatSetPoint'] return cls( thermostat_id=json['thermostatId'], - status=json['status'], + status=ThermostatControlStatus(json['status']), mode=WorkingMode(json['mode']), heat_set_point=heat_set_point, cool_set_point=cool_set_point, diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_control_status.py b/custom_components/optispark/domain/thermostat/model/thermostat_control_status.py new file mode 100644 index 0000000..b6358c7 --- /dev/null +++ b/custom_components/optispark/domain/thermostat/model/thermostat_control_status.py @@ -0,0 +1,7 @@ +from custom_components.optispark.domain.shared.model.base_enum import BaseEnum + + +class ThermostatControlStatus(str, BaseEnum): + SCHEDULE = "schedule" + MANUAL = "manual" + BOOST = "boost" \ No newline at end of file From d92ca1374ce0e6266245d42a5e532510ab1fbdb1 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 10 Jul 2024 17:58:33 +0200 Subject: [PATCH 22/52] chore: thermostat service --- .../optispark/domain/thermostat/thermostat_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/domain/thermostat/thermostat_service.py index 2fe951b..5c220d5 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -5,7 +5,7 @@ from custom_components.optispark.configuration_service import ConfigurationService, config_service from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientThermostatError -from custom_components.optispark.domain.thermostat.model.thermostat_response import ThermostatResponse +from custom_components.optispark.domain.thermostat.model.thermostat_control_response import ThermostatControlResponse class ThermostatService: @@ -43,7 +43,7 @@ async def get_control(self, thermostat_id: int, access_token: str): ) from Exception json_response = await response.json() - return ThermostatResponse.from_json(json_response) + return ThermostatControlResponse.from_json(json_response) except aiohttp.ClientError as e: print(f"HTTP error occurred: {e}") From e72848ed08739ac72473a3b5e48443b76c0c3735 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 09:18:23 +0200 Subject: [PATCH 23/52] wip: create manual control --- custom_components/optispark/api.py | 30 ++++++++++--- .../optispark/config/config.json | 3 +- .../model/thermostat_control_request.py | 35 +++++++++++++++ .../domain/thermostat/thermostat_service.py | 44 ++++++++++++++++++- 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 custom_components/optispark/domain/thermostat/model/thermostat_control_request.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 58519d1..4013e84 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -36,6 +36,7 @@ import pytz from .domain.shared.model.working_mode import WorkingMode +from .domain.thermostat.model.thermostat_control_request import ThermostatControlRequest from .domain.thermostat.model.thermostat_control_response import ThermostatControlResponse from .domain.thermostat.model.thermostat_control_status import ThermostatControlStatus from .domain.thermostat.thermostat_service import ThermostatService @@ -150,15 +151,12 @@ async def upload_history(self, dynamo_data): newest_dates = self.datetime_set_utc(extra["newest_dates"]) return oldest_dates, newest_dates + async def get_data_dates(self, dynamo_data: dict): """Call lambda and only get the newest and oldest dates in dynamo. dynamo_data will only contain the user_hash. """ - # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - # lambda_url = "http://localhost:5000/home-assistant/data-dates" - # base_url = self._config_service.get(BACKEND_URL) - # url = f'{baser_url}/' payload = {"dynamo_data": dynamo_data} payload["get_newest_oldest_data_date_only"] = True payload["user_hash"] = dynamo_data["user_hash"] @@ -174,11 +172,16 @@ async def get_data_dates(self, dynamo_data: dict): # return oldest_dates, newest_dates print("--------------------------") + print(payload["user_hash"]) # print(dynamo_data) await self._login(payload) control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: - print(f'{control.status}') + LOGGER.debug(f'Control in {control.status} status, requesting manual') + print(f' {control.status} --> generating manual control request') + manualControl = await self.create_manual(control.thermostat_id) + print(f'Created: {manualControl.status} - {manualControl.mode} - {manualControl.heat_set_point} -/' + f' {manualControl.cool_set_point}') tz = pytz.timezone("Europe/London") todays_time = time.time() @@ -272,13 +275,26 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: locations = await self._location_service.get_locations(self._token) if locations[0]: thermostat_id = locations[0].thermostat_id - print('calling thermostat service') + LOGGER.debug(f'Getting thermostat control mode') control = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) print(f'thermostat id: {control.thermostat_id}') print(f'mode: {control.mode}') - print(f'mode: {control.status}') + print(f'status: {control.status}') return control + async def create_manual(self, thermostat_id: int) -> ThermostatControlResponse: + request = ThermostatControlRequest( + mode=WorkingMode.HEAT_AND_COOL, + heat_set_point=17.5, + cool_set_point=21.5 + ) + result = await self._thermostat_service.create_manual( + thermostat_id=thermostat_id, + request=request, + access_token=self._token + ) + return result + def json_serialisable(self, data): """Convert to compressed bytes so that data can be converted to json.""" diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index da42f97..12b341f 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -8,7 +8,8 @@ "base": "/device/" }, "thermostat": { - "control": "thermostat/{thermostat_id}/control" + "control": "thermostat/{thermostat_id}/control", + "manual": "thermostat/{thermostat_id}/control/manual" } } } \ No newline at end of file diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_control_request.py b/custom_components/optispark/domain/thermostat/model/thermostat_control_request.py new file mode 100644 index 0000000..1e52e63 --- /dev/null +++ b/custom_components/optispark/domain/thermostat/model/thermostat_control_request.py @@ -0,0 +1,35 @@ +from typing import Optional + +from custom_components.optispark.domain.shared.model.working_mode import WorkingMode + + +class ThermostatControlRequest: + mode: WorkingMode + heat_set_point: float | None + cool_set_point: float | None + + def __init__( + self, + mode: WorkingMode, + heat_set_point: Optional[float] = None, + cool_set_point: Optional[float] = None + ): + self.mode = mode + self.heat_set_point = heat_set_point + self.cool_set_point = cool_set_point + + def to_dict(self) -> dict: + result = {'mode': self.mode} + + if self.mode == WorkingMode.HEAT_AND_COOL and self.heat_set_point and self.cool_set_point: + result['heatSetPoint'] = self.heat_set_point + result['coolSetPoint'] = self.cool_set_point + + if self.mode == WorkingMode.COOLING and self.heat_set_point: + result['coolSetPoint'] = self.cool_set_point + + if self.mode == WorkingMode.HEATING and self.heat_set_point: + result['heatSetPoint'] = self.heat_set_point + + return result + diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/domain/thermostat/thermostat_service.py index 5c220d5..60432e5 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -5,6 +5,7 @@ from custom_components.optispark.configuration_service import ConfigurationService, config_service from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientThermostatError +from custom_components.optispark.domain.thermostat.model.thermostat_control_request import ThermostatControlRequest from custom_components.optispark.domain.thermostat.model.thermostat_control_response import ThermostatControlResponse @@ -19,7 +20,7 @@ def __init__( self._config_service: ConfigurationService = config_service self._base_url = config_service.get("backend.baseUrl") - async def get_control(self, thermostat_id: int, access_token: str): + async def get_control(self, thermostat_id: int, access_token: str) -> ThermostatControlResponse: endpoint = config_service.get("backend.thermostat.control") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) headers = { @@ -51,3 +52,44 @@ async def get_control(self, thermostat_id: int, access_token: str): except Exception as e: print(f"Unexpected error occurred: {e}") raise + + async def create_manual( + self, + thermostat_id: int, + request: ThermostatControlRequest, + access_token: str + ) -> ThermostatControlResponse: + endpoint = config_service.get("backend.thermostat.manual") + thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + try: + response = await self._session.post( + url=thermostat_url, + headers=headers, + data=request.to_dict() + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientThermostatError( + "Create thermostat manual control error", + ) from Exception + + json_response = await response.json() + return ThermostatControlResponse.from_json(json_response) + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientThermostatError("post thermostat manual control error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise + + \ No newline at end of file From e994a2faf0478341937a164bd51a9e1d9e4f9556 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 10:16:24 +0200 Subject: [PATCH 24/52] wip: thermostat create manual added heat pump info (temp and mode) --- custom_components/optispark/api.py | 19 ++++++++++++------- custom_components/optispark/coordinator.py | 3 +++ .../domain/shared/model/working_mode.py | 15 ++++++++++++++- .../domain/thermostat/thermostat_service.py | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 4013e84..b112b87 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -172,6 +172,7 @@ async def get_data_dates(self, dynamo_data: dict): # return oldest_dates, newest_dates print("--------------------------") + # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 print(payload["user_hash"]) # print(dynamo_data) await self._login(payload) @@ -179,9 +180,13 @@ async def get_data_dates(self, dynamo_data: dict): if not control.status == ThermostatControlStatus.MANUAL: LOGGER.debug(f'Control in {control.status} status, requesting manual') print(f' {control.status} --> generating manual control request') - manualControl = await self.create_manual(control.thermostat_id) - print(f'Created: {manualControl.status} - {manualControl.mode} - {manualControl.heat_set_point} -/' - f' {manualControl.cool_set_point}') + manual_control = await self.create_manual( + thermostat_id=control.thermostat_id, + set_point=payload["temp_set_point"], + mode=payload["heat_pump_mode_raw"] + ) + print(f'Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/' + f' {manual_control.cool_set_point}') tz = pytz.timezone("Europe/London") todays_time = time.time() @@ -282,11 +287,11 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: print(f'status: {control.status}') return control - async def create_manual(self, thermostat_id: int) -> ThermostatControlResponse: + async def create_manual(self, thermostat_id: int, set_point: float, mode: str) -> ThermostatControlResponse: request = ThermostatControlRequest( - mode=WorkingMode.HEAT_AND_COOL, - heat_set_point=17.5, - cool_set_point=21.5 + mode=WorkingMode.from_string(mode), + heat_set_point=set_point, + cool_set_point=set_point ) result = await self._thermostat_service.create_manual( thermostat_id=thermostat_id, diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index bed833c..0512aa4 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -589,11 +589,14 @@ async def _check_running_manual_mode(self) -> bool: async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" # print(lambda_args.keys()) + # TODO: create class dynamo_data = { "user_hash": self.user_hash, "postcode": lambda_args["postcode"], "address": lambda_args["address"], "city": lambda_args["city"], + "temp_set_point": lambda_args['temp_set_point'], + "heat_pump_mode_raw": lambda_args['heat_pump_mode_raw'] } ( self.dynamo_oldest_dates, diff --git a/custom_components/optispark/domain/shared/model/working_mode.py b/custom_components/optispark/domain/shared/model/working_mode.py index 28ca485..b7a5df9 100644 --- a/custom_components/optispark/domain/shared/model/working_mode.py +++ b/custom_components/optispark/domain/shared/model/working_mode.py @@ -5,4 +5,17 @@ class WorkingMode(str, BaseEnum): HEATING = "Heating" COOLING = "Cooling" STOPPED = "Stopped" - HEAT_AND_COOL = "HeatAndCool" \ No newline at end of file + HEAT_AND_COOL = "HeatAndCool" + + @classmethod + def from_string(cls, value: str): + + val = value.lower() + if val == 'heating': + return WorkingMode.HEATING + if val == 'cooling': + return WorkingMode.COOLING + if val == "heatandcool": + return WorkingMode.HEAT_AND_COOL + else: + return WorkingMode.STOPPED diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/domain/thermostat/thermostat_service.py index 60432e5..6ec1af8 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/domain/thermostat/thermostat_service.py @@ -69,7 +69,7 @@ async def create_manual( response = await self._session.post( url=thermostat_url, headers=headers, - data=request.to_dict() + json=request.to_dict() ) if response.status == HTTPStatus.UNAUTHORIZED: From 72dd0b97bb2b787e1377dbe1e50e3ef7d299d171 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 11:32:00 +0200 Subject: [PATCH 25/52] wip: refactor chore: cleaning --- custom_components/optispark/__init__.py | 17 +- custom_components/optispark/api.py | 114 +-- .../optispark/backend_update_handler.py | 425 +++++++++ custom_components/optispark/coordinator.py | 805 +++++++++--------- .../optispark/domain/exception/exception.py | 2 + .../domain/{ => value_object}/__init__.py | 0 .../optispark/domain/value_object/address.py | 12 + .../domain/value_object/control_info.py | 11 + .../{domain/auth => infra}/__init__.py | 0 .../auth/model => infra/auth}/__init__.py | 0 .../{domain => infra}/auth/auth_service.py | 4 +- .../device => infra/auth/model}/__init__.py | 0 .../auth/model/login_response.py | 0 .../device/model => infra/device}/__init__.py | 0 .../device/device_service.py | 6 +- .../device}/model/__init__.py | 0 .../device/model/device_request.py | 0 .../device/model/device_response.py | 0 .../model => infra/exception}/__init__.py | 0 .../{domain => infra}/exception/exceptions.py | 0 .../location/location_service.py | 6 +- .../location/model}/__init__.py | 0 .../location/model/location_request.py | 0 .../location/model/location_response.py | 0 .../shared}/model/__init__.py | 0 .../shared/model/base_enum.py | 0 .../shared/model/working_mode.py | 2 +- .../optispark/infra/thermostat/__init__.py | 0 .../infra/thermostat/model/__init__.py | 0 .../model/thermostat_control_request.py | 2 +- .../model/thermostat_control_response.py | 4 +- .../model/thermostat_control_status.py | 2 +- .../thermostat/thermostat_service.py | 6 +- 33 files changed, 957 insertions(+), 461 deletions(-) create mode 100644 custom_components/optispark/backend_update_handler.py create mode 100644 custom_components/optispark/domain/exception/exception.py rename custom_components/optispark/domain/{ => value_object}/__init__.py (100%) create mode 100644 custom_components/optispark/domain/value_object/address.py create mode 100644 custom_components/optispark/domain/value_object/control_info.py rename custom_components/optispark/{domain/auth => infra}/__init__.py (100%) rename custom_components/optispark/{domain/auth/model => infra/auth}/__init__.py (100%) rename custom_components/optispark/{domain => infra}/auth/auth_service.py (88%) rename custom_components/optispark/{domain/device => infra/auth/model}/__init__.py (100%) rename custom_components/optispark/{domain => infra}/auth/model/login_response.py (100%) rename custom_components/optispark/{domain/device/model => infra/device}/__init__.py (100%) rename custom_components/optispark/{domain => infra}/device/device_service.py (85%) rename custom_components/optispark/{domain/location => infra/device}/model/__init__.py (100%) rename custom_components/optispark/{domain => infra}/device/model/device_request.py (100%) rename custom_components/optispark/{domain => infra}/device/model/device_response.py (100%) rename custom_components/optispark/{domain/shared/model => infra/exception}/__init__.py (100%) rename custom_components/optispark/{domain => infra}/exception/exceptions.py (100%) rename custom_components/optispark/{domain => infra}/location/location_service.py (93%) rename custom_components/optispark/{domain/thermostat => infra/location/model}/__init__.py (100%) rename custom_components/optispark/{domain => infra}/location/model/location_request.py (100%) rename custom_components/optispark/{domain => infra}/location/model/location_response.py (100%) rename custom_components/optispark/{domain/thermostat => infra/shared}/model/__init__.py (100%) rename custom_components/optispark/{domain => infra}/shared/model/base_enum.py (100%) rename custom_components/optispark/{domain => infra}/shared/model/working_mode.py (86%) create mode 100644 custom_components/optispark/infra/thermostat/__init__.py create mode 100644 custom_components/optispark/infra/thermostat/model/__init__.py rename custom_components/optispark/{domain => infra}/thermostat/model/thermostat_control_request.py (92%) rename custom_components/optispark/{domain => infra}/thermostat/model/thermostat_control_response.py (87%) rename custom_components/optispark/{domain => infra}/thermostat/model/thermostat_control_status.py (59%) rename custom_components/optispark/{domain => infra}/thermostat/thermostat_service.py (90%) diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index 8e8bf1e..2470729 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import OptisparkApiClient from .const import DOMAIN, LOGGER +from .domain.value_object.address import Address PLATFORMS: list[Platform] = [ Platform.SENSOR, @@ -24,12 +25,24 @@ # https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" - from .coordinator import OptisparkDataUpdateCoordinator # Prevent circular import + from .backend_update_handler import BackendUpdateHandler # Prevent circular import + from .coordinator import OptisparkDataUpdateCoordinator + + address = Address( + address=entry.data["address"], + postcode=entry.data["postcode"], + city=entry.data["city"], + country=entry.data["country"] + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator = OptisparkDataUpdateCoordinator( hass=hass, - client=OptisparkApiClient(session=async_get_clientsession(hass), user_hash=entry.data["user_hash"]), + client=OptisparkApiClient( + session=async_get_clientsession(hass), + user_hash=entry.data["user_hash"], + address=address + ), climate_entity_id=entry.data["climate_entity_id"], heat_pump_power_entity_id=entry.data["heat_pump_power_entity_id"], external_temp_entity_id=entry.data["external_temp_entity_id"], diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index b112b87..8d51268 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -20,26 +20,28 @@ import traceback from http import HTTPStatus -from .domain.auth.auth_service import AuthService -from .domain.auth.model.login_response import LoginResponse -from .domain.device.device_service import DeviceService -from .domain.device.model.device_request import DeviceRequest -from .domain.device.model.device_response import DeviceResponse -from .domain.exception.exceptions import * -from .domain.location.location_service import LocationService -from .domain.location.model.location_request import ( +from .domain.value_object.address import Address +from .domain.value_object.control_info import ControlInfo +from .infra.auth.auth_service import AuthService +from .infra.auth.model.login_response import LoginResponse +from .infra.device.device_service import DeviceService +from .infra.device.model.device_request import DeviceRequest +from .infra.device.model.device_response import DeviceResponse +from .infra.exception.exceptions import * +from .infra.location.location_service import LocationService +from .infra.location.model.location_request import ( LocationRequest, ) -from .domain.location.model.location_response import LocationResponse +from .infra.location.model.location_response import LocationResponse import time import pytz -from .domain.shared.model.working_mode import WorkingMode -from .domain.thermostat.model.thermostat_control_request import ThermostatControlRequest -from .domain.thermostat.model.thermostat_control_response import ThermostatControlResponse -from .domain.thermostat.model.thermostat_control_status import ThermostatControlStatus -from .domain.thermostat.thermostat_service import ThermostatService +from .infra.shared.model.working_mode import WorkingMode +from .infra.thermostat.model.thermostat_control_request import ThermostatControlRequest +from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from .infra.thermostat.model.thermostat_control_status import ThermostatControlStatus +from .infra.thermostat.thermostat_service import ThermostatService # class OptisparkApiClientError(Exception): # """Exception to indicate a general API error.""" @@ -70,6 +72,7 @@ BACKEND_URL = 'backend.url' + def floats_to_decimal(obj): """Convert data types to those supported by DynamoDB.""" # Base cases @@ -107,18 +110,21 @@ class OptisparkApiClient: _user_hash: str _has_locations: bool _has_devices: bool + _address: Address _auth_service: AuthService _location_service: LocationService _config_service: ConfigurationService def __init__( - self, - session: aiohttp.ClientSession, - user_hash: str, + self, + session: aiohttp.ClientSession, + user_hash: str, + address: Address ) -> None: """Sample API Client.""" self._session = session self._user_hash = user_hash + self._address = address self._token = None self._has_locations = False self._has_devices = False @@ -151,39 +157,44 @@ async def upload_history(self, dynamo_data): newest_dates = self.datetime_set_utc(extra["newest_dates"]) return oldest_dates, newest_dates + # async def check_and_set_manual(self, data: dict): + + async def check_and_set_manual(self, data: ControlInfo) -> bool: + """Checks if optispark is running in manual, if not set manual mode""" + + control = await self.get_thermostat_control() + if not control.status == ThermostatControlStatus.MANUAL: + LOGGER.debug(f'Control in {control.status} status, requesting manual change...') + print(f' {control.status} --> generating manual control request') + manual_control = await self.create_manual( + thermostat_id=control.thermostat_id, + set_point=data.set_point, + mode=data.mode + ) + print(f'Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/' + f' {manual_control.cool_set_point}') + return manual_control.status == ThermostatControlStatus.MANUAL + async def get_data_dates(self, dynamo_data: dict): """Call lambda and only get the newest and oldest dates in dynamo. dynamo_data will only contain the user_hash. """ - payload = {"dynamo_data": dynamo_data} - payload["get_newest_oldest_data_date_only"] = True - payload["user_hash"] = dynamo_data["user_hash"] - # # print(payload) - # extra = await self._api_wrapper( - # method="post", - # url=url, - # data=payload, - # ) - # oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) - # newest_dates = self.datetime_set_utc(extra["newest_dates"]) - # - # return oldest_dates, newest_dates - print("--------------------------") # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 - print(payload["user_hash"]) + print(dynamo_data["user_hash"]) + # print(dynamo_data) # print(dynamo_data) - await self._login(payload) + # await self._login(payload) control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: LOGGER.debug(f'Control in {control.status} status, requesting manual') print(f' {control.status} --> generating manual control request') manual_control = await self.create_manual( thermostat_id=control.thermostat_id, - set_point=payload["temp_set_point"], - mode=payload["heat_pump_mode_raw"] + set_point=dynamo_data["temp_set_point"], + mode=dynamo_data["heat_pump_mode_raw"] ) print(f'Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/' f' {manual_control.cool_set_point}') @@ -212,7 +223,6 @@ async def get_data_dates(self, dynamo_data: dict): return oldest_dates, newest_dates - async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' @@ -273,9 +283,7 @@ async def async_get_profile(self, lambda_args: dict): async def get_thermostat_control(self) -> ThermostatControlResponse: print('get working mode') if not self._token: - result = await self._auth_service.login(self._user_hash) - if result: - self._token = result.token + await self._login() locations = await self._location_service.get_locations(self._token) if locations[0]: @@ -300,7 +308,6 @@ async def create_manual(self, thermostat_id: int, set_point: float, mode: str) - ) return result - def json_serialisable(self, data): """Convert to compressed bytes so that data can be converted to json.""" uncompressed_data = pickle.dumps(data) @@ -323,6 +330,11 @@ def json_deserialise(self, payload): async def _api_wrapper(self, method: str, url: str, data: dict): """Call the Lambda function.""" + + if not self._token: + # If user is not logged perform login process + await self._login() + try: if "dynamo_data" in data: data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) @@ -378,27 +390,31 @@ async def _api_wrapper(self, method: str, url: str, data: dict): "Something really wrong happened!" ) from exception - async def _login(self, data): + async def _login(self): + """ + Makes home asssistant login into OptiSpark backend. + checks if user has locations and devices + if not create locataion and device + """ LOGGER.debug(f" Initiating login into OptiSpark backend") location: LocationResponse | None = None if not self._token: - user_hash = data["user_hash"] - if user_hash: + # user_hash = data["user_hash"] + if self._user_hash: loginResponse: LoginResponse = await self._auth_service.login( - user_hash=user_hash + user_hash=self._user_hash ) self._token = loginResponse.token self._has_locations = loginResponse.has_locations self._has_devices = loginResponse.has_devices LOGGER.debug(f" User token: {loginResponse.token}") if not self._has_locations: - info = data["dynamo_data"] location_request = LocationRequest( name="home", - address=info["address"], - zipcode=info["postcode"], - city="", - country="GB", + address=self._address.address, + zipcode=self._address.postcode, + city=self._address.city, + country=self._address.country, tariff_id=1, tariff_params={ "product_code": TARIFF_PRODUCT_CODE, diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py new file mode 100644 index 0000000..39e88ef --- /dev/null +++ b/custom_components/optispark/backend_update_handler.py @@ -0,0 +1,425 @@ +from datetime import datetime, timezone, timedelta + +from custom_components.optispark import OptisparkApiClient, const, LOGGER, history +from custom_components.optispark.domain.value_object.address import Address +import numpy as np + +from custom_components.optispark.domain.value_object.control_info import ControlInfo + + +class BackendUpdateHandler: + """Backend communication handler + """ + + def __init__( + self, + hass, + client: OptisparkApiClient, + climate_entity_id, + heat_pump_power_entity_id, + external_temp_entity_id, + user_hash, + postcode, + address, + city, + country, + tariff, + ): + """Init.""" + self.hass = hass + self.client: OptisparkApiClient = client + self.climate_entity_id = climate_entity_id + self.heat_pump_power_entity_id = heat_pump_power_entity_id + self.external_temp_entity_id = external_temp_entity_id + self.user_hash = user_hash + self.postcode = postcode + self.address = address + self.country = country + self.city = city + self.tariff = tariff + self.expire_time = datetime( + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc + ) # Already expired + self.manual_update = False + self.history_upload_complete = False + self.outside_range_flag = False + self.id_to_column_name_lookup = { + climate_entity_id: const.DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY, + heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, + external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, + } + LOGGER.debug(f"{self.user_hash = }") + # Entity ids will be None if they are optional and not enabled + self.active_entity_ids = [] + for entity_id in [ + climate_entity_id, + heat_pump_power_entity_id, + external_temp_entity_id, + ]: + if entity_id is not None: + self.active_entity_ids.append(entity_id) + + def get_missing_histories_boundary(self, history_states, dynamo_date): + """Get index where history_state matches dynamo_date.""" + for idx, datum in enumerate(history_states): + if datum.last_updated >= dynamo_date: + idx_bound = idx + return idx_bound + return idx_bound # type: ignore + + def get_missing_old_histories_states(self, history_states, column): + """Get states that are older than anything in dynamo.""" + dynamo_date = self.dynamo_oldest_dates[column] + idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) + return history_states[:idx_bound] + + def get_missing_new_histories_states(self, history_states, column): + """Get states that are newer than anything in dynamo.""" + dynamo_date = self.dynamo_newest_dates[column] + if dynamo_date is None: + # No data in dynamo - upload first x days + dynamo_date = datetime.now(tz=timezone.utc) - timedelta( + days=const.HISTORY_DAYS + ) + idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) + if idx_bound == len(history_states) - 1: + error = True + else: + error = False + return history_states[idx_bound + 1 :], error + + async def upload_new_history(self, missing_entities): + """Upload section of new history states that are newer than anything in dynamo. + + self.dynamo_dates is updated so that if this function is called again a new section will be + uploaded. + const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. + """ + histories = {} + constant_attributes = {} + + async def debug_check_history_length(days): + history_states = await history.get_state_changes( + self.hass, active_entity_id, days + ) + LOGGER.debug(f"---------- days: {days} ----------") + LOGGER.debug( + f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + + history_states = await history.get_state_changes_period( + self.hass, active_entity_id, days + ) + LOGGER.debug( + f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + + for active_entity_id in missing_entities: + column = self.id_to_column_name_lookup[active_entity_id] + history_states = await history.get_state_changes( + self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS + ) + missing_new_histories_states, error = self.get_missing_new_histories_states( + history_states, column + ) + if error: + raise RuntimeError( + "No missing history data to upload, should not have gotten here" + ) + + LOGGER.debug(f" column: {column}") + if len(missing_new_histories_states) == 0: + LOGGER.debug(f" ({column}) - Upload complete") + continue + LOGGER.debug( + f" len(missing_new_histories_states): {len(missing_new_histories_states)}" + ) + missing_new_histories_states = missing_new_histories_states[ + : const.MAX_UPLOAD_HISTORY_READINGS + ] + LOGGER.debug( + f" len(missing_new_histories_states): {len(missing_new_histories_states)}" + ) + LOGGER.debug( + f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + LOGGER.debug( + f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + ) + + histories[column], constant_attributes[column] = ( + history.states_to_histories( + self.hass, column, missing_new_histories_states + ) + ) + if histories == {}: + raise RuntimeError( + "Should not have gotten here! No missing history data to upload" + ) + dynamo_data = history.histories_to_dynamo_data( + self.hass, + histories, + constant_attributes, + self.user_hash, + self.climate_entity_id, + self.postcode, + self.tariff, + ) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.upload_history(dynamo_data) + + async def upload_old_history(self): + """Upload section of old history states that are older than anything in dynamo. + + self.dynamo_dates is updated so that if this function is called again a new section will be + uploaded. + const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. + """ + LOGGER.debug("Uploading portion of old history...") + histories = {} + constant_attributes = {} + for active_entity_id in self.active_entity_ids: + column = self.id_to_column_name_lookup[active_entity_id] + history_states = await history.get_state_changes( + self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS + ) + missing_old_histories_states = self.get_missing_old_histories_states( + history_states, column + ) + + LOGGER.debug(f" column: {column}") + if len(missing_old_histories_states) == 0: + LOGGER.debug(f" ({column}) - Upload complete") + continue + LOGGER.debug( + f" len(missing_old_histories_states): {len(missing_old_histories_states)}" + ) + missing_old_histories_states = missing_old_histories_states[ + -const.MAX_UPLOAD_HISTORY_READINGS : + ] + + histories[column], constant_attributes[column] = ( + history.states_to_histories( + self.hass, column, missing_old_histories_states + ) + ) + if histories == {}: + self.history_upload_complete = True + LOGGER.debug("History upload complete, recalculate heating profile...\n") + # Now that we have all the history, recalculate heating profile + self.manual_update = True + return + dynamo_data = history.histories_to_dynamo_data( + self.hass, + histories, + constant_attributes, + self.user_hash, + self.climate_entity_id, + self.postcode, + self.tariff, + ) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.upload_history(dynamo_data) + + async def __call__(self, lambda_args): + """Return lambda data for the current time. + + Calls lambda if new heating profile is needed + Otherwise, slowly uploads historical data + """ + + if not self._check_running_manual_mode(lambda_args): + LOGGER.debug('Request manual mode') + + now = datetime.now(tz=timezone.utc) + # This probably won't result in a smooth transition + if self.expire_time - now < timedelta(hours=0) or self.manual_update: + await self.get_heating_profile(lambda_args) + else: + if self.history_upload_complete is False: + await self.upload_old_history() + return self.get_closest_time(lambda_args) + + async def _check_running_manual_mode(self, lambda_args: dict) -> bool: + # now = datetime.now(tz=timezone.utc) + # print(f'{now} - checking mode') + data = ControlInfo( + set_point=lambda_args["temp_set_point"], + mode=lambda_args["heat_pump_mode_raw"] + ) + return await self.client.check_and_set_manual(data) + + async def update_dynamo_dates(self, lambda_args: dict): + """Call the lambda function and get the oldest and newest dates in dynamodb.""" + # print(lambda_args.keys()) + # TODO: create class + dynamo_data = { + "user_hash": self.user_hash, + "postcode": lambda_args["postcode"], + "address": lambda_args["address"], + "city": lambda_args["city"], + "temp_set_point": lambda_args['temp_set_point'], + "heat_pump_mode_raw": lambda_args['heat_pump_mode_raw'] + } + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.get_data_dates(dynamo_data=dynamo_data) + + async def update_ha_dates(self): + """Get the oldest and newest dates in HA histories for active_entity_ids.""" + ( + self.ha_oldest_dates, + self.ha_newest_dates, + ) = await history.get_earliest_and_latest_data_dates( + hass=self.hass, + climate_entity_id=self.climate_entity_id, + heat_pump_power_entity_id=self.heat_pump_power_entity_id, + external_temp_entity_id=self.external_temp_entity_id, + ) + + def entities_with_data_missing_from_dynamo(self): + """Return entities with new data that needs to be uploaded. + + If there is data in HA histories for active_entity_ids that is newer than what is in dynamo, + return those entities. + """ + entities_missing = [] + LOGGER.debug("---entities_with_data_missing_from_dynamo---") + for active_entity_id in self.active_entity_ids: + column = self.id_to_column_name_lookup[active_entity_id] + if self.dynamo_newest_dates[column] is None: + # First run, therefore data is missing + LOGGER.debug( + f"First run, upload ({const.HISTORY_DAYS}) days of history...\n" + ) + entities_missing.append(active_entity_id) + continue + if self.dynamo_newest_dates[column] < self.ha_newest_dates[column]: + LOGGER.debug( + f"self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}" + ) + LOGGER.debug( + f"self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}" + ) + LOGGER.debug(f" column: {column}") + LOGGER.debug( + f" dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}" + ) + entities_missing.append(active_entity_id) + return entities_missing + # return False + + async def get_heating_profile(self, lambda_args): + """Fetch heating profile from Optispark Backend. + + Upload all new and missing data to dynamo first. + If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. + Records the when the heating profile expires and should be refreshed. + """ + print(f'******************************** HEATING PROFILE ******************************************') + LOGGER.debug(f"********** self.expire_time: {self.expire_time}") + count = 0 + await self.update_dynamo_dates(lambda_args) + await self.update_ha_dates() + while missing_entities := self.entities_with_data_missing_from_dynamo(): + count += 1 + LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") + await self.upload_new_history(missing_entities) + LOGGER.debug("Upload of new history complete\n") + + self.lambda_results = await self.client.async_get_profile(lambda_args) + + self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] + # The backend will currently only update upon a new day. FIX! + self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) + LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") + self.manual_update = False + + async def call_lambda(self, lambda_args): + """Fetch heating profile from AWS Lambda. + + Upload all new and missing data to dynamo first. + If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. + Records the when the heating profile expires and should be refreshed. + """ + LOGGER.debug(f"********** self.expire_time: {self.expire_time}") + count = 0 + await self.update_dynamo_dates(lambda_args) + await self.update_ha_dates() + while missing_entities := self.entities_with_data_missing_from_dynamo(): + count += 1 + LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") + await self.upload_new_history(missing_entities) + LOGGER.debug("Upload of new history complete\n") + + self.lambda_results = await self.client.async_get_profile(lambda_args) + + self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] + # The backend will currently only update upon a new day. FIX! + self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) + LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") + self.manual_update = False + + def get_closest_time(self, lambda_args): + """Get the closest matching time to now from the lambda data set provided.""" + time_based_keys = [ + const.LAMBDA_BASE_DEMAND, + const.LAMBDA_PRICE, + const.LAMBDA_TEMP_CONTROLS, + const.LAMBDA_OPTIMISED_DEMAND, + ] + non_time_based_keys = [ + const.LAMBDA_BASE_COST, + const.LAMBDA_OPTIMISED_COST, + const.LAMBDA_PROJECTED_PERCENT_SAVINGS, + ] + + # Convert lists to {datetime: list_element} + my_data = {} + for key in time_based_keys: + my_data[key] = { + self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[ + key + ][i] + for i in range(len(self.lambda_results[key])) + } + for key in non_time_based_keys: + my_data[key] = self.lambda_results[key] + + # Get closet datetime that is in the past + datetime_np = np.asarray(self.lambda_results[const.LAMBDA_TIMESTAMP]) + filtered = datetime_np[datetime_np < datetime.now(tz=timezone.utc)] + closest_past_date = filtered.max() + + out = {} + for key in time_based_keys: + out[key] = my_data[key][closest_past_date] + + for key in non_time_based_keys: + out[key] = my_data[key] + + if lambda_args[const.LAMBDA_OUTSIDE_RANGE]: + # We're outside of the temp range so simply set the set point to whatever the user has + # requested + out[const.LAMBDA_TEMP_CONTROLS] = lambda_args[const.LAMBDA_SET_POINT] + self.outside_range_flag = True + LOGGER.debug( + f"initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})" + ) + elif self.outside_range_flag: + # We have just entered the temp_range! The optimisation can now be run + LOGGER.debug("Temperature range reached") + self.manual_update = True + self.outside_range_flag = False + return out diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 0512aa4..db98a62 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -21,16 +21,16 @@ ) from . import const from . import get_entity -from . import history +# from . import history +from .backend_update_handler import BackendUpdateHandler from .const import LOGGER from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry from homeassistant.helpers import entity_registry from homeassistant.helpers import template -import numpy as np +# import numpy as np - -class OptisparkSetTemperatureError(Exception): - """Error while setting the temperature of the heat pump.""" +from .domain.exception.exception import OptisparkSetTemperatureError +# from .domain.value_object.address import Address class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): @@ -82,7 +82,7 @@ def __init__( const.LAMBDA_ADDRESS: self._address, const.LAMBDA_CITY: self._city, } - self._lambda_update_handler = LambdaUpdateHandler( + self._lambda_update_handler = BackendUpdateHandler( hass=self.hass, client=self.client, climate_entity_id=self._climate_entity_id, @@ -91,6 +91,7 @@ def __init__( user_hash=self._user_hash, postcode=self._postcode, address=self._address, + country=self._country, city=self._city, tariff=self._tariff, ) @@ -336,391 +337,407 @@ async def _async_update_data(self): raise UpdateFailed(exception) from exception -class LambdaUpdateHandler: - """Handles everything lambda. - - Gets the heating profile and ensure dynamo is up to date. - """ - - def __init__( - self, - hass, - client: OptisparkApiClient, - climate_entity_id, - heat_pump_power_entity_id, - external_temp_entity_id, - user_hash, - postcode, - address, - city, - tariff, - ): - """Init.""" - self.hass = hass - self.client: OptisparkApiClient = client - self.climate_entity_id = climate_entity_id - self.heat_pump_power_entity_id = heat_pump_power_entity_id - self.external_temp_entity_id = external_temp_entity_id - self.user_hash = user_hash - self.postcode = postcode - self.address = address - self.city = city - self.tariff = tariff - self.expire_time = datetime( - 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc - ) # Already expired - self.manual_update = False - self.history_upload_complete = False - self.outside_range_flag = False - self.id_to_column_name_lookup = { - climate_entity_id: const.DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY, - heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, - external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, - } - LOGGER.debug(f"{self.user_hash = }") - # Entity ids will be None if they are optional and not enabled - self.active_entity_ids = [] - for entity_id in [ - climate_entity_id, - heat_pump_power_entity_id, - external_temp_entity_id, - ]: - if entity_id is not None: - self.active_entity_ids.append(entity_id) - - def get_missing_histories_boundary(self, history_states, dynamo_date): - """Get index where history_state matches dynamo_date.""" - for idx, datum in enumerate(history_states): - if datum.last_updated >= dynamo_date: - idx_bound = idx - return idx_bound - return idx_bound # type: ignore - - def get_missing_old_histories_states(self, history_states, column): - """Get states that are older than anything in dynamo.""" - dynamo_date = self.dynamo_oldest_dates[column] - idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) - return history_states[:idx_bound] - - def get_missing_new_histories_states(self, history_states, column): - """Get states that are newer than anything in dynamo.""" - dynamo_date = self.dynamo_newest_dates[column] - if dynamo_date is None: - # No data in dynamo - upload first x days - dynamo_date = datetime.now(tz=timezone.utc) - timedelta( - days=const.HISTORY_DAYS - ) - idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) - if idx_bound == len(history_states) - 1: - error = True - else: - error = False - return history_states[idx_bound + 1 :], error - - async def upload_new_history(self, missing_entities): - """Upload section of new history states that are newer than anything in dynamo. - - self.dynamo_dates is updated so that if this function is called again a new section will be - uploaded. - const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. - """ - histories = {} - constant_attributes = {} - - async def debug_check_history_length(days): - history_states = await history.get_state_changes( - self.hass, active_entity_id, days - ) - LOGGER.debug(f"---------- days: {days} ----------") - LOGGER.debug( - f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - LOGGER.debug( - f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - - history_states = await history.get_state_changes_period( - self.hass, active_entity_id, days - ) - LOGGER.debug( - f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - LOGGER.debug( - f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - - for active_entity_id in missing_entities: - column = self.id_to_column_name_lookup[active_entity_id] - history_states = await history.get_state_changes( - self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS - ) - missing_new_histories_states, error = self.get_missing_new_histories_states( - history_states, column - ) - if error: - raise RuntimeError( - "No missing history data to upload, should not have gotten here" - ) - - LOGGER.debug(f" column: {column}") - if len(missing_new_histories_states) == 0: - LOGGER.debug(f" ({column}) - Upload complete") - continue - LOGGER.debug( - f" len(missing_new_histories_states): {len(missing_new_histories_states)}" - ) - missing_new_histories_states = missing_new_histories_states[ - : const.MAX_UPLOAD_HISTORY_READINGS - ] - LOGGER.debug( - f" len(missing_new_histories_states): {len(missing_new_histories_states)}" - ) - LOGGER.debug( - f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - LOGGER.debug( - f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - - histories[column], constant_attributes[column] = ( - history.states_to_histories( - self.hass, column, missing_new_histories_states - ) - ) - if histories == {}: - raise RuntimeError( - "Should not have gotten here! No missing history data to upload" - ) - dynamo_data = history.histories_to_dynamo_data( - self.hass, - histories, - constant_attributes, - self.user_hash, - self.climate_entity_id, - self.postcode, - self.tariff, - ) - ( - self.dynamo_oldest_dates, - self.dynamo_newest_dates, - ) = await self.client.upload_history(dynamo_data) - - async def upload_old_history(self): - """Upload section of old history states that are older than anything in dynamo. - - self.dynamo_dates is updated so that if this function is called again a new section will be - uploaded. - const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. - """ - LOGGER.debug("Uploading portion of old history...") - histories = {} - constant_attributes = {} - for active_entity_id in self.active_entity_ids: - column = self.id_to_column_name_lookup[active_entity_id] - history_states = await history.get_state_changes( - self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS - ) - missing_old_histories_states = self.get_missing_old_histories_states( - history_states, column - ) - - LOGGER.debug(f" column: {column}") - if len(missing_old_histories_states) == 0: - LOGGER.debug(f" ({column}) - Upload complete") - continue - LOGGER.debug( - f" len(missing_old_histories_states): {len(missing_old_histories_states)}" - ) - missing_old_histories_states = missing_old_histories_states[ - -const.MAX_UPLOAD_HISTORY_READINGS : - ] - - histories[column], constant_attributes[column] = ( - history.states_to_histories( - self.hass, column, missing_old_histories_states - ) - ) - if histories == {}: - self.history_upload_complete = True - LOGGER.debug("History upload complete, recalculate heating profile...\n") - # Now that we have all the history, recalculate heating profile - self.manual_update = True - return - dynamo_data = history.histories_to_dynamo_data( - self.hass, - histories, - constant_attributes, - self.user_hash, - self.climate_entity_id, - self.postcode, - self.tariff, - ) - ( - self.dynamo_oldest_dates, - self.dynamo_newest_dates, - ) = await self.client.upload_history(dynamo_data) - - async def __call__(self, lambda_args): - """Return lambda data for the current time. - - Calls lambda if new heating profile is needed - Otherwise, slowly uploads historical data - """ - - if not self._check_running_manual_mode(): - LOGGER.debug('Request manual mode') - print('Request manual mode') - - now = datetime.now(tz=timezone.utc) - # This probably won't result in a smooth transition - if self.expire_time - now < timedelta(hours=0) or self.manual_update: - await self.call_lambda(lambda_args) - else: - if self.history_upload_complete is False: - await self.upload_old_history() - return self.get_closest_time(lambda_args) - - async def _check_running_manual_mode(self) -> bool: - now = datetime.now(tz=timezone.utc) - print(f'{now} - checking mode') - return True - - - async def update_dynamo_dates(self, lambda_args: dict): - """Call the lambda function and get the oldest and newest dates in dynamodb.""" - # print(lambda_args.keys()) - # TODO: create class - dynamo_data = { - "user_hash": self.user_hash, - "postcode": lambda_args["postcode"], - "address": lambda_args["address"], - "city": lambda_args["city"], - "temp_set_point": lambda_args['temp_set_point'], - "heat_pump_mode_raw": lambda_args['heat_pump_mode_raw'] - } - ( - self.dynamo_oldest_dates, - self.dynamo_newest_dates, - ) = await self.client.get_data_dates(dynamo_data=dynamo_data) - - async def update_ha_dates(self): - """Get the oldest and newest dates in HA histories for active_entity_ids.""" - ( - self.ha_oldest_dates, - self.ha_newest_dates, - ) = await history.get_earliest_and_latest_data_dates( - hass=self.hass, - climate_entity_id=self.climate_entity_id, - heat_pump_power_entity_id=self.heat_pump_power_entity_id, - external_temp_entity_id=self.external_temp_entity_id, - ) - - def entities_with_data_missing_from_dynamo(self): - """Return entities with new data that needs to be uploaded. - - If there is data in HA histories for active_entity_ids that is newer than what is in dynamo, - return those entities. - """ - entities_missing = [] - LOGGER.debug("---entities_with_data_missing_from_dynamo---") - for active_entity_id in self.active_entity_ids: - column = self.id_to_column_name_lookup[active_entity_id] - if self.dynamo_newest_dates[column] is None: - # First run, therefore data is missing - LOGGER.debug( - f"First run, upload ({const.HISTORY_DAYS}) days of history...\n" - ) - entities_missing.append(active_entity_id) - continue - if self.dynamo_newest_dates[column] < self.ha_newest_dates[column]: - LOGGER.debug( - f"self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}" - ) - LOGGER.debug( - f"self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}" - ) - LOGGER.debug(f" column: {column}") - LOGGER.debug( - f" dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}" - ) - entities_missing.append(active_entity_id) - return entities_missing - # return False - - async def call_lambda(self, lambda_args): - """Fetch heating profile from AWS Lambda. - - Upload all new and missing data to dynamo first. - If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. - Records the when the heating profile expires and should be refreshed. - """ - LOGGER.debug(f"********** self.expire_time: {self.expire_time}") - count = 0 - await self.update_dynamo_dates(lambda_args) - await self.update_ha_dates() - while missing_entities := self.entities_with_data_missing_from_dynamo(): - count += 1 - LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") - await self.upload_new_history(missing_entities) - LOGGER.debug("Upload of new history complete\n") - - self.lambda_results = await self.client.async_get_profile(lambda_args) - - self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] - # The backend will currently only update upon a new day. FIX! - self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) - LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") - self.manual_update = False - - def get_closest_time(self, lambda_args): - """Get the closest matching time to now from the lambda data set provided.""" - time_based_keys = [ - const.LAMBDA_BASE_DEMAND, - const.LAMBDA_PRICE, - const.LAMBDA_TEMP_CONTROLS, - const.LAMBDA_OPTIMISED_DEMAND, - ] - non_time_based_keys = [ - const.LAMBDA_BASE_COST, - const.LAMBDA_OPTIMISED_COST, - const.LAMBDA_PROJECTED_PERCENT_SAVINGS, - ] - - # Convert lists to {datetime: list_element} - my_data = {} - for key in time_based_keys: - my_data[key] = { - self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[ - key - ][i] - for i in range(len(self.lambda_results[key])) - } - for key in non_time_based_keys: - my_data[key] = self.lambda_results[key] - - # Get closet datetime that is in the past - datetime_np = np.asarray(self.lambda_results[const.LAMBDA_TIMESTAMP]) - filtered = datetime_np[datetime_np < datetime.now(tz=timezone.utc)] - closest_past_date = filtered.max() - - out = {} - for key in time_based_keys: - out[key] = my_data[key][closest_past_date] - - for key in non_time_based_keys: - out[key] = my_data[key] - - if lambda_args[const.LAMBDA_OUTSIDE_RANGE]: - # We're outside of the temp range so simply set the set point to whatever the user has - # requested - out[const.LAMBDA_TEMP_CONTROLS] = lambda_args[const.LAMBDA_SET_POINT] - self.outside_range_flag = True - LOGGER.debug( - f"initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})" - ) - elif self.outside_range_flag: - # We have just entered the temp_range! The optimisation can now be run - LOGGER.debug("Temperature range reached") - self.manual_update = True - self.outside_range_flag = False - return out +# class BackendUpdateHandler: +# """Handles everything lambda. +# +# Gets the heating profile and ensure dynamo is up to date. +# """ +# +# def __init__( +# self, +# hass, +# client: OptisparkApiClient, +# climate_entity_id, +# heat_pump_power_entity_id, +# external_temp_entity_id, +# user_hash, +# postcode, +# address, +# city, +# country, +# tariff, +# ): +# """Init.""" +# self.hass = hass +# self.client: OptisparkApiClient = client +# self.climate_entity_id = climate_entity_id +# self.heat_pump_power_entity_id = heat_pump_power_entity_id +# self.external_temp_entity_id = external_temp_entity_id +# self.user_hash = user_hash +# self.postcode = postcode +# self.address = address +# self.country = country +# self.city = city +# self.tariff = tariff +# self.expire_time = datetime( +# 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc +# ) # Already expired +# self.manual_update = False +# self.history_upload_complete = False +# self.outside_range_flag = False +# self.id_to_column_name_lookup = { +# climate_entity_id: const.DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY, +# heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, +# external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, +# } +# LOGGER.debug(f"{self.user_hash = }") +# # Entity ids will be None if they are optional and not enabled +# self.active_entity_ids = [] +# for entity_id in [ +# climate_entity_id, +# heat_pump_power_entity_id, +# external_temp_entity_id, +# ]: +# if entity_id is not None: +# self.active_entity_ids.append(entity_id) +# +# def get_missing_histories_boundary(self, history_states, dynamo_date): +# """Get index where history_state matches dynamo_date.""" +# for idx, datum in enumerate(history_states): +# if datum.last_updated >= dynamo_date: +# idx_bound = idx +# return idx_bound +# return idx_bound # type: ignore +# +# def get_missing_old_histories_states(self, history_states, column): +# """Get states that are older than anything in dynamo.""" +# dynamo_date = self.dynamo_oldest_dates[column] +# idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) +# return history_states[:idx_bound] +# +# def get_missing_new_histories_states(self, history_states, column): +# """Get states that are newer than anything in dynamo.""" +# dynamo_date = self.dynamo_newest_dates[column] +# if dynamo_date is None: +# # No data in dynamo - upload first x days +# dynamo_date = datetime.now(tz=timezone.utc) - timedelta( +# days=const.HISTORY_DAYS +# ) +# idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) +# if idx_bound == len(history_states) - 1: +# error = True +# else: +# error = False +# return history_states[idx_bound + 1 :], error +# +# async def upload_new_history(self, missing_entities): +# """Upload section of new history states that are newer than anything in dynamo. +# +# self.dynamo_dates is updated so that if this function is called again a new section will be +# uploaded. +# const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. +# """ +# histories = {} +# constant_attributes = {} +# +# async def debug_check_history_length(days): +# history_states = await history.get_state_changes( +# self.hass, active_entity_id, days +# ) +# LOGGER.debug(f"---------- days: {days} ----------") +# LOGGER.debug( +# f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# LOGGER.debug( +# f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# +# history_states = await history.get_state_changes_period( +# self.hass, active_entity_id, days +# ) +# LOGGER.debug( +# f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# LOGGER.debug( +# f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# +# for active_entity_id in missing_entities: +# column = self.id_to_column_name_lookup[active_entity_id] +# history_states = await history.get_state_changes( +# self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS +# ) +# missing_new_histories_states, error = self.get_missing_new_histories_states( +# history_states, column +# ) +# if error: +# raise RuntimeError( +# "No missing history data to upload, should not have gotten here" +# ) +# +# LOGGER.debug(f" column: {column}") +# if len(missing_new_histories_states) == 0: +# LOGGER.debug(f" ({column}) - Upload complete") +# continue +# LOGGER.debug( +# f" len(missing_new_histories_states): {len(missing_new_histories_states)}" +# ) +# missing_new_histories_states = missing_new_histories_states[ +# : const.MAX_UPLOAD_HISTORY_READINGS +# ] +# LOGGER.debug( +# f" len(missing_new_histories_states): {len(missing_new_histories_states)}" +# ) +# LOGGER.debug( +# f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# LOGGER.debug( +# f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' +# ) +# +# histories[column], constant_attributes[column] = ( +# history.states_to_histories( +# self.hass, column, missing_new_histories_states +# ) +# ) +# if histories == {}: +# raise RuntimeError( +# "Should not have gotten here! No missing history data to upload" +# ) +# dynamo_data = history.histories_to_dynamo_data( +# self.hass, +# histories, +# constant_attributes, +# self.user_hash, +# self.climate_entity_id, +# self.postcode, +# self.tariff, +# ) +# ( +# self.dynamo_oldest_dates, +# self.dynamo_newest_dates, +# ) = await self.client.upload_history(dynamo_data) +# +# async def upload_old_history(self): +# """Upload section of old history states that are older than anything in dynamo. +# +# self.dynamo_dates is updated so that if this function is called again a new section will be +# uploaded. +# const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. +# """ +# LOGGER.debug("Uploading portion of old history...") +# histories = {} +# constant_attributes = {} +# for active_entity_id in self.active_entity_ids: +# column = self.id_to_column_name_lookup[active_entity_id] +# history_states = await history.get_state_changes( +# self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS +# ) +# missing_old_histories_states = self.get_missing_old_histories_states( +# history_states, column +# ) +# +# LOGGER.debug(f" column: {column}") +# if len(missing_old_histories_states) == 0: +# LOGGER.debug(f" ({column}) - Upload complete") +# continue +# LOGGER.debug( +# f" len(missing_old_histories_states): {len(missing_old_histories_states)}" +# ) +# missing_old_histories_states = missing_old_histories_states[ +# -const.MAX_UPLOAD_HISTORY_READINGS : +# ] +# +# histories[column], constant_attributes[column] = ( +# history.states_to_histories( +# self.hass, column, missing_old_histories_states +# ) +# ) +# if histories == {}: +# self.history_upload_complete = True +# LOGGER.debug("History upload complete, recalculate heating profile...\n") +# # Now that we have all the history, recalculate heating profile +# self.manual_update = True +# return +# dynamo_data = history.histories_to_dynamo_data( +# self.hass, +# histories, +# constant_attributes, +# self.user_hash, +# self.climate_entity_id, +# self.postcode, +# self.tariff, +# ) +# ( +# self.dynamo_oldest_dates, +# self.dynamo_newest_dates, +# ) = await self.client.upload_history(dynamo_data) +# +# async def __call__(self, lambda_args): +# """Return lambda data for the current time. +# +# Calls lambda if new heating profile is needed +# Otherwise, slowly uploads historical data +# """ +# +# if not self._login(): +# LOGGER.debug('Backend login failed') +# +# +# if not self._check_running_manual_mode(): +# LOGGER.debug('Request manual mode') +# print('Request manual mode') +# +# now = datetime.now(tz=timezone.utc) +# # This probably won't result in a smooth transition +# if self.expire_time - now < timedelta(hours=0) or self.manual_update: +# await self.call_lambda(lambda_args) +# else: +# if self.history_upload_complete is False: +# await self.upload_old_history() +# return self.get_closest_time(lambda_args) +# +# async def _login(self) -> bool: +# address = Address( +# address=self.address, +# city=self.city, +# postcode=self.postcode, +# country='UK' +# ) +# return await self.client.login(user_hash=self.user_hash, address=address) +# +# async def _check_running_manual_mode(self) -> bool: +# now = datetime.now(tz=timezone.utc) +# print(f'{now} - checking mode') +# +# return True +# +# +# async def update_dynamo_dates(self, lambda_args: dict): +# """Call the lambda function and get the oldest and newest dates in dynamodb.""" +# # print(lambda_args.keys()) +# # TODO: create class +# dynamo_data = { +# "user_hash": self.user_hash, +# "postcode": lambda_args["postcode"], +# "address": lambda_args["address"], +# "city": lambda_args["city"], +# "temp_set_point": lambda_args['temp_set_point'], +# "heat_pump_mode_raw": lambda_args['heat_pump_mode_raw'] +# } +# ( +# self.dynamo_oldest_dates, +# self.dynamo_newest_dates, +# ) = await self.client.get_data_dates(dynamo_data=dynamo_data) +# +# async def update_ha_dates(self): +# """Get the oldest and newest dates in HA histories for active_entity_ids.""" +# ( +# self.ha_oldest_dates, +# self.ha_newest_dates, +# ) = await history.get_earliest_and_latest_data_dates( +# hass=self.hass, +# climate_entity_id=self.climate_entity_id, +# heat_pump_power_entity_id=self.heat_pump_power_entity_id, +# external_temp_entity_id=self.external_temp_entity_id, +# ) +# +# def entities_with_data_missing_from_dynamo(self): +# """Return entities with new data that needs to be uploaded. +# +# If there is data in HA histories for active_entity_ids that is newer than what is in dynamo, +# return those entities. +# """ +# entities_missing = [] +# LOGGER.debug("---entities_with_data_missing_from_dynamo---") +# for active_entity_id in self.active_entity_ids: +# column = self.id_to_column_name_lookup[active_entity_id] +# if self.dynamo_newest_dates[column] is None: +# # First run, therefore data is missing +# LOGGER.debug( +# f"First run, upload ({const.HISTORY_DAYS}) days of history...\n" +# ) +# entities_missing.append(active_entity_id) +# continue +# if self.dynamo_newest_dates[column] < self.ha_newest_dates[column]: +# LOGGER.debug( +# f"self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}" +# ) +# LOGGER.debug( +# f"self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}" +# ) +# LOGGER.debug(f" column: {column}") +# LOGGER.debug( +# f" dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}" +# ) +# entities_missing.append(active_entity_id) +# return entities_missing +# # return False +# +# async def call_lambda(self, lambda_args): +# """Fetch heating profile from AWS Lambda. +# +# Upload all new and missing data to dynamo first. +# If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. +# Records the when the heating profile expires and should be refreshed. +# """ +# LOGGER.debug(f"********** self.expire_time: {self.expire_time}") +# count = 0 +# await self.update_dynamo_dates(lambda_args) +# await self.update_ha_dates() +# while missing_entities := self.entities_with_data_missing_from_dynamo(): +# count += 1 +# LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") +# await self.upload_new_history(missing_entities) +# LOGGER.debug("Upload of new history complete\n") +# +# self.lambda_results = await self.client.async_get_profile(lambda_args) +# +# self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] +# # The backend will currently only update upon a new day. FIX! +# self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) +# LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") +# self.manual_update = False +# +# def get_closest_time(self, lambda_args): +# """Get the closest matching time to now from the lambda data set provided.""" +# time_based_keys = [ +# const.LAMBDA_BASE_DEMAND, +# const.LAMBDA_PRICE, +# const.LAMBDA_TEMP_CONTROLS, +# const.LAMBDA_OPTIMISED_DEMAND, +# ] +# non_time_based_keys = [ +# const.LAMBDA_BASE_COST, +# const.LAMBDA_OPTIMISED_COST, +# const.LAMBDA_PROJECTED_PERCENT_SAVINGS, +# ] +# +# # Convert lists to {datetime: list_element} +# my_data = {} +# for key in time_based_keys: +# my_data[key] = { +# self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[ +# key +# ][i] +# for i in range(len(self.lambda_results[key])) +# } +# for key in non_time_based_keys: +# my_data[key] = self.lambda_results[key] +# +# # Get closet datetime that is in the past +# datetime_np = np.asarray(self.lambda_results[const.LAMBDA_TIMESTAMP]) +# filtered = datetime_np[datetime_np < datetime.now(tz=timezone.utc)] +# closest_past_date = filtered.max() +# +# out = {} +# for key in time_based_keys: +# out[key] = my_data[key][closest_past_date] +# +# for key in non_time_based_keys: +# out[key] = my_data[key] +# +# if lambda_args[const.LAMBDA_OUTSIDE_RANGE]: +# # We're outside of the temp range so simply set the set point to whatever the user has +# # requested +# out[const.LAMBDA_TEMP_CONTROLS] = lambda_args[const.LAMBDA_SET_POINT] +# self.outside_range_flag = True +# LOGGER.debug( +# f"initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})" +# ) +# elif self.outside_range_flag: +# # We have just entered the temp_range! The optimisation can now be run +# LOGGER.debug("Temperature range reached") +# self.manual_update = True +# self.outside_range_flag = False +# return out diff --git a/custom_components/optispark/domain/exception/exception.py b/custom_components/optispark/domain/exception/exception.py new file mode 100644 index 0000000..0b1c138 --- /dev/null +++ b/custom_components/optispark/domain/exception/exception.py @@ -0,0 +1,2 @@ +class OptisparkSetTemperatureError(Exception): + """Error while setting the temperature of the heat pump.""" diff --git a/custom_components/optispark/domain/__init__.py b/custom_components/optispark/domain/value_object/__init__.py similarity index 100% rename from custom_components/optispark/domain/__init__.py rename to custom_components/optispark/domain/value_object/__init__.py diff --git a/custom_components/optispark/domain/value_object/address.py b/custom_components/optispark/domain/value_object/address.py new file mode 100644 index 0000000..1b9b516 --- /dev/null +++ b/custom_components/optispark/domain/value_object/address.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Address: + address: str + city: str + postcode: str + country: str + + def __str__(self): + return f"{self.address}, {self.city}, {self.postcode}, {self.country}" \ No newline at end of file diff --git a/custom_components/optispark/domain/value_object/control_info.py b/custom_components/optispark/domain/value_object/control_info.py new file mode 100644 index 0000000..5fa564b --- /dev/null +++ b/custom_components/optispark/domain/value_object/control_info.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ControlInfo: + set_point: float + mode: str + + def __str__(self): + return f"{self.set_point}, {self.mode}" + \ No newline at end of file diff --git a/custom_components/optispark/domain/auth/__init__.py b/custom_components/optispark/infra/__init__.py similarity index 100% rename from custom_components/optispark/domain/auth/__init__.py rename to custom_components/optispark/infra/__init__.py diff --git a/custom_components/optispark/domain/auth/model/__init__.py b/custom_components/optispark/infra/auth/__init__.py similarity index 100% rename from custom_components/optispark/domain/auth/model/__init__.py rename to custom_components/optispark/infra/auth/__init__.py diff --git a/custom_components/optispark/domain/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py similarity index 88% rename from custom_components/optispark/domain/auth/auth_service.py rename to custom_components/optispark/infra/auth/auth_service.py index 9abd6de..2a1687c 100644 --- a/custom_components/optispark/domain/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -3,8 +3,8 @@ import aiohttp from custom_components.optispark.configuration_service import ConfigurationService -from custom_components.optispark.domain.auth.model.login_response import LoginResponse -from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError +from custom_components.optispark.infra.auth.model.login_response import LoginResponse +from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError class AuthService: diff --git a/custom_components/optispark/domain/device/__init__.py b/custom_components/optispark/infra/auth/model/__init__.py similarity index 100% rename from custom_components/optispark/domain/device/__init__.py rename to custom_components/optispark/infra/auth/model/__init__.py diff --git a/custom_components/optispark/domain/auth/model/login_response.py b/custom_components/optispark/infra/auth/model/login_response.py similarity index 100% rename from custom_components/optispark/domain/auth/model/login_response.py rename to custom_components/optispark/infra/auth/model/login_response.py diff --git a/custom_components/optispark/domain/device/model/__init__.py b/custom_components/optispark/infra/device/__init__.py similarity index 100% rename from custom_components/optispark/domain/device/model/__init__.py rename to custom_components/optispark/infra/device/__init__.py diff --git a/custom_components/optispark/domain/device/device_service.py b/custom_components/optispark/infra/device/device_service.py similarity index 85% rename from custom_components/optispark/domain/device/device_service.py rename to custom_components/optispark/infra/device/device_service.py index 906b3fb..84b0930 100644 --- a/custom_components/optispark/domain/device/device_service.py +++ b/custom_components/optispark/infra/device/device_service.py @@ -4,9 +4,9 @@ from aiohttp import ClientResponse from custom_components.optispark.configuration_service import config_service -from custom_components.optispark.domain.device.model.device_request import DeviceRequest -from custom_components.optispark.domain.device.model.device_response import DeviceResponse -from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ +from custom_components.optispark.infra.device.model.device_request import DeviceRequest +from custom_components.optispark.infra.device.model.device_response import DeviceResponse +from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientDeviceError diff --git a/custom_components/optispark/domain/location/model/__init__.py b/custom_components/optispark/infra/device/model/__init__.py similarity index 100% rename from custom_components/optispark/domain/location/model/__init__.py rename to custom_components/optispark/infra/device/model/__init__.py diff --git a/custom_components/optispark/domain/device/model/device_request.py b/custom_components/optispark/infra/device/model/device_request.py similarity index 100% rename from custom_components/optispark/domain/device/model/device_request.py rename to custom_components/optispark/infra/device/model/device_request.py diff --git a/custom_components/optispark/domain/device/model/device_response.py b/custom_components/optispark/infra/device/model/device_response.py similarity index 100% rename from custom_components/optispark/domain/device/model/device_response.py rename to custom_components/optispark/infra/device/model/device_response.py diff --git a/custom_components/optispark/domain/shared/model/__init__.py b/custom_components/optispark/infra/exception/__init__.py similarity index 100% rename from custom_components/optispark/domain/shared/model/__init__.py rename to custom_components/optispark/infra/exception/__init__.py diff --git a/custom_components/optispark/domain/exception/exceptions.py b/custom_components/optispark/infra/exception/exceptions.py similarity index 100% rename from custom_components/optispark/domain/exception/exceptions.py rename to custom_components/optispark/infra/exception/exceptions.py diff --git a/custom_components/optispark/domain/location/location_service.py b/custom_components/optispark/infra/location/location_service.py similarity index 93% rename from custom_components/optispark/domain/location/location_service.py rename to custom_components/optispark/infra/location/location_service.py index c1be1af..7639171 100644 --- a/custom_components/optispark/domain/location/location_service.py +++ b/custom_components/optispark/infra/location/location_service.py @@ -3,14 +3,14 @@ import aiohttp from custom_components.optispark.configuration_service import config_service -from custom_components.optispark.domain.exception.exceptions import ( +from custom_components.optispark.infra.exception.exceptions import ( OptisparkApiClientAuthenticationError, OptisparkApiClientLocationError, ) -from custom_components.optispark.domain.location.model.location_request import ( +from custom_components.optispark.infra.location.model.location_request import ( LocationRequest, ) -from custom_components.optispark.domain.location.model.location_response import LocationResponse +from custom_components.optispark.infra.location.model.location_response import LocationResponse class LocationService: diff --git a/custom_components/optispark/domain/thermostat/__init__.py b/custom_components/optispark/infra/location/model/__init__.py similarity index 100% rename from custom_components/optispark/domain/thermostat/__init__.py rename to custom_components/optispark/infra/location/model/__init__.py diff --git a/custom_components/optispark/domain/location/model/location_request.py b/custom_components/optispark/infra/location/model/location_request.py similarity index 100% rename from custom_components/optispark/domain/location/model/location_request.py rename to custom_components/optispark/infra/location/model/location_request.py diff --git a/custom_components/optispark/domain/location/model/location_response.py b/custom_components/optispark/infra/location/model/location_response.py similarity index 100% rename from custom_components/optispark/domain/location/model/location_response.py rename to custom_components/optispark/infra/location/model/location_response.py diff --git a/custom_components/optispark/domain/thermostat/model/__init__.py b/custom_components/optispark/infra/shared/model/__init__.py similarity index 100% rename from custom_components/optispark/domain/thermostat/model/__init__.py rename to custom_components/optispark/infra/shared/model/__init__.py diff --git a/custom_components/optispark/domain/shared/model/base_enum.py b/custom_components/optispark/infra/shared/model/base_enum.py similarity index 100% rename from custom_components/optispark/domain/shared/model/base_enum.py rename to custom_components/optispark/infra/shared/model/base_enum.py diff --git a/custom_components/optispark/domain/shared/model/working_mode.py b/custom_components/optispark/infra/shared/model/working_mode.py similarity index 86% rename from custom_components/optispark/domain/shared/model/working_mode.py rename to custom_components/optispark/infra/shared/model/working_mode.py index b7a5df9..b9208d2 100644 --- a/custom_components/optispark/domain/shared/model/working_mode.py +++ b/custom_components/optispark/infra/shared/model/working_mode.py @@ -1,4 +1,4 @@ -from custom_components.optispark.domain.shared.model.base_enum import BaseEnum +from custom_components.optispark.infra.shared.model.base_enum import BaseEnum class WorkingMode(str, BaseEnum): diff --git a/custom_components/optispark/infra/thermostat/__init__.py b/custom_components/optispark/infra/thermostat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/infra/thermostat/model/__init__.py b/custom_components/optispark/infra/thermostat/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_control_request.py b/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py similarity index 92% rename from custom_components/optispark/domain/thermostat/model/thermostat_control_request.py rename to custom_components/optispark/infra/thermostat/model/thermostat_control_request.py index 1e52e63..7e144f5 100644 --- a/custom_components/optispark/domain/thermostat/model/thermostat_control_request.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py @@ -1,6 +1,6 @@ from typing import Optional -from custom_components.optispark.domain.shared.model.working_mode import WorkingMode +from custom_components.optispark.infra.shared.model.working_mode import WorkingMode class ThermostatControlRequest: diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_control_response.py b/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py similarity index 87% rename from custom_components/optispark/domain/thermostat/model/thermostat_control_response.py rename to custom_components/optispark/infra/thermostat/model/thermostat_control_response.py index f9b3ce5..bf03587 100644 --- a/custom_components/optispark/domain/thermostat/model/thermostat_control_response.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py @@ -1,7 +1,7 @@ from typing import Optional -from custom_components.optispark.domain.shared.model.working_mode import WorkingMode -from custom_components.optispark.domain.thermostat.model.thermostat_control_status import ThermostatControlStatus +from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from custom_components.optispark.infra.thermostat.model.thermostat_control_status import ThermostatControlStatus class ThermostatControlResponse: diff --git a/custom_components/optispark/domain/thermostat/model/thermostat_control_status.py b/custom_components/optispark/infra/thermostat/model/thermostat_control_status.py similarity index 59% rename from custom_components/optispark/domain/thermostat/model/thermostat_control_status.py rename to custom_components/optispark/infra/thermostat/model/thermostat_control_status.py index b6358c7..e648630 100644 --- a/custom_components/optispark/domain/thermostat/model/thermostat_control_status.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_control_status.py @@ -1,4 +1,4 @@ -from custom_components.optispark.domain.shared.model.base_enum import BaseEnum +from custom_components.optispark.infra.shared.model.base_enum import BaseEnum class ThermostatControlStatus(str, BaseEnum): diff --git a/custom_components/optispark/domain/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py similarity index 90% rename from custom_components/optispark/domain/thermostat/thermostat_service.py rename to custom_components/optispark/infra/thermostat/thermostat_service.py index 6ec1af8..06f25bb 100644 --- a/custom_components/optispark/domain/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -3,10 +3,10 @@ import aiohttp from custom_components.optispark.configuration_service import ConfigurationService, config_service -from custom_components.optispark.domain.exception.exceptions import OptisparkApiClientAuthenticationError, \ +from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientThermostatError -from custom_components.optispark.domain.thermostat.model.thermostat_control_request import ThermostatControlRequest -from custom_components.optispark.domain.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.infra.thermostat.model.thermostat_control_request import ThermostatControlRequest +from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse class ThermostatService: From 920ae782a2a57cb7c8032887ce27699f5e5ccdcd Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 14:48:21 +0200 Subject: [PATCH 26/52] chore: cleaning comments --- custom_components/optispark/api.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 8d51268..d818786 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -184,9 +184,6 @@ async def get_data_dates(self, dynamo_data: dict): print("--------------------------") # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 print(dynamo_data["user_hash"]) - # print(dynamo_data) - # print(dynamo_data) - # await self._login(payload) control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: LOGGER.debug(f'Control in {control.status} status, requesting manual') From c80b41d4f9b5a62463eb3906af4ca9795b6e1197 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 16:10:43 +0200 Subject: [PATCH 27/52] wip: get graph info --- custom_components/optispark/api.py | 49 +++++++++---------- .../optispark/backend_update_handler.py | 13 +++-- .../optispark/config/config.json | 4 +- .../thermostat/model/thermostat_prediction.py | 27 ++++++++++ .../infra/thermostat/thermostat_service.py | 38 ++++++++++++++ 5 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 custom_components/optispark/infra/thermostat/model/thermostat_prediction.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index d818786..b917e91 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -4,6 +4,7 @@ import asyncio import socket +from typing import List import aiohttp import async_timeout @@ -41,6 +42,7 @@ from .infra.thermostat.model.thermostat_control_request import ThermostatControlRequest from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse from .infra.thermostat.model.thermostat_control_status import ThermostatControlStatus +from .infra.thermostat.model.thermostat_prediction import ThermostatPrediction from .infra.thermostat.thermostat_service import ThermostatService # class OptisparkApiClientError(Exception): @@ -114,6 +116,7 @@ class OptisparkApiClient: _auth_service: AuthService _location_service: LocationService _config_service: ConfigurationService + _thermostat_id: int def __init__( self, @@ -175,43 +178,39 @@ async def check_and_set_manual(self, data: ControlInfo) -> bool: f' {manual_control.cool_set_point}') return manual_control.status == ThermostatControlStatus.MANUAL - - async def get_data_dates(self, dynamo_data: dict): + async def get_data_dates(self, thermostat_id: int): """Call lambda and only get the newest and oldest dates in dynamo. - dynamo_data will only contain the user_hash. """ print("--------------------------") # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 - print(dynamo_data["user_hash"]) + print(self._user_hash) + # Checks self._token, self._has_locations & self._has_devices + # and updates if necessary + await self._login() + # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() - if not control.status == ThermostatControlStatus.MANUAL: - LOGGER.debug(f'Control in {control.status} status, requesting manual') - print(f' {control.status} --> generating manual control request') - manual_control = await self.create_manual( - thermostat_id=control.thermostat_id, - set_point=dynamo_data["temp_set_point"], - mode=dynamo_data["heat_pump_mode_raw"] - ) - print(f'Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/' - f' {manual_control.cool_set_point}') + graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( + access_token=self._token, + thermostat_id=control.thermostat_id + ) - tz = pytz.timezone("Europe/London") - todays_time = time.time() - current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) - # yesterday = current - datetime.date.timedelta(days=1) - yesterday = datetime(year=2024, month=6, day=8) + oldest_date = graph_data[-1].date + newest_date = graph_data[0].date + + print(oldest_date) + print(newest_date) extra = { "oldest_dates": { - "heat_pump_power": yesterday, - "external_temperature": yesterday, - "climate_entity": yesterday, + "heat_pump_power": oldest_date, + "external_temperature": oldest_date, + "climate_entity": oldest_date, }, "newest_dates": { - "heat_pump_power": current, - "external_temperature": current, - "climate_entity": current, + "heat_pump_power": newest_date, + "external_temperature": newest_date, + "climate_entity": newest_date, }, } diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 39e88ef..6a809f9 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -244,7 +244,7 @@ async def __call__(self, lambda_args): now = datetime.now(tz=timezone.utc) # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: - await self.get_heating_profile(lambda_args) + await self.get_heating_profile(lambda_args, 1) else: if self.history_upload_complete is False: await self.upload_old_history() @@ -320,7 +320,7 @@ def entities_with_data_missing_from_dynamo(self): return entities_missing # return False - async def get_heating_profile(self, lambda_args): + async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): """Fetch heating profile from Optispark Backend. Upload all new and missing data to dynamo first. @@ -330,8 +330,13 @@ async def get_heating_profile(self, lambda_args): print(f'******************************** HEATING PROFILE ******************************************') LOGGER.debug(f"********** self.expire_time: {self.expire_time}") count = 0 - await self.update_dynamo_dates(lambda_args) - await self.update_ha_dates() + # await self.update_dynamo_dates(lambda_args) + # await self.update_ha_dates() + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.get_data_dates(thermostat_id=thermostat_id) + while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 12b341f..0f59519 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -1,4 +1,5 @@ { + "hoursFromNow": 24, "backend": { "baseUrl": "http://localhost:5000", "location": { @@ -9,7 +10,8 @@ }, "thermostat": { "control": "thermostat/{thermostat_id}/control", - "manual": "thermostat/{thermostat_id}/control/manual" + "manual": "thermostat/{thermostat_id}/control/manual", + "graph": "thermostat/{thermostat_id}/graph" } } } \ No newline at end of file diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py b/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py new file mode 100644 index 0000000..779d710 --- /dev/null +++ b/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from custom_components.optispark.infra.shared.model.working_mode import WorkingMode + + +class ThermostatPrediction: + date: datetime + mode: WorkingMode + set_point: float + external_temperature: float + + def __init__(self, date: datetime, mode: WorkingMode, set_point: float, external_temperature: float): + self._date = datetime + self._mode = mode + self._set_point = set_point + self.external_temperature = external_temperature + + @classmethod + def from_json(cls, json: dict): + datetime_obj = datetime.strptime(json['date'], "%Y-%m-%dT%H:%M:%S.%fZ") + mode = WorkingMode.from_string(json['mode']) + return cls( + date=datetime_obj, + mode=mode, + set_point=json['setPoint'], + external_temperature=json['externalTemperature'] + ) diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py index 06f25bb..475b325 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from typing import List import aiohttp @@ -7,6 +8,7 @@ OptisparkApiClientThermostatError from custom_components.optispark.infra.thermostat.model.thermostat_control_request import ThermostatControlRequest from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.infra.thermostat.model.thermostat_prediction import ThermostatPrediction class ThermostatService: @@ -92,4 +94,40 @@ async def create_manual( print(f"Unexpected error occurred: {e}") raise + async def get_graph(self, thermostat_id:int, access_token: str) -> List[ThermostatPrediction]: + # Graph query param, + hours_from_now = config_service.get("hoursFromNow") + endpoint = config_service.get("backend.thermostat.graph") + graph_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) + headers = { + "Authorization": f"Bearer {access_token}", + } + try: + response = await self._session.get( + url=graph_url, + headers=headers, + params={'hours_from_now': hours_from_now} + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientThermostatError( + "Get graph error", + ) from Exception + + json_array = await response.json() + return [ThermostatPrediction.from_json(item) for item in json_array] + # return ThermostatControlResponse.from_json(json_response) + + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") + raise OptisparkApiClientThermostatError("get graph error") from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise + \ No newline at end of file From c3804ea9a2420b5b0de7c9996a96880d53c60040 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 16:35:33 +0200 Subject: [PATCH 28/52] wip --- custom_components/optispark/api.py | 2 +- custom_components/optispark/backend_update_handler.py | 5 +++-- .../infra/thermostat/model/thermostat_prediction.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index b917e91..6e0884c 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -178,7 +178,7 @@ async def check_and_set_manual(self, data: ControlInfo) -> bool: f' {manual_control.cool_set_point}') return manual_control.status == ThermostatControlStatus.MANUAL - async def get_data_dates(self, thermostat_id: int): + async def get_data_dates(self): """Call lambda and only get the newest and oldest dates in dynamo. dynamo_data will only contain the user_hash. """ diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 6a809f9..a880c50 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -61,6 +61,7 @@ def __init__( def get_missing_histories_boundary(self, history_states, dynamo_date): """Get index where history_state matches dynamo_date.""" + idx_bound = 0 for idx, datum in enumerate(history_states): if datum.last_updated >= dynamo_date: idx_bound = idx @@ -330,12 +331,12 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): print(f'******************************** HEATING PROFILE ******************************************') LOGGER.debug(f"********** self.expire_time: {self.expire_time}") count = 0 - # await self.update_dynamo_dates(lambda_args) - # await self.update_ha_dates() + await self.update_dynamo_dates(lambda_args) ( self.dynamo_oldest_dates, self.dynamo_newest_dates, ) = await self.client.get_data_dates(thermostat_id=thermostat_id) + await self.update_ha_dates() while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py b/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py index 779d710..1654464 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py @@ -10,9 +10,9 @@ class ThermostatPrediction: external_temperature: float def __init__(self, date: datetime, mode: WorkingMode, set_point: float, external_temperature: float): - self._date = datetime - self._mode = mode - self._set_point = set_point + self.date = date + self.mode = mode + self.set_point = set_point self.external_temperature = external_temperature @classmethod From 1bb8f76f88503703f2ab44dd33f64b281b9f872d Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 16:43:29 +0200 Subject: [PATCH 29/52] fix --- custom_components/optispark/backend_update_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index a880c50..b71a313 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -275,7 +275,7 @@ async def update_dynamo_dates(self, lambda_args: dict): ( self.dynamo_oldest_dates, self.dynamo_newest_dates, - ) = await self.client.get_data_dates(dynamo_data=dynamo_data) + ) = await self.client.get_data_dates() async def update_ha_dates(self): """Get the oldest and newest dates in HA histories for active_entity_ids.""" @@ -335,7 +335,7 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): ( self.dynamo_oldest_dates, self.dynamo_newest_dates, - ) = await self.client.get_data_dates(thermostat_id=thermostat_id) + ) = await self.client.get_data_dates() await self.update_ha_dates() while missing_entities := self.entities_with_data_missing_from_dynamo(): From 3f4e2b7347c28e81f5ce4e285535d99aa755325a Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 11 Jul 2024 16:51:37 +0200 Subject: [PATCH 30/52] chore: get profile from graph data --- custom_components/optispark/api.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 6e0884c..0aaae13 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -117,6 +117,8 @@ class OptisparkApiClient: _location_service: LocationService _config_service: ConfigurationService _thermostat_id: int + # This is temporal + _graph_data: dict def __init__( self, @@ -190,13 +192,13 @@ async def get_data_dates(self): await self._login() # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() - graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( + self._graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( access_token=self._token, thermostat_id=control.thermostat_id ) - oldest_date = graph_data[-1].date - newest_date = graph_data[0].date + oldest_date = self._graph_data[0].date + newest_date = self._graph_data[-1].date print(oldest_date) print(newest_date) @@ -253,13 +255,22 @@ async def async_get_profile(self, lambda_args: dict): todays_time = time.time() current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) + if not self._graph_data: + await self._login() + # TODO: change this, store thermostat_id and add logic to update if necessary + control = await self.get_thermostat_control() + self._graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( + access_token=self._token, + thermostat_id=control.thermostat_id + ) + results = { - "timestamp": [current], + "timestamp": [self._graph_data[0].date], "electricity_price": [10], "base_power": [15], "optimised_power": [10], - "optimised_internal_temp": [17], - "external_temp": [15], + "optimised_internal_temp": [self._graph_data[0].set_point], + "external_temp": [self._graph_data[0].set_point], "temp_controls": [2], "dni": [10], "total_cost_optimised": 1.3, From b6bd4d67b840c5b79aff21ca6627d72fdb44f6b5 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 08:23:24 +0200 Subject: [PATCH 31/52] chore: added cache to ConfigurationService --- custom_components/optispark/configuration_service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/optispark/configuration_service.py b/custom_components/optispark/configuration_service.py index e2f004a..50f4dcb 100644 --- a/custom_components/optispark/configuration_service.py +++ b/custom_components/optispark/configuration_service.py @@ -5,6 +5,7 @@ class ConfigurationService: _instance = None _initialized = False + _cache = {} def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -37,12 +38,16 @@ def get(self, path): if not ConfigurationService._initialized: raise Exception("ConfigurationService must be initialized with a config file before use.") + if path in ConfigurationService._cache: + return ConfigurationService._cache[path] + keys = path.split('.') data = self.config_data for key in keys: data = data.get(key, {}) if data == {}: break + ConfigurationService._cache[path] = data return data From e529a4a72baa62818b0632cd53bf875957b365ee Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 08:36:58 +0200 Subject: [PATCH 32/52] chore: default value option in get from ConfigurationService --- .../optispark/configuration_service.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/custom_components/optispark/configuration_service.py b/custom_components/optispark/configuration_service.py index 50f4dcb..7b24eed 100644 --- a/custom_components/optispark/configuration_service.py +++ b/custom_components/optispark/configuration_service.py @@ -34,7 +34,7 @@ def _load_config(self): ) return {} - def get(self, path): + def get(self, path, default=None): if not ConfigurationService._initialized: raise Exception("ConfigurationService must be initialized with a config file before use.") @@ -44,11 +44,14 @@ def get(self, path): keys = path.split('.') data = self.config_data for key in keys: - data = data.get(key, {}) - if data == {}: - break + if isinstance(data, dict): + data = data.get(key, default) + else: + data = default + + # Cache the result before returning ConfigurationService._cache[path] = data return data -config_service = ConfigurationService(config_file='./config/config.json') \ No newline at end of file +config_service = ConfigurationService(config_file='./config/config.json') From 127e3261d477b64d6b4a92c6a7b4b926e3f8fd81 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 08:53:45 +0200 Subject: [PATCH 33/52] chore: disable ssl verification option --- .../optispark/config/config.json | 3 ++- .../optispark/infra/auth/auth_service.py | 23 +++++++++++-------- .../optispark/infra/device/device_service.py | 14 ++++++----- .../infra/location/location_service.py | 21 ++++++++--------- .../infra/thermostat/thermostat_service.py | 10 +++++--- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 0f59519..70c9ba2 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -1,7 +1,8 @@ { "hoursFromNow": 24, "backend": { - "baseUrl": "http://localhost:5000", + "baseUrl": "https://ec2-18-135-103-142.eu-west-2.compute.amazonaws.com/docs", + "verifySSL": false, "location": { "base": "/location/" }, diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 2a1687c..978a11d 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -2,32 +2,30 @@ import aiohttp -from custom_components.optispark.configuration_service import ConfigurationService +from custom_components.optispark.configuration_service import config_service from custom_components.optispark.infra.auth.model.login_response import LoginResponse from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError class AuthService: - _config_service: ConfigurationService - def __init__( self, session: aiohttp.ClientSession, ) -> None: """Sample API Client.""" self._session = session - self._config_service = ConfigurationService(config_file="./config/config.json") + self._base_url = config_service.get('backend.baseUrl') + self._ssl = config_service.get('verifySSL', default=True) async def login(self, user_hash: str) -> LoginResponse: - base_url = self._config_service.get("backend.baseUrl") - auth_url = f'{base_url}/auth/ha_login' + auth_url = f'{self._base_url}/auth/ha_login' try: payload = {"user_hash": user_hash} - response = await self._session.request( - method="post", + response = await self._session.post( url=auth_url, json=payload, + ssl=self._ssl ) if response.status != HTTPStatus.OK: @@ -44,7 +42,12 @@ async def login(self, user_hash: str) -> LoginResponse: has_devices=json_response["hasDevices"], ) - except: + except aiohttp.ClientError as e: + print(f"HTTP error occurred: {e}") raise OptisparkApiClientAuthenticationError( "Invalid credentials", - ) from Exception \ No newline at end of file + ) from e + except Exception as e: + print(f"Unexpected error occurred: {e}") + raise + \ No newline at end of file diff --git a/custom_components/optispark/infra/device/device_service.py b/custom_components/optispark/infra/device/device_service.py index 84b0930..aef6ff8 100644 --- a/custom_components/optispark/infra/device/device_service.py +++ b/custom_components/optispark/infra/device/device_service.py @@ -18,20 +18,22 @@ def __init__( ) -> None: """Sample API Client.""" self._session = session + self._base_url = config_service.get("backend.baseUrl") + self._ssl = config_service.get('verifySSL', default=True) async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceResponse | None: - base_url = config_service.get("backend.baseUrl") - device_url = f'{base_url}{config_service.get("backend.device.base")}' + + device_url = f'{self._base_url}{config_service.get("backend.device.base")}' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } try: - response: ClientResponse = await self._session.request( - method="post", + response: ClientResponse = await self._session.post( url=device_url, headers=headers, json=request.payload(), + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: @@ -44,8 +46,8 @@ async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceR "Add device error", ) from Exception - jsonResponse = await response.json() - return DeviceResponse.from_json(jsonResponse) + json_response = await response.json() + return DeviceResponse.from_json(json_response) except aiohttp.ClientError as e: print(f"HTTP error occurred: {e}") diff --git a/custom_components/optispark/infra/location/location_service.py b/custom_components/optispark/infra/location/location_service.py index 7639171..22116a1 100644 --- a/custom_components/optispark/infra/location/location_service.py +++ b/custom_components/optispark/infra/location/location_service.py @@ -20,23 +20,24 @@ def __init__( ) -> None: """Sample API Client.""" self._session = session + self._base_url = config_service.get("backend.baseUrl") + self._ssl = config_service.get('verifySSL', default=True) async def add_location(self, request: LocationRequest, access_token: str) -> LocationResponse | None: """Add new location""" - base_url = config_service.get("backend.baseUrl") - location_url = f'{base_url}{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}{config_service.get("backend.location.base")}' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } try: - response = await self._session.request( - method="post", + response = await self._session.post( url=location_url, headers=headers, json=request.payload(), + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: @@ -61,17 +62,16 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc async def get_locations(self, access_token: str) -> [LocationResponse]: """Get locations from OptiSpark backend""" - base_url = config_service.get("backend.baseUrl") - location_url = f'{base_url}{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}{config_service.get("backend.location.base")}' headers = { "Authorization": f"Bearer {access_token}" } try: - response = await self._session.request( - method="get", + response = await self._session.get( url=location_url, headers=headers, + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: @@ -84,9 +84,8 @@ async def get_locations(self, access_token: str) -> [LocationResponse]: "Get locations error", ) from Exception - jsonResponse = await response.json() - # return LocationResponse.from_json(jsonResponse) - locations = list(map(LocationResponse.from_json, jsonResponse)) + json_response = await response.json() + locations = list(map(LocationResponse.from_json, json_response)) # Filter out any None values in case of invalid JSON elements return [location for location in locations if location is not None] diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py index 475b325..0038d6a 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -21,6 +21,7 @@ def __init__( self._session = session self._config_service: ConfigurationService = config_service self._base_url = config_service.get("backend.baseUrl") + self._ssl = config_service.get("verifySSL") async def get_control(self, thermostat_id: int, access_token: str) -> ThermostatControlResponse: endpoint = config_service.get("backend.thermostat.control") @@ -30,9 +31,9 @@ async def get_control(self, thermostat_id: int, access_token: str) -> Thermostat } try: response = await self._session.get( - # method="get", url=thermostat_url, headers=headers, + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: @@ -61,6 +62,7 @@ async def create_manual( request: ThermostatControlRequest, access_token: str ) -> ThermostatControlResponse: + ssl = config_service.get('verifySSL', default=True) endpoint = config_service.get("backend.thermostat.manual") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) headers = { @@ -71,7 +73,8 @@ async def create_manual( response = await self._session.post( url=thermostat_url, headers=headers, - json=request.to_dict() + json=request.to_dict(), + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: @@ -106,7 +109,8 @@ async def get_graph(self, thermostat_id:int, access_token: str) -> List[Thermost response = await self._session.get( url=graph_url, headers=headers, - params={'hours_from_now': hours_from_now} + params={'hours_from_now': hours_from_now}, + ssl=self._ssl ) if response.status == HTTPStatus.UNAUTHORIZED: From 70244e43e546be8a1fcac3a3650e61c1ee4d8d2a Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 09:02:50 +0200 Subject: [PATCH 34/52] fix: dictionary key --- custom_components/optispark/config/config.json | 2 +- custom_components/optispark/infra/auth/auth_service.py | 2 +- custom_components/optispark/infra/device/device_service.py | 2 +- .../optispark/infra/location/location_service.py | 2 +- .../optispark/infra/thermostat/thermostat_service.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 70c9ba2..1d09081 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -2,7 +2,7 @@ "hoursFromNow": 24, "backend": { "baseUrl": "https://ec2-18-135-103-142.eu-west-2.compute.amazonaws.com/docs", - "verifySSL": false, + "backend.verifySSL": false, "location": { "base": "/location/" }, diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 978a11d..16fcdaa 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -16,7 +16,7 @@ def __init__( """Sample API Client.""" self._session = session self._base_url = config_service.get('backend.baseUrl') - self._ssl = config_service.get('verifySSL', default=True) + self._ssl = config_service.get('backend.verifySSL', default=True) async def login(self, user_hash: str) -> LoginResponse: auth_url = f'{self._base_url}/auth/ha_login' diff --git a/custom_components/optispark/infra/device/device_service.py b/custom_components/optispark/infra/device/device_service.py index aef6ff8..893bbee 100644 --- a/custom_components/optispark/infra/device/device_service.py +++ b/custom_components/optispark/infra/device/device_service.py @@ -19,7 +19,7 @@ def __init__( """Sample API Client.""" self._session = session self._base_url = config_service.get("backend.baseUrl") - self._ssl = config_service.get('verifySSL', default=True) + self._ssl = config_service.get('backend.verifySSL', default=True) async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceResponse | None: diff --git a/custom_components/optispark/infra/location/location_service.py b/custom_components/optispark/infra/location/location_service.py index 22116a1..c5ce259 100644 --- a/custom_components/optispark/infra/location/location_service.py +++ b/custom_components/optispark/infra/location/location_service.py @@ -21,7 +21,7 @@ def __init__( """Sample API Client.""" self._session = session self._base_url = config_service.get("backend.baseUrl") - self._ssl = config_service.get('verifySSL', default=True) + self._ssl = config_service.get('backend.verifySSL', default=True) async def add_location(self, request: LocationRequest, access_token: str) -> LocationResponse | None: """Add new location""" diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py index 0038d6a..0037b91 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -21,7 +21,7 @@ def __init__( self._session = session self._config_service: ConfigurationService = config_service self._base_url = config_service.get("backend.baseUrl") - self._ssl = config_service.get("verifySSL") + self._ssl = config_service.get("backend.verifySSL") async def get_control(self, thermostat_id: int, access_token: str) -> ThermostatControlResponse: endpoint = config_service.get("backend.thermostat.control") @@ -62,7 +62,7 @@ async def create_manual( request: ThermostatControlRequest, access_token: str ) -> ThermostatControlResponse: - ssl = config_service.get('verifySSL', default=True) + ssl = config_service.get('backend.verifySSL', default=True) endpoint = config_service.get("backend.thermostat.manual") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) headers = { From b309e5aebda147589224f0f59d03d2bb9a3724f2 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 09:43:57 +0200 Subject: [PATCH 35/52] wip --- custom_components/optispark/api.py | 10 +++++----- custom_components/optispark/config/config.json | 4 ++-- .../optispark/infra/thermostat/thermostat_service.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 0aaae13..5c6ba57 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -408,13 +408,13 @@ async def _login(self): if not self._token: # user_hash = data["user_hash"] if self._user_hash: - loginResponse: LoginResponse = await self._auth_service.login( + login_response: LoginResponse = await self._auth_service.login( user_hash=self._user_hash ) - self._token = loginResponse.token - self._has_locations = loginResponse.has_locations - self._has_devices = loginResponse.has_devices - LOGGER.debug(f" User token: {loginResponse.token}") + self._token = login_response.token + self._has_locations = login_response.has_locations + self._has_devices = login_response.has_devices + LOGGER.debug(f" User token: {login_response.token}") if not self._has_locations: location_request = LocationRequest( name="home", diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 1d09081..9215b6e 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -1,8 +1,8 @@ { "hoursFromNow": 24, "backend": { - "baseUrl": "https://ec2-18-135-103-142.eu-west-2.compute.amazonaws.com/docs", - "backend.verifySSL": false, + "baseUrl": "http://localhost:5000", + "verifySSL": false, "location": { "base": "/location/" }, diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py index 0037b91..3a05a2d 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -97,7 +97,7 @@ async def create_manual( print(f"Unexpected error occurred: {e}") raise - async def get_graph(self, thermostat_id:int, access_token: str) -> List[ThermostatPrediction]: + async def get_graph(self, thermostat_id: int, access_token: str) -> List[ThermostatPrediction]: # Graph query param, hours_from_now = config_service.get("hoursFromNow") endpoint = config_service.get("backend.thermostat.graph") From b537228ff64343aaff28a964d6d1dee57865a6f6 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Fri, 12 Jul 2024 13:01:41 +0200 Subject: [PATCH 36/52] wip --- custom_components/optispark/api.py | 110 +++++++++++------- .../optispark/backend_update_handler.py | 6 +- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 5c6ba57..0f3c648 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -40,7 +40,9 @@ from .infra.shared.model.working_mode import WorkingMode from .infra.thermostat.model.thermostat_control_request import ThermostatControlRequest -from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from .infra.thermostat.model.thermostat_control_response import ( + ThermostatControlResponse, +) from .infra.thermostat.model.thermostat_control_status import ThermostatControlStatus from .infra.thermostat.model.thermostat_prediction import ThermostatPrediction from .infra.thermostat.thermostat_service import ThermostatService @@ -72,7 +74,7 @@ # class OptisparkApiClientUnitError(OptisparkApiClientError): # """Exception to indicate unit error.""" -BACKEND_URL = 'backend.url' +BACKEND_URL = "backend.url" def floats_to_decimal(obj): @@ -121,10 +123,7 @@ class OptisparkApiClient: _graph_data: dict def __init__( - self, - session: aiohttp.ClientSession, - user_hash: str, - address: Address + self, session: aiohttp.ClientSession, user_hash: str, address: Address ) -> None: """Sample API Client.""" self._session = session @@ -169,15 +168,19 @@ async def check_and_set_manual(self, data: ControlInfo) -> bool: control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: - LOGGER.debug(f'Control in {control.status} status, requesting manual change...') - print(f' {control.status} --> generating manual control request') + LOGGER.debug( + f"Control in {control.status} status, requesting manual change..." + ) + print(f" {control.status} --> generating manual control request") manual_control = await self.create_manual( thermostat_id=control.thermostat_id, set_point=data.set_point, - mode=data.mode + mode=data.mode, + ) + print( + f"Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/" + f" {manual_control.cool_set_point}" ) - print(f'Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/' - f' {manual_control.cool_set_point}') return manual_control.status == ThermostatControlStatus.MANUAL async def get_data_dates(self): @@ -192,9 +195,10 @@ async def get_data_dates(self): await self._login() # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() - self._graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( - access_token=self._token, - thermostat_id=control.thermostat_id + self._graph_data: List[ + ThermostatPrediction + ] = await self._thermostat_service.get_graph( + access_token=self._token, thermostat_id=control.thermostat_id ) oldest_date = self._graph_data[0].date @@ -259,9 +263,15 @@ async def async_get_profile(self, lambda_args: dict): await self._login() # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() - self._graph_data: List[ThermostatPrediction] = await self._thermostat_service.get_graph( - access_token=self._token, - thermostat_id=control.thermostat_id + self._graph_data: List[ + ThermostatPrediction + ] = await self._thermostat_service.get_graph( + access_token=self._token, thermostat_id=control.thermostat_id + ) + + if self._graph_data[0].date.tzinfo is None: + self._graph_data[0].date = self._graph_data[0].date.replace( + tzinfo=timezone.utc ) results = { @@ -283,35 +293,39 @@ async def async_get_profile(self, lambda_args: dict): results["projected_percent_savings"] = 100 else: results["projected_percent_savings"] = ( - results["base_cost"] / results["optimised_cost"] * 100 - 100 + results["base_cost"] / results["optimised_cost"] * 100 - 100 ) return results async def get_thermostat_control(self) -> ThermostatControlResponse: - print('get working mode') + print("get working mode") if not self._token: await self._login() locations = await self._location_service.get_locations(self._token) if locations[0]: thermostat_id = locations[0].thermostat_id - LOGGER.debug(f'Getting thermostat control mode') - control = await self._thermostat_service.get_control(thermostat_id=thermostat_id, access_token=self._token) - print(f'thermostat id: {control.thermostat_id}') - print(f'mode: {control.mode}') - print(f'status: {control.status}') + LOGGER.debug(f"Getting thermostat control mode") + control = await self._thermostat_service.get_control( + thermostat_id=thermostat_id, access_token=self._token + ) + print(f"thermostat id: {control.thermostat_id}") + print(f"mode: {control.mode}") + print(f"status: {control.status}") + # HOTFIX + control.thermostat_id = thermostat_id return control - async def create_manual(self, thermostat_id: int, set_point: float, mode: str) -> ThermostatControlResponse: + async def create_manual( + self, thermostat_id: int, set_point: float, mode: str + ) -> ThermostatControlResponse: request = ThermostatControlRequest( mode=WorkingMode.from_string(mode), heat_set_point=set_point, - cool_set_point=set_point + cool_set_point=set_point, ) result = await self._thermostat_service.create_manual( - thermostat_id=thermostat_id, - request=request, - access_token=self._token + thermostat_id=thermostat_id, request=request, access_token=self._token ) return result @@ -348,7 +362,7 @@ async def _api_wrapper(self, method: str, url: str, data: dict): data_serialised = self.json_serialisable(data) async with async_timeout.timeout(120): - await self._login(data) + await self._login() response = await self._session.request( method=method, @@ -402,7 +416,7 @@ async def _login(self): Makes home asssistant login into OptiSpark backend. checks if user has locations and devices if not create locataion and device - """ + """ LOGGER.debug(f" Initiating login into OptiSpark backend") location: LocationResponse | None = None if not self._token: @@ -426,29 +440,37 @@ async def _login(self): tariff_params={ "product_code": TARIFF_PRODUCT_CODE, "tariff_code": TARIFF_CODE, - } + }, ) - location: LocationResponse | None = await self._location_service.add_location( - request=location_request, - access_token=self._token + print(f"{location_request}") + location: ( + LocationResponse | None + ) = await self._location_service.add_location( + request=location_request, access_token=self._token ) + print(f"{location}") self._has_locations = True if location else False if not self._has_devices: if not location: - locations: [LocationResponse] = await self._location_service.get_locations(access_token=self._token) + locations: [ + LocationResponse + ] = await self._location_service.get_locations(access_token=self._token) + print("****************** LOCATIONS *********************") + print(locations[0]) location = locations[0] device_request = DeviceRequest( - name='Heat Pump', + name="Heat Pump", location_id=location.id, - manufacturer='ha', - model_name='ha_model', - version='version', - integration_params={} + manufacturer="ha", + model_name="ha_model", + version="version", + integration_params={}, ) - device_response: DeviceResponse | None = await self._device_service.add_device( - request=device_request, - access_token=self._token + device_response: ( + DeviceResponse | None + ) = await self._device_service.add_device( + request=device_request, access_token=self._token ) self._has_devices = True if device_response else False diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index b71a313..37b1e9d 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -175,7 +175,8 @@ async def debug_check_history_length(days): ( self.dynamo_oldest_dates, self.dynamo_newest_dates, - ) = await self.client.upload_history(dynamo_data) + ) = await self.client.get_data_dates() + # ) = await self.client.upload_history(dynamo_data) async def upload_old_history(self): """Upload section of old history states that are older than anything in dynamo. @@ -230,7 +231,8 @@ async def upload_old_history(self): ( self.dynamo_oldest_dates, self.dynamo_newest_dates, - ) = await self.client.upload_history(dynamo_data) + ) = await self.client.get_data_dates() + # ) = await self.client.upload_history(dynamo_data) async def __call__(self, lambda_args): """Return lambda data for the current time. From e13fe4f7d07112441af07722622de38e599b32aa Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Mon, 15 Jul 2024 16:53:27 +0200 Subject: [PATCH 37/52] fix: homeassistant.const.TEMP_CELSIUS --- .../optispark/backend_update_handler.py | 9 ++++++--- custom_components/optispark/climate.py | 4 ++-- custom_components/optispark/coordinator.py | 15 +++++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 37b1e9d..1703f4b 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -248,9 +248,9 @@ async def __call__(self, lambda_args): # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: await self.get_heating_profile(lambda_args, 1) - else: - if self.history_upload_complete is False: - await self.upload_old_history() + # else: + # if self.history_upload_complete is False: + # await self.upload_old_history() return self.get_closest_time(lambda_args) async def _check_running_manual_mode(self, lambda_args: dict) -> bool: @@ -299,6 +299,8 @@ def entities_with_data_missing_from_dynamo(self): """ entities_missing = [] LOGGER.debug("---entities_with_data_missing_from_dynamo---") + print('**********************') + print(self.active_entity_ids) for active_entity_id in self.active_entity_ids: column = self.id_to_column_name_lookup[active_entity_id] if self.dynamo_newest_dates[column] is None: @@ -343,6 +345,7 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") + print('[------------ UPDATING!!!!! ----------------]') await self.upload_new_history(missing_entities) LOGGER.debug("Upload of new history complete\n") diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index 435b896..7b7c86b 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -7,7 +7,7 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import UnitOfTemperature from . import const from .coordinator import OptisparkDataUpdateCoordinator @@ -134,7 +134,7 @@ def temperature_unit(self) -> str: The front end of homeassistant deals with unit conversion, it just needs to know which units we are working with. """ - return TEMP_CELSIUS + return UnitOfTemperature.CELSIUS @property def target_temperature(self) -> float: diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index db98a62..e16fcfb 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -32,6 +32,8 @@ from .domain.exception.exception import OptisparkSetTemperatureError # from .domain.value_object.address import Address +from homeassistant.const import UnitOfTemperature + class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" @@ -103,9 +105,9 @@ def convert_sensor_from_farenheit(self, entity, temp): If the sensor uses Farenheit then we'll need to convert Farenheit to Celcius """ sensor_unit = entity.native_unit_of_measurement - if sensor_unit == homeassistant.const.TEMP_CELSIUS: + if sensor_unit == UnitOfTemperature.CELSIUS: return temp - elif sensor_unit == homeassistant.const.TEMP_FAHRENHEIT: + elif sensor_unit == UnitOfTemperature.FAHRENHEIT: # Convert temperature from Celcius to Farenheit return (temp - 32) * 5 / 9 else: @@ -118,9 +120,10 @@ def convert_climate_from_farenheit(self, entity, temp): If the heat_pump uses Farenheit then we'll need to convert Farenheit to Celcius """ heat_pump_unit = entity.temperature_unit - if heat_pump_unit == homeassistant.const.TEMP_CELSIUS: + # if heat_pump_unit == homeassistant.const.TEMP_CELSIUS: + if heat_pump_unit == UnitOfTemperature.CELSIUS: return temp - elif heat_pump_unit == homeassistant.const.TEMP_FAHRENHEIT: + elif heat_pump_unit == UnitOfTemperature.FAHRENHEIT: # Convert temperature from Celcius to Farenheit return (temp - 32) * 5 / 9 else: @@ -133,9 +136,9 @@ def convert_climate_from_celcius(self, entity, temp): If the heat_pump uses Farenheit then we'll need to convert Celcius to Farenheit """ heat_pump_unit = entity.temperature_unit - if heat_pump_unit == homeassistant.const.TEMP_CELSIUS: + if heat_pump_unit == UnitOfTemperature.CELSIUS: return temp - elif heat_pump_unit == homeassistant.const.TEMP_FAHRENHEIT: + elif heat_pump_unit == UnitOfTemperature.FAHRENHEIT: # Convert temperature from Celcius to Farenheit return temp * 9 / 5 + 32 else: From 619d73570c4b42a6a411e1a6e82e8f34e4d1190d Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 09:02:16 +0200 Subject: [PATCH 38/52] chore: update set_point on startup --- custom_components/optispark/api.py | 10 +++++----- .../optispark/backend_update_handler.py | 17 ++++++++++------- custom_components/optispark/coordinator.py | 1 - 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 0f3c648..fbb2ae6 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -163,7 +163,7 @@ async def upload_history(self, dynamo_data): # async def check_and_set_manual(self, data: dict): - async def check_and_set_manual(self, data: ControlInfo) -> bool: + async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: """Checks if optispark is running in manual, if not set manual mode""" control = await self.get_thermostat_control() @@ -172,16 +172,16 @@ async def check_and_set_manual(self, data: ControlInfo) -> bool: f"Control in {control.status} status, requesting manual change..." ) print(f" {control.status} --> generating manual control request") - manual_control = await self.create_manual( + control = await self.create_manual( thermostat_id=control.thermostat_id, set_point=data.set_point, mode=data.mode, ) print( - f"Created: {manual_control.status} - {manual_control.mode} - {manual_control.heat_set_point} -/" - f" {manual_control.cool_set_point}" + f"Created: {control.status} - {control.mode} - {control.heat_set_point} -/" + f" {control.cool_set_point}" ) - return manual_control.status == ThermostatControlStatus.MANUAL + return control async def get_data_dates(self): """Call lambda and only get the newest and oldest dates in dynamo. diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 1703f4b..ebdf585 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -5,6 +5,7 @@ import numpy as np from custom_components.optispark.domain.value_object.control_info import ControlInfo +from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse class BackendUpdateHandler: @@ -240,20 +241,24 @@ async def __call__(self, lambda_args): Calls lambda if new heating profile is needed Otherwise, slowly uploads historical data """ - - if not self._check_running_manual_mode(lambda_args): - LOGGER.debug('Request manual mode') + # if not self._check_running_manual_mode(lambda_args): + # LOGGER.debug('Request manual mode') + thermostat = await self._check_running_manual_mode(lambda_args) + if thermostat.mode == 'COOLING': + lambda_args[const.LAMBDA_SET_POINT] = thermostat.cool_set_point if thermostat.cool_set_point else 20 + else: + lambda_args[const.LAMBDA_SET_POINT] = thermostat.heat_set_point if thermostat.heat_set_point else 20 now = datetime.now(tz=timezone.utc) # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: - await self.get_heating_profile(lambda_args, 1) + await self.get_heating_profile(lambda_args, thermostat_id=thermostat.thermostat_id) # else: # if self.history_upload_complete is False: # await self.upload_old_history() return self.get_closest_time(lambda_args) - async def _check_running_manual_mode(self, lambda_args: dict) -> bool: + async def _check_running_manual_mode(self, lambda_args: dict) -> ThermostatControlResponse: # now = datetime.now(tz=timezone.utc) # print(f'{now} - checking mode') data = ControlInfo( @@ -299,8 +304,6 @@ def entities_with_data_missing_from_dynamo(self): """ entities_missing = [] LOGGER.debug("---entities_with_data_missing_from_dynamo---") - print('**********************') - print(self.active_entity_ids) for active_entity_id in self.active_entity_ids: column = self.id_to_column_name_lookup[active_entity_id] if self.dynamo_newest_dates[column] is None: diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index e16fcfb..e41952f 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -120,7 +120,6 @@ def convert_climate_from_farenheit(self, entity, temp): If the heat_pump uses Farenheit then we'll need to convert Farenheit to Celcius """ heat_pump_unit = entity.temperature_unit - # if heat_pump_unit == homeassistant.const.TEMP_CELSIUS: if heat_pump_unit == UnitOfTemperature.CELSIUS: return temp elif heat_pump_unit == UnitOfTemperature.FAHRENHEIT: From 5e2c03ca246ef093722016d8b598d82419a3b824 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 10:44:58 +0200 Subject: [PATCH 39/52] chore: climate fetches target temp on startup --- custom_components/optispark/api.py | 1 + custom_components/optispark/climate.py | 15 +++++++++++++-- custom_components/optispark/coordinator.py | 7 +++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index fbb2ae6..b705da8 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -163,6 +163,7 @@ async def upload_history(self, dynamo_data): # async def check_and_set_manual(self, data: dict): + # TODO: remove this method async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: """Checks if optispark is running in manual, if not set manual mode""" diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index 7b7c86b..bf9e1ca 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -12,6 +12,7 @@ from . import const from .coordinator import OptisparkDataUpdateCoordinator from .entity import OptisparkEntity +from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse #from .const import LOGGER @@ -26,11 +27,20 @@ async def async_setup_entry(hass, entry, async_add_devices): """Set up the climate platform.""" - coordinator = hass.data[const.DOMAIN][entry.entry_id] + coordinator: OptisparkDataUpdateCoordinator = hass.data[const.DOMAIN][entry.entry_id] + thermostat_info: ThermostatControlResponse = await coordinator.fetch_thermostat_info() + target_temp = 20 + if thermostat_info.mode == 'HEATING' and thermostat_info.heat_set_point: + target_temp = thermostat_info.heat_set_point + elif thermostat_info.mode == 'COOLING' and thermostat_info.cool_set_point: + target_temp = thermostat_info.cool_set_point + + async_add_devices( OptisparkClimate( coordinator=coordinator, entity_description=entity_description, + target_temp=target_temp ) for entity_description in ENTITY_DESCRIPTIONS ) @@ -43,11 +53,12 @@ def __init__( self, coordinator: OptisparkDataUpdateCoordinator, entity_description: ClimateEntityDescription, + target_temp: float, ) -> None: """Initialize the sensor class.""" super().__init__(coordinator) self.entity_description = entity_description - self._target_temperature = 20 + self._target_temperature = target_temp if target_temp else 20 self._target_temperature_high = 25 self._target_temperature_low = 20 self._hvac_mode = HVACMode.HEAT diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index e41952f..4b254cc 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -34,6 +34,8 @@ from homeassistant.const import UnitOfTemperature +from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse + class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" @@ -98,6 +100,11 @@ def __init__( tariff=self._tariff, ) + async def fetch_thermostat_info(self) -> ThermostatControlResponse: + """Fetchs thermostat info from OptiSpark backend""" + + return await self.client.get_thermostat_control() + def convert_sensor_from_farenheit(self, entity, temp): """Ensure that the sensor returns values in Celcius. From 39ca6ed0578c7e7e51b907180691112662bb2f8e Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 12:17:44 +0200 Subject: [PATCH 40/52] chore: fetch climate data from backend --- custom_components/optispark/api.py | 21 +++++++++-- custom_components/optispark/climate.py | 17 +++++---- custom_components/optispark/coordinator.py | 6 ++-- .../optispark/domain/thermostat/__init__.py | 0 .../domain/thermostat/thermostat_info.py | 36 +++++++++++++++++++ custom_components/optispark/utils.py | 25 +++++++++++++ 6 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 custom_components/optispark/domain/thermostat/__init__.py create mode 100644 custom_components/optispark/domain/thermostat/thermostat_info.py create mode 100644 custom_components/optispark/utils.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index b705da8..bb3a27f 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -21,6 +21,7 @@ import traceback from http import HTTPStatus +from .domain.thermostat.thermostat_info import ThermostatInfo from .domain.value_object.address import Address from .domain.value_object.control_info import ControlInfo from .infra.auth.auth_service import AuthService @@ -46,6 +47,7 @@ from .infra.thermostat.model.thermostat_control_status import ThermostatControlStatus from .infra.thermostat.model.thermostat_prediction import ThermostatPrediction from .infra.thermostat.thermostat_service import ThermostatService +from .utils import to_thermostat_info # class OptisparkApiClientError(Exception): # """Exception to indicate a general API error.""" @@ -256,9 +258,9 @@ async def async_get_profile(self, lambda_args: dict): payload["get_profile_only"] = True print(lambda_args) LOGGER.debug("----------Lambda get profile----------") - tz = pytz.timezone("Europe/London") - todays_time = time.time() - current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) + # tz = pytz.timezone("Europe/London") + # todays_time = time.time() + # current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) if not self._graph_data: await self._login() @@ -317,6 +319,19 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: control.thermostat_id = thermostat_id return control + async def get_thermostat_info(self) -> ThermostatInfo: + if not self._token: + await self._login() + + locations = await self._location_service.get_locations(self._token) + if locations[0]: + thermostat_id = locations[0].thermostat_id + LOGGER.debug(f"Getting thermostat control mode") + control = await self._thermostat_service.get_control( + thermostat_id=thermostat_id, access_token=self._token + ) + return to_thermostat_info(control) + async def create_manual( self, thermostat_id: int, set_point: float, mode: str ) -> ThermostatControlResponse: diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index bf9e1ca..73d1c2c 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -11,10 +11,8 @@ from . import const from .coordinator import OptisparkDataUpdateCoordinator +from .domain.thermostat.thermostat_info import ThermostatInfo from .entity import OptisparkEntity -from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse - -#from .const import LOGGER ENTITY_DESCRIPTIONS = ( ClimateEntityDescription( @@ -28,13 +26,14 @@ async def async_setup_entry(hass, entry, async_add_devices): """Set up the climate platform.""" coordinator: OptisparkDataUpdateCoordinator = hass.data[const.DOMAIN][entry.entry_id] - thermostat_info: ThermostatControlResponse = await coordinator.fetch_thermostat_info() + thermostat_info: ThermostatInfo = await coordinator.fetch_thermostat_info() target_temp = 20 - if thermostat_info.mode == 'HEATING' and thermostat_info.heat_set_point: - target_temp = thermostat_info.heat_set_point - elif thermostat_info.mode == 'COOLING' and thermostat_info.cool_set_point: - target_temp = thermostat_info.cool_set_point - + if thermostat_info.hvac_mode == HVACMode.COOL and thermostat_info.target_temp_low: + target_temp = thermostat_info.target_temp_low + elif thermostat_info.hvac_mode == HVACMode.HEAT and thermostat_info.target_temp_high: + target_temp = thermostat_info.target_temp_high + elif thermostat_info.hvac_mode == HVACMode.HEAT_COOL: + target_temp = thermostat_info.target_temp_high async_add_devices( OptisparkClimate( diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 4b254cc..26f99ce 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -34,7 +34,7 @@ from homeassistant.const import UnitOfTemperature -from .infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from .domain.thermostat.thermostat_info import ThermostatInfo class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): @@ -100,10 +100,10 @@ def __init__( tariff=self._tariff, ) - async def fetch_thermostat_info(self) -> ThermostatControlResponse: + async def fetch_thermostat_info(self) -> ThermostatInfo: """Fetchs thermostat info from OptiSpark backend""" - return await self.client.get_thermostat_control() + return await self.client.get_thermostat_info() def convert_sensor_from_farenheit(self, entity, temp): """Ensure that the sensor returns values in Celcius. diff --git a/custom_components/optispark/domain/thermostat/__init__.py b/custom_components/optispark/domain/thermostat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/thermostat/thermostat_info.py b/custom_components/optispark/domain/thermostat/thermostat_info.py new file mode 100644 index 0000000..9366f8d --- /dev/null +++ b/custom_components/optispark/domain/thermostat/thermostat_info.py @@ -0,0 +1,36 @@ +from homeassistant.components.climate import HVACMode + + +class ThermostatInfo: + _id: int + _target_temp_high: float + _target_temp_low: float + _hvac_mode: HVACMode + + def __init__( + self, + id: int, + target_temp_high: float = None, + target_temp_low: float = None, + hvac_mode: HVACMode = HVACMode.OFF + ): + self._id = id + self._target_temp_high = target_temp_high if target_temp_high is not None else 20.0 + self._target_temp_low = target_temp_low if target_temp_low is not None else 20.0 + self._hvac_mode = hvac_mode + + @property + def id(self) -> int: + return self._id + + @property + def target_temp_high(self) -> float: + return self._target_temp_high + + @property + def target_temp_low(self) -> float: + return self._target_temp_low + + @property + def hvac_mode(self) -> HVACMode: + return self._hvac_mode diff --git a/custom_components/optispark/utils.py b/custom_components/optispark/utils.py new file mode 100644 index 0000000..a0df1a0 --- /dev/null +++ b/custom_components/optispark/utils.py @@ -0,0 +1,25 @@ +from homeassistant.components.climate import HVACMode + +from custom_components.optispark.domain.thermostat.thermostat_info import ThermostatInfo +from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse + + +def to_thermostat_info(control: ThermostatControlResponse) -> ThermostatInfo: + return ThermostatInfo( + id=control.thermostat_id, + target_temp_high=control.heat_set_point, + target_temp_low=control.cool_set_point, + hvac_mode=to_hvac_mode(control.mode) + ) + + +def to_hvac_mode(mode: WorkingMode) -> HVACMode: + if mode == WorkingMode.HEATING: + return HVACMode.HEAT + if mode == WorkingMode.COOLING: + return HVACMode.COOL + if mode == WorkingMode.HEAT_AND_COOL: + return HVACMode.AUTO + + return HVACMode.OFF From c758a6a2a28656f23cc2779c96826cea66e2f196 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 13:02:23 +0200 Subject: [PATCH 41/52] chore: cleaning --- custom_components/optispark/api.py | 64 +-- .../optispark/backend_update_handler.py | 7 +- .../optispark/configuration_service.py | 6 +- custom_components/optispark/coordinator.py | 405 ------------------ .../optispark/infra/auth/auth_service.py | 7 +- .../optispark/infra/device/device_service.py | 5 +- .../infra/location/location_service.py | 9 +- .../infra/location/model/location_response.py | 7 +- .../model/thermostat_control_response.py | 5 +- .../infra/thermostat/thermostat_service.py | 13 +- 10 files changed, 47 insertions(+), 481 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index bb3a27f..a8fecdc 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -171,16 +171,16 @@ async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResp control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: - LOGGER.debug( - f"Control in {control.status} status, requesting manual change..." + LOGGER.info( + f"Control in {control.status} status, requesting manual mode..." ) - print(f" {control.status} --> generating manual control request") + LOGGER.debug(f" {control.status} --> generating manual control request") control = await self.create_manual( thermostat_id=control.thermostat_id, set_point=data.set_point, mode=data.mode, ) - print( + LOGGER.info( f"Created: {control.status} - {control.mode} - {control.heat_set_point} -/" f" {control.cool_set_point}" ) @@ -190,9 +190,9 @@ async def get_data_dates(self): """Call lambda and only get the newest and oldest dates in dynamo. dynamo_data will only contain the user_hash. """ - print("--------------------------") + # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 - print(self._user_hash) + LOGGER.debug(self._user_hash) # Checks self._token, self._has_locations & self._has_devices # and updates if necessary await self._login() @@ -207,9 +207,6 @@ async def get_data_dates(self): oldest_date = self._graph_data[0].date newest_date = self._graph_data[-1].date - print(oldest_date) - print(newest_date) - extra = { "oldest_dates": { "heat_pump_power": oldest_date, @@ -230,37 +227,10 @@ async def get_data_dates(self): async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" - # lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - # lambda_url = "http://localhost:5000/home-assistant/profile" - # url = self._config_service.get(BACKEND_URL) - # - # payload = lambda_args - # payload["get_profile_only"] = True - # LOGGER.debug("----------Lambda get profile----------") - # results, errors = await self._api_wrapper( - # method="post", - # url=url, - # data=payload, - # ) - # if errors["success"] is False: - # LOGGER.debug(f'OptisparkApiClientLambdaError: {errors["error_message"]}') - # raise OptisparkApiClientLambdaError(errors["error_message"]) - # if results["optimised_cost"] == 0: - # # Heating isn't active. Should the savings be 0? - # results["projected_percent_savings"] = 100 - # else: - # results["projected_percent_savings"] = ( - # results["base_cost"] / results["optimised_cost"] * 100 - 100 - # ) - # return results - + LOGGER.debug("Fetching profile") + # LOGGER.debug(lambda_args) payload = lambda_args payload["get_profile_only"] = True - print(lambda_args) - LOGGER.debug("----------Lambda get profile----------") - # tz = pytz.timezone("Europe/London") - # todays_time = time.time() - # current = datetime.fromtimestamp(timestamp=todays_time, tz=tz) if not self._graph_data: await self._login() @@ -301,22 +271,18 @@ async def async_get_profile(self, lambda_args: dict): return results async def get_thermostat_control(self) -> ThermostatControlResponse: - print("get working mode") + LOGGER.debug('Fetching thermostat control') if not self._token: await self._login() locations = await self._location_service.get_locations(self._token) if locations[0]: thermostat_id = locations[0].thermostat_id - LOGGER.debug(f"Getting thermostat control mode") + # LOGGER.debug(f"Getting thermostat control mode") control = await self._thermostat_service.get_control( thermostat_id=thermostat_id, access_token=self._token ) - print(f"thermostat id: {control.thermostat_id}") - print(f"mode: {control.mode}") - print(f"status: {control.status}") - # HOTFIX - control.thermostat_id = thermostat_id + LOGGER.debug(f'id:{control.thermostat_id} {control.mode} {control.status}') return control async def get_thermostat_info(self) -> ThermostatInfo: @@ -348,8 +314,6 @@ async def create_manual( def json_serialisable(self, data): """Convert to compressed bytes so that data can be converted to json.""" uncompressed_data = pickle.dumps(data) - # print(data) - # print(uncompressed_data) compressed_data = gzip.compress(uncompressed_data) LOGGER.debug(f"len(uncompressed_data): {len(uncompressed_data)}") LOGGER.debug(f"len(compressed_data): {len(compressed_data)}") @@ -458,21 +422,19 @@ async def _login(self): "tariff_code": TARIFF_CODE, }, ) - print(f"{location_request}") + # LOGGER.debug(f"{location_request}") location: ( LocationResponse | None ) = await self._location_service.add_location( request=location_request, access_token=self._token ) - print(f"{location}") self._has_locations = True if location else False if not self._has_devices: if not location: locations: [ LocationResponse ] = await self._location_service.get_locations(access_token=self._token) - print("****************** LOCATIONS *********************") - print(locations[0]) + LOGGER.debug(locations[0]) location = locations[0] device_request = DeviceRequest( diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index ebdf585..038a173 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -260,7 +260,6 @@ async def __call__(self, lambda_args): async def _check_running_manual_mode(self, lambda_args: dict) -> ThermostatControlResponse: # now = datetime.now(tz=timezone.utc) - # print(f'{now} - checking mode') data = ControlInfo( set_point=lambda_args["temp_set_point"], mode=lambda_args["heat_pump_mode_raw"] @@ -269,7 +268,6 @@ async def _check_running_manual_mode(self, lambda_args: dict) -> ThermostatContr async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" - # print(lambda_args.keys()) # TODO: create class dynamo_data = { "user_hash": self.user_hash, @@ -335,8 +333,8 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. Records the when the heating profile expires and should be refreshed. """ - print(f'******************************** HEATING PROFILE ******************************************') - LOGGER.debug(f"********** self.expire_time: {self.expire_time}") + LOGGER.debug(f'Fetching heating profile') + LOGGER.debug(f"Expire time: {self.expire_time}") count = 0 await self.update_dynamo_dates(lambda_args) ( @@ -348,7 +346,6 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): while missing_entities := self.entities_with_data_missing_from_dynamo(): count += 1 LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") - print('[------------ UPDATING!!!!! ----------------]') await self.upload_new_history(missing_entities) LOGGER.debug("Upload of new history complete\n") diff --git a/custom_components/optispark/configuration_service.py b/custom_components/optispark/configuration_service.py index 7b24eed..b63fbbd 100644 --- a/custom_components/optispark/configuration_service.py +++ b/custom_components/optispark/configuration_service.py @@ -1,6 +1,8 @@ import json import os +from .const import LOGGER + class ConfigurationService: _instance = None @@ -26,10 +28,10 @@ def _load_config(self): with open(file_path, "r") as file: return json.load(file) except FileNotFoundError: - print(f"Error: The configuration file {self.config_file} was not found.") + LOGGER.error(f"Error: The configuration file {self.config_file} was not found.") return {} except json.JSONDecodeError: - print( + LOGGER.error( f"Error: The configuration file {self.config_file} is not a valid JSON." ) return {} diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 26f99ce..183d29a 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -345,408 +345,3 @@ async def _async_update_data(self): except OptisparkApiClientError as exception: raise UpdateFailed(exception) from exception - -# class BackendUpdateHandler: -# """Handles everything lambda. -# -# Gets the heating profile and ensure dynamo is up to date. -# """ -# -# def __init__( -# self, -# hass, -# client: OptisparkApiClient, -# climate_entity_id, -# heat_pump_power_entity_id, -# external_temp_entity_id, -# user_hash, -# postcode, -# address, -# city, -# country, -# tariff, -# ): -# """Init.""" -# self.hass = hass -# self.client: OptisparkApiClient = client -# self.climate_entity_id = climate_entity_id -# self.heat_pump_power_entity_id = heat_pump_power_entity_id -# self.external_temp_entity_id = external_temp_entity_id -# self.user_hash = user_hash -# self.postcode = postcode -# self.address = address -# self.country = country -# self.city = city -# self.tariff = tariff -# self.expire_time = datetime( -# 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc -# ) # Already expired -# self.manual_update = False -# self.history_upload_complete = False -# self.outside_range_flag = False -# self.id_to_column_name_lookup = { -# climate_entity_id: const.DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY, -# heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, -# external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, -# } -# LOGGER.debug(f"{self.user_hash = }") -# # Entity ids will be None if they are optional and not enabled -# self.active_entity_ids = [] -# for entity_id in [ -# climate_entity_id, -# heat_pump_power_entity_id, -# external_temp_entity_id, -# ]: -# if entity_id is not None: -# self.active_entity_ids.append(entity_id) -# -# def get_missing_histories_boundary(self, history_states, dynamo_date): -# """Get index where history_state matches dynamo_date.""" -# for idx, datum in enumerate(history_states): -# if datum.last_updated >= dynamo_date: -# idx_bound = idx -# return idx_bound -# return idx_bound # type: ignore -# -# def get_missing_old_histories_states(self, history_states, column): -# """Get states that are older than anything in dynamo.""" -# dynamo_date = self.dynamo_oldest_dates[column] -# idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) -# return history_states[:idx_bound] -# -# def get_missing_new_histories_states(self, history_states, column): -# """Get states that are newer than anything in dynamo.""" -# dynamo_date = self.dynamo_newest_dates[column] -# if dynamo_date is None: -# # No data in dynamo - upload first x days -# dynamo_date = datetime.now(tz=timezone.utc) - timedelta( -# days=const.HISTORY_DAYS -# ) -# idx_bound = self.get_missing_histories_boundary(history_states, dynamo_date) -# if idx_bound == len(history_states) - 1: -# error = True -# else: -# error = False -# return history_states[idx_bound + 1 :], error -# -# async def upload_new_history(self, missing_entities): -# """Upload section of new history states that are newer than anything in dynamo. -# -# self.dynamo_dates is updated so that if this function is called again a new section will be -# uploaded. -# const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. -# """ -# histories = {} -# constant_attributes = {} -# -# async def debug_check_history_length(days): -# history_states = await history.get_state_changes( -# self.hass, active_entity_id, days -# ) -# LOGGER.debug(f"---------- days: {days} ----------") -# LOGGER.debug( -# f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# LOGGER.debug( -# f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# -# history_states = await history.get_state_changes_period( -# self.hass, active_entity_id, days -# ) -# LOGGER.debug( -# f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# LOGGER.debug( -# f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# -# for active_entity_id in missing_entities: -# column = self.id_to_column_name_lookup[active_entity_id] -# history_states = await history.get_state_changes( -# self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS -# ) -# missing_new_histories_states, error = self.get_missing_new_histories_states( -# history_states, column -# ) -# if error: -# raise RuntimeError( -# "No missing history data to upload, should not have gotten here" -# ) -# -# LOGGER.debug(f" column: {column}") -# if len(missing_new_histories_states) == 0: -# LOGGER.debug(f" ({column}) - Upload complete") -# continue -# LOGGER.debug( -# f" len(missing_new_histories_states): {len(missing_new_histories_states)}" -# ) -# missing_new_histories_states = missing_new_histories_states[ -# : const.MAX_UPLOAD_HISTORY_READINGS -# ] -# LOGGER.debug( -# f" len(missing_new_histories_states): {len(missing_new_histories_states)}" -# ) -# LOGGER.debug( -# f' {missing_new_histories_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# LOGGER.debug( -# f' {missing_new_histories_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' -# ) -# -# histories[column], constant_attributes[column] = ( -# history.states_to_histories( -# self.hass, column, missing_new_histories_states -# ) -# ) -# if histories == {}: -# raise RuntimeError( -# "Should not have gotten here! No missing history data to upload" -# ) -# dynamo_data = history.histories_to_dynamo_data( -# self.hass, -# histories, -# constant_attributes, -# self.user_hash, -# self.climate_entity_id, -# self.postcode, -# self.tariff, -# ) -# ( -# self.dynamo_oldest_dates, -# self.dynamo_newest_dates, -# ) = await self.client.upload_history(dynamo_data) -# -# async def upload_old_history(self): -# """Upload section of old history states that are older than anything in dynamo. -# -# self.dynamo_dates is updated so that if this function is called again a new section will be -# uploaded. -# const.MAX_UPLOAD_HISTORY_READINGS number of readings are uploaded to avoid long delay. -# """ -# LOGGER.debug("Uploading portion of old history...") -# histories = {} -# constant_attributes = {} -# for active_entity_id in self.active_entity_ids: -# column = self.id_to_column_name_lookup[active_entity_id] -# history_states = await history.get_state_changes( -# self.hass, active_entity_id, const.DYNAMO_HISTORY_DAYS -# ) -# missing_old_histories_states = self.get_missing_old_histories_states( -# history_states, column -# ) -# -# LOGGER.debug(f" column: {column}") -# if len(missing_old_histories_states) == 0: -# LOGGER.debug(f" ({column}) - Upload complete") -# continue -# LOGGER.debug( -# f" len(missing_old_histories_states): {len(missing_old_histories_states)}" -# ) -# missing_old_histories_states = missing_old_histories_states[ -# -const.MAX_UPLOAD_HISTORY_READINGS : -# ] -# -# histories[column], constant_attributes[column] = ( -# history.states_to_histories( -# self.hass, column, missing_old_histories_states -# ) -# ) -# if histories == {}: -# self.history_upload_complete = True -# LOGGER.debug("History upload complete, recalculate heating profile...\n") -# # Now that we have all the history, recalculate heating profile -# self.manual_update = True -# return -# dynamo_data = history.histories_to_dynamo_data( -# self.hass, -# histories, -# constant_attributes, -# self.user_hash, -# self.climate_entity_id, -# self.postcode, -# self.tariff, -# ) -# ( -# self.dynamo_oldest_dates, -# self.dynamo_newest_dates, -# ) = await self.client.upload_history(dynamo_data) -# -# async def __call__(self, lambda_args): -# """Return lambda data for the current time. -# -# Calls lambda if new heating profile is needed -# Otherwise, slowly uploads historical data -# """ -# -# if not self._login(): -# LOGGER.debug('Backend login failed') -# -# -# if not self._check_running_manual_mode(): -# LOGGER.debug('Request manual mode') -# print('Request manual mode') -# -# now = datetime.now(tz=timezone.utc) -# # This probably won't result in a smooth transition -# if self.expire_time - now < timedelta(hours=0) or self.manual_update: -# await self.call_lambda(lambda_args) -# else: -# if self.history_upload_complete is False: -# await self.upload_old_history() -# return self.get_closest_time(lambda_args) -# -# async def _login(self) -> bool: -# address = Address( -# address=self.address, -# city=self.city, -# postcode=self.postcode, -# country='UK' -# ) -# return await self.client.login(user_hash=self.user_hash, address=address) -# -# async def _check_running_manual_mode(self) -> bool: -# now = datetime.now(tz=timezone.utc) -# print(f'{now} - checking mode') -# -# return True -# -# -# async def update_dynamo_dates(self, lambda_args: dict): -# """Call the lambda function and get the oldest and newest dates in dynamodb.""" -# # print(lambda_args.keys()) -# # TODO: create class -# dynamo_data = { -# "user_hash": self.user_hash, -# "postcode": lambda_args["postcode"], -# "address": lambda_args["address"], -# "city": lambda_args["city"], -# "temp_set_point": lambda_args['temp_set_point'], -# "heat_pump_mode_raw": lambda_args['heat_pump_mode_raw'] -# } -# ( -# self.dynamo_oldest_dates, -# self.dynamo_newest_dates, -# ) = await self.client.get_data_dates(dynamo_data=dynamo_data) -# -# async def update_ha_dates(self): -# """Get the oldest and newest dates in HA histories for active_entity_ids.""" -# ( -# self.ha_oldest_dates, -# self.ha_newest_dates, -# ) = await history.get_earliest_and_latest_data_dates( -# hass=self.hass, -# climate_entity_id=self.climate_entity_id, -# heat_pump_power_entity_id=self.heat_pump_power_entity_id, -# external_temp_entity_id=self.external_temp_entity_id, -# ) -# -# def entities_with_data_missing_from_dynamo(self): -# """Return entities with new data that needs to be uploaded. -# -# If there is data in HA histories for active_entity_ids that is newer than what is in dynamo, -# return those entities. -# """ -# entities_missing = [] -# LOGGER.debug("---entities_with_data_missing_from_dynamo---") -# for active_entity_id in self.active_entity_ids: -# column = self.id_to_column_name_lookup[active_entity_id] -# if self.dynamo_newest_dates[column] is None: -# # First run, therefore data is missing -# LOGGER.debug( -# f"First run, upload ({const.HISTORY_DAYS}) days of history...\n" -# ) -# entities_missing.append(active_entity_id) -# continue -# if self.dynamo_newest_dates[column] < self.ha_newest_dates[column]: -# LOGGER.debug( -# f"self.dynamo_newest_dates[{column}]: {self.dynamo_newest_dates[column]}" -# ) -# LOGGER.debug( -# f"self.ha_newest_dates[{column}]: {self.ha_newest_dates[column]}" -# ) -# LOGGER.debug(f" column: {column}") -# LOGGER.debug( -# f" dynamo {self.dynamo_newest_dates[column]} is older than local {self.ha_newest_dates[column]}" -# ) -# entities_missing.append(active_entity_id) -# return entities_missing -# # return False -# -# async def call_lambda(self, lambda_args): -# """Fetch heating profile from AWS Lambda. -# -# Upload all new and missing data to dynamo first. -# If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. -# Records the when the heating profile expires and should be refreshed. -# """ -# LOGGER.debug(f"********** self.expire_time: {self.expire_time}") -# count = 0 -# await self.update_dynamo_dates(lambda_args) -# await self.update_ha_dates() -# while missing_entities := self.entities_with_data_missing_from_dynamo(): -# count += 1 -# LOGGER.debug(f"Updating dynamo with NEW data: round ({count})") -# await self.upload_new_history(missing_entities) -# LOGGER.debug("Upload of new history complete\n") -# -# self.lambda_results = await self.client.async_get_profile(lambda_args) -# -# self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] -# # The backend will currently only update upon a new day. FIX! -# self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) -# LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") -# self.manual_update = False -# -# def get_closest_time(self, lambda_args): -# """Get the closest matching time to now from the lambda data set provided.""" -# time_based_keys = [ -# const.LAMBDA_BASE_DEMAND, -# const.LAMBDA_PRICE, -# const.LAMBDA_TEMP_CONTROLS, -# const.LAMBDA_OPTIMISED_DEMAND, -# ] -# non_time_based_keys = [ -# const.LAMBDA_BASE_COST, -# const.LAMBDA_OPTIMISED_COST, -# const.LAMBDA_PROJECTED_PERCENT_SAVINGS, -# ] -# -# # Convert lists to {datetime: list_element} -# my_data = {} -# for key in time_based_keys: -# my_data[key] = { -# self.lambda_results[const.LAMBDA_TIMESTAMP][i]: self.lambda_results[ -# key -# ][i] -# for i in range(len(self.lambda_results[key])) -# } -# for key in non_time_based_keys: -# my_data[key] = self.lambda_results[key] -# -# # Get closet datetime that is in the past -# datetime_np = np.asarray(self.lambda_results[const.LAMBDA_TIMESTAMP]) -# filtered = datetime_np[datetime_np < datetime.now(tz=timezone.utc)] -# closest_past_date = filtered.max() -# -# out = {} -# for key in time_based_keys: -# out[key] = my_data[key][closest_past_date] -# -# for key in non_time_based_keys: -# out[key] = my_data[key] -# -# if lambda_args[const.LAMBDA_OUTSIDE_RANGE]: -# # We're outside of the temp range so simply set the set point to whatever the user has -# # requested -# out[const.LAMBDA_TEMP_CONTROLS] = lambda_args[const.LAMBDA_SET_POINT] -# self.outside_range_flag = True -# LOGGER.debug( -# f"initial_internal_temp({lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP]}) is outside of temp_range({lambda_args[const.LAMBDA_TEMP_RANGE]}) of the internal_temp({out[const.LAMBDA_TEMP_CONTROLS]}) - setting to set_point({lambda_args[const.LAMBDA_SET_POINT]})" -# ) -# elif self.outside_range_flag: -# # We have just entered the temp_range! The optimisation can now be run -# LOGGER.debug("Temperature range reached") -# self.manual_update = True -# self.outside_range_flag = False -# return out diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 16fcdaa..937b494 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -2,11 +2,14 @@ import aiohttp +from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service from custom_components.optispark.infra.auth.model.login_response import LoginResponse from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError + + class AuthService: def __init__( @@ -43,11 +46,11 @@ async def login(self, user_hash: str) -> LoginResponse: ) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientAuthenticationError( "Invalid credentials", ) from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise \ No newline at end of file diff --git a/custom_components/optispark/infra/device/device_service.py b/custom_components/optispark/infra/device/device_service.py index 893bbee..809e353 100644 --- a/custom_components/optispark/infra/device/device_service.py +++ b/custom_components/optispark/infra/device/device_service.py @@ -3,6 +3,7 @@ import aiohttp from aiohttp import ClientResponse +from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service from custom_components.optispark.infra.device.model.device_request import DeviceRequest from custom_components.optispark.infra.device.model.device_response import DeviceResponse @@ -50,8 +51,8 @@ async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceR return DeviceResponse.from_json(json_response) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientDeviceError("Add device error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise diff --git a/custom_components/optispark/infra/location/location_service.py b/custom_components/optispark/infra/location/location_service.py index c5ce259..7a7f343 100644 --- a/custom_components/optispark/infra/location/location_service.py +++ b/custom_components/optispark/infra/location/location_service.py @@ -2,6 +2,7 @@ import aiohttp +from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service from custom_components.optispark.infra.exception.exceptions import ( OptisparkApiClientAuthenticationError, @@ -54,10 +55,10 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc return LocationResponse.from_json(json_response) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientLocationError("Add location error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise async def get_locations(self, access_token: str) -> [LocationResponse]: @@ -90,9 +91,9 @@ async def get_locations(self, access_token: str) -> [LocationResponse]: return [location for location in locations if location is not None] except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientLocationError("Get locations error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise diff --git a/custom_components/optispark/infra/location/model/location_response.py b/custom_components/optispark/infra/location/model/location_response.py index 3a92174..7eca889 100644 --- a/custom_components/optispark/infra/location/model/location_response.py +++ b/custom_components/optispark/infra/location/model/location_response.py @@ -1,3 +1,6 @@ +from custom_components.optispark.const import LOGGER + + class LocationResponse: id: int name: str @@ -47,8 +50,8 @@ def from_json(cls, json: dict): thermostat_id=json["thermostatId"], ) except KeyError as e: - print(f"Error: No key in JSON - {e}") + LOGGER.error(f"Error: No key in JSON - {e}") return None except Exception as e: - print(f"Error: {e}") + LOGGER.error(f"Error: {e}") return None diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py b/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py index bf03587..54de13a 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py @@ -1,5 +1,6 @@ from typing import Optional +from custom_components.optispark.const import LOGGER from custom_components.optispark.infra.shared.model.working_mode import WorkingMode from custom_components.optispark.infra.thermostat.model.thermostat_control_status import ThermostatControlStatus @@ -43,8 +44,8 @@ def from_json(cls, json: dict): ) except KeyError as e: - print(f"Error: No key in JSON - {e}") + LOGGER.error(f"Error: No key in JSON - {e}") return None except Exception as e: - print(f"Error: {e}") + LOGGER.error(f"Error: {e}") return None diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/infra/thermostat/thermostat_service.py index 3a05a2d..1366b0b 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/infra/thermostat/thermostat_service.py @@ -3,6 +3,7 @@ import aiohttp +from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import ConfigurationService, config_service from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientThermostatError @@ -50,10 +51,10 @@ async def get_control(self, thermostat_id: int, access_token: str) -> Thermostat return ThermostatControlResponse.from_json(json_response) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientThermostatError("get thermostat control error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise async def create_manual( @@ -91,10 +92,10 @@ async def create_manual( return ThermostatControlResponse.from_json(json_response) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientThermostatError("post thermostat manual control error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise async def get_graph(self, thermostat_id: int, access_token: str) -> List[ThermostatPrediction]: @@ -128,10 +129,10 @@ async def get_graph(self, thermostat_id: int, access_token: str) -> List[Thermos # return ThermostatControlResponse.from_json(json_response) except aiohttp.ClientError as e: - print(f"HTTP error occurred: {e}") + LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientThermostatError("get graph error") from e except Exception as e: - print(f"Unexpected error occurred: {e}") + LOGGER.error(f"Unexpected error occurred: {e}") raise \ No newline at end of file From ea51de3f6e74e7d8a97acc52dafdadf3c3d56157 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 14:00:35 +0200 Subject: [PATCH 42/52] wip --- custom_components/optispark/api.py | 87 +++++++------------ .../optispark/infra/auth/auth_service.py | 23 +++-- 2 files changed, 50 insertions(+), 60 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index a8fecdc..42a57b7 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -36,9 +36,6 @@ ) from .infra.location.model.location_response import LocationResponse -import time -import pytz - from .infra.shared.model.working_mode import WorkingMode from .infra.thermostat.model.thermostat_control_request import ThermostatControlRequest from .infra.thermostat.model.thermostat_control_response import ( @@ -49,33 +46,6 @@ from .infra.thermostat.thermostat_service import ThermostatService from .utils import to_thermostat_info -# class OptisparkApiClientError(Exception): -# """Exception to indicate a general API error.""" - - -# class OptisparkApiClientTimeoutError(OptisparkApiClientError): -# """Lamba probably took too long starting up.""" - - -# class OptisparkApiClientCommunicationError(OptisparkApiClientError): -# """Exception to indicate a communication error.""" -# -# -# class OptisparkApiClientAuthenticationError(OptisparkApiClientError): -# """Exception to indicate an authentication error.""" -# -# -# class OptisparkApiClientLambdaError(OptisparkApiClientError): -# """Exception to indicate lambda return an error.""" -# -# -# class OptisparkApiClientPostcodeError(OptisparkApiClientError): -# """Exception to indicate invalid postcode.""" -# -# -# class OptisparkApiClientUnitError(OptisparkApiClientError): -# """Exception to indicate unit error.""" - BACKEND_URL = "backend.url" @@ -138,7 +108,6 @@ def __init__( self._location_service = LocationService(session=session) self._device_service = DeviceService(session=session) self._thermostat_service = ThermostatService(session=session) - # self._config_service = ConfigurationService(config_file='./config/config.json') self._config_service: ConfigurationService = config_service def datetime_set_utc(self, d: dict[str, datetime]): @@ -149,21 +118,19 @@ def datetime_set_utc(self, d: dict[str, datetime]): d[key] = d[key].replace(tzinfo=timezone.utc) return d - async def upload_history(self, dynamo_data): - """Upload historical data to dynamoDB without calculating heat pump profile.""" - url = self._config_service.get(BACKEND_URL) - payload = {"dynamo_data": dynamo_data} - payload["upload_only"] = True - extra = await self._api_wrapper( - method="post", - url=url, - data=payload, - ) - oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) - newest_dates = self.datetime_set_utc(extra["newest_dates"]) - return oldest_dates, newest_dates - - # async def check_and_set_manual(self, data: dict): + # async def upload_history(self, dynamo_data): + # """Upload historical data to dynamoDB without calculating heat pump profile.""" + # url = self._config_service.get(BACKEND_URL) + # payload = {"dynamo_data": dynamo_data} + # payload["upload_only"] = True + # extra = await self._api_wrapper( + # method="post", + # url=url, + # data=payload, + # ) + # oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) + # newest_dates = self.datetime_set_utc(extra["newest_dates"]) + # return oldest_dates, newest_dates # TODO: remove this method async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: @@ -195,7 +162,8 @@ async def get_data_dates(self): LOGGER.debug(self._user_hash) # Checks self._token, self._has_locations & self._has_devices # and updates if necessary - await self._login() + await self._check_token_and_login() + await self._check_location_and_device() # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() self._graph_data: List[ @@ -233,7 +201,7 @@ async def async_get_profile(self, lambda_args: dict): payload["get_profile_only"] = True if not self._graph_data: - await self._login() + await self._check_token_and_login() # TODO: change this, store thermostat_id and add logic to update if necessary control = await self.get_thermostat_control() self._graph_data: List[ @@ -273,7 +241,8 @@ async def async_get_profile(self, lambda_args: dict): async def get_thermostat_control(self) -> ThermostatControlResponse: LOGGER.debug('Fetching thermostat control') if not self._token: - await self._login() + await self._check_token_and_login() + await self._check_location_and_device() locations = await self._location_service.get_locations(self._token) if locations[0]: @@ -287,7 +256,8 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: async def get_thermostat_info(self) -> ThermostatInfo: if not self._token: - await self._login() + await self._check_token_and_login() + await self._check_location_and_device() locations = await self._location_service.get_locations(self._token) if locations[0]: @@ -334,7 +304,8 @@ async def _api_wrapper(self, method: str, url: str, data: dict): if not self._token: # If user is not logged perform login process - await self._login() + await self._check_token_and_login() + await self._check_location_and_device() try: if "dynamo_data" in data: @@ -342,7 +313,8 @@ async def _api_wrapper(self, method: str, url: str, data: dict): data_serialised = self.json_serialisable(data) async with async_timeout.timeout(120): - await self._login() + await self._check_token_and_login() + await self._check_location_and_device() response = await self._session.request( method=method, @@ -391,15 +363,14 @@ async def _api_wrapper(self, method: str, url: str, data: dict): "Something really wrong happened!" ) from exception - async def _login(self): + async def _check_token_and_login(self): """ Makes home asssistant login into OptiSpark backend. checks if user has locations and devices if not create locataion and device """ - LOGGER.debug(f" Initiating login into OptiSpark backend") - location: LocationResponse | None = None if not self._token: + LOGGER.debug(f" Initiating login into OptiSpark backend") # user_hash = data["user_hash"] if self._user_hash: login_response: LoginResponse = await self._auth_service.login( @@ -409,6 +380,12 @@ async def _login(self): self._has_locations = login_response.has_locations self._has_devices = login_response.has_devices LOGGER.debug(f" User token: {login_response.token}") + + valid = self._auth_service.is_token_expired(self._token) + LOGGER.info(f'Token is valid ---> {valid}') + + async def _check_location_and_device(self): + location: LocationResponse | None = None if not self._has_locations: location_request = LocationRequest( name="home", diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 937b494..89e670d 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -1,6 +1,8 @@ from http import HTTPStatus import aiohttp +import jwt +import time from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service @@ -8,18 +10,17 @@ from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError - - class AuthService: def __init__( - self, - session: aiohttp.ClientSession, + self, + session: aiohttp.ClientSession, ) -> None: """Sample API Client.""" self._session = session self._base_url = config_service.get('backend.baseUrl') self._ssl = config_service.get('backend.verifySSL', default=True) + self._token = None async def login(self, user_hash: str) -> LoginResponse: auth_url = f'{self._base_url}/auth/ha_login' @@ -37,6 +38,7 @@ async def login(self, user_hash: str) -> LoginResponse: ) from Exception json_response = await response.json() + self._token = json_response["accessToken"] return LoginResponse( token=json_response["accessToken"], @@ -53,4 +55,15 @@ async def login(self, user_hash: str) -> LoginResponse: except Exception as e: LOGGER.error(f"Unexpected error occurred: {e}") raise - \ No newline at end of file + + def is_token_expired(self): + try: + # Decodificar el payload del token JWT sin verificar la firma + payload = jwt.decode(self._token, options={"verify_signature": False}) + exp_timestamp = payload.get('exp', 0) + current_timestamp = time.time() + return current_timestamp > exp_timestamp + except jwt.ExpiredSignatureError: + return True + except jwt.DecodeError: + return True From 911e348ac49d46bca094dc6eefbae881d178a080 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 16 Jul 2024 16:07:12 +0200 Subject: [PATCH 43/52] chore: automatic login when token expires & big refactor --- custom_components/optispark/api.py | 287 +++++++++--------- .../optispark/infra/auth/auth_service.py | 38 ++- 2 files changed, 181 insertions(+), 144 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 42a57b7..8982830 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -2,24 +2,24 @@ from __future__ import annotations -import asyncio -import socket +# import asyncio +# import socket from typing import List import aiohttp -import async_timeout +# import async_timeout from decimal import Decimal from datetime import datetime, timezone -import pickle -import gzip -import base64 - -from homeassistant.core import async_get_hass, HomeAssistant +# import pickle +# import gzip +# import base64 +# +# from homeassistant.core import async_get_hass, HomeAssistant from .configuration_service import ConfigurationService, config_service from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE -import traceback -from http import HTTPStatus +# import traceback +# from http import HTTPStatus from .domain.thermostat.thermostat_info import ThermostatInfo from .domain.value_object.address import Address @@ -101,10 +101,10 @@ def __init__( self._session = session self._user_hash = user_hash self._address = address - self._token = None + # self._token = None self._has_locations = False self._has_devices = False - self._auth_service = AuthService(session=session) + self._auth_service = AuthService(session=session, user_hash=user_hash) self._location_service = LocationService(session=session) self._device_service = DeviceService(session=session) self._thermostat_service = ThermostatService(session=session) @@ -135,7 +135,7 @@ def datetime_set_utc(self, d: dict[str, datetime]): # TODO: remove this method async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: """Checks if optispark is running in manual, if not set manual mode""" - + await self._check_location_and_device() control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: LOGGER.info( @@ -160,16 +160,16 @@ async def get_data_dates(self): # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 LOGGER.debug(self._user_hash) - # Checks self._token, self._has_locations & self._has_devices # and updates if necessary - await self._check_token_and_login() + # await self._check_token_and_login() await self._check_location_and_device() - # TODO: change this, store thermostat_id and add logic to update if necessary + token = await self._auth_service.token + # await self._check_location_and_device() control = await self.get_thermostat_control() self._graph_data: List[ ThermostatPrediction ] = await self._thermostat_service.get_graph( - access_token=self._token, thermostat_id=control.thermostat_id + access_token=token, thermostat_id=control.thermostat_id ) oldest_date = self._graph_data[0].date @@ -199,15 +199,15 @@ async def async_get_profile(self, lambda_args: dict): # LOGGER.debug(lambda_args) payload = lambda_args payload["get_profile_only"] = True - + await self._check_location_and_device() if not self._graph_data: - await self._check_token_and_login() - # TODO: change this, store thermostat_id and add logic to update if necessary + token = await self._auth_service.token + # await self._check_token_and_login() control = await self.get_thermostat_control() self._graph_data: List[ ThermostatPrediction ] = await self._thermostat_service.get_graph( - access_token=self._token, thermostat_id=control.thermostat_id + access_token=token, thermostat_id=control.thermostat_id ) if self._graph_data[0].date.tzinfo is None: @@ -240,151 +240,160 @@ async def async_get_profile(self, lambda_args: dict): async def get_thermostat_control(self) -> ThermostatControlResponse: LOGGER.debug('Fetching thermostat control') - if not self._token: - await self._check_token_and_login() - await self._check_location_and_device() - - locations = await self._location_service.get_locations(self._token) + token = await self._auth_service.token + # if not token: + # await self._check_token_and_login() + # await self._check_location_and_device() + await self._check_location_and_device() + locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id # LOGGER.debug(f"Getting thermostat control mode") control = await self._thermostat_service.get_control( - thermostat_id=thermostat_id, access_token=self._token + thermostat_id=thermostat_id, access_token=token ) LOGGER.debug(f'id:{control.thermostat_id} {control.mode} {control.status}') return control async def get_thermostat_info(self) -> ThermostatInfo: - if not self._token: - await self._check_token_and_login() - await self._check_location_and_device() - - locations = await self._location_service.get_locations(self._token) + token = await self._auth_service.token + # if not token: + # await self._check_token_and_login() + # await self._check_location_and_device() + await self._check_location_and_device() + locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id LOGGER.debug(f"Getting thermostat control mode") control = await self._thermostat_service.get_control( - thermostat_id=thermostat_id, access_token=self._token + thermostat_id=thermostat_id, access_token=token ) return to_thermostat_info(control) async def create_manual( self, thermostat_id: int, set_point: float, mode: str ) -> ThermostatControlResponse: + await self._check_location_and_device() request = ThermostatControlRequest( mode=WorkingMode.from_string(mode), heat_set_point=set_point, cool_set_point=set_point, ) + token = await self._auth_service.token result = await self._thermostat_service.create_manual( - thermostat_id=thermostat_id, request=request, access_token=self._token + thermostat_id=thermostat_id, request=request, access_token=token ) return result - def json_serialisable(self, data): - """Convert to compressed bytes so that data can be converted to json.""" - uncompressed_data = pickle.dumps(data) - compressed_data = gzip.compress(uncompressed_data) - LOGGER.debug(f"len(uncompressed_data): {len(uncompressed_data)}") - LOGGER.debug(f"len(compressed_data): {len(compressed_data)}") - base64_string = base64.b64encode(compressed_data).decode("utf-8") - return base64_string - - def json_deserialise(self, payload): - """Convert from the compressed bytes to original objects.""" - # payload = payload["serialised_payload"] - # payload = payload["serialisePayload"] - payload = base64.b64decode(payload) - payload = gzip.decompress(payload) - payload = pickle.loads(payload) - return payload - - async def _api_wrapper(self, method: str, url: str, data: dict): - """Call the Lambda function.""" - - if not self._token: - # If user is not logged perform login process - await self._check_token_and_login() - await self._check_location_and_device() - - try: - if "dynamo_data" in data: - data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) - data_serialised = self.json_serialisable(data) - - async with async_timeout.timeout(120): - await self._check_token_and_login() - await self._check_location_and_device() - - response = await self._session.request( - method=method, - url=url, - json=data_serialised, - ) - if response.status in (401, 403): - # Clean token forcing login in next api_wrapper call - self._token = None - raise OptisparkApiClientAuthenticationError( - "Invalid credentials", - ) - - if response.status == 502: - # HomeAssistant will not print errors if there was never a successful update - LOGGER.debug( - "OptisparkApiClientCommunicationError:\n 502 Bad Gateway - check payload" - ) - raise OptisparkApiClientCommunicationError( - "502 Bad Gateway - check payload" - ) - response.raise_for_status() - payload = await response.json() - return self.json_deserialise(payload) - - except asyncio.TimeoutError as exception: - LOGGER.error(traceback.format_exc()) - LOGGER.error( - "OptisparkApiClientTimeoutError:\n Timeout error fetching information" - ) - raise OptisparkApiClientTimeoutError( - "Timeout error fetching information", - ) from exception - except (aiohttp.ClientError, socket.gaierror) as exception: - LOGGER.error(traceback.format_exc()) - LOGGER.error( - "OptisparkApiClientCommunicationError:\n Error fetching information" - ) - raise OptisparkApiClientCommunicationError( - "Error fetching information", - ) from exception - except Exception as exception: # pylint: disable=broad-except - LOGGER.error(traceback.format_exc()) - LOGGER.error("OptisparkApiClientError:\n Something really wrong happened!") - raise OptisparkApiClientError( - "Something really wrong happened!" - ) from exception - - async def _check_token_and_login(self): - """ - Makes home asssistant login into OptiSpark backend. - checks if user has locations and devices - if not create locataion and device - """ - if not self._token: - LOGGER.debug(f" Initiating login into OptiSpark backend") - # user_hash = data["user_hash"] - if self._user_hash: - login_response: LoginResponse = await self._auth_service.login( - user_hash=self._user_hash - ) - self._token = login_response.token - self._has_locations = login_response.has_locations - self._has_devices = login_response.has_devices - LOGGER.debug(f" User token: {login_response.token}") - - valid = self._auth_service.is_token_expired(self._token) - LOGGER.info(f'Token is valid ---> {valid}') + # def json_serialisable(self, data): + # """Convert to compressed bytes so that data can be converted to json.""" + # uncompressed_data = pickle.dumps(data) + # compressed_data = gzip.compress(uncompressed_data) + # LOGGER.debug(f"len(uncompressed_data): {len(uncompressed_data)}") + # LOGGER.debug(f"len(compressed_data): {len(compressed_data)}") + # base64_string = base64.b64encode(compressed_data).decode("utf-8") + # return base64_string + # + # def json_deserialise(self, payload): + # """Convert from the compressed bytes to original objects.""" + # # payload = payload["serialised_payload"] + # # payload = payload["serialisePayload"] + # payload = base64.b64decode(payload) + # payload = gzip.decompress(payload) + # payload = pickle.loads(payload) + # return payload + + # async def _api_wrapper(self, method: str, url: str, data: dict): + # """Call the Lambda function.""" + # + # # if not await self._auth_service.token: + # # # If user is not logged perform login process + # # await self._check_token_and_login() + # # await self._check_location_and_device() + # + # try: + # if "dynamo_data" in data: + # data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) + # data_serialised = self.json_serialisable(data) + # + # async with async_timeout.timeout(120): + # # await self._check_token_and_login() + # # await self._check_location_and_device() + # + # response = await self._session.request( + # method=method, + # url=url, + # json=data_serialised, + # ) + # if response.status in (401, 403): + # # Clean token forcing login in next api_wrapper call + # raise OptisparkApiClientAuthenticationError( + # "Invalid credentials", + # ) + # + # if response.status == 502: + # # HomeAssistant will not print errors if there was never a successful update + # LOGGER.debug( + # "OptisparkApiClientCommunicationError:\n 502 Bad Gateway - check payload" + # ) + # raise OptisparkApiClientCommunicationError( + # "502 Bad Gateway - check payload" + # ) + # response.raise_for_status() + # payload = await response.json() + # return self.json_deserialise(payload) + # + # except asyncio.TimeoutError as exception: + # LOGGER.error(traceback.format_exc()) + # LOGGER.error( + # "OptisparkApiClientTimeoutError:\n Timeout error fetching information" + # ) + # raise OptisparkApiClientTimeoutError( + # "Timeout error fetching information", + # ) from exception + # except (aiohttp.ClientError, socket.gaierror) as exception: + # LOGGER.error(traceback.format_exc()) + # LOGGER.error( + # "OptisparkApiClientCommunicationError:\n Error fetching information" + # ) + # raise OptisparkApiClientCommunicationError( + # "Error fetching information", + # ) from exception + # except Exception as exception: # pylint: disable=broad-except + # LOGGER.error(traceback.format_exc()) + # LOGGER.error("OptisparkApiClientError:\n Something really wrong happened!") + # raise OptisparkApiClientError( + # "Something really wrong happened!" + # ) from exception + + # async def _check_token_and_login(self): + # """ + # Makes home asssistant login into OptiSpark backend. + # checks if user has locations and devices + # if not create locataion and device + # """ + # + # if not await self._auth_service.token: + # LOGGER.debug(f" Initiating login into OptiSpark backend") + # # user_hash = data["user_hash"] + # if self._user_hash: + # login_response: LoginResponse = await self._auth_service.login( + # user_hash=self._user_hash + # ) + # + # self._has_locations = login_response.has_locations + # self._has_devices = login_response.has_devices + # LOGGER.debug(f" User token: {login_response.token}") + # + # # LOGGER.info(f'Token is valid ---> {valid}') async def _check_location_and_device(self): + login_response = self._auth_service.login_response + if not login_response: + login_response: LoginResponse = await self._auth_service.login() + self._has_locations = login_response.has_locations + self._has_devices = login_response.has_devices + token = login_response.token location: LocationResponse | None = None if not self._has_locations: location_request = LocationRequest( @@ -403,14 +412,14 @@ async def _check_location_and_device(self): location: ( LocationResponse | None ) = await self._location_service.add_location( - request=location_request, access_token=self._token + request=location_request, access_token=token ) self._has_locations = True if location else False if not self._has_devices: if not location: locations: [ LocationResponse - ] = await self._location_service.get_locations(access_token=self._token) + ] = await self._location_service.get_locations(access_token=token) LOGGER.debug(locations[0]) location = locations[0] @@ -425,7 +434,7 @@ async def _check_location_and_device(self): device_response: ( DeviceResponse | None ) = await self._device_service.add_device( - request=device_request, access_token=self._token + request=device_request, access_token=token ) self._has_devices = True if device_response else False diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 89e670d..55e5edf 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -4,6 +4,8 @@ import jwt import time +from aiohttp import ClientSession + from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service from custom_components.optispark.infra.auth.model.login_response import LoginResponse @@ -15,17 +17,20 @@ class AuthService: def __init__( self, session: aiohttp.ClientSession, + user_hash: str ) -> None: """Sample API Client.""" + self._login_response = None self._session = session self._base_url = config_service.get('backend.baseUrl') self._ssl = config_service.get('backend.verifySSL', default=True) self._token = None + self._user_hash = user_hash - async def login(self, user_hash: str) -> LoginResponse: + async def login(self) -> LoginResponse: auth_url = f'{self._base_url}/auth/ha_login' try: - payload = {"user_hash": user_hash} + payload = {"user_hash": self._user_hash} response = await self._session.post( url=auth_url, json=payload, @@ -38,7 +43,7 @@ async def login(self, user_hash: str) -> LoginResponse: ) from Exception json_response = await response.json() - self._token = json_response["accessToken"] + # self._token = json_response["accessToken"] return LoginResponse( token=json_response["accessToken"], @@ -56,9 +61,20 @@ async def login(self, user_hash: str) -> LoginResponse: LOGGER.error(f"Unexpected error occurred: {e}") raise - def is_token_expired(self): + @property + async def token(self) -> str: + if self._is_token_expired() and self._user_hash: + self._login_response: LoginResponse = await self.login() + self._token = self._login_response.token + return self._token + + @property + def login_response(self) -> LoginResponse: + return self._login_response + + def _is_token_expired(self): try: - # Decodificar el payload del token JWT sin verificar la firma + # Decodes and checks token expiration payload = jwt.decode(self._token, options={"verify_signature": False}) exp_timestamp = payload.get('exp', 0) current_timestamp = time.time() @@ -67,3 +83,15 @@ def is_token_expired(self): return True except jwt.DecodeError: return True + + # def is_token_expired(self, token: str): + # try: + # # Decodificar el payload del token JWT sin verificar la firma + # payload = jwt.decode(token, options={"verify_signature": False}) + # exp_timestamp = payload.get('exp', 0) + # current_timestamp = time.time() + # return current_timestamp > exp_timestamp + # except jwt.ExpiredSignatureError: + # return True + # except jwt.DecodeError: + # return True From dc7264ff97f53d80eaf29144f27e94d36f66f992 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 17 Jul 2024 09:10:45 +0200 Subject: [PATCH 44/52] chore: send data to backend --- custom_components/optispark/__init__.py | 1 + custom_components/optispark/api.py | 31 +++++++++++++++++++ .../optispark/backend_update_handler.py | 4 +-- custom_components/optispark/climate.py | 1 - custom_components/optispark/coordinator.py | 17 +++++++++- .../model/thermostat_control_request.py | 3 ++ 6 files changed, 53 insertions(+), 4 deletions(-) diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index 2470729..9157bbe 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" from .backend_update_handler import BackendUpdateHandler # Prevent circular import from .coordinator import OptisparkDataUpdateCoordinator + from .climate import OptisparkClimate address = Address( address=entry.data["address"], diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 8982830..1ab70a9 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -270,6 +270,37 @@ async def get_thermostat_info(self) -> ThermostatInfo: ) return to_thermostat_info(control) + async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | None: + """Sends manual request to backend""" + response = None + await self._check_location_and_device() + mode = WorkingMode.from_string(data.mode) + heat_set_point = data.set_point + cool_set_point = data.set_point + if mode == WorkingMode.COOLING: + heat_set_point = None + if mode == WorkingMode.HEATING: + cool_set_point = None + request = ThermostatControlRequest( + mode=mode, + heat_set_point=heat_set_point, + cool_set_point=cool_set_point + ) + LOGGER.debug('Post thermostat control request') + LOGGER.debug(request) + token = await self._auth_service.token + locations = await self._location_service.get_locations(token) + if locations[0]: + response = await self._thermostat_service.create_manual( + thermostat_id=locations[0].thermostat_id, + request=request, + access_token=token + ) + return response + # request = + + + # TODO, remove this method async def create_manual( self, thermostat_id: int, set_point: float, mode: str ) -> ThermostatControlResponse: diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 038a173..0c6c3f0 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -206,7 +206,7 @@ async def upload_old_history(self): f" len(missing_old_histories_states): {len(missing_old_histories_states)}" ) missing_old_histories_states = missing_old_histories_states[ - -const.MAX_UPLOAD_HISTORY_READINGS : + -const.MAX_UPLOAD_HISTORY_READINGS: ] histories[column], constant_attributes[column] = ( @@ -264,7 +264,7 @@ async def _check_running_manual_mode(self, lambda_args: dict) -> ThermostatContr set_point=lambda_args["temp_set_point"], mode=lambda_args["heat_pump_mode_raw"] ) - return await self.client.check_and_set_manual(data) + return await self.client.set_manual(data) async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index 73d1c2c..e08bb0e 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -71,7 +71,6 @@ async def async_set_hvac_mode(self, hvac_mode): async def async_set_temperature(self, **kwargs): """Utilised when the heat pump is in either heat or cooling only mode.""" self._target_temperature = kwargs['temperature'] - lambda_args = self.coordinator.lambda_args lambda_args[const.LAMBDA_SET_POINT] = self._target_temperature await self.coordinator.async_set_lambda_args(lambda_args) diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 183d29a..b0f1c72 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -23,6 +23,7 @@ from . import get_entity # from . import history from .backend_update_handler import BackendUpdateHandler +from .climate import OptisparkClimate from .const import LOGGER from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry from homeassistant.helpers import entity_registry @@ -35,6 +36,7 @@ from homeassistant.const import UnitOfTemperature from .domain.thermostat.thermostat_info import ThermostatInfo +from .domain.value_object.control_info import ControlInfo class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): @@ -86,6 +88,7 @@ def __init__( const.LAMBDA_ADDRESS: self._address, const.LAMBDA_CITY: self._city, } + self._previous_lambda_args = self._lambda_args self._lambda_update_handler = BackendUpdateHandler( hass=self.hass, client=self.client, @@ -153,7 +156,7 @@ def convert_climate_from_celcius(self, entity, temp): async def update_heat_pump_temperature(self, data): """Set the temperature of the heat pump using the value from lambda.""" temp: float = data[const.LAMBDA_TEMP_CONTROLS] - climate_entity = get_entity(self.hass, self._climate_entity_id) + climate_entity: OptisparkClimate = get_entity(self.hass, self._climate_entity_id) try: if self.heat_pump_target_temperature == temp: @@ -231,6 +234,18 @@ async def async_set_lambda_args(self, lambda_args): """ self._lambda_args = lambda_args self._lambda_update_handler.manual_update = True + + temp = lambda_args[const.LAMBDA_SET_POINT] + previous_temp = self._previous_lambda_args[const.LAMBDA_SET_POINT] + if temp and previous_temp and temp != previous_temp: + info = ControlInfo( + set_point=temp, + mode=lambda_args[const.LAMBDA_HEAT_PUMP_MODE_RAW] + ) + thermostat_control_response = await self.client.set_manual(info) + if not thermostat_control_response: + LOGGER.error(f'Unable to update thermostat control on OptisPark backend') + self._previous_lambda_args = lambda_args await self.async_request_update() @property diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py b/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py index 7e144f5..e42dc72 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py +++ b/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py @@ -33,3 +33,6 @@ def to_dict(self) -> dict: return result + def __str__(self): + return f'mode:{self.mode} - heat point: {self.heat_set_point} - cool point: {self.cool_set_point}' + From a33446895919ff63442af427109f738747bff1ef Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Wed, 17 Jul 2024 16:59:06 +0200 Subject: [PATCH 45/52] fix: circular import --- custom_components/optispark/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index b0f1c72..cbfa885 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -23,7 +23,7 @@ from . import get_entity # from . import history from .backend_update_handler import BackendUpdateHandler -from .climate import OptisparkClimate +# from .climate import OptisparkClimate from .const import LOGGER from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry from homeassistant.helpers import entity_registry @@ -156,7 +156,7 @@ def convert_climate_from_celcius(self, entity, temp): async def update_heat_pump_temperature(self, data): """Set the temperature of the heat pump using the value from lambda.""" temp: float = data[const.LAMBDA_TEMP_CONTROLS] - climate_entity: OptisparkClimate = get_entity(self.hass, self._climate_entity_id) + climate_entity = get_entity(self.hass, self._climate_entity_id) try: if self.heat_pump_target_temperature == temp: From bb6dc0273f25bf15c2cf489f6c10fece70c7245f Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 18 Jul 2024 13:15:12 +0200 Subject: [PATCH 46/52] chore: send device data --- custom_components/optispark/api.py | 174 ++++++------------ .../optispark/backend_update_handler.py | 70 ++++--- .../optispark/config/config.json | 5 +- custom_components/optispark/config_flow.py | 9 +- custom_components/optispark/const.py | 5 + custom_components/optispark/coordinator.py | 16 +- .../optispark/infra/auth/auth_service.py | 7 +- .../optispark/infra/device/device_service.py | 74 +++++++- .../infra/device/model/device_data_request.py | 37 ++++ .../infra/device/model/device_request.py | 20 +- .../infra/location/location_service.py | 4 +- 11 files changed, 239 insertions(+), 182 deletions(-) create mode 100644 custom_components/optispark/infra/device/model/device_data_request.py diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 1ab70a9..e12cfb8 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -10,6 +10,8 @@ # import async_timeout from decimal import Decimal from datetime import datetime, timezone + +from . import const # import pickle # import gzip # import base64 @@ -27,9 +29,10 @@ from .infra.auth.auth_service import AuthService from .infra.auth.model.login_response import LoginResponse from .infra.device.device_service import DeviceService +from .infra.device.model.device_data_request import DeviceDataRequest from .infra.device.model.device_request import DeviceRequest from .infra.device.model.device_response import DeviceResponse -from .infra.exception.exceptions import * +from .infra.exception.exceptions import OptisparkApiClientAuthenticationError from .infra.location.location_service import LocationService from .infra.location.model.location_request import ( LocationRequest, @@ -135,7 +138,7 @@ def datetime_set_utc(self, d: dict[str, datetime]): # TODO: remove this method async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: """Checks if optispark is running in manual, if not set manual mode""" - await self._check_location_and_device() + # await self._check_location_and_device() control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: LOGGER.info( @@ -162,7 +165,7 @@ async def get_data_dates(self): LOGGER.debug(self._user_hash) # and updates if necessary # await self._check_token_and_login() - await self._check_location_and_device() + # await self._check_location_and_device() token = await self._auth_service.token # await self._check_location_and_device() control = await self.get_thermostat_control() @@ -199,7 +202,7 @@ async def async_get_profile(self, lambda_args: dict): # LOGGER.debug(lambda_args) payload = lambda_args payload["get_profile_only"] = True - await self._check_location_and_device() + # await self._check_location_and_device() if not self._graph_data: token = await self._auth_service.token # await self._check_token_and_login() @@ -244,7 +247,7 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: # if not token: # await self._check_token_and_login() # await self._check_location_and_device() - await self._check_location_and_device() + # await self._check_location_and_device() locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id @@ -260,7 +263,7 @@ async def get_thermostat_info(self) -> ThermostatInfo: # if not token: # await self._check_token_and_login() # await self._check_location_and_device() - await self._check_location_and_device() + # await self._check_location_and_device() locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id @@ -273,7 +276,7 @@ async def get_thermostat_info(self) -> ThermostatInfo: async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | None: """Sends manual request to backend""" response = None - await self._check_location_and_device() + # await self._check_location_and_device() mode = WorkingMode.from_string(data.mode) heat_set_point = data.set_point cool_set_point = data.set_point @@ -299,12 +302,10 @@ async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | Non return response # request = - - # TODO, remove this method async def create_manual( self, thermostat_id: int, set_point: float, mode: str ) -> ThermostatControlResponse: - await self._check_location_and_device() + # await self._check_location_and_device() request = ThermostatControlRequest( mode=WorkingMode.from_string(mode), heat_set_point=set_point, @@ -316,114 +317,18 @@ async def create_manual( ) return result - # def json_serialisable(self, data): - # """Convert to compressed bytes so that data can be converted to json.""" - # uncompressed_data = pickle.dumps(data) - # compressed_data = gzip.compress(uncompressed_data) - # LOGGER.debug(f"len(uncompressed_data): {len(uncompressed_data)}") - # LOGGER.debug(f"len(compressed_data): {len(compressed_data)}") - # base64_string = base64.b64encode(compressed_data).decode("utf-8") - # return base64_string - # - # def json_deserialise(self, payload): - # """Convert from the compressed bytes to original objects.""" - # # payload = payload["serialised_payload"] - # # payload = payload["serialisePayload"] - # payload = base64.b64decode(payload) - # payload = gzip.decompress(payload) - # payload = pickle.loads(payload) - # return payload - - # async def _api_wrapper(self, method: str, url: str, data: dict): - # """Call the Lambda function.""" - # - # # if not await self._auth_service.token: - # # # If user is not logged perform login process - # # await self._check_token_and_login() - # # await self._check_location_and_device() - # - # try: - # if "dynamo_data" in data: - # data["dynamo_data"] = floats_to_decimal(data["dynamo_data"]) - # data_serialised = self.json_serialisable(data) - # - # async with async_timeout.timeout(120): - # # await self._check_token_and_login() - # # await self._check_location_and_device() - # - # response = await self._session.request( - # method=method, - # url=url, - # json=data_serialised, - # ) - # if response.status in (401, 403): - # # Clean token forcing login in next api_wrapper call - # raise OptisparkApiClientAuthenticationError( - # "Invalid credentials", - # ) - # - # if response.status == 502: - # # HomeAssistant will not print errors if there was never a successful update - # LOGGER.debug( - # "OptisparkApiClientCommunicationError:\n 502 Bad Gateway - check payload" - # ) - # raise OptisparkApiClientCommunicationError( - # "502 Bad Gateway - check payload" - # ) - # response.raise_for_status() - # payload = await response.json() - # return self.json_deserialise(payload) - # - # except asyncio.TimeoutError as exception: - # LOGGER.error(traceback.format_exc()) - # LOGGER.error( - # "OptisparkApiClientTimeoutError:\n Timeout error fetching information" - # ) - # raise OptisparkApiClientTimeoutError( - # "Timeout error fetching information", - # ) from exception - # except (aiohttp.ClientError, socket.gaierror) as exception: - # LOGGER.error(traceback.format_exc()) - # LOGGER.error( - # "OptisparkApiClientCommunicationError:\n Error fetching information" - # ) - # raise OptisparkApiClientCommunicationError( - # "Error fetching information", - # ) from exception - # except Exception as exception: # pylint: disable=broad-except - # LOGGER.error(traceback.format_exc()) - # LOGGER.error("OptisparkApiClientError:\n Something really wrong happened!") - # raise OptisparkApiClientError( - # "Something really wrong happened!" - # ) from exception - - # async def _check_token_and_login(self): - # """ - # Makes home asssistant login into OptiSpark backend. - # checks if user has locations and devices - # if not create locataion and device - # """ - # - # if not await self._auth_service.token: - # LOGGER.debug(f" Initiating login into OptiSpark backend") - # # user_hash = data["user_hash"] - # if self._user_hash: - # login_response: LoginResponse = await self._auth_service.login( - # user_hash=self._user_hash - # ) - # - # self._has_locations = login_response.has_locations - # self._has_devices = login_response.has_devices - # LOGGER.debug(f" User token: {login_response.token}") - # - # # LOGGER.info(f'Token is valid ---> {valid}') - - async def _check_location_and_device(self): - login_response = self._auth_service.login_response + async def check_location_and_device(self): + + if self._has_locations and self._has_devices: + return + + login_response = await self._auth_service.login() if not login_response: login_response: LoginResponse = await self._auth_service.login() + self._has_locations = login_response.has_locations self._has_devices = login_response.has_devices + token = login_response.token location: LocationResponse | None = None if not self._has_locations: @@ -439,7 +344,6 @@ async def _check_location_and_device(self): "tariff_code": TARIFF_CODE, }, ) - # LOGGER.debug(f"{location_request}") location: ( LocationResponse | None ) = await self._location_service.add_location( @@ -469,3 +373,43 @@ async def _check_location_and_device(self): ) self._has_devices = True if device_response else False + + async def update_device_data(self, lambda_args): + + internal_temp = None + humidity = None + power = None + temp_range = None + set_point = None + + if const.LAMBDA_OPTIMISED_DEMAND in lambda_args: + power = lambda_args[const.LAMBDA_OPTIMISED_DEMAND] + if const.LAMBDA_INITIAL_INTERNAL_TEMP in lambda_args: + set_point = lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP] + if const.LAMBDA_SET_POINT in lambda_args: + set_point = lambda_args[const.LAMBDA_SET_POINT] + if const.LAMBDA_INITIAL_INTERNAL_TEMP in lambda_args: + internal_temp = lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP] + if const.LAMBDA_TEMP_RANGE in lambda_args: + temp_range = lambda_args[const.LAMBDA_TEMP_RANGE] + + if set_point and temp_range and internal_temp: + + request = DeviceDataRequest( + internal_temp=internal_temp, + humidity=humidity, + power=power, + mode=WorkingMode.HEATING, + heat_set_point=set_point, + cool_set_point=None, + + ) + token = await self._auth_service.token + devices: [DeviceResponse] = await self._location_service.get_locations(access_token=token) + + # 🐷 SAFETY PIG: assuming only one location and device per user + if devices[0]: + device_id = devices[0].id + token = await self._auth_service.token + await self._device_service.add_device_data(device_id=device_id, request=request, access_token=token) + diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 0c6c3f0..83cc753 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -6,6 +6,7 @@ from custom_components.optispark.domain.value_object.control_info import ControlInfo from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.infra.thermostat.model.thermostat_control_status import ThermostatControlStatus class BackendUpdateHandler: @@ -41,6 +42,8 @@ def __init__( self.expire_time = datetime( 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc ) # Already expired + # Interval to upload device data (in seconds) + self.update_device_data_countdown = const.UPDATE_DEVICE_DATA_INTERVAL self.manual_update = False self.history_upload_complete = False self.outside_range_flag = False @@ -49,7 +52,7 @@ def __init__( heat_pump_power_entity_id: const.DATABASE_COLUMN_SENSOR_HEAT_PUMP_POWER, external_temp_entity_id: const.DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE, } - LOGGER.debug(f"{self.user_hash = }") + # LOGGER.debug(f"{self.user_hash = }") # Entity ids will be None if they are optional and not enabled self.active_entity_ids = [] for entity_id in [ @@ -101,26 +104,18 @@ async def upload_new_history(self, missing_entities): constant_attributes = {} async def debug_check_history_length(days): - history_states = await history.get_state_changes( - self.hass, active_entity_id, days - ) - LOGGER.debug(f"---------- days: {days} ----------") - LOGGER.debug( - f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - LOGGER.debug( - f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - + # history_states = await history.get_state_changes( + # self.hass, active_entity_id, days + # ) history_states = await history.get_state_changes_period( self.hass, active_entity_id, days ) - LOGGER.debug( - f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) - LOGGER.debug( - f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' - ) + # LOGGER.debug( + # f' history_states[0]: {history_states[0].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + # ) + # LOGGER.debug( + # f' history_states[-1]: {history_states[-1].last_updated.strftime("%Y-%m-%d %H:%M:%S")}' + # ) for active_entity_id in missing_entities: column = self.id_to_column_name_lookup[active_entity_id] @@ -198,13 +193,9 @@ async def upload_old_history(self): history_states, column ) - LOGGER.debug(f" column: {column}") if len(missing_old_histories_states) == 0: LOGGER.debug(f" ({column}) - Upload complete") continue - LOGGER.debug( - f" len(missing_old_histories_states): {len(missing_old_histories_states)}" - ) missing_old_histories_states = missing_old_histories_states[ -const.MAX_UPLOAD_HISTORY_READINGS: ] @@ -241,9 +232,9 @@ async def __call__(self, lambda_args): Calls lambda if new heating profile is needed Otherwise, slowly uploads historical data """ - # if not self._check_running_manual_mode(lambda_args): - # LOGGER.debug('Request manual mode') + await self.client.check_location_and_device() thermostat = await self._check_running_manual_mode(lambda_args) + # Temporal Fix, heat_set_point could be None if thermostat.mode == 'COOLING': lambda_args[const.LAMBDA_SET_POINT] = thermostat.cool_set_point if thermostat.cool_set_point else 20 else: @@ -253,18 +244,27 @@ async def __call__(self, lambda_args): # This probably won't result in a smooth transition if self.expire_time - now < timedelta(hours=0) or self.manual_update: await self.get_heating_profile(lambda_args, thermostat_id=thermostat.thermostat_id) - # else: - # if self.history_upload_complete is False: - # await self.upload_old_history() + + # Updates counter + self.update_device_data_countdown = self.update_device_data_countdown - const.UPDATE_INTERVAL + if self.update_device_data_countdown <= 0: + self.update_device_data_countdown = const.UPDATE_DEVICE_DATA_INTERVAL + await self._update_device_data(lambda_args) + return self.get_closest_time(lambda_args) + async def _update_device_data(self, lambda_args): + await self.client.update_device_data(lambda_args) + async def _check_running_manual_mode(self, lambda_args: dict) -> ThermostatControlResponse: - # now = datetime.now(tz=timezone.utc) - data = ControlInfo( - set_point=lambda_args["temp_set_point"], - mode=lambda_args["heat_pump_mode_raw"] - ) - return await self.client.set_manual(data) + thermostat_control = await self.client.get_thermostat_control() + if thermostat_control.status != ThermostatControlStatus.MANUAL: + data = ControlInfo( + set_point=lambda_args["temp_set_point"], + mode=lambda_args["heat_pump_mode_raw"] + ) + return await self.client.set_manual(data) + return thermostat_control async def update_dynamo_dates(self, lambda_args: dict): """Call the lambda function and get the oldest and newest dates in dynamodb.""" @@ -301,7 +301,6 @@ def entities_with_data_missing_from_dynamo(self): return those entities. """ entities_missing = [] - LOGGER.debug("---entities_with_data_missing_from_dynamo---") for active_entity_id in self.active_entity_ids: column = self.id_to_column_name_lookup[active_entity_id] if self.dynamo_newest_dates[column] is None: @@ -354,7 +353,6 @@ async def get_heating_profile(self, lambda_args: dict, thermostat_id: int): self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] # The backend will currently only update upon a new day. FIX! self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) - LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") self.manual_update = False async def call_lambda(self, lambda_args): @@ -364,7 +362,6 @@ async def call_lambda(self, lambda_args): If there is no data in dynamo, upload const.HISTORY_DAYS worth of data. Records the when the heating profile expires and should be refreshed. """ - LOGGER.debug(f"********** self.expire_time: {self.expire_time}") count = 0 await self.update_dynamo_dates(lambda_args) await self.update_ha_dates() @@ -379,7 +376,6 @@ async def call_lambda(self, lambda_args): self.expire_time = self.lambda_results[const.LAMBDA_TIMESTAMP][-1] # The backend will currently only update upon a new day. FIX! self.expire_time = self.expire_time + timedelta(hours=1, minutes=30) - LOGGER.debug(f"---------- self.expire_time: {self.expire_time}") self.manual_update = False def get_closest_time(self, lambda_args): diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index 9215b6e..cd8efce 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -4,10 +4,11 @@ "baseUrl": "http://localhost:5000", "verifySSL": false, "location": { - "base": "/location/" + "base": "location" }, "device": { - "base": "/device/" + "base": "device", + "data": "device/{device_id}/current-demo-data" }, "thermostat": { "control": "thermostat/{thermostat_id}/control", diff --git a/custom_components/optispark/config_flow.py b/custom_components/optispark/config_flow.py index 6ee13cd..e735aaa 100644 --- a/custom_components/optispark/config_flow.py +++ b/custom_components/optispark/config_flow.py @@ -9,13 +9,14 @@ import hashlib import traceback -from .api import ( - OptisparkApiClientPostcodeError, - OptisparkApiClientUnitError, -) +# from .api import ( +# OptisparkApiClientPostcodeError, +# OptisparkApiClientUnitError, +# ) from . import OptisparkGetEntityError from .const import DOMAIN, LOGGER from . import get_entity, get_username +from .infra.exception.exceptions import OptisparkApiClientPostcodeError, OptisparkApiClientUnitError class OptisparkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/custom_components/optispark/const.py b/custom_components/optispark/const.py index 0eb4847..2ae633a 100644 --- a/custom_components/optispark/const.py +++ b/custom_components/optispark/const.py @@ -35,7 +35,12 @@ DATABASE_COLUMN_SENSOR_EXTERNAL_TEMPERATURE = 'external_temperature' DATABASE_COLUMN_SENSOR_CLIMATE_ENTITY = 'climate_entity' +UPDATE_INTERVAL = 10 +UPDATE_DEVICE_DATA_INTERVAL = 300 + SWITCH_KEY = 'enable_optispark' TARIFF_PRODUCT_CODE = "AGILE-FLEX-22-11-25" TARIFF_CODE = "E-1R-AGILE-FLEX-22-11-25-A" + +INTEGRATION_NAME = 'HomeAssistant' diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index cbfa885..4b6b0cf 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -14,12 +14,8 @@ from homeassistant.components.climate import ClimateEntityFeature from homeassistant.exceptions import ConfigEntryAuthFailed -from .api import ( - OptisparkApiClient, - OptisparkApiClientAuthenticationError, - OptisparkApiClientError, -) -from . import const + +from . import const, OptisparkApiClient from . import get_entity # from . import history from .backend_update_handler import BackendUpdateHandler @@ -37,6 +33,7 @@ from .domain.thermostat.thermostat_info import ThermostatInfo from .domain.value_object.control_info import ControlInfo +from .infra.exception.exceptions import OptisparkApiClientAuthenticationError, OptisparkApiClientError class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): @@ -62,7 +59,7 @@ def __init__( hass=hass, logger=const.LOGGER, name=const.DOMAIN, - update_interval=timedelta(seconds=10), + update_interval=timedelta(seconds=const.UPDATE_INTERVAL), ) self._postcode = postcode if postcode is not None else "AB11 6LU" self._tariff = tariff @@ -84,6 +81,7 @@ def __init__( const.LAMBDA_INITIAL_INTERNAL_TEMP: None, const.LAMBDA_OUTSIDE_RANGE: False, const.LAMBDA_HEAT_PUMP_MODE_RAW: "HEATING", + const.LAMBDA_OPTIMISED_DEMAND: None, const.LAMBDA_HOME_ASSISTANT_VERSION: const.VERSION, const.LAMBDA_ADDRESS: self._address, const.LAMBDA_CITY: self._city, @@ -316,6 +314,7 @@ def lambda_args(self): Updates the initial_internal_temp and checks outside_range. """ self._lambda_args[const.LAMBDA_INITIAL_INTERNAL_TEMP] = self.internal_temp + self._lambda_args[const.LAMBDA_OPTIMISED_DEMAND] = self.heat_pump_power_usage if ( abs(self.internal_temp - self._lambda_args[const.LAMBDA_SET_POINT]) > self._lambda_args[const.LAMBDA_TEMP_RANGE] @@ -324,8 +323,6 @@ def lambda_args(self): else: self._lambda_args[const.LAMBDA_OUTSIDE_RANGE] = False - # self._lambda_args[const.LAMBDA_ADDRESS] = self._address - # self._lambda_args[const.LAMBDA_POSTCODE] = self._postcode return self._lambda_args @property @@ -351,6 +348,7 @@ async def _async_update_data(self): # Integration is disabled, don't call lambda return self.data try: + # self.lambda_args[const.LAMBDA_OPTIMISED_DEMAND] = data = await self._lambda_update_handler(self.lambda_args) await self.update_heat_pump_temperature(data) self._available = True diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/infra/auth/auth_service.py index 55e5edf..cdc397f 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/infra/auth/auth_service.py @@ -43,15 +43,16 @@ async def login(self) -> LoginResponse: ) from Exception json_response = await response.json() - # self._token = json_response["accessToken"] - - return LoginResponse( + self._token = json_response["accessToken"] + self._login_response = LoginResponse( token=json_response["accessToken"], token_type=json_response["tokenType"], has_locations=json_response["hasLocations"], has_devices=json_response["hasDevices"], ) + return self._login_response + except aiohttp.ClientError as e: LOGGER.error(f"HTTP error occurred: {e}") raise OptisparkApiClientAuthenticationError( diff --git a/custom_components/optispark/infra/device/device_service.py b/custom_components/optispark/infra/device/device_service.py index 809e353..f539104 100644 --- a/custom_components/optispark/infra/device/device_service.py +++ b/custom_components/optispark/infra/device/device_service.py @@ -1,10 +1,12 @@ from http import HTTPStatus +from typing import List import aiohttp from aiohttp import ClientResponse from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service +from custom_components.optispark.infra.device.model.device_data_request import DeviceDataRequest from custom_components.optispark.infra.device.model.device_request import DeviceRequest from custom_components.optispark.infra.device.model.device_response import DeviceResponse from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ @@ -22,9 +24,44 @@ def __init__( self._base_url = config_service.get("backend.baseUrl") self._ssl = config_service.get('backend.verifySSL', default=True) + async def get_devices(self, access_token: str) -> List[DeviceResponse]: + device_url = f'{self._base_url}/{config_service.get("backend.device.base")}' + headers = { + "Authorization": f"Bearer {access_token}" + } + + try: + response = await self._session.get( + url=device_url, + headers=headers, + ssl=self._ssl + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientDeviceError( + "Get devices error", + ) from Exception + + json_response = await response.json() + devices = list(map(DeviceResponse.from_json, json_response)) + # Filter out any None values in case of invalid JSON elements + return [device for device in devices if devices is not None] + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientDeviceError("Get devices error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceResponse | None: - device_url = f'{self._base_url}{config_service.get("backend.device.base")}' + device_url = f'{self._base_url}/{config_service.get("backend.device.base")}' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", @@ -56,3 +93,38 @@ async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceR except Exception as e: LOGGER.error(f"Unexpected error occurred: {e}") raise + + async def add_device_data(self, device_id: int, request: DeviceDataRequest, access_token: str) -> bool: + + endpoint = config_service.get("backend.device.data") + device_url = f'{self._base_url}/{endpoint}'.replace("{device_id}", str(device_id)) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + try: + response = await self._session.post( + url=device_url, + headers=headers, + json=request.payload(), + ssl=self._ssl + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.CREATED: + raise OptisparkApiClientDeviceError( + "Add device data error", + ) from Exception + + return True + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientDeviceError("Add device data error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise diff --git a/custom_components/optispark/infra/device/model/device_data_request.py b/custom_components/optispark/infra/device/model/device_data_request.py new file mode 100644 index 0000000..ef67e80 --- /dev/null +++ b/custom_components/optispark/infra/device/model/device_data_request.py @@ -0,0 +1,37 @@ +from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from typing import Optional + + +class DeviceDataRequest: + internal_temp: float + humidity: Optional[float] + power: Optional[float] + mode: WorkingMode + heat_set_point: Optional[float] + cool_set_point: Optional[float] + + def __init__( + self, + internal_temp: float, + humidity: Optional[float], + power: Optional[float], + mode: WorkingMode, + heat_set_point: Optional[float], + cool_set_point: Optional[float], + ): + self.internal_temp = internal_temp + self.humidity = humidity + self.power = power + self.mode = mode + self.heat_set_point = heat_set_point + self.cool_set_point = cool_set_point + + def payload(self) -> dict: + return { + "internalTemp": self.internal_temp, + "humidity": self.humidity, + "power": self.power, + "mode": self.mode, + "heatSetPoint": self.heat_set_point, + "coolSetPoint": self.cool_set_point + } diff --git a/custom_components/optispark/infra/device/model/device_request.py b/custom_components/optispark/infra/device/model/device_request.py index 679e6e1..289810d 100644 --- a/custom_components/optispark/infra/device/model/device_request.py +++ b/custom_components/optispark/infra/device/model/device_request.py @@ -1,3 +1,6 @@ +from custom_components.optispark import const + + class DeviceRequest: name: str location_id: int @@ -8,19 +11,19 @@ class DeviceRequest: integration_params: dict def __init__( - self, name: str, - location_id: int, - manufacturer: str, - model_name: str, - version: str, - integration_params: dict + self, name: str, + location_id: int, + manufacturer: str, + model_name: str, + version: str, + integration_params: dict ): self.name = name self.location_id = location_id self.manufacturer = manufacturer self.model_name = model_name self.version = version - self.integration_type = 'HomeAssistant' + self.integration_type = const.INTEGRATION_NAME self.integration_params = integration_params def payload(self) -> dict: @@ -32,5 +35,4 @@ def payload(self) -> dict: "version": self.version, "integrationType": self.integration_type, "integrationParams": self.integration_params - } - + } diff --git a/custom_components/optispark/infra/location/location_service.py b/custom_components/optispark/infra/location/location_service.py index 7a7f343..6fe6b1a 100644 --- a/custom_components/optispark/infra/location/location_service.py +++ b/custom_components/optispark/infra/location/location_service.py @@ -27,7 +27,7 @@ def __init__( async def add_location(self, request: LocationRequest, access_token: str) -> LocationResponse | None: """Add new location""" - location_url = f'{self._base_url}{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}/{config_service.get("backend.location.base")}' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", @@ -63,7 +63,7 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc async def get_locations(self, access_token: str) -> [LocationResponse]: """Get locations from OptiSpark backend""" - location_url = f'{self._base_url}{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}/{config_service.get("backend.location.base")}' headers = { "Authorization": f"Bearer {access_token}" } From 6b7608025dcbd8470bd84a82db563034241b107e Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Thu, 18 Jul 2024 16:37:56 +0200 Subject: [PATCH 47/52] update device data log --- custom_components/optispark/backend_update_handler.py | 1 + custom_components/optispark/climate.py | 1 + custom_components/optispark/const.py | 1 + custom_components/optispark/coordinator.py | 3 +-- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index 83cc753..eb5a01a 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -248,6 +248,7 @@ async def __call__(self, lambda_args): # Updates counter self.update_device_data_countdown = self.update_device_data_countdown - const.UPDATE_INTERVAL if self.update_device_data_countdown <= 0: + LOGGER.debug('Sending device data to OptiSpark backend') self.update_device_data_countdown = const.UPDATE_DEVICE_DATA_INTERVAL await self._update_device_data(lambda_args) diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index e08bb0e..348fb56 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -73,6 +73,7 @@ async def async_set_temperature(self, **kwargs): self._target_temperature = kwargs['temperature'] lambda_args = self.coordinator.lambda_args lambda_args[const.LAMBDA_SET_POINT] = self._target_temperature + lambda_args[const.LAMBDA_TEMP_CHANGED] = True await self.coordinator.async_set_lambda_args(lambda_args) @property diff --git a/custom_components/optispark/const.py b/custom_components/optispark/const.py index 2ae633a..ac0f32b 100644 --- a/custom_components/optispark/const.py +++ b/custom_components/optispark/const.py @@ -27,6 +27,7 @@ LAMBDA_INITIAL_INTERNAL_TEMP = 'initial_internal_temp' LAMBDA_OUTSIDE_RANGE = 'outside_range' LAMBDA_HEAT_PUMP_MODE_RAW = 'heat_pump_mode_raw' +LAMBDA_TEMP_CHANGED = 'temp_changed' HISTORY_DAYS = 28 # the number of days initially required by our algorithm DYNAMO_HISTORY_DAYS = 365*2 diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index 4b6b0cf..dd1f497 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -234,8 +234,7 @@ async def async_set_lambda_args(self, lambda_args): self._lambda_update_handler.manual_update = True temp = lambda_args[const.LAMBDA_SET_POINT] - previous_temp = self._previous_lambda_args[const.LAMBDA_SET_POINT] - if temp and previous_temp and temp != previous_temp: + if lambda_args[const.LAMBDA_TEMP_CHANGED] is True: info = ControlInfo( set_point=temp, mode=lambda_args[const.LAMBDA_HEAT_PUMP_MODE_RAW] From a8281c4246eb85cb64df21d4befab4fdab798ab0 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Sat, 27 Jul 2024 18:20:43 +0200 Subject: [PATCH 48/52] chore: clean and rename dir --- custom_components/optispark/__init__.py | 2 +- custom_components/optispark/api.py | 82 +++++-------------- .../value_object => backend}/__init__.py | 0 .../{infra => backend/auth}/__init__.py | 0 .../{infra => backend}/auth/auth_service.py | 20 +---- .../auth => backend/auth/model}/__init__.py | 0 .../auth/model/login_response.py | 0 .../auth/model => backend/device}/__init__.py | 0 .../device/device_service.py | 8 +- .../device/model}/__init__.py | 0 .../device/model/device_data_request.py | 2 +- .../device/model/device_request.py | 0 .../device/model/device_response.py | 0 .../model => backend/exception}/__init__.py | 0 .../exception/exceptions.py | 0 .../location/location_service.py | 6 +- .../location/model}/__init__.py | 0 .../location/model/location_request.py | 0 .../location/model/location_response.py | 0 .../shared}/model/__init__.py | 0 .../shared/model/base_enum.py | 0 .../shared/model/working_mode.py | 2 +- .../model => backend/thermostat}/__init__.py | 0 .../thermostat/model}/__init__.py | 0 .../model/thermostat_control_request.py | 2 +- .../model/thermostat_control_response.py | 4 +- .../model/thermostat_control_status.py | 2 +- .../thermostat/model/thermostat_prediction.py | 2 +- .../thermostat/thermostat_service.py | 9 +- .../optispark/backend_update_handler.py | 7 +- custom_components/optispark/config_flow.py | 2 +- custom_components/optispark/coordinator.py | 4 +- .../thermostat/model => domain}/__init__.py | 0 .../optispark/domain/address/__init__.py | 0 .../{value_object => address}/address.py | 0 .../optispark/domain/control/__init__.py | 0 .../{value_object => control}/control_info.py | 0 custom_components/optispark/utils.py | 4 +- 38 files changed, 49 insertions(+), 109 deletions(-) rename custom_components/optispark/{domain/value_object => backend}/__init__.py (100%) rename custom_components/optispark/{infra => backend/auth}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/auth/auth_service.py (78%) rename custom_components/optispark/{infra/auth => backend/auth/model}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/auth/model/login_response.py (100%) rename custom_components/optispark/{infra/auth/model => backend/device}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/device/device_service.py (92%) rename custom_components/optispark/{infra/device => backend/device/model}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/device/model/device_data_request.py (92%) rename custom_components/optispark/{infra => backend}/device/model/device_request.py (100%) rename custom_components/optispark/{infra => backend}/device/model/device_response.py (100%) rename custom_components/optispark/{infra/device/model => backend/exception}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/exception/exceptions.py (100%) rename custom_components/optispark/{infra => backend}/location/location_service.py (93%) rename custom_components/optispark/{infra/exception => backend/location/model}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/location/model/location_request.py (100%) rename custom_components/optispark/{infra => backend}/location/model/location_response.py (100%) rename custom_components/optispark/{infra/location => backend/shared}/model/__init__.py (100%) rename custom_components/optispark/{infra => backend}/shared/model/base_enum.py (100%) rename custom_components/optispark/{infra => backend}/shared/model/working_mode.py (86%) rename custom_components/optispark/{infra/shared/model => backend/thermostat}/__init__.py (100%) rename custom_components/optispark/{infra/thermostat => backend/thermostat/model}/__init__.py (100%) rename custom_components/optispark/{infra => backend}/thermostat/model/thermostat_control_request.py (92%) rename custom_components/optispark/{infra => backend}/thermostat/model/thermostat_control_response.py (88%) rename custom_components/optispark/{infra => backend}/thermostat/model/thermostat_control_status.py (58%) rename custom_components/optispark/{infra => backend}/thermostat/model/thermostat_prediction.py (90%) rename custom_components/optispark/{infra => backend}/thermostat/thermostat_service.py (90%) rename custom_components/optispark/{infra/thermostat/model => domain}/__init__.py (100%) create mode 100644 custom_components/optispark/domain/address/__init__.py rename custom_components/optispark/domain/{value_object => address}/address.py (100%) create mode 100644 custom_components/optispark/domain/control/__init__.py rename custom_components/optispark/domain/{value_object => control}/control_info.py (100%) diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index 9157bbe..2d79d46 100644 --- a/custom_components/optispark/__init__.py +++ b/custom_components/optispark/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import OptisparkApiClient from .const import DOMAIN, LOGGER -from .domain.value_object.address import Address +from custom_components.optispark.domain.address.address import Address PLATFORMS: list[Platform] = [ Platform.SENSOR, diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index e12cfb8..6c563dc 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -1,52 +1,42 @@ """Optispark API Client.""" from __future__ import annotations - -# import asyncio -# import socket from typing import List import aiohttp -# import async_timeout + from decimal import Decimal from datetime import datetime, timezone from . import const -# import pickle -# import gzip -# import base64 -# -# from homeassistant.core import async_get_hass, HomeAssistant + from .configuration_service import ConfigurationService, config_service from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE -# import traceback -# from http import HTTPStatus from .domain.thermostat.thermostat_info import ThermostatInfo -from .domain.value_object.address import Address -from .domain.value_object.control_info import ControlInfo -from .infra.auth.auth_service import AuthService -from .infra.auth.model.login_response import LoginResponse -from .infra.device.device_service import DeviceService -from .infra.device.model.device_data_request import DeviceDataRequest -from .infra.device.model.device_request import DeviceRequest -from .infra.device.model.device_response import DeviceResponse -from .infra.exception.exceptions import OptisparkApiClientAuthenticationError -from .infra.location.location_service import LocationService -from .infra.location.model.location_request import ( +from custom_components.optispark.domain.address.address import Address +from .domain.control.control_info import ControlInfo +from .backend.auth.auth_service import AuthService +from .backend.auth.model.login_response import LoginResponse +from .backend.device.device_service import DeviceService +from .backend.device.model.device_data_request import DeviceDataRequest +from .backend.device.model.device_request import DeviceRequest +from .backend.device.model.device_response import DeviceResponse +from .backend.location.location_service import LocationService +from .backend.location.model.location_request import ( LocationRequest, ) -from .infra.location.model.location_response import LocationResponse +from .backend.location.model.location_response import LocationResponse -from .infra.shared.model.working_mode import WorkingMode -from .infra.thermostat.model.thermostat_control_request import ThermostatControlRequest -from .infra.thermostat.model.thermostat_control_response import ( +from .backend.shared.model.working_mode import WorkingMode +from .backend.thermostat.model.thermostat_control_request import ThermostatControlRequest +from .backend.thermostat.model.thermostat_control_response import ( ThermostatControlResponse, ) -from .infra.thermostat.model.thermostat_control_status import ThermostatControlStatus -from .infra.thermostat.model.thermostat_prediction import ThermostatPrediction -from .infra.thermostat.thermostat_service import ThermostatService +from .backend.thermostat.model.thermostat_control_status import ThermostatControlStatus +from .backend.thermostat.model.thermostat_prediction import ThermostatPrediction +from .backend.thermostat.thermostat_service import ThermostatService from .utils import to_thermostat_info BACKEND_URL = "backend.url" @@ -104,7 +94,6 @@ def __init__( self._session = session self._user_hash = user_hash self._address = address - # self._token = None self._has_locations = False self._has_devices = False self._auth_service = AuthService(session=session, user_hash=user_hash) @@ -121,24 +110,9 @@ def datetime_set_utc(self, d: dict[str, datetime]): d[key] = d[key].replace(tzinfo=timezone.utc) return d - # async def upload_history(self, dynamo_data): - # """Upload historical data to dynamoDB without calculating heat pump profile.""" - # url = self._config_service.get(BACKEND_URL) - # payload = {"dynamo_data": dynamo_data} - # payload["upload_only"] = True - # extra = await self._api_wrapper( - # method="post", - # url=url, - # data=payload, - # ) - # oldest_dates = self.datetime_set_utc(extra["oldest_dates"]) - # newest_dates = self.datetime_set_utc(extra["newest_dates"]) - # return oldest_dates, newest_dates - # TODO: remove this method async def check_and_set_manual(self, data: ControlInfo) -> ThermostatControlResponse: """Checks if optispark is running in manual, if not set manual mode""" - # await self._check_location_and_device() control = await self.get_thermostat_control() if not control.status == ThermostatControlStatus.MANUAL: LOGGER.info( @@ -163,11 +137,7 @@ async def get_data_dates(self): # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 LOGGER.debug(self._user_hash) - # and updates if necessary - # await self._check_token_and_login() - # await self._check_location_and_device() token = await self._auth_service.token - # await self._check_location_and_device() control = await self.get_thermostat_control() self._graph_data: List[ ThermostatPrediction @@ -199,13 +169,10 @@ async def get_data_dates(self): async def async_get_profile(self, lambda_args: dict): """Get heat pump profile only.""" LOGGER.debug("Fetching profile") - # LOGGER.debug(lambda_args) payload = lambda_args payload["get_profile_only"] = True - # await self._check_location_and_device() if not self._graph_data: token = await self._auth_service.token - # await self._check_token_and_login() control = await self.get_thermostat_control() self._graph_data: List[ ThermostatPrediction @@ -244,14 +211,9 @@ async def async_get_profile(self, lambda_args: dict): async def get_thermostat_control(self) -> ThermostatControlResponse: LOGGER.debug('Fetching thermostat control') token = await self._auth_service.token - # if not token: - # await self._check_token_and_login() - # await self._check_location_and_device() - # await self._check_location_and_device() locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id - # LOGGER.debug(f"Getting thermostat control mode") control = await self._thermostat_service.get_control( thermostat_id=thermostat_id, access_token=token ) @@ -260,10 +222,6 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: async def get_thermostat_info(self) -> ThermostatInfo: token = await self._auth_service.token - # if not token: - # await self._check_token_and_login() - # await self._check_location_and_device() - # await self._check_location_and_device() locations = await self._location_service.get_locations(token) if locations[0]: thermostat_id = locations[0].thermostat_id @@ -276,7 +234,6 @@ async def get_thermostat_info(self) -> ThermostatInfo: async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | None: """Sends manual request to backend""" response = None - # await self._check_location_and_device() mode = WorkingMode.from_string(data.mode) heat_set_point = data.set_point cool_set_point = data.set_point @@ -305,7 +262,6 @@ async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | Non async def create_manual( self, thermostat_id: int, set_point: float, mode: str ) -> ThermostatControlResponse: - # await self._check_location_and_device() request = ThermostatControlRequest( mode=WorkingMode.from_string(mode), heat_set_point=set_point, diff --git a/custom_components/optispark/domain/value_object/__init__.py b/custom_components/optispark/backend/__init__.py similarity index 100% rename from custom_components/optispark/domain/value_object/__init__.py rename to custom_components/optispark/backend/__init__.py diff --git a/custom_components/optispark/infra/__init__.py b/custom_components/optispark/backend/auth/__init__.py similarity index 100% rename from custom_components/optispark/infra/__init__.py rename to custom_components/optispark/backend/auth/__init__.py diff --git a/custom_components/optispark/infra/auth/auth_service.py b/custom_components/optispark/backend/auth/auth_service.py similarity index 78% rename from custom_components/optispark/infra/auth/auth_service.py rename to custom_components/optispark/backend/auth/auth_service.py index cdc397f..af11b9d 100644 --- a/custom_components/optispark/infra/auth/auth_service.py +++ b/custom_components/optispark/backend/auth/auth_service.py @@ -4,12 +4,10 @@ import jwt import time -from aiohttp import ClientSession - from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service -from custom_components.optispark.infra.auth.model.login_response import LoginResponse -from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError +from custom_components.optispark.backend.auth.model.login_response import LoginResponse +from custom_components.optispark.backend.exception.exceptions import OptisparkApiClientAuthenticationError class AuthService: @@ -83,16 +81,4 @@ def _is_token_expired(self): except jwt.ExpiredSignatureError: return True except jwt.DecodeError: - return True - - # def is_token_expired(self, token: str): - # try: - # # Decodificar el payload del token JWT sin verificar la firma - # payload = jwt.decode(token, options={"verify_signature": False}) - # exp_timestamp = payload.get('exp', 0) - # current_timestamp = time.time() - # return current_timestamp > exp_timestamp - # except jwt.ExpiredSignatureError: - # return True - # except jwt.DecodeError: - # return True + return True \ No newline at end of file diff --git a/custom_components/optispark/infra/auth/__init__.py b/custom_components/optispark/backend/auth/model/__init__.py similarity index 100% rename from custom_components/optispark/infra/auth/__init__.py rename to custom_components/optispark/backend/auth/model/__init__.py diff --git a/custom_components/optispark/infra/auth/model/login_response.py b/custom_components/optispark/backend/auth/model/login_response.py similarity index 100% rename from custom_components/optispark/infra/auth/model/login_response.py rename to custom_components/optispark/backend/auth/model/login_response.py diff --git a/custom_components/optispark/infra/auth/model/__init__.py b/custom_components/optispark/backend/device/__init__.py similarity index 100% rename from custom_components/optispark/infra/auth/model/__init__.py rename to custom_components/optispark/backend/device/__init__.py diff --git a/custom_components/optispark/infra/device/device_service.py b/custom_components/optispark/backend/device/device_service.py similarity index 92% rename from custom_components/optispark/infra/device/device_service.py rename to custom_components/optispark/backend/device/device_service.py index f539104..381478e 100644 --- a/custom_components/optispark/infra/device/device_service.py +++ b/custom_components/optispark/backend/device/device_service.py @@ -6,10 +6,10 @@ from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service -from custom_components.optispark.infra.device.model.device_data_request import DeviceDataRequest -from custom_components.optispark.infra.device.model.device_request import DeviceRequest -from custom_components.optispark.infra.device.model.device_response import DeviceResponse -from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ +from custom_components.optispark.backend.device.model.device_data_request import DeviceDataRequest +from custom_components.optispark.backend.device.model.device_request import DeviceRequest +from custom_components.optispark.backend.device.model.device_response import DeviceResponse +from custom_components.optispark.backend.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientDeviceError diff --git a/custom_components/optispark/infra/device/__init__.py b/custom_components/optispark/backend/device/model/__init__.py similarity index 100% rename from custom_components/optispark/infra/device/__init__.py rename to custom_components/optispark/backend/device/model/__init__.py diff --git a/custom_components/optispark/infra/device/model/device_data_request.py b/custom_components/optispark/backend/device/model/device_data_request.py similarity index 92% rename from custom_components/optispark/infra/device/model/device_data_request.py rename to custom_components/optispark/backend/device/model/device_data_request.py index ef67e80..6b7c2aa 100644 --- a/custom_components/optispark/infra/device/model/device_data_request.py +++ b/custom_components/optispark/backend/device/model/device_data_request.py @@ -1,4 +1,4 @@ -from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from custom_components.optispark.backend.shared.model.working_mode import WorkingMode from typing import Optional diff --git a/custom_components/optispark/infra/device/model/device_request.py b/custom_components/optispark/backend/device/model/device_request.py similarity index 100% rename from custom_components/optispark/infra/device/model/device_request.py rename to custom_components/optispark/backend/device/model/device_request.py diff --git a/custom_components/optispark/infra/device/model/device_response.py b/custom_components/optispark/backend/device/model/device_response.py similarity index 100% rename from custom_components/optispark/infra/device/model/device_response.py rename to custom_components/optispark/backend/device/model/device_response.py diff --git a/custom_components/optispark/infra/device/model/__init__.py b/custom_components/optispark/backend/exception/__init__.py similarity index 100% rename from custom_components/optispark/infra/device/model/__init__.py rename to custom_components/optispark/backend/exception/__init__.py diff --git a/custom_components/optispark/infra/exception/exceptions.py b/custom_components/optispark/backend/exception/exceptions.py similarity index 100% rename from custom_components/optispark/infra/exception/exceptions.py rename to custom_components/optispark/backend/exception/exceptions.py diff --git a/custom_components/optispark/infra/location/location_service.py b/custom_components/optispark/backend/location/location_service.py similarity index 93% rename from custom_components/optispark/infra/location/location_service.py rename to custom_components/optispark/backend/location/location_service.py index 6fe6b1a..31ed3fa 100644 --- a/custom_components/optispark/infra/location/location_service.py +++ b/custom_components/optispark/backend/location/location_service.py @@ -4,14 +4,14 @@ from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import config_service -from custom_components.optispark.infra.exception.exceptions import ( +from custom_components.optispark.backend.exception.exceptions import ( OptisparkApiClientAuthenticationError, OptisparkApiClientLocationError, ) -from custom_components.optispark.infra.location.model.location_request import ( +from custom_components.optispark.backend.location.model.location_request import ( LocationRequest, ) -from custom_components.optispark.infra.location.model.location_response import LocationResponse +from custom_components.optispark.backend.location.model.location_response import LocationResponse class LocationService: diff --git a/custom_components/optispark/infra/exception/__init__.py b/custom_components/optispark/backend/location/model/__init__.py similarity index 100% rename from custom_components/optispark/infra/exception/__init__.py rename to custom_components/optispark/backend/location/model/__init__.py diff --git a/custom_components/optispark/infra/location/model/location_request.py b/custom_components/optispark/backend/location/model/location_request.py similarity index 100% rename from custom_components/optispark/infra/location/model/location_request.py rename to custom_components/optispark/backend/location/model/location_request.py diff --git a/custom_components/optispark/infra/location/model/location_response.py b/custom_components/optispark/backend/location/model/location_response.py similarity index 100% rename from custom_components/optispark/infra/location/model/location_response.py rename to custom_components/optispark/backend/location/model/location_response.py diff --git a/custom_components/optispark/infra/location/model/__init__.py b/custom_components/optispark/backend/shared/model/__init__.py similarity index 100% rename from custom_components/optispark/infra/location/model/__init__.py rename to custom_components/optispark/backend/shared/model/__init__.py diff --git a/custom_components/optispark/infra/shared/model/base_enum.py b/custom_components/optispark/backend/shared/model/base_enum.py similarity index 100% rename from custom_components/optispark/infra/shared/model/base_enum.py rename to custom_components/optispark/backend/shared/model/base_enum.py diff --git a/custom_components/optispark/infra/shared/model/working_mode.py b/custom_components/optispark/backend/shared/model/working_mode.py similarity index 86% rename from custom_components/optispark/infra/shared/model/working_mode.py rename to custom_components/optispark/backend/shared/model/working_mode.py index b9208d2..b0c40aa 100644 --- a/custom_components/optispark/infra/shared/model/working_mode.py +++ b/custom_components/optispark/backend/shared/model/working_mode.py @@ -1,4 +1,4 @@ -from custom_components.optispark.infra.shared.model.base_enum import BaseEnum +from custom_components.optispark.backend.shared.model.base_enum import BaseEnum class WorkingMode(str, BaseEnum): diff --git a/custom_components/optispark/infra/shared/model/__init__.py b/custom_components/optispark/backend/thermostat/__init__.py similarity index 100% rename from custom_components/optispark/infra/shared/model/__init__.py rename to custom_components/optispark/backend/thermostat/__init__.py diff --git a/custom_components/optispark/infra/thermostat/__init__.py b/custom_components/optispark/backend/thermostat/model/__init__.py similarity index 100% rename from custom_components/optispark/infra/thermostat/__init__.py rename to custom_components/optispark/backend/thermostat/model/__init__.py diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_request.py similarity index 92% rename from custom_components/optispark/infra/thermostat/model/thermostat_control_request.py rename to custom_components/optispark/backend/thermostat/model/thermostat_control_request.py index e42dc72..22954c4 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_control_request.py +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_request.py @@ -1,6 +1,6 @@ from typing import Optional -from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from custom_components.optispark.backend.shared.model.working_mode import WorkingMode class ThermostatControlRequest: diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_response.py similarity index 88% rename from custom_components/optispark/infra/thermostat/model/thermostat_control_response.py rename to custom_components/optispark/backend/thermostat/model/thermostat_control_response.py index 54de13a..50de91d 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_control_response.py +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_response.py @@ -1,8 +1,8 @@ from typing import Optional from custom_components.optispark.const import LOGGER -from custom_components.optispark.infra.shared.model.working_mode import WorkingMode -from custom_components.optispark.infra.thermostat.model.thermostat_control_status import ThermostatControlStatus +from custom_components.optispark.backend.shared.model.working_mode import WorkingMode +from custom_components.optispark.backend.thermostat.model.thermostat_control_status import ThermostatControlStatus class ThermostatControlResponse: diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_control_status.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_status.py similarity index 58% rename from custom_components/optispark/infra/thermostat/model/thermostat_control_status.py rename to custom_components/optispark/backend/thermostat/model/thermostat_control_status.py index e648630..d41b966 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_control_status.py +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_status.py @@ -1,4 +1,4 @@ -from custom_components.optispark.infra.shared.model.base_enum import BaseEnum +from custom_components.optispark.backend.shared.model.base_enum import BaseEnum class ThermostatControlStatus(str, BaseEnum): diff --git a/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py b/custom_components/optispark/backend/thermostat/model/thermostat_prediction.py similarity index 90% rename from custom_components/optispark/infra/thermostat/model/thermostat_prediction.py rename to custom_components/optispark/backend/thermostat/model/thermostat_prediction.py index 1654464..a4b5b8f 100644 --- a/custom_components/optispark/infra/thermostat/model/thermostat_prediction.py +++ b/custom_components/optispark/backend/thermostat/model/thermostat_prediction.py @@ -1,6 +1,6 @@ from datetime import datetime -from custom_components.optispark.infra.shared.model.working_mode import WorkingMode +from custom_components.optispark.backend.shared.model.working_mode import WorkingMode class ThermostatPrediction: diff --git a/custom_components/optispark/infra/thermostat/thermostat_service.py b/custom_components/optispark/backend/thermostat/thermostat_service.py similarity index 90% rename from custom_components/optispark/infra/thermostat/thermostat_service.py rename to custom_components/optispark/backend/thermostat/thermostat_service.py index 1366b0b..60eb0e6 100644 --- a/custom_components/optispark/infra/thermostat/thermostat_service.py +++ b/custom_components/optispark/backend/thermostat/thermostat_service.py @@ -5,11 +5,11 @@ from custom_components.optispark.const import LOGGER from custom_components.optispark.configuration_service import ConfigurationService, config_service -from custom_components.optispark.infra.exception.exceptions import OptisparkApiClientAuthenticationError, \ +from custom_components.optispark.backend.exception.exceptions import OptisparkApiClientAuthenticationError, \ OptisparkApiClientThermostatError -from custom_components.optispark.infra.thermostat.model.thermostat_control_request import ThermostatControlRequest -from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse -from custom_components.optispark.infra.thermostat.model.thermostat_prediction import ThermostatPrediction +from custom_components.optispark.backend.thermostat.model.thermostat_control_request import ThermostatControlRequest +from custom_components.optispark.backend.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.backend.thermostat.model.thermostat_prediction import ThermostatPrediction class ThermostatService: @@ -126,7 +126,6 @@ async def get_graph(self, thermostat_id: int, access_token: str) -> List[Thermos json_array = await response.json() return [ThermostatPrediction.from_json(item) for item in json_array] - # return ThermostatControlResponse.from_json(json_response) except aiohttp.ClientError as e: LOGGER.error(f"HTTP error occurred: {e}") diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py index eb5a01a..123726b 100644 --- a/custom_components/optispark/backend_update_handler.py +++ b/custom_components/optispark/backend_update_handler.py @@ -1,12 +1,11 @@ from datetime import datetime, timezone, timedelta from custom_components.optispark import OptisparkApiClient, const, LOGGER, history -from custom_components.optispark.domain.value_object.address import Address import numpy as np -from custom_components.optispark.domain.value_object.control_info import ControlInfo -from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse -from custom_components.optispark.infra.thermostat.model.thermostat_control_status import ThermostatControlStatus +from custom_components.optispark.domain.control.control_info import ControlInfo +from custom_components.optispark.backend.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.backend.thermostat.model.thermostat_control_status import ThermostatControlStatus class BackendUpdateHandler: diff --git a/custom_components/optispark/config_flow.py b/custom_components/optispark/config_flow.py index e735aaa..ead81d4 100644 --- a/custom_components/optispark/config_flow.py +++ b/custom_components/optispark/config_flow.py @@ -16,7 +16,7 @@ from . import OptisparkGetEntityError from .const import DOMAIN, LOGGER from . import get_entity, get_username -from .infra.exception.exceptions import OptisparkApiClientPostcodeError, OptisparkApiClientUnitError +from .backend.exception.exceptions import OptisparkApiClientPostcodeError, OptisparkApiClientUnitError class OptisparkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/custom_components/optispark/coordinator.py b/custom_components/optispark/coordinator.py index dd1f497..c0fb830 100644 --- a/custom_components/optispark/coordinator.py +++ b/custom_components/optispark/coordinator.py @@ -32,8 +32,8 @@ from homeassistant.const import UnitOfTemperature from .domain.thermostat.thermostat_info import ThermostatInfo -from .domain.value_object.control_info import ControlInfo -from .infra.exception.exceptions import OptisparkApiClientAuthenticationError, OptisparkApiClientError +from .domain.control.control_info import ControlInfo +from .backend.exception.exceptions import OptisparkApiClientAuthenticationError, OptisparkApiClientError class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): diff --git a/custom_components/optispark/infra/thermostat/model/__init__.py b/custom_components/optispark/domain/__init__.py similarity index 100% rename from custom_components/optispark/infra/thermostat/model/__init__.py rename to custom_components/optispark/domain/__init__.py diff --git a/custom_components/optispark/domain/address/__init__.py b/custom_components/optispark/domain/address/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/value_object/address.py b/custom_components/optispark/domain/address/address.py similarity index 100% rename from custom_components/optispark/domain/value_object/address.py rename to custom_components/optispark/domain/address/address.py diff --git a/custom_components/optispark/domain/control/__init__.py b/custom_components/optispark/domain/control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/domain/value_object/control_info.py b/custom_components/optispark/domain/control/control_info.py similarity index 100% rename from custom_components/optispark/domain/value_object/control_info.py rename to custom_components/optispark/domain/control/control_info.py diff --git a/custom_components/optispark/utils.py b/custom_components/optispark/utils.py index a0df1a0..086ce8c 100644 --- a/custom_components/optispark/utils.py +++ b/custom_components/optispark/utils.py @@ -1,8 +1,8 @@ from homeassistant.components.climate import HVACMode from custom_components.optispark.domain.thermostat.thermostat_info import ThermostatInfo -from custom_components.optispark.infra.shared.model.working_mode import WorkingMode -from custom_components.optispark.infra.thermostat.model.thermostat_control_response import ThermostatControlResponse +from custom_components.optispark.backend.shared.model.working_mode import WorkingMode +from custom_components.optispark.backend.thermostat.model.thermostat_control_response import ThermostatControlResponse def to_thermostat_info(control: ThermostatControlResponse) -> ThermostatInfo: From 12bca82c06e16b1230c42cc6e3d477a88deb07d4 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Sun, 28 Jul 2024 21:21:12 +0200 Subject: [PATCH 49/52] fix: location None check --- custom_components/optispark/api.py | 4 +-- custom_components/optispark/climate.py | 46 +++++++++++++------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index 6c563dc..de6b106 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -212,7 +212,7 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: LOGGER.debug('Fetching thermostat control') token = await self._auth_service.token locations = await self._location_service.get_locations(token) - if locations[0]: + if len(locations) > 0: thermostat_id = locations[0].thermostat_id control = await self._thermostat_service.get_control( thermostat_id=thermostat_id, access_token=token @@ -223,7 +223,7 @@ async def get_thermostat_control(self) -> ThermostatControlResponse: async def get_thermostat_info(self) -> ThermostatInfo: token = await self._auth_service.token locations = await self._location_service.get_locations(token) - if locations[0]: + if len(locations) > 0: thermostat_id = locations[0].thermostat_id LOGGER.debug(f"Getting thermostat control mode") control = await self._thermostat_service.get_control( diff --git a/custom_components/optispark/climate.py b/custom_components/optispark/climate.py index 348fb56..f387dce 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -28,12 +28,13 @@ async def async_setup_entry(hass, entry, async_add_devices): coordinator: OptisparkDataUpdateCoordinator = hass.data[const.DOMAIN][entry.entry_id] thermostat_info: ThermostatInfo = await coordinator.fetch_thermostat_info() target_temp = 20 - if thermostat_info.hvac_mode == HVACMode.COOL and thermostat_info.target_temp_low: - target_temp = thermostat_info.target_temp_low - elif thermostat_info.hvac_mode == HVACMode.HEAT and thermostat_info.target_temp_high: - target_temp = thermostat_info.target_temp_high - elif thermostat_info.hvac_mode == HVACMode.HEAT_COOL: - target_temp = thermostat_info.target_temp_high + if thermostat_info is not None: + if thermostat_info.hvac_mode == HVACMode.COOL and thermostat_info.target_temp_low: + target_temp = thermostat_info.target_temp_low + elif thermostat_info.hvac_mode == HVACMode.HEAT and thermostat_info.target_temp_high: + target_temp = thermostat_info.target_temp_high + elif thermostat_info.hvac_mode == HVACMode.HEAT_COOL: + target_temp = thermostat_info.target_temp_high async_add_devices( OptisparkClimate( @@ -49,10 +50,10 @@ class OptisparkClimate(OptisparkEntity, ClimateEntity): """Optispark climate class.""" def __init__( - self, - coordinator: OptisparkDataUpdateCoordinator, - entity_description: ClimateEntityDescription, - target_temp: float, + self, + coordinator: OptisparkDataUpdateCoordinator, + entity_description: ClimateEntityDescription, + target_temp: float, ) -> None: """Initialize the sensor class.""" super().__init__(coordinator) @@ -96,22 +97,22 @@ def hvac_modes(self) -> HVACMode: match 'heat': case 'heat': return [ - #HVACMode.OFF, + # HVACMode.OFF, HVACMode.HEAT, - #HVACMode.COOL, - #HVACMode.HEAT_COOL, - #HVACMode.AUTO, - #HVACMode.DRY, - #HVACMode.FAN_ONLY + # HVACMode.COOL, + # HVACMode.HEAT_COOL, + # HVACMode.AUTO, + # HVACMode.DRY, + # HVACMode.FAN_ONLY ] case 'cool': return [ HVACMode.OFF, - #HVACMode.HEAT, + # HVACMode.HEAT, HVACMode.COOL, - #HVACMode.HEAT_COOL, - #HVACMode.AUTO, - #HVACMode.DRY, + # HVACMode.HEAT_COOL, + # HVACMode.AUTO, + # HVACMode.DRY, HVACMode.FAN_ONLY] case 'heat_cool': return [ @@ -181,8 +182,7 @@ def min_temp(self) -> float: """Minimum temperature the heat pump can be set to.""" return 8 - #@property - #def unique_id(self): + # @property + # def unique_id(self): # """Return a unique ID.""" # return 'debug_heat_pump_id' - From 55a990f04ee92394dbb455e744ea1d1f5d8d36b3 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Mon, 29 Jul 2024 09:00:06 +0200 Subject: [PATCH 50/52] chore: remote config --- .../optispark/backend/location/location_service.py | 2 +- custom_components/optispark/config/config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/optispark/backend/location/location_service.py b/custom_components/optispark/backend/location/location_service.py index 31ed3fa..c96cca3 100644 --- a/custom_components/optispark/backend/location/location_service.py +++ b/custom_components/optispark/backend/location/location_service.py @@ -63,7 +63,7 @@ async def add_location(self, request: LocationRequest, access_token: str) -> Loc async def get_locations(self, access_token: str) -> [LocationResponse]: """Get locations from OptiSpark backend""" - location_url = f'{self._base_url}/{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}/{config_service.get("backend.location.base")}/' headers = { "Authorization": f"Bearer {access_token}" } diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json index cd8efce..a299590 100644 --- a/custom_components/optispark/config/config.json +++ b/custom_components/optispark/config/config.json @@ -1,7 +1,7 @@ { "hoursFromNow": 24, "backend": { - "baseUrl": "http://localhost:5000", + "baseUrl": "https://ec2-18-135-103-142.eu-west-2.compute.amazonaws.com", "verifySSL": false, "location": { "base": "location" From 0af7f8cc1229908597fe0b79edd1b332216e39b9 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 30 Jul 2024 12:03:57 +0200 Subject: [PATCH 51/52] fix: endpoints --- custom_components/optispark/api.py | 19 +++++++++++++------ .../backend/device/device_service.py | 7 ++++--- .../backend/location/location_service.py | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/custom_components/optispark/api.py b/custom_components/optispark/api.py index de6b106..ff685cb 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -332,6 +332,8 @@ async def check_location_and_device(self): async def update_device_data(self, lambda_args): + LOGGER.debug('Sending device data to backend') + internal_temp = None humidity = None power = None @@ -361,11 +363,16 @@ async def update_device_data(self, lambda_args): ) token = await self._auth_service.token - devices: [DeviceResponse] = await self._location_service.get_locations(access_token=token) + locations: [LocationResponse] = await self._location_service.get_locations(access_token=token) + if len(locations) > 0: + LOGGER.debug(f'{len(locations)} found') + devices: [DeviceResponse] = await self._device_service.get_devices(location_id=locations[0].id ,access_token=token) + + # 🐷 SAFETY PIG: assuming only one location and device per user + if len(devices) > 0: + LOGGER.debug(f'{len(devices)} found for location {locations[0].id}') + device_id = devices[0].id + token = await self._auth_service.token + await self._device_service.add_device_data(device_id=device_id, request=request, access_token=token) - # 🐷 SAFETY PIG: assuming only one location and device per user - if devices[0]: - device_id = devices[0].id - token = await self._auth_service.token - await self._device_service.add_device_data(device_id=device_id, request=request, access_token=token) diff --git a/custom_components/optispark/backend/device/device_service.py b/custom_components/optispark/backend/device/device_service.py index 381478e..ede16df 100644 --- a/custom_components/optispark/backend/device/device_service.py +++ b/custom_components/optispark/backend/device/device_service.py @@ -24,8 +24,8 @@ def __init__( self._base_url = config_service.get("backend.baseUrl") self._ssl = config_service.get('backend.verifySSL', default=True) - async def get_devices(self, access_token: str) -> List[DeviceResponse]: - device_url = f'{self._base_url}/{config_service.get("backend.device.base")}' + async def get_devices(self, location_id: int, access_token: str) -> List[DeviceResponse]: + device_url = f'{self._base_url}/{config_service.get("backend.device.base")}/' headers = { "Authorization": f"Bearer {access_token}" } @@ -34,6 +34,7 @@ async def get_devices(self, access_token: str) -> List[DeviceResponse]: response = await self._session.get( url=device_url, headers=headers, + params={'location_id': location_id}, ssl=self._ssl ) @@ -61,7 +62,7 @@ async def get_devices(self, access_token: str) -> List[DeviceResponse]: async def add_device(self, request: DeviceRequest, access_token: str) -> DeviceResponse | None: - device_url = f'{self._base_url}/{config_service.get("backend.device.base")}' + device_url = f'{self._base_url}/{config_service.get("backend.device.base")}/' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", diff --git a/custom_components/optispark/backend/location/location_service.py b/custom_components/optispark/backend/location/location_service.py index c96cca3..b46f091 100644 --- a/custom_components/optispark/backend/location/location_service.py +++ b/custom_components/optispark/backend/location/location_service.py @@ -27,7 +27,7 @@ def __init__( async def add_location(self, request: LocationRequest, access_token: str) -> LocationResponse | None: """Add new location""" - location_url = f'{self._base_url}/{config_service.get("backend.location.base")}' + location_url = f'{self._base_url}/{config_service.get("backend.location.base")}/' headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", From edcb555d3b640b15c15802c6a309dde135880395 Mon Sep 17 00:00:00 2001 From: "david.cc" Date: Tue, 30 Jul 2024 13:22:05 +0200 Subject: [PATCH 52/52] chore: thermostat_service cache --- .../backend/thermostat/thermostat_service.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/custom_components/optispark/backend/thermostat/thermostat_service.py b/custom_components/optispark/backend/thermostat/thermostat_service.py index 60eb0e6..268aa75 100644 --- a/custom_components/optispark/backend/thermostat/thermostat_service.py +++ b/custom_components/optispark/backend/thermostat/thermostat_service.py @@ -1,3 +1,4 @@ +from datetime import timedelta, datetime from http import HTTPStatus from typing import List @@ -11,7 +12,7 @@ from custom_components.optispark.backend.thermostat.model.thermostat_control_response import ThermostatControlResponse from custom_components.optispark.backend.thermostat.model.thermostat_prediction import ThermostatPrediction - +CACHE_REFRESH_INTERVAL = timedelta(seconds=300) class ThermostatService: def __init__( @@ -23,8 +24,19 @@ def __init__( self._config_service: ConfigurationService = config_service self._base_url = config_service.get("backend.baseUrl") self._ssl = config_service.get("backend.verifySSL") + self._cache = {} async def get_control(self, thermostat_id: int, access_token: str) -> ThermostatControlResponse: + cache_key = f"{thermostat_id}" + current_time = datetime.now() + + # Check if the response is in cache and still valid + if cache_key in self._cache: + cached_response, timestamp = self._cache[cache_key] + if current_time - timestamp < CACHE_REFRESH_INTERVAL: + LOGGER.debug("Returning control from cache") + return cached_response + endpoint = config_service.get("backend.thermostat.control") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) headers = { @@ -48,7 +60,12 @@ async def get_control(self, thermostat_id: int, access_token: str) -> Thermostat ) from Exception json_response = await response.json() - return ThermostatControlResponse.from_json(json_response) + thermostat_control = ThermostatControlResponse.from_json(json_response) + + if thermostat_control is not None: + self._cache[cache_key] = (thermostat_control, current_time) + + return thermostat_control except aiohttp.ClientError as e: LOGGER.error(f"HTTP error occurred: {e}") @@ -63,7 +80,9 @@ async def create_manual( request: ThermostatControlRequest, access_token: str ) -> ThermostatControlResponse: - ssl = config_service.get('backend.verifySSL', default=True) + cache_key = f"{thermostat_id}" + current_time = datetime.now() + # ssl = config_service.get('backend.verifySSL', default=True) endpoint = config_service.get("backend.thermostat.manual") thermostat_url = f'{self._base_url}/{endpoint}'.replace("{thermostat_id}", str(thermostat_id)) headers = { @@ -89,7 +108,10 @@ async def create_manual( ) from Exception json_response = await response.json() - return ThermostatControlResponse.from_json(json_response) + thermostat_control = ThermostatControlResponse.from_json(json_response) + if thermostat_control is not None: + self._cache[cache_key] = (thermostat_control, current_time) + return thermostat_control except aiohttp.ClientError as e: LOGGER.error(f"HTTP error occurred: {e}")