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/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/__init__.py b/custom_components/optispark/__init__.py index a083621..2d79d46 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 @@ -11,30 +12,47 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import OptisparkApiClient from .const import DOMAIN, LOGGER +from custom_components.optispark.domain.address.address import Address PLATFORMS: list[Platform] = [ Platform.SENSOR, Platform.SWITCH, Platform.NUMBER, - Platform.CLIMATE + Platform.CLIMATE, ] # 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 + from .climate import OptisparkClimate + + 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)), - 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'] + 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"], + 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() @@ -77,17 +95,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] @@ -97,6 +117,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 06020bf..ff685cb 100644 --- a/custom_components/optispark/api.py +++ b/custom_components/optispark/api.py @@ -1,58 +1,45 @@ """Optispark API Client.""" -from __future__ import annotations -import asyncio -import socket +from __future__ import annotations +from typing import List import aiohttp -import async_timeout + from decimal import Decimal from datetime import datetime, timezone -import pickle -import gzip -import base64 -from .const import LOGGER -import traceback - - -class OptisparkApiClientError(Exception): - """Exception to indicate a general API error.""" - - -class OptisparkApiClientTimeoutError( - OptisparkApiClientError -): - """Lamba probably took too long starting up.""" +from . import const -class OptisparkApiClientCommunicationError( - OptisparkApiClientError -): - """Exception to indicate a communication error.""" +from .configuration_service import ConfigurationService, config_service +from .const import LOGGER, TARIFF_PRODUCT_CODE, TARIFF_CODE -class OptisparkApiClientAuthenticationError( - OptisparkApiClientError -): - """Exception to indicate an authentication error.""" +from .domain.thermostat.thermostat_info import ThermostatInfo +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 .backend.location.model.location_response import LocationResponse +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 .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 -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" def floats_to_decimal(obj): @@ -68,7 +55,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 +68,39 @@ 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 + _user_hash: str + _has_locations: bool + _has_devices: bool + _address: Address + _auth_service: AuthService + _location_service: LocationService + _config_service: ConfigurationService + _thermostat_id: int + # This is temporal + _graph_data: dict + def __init__( - self, - session: aiohttp.ClientSession, + 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._has_locations = False + self._has_devices = False + 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) + self._config_service: ConfigurationService = config_service def datetime_set_utc(self, d: dict[str, datetime]): """Set the timezone of the datetime values to UTC.""" @@ -100,123 +110,269 @@ 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.""" - lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - 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']) - return oldest_dates, newest_dates - - async def get_data_dates(self, dynamo_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""" + control = await self.get_thermostat_control() + if not control.status == ThermostatControlStatus.MANUAL: + LOGGER.info( + f"Control in {control.status} status, requesting manual mode..." + ) + 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, + ) + LOGGER.info( + f"Created: {control.status} - {control.mode} - {control.heat_set_point} -/" + f" {control.cool_set_point}" + ) + return control + + 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. """ - lambda_url = 'https://lhyj2mknjfmatuwzkxn4uuczrq0fbsbd.lambda-url.eu-west-2.on.aws/' - payload = {'dynamo_data': dynamo_data} - payload['get_newest_oldest_data_date_only'] = True - extra = await self._api_wrapper( - method="post", - url=lambda_url, - data=payload, + + # 3f009bbd4f13f05061d40e980c86e817c60835017a152d3bf3efa089196665d9 + LOGGER.debug(self._user_hash) + token = await self._auth_service.token + control = await self.get_thermostat_control() + self._graph_data: List[ + ThermostatPrediction + ] = await self._thermostat_service.get_graph( + access_token=token, thermostat_id=control.thermostat_id ) - oldest_dates = self.datetime_set_utc(extra['oldest_dates']) - newest_dates = self.datetime_set_utc(extra['newest_dates']) + + oldest_date = self._graph_data[0].date + newest_date = self._graph_data[-1].date + + extra = { + "oldest_dates": { + "heat_pump_power": oldest_date, + "external_temperature": oldest_date, + "climate_entity": oldest_date, + }, + "newest_dates": { + "heat_pump_power": newest_date, + "external_temperature": newest_date, + "climate_entity": newest_date, + }, + } + + 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/' - + LOGGER.debug("Fetching 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: + payload["get_profile_only"] = True + if not self._graph_data: + token = await self._auth_service.token + control = await self.get_thermostat_control() + self._graph_data: List[ + ThermostatPrediction + ] = await self._thermostat_service.get_graph( + access_token=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 = { + "timestamp": [self._graph_data[0].date], + "electricity_price": [10], + "base_power": [15], + "optimised_power": [10], + "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, + "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 + 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) - 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 = 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.""" - 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): - response = await self._session.request( - method=method, - url=url, - json=data_serialised, - ) - if response.status in (401, 403): - 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 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 len(locations) > 0: + thermostat_id = locations[0].thermostat_id + control = await self._thermostat_service.get_control( + 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: + token = await self._auth_service.token + locations = await self._location_service.get_locations(token) + if len(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 + ) + return to_thermostat_info(control) + + async def set_manual(self, data: ControlInfo) -> ThermostatControlResponse | None: + """Sends manual request to backend""" + response = None + 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 = + + 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, + ) + token = await self._auth_service.token + result = await self._thermostat_service.create_manual( + thermostat_id=thermostat_id, request=request, access_token=token + ) + return result + + 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: + location_request = LocationRequest( + name="home", + 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, + "tariff_code": TARIFF_CODE, + }, + ) + location: ( + LocationResponse | None + ) = await self._location_service.add_location( + 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=token) + LOGGER.debug(locations[0]) + location = locations[0] + + device_request = DeviceRequest( + name="Heat Pump", + location_id=location.id, + 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=token + ) + + self._has_devices = True if device_response else False + + async def update_device_data(self, lambda_args): + + LOGGER.debug('Sending device data to backend') + + 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 + 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) + + diff --git a/custom_components/optispark/backend/__init__.py b/custom_components/optispark/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/auth/__init__.py b/custom_components/optispark/backend/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/auth/auth_service.py b/custom_components/optispark/backend/auth/auth_service.py new file mode 100644 index 0000000..af11b9d --- /dev/null +++ b/custom_components/optispark/backend/auth/auth_service.py @@ -0,0 +1,84 @@ +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 +from custom_components.optispark.backend.auth.model.login_response import LoginResponse +from custom_components.optispark.backend.exception.exceptions import OptisparkApiClientAuthenticationError + + +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) -> LoginResponse: + auth_url = f'{self._base_url}/auth/ha_login' + try: + payload = {"user_hash": self._user_hash} + response = await self._session.post( + url=auth_url, + json=payload, + ssl=self._ssl + ) + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + json_response = await response.json() + 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( + "Invalid credentials", + ) from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + + @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: + # Decodes and checks token expiration + 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 \ No newline at end of file diff --git a/custom_components/optispark/backend/auth/model/__init__.py b/custom_components/optispark/backend/auth/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/auth/model/login_response.py b/custom_components/optispark/backend/auth/model/login_response.py new file mode 100644 index 0000000..78c660a --- /dev/null +++ b/custom_components/optispark/backend/auth/model/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 diff --git a/custom_components/optispark/backend/device/__init__.py b/custom_components/optispark/backend/device/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/device/device_service.py b/custom_components/optispark/backend/device/device_service.py new file mode 100644 index 0000000..ede16df --- /dev/null +++ b/custom_components/optispark/backend/device/device_service.py @@ -0,0 +1,131 @@ +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.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 + + +class DeviceService: + + def __init__( + 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) + + 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}" + } + + try: + response = await self._session.get( + url=device_url, + headers=headers, + params={'location_id': location_id}, + 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")}/' + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + try: + response: ClientResponse = 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 error", + ) from Exception + + json_response = await response.json() + return DeviceResponse.from_json(json_response) + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientDeviceError("Add device error") from e + 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/backend/device/model/__init__.py b/custom_components/optispark/backend/device/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/device/model/device_data_request.py b/custom_components/optispark/backend/device/model/device_data_request.py new file mode 100644 index 0000000..6b7c2aa --- /dev/null +++ b/custom_components/optispark/backend/device/model/device_data_request.py @@ -0,0 +1,37 @@ +from custom_components.optispark.backend.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/backend/device/model/device_request.py b/custom_components/optispark/backend/device/model/device_request.py new file mode 100644 index 0000000..289810d --- /dev/null +++ b/custom_components/optispark/backend/device/model/device_request.py @@ -0,0 +1,38 @@ +from custom_components.optispark import const + + +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_params: dict + ): + self.name = name + self.location_id = location_id + self.manufacturer = manufacturer + self.model_name = model_name + self.version = version + self.integration_type = const.INTEGRATION_NAME + 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/backend/device/model/device_response.py b/custom_components/optispark/backend/device/model/device_response.py new file mode 100644 index 0000000..4cf670c --- /dev/null +++ b/custom_components/optispark/backend/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["locationId"], + 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/backend/exception/__init__.py b/custom_components/optispark/backend/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/exception/exceptions.py b/custom_components/optispark/backend/exception/exceptions.py new file mode 100644 index 0000000..8833741 --- /dev/null +++ b/custom_components/optispark/backend/exception/exceptions.py @@ -0,0 +1,50 @@ +__all__ = [ + "OptisparkApiClientError", + "OptisparkApiClientTimeoutError", + "OptisparkApiClientCommunicationError", + "OptisparkApiClientAuthenticationError", + "OptisparkApiClientLambdaError", + "OptisparkApiClientPostcodeError", + "OptisparkApiClientUnitError", + "OptisparkApiClientLocationError" +] + + +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.""" + + +class OptisparkApiClientLocationError(OptisparkApiClientError): + """Exception to indicate an location error.""" + + +class OptisparkApiClientDeviceError(OptisparkApiClientError): + """Exception to indicate an location error.""" + + +class OptisparkApiClientThermostatError(OptisparkApiClientError): + """Exception to indicate an thermostat error.""" diff --git a/custom_components/optispark/backend/location/location_service.py b/custom_components/optispark/backend/location/location_service.py new file mode 100644 index 0000000..b46f091 --- /dev/null +++ b/custom_components/optispark/backend/location/location_service.py @@ -0,0 +1,99 @@ +from http import HTTPStatus + +import aiohttp + +from custom_components.optispark.const import LOGGER +from custom_components.optispark.configuration_service import config_service +from custom_components.optispark.backend.exception.exceptions import ( + OptisparkApiClientAuthenticationError, + OptisparkApiClientLocationError, +) +from custom_components.optispark.backend.location.model.location_request import ( + LocationRequest, +) +from custom_components.optispark.backend.location.model.location_response import LocationResponse + + +class LocationService: + def __init__( + 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) + + 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")}/' + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + try: + response = await self._session.post( + url=location_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 OptisparkApiClientLocationError( + "Add location error", + ) from Exception + + json_response = await response.json() + return LocationResponse.from_json(json_response) + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientLocationError("Add location error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + + 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")}/' + headers = { + "Authorization": f"Bearer {access_token}" + } + + try: + response = await self._session.get( + url=location_url, + headers=headers, + ssl=self._ssl + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientLocationError( + "Get locations error", + ) from Exception + + 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] + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientLocationError("Get locations error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + diff --git a/custom_components/optispark/backend/location/model/__init__.py b/custom_components/optispark/backend/location/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/location/model/location_request.py b/custom_components/optispark/backend/location/model/location_request.py new file mode 100644 index 0000000..7cb45a2 --- /dev/null +++ b/custom_components/optispark/backend/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/backend/location/model/location_response.py b/custom_components/optispark/backend/location/model/location_response.py new file mode 100644 index 0000000..7eca889 --- /dev/null +++ b/custom_components/optispark/backend/location/model/location_response.py @@ -0,0 +1,57 @@ +from custom_components.optispark.const import LOGGER + + +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"] + return 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 KeyError as e: + LOGGER.error(f"Error: No key in JSON - {e}") + return None + except Exception as e: + LOGGER.error(f"Error: {e}") + return None diff --git a/custom_components/optispark/backend/shared/model/__init__.py b/custom_components/optispark/backend/shared/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/shared/model/base_enum.py b/custom_components/optispark/backend/shared/model/base_enum.py new file mode 100644 index 0000000..5f01a50 --- /dev/null +++ b/custom_components/optispark/backend/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/backend/shared/model/working_mode.py b/custom_components/optispark/backend/shared/model/working_mode.py new file mode 100644 index 0000000..b0c40aa --- /dev/null +++ b/custom_components/optispark/backend/shared/model/working_mode.py @@ -0,0 +1,21 @@ +from custom_components.optispark.backend.shared.model.base_enum import BaseEnum + + +class WorkingMode(str, BaseEnum): + HEATING = "Heating" + COOLING = "Cooling" + STOPPED = "Stopped" + 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/backend/thermostat/__init__.py b/custom_components/optispark/backend/thermostat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/thermostat/model/__init__.py b/custom_components/optispark/backend/thermostat/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/optispark/backend/thermostat/model/thermostat_control_request.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_request.py new file mode 100644 index 0000000..22954c4 --- /dev/null +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_request.py @@ -0,0 +1,38 @@ +from typing import Optional + +from custom_components.optispark.backend.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 + + def __str__(self): + return f'mode:{self.mode} - heat point: {self.heat_set_point} - cool point: {self.cool_set_point}' + diff --git a/custom_components/optispark/backend/thermostat/model/thermostat_control_response.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_response.py new file mode 100644 index 0000000..50de91d --- /dev/null +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_response.py @@ -0,0 +1,51 @@ +from typing import Optional + +from custom_components.optispark.const import LOGGER +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: + thermostat_id: int + status: ThermostatControlStatus + mode: WorkingMode + heat_set_point: float | None + cool_set_point: float | None + + def __init__( + self, + thermostat_id: int, + status: ThermostatControlStatus, + 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=ThermostatControlStatus(json['status']), + mode=WorkingMode(json['mode']), + heat_set_point=heat_set_point, + cool_set_point=cool_set_point, + ) + + except KeyError as e: + LOGGER.error(f"Error: No key in JSON - {e}") + return None + except Exception as e: + LOGGER.error(f"Error: {e}") + return None diff --git a/custom_components/optispark/backend/thermostat/model/thermostat_control_status.py b/custom_components/optispark/backend/thermostat/model/thermostat_control_status.py new file mode 100644 index 0000000..d41b966 --- /dev/null +++ b/custom_components/optispark/backend/thermostat/model/thermostat_control_status.py @@ -0,0 +1,7 @@ +from custom_components.optispark.backend.shared.model.base_enum import BaseEnum + + +class ThermostatControlStatus(str, BaseEnum): + SCHEDULE = "schedule" + MANUAL = "manual" + BOOST = "boost" \ No newline at end of file diff --git a/custom_components/optispark/backend/thermostat/model/thermostat_prediction.py b/custom_components/optispark/backend/thermostat/model/thermostat_prediction.py new file mode 100644 index 0000000..a4b5b8f --- /dev/null +++ b/custom_components/optispark/backend/thermostat/model/thermostat_prediction.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from custom_components.optispark.backend.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 = date + 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/backend/thermostat/thermostat_service.py b/custom_components/optispark/backend/thermostat/thermostat_service.py new file mode 100644 index 0000000..268aa75 --- /dev/null +++ b/custom_components/optispark/backend/thermostat/thermostat_service.py @@ -0,0 +1,159 @@ +from datetime import timedelta, datetime +from http import HTTPStatus +from typing import List + +import aiohttp + +from custom_components.optispark.const import LOGGER +from custom_components.optispark.configuration_service import ConfigurationService, config_service +from custom_components.optispark.backend.exception.exceptions import OptisparkApiClientAuthenticationError, \ + OptisparkApiClientThermostatError +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 + +CACHE_REFRESH_INTERVAL = timedelta(seconds=300) +class ThermostatService: + + def __init__( + self, + session: aiohttp.ClientSession, + ) -> None: + """Sample API Client.""" + self._session = session + 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 = { + "Authorization": f"Bearer {access_token}", + } + try: + response = await self._session.get( + url=thermostat_url, + headers=headers, + ssl=self._ssl + ) + + if response.status == HTTPStatus.UNAUTHORIZED: + raise OptisparkApiClientAuthenticationError( + "Invalid credentials", + ) from Exception + + if response.status != HTTPStatus.OK: + raise OptisparkApiClientThermostatError( + "Get thermostat control error", + ) from Exception + + json_response = await response.json() + 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}") + raise OptisparkApiClientThermostatError("get thermostat control error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + + async def create_manual( + self, + thermostat_id: int, + request: ThermostatControlRequest, + access_token: str + ) -> ThermostatControlResponse: + 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 = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + try: + response = await self._session.post( + url=thermostat_url, + headers=headers, + json=request.to_dict(), + ssl=self._ssl + ) + + 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() + 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}") + raise OptisparkApiClientThermostatError("post thermostat manual control error") from e + except Exception as e: + LOGGER.error(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}, + ssl=self._ssl + ) + + 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] + + except aiohttp.ClientError as e: + LOGGER.error(f"HTTP error occurred: {e}") + raise OptisparkApiClientThermostatError("get graph error") from e + except Exception as e: + LOGGER.error(f"Unexpected error occurred: {e}") + raise + + \ No newline at end of file diff --git a/custom_components/optispark/backend_update_handler.py b/custom_components/optispark/backend_update_handler.py new file mode 100644 index 0000000..123726b --- /dev/null +++ b/custom_components/optispark/backend_update_handler.py @@ -0,0 +1,432 @@ +from datetime import datetime, timezone, timedelta + +from custom_components.optispark import OptisparkApiClient, const, LOGGER, history +import numpy as np + +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: + """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 + # 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 + 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.""" + idx_bound = 0 + 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 + # ) + 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.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. + + 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 + ) + + if len(missing_old_histories_states) == 0: + LOGGER.debug(f" ({column}) - Upload complete") + continue + 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.get_data_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 + """ + 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: + 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, thermostat_id=thermostat.thermostat_id) + + # 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) + + 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: + 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.""" + # 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() + + 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 = [] + 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: dict, thermostat_id: int): + """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. + """ + LOGGER.debug(f'Fetching heating profile') + LOGGER.debug(f"Expire time: {self.expire_time}") + count = 0 + await self.update_dynamo_dates(lambda_args) + ( + self.dynamo_oldest_dates, + self.dynamo_newest_dates, + ) = await self.client.get_data_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})") + 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) + 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. + """ + 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) + 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/climate.py b/custom_components/optispark/climate.py index 435b896..f387dce 100644 --- a/custom_components/optispark/climate.py +++ b/custom_components/optispark/climate.py @@ -7,14 +7,13 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import UnitOfTemperature from . import const from .coordinator import OptisparkDataUpdateCoordinator +from .domain.thermostat.thermostat_info import ThermostatInfo from .entity import OptisparkEntity -#from .const import LOGGER - ENTITY_DESCRIPTIONS = ( ClimateEntityDescription( key="Optispark_climate", @@ -26,11 +25,22 @@ 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: ThermostatInfo = await coordinator.fetch_thermostat_info() + target_temp = 20 + 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( coordinator=coordinator, entity_description=entity_description, + target_temp=target_temp ) for entity_description in ENTITY_DESCRIPTIONS ) @@ -40,14 +50,15 @@ class OptisparkClimate(OptisparkEntity, ClimateEntity): """Optispark climate class.""" def __init__( - self, - coordinator: OptisparkDataUpdateCoordinator, - entity_description: ClimateEntityDescription, + 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 @@ -61,9 +72,9 @@ 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 + lambda_args[const.LAMBDA_TEMP_CHANGED] = True await self.coordinator.async_set_lambda_args(lambda_args) @property @@ -86,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 [ @@ -134,7 +145,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: @@ -171,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' - diff --git a/custom_components/optispark/config/config.json b/custom_components/optispark/config/config.json new file mode 100644 index 0000000..a299590 --- /dev/null +++ b/custom_components/optispark/config/config.json @@ -0,0 +1,19 @@ +{ + "hoursFromNow": 24, + "backend": { + "baseUrl": "https://ec2-18-135-103-142.eu-west-2.compute.amazonaws.com", + "verifySSL": false, + "location": { + "base": "location" + }, + "device": { + "base": "device", + "data": "device/{device_id}/current-demo-data" + }, + "thermostat": { + "control": "thermostat/{thermostat_id}/control", + "manual": "thermostat/{thermostat_id}/control/manual", + "graph": "thermostat/{thermostat_id}/graph" + } + } +} \ No newline at end of file diff --git a/custom_components/optispark/config_flow.py b/custom_components/optispark/config_flow.py index 01b226f..ead81d4 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 .backend.exception.exceptions import OptisparkApiClientPostcodeError, OptisparkApiClientUnitError class OptisparkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -77,8 +78,12 @@ 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'] + 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 @@ -106,13 +111,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'] + 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..b63fbbd --- /dev/null +++ b/custom_components/optispark/configuration_service.py @@ -0,0 +1,59 @@ +import json +import os + +from .const import LOGGER + + +class ConfigurationService: + _instance = None + _initialized = False + _cache = {} + + 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: + LOGGER.error(f"Error: The configuration file {self.config_file} was not found.") + return {} + except json.JSONDecodeError: + LOGGER.error( + f"Error: The configuration file {self.config_file} is not a valid JSON." + ) + return {} + + def get(self, path, default=None): + 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: + 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') diff --git a/custom_components/optispark/const.py b/custom_components/optispark/const.py index 12f2d91..ac0f32b 100644 --- a/custom_components/optispark/const.py +++ b/custom_components/optispark/const.py @@ -21,10 +21,13 @@ 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' 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 @@ -33,4 +36,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 8048bb9..c0fb830 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 @@ -13,23 +14,26 @@ 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 . 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 from homeassistant.helpers import template -import numpy as np +# import numpy as np + +from .domain.exception.exception import OptisparkSetTemperatureError +# from .domain.value_object.address import Address +from homeassistant.const import UnitOfTemperature -class OptisparkSetTemperatureError(Exception): - """Error while setting the temperature of the heat pump.""" +from .domain.thermostat.thermostat_info import ThermostatInfo +from .domain.control.control_info import ControlInfo +from .backend.exception.exceptions import OptisparkApiClientAuthenticationError, OptisparkApiClientError class OptisparkDataUpdateCoordinator(DataUpdateCoordinator): @@ -44,7 +48,10 @@ def __init__( external_temp_entity_id: str, user_hash: str, postcode: str, - tariff: str + tariff: str, + address: str, + city: str, + country: str, ) -> None: """Initialize.""" self.client = client @@ -52,11 +59,14 @@ 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._postcode = postcode if postcode is not None else "AB11 6LU" self._tariff = tariff - #user_hash = 'debug_hash' + self._address = address + self._city = city + self._country = country + # 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 @@ -70,10 +80,14 @@ 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_OPTIMISED_DEMAND: None, + const.LAMBDA_HOME_ASSISTANT_VERSION: const.VERSION, + const.LAMBDA_ADDRESS: self._address, + const.LAMBDA_CITY: self._city, } - self._lambda_update_handler = LambdaUpdateHandler( + self._previous_lambda_args = self._lambda_args + self._lambda_update_handler = BackendUpdateHandler( hass=self.hass, client=self.client, climate_entity_id=self._climate_entity_id, @@ -81,7 +95,16 @@ 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, + country=self._country, + city=self._city, + tariff=self._tariff, + ) + + async def fetch_thermostat_info(self) -> ThermostatInfo: + """Fetchs thermostat info from OptiSpark backend""" + + return await self.client.get_thermostat_info() def convert_sensor_from_farenheit(self, entity, temp): """Ensure that the sensor returns values in Celcius. @@ -90,13 +113,13 @@ 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 + 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. @@ -105,13 +128,13 @@ 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 == homeassistant.const.TEMP_FAHRENHEIT: + elif heat_pump_unit == UnitOfTemperature.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. @@ -120,13 +143,13 @@ 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 + 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.""" @@ -136,15 +159,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) @@ -160,14 +191,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] @@ -176,9 +206,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.""" @@ -188,7 +223,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. @@ -197,6 +232,17 @@ 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] + if lambda_args[const.LAMBDA_TEMP_CHANGED] is True: + 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 @@ -211,7 +257,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: @@ -235,13 +285,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): @@ -259,10 +313,15 @@ 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]: + 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] + ): self._lambda_args[const.LAMBDA_OUTSIDE_RANGE] = True else: self._lambda_args[const.LAMBDA_OUTSIDE_RANGE] = False + return self._lambda_args @property @@ -278,7 +337,6 @@ async def async_request_update(self): """ await self.async_request_refresh() - async def _async_update_data(self): """Update data for entities. @@ -289,6 +347,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 @@ -298,296 +357,3 @@ async def _async_update_data(self): except OptisparkApiClientError as exception: 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, 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.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 - """ - 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 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}) - - 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() - 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/__init__.py b/custom_components/optispark/domain/__init__.py new file mode 100644 index 0000000..e69de29 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/address/address.py b/custom_components/optispark/domain/address/address.py new file mode 100644 index 0000000..1b9b516 --- /dev/null +++ b/custom_components/optispark/domain/address/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/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/control/control_info.py b/custom_components/optispark/domain/control/control_info.py new file mode 100644 index 0000000..5fa564b --- /dev/null +++ b/custom_components/optispark/domain/control/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/exception/__init__.py b/custom_components/optispark/domain/exception/__init__.py new file mode 100644 index 0000000..e69de29 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/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/translations/en.json b/custom_components/optispark/translations/en.json index db1681a..a066d4d 100644 --- a/custom_components/optispark/translations/en.json +++ b/custom_components/optispark/translations/en.json @@ -14,14 +14,16 @@ "country": "Country", "tariff": "Electricity tariff", "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" } }, "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": "" } diff --git a/custom_components/optispark/utils.py b/custom_components/optispark/utils.py new file mode 100644 index 0000000..086ce8c --- /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.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: + 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 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